add concord backend

This commit is contained in:
2026-09-15 07:56:09 +07:00
parent 8b0cbd294e
commit 871efa6b6f
9 changed files with 1325 additions and 12 deletions
+42 -7
View File
@@ -209,11 +209,12 @@ Decision: no test files are maintained for this feature. M1 and M2 were verified
The consequence to plan around is in §14 and risk 13.
### 5.5 Modified (6 files, all small)
### 5.5 Modified (7 files, all small)
| File | Change |
|---|---|
| `shared/.../nostr/Nostr.kt` | add `val concord`, 2 routing branches |
| `shared/.../nostr/Nostr.kt` | add `val concord`, `init(dbPath, storage)`, 2 routing branches |
| `composeApp/.../NostrForegroundService.kt` | pass `AppStore(this)` into `init` |
| `composeApp/.../MainActivity.kt` | `private val concordRepository by lazy { … }`, add to `App(...)` params |
| `composeApp/.../App.kt` | factory branch, `viewModel(...)`, 3 × `entry<…>`, snackbar collector |
| `composeApp/.../Navigation.kt` | `Screen.Communities`, `Screen.Community(id)`, `Screen.Channel(communityId, channelId)`, `Screen.JoinCommunity` |
@@ -702,13 +703,45 @@ Each milestone ends with something runnable.
### M3 — Join + read
`ConcordStore.kt`, `ConcordInvite.kt`, `ConcordControl.kt`, `ConcordManager` read path, `Nostr.kt` routing.
Delivered. `ConcordModels.kt`, `ConcordInvite.kt`, `ConcordControl.kt`, `ConcordStore.kt`, the `ConcordManager` read path, and the two routing branches in `Nostr.kt`.
**Done when:** pasting a real invite link stores the membership, connects the community's relays, folds metadata + channel list, and renders channel messages from a real community.
What shipped:
- `ConcordInvite.kt``parseInviteLink`, `decodeInviteFragment` (CORD-05 §3), the stock relay dictionary, `inviteBundleKey`, `decryptInviteBundle`, `problems()` validation, `relaySet`, `toMembership`
- `ConcordControl.kt``editionOf` + `fold`, with the `ep` chain walk, highest-version-wins and the lower-rumor-id tie-break
- `ConcordStore.kt` — memberships through `AppStorage.setSecret`, plane rumors through LMDB indexed on `d`/`r`/`k`
- `ConcordManager.kt` — plane index, `restore()`/`sync()`, `isPlaneAddress`, `handlePlaneEvent`, `onInboxRumor`, `previewInvite`, `join`, `channelMessages`
- `Nostr.kt` — the plane-author routing branch, the Concord queue, the Direct Invite interception, and `concord.attach(storage)` from `init`
**Verified** with a throwaway check on the iOS target (20 cases, all passing), then deleted:
- the fragment decodes for the stock flag, explicit dictionary ids, `wss://`-implied hosts and verbatim URLs; unknown ids, short tokens and a wrong version are refused; explicit entries cap at 3 while the stock flag yields the whole dictionary ✅
- a link round-trips through a real `naddr` (kind `33301`, empty identifier) ✅
- a valid bundle passes `problems()`; a tampered owner, a malformed salt and 257 channels are refused ✅
- `bundle_key` round-trips a bundle through NIP-44, and a different token cannot open it ✅
- `expires_at` converts ms→s and an absent expiry never expires ✅
- an intact Control chain folds to its highest version; a broken link truncates to the last good one; a missing predecessor disqualifies that version alone; same-version ties break on the lower rumor id; entities fold independently; metadata for another `community_id` is ignored; `deleted` is projected ✅
- `editionOf` parses a real rumor and refuses `vsk 10` and a missing `ev`
**One bug the check caught:** `inviteBundleKey` first used the 32-byte hex guard, but the token is 16 bytes — every link would have failed to open.
**Not covered by that check:** anything needing the network — fetching a bundle, the subscription, and publishing the Join. Those are the interop smoke test below.
### M3 interop smoke test — the acceptance gate
Against a real Community, with a real invite link:
1. paste the link → the preview shows the right name and channel count, and no `problems`
2. join → the membership persists across a restart, the community's relays connect, and the Control plane folds metadata + the channel list
3. **a message from another Concord client appears in Coop** — this is the one nothing else catches, since a label typo breaks interop silently
4. a message sent from Coop appears there too (once M4 lands)
If step 3 fails, check in this order: the `concord/channel` label, the `channel`/`epoch` binding tags, then `community_id` (risk 1).
### M4 — Write
`sendChannelMessage`, Guestbook `join` (`3306`, `content: "join"`), `unreadCount`.
`sendChannelMessage` and `unreadCount`. (The Guestbook `join` originally listed here shipped early, with M3's `join`.)
**Done when:** a message sent from Coop appears in another Concord client, and a message from that client appears in Coop.
@@ -741,6 +774,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
| 1 | **`community_id` owner proof.** CORD-05 §1 writes `sha256(owner ‖ salt)`. `examples.md` §6.1 writes `sha256("concord/community" ‖ owner ‖ salt)`. CORD-02 A.4 (marked *frozen*, normative) writes `sha256(utf8("concord/community") ‖ owner_xonly[32] ‖ owner_salt[32])`. | **Implement A.4.** It is a hard-fail interop check, so validate against a reference implementation at the first opportunity. |
| 2 | **`expires_at` units differ in three places** — bundle = unix **ms**, Invite List entry = unix **s**, NIP-40 tag = **s**. | Keep all conversions in one function. |
| 3 | **`vac` on `3303`.** CORD-06 §3 says a rotation cites its Grant, but neither its §1 JSONC nor `examples.md` shows the tag. | Deferring rekeys sidesteps this. |
| 4 | **What `concord/invite-key` yields.** CORD-05 §2 writes `bundle_key = hkdf(token, "concord/invite-key")` and then `nip44_encrypt(bundle_key, …)`, which reads as a raw conversation key. A.6 lists the label in the *derivation* registry, where every row is fed through `group_key``scalar_normalize` → keypair, and elsewhere CORD-01 always writes `conv_key` for the self-ECDH value it feeds `nip44_encrypt`. | **Modelled as `group_key("concord/invite-key", token, 0…0)`.** This is the only reading the existing SDK can implement — it exposes NIP-44 by keypair only, never by conversation key — and it is consistent with every other row in A.6. It is a hard-fail interop check: verify at the smoke test, and if a bundle refuses to open, the raw-conversation-key reading is the alternative and would need NIP-44 hand-rolled. |
### 13.2 Design risks
@@ -756,6 +790,7 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
| 11 | **Spec is young** (71 commits, no reference implementation in-repo, examples explicitly non-normative). | Keep every frozen constant in `ConcordKind.kt` so a spec revision is a one-file change. |
| 12 | **`scalar_normalize`'s retry branch is ~2⁻¹²⁸ rare.** It will never fire in practice, so a bug there would never surface either. | Split the pure `groupSeed` out of `groupKey` so the counter path is reachable through its `isValid` seam rather than buried behind a crypto call. |
| 13 | **The nostr SDK cannot run in a host JVM unit test.** Its uniffi bindings are JNA-backed and the Android artifact carries Android-ABI `.so` files, so `testDebugUnitTest` on macOS fails with `UnsatisfiedLinkError: libjnidispatch.jnilib`. Discovered in M1. | Not a problem while tests are not kept, but it does mean **there is no automated regression net** for anything that touches `SecretKey`, `Keys`, `nip44*` or `EventBuilder`. Verification is manual: `:shared:iosSimulatorArm64Test` is the only executable target here that can load the SDK, so a throwaway check in `shared/src/iosTest` is the cheapest way to exercise wire-format code, and the M3 interop smoke test is the real acceptance gate. Keep the SDK-free half in pure functions so it at least *could* be covered without a device. |
| 14 | **`Nostr` is a Context-free singleton, but Concord's keys need `AppStorage`.** Neither the construction site (`NostrManager.instance`) nor the class has a `Context`. | `Nostr.init(dbPath, storage)` takes it and hands it to `ConcordManager.attach`. The foreground service is the only caller and already runs before any notification is handled, so the ordering is guaranteed. A second `AppStorage` instance in the M5 repository is fine — both wrap the same DataStore. |
### 13.3 Open decision
@@ -765,10 +800,10 @@ Reactions (`kind 7`) and edits (`kind 3302`) reusing the existing DM reaction UI
## 14. Verification plan
**No test files are kept** (decision, M2). The checks below were written and run during M1 and M2,
**No test files are kept** (decision, M2). The checks below were written and run during M1M3,
then discarded; the tree carries no `commonTest` or `iosTest` sources for Concord. They are
recorded here because they are what establishes the wire format is right, and because whoever
next touches the crypto should re-run the same checks.
recorded here (and per-milestone in §12) because they are what establishes the wire format is
right, and because whoever next touches this code should re-run the same checks.
The problem this leaves is real and worth stating plainly: this is frozen-by-spec crypto with
**no spec test vectors** ("Examples are illustrative, not verifiable test vectors"), where a