0fa807a6fbd85ca7915043cb127c8cb1298e05f5
758 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0fa807a6fb |
docs: record what the pull built, and the seven places it chose differently
Phases 0 to 5 of docs/curated-to-mantra.md are landed on this branch: thirty
commits pulled from curated/curated, each carrying a `Pulled-From` trailer
naming the commit it came from, on top of Phase 0's two native ones. The plan
is kept as written and its status line now says what happened, with the table
the house keeps for a plan that has been built -- what it said against what
it turned out to be -- and the README's sentence about it follows.
**Seven differences, and none of them are corrections to the plan's
decisions.** The three decisions -- drop the brand line but keep its
reasoning, keep Mantra's sections by dropping 808a3459, take the curated
lists -- all held, and the measured predictions about them (the screen's
order, the tests, the audit) came true to the number. What changed was
mechanics: the phased replay is a cherry-pick per commit rather than a rebase
of each cut, because a recreated merge cannot reach a parent that was replayed
in an earlier phase; the library pin follows the commit rather than staying
put, because the compiler said the nsec line does not build against 84cc44c;
the join files are taken from upstream's own merges rather than unioned, for
three reasons that each took a failed attempt to learn; and the exactness
residual is seventeen files rather than sixteen plus a logo, because Mantra's
own side had moved too. Each is written in the table with the reasoning
beside it, so the next pull starts from what happened.
**The numbers are per phase, so a regression later can be placed.** jvmTest
736, 864, 905, 1,007, 1,039 and testDebugUnitTest 403, 486, 495, 530, 530
across Phases 0 to 5; both compilers clean and every m3 audit budget met at
each; the final jvmTest executed rather than restored from the build cache,
1,039 tests in 46 seconds of test time, 0 failures. The plan's own dry run
predicted 1,036; the three extra are 39fb64b6's pin of the library's
nostrPublicKey() against NIP-06.
**What is not done is named rather than implied.** Phase 6's two reverse-pulls
--
|
||
|
|
1c59ed34d7 |
chore(library): track the submodule's default branch
.gitmodules names master as the branch lightning-kmp-app tracks, so that `git submodule update --remote` follows the library's default branch rather than whatever this git's fallback happens to be. The pinned commit is unchanged: the app still builds against 84cc44c on claude/nostr-credentials, one commit ahead of master, until that commit is merged and the pointer moved to master's tip -- which is what tracking master says should happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@3116eb8a8c |
||
|
|
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 |
||
|
|
042c5db5f4 |
docs: record what the npub sign-in plan built, and the twelve places it chose differently
Phase 8 of docs/npub-sign-in.md is the rollout, which is process rather than code; what is left to write down is what the seven phases before it turned out to be. The header moves from "Not built" to "Built", with the table the other phased plans keep: what the plan said against what the implementation did, twelve rows, each a decision worth reading before touching the code it describes -- the startup branch one phase early because the sealed type asked for it, the migration's three outcomes, the DAO guard that was belt-and-braces on paper and load-bearing for two phases, a null identity answering "can sign" with true because nobody is not read-only, and a round trip run against a real database with a contrast case so that "nothing was signed" is known to be a claim the harness can refute. One finding that belongs to no phase is recorded with it: a compose test looking for a floating action button's label has to search the unmerged tree, or its "does not exist" is vacuously true. And the one rollout fact that is not process: the library commit is on claude/nostr-credentials at 01962f3, bumped in by Phase 2, and like the nsec plan's before it has to be pushed and tagged before any of this leaves the machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@f866b9d17b |
||
|
|
c53a6f77cc |
test(identity): the preview end to end, with nothing signed, and the upgrade
Phase 7 of docs/npub-sign-in.md. NpubPreviewRoundTripJvmTest is the nsec round trip with the repository made real: an in-memory database under the app's own DAOs, because the assertion this exists for is about what is not in it. An npub is signed in the way the sign-in screen does it, listed the way startup does, activated as a read-only identity, and handed to NavigationViewModel: it lands on UnqueuedProfileSynchronization. The kind 0 the relays would answer with is indexed through the read-only key pair: ProfileLoaded. And with the real NotaryViewModel watching the same rows for longer than its key package delay, nothing was signed -- the only unsigned row for the pubkey is the placeholder, still at genesis; nothing is queued for a signature; no key package bundle; no broadcast request; no node. The contrast that makes those assertions worth having: the same harness as a signing identity does sign -- the notary makes a key package bundle within its delay. Without it, "nothing was signed" could be true of a harness in which nothing can be signed. Then the upgrade: the nsec of the key held read-only signs in over it through the same view model, under the same id, leaving one credential that is now a secret, one listed identity, and one account -- the second sign-in planted nothing. The full jvm suites pass: 988 in the app, 159 in the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@315e93315a |
||
|
|
786ac15959 |
feat(identity): two exits for an identity that holds no key
Phase 6 of docs/npub-sign-in.md. Leaving: one sequence, two doors. ForgetIdentity is NostrSecretViewModel.forgetKey's sequence lifted out -- the credential out of the file, the metadata hidden, the account rows gone, in that order so a failure partway leaves the credential on disk rather than an identity the selector lists but nothing can open. Since the credentials file the first step is the same call for both credential kinds, so it is one function; a mnemonic identity is refused at the first step, because removing a seed is a wallet question this does not answer. The nsec view model calls it now and keeps only its own state. Sign out on the profile tab does, for a read-only identity only, what its colour has been promising: a confirmation naming the npub -- this device holds no key for it, so there is nothing to lose; the profile stays on the relays -- then the identity leaves the device and the nav host's tail re-lists and clears the active identity, which shows the selector or, if this was the last one, Landing. It is the first real sign out in the app. For the other two kinds the button keeps its pending route. SignOutViewModel owns the confirmation and the in-flight state; SignOutDependencies bundles what the screen needs so that a caller with none of it -- previews, tests -- passes nothing. The not-found screen had, for a read-only identity, no exit: Phase 5 hid the set-up form (a kind 0 has to be signed), the profile tab is not reachable before ProfileLoaded, and a user whose npub was found on no relay could try again for ever. So a third action, shown only where the second is not: use a different key, which is the same sequence behind the same dialog, reached from the other end of the identity's life. Tests: the sequence and where it stops, the view model's states around it, and the two screens composed as each kind -- the profile's sign out asking first and naming the npub, the not-found screen offering the form to one kind and the other key to the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@a586b7c116 |
||
|
|
f5a6f74731 |
feat(ui): what a read-only identity is shown, and what it is not
Phase 5 of docs/npub-sign-in.md -- the half the nsec plan called a product. One question, asked in one way. LocalCanSign is a composition local for the snackbar host's reason: screens do not receive the identity, MetadataEventDetail -- where follow and send message live -- is several composables below anything that could be handed more, and a parameter threaded through twenty-six lists is forgotten in the twenty-seventh. ProvideSigningCapability sits once above the navigation suite. The default is true rather than an error, the one way this differs from the snackbar host: the provider cannot be forgotten per screen, so the only things composed outside it are previews and tests. And it is a capability, not a kind -- "can this identity sign?", not "is this an npub?" -- so that a remote signer is not a fourth value in every when. The inventory, hidden rather than disabled because the empty state beside each says why: HomeScreen's New chat in both layouts, its sheet and its npub dialog; MetadataEventDetail's follow, unfollow, follow back, edit profile and send message (the "follows you" state still shows -- a fact, not an action); ActiveProfileScreen's key package management and key recovery; ShareProfileScreen's re-broadcast, which signs nothing but queues a copy for the finder relays, and a read-only identity puts nothing on a relay; SocialPreconditionScreen's invite and view invites, pending today and writes when they exist; and the not-found screen's set-up form, since a kind 0 has to be signed. Everything else that writes is behind a chat room, which a read-only identity can never open. The Messages tab, for a read-only identity, is an EmptyState where the rooms would be -- one column at every width, since a two-pane layout is a list beside a detail and there is no list -- whose message says which absence it is and whose action is the upgrade: Sign in with the nsec, which opens the same screen as landing's, hits the credentials file's upgrade rule, and comes back through startup with rooms in it. ChatRoomListViewModel is not composed, so the inbox sync and the MLS negentropy it would queue are not queued. Tests: ReadOnlyEntrancesJvmTest composes the home and profile screens under each value of LocalCanSign and looks for the controls by text, on the unmerged tree so that "does not exist" is not vacuous. The M3 audit's budgets hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@e31033e857 |
||
|
|
4c04d3b12b |
feat(sign-in): an npub, recognised, confirmed as read-only, then written
Phase 4 of docs/npub-sign-in.md. The field accepts a third thing. CredentialParser recognises npub1... and nostr:npub1... as SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix that name a point on the curve -- the counterpart of the isValid() a secret is checked with, since not every x coordinate has a point above it. PublicKeyOnly goes, and its string with it; an npub that does not decode, or names no point, is InvalidKey. Hex stays a secret: a private key and an x-only public key are the same size, the confirm step shows the derived npub so a public key pasted as hex is visible for what it is, and guessing would never fire when it mattered, since an x coordinate is almost always also a valid scalar. The test pins the vector's own public key, pasted as hex, landing on a different npub. The confirm step's third arm says what the user is about to get and not get, once, before the choice: this profile and the people it follows; messages closed and nothing sent; paste the nsec later to open it. The landing caption, the field's label and its placeholder name the third input, the last with "to look around" for a user who does not know the word read-only. The view model takes the third writer the way it takes the other two and commits through one shared write-and-map. signInToProfile is idempotent by pubkey. Until now nothing called it twice for one key -- the writers refused a second sign-in before it got there. Pasting the nsec of a key held read-only 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. A repository test signs in twice and counts one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@bfad1f39a2 |
||
|
|
6532bfc45f |
feat(identity): list, start and read as an identity that holds no key
Phase 3 of docs/npub-sign-in.md. listIdentities runs the migration from nostr-keys.dat before its read -- there rather than inside the reader, so a listing that writes is a named step and not a surprise, and from init, before anything else touches either file. An old file that cannot be read is left where it is and reported the way an unreadable credentials file is, so the user sees an error rather than silently losing every imported identity. The selector says "read only" under the npub of a public-key identity, before the tap: two identities can share an avatar and a name, and only one of them will let the user send a message. Identity.toKeyPair is now the one place a quartz KeyPair is built from an identity. With a secret it is the pair as before, the public key a cross-check; without one it is quartz's 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 decryptGiftWrapSeal builds exactly that from a forwarded pair, which is why the helper has the trap on it. The synchronization view model runs the two pumps that read for every identity and the two that write only for one that can sign. 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, and the live subscriptions are the gift-wrap inbox and the group-membership follow, both fetching what this identity cannot open. One guard in NostrDao.indexNostrEvent, after the isAddressedTo check: a wrap addressed to a key the pair does not hold is kept, event and wrap, the way someone else's mail already is. storeNostrEvent is @Transaction and indexes inside it, so before this the unseal ran with a key nobody has, threw, and the throw took the event with it while logging as a decryption failure. Verified both ways: the new DAO test fails on exactly that case with the guard removed and passes with it, and the same wrap opens once the pair holds its key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@7db863e392 |
||
|
|
d5bd248364 |
feat(identity): one credentials file, with a read-only kind and the upgrade in one write
Phase 2 of docs/npub-sign-in.md: the library half is lightning-kmp-app 01962f3 (claude/nostr-credentials), bumped in here; this is the app half. nostr-keys.dat becomes nostr-credentials.dat, one typed entry per public key -- a secret, or only the public key -- so that a profile signed in to read-only and the same profile with its nsec pasted later are one entry in one file. StoredIdentity gains NostrPublic, whose id is the one its secret would have (hash160 of the x-only key), and merge reads the typed map. It keeps the one precedence it needs: a public entry for a key a seed already derives lists the wallet only, which is the state the seed writer's two-file upgrade can leave behind if it dies between its writes. A secret for a seed's key is still listed twice, as before -- that is a duplicate the writers refuse, not a state the listing hides. IdentityWriter: writeNostrPublicKey beside writeNostrKey, both refusing a duplicate by public key against the seeds and the credentials, with the one exception that is the point of the file -- the nsec of a key held read-only is not a duplicate, since the device does not have that secret. writeNostrKey replaces the public entry with a secret one in a single write, under the id it already had, so the read-only identity's preferences are the ones the signing identity keeps. writeMnemonic has to span two files for the same upgrade and removes the credential first, then writes the seed: a crash between the two loses the read-only identity, which the npub pasted again restores, rather than listing one npub twice under two ids. forgetNostrKey becomes forgetNostrCredential and removes an entry of either kind; NotABareKey becomes NotACredential and still means a mnemonic. The startup screen's third branch is here rather than in Phase 3 because StoredIdentity is sealed and the compiler asked for it: a read-only identity is active the moment it is read, as the nsec one is. NostrSecretViewModel reads the secret entry and answers a public one with "no key for this identity", which the screen it belongs to will never show once Phase 5 hides the row. Tests: the writer's upgrade in both directions -- the nsec of a read-only key accepted under the same id, the npub of a secret refused -- and forget of either kind; merge listing all three kinds, the shared id of a public key and its secret, and the seed-over-public precedence. Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library's master as 84cc44c with an identical tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@5efeae769f |
||
|
|
cf43973b36 |
feat(identity): a key the identity may not have
Phase 1 of docs/npub-sign-in.md. A type change and nothing a user can see. Identity.nostrPrivateKey becomes nullable and nostrPublicKey becomes a constructor field, because the kind this plan adds -- NostrPublic, a bare public key -- has nothing to derive a pubkey from. A data class would let the two disagree, so two checks in init refuse an identity whose kind and key disagree about whether there is one, and one whose key does not derive the public key it was given. Identity.signing derives the pubkey and refuses the read-only kind; Identity.readOnly builds the other; nothing else constructs one now. The id of a read-only identity is toWalletId() of the x-only key -- the same id its nsec would have -- which is what will let a read-only identity become a signing one and keep its preferences. canSign is a property of the identity rather than nostrPrivateKey != null at each site, so that a signer with no local key has one place to answer. Every reader of the key already reached it through ?. on a nullable identity, so the compiler forces nothing here; the sites that need a decision are the plan's Phase 5, found by reading. The one site that did change is KeyRecoveryScreen, whose two whens branched on NostrSecret with an else that meant "mnemonic": both are exhaustive now, so the new kind is not handed the phrase option by default. Tests: NavigationIdentityRoutingJvmTest gains a read-only fixture with the same id and pubkey as the secret one, the routing case for it (by its account, like any other), and the three refusals plus the factory's own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@15766596e4 |
||
|
|
10df01f4b7 |
docs(npub): one credentials file, not two, and the write that makes the upgrade atomic
The plan's first design kept public keys in a plaintext file beside
nostr-keys.dat, app-side, so that nothing touched the library. Review asked
why the key file was not simply made a credentials file with a read-only
kind, and the answer is that it should be. The upgrade -- pasting the nsec
of a key held read-only -- was the one operation spanning both files, so it
was two writes with a crash window between them and a "secret wins" rule in
merge to repair it; with one file keyed by pubkey it is a single write that
replaces {type: public} with {type: secret}. Forget collapses to one path
with it. And the advantage that paid for the two-file design was worth less
than it looked: the library commit that introduced nostr-keys.dat is
untagged, so a credentials format rides the tag that work already owes.
Phase 2 is rewritten for nostr-credentials.dat: a typed entry per pubkey in
the same envelope, renamed rather than versioned in place because an older
build's listIdentities returns on SerializationError before it publishes
anything and would list no wallets at all; a migration that reads the v1
file once and deletes it, since a frozen copy would resurrect a forgotten
key on a downgrade; and the one upgrade that still spans two files -- a
recovery phrase over a public credential -- with its order stated. The
header, Phases 3, 6, 7 and 8, the estimate and the appendix follow, where
the two-file design now sits as the rejected alternative with the reason.
One overclaim of the revision's own is corrected in it: the library does not
use a class discriminator anywhere -- its cloud payloads pick a variant by a
version field -- so the plan says the discriminator is chosen here, not
inherited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@dc8a1091d3
|
||
|
|
69050bb743 |
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 |
||
|
|
c5c89c8d52 |
feat(groups): a row per kind of signed work on the group's screen, each opening its own
The nostr profile, the relay lists, the posts, the curated schemas and the curated entries were five sections of cards on the group's screen, every card with a button under it, which made the screen as long as everything the group had ever said, with its members and its subgroups somewhere underneath. Each section is now a row -- a card with an icon and a chevron, the shape of the signing key row above them -- carrying the one line a member wants at a glance: the group's name and address, how many of the four lists it has agreed, and a count of posts, schemas and entries. The row opens a pushed screen holding what the section held, unchanged: the cards, the broadcast under each signed event, the queue under each schema, and the gated ways into the editors, which sit inside the empty state where there is nothing yet. The paste stays on the group's screen at the end of the block, since it is about the block rather than any one row, and the whole block is still absent in a NIP-17 room for the reason it always was. Five routes, screens, view models and states, each reading only what its screen shows plus whether this device can sign. The broadcast button becomes a widget, since five screens draw it, and inComparableGroups moves to the key extensions beside shortened, with the doc comment that had been orphaned from it. The section tests become a fixture object and six tests: one for the rows -- their order, what each says, which route each opens, their absence in a NIP-17 room -- and one per screen for what the sections' tests used to check. Replayed onto Mantra by docs/curated-to-mantra.md: GroupNostrProfileSectionJvmTest.kt: deleted, as this commit deletes it upstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@55664cc7d4 |
||
|
|
b5cd41cb3d |
fix(ui): a back button on every pushed screen, and one spelling of it
Eighteen screens drew a back arrow in their app bar's leading slot and twenty-two pushed screens did not. On Android the system back stood in for it and on iOS the edge swipe, but the desktop target has neither, and a screen reached by `navigate(...)` with no way to pop it is a dead end there: seven group forms, six chat screens, five DKG screens, and four screens with no app bar at all. **One widget, `NavigateBackButton`, rather than a nineteenth inline copy.** The eighteen that had the button spelled it four ways -- `Icons.Filled.ArrowBack`, the auto-mirrored one, `ArrowBackIosNew`, and a `"Back"` literal against a `stringResource`. The widget decides twice: the icon is the auto-mirrored one, because "back" points at the leading edge and the leading edge is on the right in an RTL locale, which is what `Icons.Filled.ArrowBack` is deprecated for; and the description is the catalogue's, because it is text. All forty-one sites use it now, the eighteen converted mechanically with the imports they no longer need dropped. **Each screen takes `onNavigateBack` and the host passes `popBackStack()`.** Hoisted rather than read from a controller in the screen, so every one stays previewable and testable without a nav host, which is how the twenty-two were fixed without a nav host in a single test. **Four screens had no bar to put it in, and got one.** `ChatRoomCreationScreen` is "New chat", after the button that opens it. `CreateProfileScreen` is "Create profile", and the two body headlines that repeated the name are gone, which is the shape `SignInScreen` beside it on the landing page already has. `WriteNewNoteScreen` is "New note" whether it is a reply, a quote or neither, one new string. The "coming soon" placeholder's bar is titled with the name of what was tapped. `AddMemberToChatRoomConfirmationScreen`'s bar had been commented out; it is back, titled "Invite new member" since the body already names who and which room. `NostrEventDetailScreen`'s repost and unknown-kind branches were the last two placeholders without a bar. **Two screens are reached two ways, and only the caller knows which.** Their callback is nullable, and null draws no button. `ChatRoomMessagingScreen` is a destination on a phone and the home screen's detail pane in an expanded window; in the pane the room list is beside the transcript and a button that popped would pop the home screen, so the pane passes null. `ImplementationPendingScreen` is pushed from "learn more" and "edit profile" and is also where the navigation observer lands with `popUpTo(0)` on an error; the host reads `previousBackStackEntry`, remembered at first composition because the departing screen reads it again after the stack has moved on. The DKG approval screens reuse their existing `onDone`, which "Not now" already called -- the bar makes the same leave reachable from the loading and error states, which had no other way off. **Left alone on purpose.** The three top-level destinations have the navigation bar, which phase 6 put there instead of app-bar icons. The onboarding and loading screens cleared the stack to get where they are and have nothing under them. `SearchScreen` and `SearchResultScreen` keep their `ArrowBackIosNew`: it is a search bar's collapse control in a `leadingIcon` slot, not a navigation icon. `NavigateBackButtonJvmTest` covers the two conditional screens by the description a screen reader would announce: present and popping when pushed, absent in the pane and at the root. The rule is recorded in CLAUDE.md beside the others a new screen has to follow. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (938 tests, 4 new) and docs/scripts/m3-audit.sh --check, all budgets met. Replayed onto Mantra by docs/curated-to-mantra.md: CuratedSuggestionListScreen.kt: taken as the original merge fedbe724 left it, this being the branch join; AcceptCuratedSuggestionScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts; BroadcastGroupSignedEventScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@0634487e12 |
||
|
|
a204279e2a |
feat(groups): the entries the group has accepted, under the schemas on its screen
A section for the kind 31890s the group signed, read off GroupSignedEvent the way the profile, the posts, the relay lists and the schemas are. The reading gate is theirs -- the room as author, the room's real signature -- plus two of its own: an entry is matched to the list it replies to by the coordinate in its a root, and checked against that list's fields exactly as the queue checks a copy off a relay, so what this section calls accepted is what the queue will mark curated once the entry is broadcast and read back. Newest per coordinate, since re-curating is a newer event under the same d. Each card names the list, gives the queue row's glance of what the entry is, credits the suggester, and dates the group's decision; under it, the same broadcast button every signed event in the block has, gated on nothing. There is no way to add one here, because an entry is accepted from the queue on the schema's card above, and the empty state says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@732a4527c6 |
||
|
|
8d32a69bf3 |
feat(groups): accept a suggestion into the list, edited, as an entry for the group to sign
The queue's sheet used to end in the suggestion's JSON and a button to copy it. Now it ends in the one thing a member can do about a suggestion: take it into the list. That opens a form seeded from the suggestion -- an input per field the schema asks for, enums as chips, one input per value where a field repeats -- and a button that proposes a kind 31890 for the room's quorum to sign, pointing back at the suggestion it came from. The write side of the NIP is ported for it: CuratedEntryEvent.canonicalTemplate follows bitcoin.mov's buildCuratedCanonicalTemplate tag for tag, and walks the same field list as the reader, so what the form proposes is what the queue reads back. The one deviation is that nothing is clipped on the way through; a value the schema refuses is named under its input instead, by the queue's own verifier, before a quorum is spent on it. The d is kept whatever field writes it, so accepting twice revises one entry rather than making two, and derived fields are kept as the suggester's client filled them in, since this app derives nothing. After the quorum signs, a 31890 has somewhere to land: the signing screen says which entry and who suggested it, the transcript gets a line naming the list, and the broadcast screen seeds the schema's relays, which the NIP makes a MUST and which the entry only names by coordinate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@fcc19f9500 |
||
|
|
39b1c714b8 |
feat(groups): a broadcast button under each signed event, and the relays it goes to
Until now nothing a group signed reached a relay. `FrostSigningManager.complete` files the batch as `GroupSignedEvent` rows and applies it locally, and the profile, the relay lists, the posts and the schemas are all read off those rows -- the docs on each said so, and the only way out was the copy button on the key-state sheet. This is the way out: a "Broadcast" button under every signed event in the identity block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits the relays it will go to, presses once, and reads what each relay answered. The raw JSON is at the bottom for anyone who would rather use another tool. **The send goes to the relay pool directly, not through the broadcast queue.** Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, which is durable and retried, and it is not used here on purpose. The queue hangs off a `NostrEvent` row, and a row for the group's event is more than a queue entry: the home feed selects by kind, so the group's post would appear in it as anybody's; and the sync loop offers every local event id to the relays it reconciles with, so the event would reach relays the screen never named -- which would make "edit the relays" a fiction. `GroupSignedEvent` already explains at length why it is not a `NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of `RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is and for the same reason: the manager cannot be stood up in a test. The cost is that a broadcast is only as durable as the button press. Each row says what its relay said, and a relay that did not answer is sent to again by pressing again. **Behind no gate.** Every other button in the block is hidden from a member holding no share of the key, because each proposes a signature by the group. Sending what the group has already signed takes no share: the signature is the group's whoever repeats it, and any member could paste the JSON into another client today. So the button is there for whoever is looking, the way the suggestion queue is. **Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's General list where it has agreed one -- its write relays, and nothing from the app's set beside them, since a member who edited the relays meant them -- and the app's publish set where it has not. A relay list also goes to the indexers, because a list sent only to the relays it names is circular; a curated schema also goes to the relays it names for its own replies, so a client reading suggestions there finds what they answer. Nothing goes to a relay the group has blocked. The seed happens once, so a list the member emptied stays empty. **Each relay's answer is shown in the relay's words.** The pool answers once per relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and any other error -- and the rows keep them apart, because "blocked: not on the allow list" is something a member can act on and a red icon is not. A relay still unanswered when the whole send runs out of time reads "No answer", which is not the same as refused. Answers are matched by host, since the pool names a relay by the URL it first opened a socket with. The relay-list rows get their button inside the shared card, under the row and at the end, because the four rows share one card and "under the event" has to mean under the row. An agreed empty list gets one too: a withdrawal is a statement, and relays still holding the old list need to hear it. The key-state event and the subgroup certificates get none -- `ChronicleManager` keeps the key state off even the members-only path, and a public relay is a bigger audience than that. `BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only seeding, the three answer shapes and the host matching, and that what goes on the wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest` presses the button against a fake pool and finds each answer under its relay with the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group with a profile, two agreed lists, a post and a schema, each under its own card, and finds them still there for a member with no share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@6ae5a9676e |
||
|
|
b127647145 |
feat(groups): the queue of a list the group curates, read off the relays it named
`930d37c8` gave a group the schema: the definition of a list, signed by the room's
own key, for strangers to answer. This is the answers. Under every schema card on the
group's screen there is now a "View suggestions" button, and it opens the list's
*queue* -- every kind 31888 anyone has published in reply to the schema's coordinate,
newest first, whoever signed it, with a mark on each one the group has already taken
up into the list. It is the second half of the curated-list NIP in this app, the
read side of it: suggestions (31888) and canonical entries (31890) are now read and
checked here. They are still not written; that is the third half.
The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that
site's one list: one row per suggestion event rather than one per film, so that a
reader can see what is waiting before anything is added and whether the curator has
taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the
reference for everything the screen decides -- one row per event, the newest
version per coordinate, "Curated" where a canonical entry shares the `d`, and a
timer standing in for an answer that never comes.
**The button is behind no gate, and it is per card.** Every other button in the
identity block is hidden from a member holding no share of the key, because each of
them proposes a signature by the group. The queue is the one thing in the block the
group did not write. A schema exists to be answered by strangers, and reading the
answers takes no share of anything, so the button is there for whoever is looking
-- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a
schema and finds it. It sits under its own card rather than once for the section,
in the same `item {}` as the card, because each list has its own queue and a button
between two cards would say nothing about which one it opened.
**The protocol is ported rule for rule, and the fixtures are the NIP's own.**
`nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`,
`verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind
is the one the schema names; every required field is present; no field without
`repeat` appears twice; every value matches its field's type and config; every
`require-any` group is met; the `a` root names this schema and no other; and the
author is somebody the visibility admits. A canonical entry gets two rules on top --
its author is the schema's author, because curation is the one power a schema does
not delegate, and a source pointer, when there is one, is a well-formed suggestion
coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest`
takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's
example, and the curator's sign-off on it, and checks both against the bitcoin.mov
schema from the same document; then the doc's own minimum suggestion, which has an
IMDb link and no watch link and satisfies `require-any` that way; then every
rejection the doc lists and says why it matters -- an unknown type, a year outside
1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a
`d` with a space in it -- plus the reply rules, the repeat rule, the closed-list
rule and the two canonical rules.
**Rejected, not repaired, and read by field.** The NIP says a client MUST verify an
entry against its schema and MUST reject one that does not satisfy it rather than
showing it with the bad parts blanked out; relays carry malformed, partial and
hostile events from other applications. So `suggestion` and `canonical` return the
entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the
reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema.
problems` is an enum rather than a sentence -- so that a form later can show them
inline. What comes back is a `CuratedEntry` keyed by the schema's *field names*
rather than by tag, because a field is what a form and a screen know an entry by and
the tag is only where it was kept: the two `r` tags of the example land on
`watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body
on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no
field of the NIP's schema and are not in the reading -- "tags the schema does not
define MAY be present and MUST be ignored".
**A pattern this platform cannot compile counts as not matched.** The reference
builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on
a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring
an uncompilable pattern would accept entries the site refuses; refusing them is the
conservative reading, and it is documented at the check.
**A coordinate splits on the first two colons only.** A `d` may itself contain
colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses --
so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`,
and the test pins the identifier surviving its own colon and the pubkey being
lowercased on the way through.
**The queue is a reading over stored rows, with three things to close and one to
add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the
coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the
queue out of it. Closed: an event that does not satisfy the schema; a kind 31890
signed by anybody but the group, which is somebody else's list and marks nothing;
and an older version of an entry its author has since replaced, since both kinds
are addressable and different relays hold different latest versions, so the merge
that realises an edit happens here, newest per `kind:pubkey:d` with the event id
breaking ties. Added: `isCurated`, true where a canonical entry the group signed
shares the suggestion's identifier -- the coordinate both land on, and the
reference page's rule. Newest first, because that is the order a queue is read in.
**A stranger is named by their profile, and by their key until one arrives.** The
indexer writes a placeholder profile named "LOADING..." the moment a pubkey is
first seen, and a queue full of that would be a queue of nobody.
`suggesterName` reads the profile only when it is a real one -- `createdAt` past
`GENESIS_AT` -- and otherwise the short key, which is at least stable and
distinguishing. The row's avatar is tinted from the key like every avatar here, so
two suggestions by one stranger read as one stranger.
**Read from where the schema says, and the screen says where.** The schema's
`relay` tags are signed into the event for exactly this: a client that finds the
list anywhere knows where its replies live and cannot be sent elsewhere by an
unsigned config. A schema naming none -- the NIP allows it and lets a client pick
-- is read from the group's general relay list, the *read* relays of it, since this
is a read; and failing an agreed, non-empty one, from this build's own relay, the
same fallback the schema editor seeds from. Spellings are normalised so one relay
written two ways is one request. The header names the relays asked, because a
queue read from the wrong relays and a list nobody has written to look exactly
alike from here, and a member should be able to tell them apart.
**Nothing here talks to a relay; it is a pull like every other one-off read.**
`CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay
for the sync pump to drain, carrying the NIP's two queries as one request's two
filters -- everything anyone published in reply to the coordinate, and what the
*group* published in reply to it, the second scoped by `authors` to spare the relay
the work `queueOf` does regardless -- and watches the local table for what comes
back. That watch needed a way to observe an arbitrary NIP-01 filter, which the
repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes
and falls back to text notes for the rest, and none of its shapes is "these kinds
with this `a` tag". `observeNostrEventsMatching` applies the filter whole through
`NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is
anchored and escaped rather than a substring scan; the DAO method behind it names
its observed tables explicitly, the events and the profiles joined onto them, so a
row whose author's kind 0 arrives after the row does re-emits with the name filled
in. What the screen shows is therefore what this device has *stored* for the
coordinate -- which is also what it shows with no network at all, and what it
showed last time until the relays answer again. `NostrEvent.toEvent` is the small
conversion the reading needs to hand a stored row to a verifier written against
the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the
group's own.
**"Still looking" is bounded by time, because nothing else bounds it.** The pump
marks a request sent when the REQ goes out and processed when an event comes back.
A relay holding nothing for the coordinate answers with an EOSE the pump does not
record, so there is no signal for "asked and empty", and a screen that showed the
local table's first, empty read would call every list empty for the second before
its rows arrived -- which teaches a member not to believe it. So the queue is
"being asked" from the request going out until either something lands or ten
seconds pass, and only after that is an empty queue empty. The reference store does
the same with a six-second timer; ten here because the request queues behind the
pump's four subscription slots before it goes out. Rows arriving end it early, a
late arrival after it is still shown, and `withQueue` -- the one state merge --
pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers
"Ask again", which re-queues the same requests: the one thing the watch cannot do
on its own is make more arrive.
**The row shows what an entry is, not where to find it.** The screen is driven by
a schema it has never seen, so what goes on a row is a decision rather than a
layout: the title as the headline, since every list has one; a glance line of the
form fields that say what the entry is, as `Label: value`, because a bare
"2014 · en" says nothing to somebody who does not know the list's fields; the body
where the list has one; and who suggested it, when. Links are left to the sheet --
a URL on a row is a line of noise. "Curated" sits on the row as the one thing on it
a member might act on: it says the group already took this up, and re-curating it
would revise the entry rather than add one.
**The sheet has the whole entry and the event it came as.** Every field the entry
answered, labelled as the schema labels it and in the schema's order, so it reads
as the form would have; the title not repeated, since it is the heading; tokens and
links monospaced because those are compared and copied rather than read. Then the
signed event, byte for byte, with a copy button -- the way the key state sheet
shows the group's own statement and for the same reason: a prettier rendering is a
different string to the one whose id was hashed. It is also the thing a canonical
entry is built from, when that half arrives.
**Four states, and the transition between them.** Loading while the room and the
schema are read; an error, with nothing to retry, for a schema this device does
not hold, since the identifier came from a navigation argument and reading it
again fails the same way; and a loaded state that is either the rows, or looking,
or empty with something to do. `ScreenStateTransition` wraps the `when`, which is
the Scaffold's whole content.
**The audit's proper-noun list grows by two films.** The preview suggests *The Rise
and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and
`m3-title-case.py` excludes sample data by name rather than by pattern.
**Still nothing has reached a relay, and it bites once more.** Group-signed events
are not yet published, so a group's schema has not been where a suggester could
find it, and its queue is empty until it has. The screen reads the coordinate
`31889:<group>:<d>` all the same and shows whatever a relay holds for it; the
suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are
correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at
the reading.
32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion
and canonical entry against the NIP's schema: the reading by field, the minimum
suggestion, the source pointer and its absence, curation not being delegated, a
malformed source, the wrong kind, the reply rules, a missing required field, the
repeat rule both ways, every value rule the doc lists, `require-any`, the closed
and private lists, `checkValue` on durations, patterns and https, and the
coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows:
verified suggestions newest first with the broken, the foreign and the wrong-kind
ones dropped, an edit collapsing to its newest version, the curated mark placed by
the group's canonical entry and not a stranger's, and the naming of a suggester
with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest`
(6) covers the relay order and the read-only filter, one request per relay carrying
both queries and the watch covering both kinds, the three outcomes of `withQueue`,
an `initiate` run end to end against fakes -- the relays asked, the filter watched,
the rows shown, the looking ended -- and a missing schema being an error rather
than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header,
the row's glance line with the link kept off it, the mark on the right row and only
that row, looking against empty, and the sheet with its fields, its link and its
event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the
button under each card in turn and in front of a member with no share.
1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.
Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@1e52fc8f25
|
||
|
|
14ffa04e7c |
docs: record what the nsec sign-in plan built, and the twelve places it chose differently
Phase 8 of docs/nsec-sign-in.md is the rollout, which is process rather than code; what is left to write down is what the seven phases before it actually turned out to be. The header moves from "Not built" to "Built", with the table the other phased plans keep: what the plan said against what the implementation did, twelve rows, each one a decision worth reading before touching the code it describes -- the not-found exit as a screen state rather than a route, input problems on the field rather than through ErrorState, the kind 0 written over the placeholder rather than beside it, forget taking the account rows too. Two findings that belong to no phase are recorded with it: the android source set's file-name collision that put the failure classifier in its own file, and DataStoreManager's process-global preferences cache, which only a test reusing a key across directories could have found. And the one rollout fact that matters: the library commit is on claude/nostr-key-store in the submodule, bumped in by the Phase 3 commit, and has to be pushed and tagged with this branch. The docs index now says the plan has been built and points at the table. Replayed onto Mantra by docs/curated-to-mantra.md: README.md: line-set three-way merge, both sides' additions kept and this commit's deletions applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@2326839e48 |
||
|
|
9e6aa382af |
test(identity): the restore round trip, and that no node ran
Phase 7 of docs/nsec-sign-in.md. Phases 2 through 6 each pin one seam; this is the seams in a row, with the network faked at the repository. NsecRestoreRoundTripJvmTest unlocks the jvm key store into a temporary directory, writes an nsec the way the sign-in screen's commit does, lists identities the way startup does (both stores, merged) and asserts the key is listed under the id the writer returned, builds the Identity the startup screen's NostrSecret branch builds -- business = null -- and hands NavigationViewModel a device holding the placeholder account the sign-in planted. It must land on UnqueuedProfileSynchronization: fetch, do not create. Then the device's account gains an indexed kind 0, as the rows would when the relays answer, and the same observer must move to ProfileLoaded. And the assertion nothing in the UI would reveal: no Lightning node ran. The identity has no business behind it, and the jvm BusinessManager's flow of running businesses is empty afterwards. An nsec identity that quietly started a node -- Electrum, the LSP peer, the watchers -- would be a bug with no visible symptom, which is why it is asserted here rather than noticed later. The device fake holds its account in a MutableStateFlow rather than a flowOf, so the second step is the observer seeing a change, as it would in the app, not a second call. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (902 tests) and :composeApp:m3Audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@647ee815c1 |
||
|
|
e2295b3971 |
feat(recovery): the key behind an identity that has no phrase, and the way to forget it
Phase 6 of docs/nsec-sign-in.md. KeyRecoveryScreen offered one backup, the
recovery phrase, whose screen reads userWallet.words out of the seed map. For
an identity signed in with a bare key there are no words, and the honest
answer that screen would give is "this device holds no phrase for the
profile". So:
KeyRecoveryScreen branches on the active identity's kind. A mnemonic identity
keeps the phrase option. A NostrSecret identity gets a nostr secret key option
in its place, routing to NostrSecretRoute, with its own status line ("you said
you stored it") and a header that no longer promises coins. The backup flags
underneath are the same two per-identity preferences the phrase screen
writes; they already mean "this identity's secret is not backed up" for
whichever secret it is.
NostrSecretScreen is RecoveryPhraseScreen with the word grid replaced by the
nsec: hidden until revealed, revealed in monospace, selectable, with a copy
action -- nobody transcribes sixty-three characters by hand -- the same two
checkboxes, hidden again on leaving. The view model reads the key from
nostr-keys.dat at reveal time, by the identity's public key, not off the
active identity, so the screen's contract -- nothing secret held longer than
it is shown -- is the phrase screen's. The disclaimer is a new string: the
old one said "...and the funds in its wallet", and this identity has no
wallet to warn about.
Forget this key. An import needs an inverse, and this is the first real "sign
out" in the app; ActiveProfileScreen's button still routes to a pending
screen, and the general case stays there, because removing a seed is a wallet
question with funds behind it. Behind a dialog that names the npub and says
the profile stays on the relays, four effects in an order that matters: the
key out of nostr-keys.dat and the identity's preference files off the disk
(IdentityWriter.forgetNostrKey, which refuses a key that is not in the file
-- a wallet's nostr key lives in the seed); the metadata entry hidden, since
the metadata store has no delete and isHidden is what the selector filters
on; the account's unsigned rows deleted (forgetLocalAccount -- the kind 0
that made it a local account and anything queued that can now never be
signed, with the requests that hang off them cascading; published events
and the profile cache stay, as anyone else's would); and only then the
caller told, which re-lists identities and clears the active one so the
navigation observer sends a null identity to startup. A failure at the first
step leaves the rest untouched -- an identity the selector lists but nothing
can open is worse than one that is still there.
Tests. IdentityWriterJvmTest runs both bare-key writers against the jvm key
store and a real directory: a key written once and refused the second time
by public key, with its preferences file created; forgetting one key leaves
the other and takes its preferences with it; a key that is not in the file is
not forgotten. Its keys are fresh per test rather than fixed, because
DataStoreManager caches each id's preferences in a companion object for the
life of the process, and a fixed key was served the UserPrefs an earlier test
had created in an earlier temp directory -- worth knowing about that cache.
NostrSecretViewModelJvmTest records the four effects and asserts their order,
and that a key that could not be removed stops the sequence at one.
ForgetLocalAccountJvmTest, against Room, asserts the account and its cascaded
requests go while an unrelated account stays.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (901
tests) and :composeApp:m3Audit.
Replayed onto Mantra by docs/curated-to-mantra.md: KeyRecoveryScreen.kt: the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@e3cc23ae31
|
||
|
|
347cf26d83 |
feat(sign-in): ask the relays that would know, and say when none of them did
Phase 5 of docs/nsec-sign-in.md. Not nsec-specific: a restored recovery phrase whose profile was never published, or a device offline at sign-in, sat on the same spinner. An imported nsec is the first path that hits it routinely -- many nostr keys are made in a client that never wrote a kind 0. Ask the relays that would know. The sign-in sync fanned its REQ out over Relays.DefaultDMRelayList, which is listOf(ephemeral): our own relay, alone. The right answer for a profile this app created; an identity that has lived on Damus for three years has never heard of it. SignInSync now holds the one definition three call sites want -- the kinds, the request builder and the bootstrap set, which is every indexer relay plus ours. Indexer relays exist to hold everyone's kinds 0, 3 and 10002; that is exactly what the sync asks for. Then follow the answer, once. When a level-0 sign-in request brings back the user's own kind 10002, the sync pump queues the same request at level 1 at the relays that list says they write to, less the bootstrap set already asked. The outbox model doing what it is for: the indexers know *where* the user publishes, and the user's own relays are where the rest of their lists are authoritative. Write relays, not read relays -- asking a user's inbox for their own events is the mistake the split exists to name. One hop per account per session, because every bootstrap relay that holds the list answers with it; and a level-1 request never re-enters, because past the user's own relays lies the feed, not the profile. Say when none of them did. A request went pending -> sent when its REQ was dispatched and sent -> processed only if an event arrived for it; a relay that answered with EOSE and nothing else left its row at `sent` for good, so "still searching" and "searched, found nothing" were the same row and the screen waiting on the sync had no way to say the second thing. The pump's finally block -- every exit: EOSE, CLOSED, the bounded timeout -- now records `complete`, conditionally in SQL on the row still being `sent`, because the event handler that writes `processed` runs in its own coroutine and can land after the subscription has closed, and a completion that overwrote it would turn "found" back into "found nothing". UnsyncedProfileViewModel reads that. Its one decision, `decide`, is a pure function of the account: a kind 0 indexed or a Profile row means the route is about to move, so still searching; any request still pending or sent is a relay that has not finished; only when every request has finished and nothing came is the answer not-found. The screen's not-found state offers two exits. Try again re-queues the bootstrap, and the new in-flight requests put the screen back to searching on their own. Set up a profile is the six-event bootstrap a fresh key gets, without a fresh key: setUpProfileForExistingKey writes the kind 0 *over* the placeholder row, under its own id with signedAt back to null, rather than beside it -- getLocalAccounts is every kind-0 row, and two for one pubkey would be two accounts disagreeing about which is this one. The rows change under observeLocalAccount, NavigationViewModel sees an unsigned kind 0, and the notary, which already holds this key, signs it. createNewProfile now builds its events through the same bootstrapUnsignedEvents. Tests. SignInSyncTest pins the bootstrap set (every indexer, ours, no duplicates, more than one) and the outbox reading of a relay list: write and unmarked relays in, read relays and malformed tags out, already-asked removed. UnsyncedProfileDecisionTest walks pending -> sent -> complete and asserts the answer flips only on the last, that a request answered with something other than a profile still counts as finished, that an arrived kind 0 is never not-found. SynchronizeNostrEventRequestCompletionJvmTest pins the conditional update against a real Room database: sent becomes complete, processed and pending are left alone. SetUpProfileForExistingKeyJvmTest runs signInToProfile then the set-up against the repository and asserts one kind-0 row, the same id, unsigned, carrying the name, with five events beside it. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (895 tests) and :composeApp:m3Audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@d61d3560a0 |
||
|
|
2e31daf117 |
feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written
Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence about alpha testing and becomes the screen: one field, two things it accepts, and the npub it signs as shown before anything is written. One field, because a user does not choose an input type -- they paste what they have -- and the two shapes are unambiguous: words have spaces, keys do not. CredentialParser is that decision as a pure function. Words go through MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed nsec or sixty-four hex characters decode to a PrivateKey and must pass isValid(). The two shapes a reasonable person might paste and that cannot work are refused by name rather than lumped in with garbage: an npub is PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey (NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is, and a capital is a paste artefact worth showing rather than silently fixing. Problems with the input stay on the field, as supporting text, while the prompt is still showing; problems after confirming -- the key was already here, the write failed -- are a state of the screen, through ErrorState with a retry back to the field. Four states, wrapped in ScreenStateTransition. Confirm shows the npub in full, in monospace, and which kind was recognised, with the one consequence that differs: a recovery phrase also restores the wallet, a bare key comes with none. The secret itself is never echoed. SignInToProfileViewModel.commit is the effect, and its order is the point. The secret is written to its store first -- IdentityWriter.writeNostrKey for an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a phrase, both injected as functions so the commit can be driven without a key store -- and only on success is the placeholder account planted with nostrRepository.signInToProfile. The placeholder before the write would send the user to fetch a profile they can never sign for; the write before the placeholder is what the nav host then does. Both writers already refuse a duplicate by nostr public key, so a wallet's own nostr secret pasted as an nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare. The nav host wires the route with the same tail the create flow uses after writeSeed: re-list identities, select the new one, go to startup with the form popped, so back does not return to a field holding a secret. Startup finds the identity, activates it, and NavigationViewModel takes it from the placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from both entrances. Strings: nineteen new, sentence case, including the refusals as sentences that say what to do instead. The six Torch-era sign-in strings nothing referenced any more are gone, and the landing caption no longer promises a remote signer this does not deliver. Tests. CredentialParserJvmTest is every row of the table in and out, all three spellings of NIP-06's vector -- words, nsec, hex, with and without the nostr: prefix, in either case -- asserted to land on the same public key, which is the equality the duplicate check rests on; and each refusal by name. SignInToProfileViewModelJvmTest records the effects of a commit and asserts their order and count: write then plant for both kinds, and no planting after a refusal or a throw. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885 tests) and :composeApp:m3Audit, which found no spacing literal or title-case string in the new screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@220616019e |
||
|
|
24ca50ca48 |
feat(identity): list and start identities from both stores, and fix the other entrance
Phase 3 of docs/nsec-sign-in.md, and the submodule bump that brings Phase 2's
NostrKeyManager in (lightning-kmp-app 01489b8 -> 59c11ed, branch
claude/nostr-key-store). Nothing can create an nsec identity yet -- that is
Phase 4 -- but from here one that exists is listed, selected, started and
routed like any wallet.
StoredIdentity is what the startup screen now reads: a sealed type over
Mnemonic(userWallet) and NostrSecret(id, privateKey), both carrying the nostr
public key, because that is the one thing the two kinds share and the one
thing a duplicate check has to compare. StoredIdentity.merge folds seed.dat
and nostr-keys.dat into one map keyed by WalletId; a wallet's pubkey is one
more LocalKeyManager over words that SeedManager has already derived once.
SovereignWalletViewModel.listAvailableWallets becomes listIdentities. It
reads both files and surfaces a failure in either rather than skipping it: a
corrupt nostr-keys.dat would otherwise drop every imported identity from the
list without a word, and the seed file has always been handled this way.
Metadata registration is unchanged -- it keys on WalletId and does not care
what is behind one.
SovereignWalletStartupScreen picks a StoredIdentity, and LoadWallet, the
screen-lock gate, takes the identity rather than the wallet: it only ever read
the id, to look up the lock preferences, and it now sits outside the branch on
kind so a future lock is not something to remember to add twice. The Mnemonic
branch is the old path -- startupNode, setActiveWallet. The NostrSecret branch
builds the Identity directly with business = null and sets it; no
platformStartupLogic, no schedulePlatformLogic, no StartupViewState. Setting
it recomposes into the existing `activeIdentity != null` branch, which is what
reports the startup, so the nsec path does not double-report as the mnemonic
path does. WalletsSelector shows the npub on the second line for both kinds;
it used to show the node id, which a bare key does not have.
The other entrance. NavigationViewModel.loadNostrProfile(startupRoute) read
getLocalAccounts().firstOrNull()?.profile?.publicKey
which is wrong twice for this plan. It takes the first kind-0 account on the
device whichever identity is active -- harmless with one wallet, wrong half
the time with two, and an imported key is how a device gets its second. And
it keys off the Profile row, which only the notary's *signing* writes, so a
placeholder account planted by signInToProfile (or a profile created moments
ago and not yet signed) read as null and was answered with Landing, while
observeProfile, reading the same rows, answered the sync screen: two writers
to one state in whichever order the coroutines ran, and Landing pops the back
stack. It now selects the account by the active identity's pubkey, matched on
the unsigned event's pubKey, and passes it straight to processLocalAccount
rather than fetching it a second time.
The seed writer, hoisted. platformWriteSeed was an expect with three
byte-identical actuals -- android, jvm, ios -- using nothing but commonMain.
It is one function now, IdentityWriter.writeMnemonic, and the second writer,
IdentityWriter.writeNostrKey, sits next to it rather than being triplicated
in turn. Both refuse a duplicate by nostr public key as well as by id: a
wallet and an imported key can be the same npub under different ids, and the
database is keyed by pubkey. The three platform files lose the copy and the
imports that only it used; the jvm file's header, which described the copy,
now describes what is left.
Tests. NavigationRoutingTest gains the placeholder fixture -- kind 0 at
GENESIS_AT, no Profile, no sync request -- and asserts it routes to
UnqueuedProfileSynchronization and never Landing. The jvm routing test gains
a device with two accounts and asserts the startup entrance reads the active
identity's. StoredIdentityJvmTest pins the merge against NIP-06's own vector:
the words "leader monkey parrot ..." derive 17162c92...cd917, the same key an
nsec import of that secret produces, which is the whole basis of the
duplicate check; that a wallet and its own nostr key as a bare secret land
under different ids (why the writers dedupe by pubkey); and that a key filed
under a pubkey it does not derive is dropped.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (870
tests) and :composeApp:m3Audit. The submodule commit is local to the branch
named above and has to be pushed with this one.
Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 59c11ed9, the pin this commit compiles against (git had fast-forwarded it to the newer one).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@366b017787
|
||
|
|
bfba7e186f |
refactor(identity): put an Identity in front of the wallet, and read the key off it
Phase 1 of docs/nsec-sign-in.md. No behaviour changes; this is the type the
rest of the plan stands on, landed alone so that its diff is boring.
Every consumer of "the active user" in the app was the same expression,
activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
ten times in eight files, and none of them wanted anything from the node but
those 32 bytes. The new to.curare.compose.identity.Identity carries the nostr
private key, the x-only public key (computed once), the per-id preferences and
an optional PhoenixBusiness, with an IdentityKind saying whether it came from
twelve words or a bare secret. It replaces fr.acinq.phoenix.data.ActiveWallet
in the app rather than wrapping it: ActiveWallet is declared in the library but
nothing in the library reads it, and flattening its fields into Identity is
what lets the recovery screens, which read internalPrefs off the active
wallet, keep working for an identity that will never have a wallet.
SovereignWalletViewModel.activeWalletInUI becomes activeIdentity;
setActiveWallet(walletId, business) builds the Mnemonic identity from the node
it just started and delegates to a new setActiveIdentity(identity), which is
the entry the nsec kind will use. The twenty-five StateFlow<ActiveWallet?>
parameters across eighteen files become StateFlow<Identity?>, a rename.
The ten sites become a read of the identity's key. Two got simpler:
NotaryViewModel dropped the flatMapLatest from the wallet flow into the node's
key-manager flow, which only existed because the key arrived after the node,
and RelaysSocketManager likewise; both keep their per-key cancellation.
NavigationViewModel.observeProfile lost its `business == null -> Startup`
branch -- the one thing that would have made a nodeless identity loop -- and
the "Couldn't get the nostrKey" dead end, since an identity is whole from the
moment it exists. The unconstructed NostrNotaryRepository now takes the
identity flow instead of a WalletManager, so that it compiles against the
same seam.
WalletId stays as the id for both kinds. Every preference in the library keys
on it, and a second id type would mean a second copy of each; for an nsec
identity XonlyPublicKey.toWalletId() gives hash160 of the x-only key, the same
forty-hex shape as hash160 of a node id.
Tests: NavigationRoutingTest keeps passing with the flow retyped, and a new
jvm test pins the two things this refactor is for. A NostrSecret identity with
business = null and a synced account on the device routes to ProfileLoaded
rather than back to startup; and Identity.nostrPublicKey equals the key quartz
derives for the same secret -- the x-only form, sixty-four hex characters,
which is not what the library's own LocalKeyManager.nostrPublicKey() returns.
That test runs under runBlocking with a real-time timeout because the
observer waits 2.1 seconds on Dispatchers.IO, and runTest's virtual clock
would time out first.
The app's WalletManagerExtension.nostrPublicKey() stays: CreateProfileViewModel
still derives a fresh key's pubkey with it. The plan said it could go; it was
wrong by one caller.
Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (863
tests) and :composeApp:m3Audit.
Replayed onto Mantra by docs/curated-to-mantra.md: RelaysSocketManager.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (
|
||
|
|
c49914818a |
docs: plan nsec sign-in, and the ten call sites that make it small
An nsec is a BIP32 leaf of the seed at m/44'/1237'/0'/0/0, and the derivation runs one way, so an nsec can never have a wallet behind it. What makes the feature tractable anyway is that nothing in the app reads the wallet except the nostr key -- ten sites, all the same expression -- so the plan puts an Identity in front of the wallet and gives an nsec an identity with no node behind it. Eight phases: the identity type; a sibling key file in the library under the one keystore alias Android will accept; listing and starting both kinds; the sign-in screen for a phrase or an nsec, deduped on pubkey rather than wallet id; indexer relays and a not-found exit for the sync that today asks one relay and never finishes; recovery for a key with no phrase; tests; rollout. Two findings along the way are recorded as pre-existing rather than new: loadNostrProfile(startupRoute) keys off a Profile row a placeholder account does not have and answers Landing while observeProfile answers the sync screen, and the library's LocalKeyManager.nostrPublicKey() returns the 33-byte compressed key, not a nostr key. Replayed onto Mantra by docs/curated-to-mantra.md: README.md: line-set three-way merge, both sides' additions kept and this commit's deletions applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@daae63d5c5 |
||
|
|
ce951bb5d0 |
docs(scripts): the pin follows the commit even when git fast-forwards the gitlink
Found landing Phase 3 for real, one commit after the trial had passed it. In the trial worktree the submodule directory was an empty placeholder, so when 366b0177 moved the pin from 01489b8 to 59c11ed against a branch already at 84cc44c, git could not compare the three and reported a conflict, and the pin rule set 59c11ed as intended. On the real branch the submodule is populated, git can see that 59c11ed is an ancestor of 84cc44c, and it resolves the gitlink by fast-forward -- cleanly, silently, and to the newer pin, which is the one the commit does not compile against. So the pin rule no longer waits for a conflict: after any pick of a commit the table names, the gitlink is set to what the table says, and the note says git had fast-forwarded it. The same code path now covers 5efeae76's mapping of the unfetchable 01962f3 onto 84cc44c, which used to be a special case. Phase 3 was reset to the Phase 2 tip and is landed again with this in place; nothing else about those eight commits changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
26de13b010 |
feat(groups): an unsigned event, pasted, and the promise that it lands where its kind says
The group's identity block has a typed editor for each thing the group signs about itself -- the profile form, the post composer, the relay editor, the schema editor -- and each builds one kind of event. This is the untyped way in: an unsigned nostr event as JSON, pasted whole from wherever it was made, proposed to the quorum like anything else, and filed by kind once it is signed. A pasted kind 0 becomes the profile, a kind 1 a post, a kind 31889 a curated schema, a kind 10002 the general relay list. The entry is "Propose event", last in the identity block, behind the same share-holder gate as every button in it and absent from a NIP-17 room like the block itself. The case it exists for is the schema. `npm run schema:dry` in the bitcoin.mov repo prints exactly the kind 31889 event it would publish, id, pubkey, signature and all; until now the only way to get that list under a group's key was to retype it field by field into the editor. Now it is pasted, checked, and signed. **Where the event lands is decided by nothing in this change, and that is the design.** `FrostSigningManager.complete` files every signed event as a `GroupSignedEvent`, and the group's screen reads its profile, posts, relay lists and schemas off those rows by kind, each reader verifying the room's signature for itself. A pasted kind 0 becomes the profile the same way a kind 0 from the profile form does, because by the time either is signed there is no telling them apart. A second path -- a switch on kind that wrote the pasted event into the right place -- would have been a second reading of the same rows, and two readings of one signature drift. So the persistence half of this feature is a test rather than code: `GroupEventProposalTest` takes a pasted kind 0, kind 1, kind 31889 and kind 10002 each through a real FROST quorum signature, in the shape `FrostSigningManager.advance` runs, and asserts that `GroupNostrProfile`, `GroupPost`, `GroupCuratedSchema` and `GroupRelayList` accept what comes out, under the group's key and coordinate rather than the pasted one. **What the paste says about its author and its time is ignored, and the screen says so.** `id`, `pubkey`, `sig` and `created_at` never leave `GroupEventProposal`: what goes to `proposeSigning` is the kind, the tags and the content, and the session resolves the room's key, hashes the id over it and stamps the time it opens at -- which is what every typed editor does too. `created_at` in particular had to go. Every kind here is replaceable or addressable, so a pasted timestamp older than the group's last event would make the new one *lose* to the old on every reader and the proposal would look as though it had done nothing. The explanatory line under the byline names both facts, because a paste with a `pubkey` in it is the one case where a member could reasonably expect otherwise. **Only kinds the screen has a place for, and only events that place would accept -- checked before the quorum is asked, not after.** A signature is the most expensive thing this app does, and the readers refuse rather than repair: a kind 0 whose content is not a profile, a blank kind 1, a kind 31889 with no visibility would each be signed, filed and shown nowhere, with nothing to tell the member their quorum was spent on nothing. So `GroupEventProposal.read` makes every check a reader makes, on the paste, and refuses with the reader's own reason -- the schema's reasons are the same `CuratedSchemaProblem`s and the same sentences the schema editor shows, since they are the same checks. `ACCEPTED_KINDS` is the set of kinds with a section on the group's screen: 0, 1, the four relay lists and 31889. Not the set `ChatMessage.applyInnerEvent` has an arm for. A subgroup certificate or a key state event has invariants a paste cannot be trusted to meet and a place in the room's life that a form is not, and the nip30303 kinds have editors of their own. A long-form article is a perfectly good event the group could sign; it is refused because the line is "has somewhere to land", and the refusal names the kinds that do. An empty kind 0 is accepted, since wiping a profile is a thing groups are entitled to do, and an empty relay list is accepted, since it is a withdrawal the relays card shows as "no relays" rather than as nothing -- the same two lines `GroupNostrProfile.of` and `GroupRelayList` already draw. **The preview is the signing screen's own words.** Above the paste is what the group would sign -- "Kind 31889 · Curated schema" over "bitcoin.mov · public · 1 field" -- read live on every change, since a paste is a few kilobytes and one JSON parse is cheaper than a debounce that would hide the answer for a beat. It is `ProposedEvent.summarize` on the reading, not a description of this screen's own, so that what a member reads before proposing is word for word what every other member reads before signing. Two descriptions of one event would drift. **And `ProposedEvent.summarize` gains arms for kind 0, kind 1 and the relay lists.** The three features before this one added none for their kinds, so a member asked to sign the group's profile has been shown "Event of kind 0" over a line of JSON, and a relay list as "Event of kind 10002" over nothing at all. A profile is now said by its name and what it says about itself, with "An empty profile" and "Unreadable profile" kept apart because a signer should know which; a post by its words, the same call the translated-passage arm makes; a relay list by which of the four it is and its hosts, or "No relays" for a withdrawal. `ProposedEventTest` is new and pins all of them, the schema arm from the last commit included, and that an unrecognised kind keeps its number -- refusing to describe an event is better than describing it wrongly. **The paste stays on every refusal.** A refused reading is drawn under the field and the button, pressed anyway, is answered rather than ignored; a failed proposal is a snackbar over the same paste. The JSON is the thing worth keeping, and a member who pasted two kilobytes of schema should not have to find them again because a visibility tag was missing. The field is set in a monospace face for the same reason: it is JSON, and a bracket that does not line up is what a member is looking for in it. `isActionPending` guards a double press, because a pasted kind 1 accumulates like any other post and a second session over the same paste is a second post on the record forever. **The byline is the group's**, as on the post composer and for its reason: whatever is pasted goes out in the group's name, and a paste naming some other `pubkey` goes out in the group's name anyway. 23 new tests. `GroupEventProposalTest` (12) covers the reading -- what is read and what is dropped, junk named as junk, the accepted set, an unreadable profile against an empty one, a blank post, a schema refused with the reader's reasons, a relay list with or without relays -- and the four signed chains that are this commit's promise. `ProposedEventTest` (5) pins how each kind is said to a signer. `ProposeGroupEventScreenJvmTest` (4) types into the screen and checks the preview, the two refusals and the inert button for a member with no share, and two more cases in `GroupNostrProfileSectionJvmTest` put the entry last in the identity block and take it away from a member with no share and from a NIP-17 room. 1347 tests pass -- 861 in `:composeApp:jvmTest`, 486 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@392b90b8d5 |
||
|
|
f8e5d3610d |
feat(groups): the lists a group curates, and the one thing an edit may not change
`334e6dd1` gave a group a way to speak in its own name. This is the way it asks: kind 31889, a *curated schema event*, signed by the room's own key -- the definition of a list anyone may suggest entries to and only that key may accept them into. The protocol is the curated-list NIP written up in the bitcoin.mov repo (`docs/NIP.md` there, with `curated-schema-events.md` as the guided tour), and this is its first half in this app: the schema. Suggestions (31888) and canonical entries (31890) reply to it and are not read or written here yet. The section sits under Posts and closes the nostr-identity block, above Subgroups. The profile says who the group is, the relay lists say where to find it, the posts are what it says there -- and a schema is the other thing it publishes for strangers to answer. A post is the group speaking; a schema is the group asking, for entries to a list it will then curate by quorum under the same key. Everything in that block is signed by that key and read by people who were never in the room, and the divider that used to close it after the posts now closes it after these. **The protocol is ported tag for tag from the reference module, and the fixture is the NIP's own example.** `nostr/curated/CuratedSchemaEvent` reads and writes the event; `CuratedSchema`, `CuratedField` and `CuratedFieldConfig` are what the tags mean. The point of publishing a schema is that another client can drive its form from it, so the one property worth pinning is that what bitcoin.mov published reads here and what this app signs would read there. `CuratedSchemaEventTest` parses the bitcoin.mov schema from the NIP verbatim -- nine `field` tags, the `require-any`, the domain, the relay -- checks every field of it, and round-trips it through `template` back to an equal schema. The field tag's seventh position is a JSON blob, and a key this app does not know is kept in `CuratedFieldConfig.others` and written back, because other clients legitimately add their own and an edit here must not strip what one of them meant. **Rejected rather than repaired, with the one repair the NIP requires.** A schema is what suggestions are checked against, so a reader that patched a broken one would be accepting entries against a form nobody signed. `CuratedSchema.problems` is the NIP's rejection list and nothing more -- a missing identifier, title, name or description, an over-long one, a visibility that is absent or not one of the three words, no `field` tags at all, a picture that is not https, a domain that is not a hostname, a relay that is not a relay -- and it is the *same* list whether the schema was typed into the editor or read off a relay: the editor refuses to propose what a reader would refuse to show. The exception is `normalized`, which forces a field writing to `d` and one writing to `title` into place, both required, because that is the rule the NIP states so that no schema, wherever it came from, can talk a client into accepting untitled or unaddressable entries. The "no fields" check runs before that repair, on what the publisher said. **Visibility is never guessed, so it is nullable.** A list that does not say who may suggest to it is not one a client should act on, and the reference makes the same point by reporting a missing visibility as a violation rather than defaulting it. An enum with no "unknown" arm cannot carry that, so `CuratedSchema.visibility` is null for a schema that does not say, `VisibilityMissing` is the problem it raises, and `template` writes no `visibility` tag for a null one -- which every reader, this one included, then refuses. The editor never produces one; only an event off a relay can be missing it. **One per identifier, newest wins.** The third ordering in this family. A profile is one replaceable event and the newest wins; posts are not replaceable and accumulate; a schema is addressable, so a group may curate several lists and each is its own coordinate `31889:<room>:<d>`. `GroupCuratedSchema.newestPerListAmong` groups by identifier, keeps the newest within each -- an edit is a newer event under the same `d` -- and orders the lists most recently signed first, with the event id breaking every tie so two devices reading the same events in different orders agree. **An edit keeps the identifier, whatever the field says.** The identifier is the coordinate every suggestion to the list replies to. Changing it would not edit the list but start a second one with an empty queue and orphan the first's, so the identifier field is read-only on an edit, says why under itself, and `proposeSchema` takes it from the existing schema rather than from the field even so -- a screen not drawing something is not a guard. A new list is "Add schema" on the group's screen, which is why the section has both a per-card way in and a button: each schema is edited on its own. **A new list starts filled in, and with the group's own relays.** The two mandatory fields are put on the form before anything is typed, rather than left for `normalized` to add at signing time, because a form that showed an empty list and then signed two fields would be lying about what it proposed. The relays are seeded from the group's NIP-65 write relays -- where its canonical entries would be read from -- falling back to `GroupRelaySet.General.defaults()`, which is this build's own relay and the same thing a group opening the relay editor for the first time is shown. A schema naming no relays is one whose suggestions could go anywhere, so the seed is worth getting right; the NIP says the same, and a client is allowed to fall back to its own list only when the schema names none. **What the editor lets a group sign is held to the app's rule, not the NIP's.** A `relay` tag may be `ws://` per the NIP and a schema read off a relay is accepted with one; a relay *added here* goes through `GroupRelaySet.relayUrlOrNull` -- `wss://`, not this machine -- because the group is about to put a quorum's signature on it for strangers to publish to, which is exactly the argument that rule was written for. The duplicate check compares normalized forms, since a relay signed into an existing schema is kept as the group wrote it and the normalizer adds a trailing slash: the same host with and without one is one relay. The first version compared strings and would have let the second copy in; `EditGroupCuratedSchemaViewModelJvmTest` pins it. Suggesters -- the `p` tags a closed or private list admits -- are accepted as an npub (with or without `nostr:`) or 64 hex characters and nothing else, and only offered when the visibility gives them something to be. **A field is edited on a sheet of its own, and a mandatory one cannot stop being one.** A field has a dozen settings, and thirteen of them inline would be a screen nobody could find anything on. The sheet offers the positional parts first -- name, label, placeholder, type, required -- and then only the config keys the chosen type gives a meaning to: `min` and a numeric `max` for the three numeric types, `options` for an enum, `https` for a url. A setting the event gives no meaning to would be written into the schema and mean nothing, so when the type changes away from one it is not kept. For a field writing to `d` or `title` the tag is pinned and the requirement is on and disabled, because `normalized` would only put the field back if the sheet let it be moved or relaxed, and offering a change the event will not carry is worse than not offering it. A field name is one word and belongs to one field, and a rename follows through to the `require-any` rules that named it, since a rule naming a field that no longer exists would silently never be satisfied. The rules themselves are a chip per field, selected for the ones in the rule; one with fewer than two names says nothing and is dropped from the draft rather than refused. **The transcript arm returns null rather than `unsupported`.** The opposite call to the kind:1 arm, for the reason that arm gives: a kind:1 is content somebody meant, and a schema rumor from a member -- or one the room signed that no client could act on -- is identity plumbing, the position the kind:0 and relay-list arms are in. Parsed as well as verified, so the transcript and the group's screen agree about which events are lists: `GroupCuratedSchema.of` refuses the same event both places. The line names the list rather than describing the schema, because what a reader of the room needs to know is that the group now curates -- or has changed the form for -- a list called so-and-so, and the fields are on the group's screen. `TYPE_GROUP_SCHEMA_SIGNED` joins `GROUP_IDENTITY_TYPES`, so it is drawn as a `RitualNotice` and previewed like the other three. **The signing screen says what it is.** `ProposedEvent.summarize` gets an arm for 31889, which the last three group features did not add for their kinds. A member deciding whether to sign this is agreeing to take suggestions from whoever the visibility admits and to be the one key that accepts them, and the event's content is only the description -- so "Event of kind 31889" over a sentence would leave them signing a list without being told its name, who may suggest to it, or how much an entry asks for. The summary is those three. **The sheet's switches carry their label as a description.** A screen reader otherwise announces "switch, off" beside a sentence it has already read past. It is also what the layout test reaches the switch by, which is how it was noticed. **The heading is "Curated schemas"**, not the kind's name. The audit enforces sentence case, and the sibling headings name the thing -- "Posts", "Relays" -- rather than the event. **And the caveat that bites hardest here of all: nothing has reached a relay.** The same limit `GroupPost` states, and a schema is the one statement whose *entire* purpose is to be replied to by strangers. Until group-signed events are published, a list defined here has no queue. The relays are signed into the schema ready for that, which is also why the NIP puts them in the event rather than in a config file. 51 new tests. `CuratedSchemaEventTest` (22) works from the NIP's example event: the read, the round trip, the tag order, a config key this app does not know, a malformed blob costing a field its constraints and not its life, every rejection rule, the description falling back to content, the mandatory-field repair, and domain normalization. `GroupCuratedSchemaTest` (8) signs with real FROST quorums through the same shape `FrostSigningManager.advance` runs -- the room's signature reads, a stranger's does not, a forged author does not, and a signed-but-unusable schema is refused -- and pins one-per-identifier ordering and the id tie-break. `EditGroupCuratedSchemaViewModelJvmTest` (14) covers the seed, the identifier lock, the mandatory fields, renames following through to rules, the pubkey and relay rules, the normalized duplicate check, and the button proposing nothing for an untouched schema. `EditGroupCuratedSchemaScreenJvmTest` (4) and three more cases in `GroupNostrProfileSectionJvmTest` cover both editor states, the sheet offering a type its own settings, and the section's place on the screen, its empty state, a member with no share reading schemas they cannot edit, and its absence in a NIP-17 room. 1307 tests pass -- 838 in `:composeApp:jvmTest`, 469 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@930d37c81e |
||
|
|
e74592a48e |
feat(groups): what the group says out loud, and the one arm that must not swallow a message
`4c0ed0c1` gave a group a name and an address on nostr and nothing to say from either. This is the third statement that identity is made of: kind:1s authored by the room's own key, listed under the relay lists on the group's screen, with a composer behind "Add post" that proposes the next one to the quorum. The section sits under Relays and above Library on purpose. The profile says who the group is, the relay lists say where to find it, and these are what a reader would find there -- so they finish the identity block, and the library below them is the group's *work* rather than its voice. **A post is the group speaking; the transcript is a member speaking.** Those are different things and the room already had the second. A room's messages are `ChatEvent` kind:9 sent by whoever sent them; a post is kind:1 authored by the room's id, so any reader can check the group said it and that no single member could have made it say so. The composer's byline is the whole of that design: every other composer in this app puts the writer's name over the draft because the writer is the author, and doing that here would have a member writing what looks like their own note and finding the group had said it. The byline is the group's profile picture and name, shown before a word is typed. **The kind:1 arm in `applyInnerEvent` is the dangerous one in this change, and it is dangerous in the opposite direction to the last two.** Every kind the group signs needs an arm there or a signed post lands in the transcript as raw JSON. But kind:1 is not like kind:0 or a relay list: those are identity plumbing nobody sends into a room on purpose, so the arms added in `4c0ed0c1` return null for one that is not the group's and the event silently vanishes. A kind:1 is *content*. Somebody meant it, and it has rendered as an `unsupported` event since long before any of this existed. So this arm verifies, and on failure **falls through to `unsupported` rather than dropping the event** -- which required naming that branch as a local `fun unsupported()` so one arm can reach it deliberately instead of only falling off the end of the `when`. Getting this wrong would have been this change quietly deleting messages it has no business touching, and it would have looked like nothing at all. **Verified, not merely attributed** -- the same rule the other two arms ended up at. A rumor carries an empty signature and any pubkey its sender likes, so an author check alone would let one member write "the group posted in its own name" into the room's own transcript. `GroupSignedEvent.verifies` is what makes the line mean what it says. **Posts accumulate; everything else in this feature replaces.** kind:1 is not replaceable, so a second post stands beside the first rather than superseding it, and every "newest wins" the profile and the relay lists are built on is wrong for these. `newestFirstAmong` sorts rather than reduces, and `GroupPostTest` asserts three posts survive all three orderings a query could hand them over in -- a reading that kept only the newest would silently delete a group's entire history the first time it posted twice, and would look like a feature until somebody noticed. **Blank posts are refused twice.** The composer will not propose one, because a quorum signing an empty note puts a post on the record that renders as nothing, cannot be told from a broken one, and -- kind:1 not being replaceable -- cannot be un-said. And `newestFirstAmong` drops one that reaches it anyway, since anything blank arriving from outside this app is in exactly the same position. **A reply is marked as one.** Nothing here writes replies -- `GroupPost.template` builds a bare note, no `e` tags, no mentions, no NIP-14 subject, because every tag `TextNoteEvent.build` can add describes a relationship to something else that a group's first way of posting does not have and should not guess at. The mark is for an event that arrived some other way: drawing a reply as an announcement would put half a conversation on a group's page with nothing saying it was half. **The card shows the whole note.** A post is short by construction and it is the one thing on that screen which exists to be *read*; truncating it would mean tapping through to see a paragraph. The composer grows into the screen for the same reason -- a three-line box that hides what was written two sentences ago is a box nobody proofreads in. **No new `Post` row, for the reason there is no `Profile` row.** `Post` hangs off both `NostrEvent` and `Profile` by foreign key and a group has neither, so this reads off `GroupSignedEvent` like the profile and the relay lists. `FrostSigningManager.complete` already files every signed event before applying it, so no table, no migration, and one more kind-scoped query. **Reading a post needs no share of the key; proposing one does.** A post is the one statement here meant for people who were not in the room, so a member holding no share reads the section like anybody else and simply gets no "Add post". Both gates are `canEditNostrProfile`, which is `canSign` read once for what is now four entry points. **And the caveat that bites hardest here: nothing has reached a relay.** `FrostSigningManager.complete` puts nothing on the wire, so a group's posts are visible to its members and to nobody else -- for a profile that is an inconvenience, for a post it is most of the point. The relay lists directly above the section say where these should go and nothing yet sends them. Stated at the top of `GroupPost` in those terms rather than the neutral ones the other two readers use. `TYPE_GROUP_POST_SIGNED` joins `GROUP_IDENTITY_TYPES`, so the transcript draws it as a `RitualNotice` like the other two -- nobody said it, the group signed it. The line names the post rather than quoting it: the post itself is on the group's screen where it can be read whole, and a quote would be the same words twice with the second copy unable to say who agreed to them. 10 new cases in `GroupPostTest`, signed with real FROST quorums through the same shape `FrostSigningManager.advance` runs, and three more in the section layout test -- ordering on screen, the empty state, and a member with no share reading posts they cannot add to. 1226 tests pass -- 787 in `:composeApp:jvmTest`, 439 in `:composeApp:testDebugUnitTest`, 13 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@334e6dd1cb |
||
|
|
590230f215 |
feat(groups): the group's own nostr identity, and the two ways a member could forge it
A Marmot room's id *is* the pubkey it signs as -- see `docs/shared-key-derivation.md`, where those are one value -- so every group in this app has had a nostr identity from the moment it had a key, and has never had a way to say anything about it. This adds the two statements that identity is made of: a kind:0 saying who the group is, and the four relay lists saying where it can be found. Both are signed by the group's quorum like everything else it says. Two sections on the group's detail screen, between the signing key and the library, and two editors behind them. Neither editor saves: what leaves them is a `FrostSigningSession`, and the profile changes on every member's device at once when enough members sign, or not at all. **A group's kind:0 cannot be a `Profile` row, and that decides the whole shape.** `Profile` hangs off `NostrEvent` by foreign key, and a group-signed event is not a `NostrEvent`: no member sent it, it never travelled the wire as itself, and the outbound pipeline re-authors rumors as their sender and would strip the group's signature off -- which is exactly the position `GroupSignedEvent` exists for. So `GroupNostrProfile` and `GroupRelayList` read off the group's signed events and are readings rather than rows. Nothing new is stored: `FrostSigningManager.complete` already files every signed event before it applies one, so both readers had their data before this change and neither adds a table, a migration or a DAO method beyond the two existing kind-scoped queries. **Both readings are gated on `GroupSignedEvent.verifies`, and that is the whole trust model.** The author has to be the room, the id has to be the hash of the fields beside it, and the signature has to verify -- checkable from the row alone, with no ceremony or key state to consult. A kind:0 that merely arrived in the room is not the group's profile; a relay list filed against the room by another group is not the group's relay list. `GroupNostrProfileTest` and `GroupRelayListTest` sign their fixtures with real FROST quorums through the same shape `FrostSigningManager.advance` runs, so a stranger's signature, a forged author and three kinds of rubbish in the signature field are all tested against the code that actually verifies rather than against a mock of it. **The arm in `applyInnerEvent` had to verify, not merely attribute, and the first draft did not.** Every kind the group signs needs an arm there or it falls through to `unsupported` and puts raw JSON in the transcript -- so a profile update would have appeared in chat as a JSON blob. But that dispatch is reached from two places and cannot tell them apart: a completed signing session, where the author is the room by construction, and an arriving inner event, where it is whatever the sender wrote. A rumor carries an empty signature and any pubkey its sender likes, so the relay arm's original author-only check would have let one member write "the group signed its general relay list" into the room's transcript with no group involved. It now builds a `GroupSignedEvent` and calls `verifies`; the metadata arm was already safe because it goes through `GroupNostrProfile.of`, which does. A member's own kind:0 or relay list passing through the room fails the author half and gets no line at all, which is right -- it is theirs, not the group's. **`GROUP_PROFILE_TYPES` became `GROUP_IDENTITY_TYPES`** and holds both new message types. The profile and the relay lists are the same kind of statement and the transcript does the same thing with both -- a `RitualNotice`, answered and settled, because by the time the line exists a quorum has signed and there is nothing left to do about it. A type missing from that set renders as a chat bubble, silently, looking exactly like a member having said "The group is now called Translation collective"; that is why it is a set with two members rather than two checks. **Relay lists: four sets, and the two that were left out are the interesting part.** General (NIP-65, 10002), Messages (NIP-17, 10050), Search (NIP-50, 10007) and Blocked (NIP-51, 10006), matching the reference interface. Key packages (MIP-00, 10051) is not here even though this app writes one for every person: a key package is a device's offer to be added to an MLS group, and a group has no device and joins nothing, so a group advertising where its key packages live would point at an address that is permanently empty -- worse than saying nothing. Relay feeds (10012) is out for the harder reason below. **A group signs and cannot decrypt, so every list is written in public tags.** NIP-51 puts a relay list's entries in the encrypted half by default, and a blocked list especially: who you refuse to talk to is nobody's business. The encryption is NIP-44 to the author's own key and there is no ECDH for a FROST threshold key here. That rules out 10012 entirely -- its list is only ever private -- and it means the group's blocked list is public where a person's client would keep it secret. Said in the code, and said on the Blocked tab where somebody is about to act on it. **The editor proposes every list that changed at once, which is the deliberate departure from the interface it copies.** Wisp publishes the tab in front of you, because a person signs for themselves and there is nothing to coordinate. Here each signature costs a quorum's attention, and four sessions for one sitting at one screen would ask for it four times over what is plainly one decision. `changedSets` is what keeps that honest, and it is the piece with a quiet failure on both sides: too eager and every visit re-signs four untouched lists, dating a decision the group did not make; too shy and an edit is dropped on a screen that said it had been sent. It compares in order, because a relay list is written in the order it is read back and a member who moved a relay to the front meant to; it counts a seeded first-time list as a change, so a group whose editor filled itself in will actually send it; and it counts an empty unagreed set as no change, so opening Blocked and pressing the button does not put a quorum's signature over silence. Nine cases in `EditGroupRelaysViewModelJvmTest`. **A relay that is neither read nor write is a state NIP-65 cannot express**, so it must not be reachable. `AdvertisedRelayInfo.assemble` writes a bare `r` tag for both, `read` for read-only and `write` for write-only, and has nothing for neither -- what neither would mean is that the relay is not in the list, and removing it is the row's own button. The toggles refuse to turn off the last marker and `template` drops such a relay as a second line of defence, because a bare tag written for it would advertise it as *both*, which is the opposite of what was asked. The round trip is tested for all three markers at once: the ordinary case is the dangerous one, since "both" is encoded by the third element being *absent*, so a reader that dropped it entirely would look correct for a both-ways relay and silently promote every read-only one. **`ephemeral.mantra.press` is what a first-time group is seeded with**, for General, Messages and Search. It is `Relays.ephemeral`, already in this build's bootstrap, publish, finder and DM sets. Blocked is seeded empty on purpose: a default there is a refusal to talk to somebody that nobody in the group chose. **A profile edit builds on the group's last one; a relay list does not.** `template` for the profile goes through `MetadataEvent.updateFromPast`, so a field this form does not offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives an edit instead of being dropped by it. Using `createNew` there would compile, pass any test that only looked at the six fields on the form, and quietly wipe the rest every time somebody fixed a typo in the group's name. A relay list is the opposite: it is one list, replacing it is the whole point of signing a new one, and carrying a tag forward would make removal impossible. Both are tested for the behaviour that would be silent if wrong. **The name is written to both `name` and `display_name`.** They are the two fields readers pick a name out of and they disagree at their peril: a group whose `display_name` came from some other tool and whose `name` was edited here would keep answering to the old one in every client preferring `display_name` -- an edit that visibly does not take. One field on the form, both keys in the event. **Blank clears, and that is the only way to unsay something a group has signed.** `MetadataEvent`'s rule for these arguments is that an empty string removes the key and null leaves it alone, so the form sends what its fields hold. Said on the screen, because a member emptying a field is entitled to know whether it will be ignored. **Both sections are hidden entirely in a NIP-17 room.** Its id is a conversation rather than a key it can sign as, so it has no nostr identity and never will; an empty section there would promise something that is not coming. Every other room shows the sections and gates only the two edit buttons on holding a share of the key -- the profile and the relay lists are worth reading whoever is looking, and only a share-holder can open a session about them. That is `canEditNostrProfile`, which reads `canSign` once for the two entry points and the subgroup one, since it walks the room's ceremonies looking for a share and asking three times would do the same walk three times for one answer. **"Never agreed" and "agreed empty" are different states and the summary says which.** A set the group has never signed reads "not set yet"; one it signed empty reads "no relays". Collapsing them would hide a deliberate withdrawal behind an oversight, and `GroupRelayList.newestAmong` returns an unsigned empty list rather than null precisely so the screen has a row per set either way. **The editor's working copy is seeded from the screen, not from the loader**, and that is a fix rather than a preference. Seeding inside `initiateEditGroupRelays` meant any path that supplied a loaded state -- the `@ConformancePreviews` body, the layout test -- got an empty editor while the app worked fine, which is the shape of bug that survives review because the only thing that exercises it is the thing nobody looks at. `seedWorkingCopy` is idempotent and fills only missing keys, so the effect that calls it can re-run without throwing away typing. **`ProfileAvatar` gained an overload taking a picture URL rather than a `Profile`**, since a group has no `Profile` row to hand it and the picture and the name were all it ever wanted from one. The two existing overloads delegate to it, so there is one avatar rather than a second one for groups. **What this does not do: nothing here reaches a relay.** `FrostSigningManager.complete` applies signed events locally and puts nothing on the wire, for the reason its comment gives. So the profile and the lists are visible to members and to nobody else, and the relay lists say where the group's work *should* go without anything yet sending it there. Publishing would mean a `NostrEvent` row for an event no member authored, which is an architectural decision and belongs in its own change. Said at the top of both readers so the next reader does not assume otherwise. **And they are not chroniclable.** `ChronicleEvent.APPLY_ORDER` is an allowlist that deliberately excludes group-signed statements like `GroupKeyStateEvent`, on the grounds that a validly signed old one replayed by whoever kept a copy is a statement nobody can refuse. These five kinds are in the same position, so a member who joins after a profile is signed will not be handed it in their catch-up. Adding them is a decision with its own trade and is not made here. 48 strings, all sentence case, all bare-apostrophe -- Compose Resources does not unescape `\'`, so the aapt spelling would render the backslash. 1203 tests pass -- 774 in `:composeApp:jvmTest`, 429 in `:composeApp:testDebugUnitTest`, 41 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@4c0ed0c1ce |
||
|
|
2a02fbc14f |
docs: record the day's dry run, and the three things it changed in the plan
Phase 1 of docs/curated-to-mantra.md, run on 2026-09-13 on top of Phase 0, on a scratch worktree that landed nothing. The plan asked for exactly this -- redo the dry run the day you land, because the numbers drift -- and it was right to: the upstream had moved, the pipeline as written could not do phased landings, and the library pin turned out to have to move backwards. All three are now in the plan, in the Phase 1 section, as a record beside the plan rather than a rewrite of it. **The replay lands by cherry-pick in topological order, not by `rebase --rebase-merges`, and the reason is a property of git rather than a preference.** The plan's phases are cuts through a graph with four merges in it. Rebasing a cut whose merges have one parent in an earlier cut recreates each such merge against the *pre*-replay parent -- the commit in the rewritten source branch, not the one already landed -- and drags the unrebased lineage in beside the rebased one. A single whole-range rebase, which is what the plan's dry run measured, never meets this because every parent is inside the range. So: the ordinary commits are picked one at a time, the merge commits land as nothing, and their content, which is only ever conflict resolutions, is folded in where the conflicts actually surface. The driver that does it is docs/scripts/curated-replay.py, added here; every commit it lands carries a `Pulled-From: curated/curated@<sha>` trailer stamped by the rewrite, and a paragraph naming any resolution made on the way in. **At a branch join the join files are taken exactly as upstream's own merge left them, and a union was tried and rejected three times before that rule was reached.** A union -- keep both sides of the conflict -- is the obvious resolution for two branches appending strings to the same file, and it was wrong three ways, each caught by the exactness check against the rewritten tip and none of them visible in a passing build. It duplicates lines both sides already hold when a conflict hunk widens (thirteen string keys, twice). It never applies the other side's deletions, so the two copy-suggestion strings that fcc19f95 removes survived. And, the one that took longest to see, a join resolved in a different *order* from upstream's merge leaves every later commit that edits the block unable to find its base, so its deletions fail silently while its additions land -- which is why the second fix still left the same two strings behind. The rule that survives: files whose lines are unique by construction (string keys, imports, route registrations, table rows) get a line-set three-way merge over diff3 hunks -- ours, minus what theirs deleted from base, plus what theirs added that the file does not already hold anywhere -- and never at a join, where the file upstream's merge produced is the answer. The driver's docstring says all of this so the next reader does not rediscover it. **The library pin goes back to 59c11ed for Phases 3 and 4 and forward again in Phase 5, and the compiler was asked before deciding.** The nsec line was written against lightning-kmp-app 59c11ed and writes through its `NostrKeyManager`; 84cc44c, Mantra's pin, renamed that class to a read-only `LegacyNostrKeysFile`. Reasoning said the Phase 3 cut would not compile against 84cc44c; the trial worktree was put at that cut and built against it, and produced eight `Unresolved reference 'NostrKeyManager'` errors in IdentityWriter.kt. So 366b0177 moves the pin to 59c11ed, whose nested lightning-kmp -> bitcoin-kmp -> secp256k1 chain is the same commit as 84cc44c's and rebuilds nothing native, and 5efeae76 brings it to 84cc44c -- not to the 01962f3 it named upstream, which is a branch commit since rebased onto the library's master and no longer fetchable, but whose tree 84cc44c reproduces exactly. Every commit on the branch will compile against the pin it records, which is what upstream's history had and a fixed pin would have thrown away. Alternatives rejected: adapting the Phase 3 commits to the renamed class (rewriting upstream's work on the way in, and inventing an intermediate state nobody built) and landing Phases 3 to 5 as one unit whose inner commits do not build (bisect would hate it, and so would review). **The upstream moved, and the new line is the next pull rather than part of this one.** curated/curated went from 86cb876b, which the plan was written against, to 29027f2b: ten commits for several profiles on one device, one of which (759199f2) adds schema version 20, the fork's first migration. They are recorded and left out on purpose; a migration landing on Mantra's database deserves its own decision, and the plan's own principle -- pull the whole non-brand tree so the next pull is a replay -- says how that decision goes once it is made. The trial, with the committed driver: 30 commits replayed (4, 8, 6, 12), 21 clean and 9 with a resolution note, 0 stuck; the driver's own rerun from the Phase 0 tip reproduces the tree and improves on the hand-run trial by the one README line the old union had wrongly kept; residual diff against the rewritten 86cb876b exactly the known set -- Phase 0's prose, 39fb64b6's files, 808a3459's three, this document and its scripts, the logo. :composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean; :composeApp:jvmTest 1,039 tests, 0 failures; :composeApp:testDebugUnitTest 530 tests, 0 failures; :composeApp:m3Audit all budgets met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba0830a3d4 |
docs: say why the two hash tags and the relay host must never be renamed
Phase 0 of docs/curated-to-mantra.md, item 3. Three strings in this tree spell "mantra" for reasons that have nothing to do with the brand, and until now nothing beside them said so: `SharedKeyDerivation.TWEAK_TAG` and `ChillDkgRitualManager.HOST_KEY_DERIVATION_TAG` are inputs to hashes, and `Relays.ephemeral` is a relay that is running. Each now carries a comment saying what renaming it would cost, and docs/shared-key-derivation.md gets the paragraph that ties the three together. **The comments come from the fork, and are rewritten rather than pulled.** The Curated fork found out what these strings were the hard way: it renamed the app twice, and each time had to decide which of thousands of "mantra" tokens were the brand. Its rebrand commits (3bc8be53, e6aee792) left these three alone and wrote down why, and run through the pull's name-rewrite those commits collapse to almost nothing but those comments. They were not taken as commits, because what survives the rewrite is a sentence like "has survived two rebrands -- Mantra to Curated, Curated to Mantra", which in this repository describes rebrands that never happened. The fact they state from this side is different and worth stating plainly: the fork keeps all three byte for byte, so a Mantra member and a Curated member of one group derive one key and talk to one relay, and a rename *here* would split them as surely as a rename there. **Why comments at all, when the derivation note already has the rule.** The note's one rule is about the path a key is derived along; it never said that the tag string itself is part of the derivation, and the failure mode of renaming it is silent -- every room orphaned, every partial signature aggregating to nothing that verifies, and nothing on screen to say so. A `v2` tag is the shape a deliberate change would take, and the comments say so, so that the next person to grep for the brand finds the answer before the diff. **`ComposeAppCommonTest` moves from `press.auxiliary` to `press.mantra`.** It is the KMP template's `1 + 2 == 3`, the last file under a package the app vacated two brands ago, and the fork relocated it rather than deleting it so the source tree has one root package instead of an orphan under an empty one. Same here; it is moved with `git mv` so its history follows. No behaviour changes. :composeApp:jvmTest 736 tests, 0 failures; :composeApp:testDebugUnitTest 403 tests, 0 failures; :composeApp:m3Audit all budgets met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4681ac1a03 |
refactor: retire the brand before mantra, and keep the three records of it
Phase 0 of docs/curated-to-mantra.md, item 2. The app has been Mantra since
before this repository had a docs/ directory, and four things still said Torch,
the brand before it: the theme composable, `TorchTheme`, at 116 sites; the
user agent, `UserAgent.APP_NAME` and `CLIENT_NAME`; two error strings a user
could actually be shown ("tell X to use Torch", "finished setting up Torch");
and iOS, where `Config.xcconfig` built `PRODUCT_NAME=Torch` under the bundle id
`ac.aux.compose.Aux` and the project's product reference was still `Aux.app` --
two brands ago. All four now say Mantra.
**This is done natively, before anything is pulled from the fork, so that the
fork's theme maps onto a name that means something.** The Curated fork retired
Torch itself in d26cf6c7 (`TorchTheme` -> `CuratedTheme`, later `CurareTheme`).
The pull rewrites every fork commit into this repository's names, and until
now the only honest target for `CurareTheme` was `TorchTheme` -- the brand
before last -- which would have had every pulled screen wrapping itself in a
name the app stopped using long ago. `MantraTheme` is what the rewrite maps to
from here; docs/scripts/curated-unbrand.py's theme, `UserAgent` and iOS rules
are changed in this commit for that reason, and the comment that said "change
here if Phase 0 renames it" goes with them. Retiring Torch as part of the pull
instead -- by taking the rewritten d26cf6c7 -- was rejected because that commit
would then be a fork commit renaming something the fork never had, and because
the iOS half of the debt was never the fork's to pay.
**The user agent went last, and the reason it could go at all is written where
the next reader will look.** `CLIENT_NAME` is the `client` tag on every relay
list this app publishes, which is why it was left alone when the top bar was
fixed: it goes on the wire, so it read as a network identity question. It is
attribution and nothing derives from it, which is the difference between it
and the two `mantra/` hash tags that must never move; the comment on the
`HomeScreen` bar and the paragraph in material-design-conformance.md that both
said "still says Torch" now say that, in order, rather than describing a state
this commit ends.
**Three records keep the old name on purpose.** material-design-conformance.md:278
records a finding -- the top bar once read "Torch" while the window title read
"Mantra" -- and changing it would falsify the record. The paragraph at :654 is
rewritten rather than deleted because it was a statement of current state, and
the current state changed. `NavigationRoutingTest`'s "Introducing... Torch"
fixture is the string a stranded user actually saw, and the test is about the
stranding. Nothing else in the tree says Torch.
**iOS is unverified.** The build gates the apple targets behind `isMacOsX` and
this was built on linux; the xcconfig and pbxproj edits are the same shape the
fork's went through, and this is the first time this repository has named
itself there. Build it on a Mac before believing it.
:composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean;
:composeApp:jvmTest 736 tests, 0 failures; :composeApp:testDebugUnitTest 403
tests, 0 failures; :composeApp:m3Audit all budgets met.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b796a32be3 |
docs: plan the pull of Curated's work back into Mantra, and the tool that makes it mechanical
Curated (remote `curated`, curated/curated-kmp) forked from this repository at |
||
|
|
39fb64b6c0 |
refactor: take nostrPublicKey() from the library, and pin it against NIP-06
The app has carried its own `LocalKeyManager.nostrPublicKey()` in WalletManagerExtension.kt since before the library's returned anything usable: the library's gave back `publicKey().toHex()`, the 33-byte compressed encoding, so the app went through quartz -- `KeyPair(privKey).pubKey.toHexKey()` -- to get the x-only key that relays, events and npubs actually carry. As of lightning-kmp-app 84cc44c the library's returns the x-only key too, and the app's copy is a second implementation of the one value that is every profile's identity. This deletes it and switches its three callers -- RelaysSocketManager, NavigationViewModel, CreateProfileViewModel -- to `fr.acinq.phoenix.managers.nostrPublicKey`. **The two were proved equal before anything was deleted, not read to be.** They go through different stacks (quartz's `KeyPair` against bitcoin-kmp's `xOnly()`), and a difference between them would not fail a build or a test: it would publish every existing profile under a new key on the next launch, with nothing on screen to say so. So the first form of the test in this commit built one `LocalKeyManager` from NIP-06's mnemonic exactly as CreateProfileViewModel does -- `NodeParamsManager.chain`, `NodeParamsManager.remoteSwapInXpub` -- and called both functions on it, the library's imported under an alias because the names collide. Equal on mainnet, which is what the app ships on, and equal on Testnet3, where the library derives from account 1' instead of 0'. Only then did the app's go. **What remains is a pin from the consuming side, anchored to NIP-06 rather than to a captured output.** A value copied out of a passing run pins the code to itself; NIP-06 publishes two test vectors -- a twelve-word and a twenty-four-word mnemonic, empty passphrase, path m/44'/1237'/0'/0/0 -- and that path is the library's mainnet path exactly, so on the chain the app ships on the answer is not ours to choose. Both vectors are asserted, and the test also asserts that `NodeParamsManager.chain` is Mainnet, so a chain change surfaces here as the moment the published answers stop applying rather than as two mysteriously failing assertions. **Quartz stays in the test, as an oracle rather than as an implementation.** The app signs events with quartz from `nostrPrivateKey()`, and the key it publishes has to be the one quartz derives for the secret it signs with; that agreement was the property the deleted copy embodied by construction. It is now stated once, on mainnet and on Testnet3 -- alongside the shape check, sixty-four lowercase hex characters, which is the line a revert to the compressed form (sixty-six) would trip. Off mainnet there is no published vector for account 1', so shape and quartz-agreement are all that is asserted there. **The test lives in `managers`, not `extensions`.** Its first form sat beside the function it was checking, in `press.mantra.compose.extensions`. With that function gone, a test in that package would be about an extension the app no longer has; `press.mantra.compose.managers` mirrors `fr.acinq.phoenix.managers`, the library package it pins, and is where the app's other key-material tests sit. The import in each caller is placed inside the file's sorted `fr.acinq.phoenix` block rather than where the old `press.mantra` line stood. :composeApp:compileDebugKotlinAndroid is clean; :composeApp:jvmTest is 736 tests, 0 failures -- the 733 before this change plus the three here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f4434ab15d |
build: advance lightning-kmp-app onto the nostr credentials file
Moves the pin from 01489b8 to 84cc44c, three commits on the submodule's master:
two features and the merge between them. The submodule's own messages call this
the library half of the app's nsec/npub sign-in plan; the app half is not in
this commit, which changes no composeApp source.
The first feature gives a nostr key with no seed above it somewhere to live. A
key pasted in as an nsec cannot go in seed.dat, whose reader runs
MnemonicCode.toSeed over every entry and whose format is inherited from
upstream, so it gets a sibling file, nostr-keys.dat, under the same
KEY_NO_AUTH: one version byte, a 16-byte iv, and the ciphertext of a JSON map
from x-only public key to private key. Every secret is re-derived on read and
must land on its own key, so a corrupt file is refused rather than handed out
under the wrong identity. The temp-file / read-back / atomicMove discipline
SeedManager always had is lifted into AtomicFileWrite so the new file gets it
by calling rather than by copying; SeedManager.writeSeedToDir now goes through
it with its original check and its original exception type, and the three
public entry points the app uses -- loadAndDecrypt, loadAndDecryptOrNull,
writeSeedToDisk -- are unchanged.
The second replaces that file's shape with a typed one before anything has
written it in anger. nostr-credentials.dat holds one entry per x-only public
key, each either {"type":"secret","privateKey":hex} or {"type":"public"}, so a
profile signed in read-only and the same profile with its nsec pasted later are
one entry in one file, and the second is a single write over the first. It is a
new file rather than version 2 of the old one because an older build's identity
listing returns on an unreadable version before it publishes anything, and
would list no wallets at all, seed wallets included; a file it does not look
for cannot do that to it. NostrCredentialManager.migrateFromNostrKeys reads the
old file once, writes the new one and deletes the old, and runs only while the
new file is absent. NostrKeyManager becomes LegacyNostrKeysFile, read-only, for
that one caller.
Also in WalletManager.kt: PrivateKey.nostrPublicKeyHex(), the x-only form, and
LocalKeyManager.nostrPublicKey() now returns it. That function used to return
publicKey().toHex(), the 33-byte compressed encoding, which is not a nostr
public key at all -- nothing in the library called it, and the app has been
carrying an extension of its own with the same name instead. Retiring that copy
is the next commit, not this one.
The nested experimental/lightning-kmp gitlink does not move: it stays at
5103b79, so the lightning-kmp -> bitcoin-kmp -> secp256k1-kmp chain under the
composite is exactly what it was, and nothing native is rebuilt.
Verified in the composite: :composeApp:compileDebugKotlinAndroid is clean,
:composeApp:jvmTest is 733 tests, 0 failures, and
:lightning-kmp-app:library:jvmTest is 159 tests, 0 failures, 3 skipped -- up
from 128 by exactly the 31 tests in the four new classes
(EncryptedNostrKeysTest, EncryptedNostrCredentialsTest,
LegacyNostrKeysFileJvmTest, NostrCredentialManagerJvmTest), the skips still
being the @Ignore'd ElectrumServersTest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ba26c0b12a |
Update version name and gitignore
|
||
|
|
0be31803f2 |
docs: record phase 10, and the deferred decision it carries out
`docs/subgroups.md` was written as ten phases of reasoning kept in the order they were argued, and phase 4 spent forty lines on why the child's ceremony was *not* held in the parent's Marmot room -- explicitly so the decision would not be re-litigated without its price attached. That section is now a shopping list that has been carried out, so it keeps its argument and gains a pointer forward, and the three costs it enumerated are checked off one by one in a new phase 10. The parts of the note that state the old arrangement as present-tense fact are updated rather than annotated: the three-ceremonies table now reads one room, three ceremonies, two quorums, and says the thing that needs saying twice -- an MLS message reaches the whole tree, so a ceremony in the parent's room has to name who it is with. Phase 10 itself is written the way the others are, around what fails silently: - `DkgSession.chatRoomId` stopped identifying a ceremony, and the place that matters is `completedKey`'s last fallback, which every member welcomed after a group's own ceremony lands on; - `signingPath` had to admit a Marmot room, which widens the one function whose contract is that a path never comes off a proposal; - the p-tags had to stay on both transports, which is the opposite of what `FrostSigningManager` correctly does. The two sections that argued the old collision -- "The collision this buys" and "Why a subgroup cannot be the whole group was withdrawn" -- keep their reasoning and gain the end of it: `(room, parent)` stopped telling two subgroups of one parent apart, so the lookup moved to `(room, parent, admins)`, and the permanent half of the refusal disappeared with the derived room. The limitation and the appendix entry are struck through rather than deleted, since what they were weighing is why the phase exists. `docs/shared-key-ceremony.md` no longer says a ceremony runs over a NIP-17 chat. The participant set is the proposal's p-tags on both transports, and that distinction is the whole reason it is stated that way rather than as "the group". `docs/mls-skipped-keys.md` keeps `proposeRitual` in its table of reliable triggers and now says what changed about it: it reached that table on gift wraps, where the bug does not apply, and a subgroup's ceremony now rides group events. It is the entry with the worst consequence -- a ChillDKG cannot finish until every participant takes part, so one lost round-1 message stalls it permanently for everybody rather than costing one member a line of chat. That is the thing the quartz fix in that note is now load-bearing for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
820e83e181 |
test(subgroups): the ceremony in somebody else's room, and the four ways it lies
Six cases, and the ones worth writing are the ones where reading the room instead of the ceremony produces a plausible wrong answer rather than a crash. **The transport, and the p-tags on it.** The proposal is a `MarmotInnerEvent` and there are no gift wraps at all, and it p-tags the picked admins and nobody else. Both halves: the transport is the change, and the p-tags are what keeps the change from being a disaster, because dropping them the way `FrostSigningManager` correctly does in a Marmot room would enrol the whole parent in the child's permanent signing quorum. **The name.** On the proposal, on the session, once -- and the room keeps its own. There is nowhere else for a child's name to live now, and the parent's is the one name it must not take. **A parent member who was not picked drops the proposal**, opens no session, and publishes nothing. This is the property that makes the parent's room a safe place to hold a ceremony a subset of it is in: everyone can read the message, only the p-tagged are in the ceremony, and "can read" and "is in" have to stay different questions when the second one is permanent. **A picked admin joins, and not before approving.** The approval gate is unchanged by the move and has to stay that way -- a relay delivering a group event to a phone in a pocket must not enrol its owner in anything -- so this asserts nothing goes out until `approve`, then that what goes out is an inner event carrying the roster minus its own sender. **A host key that beat the proposal is replayed**, out of the inner-event store. The resume machinery is what makes the ritual safe to run anywhere, and it reads the backlog out of whichever store the room's transport writes to; looking in the wrong one is a stall with nothing to blame it on. **`completedKey` refuses to hand the parent its child's key.** The regression test for `ba8aa1e2`: a parent member with no key state and no share -- the position every member welcomed after the group's own ceremony is in, and the one that reaches the last fallback -- with a completed subgroup ceremony sitting in the room. Before the fix that fallback returned the child's key and the parent would have authored events as its own subgroup. It also asserts the child's *own* room still resolves it, by rederiving the id from the key, which is the check that actually binds a room to a key. Two databases, and the MLS group in each is real but not joint: messages are handed to the manager rather than encrypted between two trees. That is the right seam here -- what is under test is which store a message is queued in and which set it names, not whether quartz can encrypt it -- and `SignedGroupKeyStateTest` is where a session runs end to end. 1136 tests pass, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5110687413 |
feat(subgroups): run the child's ceremony in the parent's room, not beside it
The switch. A subgroup's ChillDKG and its key state now happen in the parent's Marmot room; the sibling NIP-17 room derived from the child's admins is gone, and so is the rule that one admin set could hold one subgroup forever. This is what `docs/subgroups.md` deferred in "Why not the parent's Marmot room" and said to revisit. The reason to wait was `docs/mls-skipped-keys.md` -- a DKG cannot finish until every participant takes part, so one group event lost to the skipped-keys bug stalls it for everybody -- and that is being fixed. The two transports and the two guards this needs landed in the three commits before it. **`SelectSubgroupAdminsViewModel` stops standing up a room.** It opens the ritual in `loaded.parentRoom`, p-tagged to the picked admins, and hands back to the parent's transcript. That transcript is where the other admins were going to answer from anyway -- `observeCeremoniesAwaitingYou` and `observeProposalsAwaitingYou` are room-scoped and were already looking there -- so the coordinator now lands in the same place as everybody else rather than in a room only they knew was coming. The name and the admin set ride on the proposal and land on the session, which is what `1e7ddd84`'s two columns are for. `MarmotGroupName.of` still puts the `#` on at the two sites that use the name, and is still idempotent, so the certificate the parent signs and the room `MarmotGroupCreation` creates are called the same thing. **`SubgroupManager.ceremonyRoomIdFor` is deleted and `refuseCeremonyRoom` becomes `refuseSubgroup`.** There is no ceremony room to derive or to name a refusal after. The refusal it made -- "this group already has a subgroup run by exactly these members", forever -- narrows to a ceremony over those admins under that parent that is still *running*, on `getLatestSubgroupSessionFor`. That refusal is worth keeping for a reason the old one did not have. It is not that two subgroups over the same people are forbidden; it is that nobody can answer for two live ceremonies at once. Every admin would be asked twice, on two ladders, for two keys, one of which nobody will make a room from. A *finished* one means that subgroup exists, and asking for another is a legitimate ask that the derived room made impossible. **Three reads move from the room to the ceremony**, all of them in the ritual screen, and each would have been silently wrong in the parent's room: - `proposeBirthCertificate`'s `adminPublicKeys` -- the room's roster would ask the parent to certify itself as its own child; - `createAdminGroup`'s `members` -- it would welcome the whole parent into the subgroup; - the name both of those carry -- it would be the parent's. `DkgRitualUIState.ritualMembers` follows, so the progress ladder draws the ceremony's participants rather than a row per parent member, and does not report a ceremony waiting on people it was never with. Profiles still come off the room, which for a subgroup is the parent and holds every admin the ceremony can have. **Nothing new is offered on a Marmot room's detail screen.** The shared-key button is still gated on `mlsGroupState == null`, and the comment there now says why that is still right: offering a ceremony to a room is offering it a key of its own, and a Marmot room's id is derived from a key it already has. Its subgroups' ceremonies are reached from the transcript line that names the one they are about, which is the only thing that can say which. `SubgroupManagerJvmTest` follows the refusal. The two tests built on `ceremonyRoomIdFor` become tests of what actually distinguishes ceremonies now -- another subgroup's and the room's own do not block one, a live one over the same admins does, a finished one does not -- and the ordering test moves to `DkgSession.formatParticipants`, which is where "the same set however it was assembled" now has to hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba8aa1e220 |
fix(subgroups): stop a parent resolving its own child's key, and let a child sign
Two guards in `FrostSigningManager` that were correct only while every ceremony ran in a room of its own. A parent's Marmot room is about to host its subgroups' ceremonies, and both of these read a ceremony's room as though that could only mean one thing. Neither fails loudly. **`completedKey`'s last fallback is "a ceremony held in this very room", and that stops being the room's own key.** The fallback is not decoration: a room's `GroupKeyState` is signed before the room exists and filed as it is created, so every member welcomed after that -- an invite, a reinstall -- has a room and no state, and lands here. The parent's room will hold a *completed* ceremony whose threshold key belongs to the child, so those members would resolve the child's key for the parent and the group would author events as its own subgroup, with a valid signature and nothing on screen to say so. The certificate a subgroup is born with is exactly one of those events. `getLatestOwnSessionForChatRoom` is the same query with `parentChatRoomId IS NULL`. A ceremony run to make a subgroup is never the room's own key, and that column is all that has to be read to know it. **`signingPath` has one case it cannot self-check, and there are now two rooms in it.** Everywhere else a candidate path is right exactly when walking it reaches the room, which makes the function self-checking rather than trusting -- and the path decides what key the group signs as, so it must never come off a proposal. The exception is the room a ceremony ran in, signing the statement that lets the room the ceremony's key derives be created. That room is not derived from the key at all, so nothing rederives. It used to mean "a NIP-17 room whose ceremony is its own", gated on `mlsGroupState == null`. It now also means "the parent's room, where the ceremony claims that parent" -- without which a subgroup's key state would be signed as the bare threshold key, an identity no room answers to. `key.parentChatRoomId` is an unverified claim off a proposal and admitting it here grants nothing. The signature it enables is by the *child's* key over the *child's* own id, both derived from a ceremony every signer contributed to and approved twice. A member who put a false parent on a proposal ends up with a key state for a room made from a key they helped make, which is what telling the truth would have got them. Both inputs are still read from this device's own database, so a proposer chooses nothing: naming some other ceremony this device holds a share for gets no path, and a session with no path signs as the threshold key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1c0acd546a |
feat(subgroups): put the key ceremony on both transports, p-tags and all
`ChillDkgRitualManager` ran on gift wraps only. It now runs on whichever transport the room it is in has -- a gift-wrapped rumor in a NIP-17 room, an MLS application message in a Marmot one -- which is the shape `FrostSigningManager` has had since `113eda9f`, and for the same underlying reason: a group has to say something before it owns the room the saying is about. Nothing is wired to the new arm yet. A group's own ceremony still runs in its NIP-17 room and a subgroup's still runs in the sibling room derived from its admins; what changes here is that the manager stops assuming which. **`broadcast` reads the room and writes to the matching store.** MLS state present is what marks a room Marmot -- the same reading `sendChatMessage` makes when it chooses between a group event and gift wraps. `replayStoredMessages` has to make that reading again, because a ceremony's backlog is in whichever store its messages were queued in and looking in the wrong one is a stall with nothing to blame it on. **The p-tags stay on both, unlike `FrostSigningManager`'s, and this is the one thing here that would be a disaster to get wrong.** There a p-tag is an address and a group event needs none, because the message is encrypted to the whole tree and the signer set comes from the ceremony's host keys either way. Here the p-tag set *is* the participant set: `acceptProposal` builds `n` out of it on every device and `n` is hashed into the session identity, so a Marmot proposal without them leaves every receiver unable to say what they were invited to. In a room whose membership is wider than the ceremony they are also the only thing marking who is in it, and a member who publishes a host key is in that group's signing quorum for good. Inside MLS encryption, naming them leaks nothing. They are also read off the *session* rather than off the room now, via `participantsOf`, and sorted. A room whose membership is wider than the ceremony is exactly the case this is for. **`processRitualPayload` takes an `Event`.** The rumor as its sender wrote it, which is the shape both transports hand over -- `NostrDao` already rebuilt one from a decrypted gift wrap for the FROST arm and now does the same here. `sig` is empty on both and nothing reads it: what vouches for the author is the seal or the MLS frame, not a signature on the payload. `record` and `isFromCoordinator` follow. **`proposeRitual` grows two parameters and splits its guard.** `participantPublicKeys` defaults to the room's members, which is the whole story where the room is the participant set; `subject` defaults to the room's name, but only for a ceremony with no parent -- falling back there would name a child after its parent. The guard now asks two different questions. A ceremony with no parent is scoped by room as before. One with a parent is matched on `(room, parent, admins)` and folds only into a ceremony that is still *running*: a completed one means that subgroup was made, and a group is entitled to a second run by the same people. That was impossible while the ceremony room was derived from its admins -- asking again handed back the first ceremony's key, so the "new" subgroup was the old one under a new name. A new `require`, because the old invariant stops holding for free: everyone in a ceremony has to be able to hear it. A gift wrap is sealed per p-tag so this was true by construction; a group event reaches the MLS tree and nobody else, so a participant outside it is an `n` that can never be met. Placed behind the duplicate guard, so a running ceremony is still handed back whatever the room's rows have since done. **`NostrDao` dispatches the DKG kinds on the Marmot arm** beside the FROST ones it already did, and `ChatMessage.applyInnerEvent` returns null for them the way it does for `FrostSigningEvents.ALL` -- the manager writes the transcript of a ceremony itself, and an "unsupported" row would be a second, worse account of it. A member of the room who was not p-tagged never reaches either: their `acceptProposal` drops the proposal and no session is opened. **A ceremony with a parent says so in the transcript.** The line lands in a room that already has a key, and "started a shared key ceremony. It will take 2 of 3 members to sign with the key" reads there as though *this* group were getting a new one. It is not -- the quorum quoted is the child's, over the child's admins -- and that is the sort of misreading that gets somebody to approve a step they did not follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1e7ddd844a |
feat(subgroups): schema 19 -- the two things a ceremony's own room used to answer
A subgroup's ChillDKG is about to move out of the sibling NIP-17 room derived from its admins and into the parent's Marmot room. That room answers two questions the sibling room answered for free, and it answers both of them wrong. **Who the ceremony is with.** The sibling room's membership *was* the participant set -- it was derived from it -- so `memberPublicKeys(localChatRoom)` was the admin list, and three callers read it that way: `broadcast`'s p-tags, the birth certificate's `adminPublicKeys`, and the members `MarmotGroupCreation` welcomes into the child. The parent's room is a superset, so each of those would silently name every person the subgroup is *not*. `participantPublicKeys` is the set the ceremony was opened on, sorted and comma-joined the way `publicShares` already stores a list. **What the room it makes is called.** The sibling room carried the subgroup's name as its subject, put there by the coordinator and delivered to everyone else by `getOrCreateNip17ChatRoom` reading the proposal's `subject` tag. A ceremony in the parent's room has no room of its own to be named, and the parent's name is the one name a child must not take -- `proposeBirthCertificate` normalises this string into the certificate the parent's quorum signs, and `MarmotGroupCreation` normalises it again onto the room. `subject` is where it lands instead. Both are read off the proposal, which has carried both since `7bccf243` -- the p-tags and the `subject` tag. Nothing new goes on the wire; what changes is where it is kept. Both are nullable and both fall back to the room, which is correct for every row written before this: a ceremony in its own NIP-17 room ran over exactly that room's members and was named after it. **Sorted, because two devices assemble the set differently.** The coordinator builds it from a picker; every other device builds it from the proposal's p-tags plus the sender. `DkgSession.formatParticipants` sorts so those are the same string, which is what makes it something a query can match on. Two queries, both of which exist because `DkgSession.chatRoomId` is about to stop identifying a ceremony: `getLatestSubgroupSessionFor(room, parent, admins)` is what "may I open another" means for a subgroup once the parent's room hosts every one the group ever runs. `getLatestSessionFor(room, parent)` cannot answer it -- two subgroups of one parent share both columns -- and scoping on the room alone would refuse a group's second subgroup on the strength of its first. `getLatestOwnSessionForChatRoom(room)` is `getLatestSessionForChatRoom` with `parentChatRoomId IS NULL`. It exists for `FrostSigningManager.completedKey`'s last fallback, which is the one every parent member welcomed after the group's own ceremony lands on: the parent's room will hold a *completed* ceremony whose key is the child's, and a ceremony run to make a subgroup is never the room's own key. The claim is unverified, but the only direction it can be abused in is a member excluding a ceremony they themselves proposed, whose key they would simply not have proposed. `AutoMigration(18, 19)`: two nullable columns, a shape Room migrates itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
45cc80b538 |
feat(marmot): put a # in front of every group's name, and retire (#admins)
A device's room list holds two unrelated kinds of room and nothing on a row said
which. A NIP-17 room is a conversation between the people in it. A Marmot room is
a *group* -- an id its key derives, a membership baked into an MLS tree, admins
who can act for it, a signature anyone holding the id can check -- and the two
behave differently enough that guessing is a mistake.
`"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half.
Only the admin room got it; a subgroup got no marker at all, so as soon as a group
had one child, half the Marmot rooms on the device were unmarked. It also sorted
nowhere near the group it belonged to, and a truncated row drops a trailing suffix
first -- so the marker was missing exactly where the list is crowded enough to
need it.
**The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than
where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every
member is welcomed with, so a `#` added at display time would be a name this
device alone could see. `#Ekklesia` marks both kinds of group room, and marks them
at the front.
**Three mints, because there are three ways a Marmot room comes into existence.**
`MarmotGroupCreation.create` is the funnel for two of them -- the admin room a
group opens after its ceremony, and a subgroup -- and normalising there means
neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s
convenient room, has a random id rather than a derived one, so it has no key state
to adopt and no admin set to bake in and does not pass through that funnel; it
applies the rule itself.
**Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice
from the same bare ceremony-room subject, by two callers that never see each
other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's
quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room
carries. Those two have to be the same string, or the subgroup is not called what
its parent certified -- and a certificate is a signature over the name, so a
verifier comparing them would see a real mismatch. `of` being idempotent is what
makes them agree by construction rather than by both sites being kept in step.
**The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup
is made, not the subgroup -- and prefixing it too produced two identically-named
rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits
beside `#Translators` (the group it stood up), which is the distinction the `#`
exists to draw. Its subject is trimmed, so the bare name and the two normalised
ones cannot differ by whitespace.
**The `#` is drawn beside the name field, not pushed into its state.** `name` in
`SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the
convention, because normalising on every keystroke moves the caret out from under
somebody halfway through a word. The coordinator still reads the name they are
about to get.
Four strings lose the old name -- "Create the #admins group" becomes "Create the
admin room", and the three about what "the #admins room" will sign with now say
"the admin room". Their keys are renamed with them, since the keys in this
catalogue are derived from the text. Around twenty comments, two screen previews
and seven test fixtures follow.
Docs: the ceremony note states the convention and what it replaces, and the
subgroups note's name-field section is rewritten -- it had been arguing from the
`"${parent.subject} (#admins)"` synthesis that no longer exists.
`docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured
debugging log, and rewriting it would falsify a record.
Three tests. `MarmotGroupNameTest` pins the rule, idempotence included.
`MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both
the room row this device draws and the group data every other member reads.
`SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed
event's tags back out of the signing session: the name the parent is asked to sign
is the name `MarmotGroupCreation` will give the room. That last one needed the
signable-parent fixture to seed host keys, since a ceremony's signer ids are
derived from them rather than stored.
**Rooms that already exist keep their names.** The name lives in the epoch-0 group
context, so renaming one is an MLS commit every member has to process -- a
different change from a naming convention, and not made here.
403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case
strings and 0 dp literals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5c893ab108 |
feat(subgroups): put the ceremony that needs you at the bottom of the chat
A NIP-17 room already showed the standing "waiting for your signature" notice under the newest message -- `ProposalsAwaitingYouNotice` is transport-agnostic and reads `FrostSigningSession` by room. What it never covered is the other thing a room can owe somebody, which in a NIP-17 room is the main thing: a ceremony. That gap matters more here than the signing one does. A ChillDKG cannot finish until **every** member has taken part, so one member not finding their request stalls everyone indefinitely -- and the only way to find it was to scroll the transcript to its request line, past whatever else the room has been used for. Three subgroups on the connected devices sat at 1 of 3 host keys for exactly that reason. `CeremoniesAwaitingYouNotice` sits beside the signing one, first in the reversed layout so a room owing both puts the ceremony nearest the composer -- until a ceremony finishes there is no key to sign anything with. **It covers two different kinds of owing, and the second has no gate behind it.** A participant is owed an approval, read through the same `pendingApproval` the ritual screen uses so the two cannot disagree. The member who *opened* it is owed something the protocol has no gate for: a ceremony reaching COMPLETE finishes nothing on its own -- the group has a key and somebody still has to get its state signed and create the room -- and that somebody is whoever opened it. Nothing else in the app would ever say so, which is what the coordinator was missing. `roomAwaitingCreation` is how it knows when to stop: the room a finished ceremony's key derives either exists or does not. Asking that rather than keeping a flag means the notice cannot become permanent furniture in every room that has ever held a ceremony. **It reads every ceremony in the room, not the newest.** A room holds more than one the moment a subgroup's admins are the whole group, and the one that wants you is routinely not the one that happened last -- that is what buried 2.0 and 2.1. The notice opens the ceremony it names, by session id, through the same `onOpenSharedKey` the transcript's own lines use since `0b65d702`. Unlike the signing notice it opens the ceremony rather than a queue: there is no queue of ceremonies, and with several the count is shown and the newest opened. Eight strings in the catalogue in sentence case, each step worded the way its approval screen words it so a member is not asked twice in two vocabularies. `dkgRepository` is threaded to `ChatMessageListViewModel` through the messaging screen, the home pane and the nav host. 397 common tests, 718 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a6bb811b03 |
fix(subgroups): land the coordinator in the transcript, not on a screen offering another ceremony
Pressing "Start the key ceremony" in the picker opened the ceremony and then navigated to the shared-key screen -- whose whole job is to *offer* a ceremony to a room that has none. So the coordinator arrived at a "Start key ceremony" button seconds after starting one, and pressing it opened a second. The second one is not a duplicate as far as the protocol is concerned, which is why nothing refused it. `proposeRitual`'s guard is scoped by purpose, deliberately: a room has to be able to hold the group's own ceremony and a subgroup's, since a subgroup whose admins are the whole group runs in the room the group's own ceremony ran in. A room holding a subgroup's ceremony will therefore take one for *no* subgroup without complaint -- and then "the room's newest ceremony" is the new empty one, and the subgroup's is buried under it. That is what happened to 2.0 and 2.1. **The coordinator goes to the transcript.** The ceremony is already open and its first line is already in that room; what they need is to watch it and answer the requests as they arrive, through the same ritual notices every other member uses -- which since `0b65d702` name their own ceremony and so open the right one. The shared-key screen has nothing to offer a room that already has a ceremony, and should not have been the destination. **And the screen stops offering one.** `canStartRitual` asked only about the ceremony being *shown*; it now also asks whether the room holds any that has not failed, which is a different question and the one that matters here. `DkgRitualUIState.roomHasCeremony` is observed separately for exactly that reason. **The route's `parentChatRoomId` goes.** Nothing passed it any more, and it was the shape of two of these bugs: only the member who opened a subgroup could ever fill it in, so a route that claimed to know the purpose was right for one caller and silently wrong for everybody else. The purpose is read off `DkgSession.parentChatRoomId` and nowhere else now, which makes that rule structural rather than conventional. One test in `RobustRoomKeyCeremonyTest` pinning the thing that is *not* a bug: a room holding a subgroup's ceremony still accepts one for another purpose, because two purposes are two ceremonies -- so the refusal has to live in what the UI offers, not in the guard. 397 common tests, 718 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
31e6fc6425 |
fix(subgroups): ask whether the key state is signed by ceremony, not by room
Subgroups "2.0" and "2.1" on the connected devices had finished everything. A
ChillDKG COMPLETE for each, a birth certificate signed by the parent's quorum for
each, a key state signed by their own quorum for each -- all four events sitting in
the coordinator's database. The coordinator was offered no way to create the room.
`observeSignedGroupKeyState` resolved which key to ask about by looking the
ceremony up **from the room**:
getLatestSessionFor(chatRoomId, parentChatRoomId) // parent was null here
The view model calls it with no parent, so that reads "the ceremony in this room
that is *not* for a subgroup". A subgroup's ceremony room holds only the
subgroup's ceremony, so it matched nothing, derived no key, and reported "not
signed" over a state in the same database. The button is gated on that boolean, so
it never appeared.
This is the same mistake as `0b65d702` one layer down. That commit fixed the
*session* lookup by having the transcript name the ceremony; it left the key-state
and certificate lookups still re-deriving a ceremony from the room. A room does not
have one, and every lookup that assumes it does is wrong in a different way: by
room it finds none here, and by "newest in room" it would have found the wrong one
in the rooms from the previous report.
So both observers now take the ceremony they are about.
`observeSignedGroupKeyState(dkgSessionId)` derives the subject room from that
ceremony's own threshold key, and `observeBirthCertificate(dkgSessionId, parent)`
does the same. Neither can disagree with the ceremony the screen is showing,
because it is handed the same one.
In the view model they move into `observeForCeremony`, re-pointed when the
ceremony changes the way the message watcher already is -- previously the key-state
watcher was started once at init against a room, which is what let it drift from
the session on screen. `observeKeyState` keeps only the room-scoped watch of
signing sessions, which is genuinely a room question.
One test in `SignedGroupKeyStateTest`: a key state the group really signed is
reachable from the ceremony that produced it, and a ceremony with no key answers
null rather than throwing -- this flow runs from before a ceremony finishes.
397 common tests, 717 jvm tests, `m3Audit` meets every budget.
The two stuck subgroups will offer "create the subgroup" on the next build: their
key states are already signed and on file, and nothing about them has to be redone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0b65d7025b |
fix(subgroups): open the ceremony a transcript line is about, not the room's newest
Diagnosed off the four connected devices. Three subgroup ceremonies of "one (#admins)", all opened correctly by the coordinator, all sitting at COLLECTING_HOST_KEYS with 1 of 3 host keys in. Nothing was dropped: every participant device held the session, the ceremony room, its members and the "your approval is needed" line. What none of them could do was reach it. **A room does not have *a* ceremony, and every version of this screen has assumed it does.** A subgroup whose admins are the whole group runs in the room the group's own ceremony ran in -- that is the point of `1930d6aa`, and it is what the devices did. Ask that room for its ceremony and you get whichever was opened last. On the devices that was the group's own, already COMPLETE, in five of the six room/device pairs; the subgroup's request for a host key sat underneath it, unanswered, with the screen showing a finished ceremony and nothing to do. Both previous attempts were the same mistake: - unscoped "newest in room" -- picks the wrong ceremony whenever the other one is newer, which is what shipped and what the devices show; - scoped by purpose (`1930d6aa`) -- invisible to every member who did not open the subgroup, since only the coordinator's route carries a parent (`98626a9e`). Neither ordering can be right, because the question is wrong. **A line knows which ceremony it is about; the room does not.** So `ChatMessage` gains `dkgSessionId`, the pair to `frostSigningSessionId` and added for the same reason one-at-a-time stopped being true -- `docs/frost-batch-signing.md` reached this conclusion for signing sessions already, and the ceremony half was left on the clock because "a room runs one at a time". It doesn't any more. Every DKG line is stamped in `announce`, which all of them already funnel through. `DkgRitualRoute`, the three approval routes and their screens carry the id, the transcript's ritual notice passes the tapped line's, and `DkgRitualViewModel` observes that session when given one and the room's newest otherwise -- which is still the best a caller holding only a room can do, and is what the group's details entry passes. `ChatMessage.isAbout` uses it too, so `answeredRequests` stops crediting an answer given to one ceremony as an answer to the other. Rows written before the column read back null and fall back to the clock, which is correct for them: nothing that predates subgroups ran two ceremonies in one room. Schema 18, one nullable column, `AutoMigration(17, 18)`. One test in `RobustRoomKeyCeremonyTest`: with two ceremonies in one room, every line names one of them and a subgroup line resolves to the subgroup's ceremony. It deliberately does not assert which the room's "newest" is -- two ceremonies opened in the same second tie on `createdAt`, and the point is that nothing relies on that ordering any more. 397 common tests, 716 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |