Files
mantra-kmp/docs/npub-sign-in.md
Kgothatso Ngako 89e364c1bd chore(library): base the credentials branch on the library's master
The Phase 2 library commit was made on a branch cut from 59c11ed -- the
nsec key-store commit, which is what the app's curated branch pins -- but
that commit is not the library's default branch's tip: master is at
e51e3ae, the merge of PR #1 that brought 59c11ed in. The two trees are
identical, so the rebase is content-free; what changes is that
claude/nostr-credentials is now one commit ahead of master rather than a
sibling of its tip, and the app's pointer follows it to 84cc44c.

The plan said the nsec library commit "sits on a remote branch called
detached", which was true of where it was found and false of where it is:
it reached master through the PR. What is still true, and still the
rollout's one hard step, is that it is untagged -- the library has no tags
at all -- and JitPack consumers resolve by tag. The two sentences that said
otherwise are corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@00c36ec509
2026-09-13 12:03:15 +02:00

977 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
**Built**, phases 1–7, one commit each, in the order given; Phase 8 is the rollout
and is process rather than code. The phases are kept as written because they are
the reasoning, and the code reads better against the argument it came from than
against a summary of itself. Where the implementation chose differently the table
below says so:
| what the plan said | what it turned out to be |
|---|---|
| the startup screen's third branch in Phase 3 | in Phase 2: `StoredIdentity` is sealed, and the compiler asked for the branch the moment `NostrPublic` existed. The right code either way, one phase early |
| `KeyRecoveryScreen`'s `NostrPublic` arm "unreachable, and says so" | for the option, `Unit` with the comment; for the sentence above it, grouped with `NostrSecret` — so that if the screen is ever reached it does not promise coins |
| "the old reader kept as `LegacyNostrKeysFile` for exactly one caller" | `LegacyNostrKeysFile` is the read half of what was `NostrKeyManager`, and `EncryptedNostrKeys` stays beside it, because the migration's test has to *produce* a v1 file and nothing else can |
| the migration, unspecified in its outcomes | `MigrationResult` — `Migrated(count)`, `NotNeeded`, `Failed(failure)` — and `listIdentities` maps a failure onto the same `ListWalletState.Error` an unreadable credentials file gets, per kind |
| the wire shape as "a `@Serializable sealed class`" | two types: `NostrCredential`, whose `Secret` carries a `PrivateKey` and cannot be built with a key that is not one, and a private serializable mirror inside the encrypted file whose `Secret` carries hex |
| the DAO guard as "belt and braces" | load-bearing between Phase 3 and Phase 5, when the room list's inbox sync still ran for a read-only identity; and verified both ways — the test fails with the guard removed |
| `ProvideSigningCapability` from the active identity | a null identity — startup, landing, sign-in — answers *true*: it is nobody, not read-only, and those screens have nothing to hide |
| `ForgetIdentity` "dispatches the first step on its kind" | no dispatch: since the credentials file the first step is one call for both credential kinds, and `forgetNostrCredential` itself answers `NotACredential` for a mnemonic |
| sign out and the not-found exit, shape unspecified | one `SignOutViewModel` owning the confirmation and the in-flight state, one `SignOutOfReadOnlyDialog` differing only in its title, and a `SignOutDependencies` bundle so each screen takes one nullable parameter rather than four lambdas that throw |
| `signInToProfile` "made idempotent by pubkey" | done, and tested by signing in twice and counting one; the sign-in view model's three writers now go through one shared write-and-map |
| the round trip "with the network faked at the repository" | with the *database* real: an in-memory Room under the app's own DAOs and a real `NotaryViewModel`, because "nothing was signed" is about what is not in the tables — plus a contrast case the plan did not ask for, the same harness as a signing identity producing a key package, so the assertions are known to bite |
| `use_a_different_key` as a new string | it existed: the sign-in screen's own "use a different key" button. One string, two screens, one meaning |
One thing found on the way that is in no phase: a compose test that looks for a
floating action button's label by text has to search the unmerged tree, because
`ExtendedFloatingActionButton` merges its label into the button's semantics — and
on the merged tree an `assertDoesNotExist` for a hidden control is vacuously true.
`ReadOnlyEntrancesJvmTest` uses the unmerged tree for every lookup for that reason.
The library commit is on `claude/nostr-credentials` in the submodule, at `84cc44c`,
one commit ahead of the library's `master` and bumped into the app by the Phase 2
commit; it has to be pushed with this branch and tagged for JitPack consumers
before any of this leaves the machine. The nsec plan's `59c11ed` reached `master`
through the library's PR #1 but was never tagged; Phase 8 below says why the two go
out on one tag.
## 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 |
| `nostr-keys.dat`, with a version byte and a doc that says "a new shape would be a new version" | `EncryptedNostrKeys`, `NostrKeyManager`, `AtomicFileWrite` | the envelope, the atomic write and the manager shape all carry over; only the entries 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 — the right rule for that
shape, which is why the answer is a new shape rather than a value smuggled into
this one ([Phase 2](#phase-2--one-credentials-file)).
**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 — one credentials file
**Library, then app. The library half needs nothing above it; the app half needs
Phase 1.** The only phase that touches the library, and the one place this plan
changes one of the nsec plan's stores rather than adding to them.
### The file that was written expecting this
`EncryptedNostrKeys`'s own doc says it: "this file has one shape, and a new shape
would be a new version." A read-only credential is the new shape. Rather than a
second file beside `nostr-keys.dat` holding public keys in the clear — the design
this plan first had, and rejected for the reason in the
[appendix](#a-plaintext-sibling-file-for-public-keys) — the file becomes what its
name should have been: **`nostr-credentials.dat`**, one entry per nostr public key,
each entry saying what the device holds for it.
```json
{
"<x-only pubkey hex>": { "type": "secret", "privateKey": "<private key hex>" },
"<x-only pubkey hex>": { "type": "public" }
}
```
Same envelope — version byte, sixteen-byte iv, ciphertext of UTF-8 JSON under
`KeyStoreNames.KEY_NO_AUTH` — same atomic write through `AtomicFileWrite`, same
manager shape. The JSON is a `Map<String, NostrCredential>` with `NostrCredential`
a `@Serializable sealed class` and `type` its class discriminator — kotlinx's
standard sealed polymorphism, `Json { classDiscriminator = "type" }`, which the
library does not use elsewhere (its cloud payloads pick a variant by a version
field) and which is chosen here because the two variants have different fields and
the map then decodes in one call with no hand-written dispatch.
```kotlin
package fr.acinq.phoenix.security
@Serializable
sealed class NostrCredential {
@Serializable @SerialName("secret") data class Secret(val privateKey: PrivateKey) : NostrCredential()
@Serializable @SerialName("public") data object Public : NostrCredential()
}
class EncryptedNostrCredentials(val iv: ByteArray, val ciphertext: ByteArray) {
fun decryptAndGetCredentials(): Map<String, NostrCredential>
fun serialize(): ByteArray
companion object {
const val VERSION: Byte = 1
fun deserialize(bytes: ByteArray): EncryptedNostrCredentials
fun encrypt(credentials: Map<String, NostrCredential>): EncryptedNostrCredentials
}
}
```
The read keeps the check that makes the file trustworthy and adds its counterpart:
a `secret` entry must derive the public key it is filed under, as today, and a
`public` entry's map key must be sixty-four hex characters naming a point on the
curve. Either failure is `SerializationError`, because either can only be
corruption.
Encrypting a public key protects nothing and costs nothing. What it buys is one
read path with one failure classification, and — a small thing, but real — the
list of profiles a user has looked at is a browsing record, and it is at rest
under the same key as everything else about them.
### Why the file is renamed rather than versioned in place
A version byte of 2 in `nostr-keys.dat` would be the natural move and it is the
wrong one. An older build's reader throws on an unknown version, `NostrKeyManager`
classifies that as `SerializationError`, and `listIdentities` **returns** on that
result before it publishes anything
([SovereignWalletViewModel.kt:218](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)):
the old build would list no identities at all, seed wallets included. That is the
`seed.dat` failure the nsec plan's appendix rejected a version-4 payload for. A
file the old build does not look for cannot do that to it.
So: `nostr-credentials.dat`, version 1 of a new file, and the old reader kept as
`LegacyNostrKeysFile` for exactly one caller.
### Migration
`NostrCredentialManager.migrateFromNostrKeys(phoenixGlobal)`: if
`nostr-credentials.dat` is absent and `nostr-keys.dat` exists, read the old file,
convert every entry to `Secret`, write the new file through the verified atomic
write, and **delete the old one**. Called once from `listIdentities`, which runs
from `SovereignWalletViewModel.init` before anything else touches either file, so
a read stays a read and the migration is a named step with a test of its own.
Delete, rather than leave a frozen copy for an older build to find. A copy goes
stale in the one direction that matters: a key the user asks the new build to
*forget* would survive in a file only the old build reads, and come back on a
downgrade. Losing sight of every nsec identity on a downgrade until the next
upgrade is the lesser failure, and [Phase 8](#phase-8--rollout) says so out loud.
As far as the repository can show, the migration will run for nobody: the library
commit that introduced `nostr-keys.dat` carries no tag and the app has none. It
exists because the repository cannot prove a negative, and because it is thirty
lines.
### 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(wallets, credentials)` keeps its two inputs; a `Secret` is a
`NostrSecret` and a `Public` a `NostrPublic`. One entry per pubkey is the file's
own invariant, so there is no precedence between credentials to decide. There is
one between a seed and a credential, and it exists for the one upgrade that has to
span two files (below): a `Public` whose pubkey a seed derives is dropped, with a
log line, and the next write repairs the file.
### The writers
```kotlin
object IdentityWriter {
…
suspend fun writeNostrPublicKey(log, phoenixGlobal, globalPrefs, publicKey: HexKey, isTorEnabled, customElectrumServer): WriteNostrCredentialResult
suspend fun forgetNostrCredential(log, phoenixGlobal, id: WalletId, publicKey: HexKey): ForgetNostrCredentialResult
}
```
`writeNostrKey` stays and writes a `Secret`; `writeNostrPublicKey` writes a
`Public`; both refuse a duplicate by public key against the seeds *and* the
credentials, and both `prepareIdentity` — the Tor and Electrum preferences mean
even less to a read-only identity than to an nsec one, and are saved for the
reason the nsec plan gave. `forgetNostrKey` becomes `forgetNostrCredential` and
removes an entry of either kind; its `NotABareKey` becomes `NotACredential`, which
still means a mnemonic.
### The upgrade rule
`writeNostrKey`'s duplicate check learns one distinction:
| the pubkey is already here as | pasting its nsec | pasting its npub |
|---|---|---|
| a wallet (seed) | `AlreadyExists` | `AlreadyExists` |
| a `secret` credential | `AlreadyExists` | `AlreadyExists` |
| a `public` credential | **upgrade**: the entry becomes `secret`, in one write; `Written(id)` with the id unchanged | `AlreadyExists` |
One write, because it is one file. There is no window in which the device holds
both or neither, and nothing to reconcile afterwards — which is the whole reason
this phase is a library change and not a second file.
The one upgrade that still spans two files is a *recovery phrase* pasted over a
public credential: the seed goes to `seed.dat`, and that file cannot hold anything
else. `writeMnemonic` removes the `public` entry **first**, then writes the seed.
A crash between the two loses the read-only identity — recoverable by pasting the
npub again — rather than leaving one pubkey listed twice under two ids, which is
what the reverse order would do and what the merge rule above is there to catch
if it somehow happens anyway. The wallet's id is `hash160(nodeId)` and its
preferences are fresh, as a new wallet's are today.
### Tests
In the library, after the two that exist: `EncryptedNostrCredentialsTest` in
`commonTest`, the layout as literal bytes with one entry of each kind, because the
format is a compatibility contract from the moment it exists; and, in `jvmTest`, a
round trip through the key store, plus the migration — a v1 `nostr-keys.dat`
written by `LegacyNostrKeysFile`'s test fixture, read back as `Secret` entries in
`nostr-credentials.dat`, the old file gone.
In the app: `StoredIdentityJvmTest` gains a `Public` credential listed as
`NostrPublic`, and a `Public` whose pubkey a seed derives dropped.
`IdentityWriterJvmTest` gains: a public key is written once and refused the second
time; its nsec is then accepted, under the same id, and the entry is now `secret`;
the npub of an nsec already here is refused; forgetting a credential of either kind
leaves the others and takes its preferences.
---
## Phase 3 — listing, starting, and reading without a key
**App. Needs Phase 2.**
### Listing and starting
`SovereignWalletViewModel.listIdentities` runs the migration, then reads
`nostr-credentials.dat` where it read `nostr-keys.dat`, with the same five results
handled the same way; the change is the type of the map it hands to `merge`
([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 and a real key store. 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, and since Phase 2 the first step is the same call — `forgetNostrCredential`
removes an entry of either kind. Lift the sequence into a `ForgetIdentity` helper
that takes the identity, returns `NotACredential` for a mnemonic one, and is
called by `NostrSecretViewModel` and the two exits below alike.
`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; the credentials file holds one entry
for the pubkey and it is `secret`; `signInToProfile` planted nothing new, and the
account routes to `ProfileLoaded` as before. Re-list: one identity, `NostrSecret`,
same id, same metadata.
---
## Phase 8 — rollout
**Library first, and on the tag the nsec work still owes.** Phase 2 is a commit
on the submodule, reaching the app as a pointer bump in the Phase 2 commit. The
library commit that introduced `nostr-keys.dat` is on the library's `master` — it
arrived through PR #1 — but is untagged, and the library has no tags at all; the
nsec plan says it has to be tagged before any of that work leaves the machine, and
Phase 2 goes out on the same tag, so that no JitPack consumer ever sees the v1 file
without the reader that migrates it.
**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 3 through 5 ship together.** A build with the file but not the screen is
harmless; a build with the screen but not the file 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 before Phase 3 does not read
`nostr-credentials.dat` and does not know it exists. If it also predates the nsec
work it is unaffected in every way. If it is a build *with* `nostr-keys.dat` and
the migration has run on this device, that file is gone and the build lists no
nsec identities until the next upgrade — the failure Phase 2 chose over a
forgotten key coming back, and the one line of this rollout worth a release note.
`seed.dat` is untouched throughout.
## Estimate
| phase | work | days | blocked by |
|---|---|---|---|
| 1 | a key the identity may not have | 0.5–1 | — |
| 2 | the credentials file, migration, the writers, the upgrade rule (library + app) | 1.5 | 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 plaintext sibling file for public keys
This plan's first design: `nostr-public-keys.json` beside the two `.dat` files,
unencrypted because a public key is public, written through the same atomic
helper, and app-side — no library change, no tag. It was rejected in review, and
rightly. The upgrade became two writes across two files with a crash window
between them and a "secret wins" rule in `merge` to repair the window; forget
became two paths; and the advantage that paid for all of that was worth less than
it looked, because the library commit that introduced `nostr-keys.dat` is itself
untagged, so a credentials format rides the tag that work already owes. One file,
one entry per pubkey, one write.
### A sentinel entry in the version-1 format
An empty-string value, or a zero key, under the pubkey, in `nostr-keys.dat` as it
is. 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 typed credential is what a sentinel is trying
to be, and the file's doc had already reserved a version for it.
### Bumping the version in place
Version byte 2 in `nostr-keys.dat`, with the typed entries. Rejected in
[Phase 2](#why-the-file-is-renamed-rather-than-versioned-in-place): an older
build's `listIdentities` returns on `SerializationError` before it publishes
anything, so the old build would list no identities at all, seed wallets included.
A new file name is invisible to a build that does not know it.
### 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 store in a different layer, read at a different
time, is a second opinion where the credentials file is meant to be the only one.