docs: plan npub sign-in, starting from what a read-only identity is for
The nsec plan's out-of-scope note said a read-only mode is a product, not a branch. This plan takes that at its word: what a public key can see here is thin -- a profile card, its follows as search results, no feed, no rooms, since every room is MLS or a gift wrap to the key that was not pasted -- so the first section decides what such an identity is for before anything is designed. It is a preview: the app as your own profile, before you paste a secret into it. That one word settles the Messages tab (an empty state that offers the upgrade), pasting the nsec of a read-only key (an upgrade in place under the same id, not "already on this device"), sign out (real for this kind only), and not-found (try again or a different key, never set one up). Eight phases: a nullable key on Identity, with the note that the compiler will be silent about it; a plaintext list beside the two key files, app-side through the public getDatadir and AtomicFileWrite so nothing needs a JitPack tag; the third startup branch, a read-only KeyPair built in one place, and only the two pumps that read; the sign-in screen, where hex stays a secret because an x coordinate is almost always also a valid scalar; a LocalCanSign capability and an inventory of every write entrance one tap from the three tabs; two exits; two round trips; rollout. Two traps found on the way are recorded where they bite: quartz's KeyPair(privKey = null) generates a fresh key rather than meaning "no key", and decryptGiftWrapSeal forwards exactly that; and signInToProfile plants a second kind 0 on a second call, which nothing reached until the upgrade path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@78807fe956
This commit is contained in:
@@ -17,6 +17,7 @@ silent, or a decision that looked arbitrary and was not.
|
||||
| [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list |
|
||||
| [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover |
|
||||
| [nsec-sign-in.md](./nsec-sign-in.md) | signing in with an existing nostr key — why an nsec can never have a wallet behind it, the ten call sites that make it small, and the sign-in machine that was already built and unreachable |
|
||||
| [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles |
|
||||
| [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it |
|
||||
| [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme |
|
||||
| [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand |
|
||||
@@ -44,3 +45,7 @@ The nsec sign-in note is a phased plan that has been built; it inherits the
|
||||
key-storage decision from the jvm-target note and drives the navigation state
|
||||
machine `NavigationViewModel.processLocalAccount` implements, so read it with the
|
||||
code open, and read its table of where the build chose differently first.
|
||||
The npub sign-in note is a phased plan that has not been built, and reads as that
|
||||
plan's out-of-scope note answered: it takes the `Identity` type and the sign-in
|
||||
machine as given and asks what an identity with no secret is for, before it asks
|
||||
how to build one.
|
||||
|
||||
870
docs/npub-sign-in.md
Normal file
870
docs/npub-sign-in.md
Normal file
@@ -0,0 +1,870 @@
|
||||
# Signing in with an npub
|
||||
|
||||
How a user looks at this app as a profile they hold no secret for, what such an
|
||||
identity can and cannot do here, and why the answer to "what is it for" decides
|
||||
every screen it touches.
|
||||
|
||||
Read this after [nsec-sign-in.md](./nsec-sign-in.md). It inherits the `Identity`
|
||||
type, the two key stores, the sign-in screen and the not-found exit from there, and
|
||||
most of what follows is that plan's out-of-scope note taken at its word — "a
|
||||
read-only mode is a product, not a branch" — and asked what the product would be.
|
||||
|
||||
**Not built.** Phases 1–8 below, one commit each, in the order given. Phase 1 is a
|
||||
type change and ships alone. Phases 2 through 5 ship together, for the reason in
|
||||
[Phase 8](#phase-8--rollout): a build that can make a read-only identity but still
|
||||
offers it "New chat" is the failure this plan exists to avoid. Nothing here touches
|
||||
the library.
|
||||
|
||||
## The constraint
|
||||
|
||||
A nostr public key can be addressed and can verify. It cannot do anything else,
|
||||
and in this app "anything else" is most of the app:
|
||||
|
||||
| what needs the secret | where |
|
||||
|---|---|
|
||||
| signing every queued event — the kind 0, the follow list, key packages, group posts, proposals | `NotaryViewModel.observeUnsignedNostrEvents` |
|
||||
| opening a NIP-17 gift wrap addressed to the user | [GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt) — NIP-44 with the recipient's key |
|
||||
| joining a Marmot group — a key package has to be signed and published before any welcome can be addressed to it, and the welcome itself arrives as a gift wrap | `MarmotRepository.publishMarmotKeyPackageBundle`, `MarmotInboundManager` |
|
||||
| the ChillDKG host key, `sha256(tag ‖ nostrPrivateKey)` | [ChillDkgRitualManager.kt:117](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt) |
|
||||
|
||||
What a public key *gets* is the events it has published — the kind 0, the kind 3,
|
||||
the relay lists — and the profiles of the people it follows, because indexing a
|
||||
kind 3 plants a placeholder for each and queues its sync
|
||||
([NostrDao.kt:1128](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)).
|
||||
In a client with a public feed, that is the client: read-only in Damus or Amethyst
|
||||
means "browse as this person".
|
||||
|
||||
**This app has no public feed.** The home tab is a list of chat rooms, and every
|
||||
room is MLS or a gift wrap, both encrypted to the key the user did not paste. The
|
||||
curated lists, proposals, ceremonies and translations are reached only through a
|
||||
room (`ChatRoomDetailScreen`). Search is over profiles, and trending notes is a
|
||||
string that says "coming soon". So a read-only identity here sees its own profile
|
||||
card, the people it follows — as search results, by name, once the sync has
|
||||
fetched them; nothing renders the list itself — any profile's detail, and share
|
||||
profile. That is the honest inventory, and it is thin — thin
|
||||
enough that the first thing to decide is not how to build it but what it is
|
||||
[for](#the-one-decision-to-make-first).
|
||||
|
||||
## What is already built
|
||||
|
||||
More than the nsec plan's out-of-scope note suggests, because that plan built most
|
||||
of it on the way.
|
||||
|
||||
| piece | where | state |
|
||||
|---|---|---|
|
||||
| the parser recognises `npub1…` and `nostr:npub1…` — and refuses it, as `PublicKeyOnly` | [CredentialParser.kt:94](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt) | one branch from accepting it |
|
||||
| `signInToProfile(pubkey)` plants the `GENESIS_AT` placeholder from a pubkey alone | [DatabaseNostrRepository.kt:258](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | built; needs one property, see [Phase 4](#a-sign-in-that-can-be-repeated) |
|
||||
| the state machine from there — `UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded` — reads `identity.nostrPublicKey` and never a key | [NavigationViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built |
|
||||
| the sign-in sync, the indexer relays, the one hop, and the not-found exit | `SignInSync`, `UnsyncedProfileViewModel` | built; the exit needs a third action, see [Phase 6](#phase-6--leaving) |
|
||||
| `RelaysSocketManager` follows the identity's relay list by pubkey | [RelaysSocketManager.kt:70](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) | works unchanged |
|
||||
| the notary returns before doing anything when there is no key | [NotaryViewModel.kt:72](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt) | works unchanged — and so the key package bundle it would publish is never attempted |
|
||||
| quartz's `KeyPair(pubKey = …)` with no private key, commented in its own source as "this is a read-only account" | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` | usable; it is why the DAO carries `privKey!!` at all |
|
||||
| `Identity.business` is nullable; a read-only identity is `NostrSecret` minus the secret | [Identity.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt) | one field from it |
|
||||
| the forget flow — key out, prefs deleted, metadata hidden, account rows gone, re-list, selector | `NostrSecretViewModel.forgetKey`, `IdentityWriter.forgetNostrKey` | built for an nsec; the sequence transfers whole |
|
||||
| `WalletsSelector` shows the npub for every kind | [WalletsSelector.kt:161](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | needs a "read only" label |
|
||||
| `SeedManager.getDatadir` and `AtomicFileWrite.writeVerified` are public library API | `fr.acinq.phoenix` | the app can keep a third file beside the two without a library change |
|
||||
| a string, `this_will_give_you_read_only_access_to_the` | `strings.xml` | present, unused, and says too little |
|
||||
|
||||
## What actually blocks it
|
||||
|
||||
Five things, in dependency order.
|
||||
|
||||
**The identity's key is not optional.** `Identity.nostrPrivateKey` is a
|
||||
`PrivateKey` and `nostrPublicKey` is computed from it
|
||||
([Identity.kt:50](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt),
|
||||
62). Every reader of the identity compiles against that.
|
||||
|
||||
**There is nowhere to keep a public key.** Both stores hold secrets. `nostr-keys.dat`
|
||||
is a map from public key to private key, and `EncryptedNostrKeys` refuses a file
|
||||
whose entry does not derive the key it is filed under — which is the right rule for
|
||||
that file and rules out a sentinel (see the
|
||||
[appendix](#a-sentinel-entry-in-nostr-keysdat)).
|
||||
|
||||
**The pumps are gated on the private key.** `SynchronizationViewModel` runs its
|
||||
four collectors inside `identity?.nostrPrivateKey?.let { … }`
|
||||
([SynchronizationViewModel.kt:194](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt)).
|
||||
A read-only identity's sign-in sync would be queued and never dispatched, and the
|
||||
machine would park on `UnsyncedProfile` for good — the nsec plan's Phase 5 failure
|
||||
again, from a different cause.
|
||||
|
||||
**The DAO opens what is addressed to it, or rolls back.** `storeNostrEvent` is
|
||||
`@Transaction` and indexes inside it; a gift wrap addressed to the active key that
|
||||
cannot be unsealed throws
|
||||
([NostrDao.kt:448–459](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)),
|
||||
and the throw takes the event with it. Worse, and not obvious: `decryptGiftWrapSeal`
|
||||
builds `KeyPair(privKey = keyPair.privKey)`
|
||||
([GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt)),
|
||||
and in quartz a `KeyPair` given neither key *generates a fresh random one*. A
|
||||
read-only pair forwarded there would try to open the wrap with a key nobody has,
|
||||
fail, and roll back — silently correct-looking in the logs.
|
||||
|
||||
**Every write entrance is unconditional.** Some twenty-five view models write
|
||||
something the notary has to sign or a key has to seal. Most are behind a chat
|
||||
room and unreachable without one; but the "New chat" button,
|
||||
follow, send message, key package management, key recovery and the not-found
|
||||
screen's "set one up" are each one tap from the three tabs. A pasted npub that
|
||||
lands on a screen offering "New chat" is not a preview, it is a broken app.
|
||||
|
||||
## The one decision to make first
|
||||
|
||||
What is a read-only identity *for?* Three answers were considered.
|
||||
|
||||
**Browsing.** Needs a public feed. This app does not have one, and a mode whose
|
||||
whole content is "coming soon" should not ship ahead of it.
|
||||
|
||||
**Auditing a group from outside.** The curated lists a group publishes are public
|
||||
events (kinds 31889, 31888, 31890), and a member's device is not the only place they
|
||||
could be read. But nothing today reaches them except through a room, and building
|
||||
a public list browser is a feature with its own screens — the feed problem in a
|
||||
smaller shape.
|
||||
|
||||
**A preview.** Look at the app as your own profile — the name it has for you, the
|
||||
people it knows you follow, what it found on the relays — before you paste a secret
|
||||
into it. And, having looked, paste it.
|
||||
|
||||
**This plan takes the third.** It is what the thin inventory is actually good for,
|
||||
and it settles four questions that would otherwise each be an argument of their own:
|
||||
|
||||
- The Messages tab shows an empty state that says why it is empty and offers the
|
||||
one thing that fills it: signing in with the nsec.
|
||||
- Pasting the nsec of a key already here read-only is an **upgrade in place**, not
|
||||
`AlreadyOnThisDevice`. The device does not have that key; refusing it would be
|
||||
false.
|
||||
- Sign out is real for this kind, and only this kind: there is nothing on the
|
||||
device to lose. It is the first sign out in the app, and the general case stays
|
||||
pending for the reason the nsec plan gave.
|
||||
- Not-found offers *try again* or *a different key*, never *set one up* — a profile
|
||||
cannot be set up for a key that cannot sign its kind 0.
|
||||
|
||||
Everything below is downstream of that one word, preview, and if it is ever
|
||||
revisited — a public feed lands, say — the places to change are the four above.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — a key the identity may not have
|
||||
|
||||
**App only. No behaviour change.** A type change that ships alone, so its diff is
|
||||
boring.
|
||||
|
||||
### The type
|
||||
|
||||
```kotlin
|
||||
enum class IdentityKind {
|
||||
Mnemonic,
|
||||
NostrSecret,
|
||||
/** A bare nostr public key. Nothing can be signed, opened or derived from it. */
|
||||
NostrPublic,
|
||||
}
|
||||
|
||||
data class Identity(
|
||||
val id: WalletId,
|
||||
val kind: IdentityKind,
|
||||
val nostrPublicKey: HexKey,
|
||||
/** Null only for [IdentityKind.NostrPublic]. */
|
||||
val nostrPrivateKey: PrivateKey?,
|
||||
val userPrefs: UserPrefs,
|
||||
val internalPrefs: InternalPrefs,
|
||||
val business: PhoenixBusiness?,
|
||||
) {
|
||||
/** What the notary, the pumps and every write entrance ask. */
|
||||
val canSign: Boolean get() = nostrPrivateKey != null
|
||||
|
||||
init {
|
||||
require((nostrPrivateKey == null) == (kind == IdentityKind.NostrPublic)) { "kind and key disagree" }
|
||||
require(nostrPrivateKey == null || nostrPrivateKey.nostrPublicKeyHex() == nostrPublicKey) { "key does not derive its public key" }
|
||||
}
|
||||
|
||||
override fun toString(): String = "Identity(id=$id, kind=$kind, key=<redacted>)"
|
||||
|
||||
companion object {
|
||||
fun signing(id: WalletId, kind: IdentityKind, privateKey: PrivateKey, userPrefs: UserPrefs, internalPrefs: InternalPrefs, business: PhoenixBusiness?): Identity
|
||||
fun readOnly(id: WalletId, nostrPublicKey: HexKey, userPrefs: UserPrefs, internalPrefs: InternalPrefs): Identity
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`nostrPublicKey` moves from a derived property to a constructor field, because for
|
||||
one kind there is nothing to derive it from; the two `require`s in `init` keep the
|
||||
field and the key from disagreeing, which a data class would otherwise happily
|
||||
allow. The factories exist so that the two places that build a signing identity
|
||||
today — `SovereignWalletViewModel.setActiveWallet` and the nsec branch of
|
||||
`SovereignWalletStartupScreen` — keep deriving the pubkey in one place, and so that
|
||||
nobody constructs a read-only identity with a key by accident.
|
||||
|
||||
`id` for a read-only identity is `toWalletId()` of the x-only key — the **same id**
|
||||
its nsec would have. That is deliberate and it is what makes the upgrade in
|
||||
[Phase 2](#the-upgrade-rule) free: preferences and metadata are keyed by id, and a
|
||||
read-only identity that becomes a signing one keeps both.
|
||||
|
||||
### The readers
|
||||
|
||||
Every reader of `nostrPrivateKey` already reaches it through `?.` on a nullable
|
||||
identity — `activeIdentityStateFlow.value?.nostrPrivateKey` in the DKG, chat-room
|
||||
and subgroup view models, `.map { it?.nostrPrivateKey }` in the notary,
|
||||
`identity?.nostrPrivateKey?.let` in the pumps. Making the field nullable changes
|
||||
the type of none of those expressions, and **the compiler will be silent**. That
|
||||
is worth saying plainly, because it is the opposite of Phase 1 of the nsec plan,
|
||||
where twenty-five parameter types forced every site to be looked at: here nothing
|
||||
forces anything, and the sites that need a decision are found by reading, in
|
||||
[Phase 5](#phase-5--what-a-read-only-screen-shows), not by compiling.
|
||||
|
||||
One site does change. `KeyRecoveryScreen` branches on `activeIdentity?.kind` with
|
||||
`NostrSecret` in one arm and `else` in the other
|
||||
([KeyRecoveryScreen.kt:177](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt)),
|
||||
and the `else` meant *mnemonic*. A read-only identity would fall into it and be
|
||||
offered a recovery phrase it does not have. Make both `when`s in that file
|
||||
exhaustive; the `NostrPublic` arm is unreachable after Phase 5 hides the row, and
|
||||
an unreachable arm that says so is better than an `else` that will mean something
|
||||
different next time a kind is added.
|
||||
|
||||
### Tests
|
||||
|
||||
`NavigationIdentityRoutingJvmTest` has a `nostrSecretIdentity()` fixture and the
|
||||
case that a nodeless identity is routed by its account rather than to startup. It
|
||||
gains a `readOnlyIdentity()` fixture and the same case: with the placeholder
|
||||
account, `UnqueuedProfileSynchronization`; with a kind 0 indexed, `ProfileLoaded`.
|
||||
And the two `require`s each get a one-line negative test, because they are the
|
||||
whole reason the field is a constructor parameter.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — the list of public keys
|
||||
|
||||
**App. Needs Phase 1.** A third file beside the two, and the writer that keeps it.
|
||||
|
||||
### A plain file, on purpose
|
||||
|
||||
`nostr-public-keys.json`, in the directory `SeedManager.getDatadir(phoenixGlobal.ctx)`
|
||||
returns — the same durable, app-private location as `seed.dat` and
|
||||
`nostr-keys.dat`, for the same reason: a cache directory is purged under storage
|
||||
pressure, and an identity that vanishes on a low-storage day is a support ticket.
|
||||
|
||||
```json
|
||||
{ "version": 1, "publicKeys": ["<x-only pubkey hex>", "…"] }
|
||||
```
|
||||
|
||||
Not encrypted, because there is nothing to protect: a public key is public, and
|
||||
the nsec plan's [rejection of plaintext](./nsec-sign-in.md#plaintext-in-datastore)
|
||||
was about a key that signs as the user. Written through `AtomicFileWrite.writeVerified`
|
||||
all the same — not because a truncated list is a catastrophe, but because the
|
||||
discipline costs one call and the alternative is a second way of writing a file in
|
||||
a directory that has one.
|
||||
|
||||
**In the app, not the library.** `nostr-keys.dat` went into the library because it
|
||||
needed the keystore's `expect`/`actual`s. This file needs a directory and an atomic
|
||||
write, and both are public API. Keeping it out of the library is not a small
|
||||
thing: the nsec plan's Phase 8 begins with "library first — tag it, since JitPack
|
||||
consumers resolve by tag", and this plan's does not have to.
|
||||
|
||||
**Not in `GlobalPrefs`** — for the same reason (`GlobalPrefs` is a library class,
|
||||
and its preference keys are `private`) and one more: `DataStoreManager` caches preferences per id for
|
||||
the life of the process, which `IdentityWriterJvmTest` already has to work
|
||||
around. A file is read when it is read.
|
||||
|
||||
```kotlin
|
||||
package press.mantra.compose.identity
|
||||
|
||||
object NostrPublicKeyStore {
|
||||
sealed interface ReadResult {
|
||||
data class Success(val publicKeys: Set<HexKey>) : ReadResult
|
||||
data object FileNotFound : ReadResult
|
||||
data object FileUnreadable : ReadResult
|
||||
data object SerializationError : ReadResult
|
||||
}
|
||||
fun read(phoenixGlobal: PhoenixGlobal): ReadResult
|
||||
fun readOrNull(phoenixGlobal: PhoenixGlobal): Set<HexKey>? // empty set when absent, null on failure
|
||||
fun write(phoenixGlobal: PhoenixGlobal, publicKeys: Set<HexKey>)
|
||||
}
|
||||
```
|
||||
|
||||
Three failures rather than `NostrKeyManager`'s five, because there is no key store
|
||||
in the path. `read` validates each entry as sixty-four hex characters and drops —
|
||||
with a log line — any that is not, rather than refusing the file: a corrupt entry
|
||||
in a list of public keys costs one identity, and a refused file costs all of them.
|
||||
|
||||
### Listing
|
||||
|
||||
```kotlin
|
||||
sealed interface StoredIdentity {
|
||||
…
|
||||
data class NostrPublic(
|
||||
override val id: WalletId,
|
||||
override val nostrPublicKey: HexKey,
|
||||
) : StoredIdentity {
|
||||
override val kind: IdentityKind get() = IdentityKind.NostrPublic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`StoredIdentity.merge` takes a third argument and applies one rule: **a secret wins
|
||||
over a public key**. A pubkey present in `nostr-keys.dat` or derived from a seed
|
||||
and also in the list is listed once, as the signing kind, and the list entry is
|
||||
logged as stale. It cannot happen after the writers below have run to completion,
|
||||
and it can happen if the process dies between their two writes; the merge is where
|
||||
the two files are reconciled, and the next write repairs the list.
|
||||
|
||||
### The writers
|
||||
|
||||
```kotlin
|
||||
object IdentityWriter {
|
||||
…
|
||||
suspend fun writeNostrPublicKey(log, phoenixGlobal, globalPrefs, publicKey: HexKey, isTorEnabled, customElectrumServer): WriteNostrPublicKeyResult
|
||||
suspend fun forgetNostrPublicKey(log, phoenixGlobal, id: WalletId, publicKey: HexKey): ForgetNostrPublicKeyResult
|
||||
}
|
||||
```
|
||||
|
||||
Same shape as `writeNostrKey` and `forgetNostrKey`: load, refuse a duplicate **by
|
||||
public key across all three stores**, write, `prepareIdentity`. The Tor and
|
||||
Electrum preferences mean even less to a read-only identity than to an nsec one;
|
||||
they are saved for the reason the nsec plan gave — `loadUserPrefsForWallet` is what
|
||||
creates the file.
|
||||
|
||||
### The upgrade rule
|
||||
|
||||
`writeNostrKey` refuses a key whose public key is already known. That check learns
|
||||
one distinction:
|
||||
|
||||
| the pubkey is already here as | pasting its nsec | pasting its npub |
|
||||
|---|---|---|
|
||||
| a wallet (seed) | `AlreadyExists` | `AlreadyExists` |
|
||||
| an nsec | `AlreadyExists` | `AlreadyExists` |
|
||||
| a public key | **upgrade**: write the key, then remove the list entry, `Written(id)` with the id unchanged | `AlreadyExists` |
|
||||
|
||||
`writeMnemonic` gets the same upgrade for a seed whose nostr key is in the list —
|
||||
remove the entry; the wallet's id is `hash160(nodeId)` and its preferences are
|
||||
fresh, which is what a new wallet gets today.
|
||||
|
||||
**The order of the two writes is not a style choice.** Key first, then the list. A
|
||||
crash between them leaves the pubkey in both files, which `merge` resolves in
|
||||
favour of the secret; the reverse order would leave it in neither, which is an
|
||||
identity gone.
|
||||
|
||||
### Tests
|
||||
|
||||
`StoredIdentityJvmTest` gains: three stores land in one map, each under its own
|
||||
id; a pubkey in the list and in `nostr-keys.dat` is listed once as `NostrSecret`.
|
||||
`IdentityWriterJvmTest` gains: a public key is written once and refused the second
|
||||
time; its nsec is then accepted, under the same id, and the list no longer holds
|
||||
it; the npub of an nsec already here is refused; forgetting a public key leaves the
|
||||
others and takes its preferences. A layout test for the JSON — the version field
|
||||
and one entry, as literal bytes — sits beside them, because the file is a
|
||||
compatibility contract from the moment it exists.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — listing, starting, and reading without a key
|
||||
|
||||
**App. Needs Phase 2.**
|
||||
|
||||
### Listing and starting
|
||||
|
||||
`SovereignWalletViewModel.listIdentities` reads the third store after the second,
|
||||
with `ReadResult.FileNotFound` as the empty set and the other two failures surfaced
|
||||
as `ListWalletState.Error`, the way the other two files are handled
|
||||
([SovereignWalletViewModel.kt:242](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)).
|
||||
|
||||
`SovereignWalletStartupScreen` gains its third branch, beside the nsec one
|
||||
([SovereignWalletStartupScreen.kt:185](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)):
|
||||
|
||||
```kotlin
|
||||
is StoredIdentity.NostrPublic -> sovereignWalletViewModel.setActiveIdentity(
|
||||
Identity.readOnly(
|
||||
id = identity.id,
|
||||
nostrPublicKey = identity.nostrPublicKey,
|
||||
userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id),
|
||||
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id),
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
No node, no `platformStartupLogic`, active the moment it is read — exactly the nsec
|
||||
branch. The screen-lock gate wraps all three, as it was built to.
|
||||
|
||||
`WalletsSelector` shows the npub for every kind already; a read-only row adds the
|
||||
words *read only* under it, in `labelSmall`, so the user can tell which of two
|
||||
identities with the same avatar will let them send a message before they tap.
|
||||
|
||||
### The pumps
|
||||
|
||||
`SynchronizationViewModel`'s collector becomes
|
||||
|
||||
```kotlin
|
||||
activeIdentityStateFlow.collectLatest { identity ->
|
||||
identity?.let {
|
||||
val keyPair = identity.toKeyPair()
|
||||
…
|
||||
supervisorScope {
|
||||
launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) }
|
||||
launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) }
|
||||
if (identity.canSign) {
|
||||
launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) }
|
||||
launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**A read-only identity runs the two pumps that read and neither of the two that
|
||||
write.** Stated as a rule, because Phase 5 leans on it: *a read-only identity never
|
||||
sends anything to a relay*, not a signature and not a copy. The broadcast pump
|
||||
would find nothing — nothing is ever signed, and the one control that queues an
|
||||
already-signed event is hidden — and the
|
||||
live subscriptions are the gift-wrap inbox and the group-membership follow, both of
|
||||
which fetch things this identity cannot open, and the catch-up negentropy for the
|
||||
same. Not running them is not an optimisation; it is the identity not asking for
|
||||
what it cannot use. The sync pump and the negentropy pump are what the sign-in
|
||||
sync, the one hop and *try again* are queued on, and they are all a preview needs.
|
||||
|
||||
`Identity.toKeyPair()` is the one place a quartz `KeyPair` is built from an
|
||||
identity:
|
||||
|
||||
```kotlin
|
||||
fun Identity.toKeyPair(): KeyPair = KeyPair(
|
||||
privKey = nostrPrivateKey?.value?.toByteArray(),
|
||||
pubKey = nostrPublicKey.hexToByteArray(),
|
||||
)
|
||||
```
|
||||
|
||||
With a private key, quartz recomputes the public key from it and the second
|
||||
argument is a cross-check. Without one, it is the read-only constructor. What it
|
||||
must never be is `KeyPair(privKey = null)` with nothing else — that is quartz's
|
||||
"make me a new key", and it is what `decryptGiftWrapSeal` does today
|
||||
([GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt)).
|
||||
The helper exists so that the trap has one place to be avoided; leave a comment on
|
||||
it that says what the trap is.
|
||||
|
||||
### The one DAO guard
|
||||
|
||||
After the `isAddressedTo` check in `indexNostrEvent`
|
||||
([NostrDao.kt:439](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)):
|
||||
|
||||
```kotlin
|
||||
if (activeKeyPair.privKey == null) {
|
||||
// Addressed to us, and we cannot open it. Keep the event and the wrap we just
|
||||
// stored, as the branch above does for wraps addressed to someone else; the
|
||||
// key that opens this one may be signed in later.
|
||||
logger.d("GiftWrap ${nostrEvent.id} is addressed to us, but this identity holds no key")
|
||||
return@let
|
||||
}
|
||||
```
|
||||
|
||||
The two `privKey!!` sites further down (lines 898 and 1376) are downstream of a
|
||||
successful unseal and a Marmot room this device is in; a read-only identity reaches
|
||||
neither, and they stay as they are. With the live subscriptions off, and once
|
||||
Phase 5 leaves the room list uncomposed, a gift wrap addressed to a read-only
|
||||
identity arrives only if something else asks for kind 1059 — nothing else does —
|
||||
so this guard is belt and braces. It stays because the alternative, when something
|
||||
does ask, is a rolled-back transaction that logs as a decryption failure and is
|
||||
not one; and between this phase and Phase 5 it is not belt and braces at all, it
|
||||
is what keeps the room list's inbox sync from rolling back every wrap it fetches.
|
||||
|
||||
### Tests
|
||||
|
||||
`NostrDaoJvmTest` gains one case: a gift wrap addressed to a read-only `KeyPair`
|
||||
is stored, its `GiftWrapMessage` row is stored, no seal is stored, and nothing
|
||||
throws — `NostrNip17DaoJvmTest` has the fixtures for building the wrap. The
|
||||
listing itself is covered where the nsec plan ended up covering it: `merge`, in
|
||||
`StoredIdentityJvmTest` (Phase 2), and the round trip in Phase 7, which lists
|
||||
through the view model against a real directory. The nsec plan asked for a
|
||||
`SovereignWalletViewModel` listing test of its own and the build did not write
|
||||
one; this plan does not pretend to.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — the sign-in screen
|
||||
|
||||
**App. Needs Phase 3.** The field accepts a third thing.
|
||||
|
||||
### Shape
|
||||
|
||||
| pasted | recognised as | validation |
|
||||
|---|---|---|
|
||||
| words | recovery phrase | unchanged |
|
||||
| `nsec1…`, `nostr:nsec1…` | nostr secret | unchanged |
|
||||
| 64 hex characters | **nostr secret, still** | see below |
|
||||
| `npub1…`, `nostr:npub1…` | nostr public key, read only | bech32 decodes with hrp `npub` to 32 bytes that are an x coordinate on the curve — `XonlyPublicKey(bytes).publicKey.isValid()`, the counterpart of the `isValid()` a secret is checked with |
|
||||
| `ncryptsec1…` | rejected, with the reason | unchanged |
|
||||
|
||||
**Hex stays a secret.** A private key and an x-only public key are both thirty-two
|
||||
bytes, and sixty-four hex characters cannot say which it is. The parser has always
|
||||
read hex as a secret and shown the derived npub on the confirm step so a wrong
|
||||
paste is visible; a user who pastes a public key as hex will see an npub they do
|
||||
not recognise and go back. Guessing — "it is not a valid secret, so try it as a
|
||||
public key" — would never fire when it mattered: a public key's x coordinate is,
|
||||
with overwhelming probability, also a valid scalar, so the parser would accept it
|
||||
as a secret, derive an unrelated npub, and never reach the guess. An npub has to
|
||||
arrive as an npub.
|
||||
|
||||
`CredentialProblem.PublicKeyOnly` goes, and with it the string
|
||||
`an_npub_is_a_public_key_mantra_needs_the`. An npub that does not decode, or
|
||||
decodes to a point that is not on the curve, is `InvalidKey`, which already says
|
||||
"that nostr key is not valid".
|
||||
|
||||
### The confirm step
|
||||
|
||||
`SignInCredential` gains
|
||||
|
||||
```kotlin
|
||||
data class NostrPublicKey(override val nostrPublicKey: HexKey) : SignInCredential
|
||||
```
|
||||
|
||||
and `Confirm` a third arm, `Icons.Default.Visibility` beside a sentence that says
|
||||
what the user is about to get and not get. `this_will_give_you_read_only_access_to_the`
|
||||
says "This will give you read only access to the profile", which is true and not
|
||||
enough: it does not say that messages stay closed, and it does not say what to do
|
||||
about it. Replace it:
|
||||
|
||||
> Recognised as a public key. You will see this profile and the people it
|
||||
> follows; messages stay closed and nothing can be sent. Paste the nsec later to
|
||||
> open it.
|
||||
|
||||
Sentence case, one string, read on the confirm step and nowhere else. The button
|
||||
still says *Sign in*.
|
||||
|
||||
### Committing
|
||||
|
||||
```kotlin
|
||||
is SignInCredential.NostrPublicKey -> when (val result = writeNostrPublicKey(credential.nostrPublicKey)) {
|
||||
is Written -> Outcome.SignedIn(result.id)
|
||||
is AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice)
|
||||
is CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite)
|
||||
}
|
||||
```
|
||||
|
||||
then `signInToProfile`, then the same tail as the other two: re-list, select, go to
|
||||
startup. Startup finds a `StoredIdentity.NostrPublic`, activates it, and the machine
|
||||
runs from the placeholder exactly as it does for an nsec — the sync pump is running
|
||||
because Phase 3 made it run.
|
||||
|
||||
### A sign-in that can be repeated
|
||||
|
||||
`signInToProfile` inserts a new kind-0 `UnsignedNostrEvent` every time it is called
|
||||
([DatabaseNostrRepository.kt:258](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)).
|
||||
Until now nothing called it twice for one pubkey: the writers refused the second
|
||||
sign-in before it got there. The upgrade is the first path that signs in a pubkey
|
||||
whose account already exists, and a second placeholder would be "two kind-0 rows
|
||||
for one pubkey, two accounts disagreeing about which is this one" — the failure
|
||||
`setUpProfileForExistingKey`'s own comment describes.
|
||||
|
||||
Make it idempotent by pubkey: if a kind-0 `UnsignedNostrEvent` exists for the key,
|
||||
do nothing. That is the right property regardless of the upgrade, and the existing
|
||||
test `both credentials of one key plant the same account` becomes `…plant one
|
||||
account, however many times they sign in`.
|
||||
|
||||
### Landing
|
||||
|
||||
The caption under *Sign in* names what the screen accepts, and the label and
|
||||
placeholder on the field do too. Three strings change to say "a recovery phrase,
|
||||
an nsec, or an npub to look around" — the last clause because a user who does not
|
||||
know the word *read-only* should still be told the difference before they paste.
|
||||
|
||||
### Tests
|
||||
|
||||
`CredentialParserJvmTest`: an npub, a `nostr:`-prefixed npub and the upper-cased
|
||||
form all name the same public key; an npub that does not decode is an invalid key;
|
||||
sixty-four hex characters are still a secret. `SignInToProfileViewModelJvmTest`: a
|
||||
public key is written, then its account is planted, then the id comes back; the
|
||||
npub of a key already here is refused and plants nothing; the nsec of a key here
|
||||
read-only is written under the same id and plants nothing new.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — what a read-only screen shows
|
||||
|
||||
**App. Compiles on Phase 1; visible after Phase 3.** The half the nsec plan called
|
||||
a product.
|
||||
|
||||
### One question, asked in one way
|
||||
|
||||
Screens do not receive the identity. `HomeScreen`, `ActiveProfileScreen` and the
|
||||
detail widgets take an `activeUserPublicKey`, and `MetadataEventDetail` — where
|
||||
*follow* and *send message* live — is several composables below anything that
|
||||
could be handed more. Threading `canSign` down would be the snackbar host's
|
||||
problem again: the same parameter in twenty-six lists and forgotten in the
|
||||
twenty-seventh.
|
||||
|
||||
```kotlin
|
||||
// ui/composable/widgets/SigningCapability.kt
|
||||
val LocalCanSign: ProvidableCompositionLocal<Boolean> = staticCompositionLocalOf { true }
|
||||
|
||||
@Composable
|
||||
fun ProvideSigningCapability(activeIdentity: StateFlow<Identity?>, content: @Composable () -> Unit)
|
||||
```
|
||||
|
||||
Provided once, in `MantraNavHost` around the `NavHost`, from
|
||||
`sovereignWalletViewModel.activeIdentity`. The default is `true` rather than an
|
||||
error, and that is the one way this differs from `LocalSnackbarHostState`: the
|
||||
provider sits above every screen and cannot be forgotten per screen, so the only
|
||||
things composed outside it are previews and the existing compose tests, and those
|
||||
should render as they always have.
|
||||
|
||||
It is a capability, not a kind. A screen asks "can this identity sign?", not "is
|
||||
this an npub?", because the answer is what it needs and because a remote signer —
|
||||
which can sign and holds no local key — would otherwise be a fourth value in every
|
||||
`when` (see [Out of scope](#out-of-scope)).
|
||||
|
||||
### The inventory
|
||||
|
||||
Every write entrance reachable from the three tabs without a chat room, and what
|
||||
a read-only identity gets instead. A row that says *hidden* is hidden, not
|
||||
disabled: a disabled "New chat" invites the question "why", and the answer is on
|
||||
the empty state beside it.
|
||||
|
||||
| screen | control | it writes | read only |
|
||||
|---|---|---|---|
|
||||
| `HomeScreen` | *New chat* (both layouts), the sheet, the npub dialog | key packages, welcomes, gift wraps | hidden; the tab shows the [empty state below](#the-messages-tab) |
|
||||
| `HomeScreen` | `ChatRoomListViewModel.initiate` | queues the inbox sync and the MLS negentropy | not composed, so not queued |
|
||||
| `MetadataEventDetail` | *follow*, *unfollow*, *follow back* ([:215](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)) | a kind 3 | hidden; the "follows you" state still shows |
|
||||
| `MetadataEventDetail` | *send message* ([:303](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)) | a gift wrap | hidden |
|
||||
| `MetadataEventDetail` | *edit profile* | a kind 0 | hidden (it is `ImplementationPendingRoute` today, and stays so for the other kinds) |
|
||||
| `ActiveProfileScreen` | *key package management* ([:233](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | key packages | hidden |
|
||||
| `ActiveProfileScreen` | *key recovery* ([:286](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | nothing, but there is nothing to recover | hidden |
|
||||
| `ActiveProfileScreen` | *sign out* ([:341](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | — | **real**, see [Phase 6](#phase-6--leaving) |
|
||||
| `ShareProfileScreen` | *re-broadcast* ([:191](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt)) | nothing is signed, but a copy of the kind 0 is queued for the finder relays | hidden; a preview puts nothing on a relay, not even a copy, and the broadcast pump is not running to carry it |
|
||||
| `UnsyncedProfileScreen` | *set one up* ([:217](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt)) | the six-event bootstrap | hidden; replaced, see Phase 6 |
|
||||
| `SocialPreconditionScreen` | *invite a friend*, *view invites* | pending, and writes when they exist | hidden; only *skip for now* |
|
||||
| `WriteNewNoteScreen` and the rest | — | — | unreachable: every route to them starts in a room or behind a control above |
|
||||
|
||||
Two things are *not* on the list because they are already handled. The notary's
|
||||
key package bundle is behind its key check, so a read-only identity never publishes
|
||||
one. And the DKG, chat-room and subgroup view models each answer a missing key with
|
||||
an error state — they are unreachable, and if they were reached they would say so
|
||||
rather than crash.
|
||||
|
||||
### The Messages tab
|
||||
|
||||
Where the room list would be, for a read-only identity:
|
||||
|
||||
```kotlin
|
||||
EmptyState(
|
||||
message = stringResource(Res.string.messages_need_the_secret_key_this_profile),
|
||||
icon = Icons.Default.Lock,
|
||||
action = {
|
||||
TextButton(onClick = { onNavigateToRoute(SignInRoute) }) {
|
||||
Text(stringResource(Res.string.sign_in_with_the_nsec))
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
> Messages need the secret key. This profile is read only: you can see it and the
|
||||
> people it follows, but nothing here can be opened or sent.
|
||||
|
||||
`EmptyState`'s message is required for exactly this reason — an absence has to say
|
||||
which absence it is — and its `action` slot is where the upgrade lives. The
|
||||
`SignInRoute` it opens is the same screen as landing's; the nsec pasted there hits
|
||||
the upgrade rule, and the tail takes the user through startup to a Messages tab
|
||||
with rooms in it.
|
||||
|
||||
One column at every width. The two-pane layout is a list beside a detail, and
|
||||
there is no list; an empty state that is the only thing on the screen is the one
|
||||
case `readableContent()`'s centring is for.
|
||||
|
||||
The tab stays. Hiding it would leave a navigation bar with two items — the
|
||||
conformance work promoted search and profile to peers precisely so the bar would
|
||||
not be "strictly worse than the app bar it replaced", and two items is halfway
|
||||
back to that — and the bar is where the upgrade is found.
|
||||
|
||||
### Tests
|
||||
|
||||
`ReadOnlyEntrancesJvmTest`, with `runDesktopComposeUiTest` as `ChatPaneLayoutJvmTest`
|
||||
does: `HomeScreen` in its loaded state under `LocalCanSign provides false` has no
|
||||
node with the text *New chat* and one with *Sign in with the nsec*; under `true`,
|
||||
the reverse. `ActiveProfileScreen` under `false` has no *Key recovery* and has
|
||||
*Sign out*. The inventory above is the list of assertions; a row without one is a
|
||||
row that will regress.
|
||||
|
||||
And `./gradlew :composeApp:m3Audit` — an empty state with an action and a new
|
||||
`when` arm is where a `16.dp` and a "Read Only" arrive.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — leaving
|
||||
|
||||
**App. Needs Phases 2 and 5.** Two exits, one sequence.
|
||||
|
||||
### Sign out, for the kind that can
|
||||
|
||||
`NostrSecretViewModel.forgetKey` does five things in an order that matters — key
|
||||
out of the file, preferences deleted, metadata hidden, account rows gone, then the
|
||||
caller re-lists and clears the active identity — and the order is documented on the
|
||||
function: a failure partway leaves the key on disk rather than an identity the
|
||||
selector lists but nothing can open. The sequence is the same for a read-only
|
||||
identity with the first step swapped. Lift it into a `ForgetIdentity` helper that
|
||||
takes the identity and dispatches the first step on its kind, returning
|
||||
`NotABareKey` for a mnemonic one, and have `NostrSecretViewModel` call it. The diff
|
||||
will show whether that is one function or two.
|
||||
|
||||
`ActiveProfileScreen`'s *sign out* then does, for a read-only identity only, what
|
||||
its colour has been promising: a confirmation dialog naming the npub —
|
||||
|
||||
> Sign out of this profile? This device holds no key for it, so there is nothing
|
||||
> to lose. The profile stays on the relays, and you can sign in again any time.
|
||||
|
||||
— and `ForgetIdentity`, then the nav host's existing tail
|
||||
(`listIdentities { resetToSelector() }`, which shows the selector or, if this was
|
||||
the last identity, Landing). For the other two kinds the button keeps routing to
|
||||
`ImplementationPendingRoute("Sign out")`, and the nsec plan's reason stands:
|
||||
removing a seed is a wallet question.
|
||||
|
||||
### The not-found exit
|
||||
|
||||
`UnsyncedProfileScreen`'s not-found state offers *try again* and *set one up*. For
|
||||
a read-only identity the second is hidden by Phase 5 — and then the state has
|
||||
**no exit**: the profile tab is not reachable before `ProfileLoaded`, so a user
|
||||
whose npub was not found on any relay could try again for ever. That is the nsec
|
||||
plan's "parks on a spinner" with the spinner replaced by a button.
|
||||
|
||||
A third action, shown only where the second is not: *use a different key*, which
|
||||
is `ForgetIdentity` behind the same confirmation and the same tail, landing on the
|
||||
selector or Landing. The screen learns the two callbacks the way `NostrSecretScreen`
|
||||
did — passed from the nav host — and reads `LocalCanSign` to decide which of the
|
||||
two it shows.
|
||||
|
||||
### Tests
|
||||
|
||||
`ForgetIdentity` gets the `IdentityWriterJvmTest` treatment: a read-only identity
|
||||
is forgotten and its list entry, preferences and account rows are gone; the other
|
||||
entries remain. `UnsyncedProfileViewModel`'s existing decision test is unchanged —
|
||||
not-found is still not-found — and the screen test asserts which action each kind
|
||||
gets.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — the tests that actually prove it
|
||||
|
||||
Two, beside `NsecRestoreRoundTripJvmTest`, whose fixture they reuse.
|
||||
|
||||
**The preview, end to end.** Write an npub through Phase 2's writer, list
|
||||
identities, activate the `NostrPublic` one, hand `NavigationViewModel` a repository
|
||||
holding the placeholder: `UnqueuedProfileSynchronization`. The same account with a
|
||||
kind 0 indexed: `ProfileLoaded`. Then the assertion the nsec round trip makes about
|
||||
the node, made about the key: **nothing was signed**. The only `UnsignedNostrEvent`
|
||||
for the pubkey is the placeholder, still at `GENESIS_AT`; no key package bundle
|
||||
row; no broadcast request; `identity.business == null`; `identity.canSign` false.
|
||||
|
||||
**The upgrade.** From the state above, write the nsec of the same pubkey through
|
||||
`writeNostrKey`: `Written` with the same id; `nostr-keys.dat` holds the key and the
|
||||
list does not; `signInToProfile` planted nothing new, and the account routes to
|
||||
`ProfileLoaded` as before. Re-list: one identity, `NostrSecret`, same id, same
|
||||
metadata.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — rollout
|
||||
|
||||
**No library change.** Every file this plan touches is in `composeApp`, so there is
|
||||
no submodule pointer to bump and no tag to cut. That is the one place this rollout
|
||||
is simpler than the nsec plan's, and it is because Phase 2 chose the app.
|
||||
|
||||
**Phase 1 ships alone.** A type change with two `require`s and one exhaustive
|
||||
`when`; if the release after it behaves differently, the cause is in one diff.
|
||||
|
||||
**Phases 2 through 5 ship together.** A build with the store but not the screen is
|
||||
harmless; a build with the screen but not the store strands the user at
|
||||
"Initializing…"; and a build with both but not Phase 5 is the thing this plan is
|
||||
for — an identity that looks signed in and offers "New chat". Phase 6 can follow
|
||||
by a release: without it a read-only identity has no sign out and no not-found
|
||||
exit, which is a dead end but not a lie.
|
||||
|
||||
**Old builds and the new file.** A build without Phase 3 does not read
|
||||
`nostr-public-keys.json` and does not know it exists. A downgrade loses sight of a
|
||||
read-only identity and nothing else; `seed.dat` and `nostr-keys.dat` are untouched
|
||||
throughout.
|
||||
|
||||
## Estimate
|
||||
|
||||
| phase | work | days | blocked by |
|
||||
|---|---|---|---|
|
||||
| 1 | a key the identity may not have | 0.5–1 | — |
|
||||
| 2 | the list, its writers, the upgrade rule | 1 | 1 |
|
||||
| 3 | listing, starting, the pumps, the DAO guard | 1 | 2 |
|
||||
| 4 | the sign-in screen; an idempotent sign-in | 0.5–1 | 3 |
|
||||
| 5 | the capability, the inventory, the Messages tab | 1 | 1 to compile, 3 to see |
|
||||
| 6 | sign out, and the not-found exit | 0.5 | 2, 5 |
|
||||
| 7 | the two round trips | 0.5 | 4, 5, 6 |
|
||||
| 8 | rollout | — | all |
|
||||
|
||||
**Roughly one focused week.** Phase 5 is where the estimate is least reliable:
|
||||
the inventory was made by reading, and the compiler will not say whether it is
|
||||
complete. The compose tests are how it is checked, and a row found late is a row
|
||||
added to both.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **A public feed.** The thing that would make read-only *browsing* rather than
|
||||
*previewing*. Its own plan, and when it lands the four decisions under
|
||||
[the one decision](#the-one-decision-to-make-first) are the places to revisit.
|
||||
- **NIP-46 remote signer, NIP-55 Android signer.** This plan builds half of what
|
||||
they need — an identity whose secret is not on `Identity` — and none of the
|
||||
other half, a signing interface the notary calls instead of a `NostrSignerSync`.
|
||||
`LocalCanSign` is a Boolean on purpose: a remote signer answers it *true* —
|
||||
which is why `canSign` is a property of the identity and not `nostrPrivateKey != null`
|
||||
repeated at each site — and the question it adds is "can it derive?": the
|
||||
ChillDKG host key needs the raw bytes, and no signer protocol gives them. A
|
||||
second capability, not a third value of this one.
|
||||
- **NIP-05 sign-in.** `alice@example.com` resolves to a pubkey with one HTTPS
|
||||
request, and the app already accepts a NIP-05 for starting a message. It is a
|
||||
natural fourth row in Phase 4's table, and it is a network round trip on a form
|
||||
field, which is why `EditGroupCuratedSchemaViewModel.pubkeyOrNull` declined it.
|
||||
A cheap follow-on once the npub path is proven.
|
||||
- **NIP-49 `ncryptsec`.** Unchanged from the nsec plan.
|
||||
- **Setting up a profile for a public key.** Impossible: the kind 0 has to be
|
||||
signed. Said here so that nobody looks for the branch Phase 5 hid.
|
||||
- **Reading a group's curated lists from outside it.** Public events, but every
|
||||
path to them goes through a room today. The feed problem in a smaller shape.
|
||||
- **Tightening secrets in memory.** Unchanged, and this plan adds no secret to
|
||||
tighten.
|
||||
|
||||
## Appendix — what was considered and rejected
|
||||
|
||||
### A sentinel entry in `nostr-keys.dat`
|
||||
|
||||
An empty-string value, or a zero key, under the pubkey. One file, one store, one
|
||||
`merge`. Rejected because the file's contract is the reason it is trustworthy:
|
||||
`EncryptedNostrKeys` refuses an entry whose key does not derive its pubkey, and a
|
||||
sentinel is exactly such an entry. A second version byte for a file that exists to
|
||||
hold secrets, so that it can hold a non-secret, is the wrong direction; and it
|
||||
would be a library change, with the tag and the JitPack consumers that come with
|
||||
one.
|
||||
|
||||
### A fake private key
|
||||
|
||||
Give a read-only identity a random `PrivateKey` so that nothing has to become
|
||||
nullable. The notary would sign as a key nobody follows, the DAO would try to
|
||||
unseal wraps with it, the key package bundle would be published under it, and
|
||||
every one of those would look, in the logs, like success. The nsec plan rejected
|
||||
the fake `LocalKeyManager` for the same reason: a real object in a state its
|
||||
authors never intended. Nullable is honest.
|
||||
|
||||
### Gating on the kind at each site
|
||||
|
||||
`if (identity.kind == IdentityKind.NostrPublic) hide()`. Works today, and is the
|
||||
wrong question: the site wants to know whether it can sign. When a remote signer
|
||||
arrives — the nsec plan already names it — every such `if` is a bug, and the
|
||||
compiler will point at none of them.
|
||||
|
||||
### Hiding the Messages tab
|
||||
|
||||
A read-only identity lands on its profile and the bar has two items. Rejected: the
|
||||
bar is the navigation, M3 gives a navigation bar three to five destinations and the
|
||||
conformance work made an IA decision to reach three, and the empty state is where
|
||||
the upgrade is offered. An identity that cannot find the way to become a signing
|
||||
one is a preview of nothing.
|
||||
|
||||
### Running all four pumps regardless
|
||||
|
||||
Let the broadcast pump idle and the live subscriptions fetch what they fetch; the
|
||||
DAO guard keeps the wraps. Rejected for what it costs the user for nothing:
|
||||
`GiftWrapMessage` rows that will never be opened, placeholder profiles for their
|
||||
senders, profile syncs for each, and a socket held open for an inbox that cannot
|
||||
be read. The pumps that run are the ones with something to do.
|
||||
|
||||
### A Room table for the list
|
||||
|
||||
`ReadOnlyIdentity(publicKey)`, listed by the repository. Rejected because the
|
||||
startup listing is built from the key stores by a view model that has no
|
||||
repository, and because the nsec plan's listing test exists to catch two stores
|
||||
disagreeing about what an id is; a third store in a different layer, read at a
|
||||
different time, is a third opinion.
|
||||
@@ -947,7 +947,8 @@ found.
|
||||
would accept it. But every write path in the app assumes a key: the notary
|
||||
would have nothing to sign with and the state machine would park on
|
||||
`UnsignedProfile` the first time anything was queued. A read-only mode is a
|
||||
product, not a branch.
|
||||
product, not a branch — and [npub-sign-in.md](./npub-sign-in.md) is the plan
|
||||
for that product, starting from what it is for.
|
||||
- **NIP-49 `ncryptsec`.** A passphrase-encrypted nsec. quartz 1.14.0 ships
|
||||
`nip49PrivKeyEnc.Nip49.decrypt`, so it is one call and a passphrase field; it
|
||||
belongs in Phase 4's table once the plain nsec path is proven, and is a cheap
|
||||
|
||||
Reference in New Issue
Block a user