diff --git a/quartz/plans/2026-09-17-cordn-interop.md b/quartz/plans/2026-09-17-cordn-interop.md index 17f550bce2..184779bcf7 100644 --- a/quartz/plans/2026-09-17-cordn-interop.md +++ b/quartz/plans/2026-09-17-cordn-interop.md @@ -1,12 +1,14 @@ # Cordn interop: extract the MLS core, then add a second binding -Status: Stages 1, 2 and the core of 3 landed. The RFC 9420 engine is `quartz/…/mls/` and imports +Status: Stages 1, 2, the core of 3 and the wire half of Stage 0 landed. The RFC 9420 engine is `quartz/…/mls/` and imports nothing from `marmot/` — a binding supplies its rules through `MlsGroupPolicy`. `quartz/…/contextvm/` implements the core spec plus all 12 CEPs on the client side, with the Tier C fixture server; `quartz/…/cordn/` implements the MLS profile, the eleven coordinator tools, the seal, envelopes, -group refs and the sync rules, verified against ts-mls in both directions. Stage 0 (cordn-side vectors) and Stage 4 (app integration) -are open. §4.1 turned out not to gate the binding — see Stage 3 — but remains a real -incompatibility between the two ecosystems. +group refs and the sync rules, verified against ts-mls in both directions **and against cordn's +own wire contracts** (`quartz/tools/cordn-vector-gen` → `resources/cordn/`). Stage 4 (app +integration) is open, as is Tier B — which turns out to be blocked on licensing, not on +tooling (§7). §4.1 turned out not to gate the binding — see Stage 3 — and is now settled on our +side by implementing both encodings rather than waiting for an agreement (§4.1). Correction to an earlier gate in this plan: §4.1 does **not** block Stage 2. ContextVM is credential-agnostic and has no MLS dependency at all, so the transport was safe to build first; @@ -25,11 +27,14 @@ Sources checked on 2026-09-17: - `ContextVM/sdk` @ `b5d1e4e` (2026-09-17), version `0.13.17` — **LGPL-3.0**, see §7. Read only to confirm deployed defaults, never as an implementation source -Verification status: `:quartz:jvmTest` passes in a container at 5330 tests, covering the MLS -engine, ContextVM and cordn, so the interop claims in §3 are execution-verified rather than -read-verified. `:quartz:testAndroidHostTest` passes too, apart from four pre-existing failures in -`NostrServerTest` and `LiveNegentropyIndexStoreTest` that predate this work (confirmed by running -them at the parent commit). +Verification status: `:quartz:jvmTest` passes in a container, covering the MLS engine, ContextVM +and cordn, so the interop claims in §3 are execution-verified rather than read-verified. +`:quartz:testAndroidHostTest` passes as well — the four failures this plan previously recorded as +pre-existing (`NostrServerTest`, `LiveNegentropyIndexStoreTest`) were fixed on 2026-09-18: the +event store classified insert failures by parsing the SQLite driver's exception message, and +Android's carries none. Note the source-set shape when reading test counts: `jvmTest` dependsOn +`jvmAndroidTest`, but `androidHostTest` does **not**, so the cordn and ContextVM suites under +`jvmAndroidTest/` run on the JVM target only. ## 1. Executive summary @@ -139,8 +144,22 @@ One KeyPackage cannot satisfy both. This is exactly what `spec/00.md` §13 requi implementations to agree on, and the two ecosystems picked differently. It is a one-line change on either side today and unfixable once either has deployed users at scale. -**Action:** raise with gzuuus before Stage 2. Either side moving is fine; what matters is that -one does. +**Decision (2026-09-18): support both encodings; do not wait for an agreement.** Neither +ecosystem is expected to move, and neither needs to. The credential encoding is a *binding* +choice, and since Stage 1 the engine no longer has an opinion about it — `MlsGroupPolicy` picks +the profile, `MarmotCapabilities`/`CordnCredential` supply the encoding. Marmot KeyPackages carry +the raw 32 bytes; cordn KeyPackages carry the 64 ASCII hex bytes; both are produced and read by +the same engine, and neither can be mistaken for the other (32 raw bytes is not valid 64-char +ASCII hex, so `CordnCredential.identityOrNull` and `KeyPackageUtils`'s 32-byte check are +mutually exclusive by construction). + +What this costs is §4.4: a member advertising only one profile's capabilities cannot be added to +the other's groups, so "one MLS group, both clients" stays out of reach. That was already a +deliberate profile decision rather than a consequence of this one. What it buys is that Amethyst +speaks both today rather than blocking on a conversation neither side has an incentive to finish. + +Still worth raising with gzuuus as a fact rather than a request — an implementation that reads +both is useful evidence for whichever encoding a future joint profile picks. ### 4.2 No key-package event kind @@ -545,8 +564,38 @@ dependency rule in `.claude/CLAUDE.md` this means: - If a Kotlin/JVM ContextVM library ever appears under LGPL, linking it is a WARN-and-call-out (call it out in the PR description), not an automatic stop. -Everything under `Cordn-msg` is MIT, so the specs and the reference coordinator are safe to read -and to implement against. +🔴 **Correction (2026-09-18, verified): "everything under `Cordn-msg` is MIT" was wrong.** Only +two of the five packages are licensed at all. Checked against the actual files at `b465df0` and +against the published npm tarballs: + +| Package | `LICENSE` file | `license` field | Published to npm | +| ------- | -------------- | --------------- | ---------------- | +| `packages/core` | yes | MIT | `@cordn/core` | +| `packages/cli` | yes | MIT | `@cordn/cli` | +| `packages/coordinator` | **no** | **none** | no | +| `packages/server` | **no** | **none** | no | +| `packages/test-utils` | **no** | **none** | no | + +The repository root has no LICENSE either. So the **reference coordinator is unlicensed** — +default copyright, all rights reserved — and so is the `ghcr.io/cordn-msg/cordn` image built from +it. Almost certainly an oversight rather than intent, given the two licensed siblings, but the +rule in `.claude/CLAUDE.md` does not have an "obviously meant to be MIT" branch. + +Consequences, and they are the reason Stage 0 landed the way it did: + +- **Tier B is blocked, not merely awkward.** The plan's Tier B (run their coordinator locally with + `CORDN_STORAGE_BACKEND=memory`) rests entirely on unlicensed code. Not a shipping dependency, + but it would become a documented, committed part of our test process, which is exactly what the + dependency rule exists to stop happening quietly. Docker being unavailable in the build + container is the lesser problem. +- **`@cordn/core` is fine and is enough for the valuable half.** It is MIT, it ships a LICENSE, + and it holds the authoritative zod contracts for all eleven tools plus the group-ref bech32 + codec — i.e. the wire surface. That is what `quartz/tools/cordn-vector-gen` uses. +- **Ask upstream for a LICENSE on the remaining packages.** Cheap for them, and it is what + unblocks Tier B. Worth raising alongside §4.2. + +The specs themselves (`spec/`, `design/`) are also unlicensed, so the existing rule stands: we +implement from them, we do not paste their prose into KDoc. ## 8. What the coordinator can see @@ -623,9 +672,38 @@ looking like a group chat. ## 9. Plan -### Stage 0 — Ground truth and the upstream conversation +### Stage 0 — Ground truth and the upstream conversation — PARTLY LANDED -No production code. Two deliverables. +The wire half landed on 2026-09-18; the live-coordinator half is blocked on §7. + +**Landed: contract vectors from cordn's own code.** `quartz/tools/cordn-vector-gen` generates +`resources/cordn/coordinator-contracts.json` from **`@cordn/core`** (MIT) — the package their +reference coordinator and client both import. It hands each payload *we* send to *their* zod +schema, so a field we named wrong fails at generation time, and carries a `rejects` set per +method so a passing positive means something. `CoordinatorContractVectorTest` (15 tests) drives +`CoordinatorClient` through the real ContextVM transport and asserts the arguments the +coordinator sees equal those vectors, then replays their result shapes back through our parser; +`CordnGroupRefVectorTest` (5 tests) checks `cordn1…` refs in both directions against their bech32 +codec. + +Two findings from doing it: + +1. **Mutation-checked, because 20 tests passing on the first run means nothing on its own.** + Making a first-ever fetch send `after = 0` instead of omitting it, and flipping the group-ref + TLV emission to ascending order, each killed exactly one test. The TLV mutation was caught + *only* by the encode-direction test — decoders accept any order by spec, so a decode-only + suite would have shipped that divergence. +2. **The `kp_publish` legacy field is read-only.** Their current schema rejects + `{kp_ref, keyPackageBase64}`, so our `?? keyPackageBase64` fallback is correct for parsing old + publication events and must never be used when sending. + +**Blocked: Tier B (live coordinator).** Not for want of tooling — the reference coordinator is +unlicensed. See §7. + +**Not done: the upstream conversation**, which is the maintainer's to have. §4.1 no longer waits +on it (we implement both encodings); §4.2 and the missing LICENSE files are the asks worth making. + +The original plan for this stage, for reference: 1. **Restore executable verification.** Get `:quartz:jvmTest` green in a clean container (the 429 in the header) and confirm `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest` and @@ -966,9 +1044,19 @@ Still open in Stage 3: ## 10. Open questions -1. **§4.1 credential encoding** — who moves? Blocks Stage 2. +1. ~~**§4.1 credential encoding** — who moves?~~ **Answered 2026-09-18: nobody has to.** We + implement both. The engine has had no opinion on credential encoding since Stage 1, and the + two encodings are 32 raw bytes vs 64 ASCII bytes — mutually exclusive by length, so no leaf + can be misread as the other profile's. `BothCredentialProfilesTest` pins that, including the + `memberIdentityHex` hex-of-hex trap. The cost is §4.4 (no single group holding both clients), + which was already a deliberate profile decision. 2. **§4.2 publication payload** — will upstream give KeyPackage publication its own signed payload - or kind? Changes whether cordn KeyPackages can exist outside a coordinator. + or kind? Changes whether cordn KeyPackages can exist outside a coordinator. Still open, and now + the highest-value ask, since §4.1 no longer needs one. +2b. **Will upstream license the coordinator?** `packages/coordinator`, `packages/server` and + `packages/test-utils` carry no LICENSE (§7). Until they do, Tier B cannot be built on them. + Cheap for upstream to fix and it unblocks the only remaining verification tier that needs + their code. 3. **Is the goal interop or a second transport?** "Amethyst can talk to cordn users" and "Amethyst supports coordinator-backed groups" are different products. The second is strictly less work (no §4.1 dependency for group creation among Amethyst users) and strictly less valuable. diff --git a/quartz/src/commonTest/resources/cordn/coordinator-contracts.json b/quartz/src/commonTest/resources/cordn/coordinator-contracts.json new file mode 100644 index 0000000000..07d89923e7 --- /dev/null +++ b/quartz/src/commonTest/resources/cordn/coordinator-contracts.json @@ -0,0 +1,386 @@ +{ + "_comment": "Generated by quartz/tools/cordn-vector-gen from @cordn/core (MIT). Every payload here was validated by cordn's own zod schemas / bech32 codec. Do not hand-edit.", + "generator": { + "cordnCore": "0.5.5" + }, + "methods": { + "publishKeyPackage": "kp_publish", + "listAvailableKeyPackages": "kp_list", + "consumeKeyPackage": "kp_take", + "removeKeyPackages": "kp_remove", + "fetchPendingWelcomes": "welcome_take", + "storeWelcome": "welcome_store", + "storeJoinRequest": "join_request_store", + "fetchManyPendingJoinRequests": "join_request_take_many", + "postGroupMessage": "msg_post", + "fetchManyGroupMessages": "msg_fetch_many", + "subscribeManyGroupMessages": "msg_sub_many" + }, + "contracts": { + "kp_publish": { + "input": { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "kp_64": "AAECAwQFBgc=" + }, + "output": { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "last_resort": false, + "at": 1757000000 + }, + "rejects": [ + { + "kp_64": "AAECAwQFBgc=" + }, + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "keyPackageBase64": "AAECAwQFBgc=" + } + ] + }, + "kp_list": { + "input": {}, + "output": { + "keyPackages": [ + { + "pk": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "last_resort": false, + "at": 1757000000 + }, + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100 + } + ] + }, + "rejects": [ + { + "keyPackages": [ + { + "pk": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "at": 1757000000 + } + ] + } + ] + }, + "kp_take": { + "input": { + "id": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + }, + "output": { + "keyPackage": { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100, + "event": { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "created_at": 1757000100, + "kind": 25910, + "tags": [ + [ + "p", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + ], + "content": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"kp_publish\",\"arguments\":{\"kp_ref\":\"2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e\",\"kp_64\":\"AAECAwQFBgc=\"}}}", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "emptyOutput": { + "keyPackage": null + }, + "rejects": [ + { + "keyPackage": { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100 + } + } + ] + }, + "kp_remove": { + "input": { + "kp_refs": [ + "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e" + ] + }, + "output": { + "kp_refs": [ + "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + ] + }, + "rejects": [ + { + "kp_refs": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + }, + "welcome_take": { + "input": {}, + "inputWithConsumed": { + "consumed": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "at": 1757000000 + } + ] + }, + "output": { + "welcomes": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "welcome_64": "V2VsY29tZQ==", + "at": 1757000200, + "after": 12 + }, + { + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZTI=", + "at": 1757000300 + } + ] + }, + "rejects": [ + { + "consumed": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + } + ] + }, + "welcome_store": { + "input": { + "target_pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==", + "after": 12 + }, + "inputWithoutAfter": { + "target_pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==" + }, + "output": { + "at": 1757000200 + }, + "rejects": [ + { + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==" + } + ] + }, + "join_request_store": { + "input": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + }, + "output": { + "at": 1757000400 + }, + "rejects": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + }, + "join_request_take_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "inputWithConsumed": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ], + "consumed": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "at": 1757000400 + } + ] + }, + "output": { + "requests": [ + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "at": 1757000400, + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "rejects": [ + { + "requests": [ + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "at": 1757000400 + } + ] + }, + { + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + } + ] + }, + "msg_post": { + "input": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "msg_64": "c2VhbGVk" + }, + "output": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 42, + "at": 1757000500 + }, + "rejects": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "msg_fetch_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "inputWithCursor": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": 42 + } + ] + }, + "output": { + "messages": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 43, + "msg_64": "c2VhbGVkLTE=", + "at": 1757000600 + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 44, + "msg_64": "c2VhbGVkLTI=", + "at": 1757000700 + } + ] + }, + "rejects": [ + { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": "42" + } + ] + } + ] + }, + "msg_sub_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": 42 + } + ] + }, + "output": { + "subscribed": true, + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + }, + "streamFragment": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 45, + "msg_64": "c2VhbGVkLTM=", + "at": 1757000800 + }, + "rejects": [ + { + "subscribed": false, + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + } + ] + } + }, + "groupRefs": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "encoded": "cordn1qqjrvep3vccxvdnp95exzvm9956xvvnr95ukzvty95mkxdnzx4jngepnvyerz8f868g" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "encoded": "cordn1qysvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenqqysmxgvtxxpnrvcfdxfsnxefdx3nryced89snzepdxa3nvc34v56xgvmpxgcs59pdwr" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relays": [ + "wss://relay.example.com/" + ], + "encoded": "cordn1qgv8wumn8ghj7un9d3shjtn90psk6urvv5hxxmmd9uqjpnxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvqqjrvep3vccxvdnp95exzvm9956xvvnr95ukzvty95mkxdnzx4jngepnvyerzxaqmx8" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relays": [ + "wss://relay.example.com/", + "wss://relay2.example.com/" + ], + "encoded": "cordn1qgv8wumn8ghj7un9d3shjtn90psk6urvv5hxxmmd9uppjamnwvaz7tmjv4kxz7fj9ejhsctdwpkx2tnrdakj7qfqenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxqqfpkvsckvvrxxesj6vnpxdjj6drxxf3j6wtpx9jz6dmrxe3r2ef5vsekzv33f7g84r" + }, + { + "gid": "a", + "encoded": "cordn1qqqkzhjmr98" + }, + { + "gid": "grupo-café-éàü", + "encoded": "cordn1qqfxwun4wphj6cmpvmp6jtwr48p6psauqarqhy" + } + ], + "groupRefUppercase": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "encoded": "CORDN1QYSVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENQQYSMXGVTXXPNRVCFDXFSNXEFDX3NRYCED89SNZEPDXA3NVC34V56XGVMPXGCS59PDWR" + }, + "groupRefRejects": [ + "cordn1qqqqq", + "nostr1qqqqq", + "CORDN1QQQQQ", + "cordn", + "" + ] +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt index edbaf1966f..367466223f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt @@ -70,11 +70,23 @@ class CordnFixtureCoordinator( val method: String, /** The caller pubkey the coordinator learned, via CEP-16 `_meta`. */ val callerPubKey: HexKey?, + /** Exactly the arguments object that arrived, for wire-shape assertions. */ + val arguments: JsonObject = JsonObject(emptyMap()), ) /** Every call seen, with who made it. The privacy surface, made observable. */ val calls = mutableListOf() + /** + * Answers keyed by tool name that replace this fixture's own bookkeeping, + * served verbatim as `structuredContent`. + * + * For replaying a result recorded from another implementation: the fixture + * models the coordinator well enough to drive a client, but a result it + * composed itself only proves we agree with ourselves. + */ + val scriptedResults = mutableMapOf() + private val welcomes = mutableListOf() private val joinRequests = mutableListOf() private val messages = mutableMapOf>() @@ -139,7 +151,9 @@ class CordnFixtureCoordinator( ?.get("clientPubkey") ?.jsonPrimitive ?.content - calls += Call(name, caller) + calls += Call(name, caller, args) + + scriptedResults[name]?.let { return success(id, it) } val structured = when (name) { @@ -264,15 +278,20 @@ class CordnFixtureCoordinator( else -> buildJsonObject {} } - return JsonRpcSuccess( - id, - buildJsonObject { - put("content", buildJsonArray {}) - put(CoordinatorFields.STRUCTURED_CONTENT, structured) - }, - ) + return success(id, structured) } + private fun success( + id: JsonRpcId, + structured: JsonObject, + ) = JsonRpcSuccess( + id, + buildJsonObject { + put("content", buildJsonArray {}) + put(CoordinatorFields.STRUCTURED_CONTENT, structured) + }, + ) + /** The messages each requested group has after its cursor. */ fun after(args: JsonObject): List = args[CoordinatorFields.GROUPS]!!.jsonArray.flatMap { entry -> diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt new file mode 100644 index 0000000000..509bf22c45 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * One engine, both credential encodings, side by side. + * + * §4.1 of `quartz/plans/2026-09-17-cordn-interop.md` records a hard + * incompatibility: Marmot writes a Nostr pubkey into a BasicCredential as the + * **raw 32 bytes**, cordn as **64 ASCII bytes of lowercase hex**. The decision + * was to implement both rather than wait for either ecosystem to move, which + * only works if the two profiles cannot be confused for one another — a leaf + * read under the wrong binding must come back as "not mine", never as a + * plausible-looking wrong pubkey. + * + * That is what this pins. It is cheap to get wrong in a way no single-binding + * test would notice: `MlsGroup.memberIdentityHex` hex-encodes credential bytes, + * so run over a cordn leaf it returns 128 characters of hex-of-hex — a string + * that looks like a pubkey, compares unequal to every real one, and would + * quietly drop a member from a UI list. + */ +class BothCredentialProfilesTest { + private val aliceHex = "aa".repeat(32) + private val bobHex = "bb".repeat(32) + + private fun marmotGroup() = MlsGroup.create(aliceHex.hexToByteArray(), policy = MarmotGroupPolicy) + + private fun cordnGroup() = + MlsGroup.create( + identity = CordnCredential.of(aliceHex).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "both profiles").toExtension()), + ) + + @Test + fun `the two encodings cannot collide, by length alone`() { + val marmot = Credential.Basic(aliceHex.hexToByteArray()) + val cordn = CordnCredential.of(aliceHex) + + assertEquals(32, marmot.identity.size) + assertEquals(64, cordn.identity.size) + assertNotEquals(marmot.identity.toHexKey(), cordn.identity.toHexKey()) + + // The disambiguation is structural, not heuristic: cordn's reader wants + // exactly 64 bytes, Marmot's wants exactly 32, and no byte string is + // both. Neither binding needs to guess which profile a leaf belongs to. + assertNull(CordnCredential.identityOrNull(marmot), "a Marmot credential must not read as a cordn identity") + } + + @Test + fun `a cordn credential round-trips through its own reader`() { + assertEquals(aliceHex, CordnCredential.identityOrNull(CordnCredential.of(aliceHex))) + assertContentEquals(aliceHex.encodeToByteArray(), CordnCredential.of(aliceHex).identity) + } + + @Test + fun `both groups run on the same engine at once`() { + // Not a formality: since Stage 1 the profile arrives as a constructor + // argument, so a leaked default or a shared mutable would show up as + // one group adopting the other's rules. + val marmot = marmotGroup() + val cordn = cordnGroup() + + assertEquals(0L, marmot.epoch) + assertEquals(0L, cordn.epoch) + + // Each group reports its own creator under its own encoding. + assertEquals(setOf(aliceHex), CordnCredential.memberIdentities(cordn)) + assertEquals(aliceHex, marmot.memberIdentityHex(0)) + + // And a message in each still opens. + listOf(marmot, cordn).forEach { group -> + val sealed = group.encrypt("hello".encodeToByteArray()) + assertEquals("hello", group.decrypt(sealed).content.decodeToString()) + } + } + + @Test + fun `reading a cordn leaf with the raw-bytes helper gives hex-of-hex, not a pubkey`() { + // The trap, made explicit so nobody "fixes" CordnCredential.membersOf + // back into memberIdentityHex. 64 ASCII bytes hex-encode to 128 chars. + val cordn = cordnGroup() + + assertEquals(128, cordn.memberIdentityHex(0)?.length) + assertNotEquals(aliceHex, cordn.memberIdentityHex(0)) + assertEquals(aliceHex, CordnCredential.identityOrNull(cordn.members().first { it.first == 0 }.second)) + } + + @Test + fun `each profile admits a joiner carrying its own encoding`() { + val cordn = cordnGroup() + val bobsKeyPackage = cordnGroup().createKeyPackage(CordnCredential.of(bobHex).identity, ByteArray(0)) + + cordn.addMember(bobsKeyPackage.keyPackage.toTlsBytes()) + + assertEquals(setOf(aliceHex, bobHex), CordnCredential.memberIdentities(cordn)) + + val marmot = marmotGroup() + val bobsMarmotKeyPackage = marmotGroup().createKeyPackage(bobHex.hexToByteArray(), ByteArray(0)) + + marmot.addMember(bobsMarmotKeyPackage.keyPackage.toTlsBytes()) + + assertEquals(bobHex, marmot.memberIdentityHex(1)) + // ...and the joiner each admitted is invisible to the other binding. + assertNull(CordnCredential.identityOrNull(marmot.members().first { it.first == 1 }.second)) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt new file mode 100644 index 0000000000..9c7d5a4d70 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorMethod +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [CoordinatorClient] against cordn's own wire contracts. + * + * `resources/cordn/coordinator-contracts.json` is generated by + * `quartz/tools/cordn-vector-gen` from **`@cordn/core`** (MIT) — the package + * the reference coordinator and client both import. Every payload in it was + * accepted by their zod schema, and every `rejects` entry was refused by it. + * + * So this suite asks the two questions our own fixture cannot: does the JSON + * we put on the wire match what their coordinator parses, and can we read what + * theirs sends back? Both directions run through the real ContextVM transport, + * so a regression in argument assembly, field naming or result parsing fails + * here rather than against a live coordinator. + * + * What it does NOT cover is the coordinator's behaviour — ordering, cursor + * assignment, admission control. That needs Tier B, which is blocked; see + * `quartz/plans/2026-09-17-cordn-interop.md` §7. + */ +class CoordinatorContractVectorTest { + private val vectors: JsonObject = + Json + .parseToJsonElement( + checkNotNull(CoordinatorContractVectorTest::class.java.getResourceAsStream("/cordn/coordinator-contracts.json")) { + "missing cordn contract vectors" + }.use { it.readBytes() } + .decodeToString(), + ).jsonObject + + private fun contract(method: String): JsonObject = vectors["contracts"]!!.jsonObject[method]!!.jsonObject + + private fun input( + method: String, + key: String = "input", + ): JsonObject = contract(method)[key]!!.jsonObject + + private fun output( + method: String, + key: String = "output", + ): JsonObject = contract(method)[key]!!.jsonObject + + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val stableSigner = NostrSignerInternal(KeyPair()) + private val ephemeralSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + private val coordinator = CordnFixtureCoordinator() + + private val client = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(stableSigner, ephemeralSigner), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ), + ) + + private fun serve() = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + injectClientPubkey = true, + handler = coordinator::handle, + ).also { it.start() } + + /** Runs [block] while pumping the fixture, since the relay callback cannot suspend. */ + private suspend fun driving(block: suspend () -> T): T = + coroutineScope { + val server = serve() + val work = async { block() } + while (!work.isCompleted) { + yield() + server.pump() + yield() + } + work.await() + } + + /** The arguments the coordinator saw for [method], which must be its only call. */ + private fun sent(method: String): JsonObject { + val calls = coordinator.calls.filter { it.method == method } + assertEquals(1, calls.size, "expected exactly one $method call, saw ${coordinator.calls.map { it.method }}") + return calls.single().arguments + } + + private suspend fun scripted( + method: String, + result: JsonObject, + block: suspend () -> Unit, + ) { + coordinator.scriptedResults[method] = result + driving(block) + } + + // ---- what we send ---------------------------------------------------- + + @Test + fun `kp_publish arguments match cordn's schema`() = + runTest { + driving { client.publishKeyPackage("1f".repeat(16), "AAECAwQFBgc=") } + assertEquals(input("kp_publish"), sent("kp_publish")) + } + + @Test + fun `kp_remove arguments match cordn's schema`() = + runTest { + driving { client.removeKeyPackages(listOf("1f".repeat(16), "2e".repeat(16))) } + assertEquals(input("kp_remove"), sent("kp_remove")) + } + + @Test + fun `kp_take arguments match cordn's schema`() = + runTest { + scripted("kp_take", output("kp_take", "emptyOutput")) { client.takeKeyPackage("1f".repeat(16)) } + assertEquals(input("kp_take"), sent("kp_take")) + } + + @Test + fun `welcome_store arguments match cordn's schema, with and without a cursor`() = + runTest { + driving { + client.storeWelcome("b".repeat(64), "2e".repeat(16), "V2VsY29tZQ==", after = 12) + } + assertEquals(input("welcome_store"), sent("welcome_store")) + + coordinator.calls.clear() + driving { client.storeWelcome("b".repeat(64), "2e".repeat(16), "V2VsY29tZQ==") } + assertEquals(input("welcome_store", "inputWithoutAfter"), sent("welcome_store")) + } + + @Test + fun `welcome_take omits consumed when there is nothing to retire`() = + runTest { + driving { client.takeWelcomes() } + assertEquals(input("welcome_take"), sent("welcome_take")) + + coordinator.calls.clear() + driving { client.takeWelcomes(listOf(ConsumedWelcomeRef("1f".repeat(16), 1757000000L))) } + assertEquals(input("welcome_take", "inputWithConsumed"), sent("welcome_take")) + } + + @Test + fun `join_request arguments match cordn's schema`() = + runTest { + val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + driving { client.storeJoinRequest(gid, "1f".repeat(16)) } + assertEquals(input("join_request_store"), sent("join_request_store")) + + coordinator.calls.clear() + driving { client.takeJoinRequests(listOf(gid)) } + assertEquals(input("join_request_take_many"), sent("join_request_take_many")) + + coordinator.calls.clear() + driving { + client.takeJoinRequests(listOf(gid), listOf(ConsumedJoinRequestRef(gid, "b".repeat(64), 1757000400L))) + } + assertEquals(input("join_request_take_many", "inputWithConsumed"), sent("join_request_take_many")) + } + + @Test + fun `msg_post arguments match cordn's schema`() = + runTest { + driving { client.postMessage("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", "c2VhbGVk") } + assertEquals(input("msg_post"), sent("msg_post")) + } + + @Test + fun `a first fetch omits after entirely rather than sending zero`() = + runTest { + // `after` is typed as a number, so 0 is a real cursor, not "from + // the start" -- sending it would silently skip the first message. + val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + driving { client.fetchMessages(mapOf(gid to null)) } + assertEquals(input("msg_fetch_many"), sent("msg_fetch_many")) + + coordinator.calls.clear() + driving { client.fetchMessages(mapOf(gid to 42L)) } + assertEquals(input("msg_fetch_many", "inputWithCursor"), sent("msg_fetch_many")) + } + + // ---- what we read ---------------------------------------------------- + + @Test + fun `kp_publish result parses`() = + runTest { + lateinit var seen: PublishedKeyPackage + scripted("kp_publish", output("kp_publish")) { + seen = client.publishKeyPackage("1f".repeat(16), "AAECAwQFBgc=") + } + assertEquals("1f".repeat(16), seen.keyPackageRef) + assertEquals(false, seen.lastResort) + assertEquals(1757000000L, seen.at) + } + + @Test + fun `kp_list result parses, last_resort and all`() = + runTest { + lateinit var seen: List + scripted("kp_list", output("kp_list")) { seen = client.listKeyPackages() } + assertEquals(2, seen.size) + assertEquals("a".repeat(64), seen[0].pubKey) + assertEquals(false, seen[0].lastResort) + assertEquals(true, seen[1].lastResort) + assertEquals(1757000100L, seen[1].at) + } + + @Test + fun `kp_take result carries the publication event, and a null one means nothing held`() = + runTest { + lateinit var taken: TakenKeyPackage + scripted("kp_take", output("kp_take")) { taken = client.takeKeyPackage("2e".repeat(16))!! } + assertEquals("b".repeat(64), taken.pubKey) + assertEquals(true, taken.lastResort) + assertEquals(25910, taken.publicationEvent.kind) + // Read back out of the signed payload, not from a sibling field. + assertEquals("AAECAwQFBgc=", taken.keyPackageBase64()) + + coordinator.calls.clear() + coordinator.scriptedResults.clear() + var empty: TakenKeyPackage? = null + scripted("kp_take", output("kp_take", "emptyOutput")) { empty = client.takeKeyPackage("2e".repeat(16)) } + assertNull(empty, "`keyPackage: null` means the coordinator holds nothing, not an error") + } + + @Test + fun `welcome_take result parses, including the optional resume cursor`() = + runTest { + lateinit var seen: List + scripted("welcome_take", output("welcome_take")) { seen = client.takeWelcomes() } + assertEquals(2, seen.size) + assertEquals(12L, seen[0].after) + assertNull(seen[1].after, "`after` is optional; absent must not become 0") + assertEquals("V2VsY29tZQ==", seen[0].welcomeBase64) + } + + @Test + fun `join_request_take_many result parses, gid and all`() = + runTest { + lateinit var seen: List + scripted("join_request_take_many", output("join_request_take_many")) { + seen = client.takeJoinRequests(listOf("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21")) + } + assertEquals(1, seen.size) + // The single-group variant of this record has no `gid`; the many + // variant does, and we read the many one. + assertEquals("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", seen[0].gid) + assertEquals("b".repeat(64), seen[0].pubKey) + } + + @Test + fun `msg_post and msg_fetch_many results parse`() = + runTest { + lateinit var posted: PostedMessage + scripted("msg_post", output("msg_post")) { + posted = client.postMessage("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", "c2VhbGVk") + } + assertEquals(42L, posted.cursor) + + coordinator.calls.clear() + coordinator.scriptedResults.clear() + lateinit var fetched: List + scripted("msg_fetch_many", output("msg_fetch_many")) { + fetched = client.fetchMessages(mapOf("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" to 42L)) + } + assertEquals(listOf(43L, 44L), fetched.map { it.cursor }) + assertEquals("c2VhbGVkLTE=", fetched[0].sealedBase64) + } + + @Test + fun `every coordinator tool name matches cordn's own table`() = + runTest { + val theirs = + vectors["methods"]!! + .jsonObject.values + .map { it.toString().trim('"') } + .toSet() + val ours = CoordinatorMethod.entries.map { it.wire }.toSet() + assertEquals(theirs, ours, "the tool name set must match cordn's COORDINATOR_METHODS exactly") + assertTrue(theirs.size == 11) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt new file mode 100644 index 0000000000..14a5ed3095 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** + * `cordn1…` refs against cordn's own bech32 codec. + * + * The strings below came out of `@cordn/core`'s `encodeGroupRef`, not out of + * ours (see `quartz/tools/cordn-vector-gen`). A group ref is the one cordn + * artifact a user copies and pastes by hand, so a divergence here is not a + * protocol nuance — it is a link that works in one client and not the other. + * + * Both directions are checked: we must decode what they produce, and produce + * byte-identical output for the same input. The second half is the one that + * catches TLV ordering, since §3 lets a decoder accept any order and would hide + * the difference. + */ +class CordnGroupRefVectorTest { + private val vectors: JsonObject = + Json + .parseToJsonElement( + checkNotNull(CordnGroupRefVectorTest::class.java.getResourceAsStream("/cordn/coordinator-contracts.json")) { + "missing cordn contract vectors" + }.use { it.readBytes() } + .decodeToString(), + ).jsonObject + + private fun JsonObject.text(key: String) = this[key]?.jsonPrimitive?.content + + private fun JsonObject.toRef() = + CordnGroupRef( + gid = text("gid")!!, + coordinatorPubKey = text("coordinatorPubkey"), + relays = this["relays"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty(), + ) + + @Test + fun `we decode every ref cordn encodes`() { + val cases = vectors["groupRefs"]!!.jsonArray.map { it.jsonObject } + assertEquals(6, cases.size, "the vector file lost cases") + + cases.forEach { case -> + val decoded = CordnGroupRef.decode(case.text("encoded")!!) + assertEquals(case.toRef(), decoded, "decoding ${case.text("encoded")}") + } + } + + @Test + fun `we encode byte-identically to cordn`() { + vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }.forEach { case -> + assertEquals( + case.text("encoded"), + case.toRef().encode(), + "encoding gid=${case.text("gid")}", + ) + } + } + + @Test + fun `an all-uppercase ref decodes, as it does for cordn`() { + // Bech32 forbids mixed case but permits uppercase, and their + // `decodeGroupRef` accepts it even though their `isGroupRef` screen + // does not. A ref pasted in caps has to keep working. + val case = vectors["groupRefUppercase"]!!.jsonObject + assertEquals(case.toRef(), CordnGroupRef.decode(case.text("encoded")!!)) + } + + @Test + fun `we refuse everything cordn refuses`() { + vectors["groupRefRejects"]!!.jsonArray.forEach { + val bad = it.jsonPrimitive.content + assertFails("must refuse ${bad.ifEmpty { "" }}") { CordnGroupRef.decode(bad) } + } + } + + @Test + fun `a non-ASCII gid survives byte for byte`() { + // §4.1: the coordinator keys its cursor space on these bytes, so any + // normalisation on our side silently splits a group in two. + val case = vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }.last { it.text("gid")!!.any { c -> c.code > 127 } } + val decoded = CordnGroupRef.decode(case.text("encoded")!!) + assertEquals(case.text("gid"), decoded.gid) + assertEquals(case.text("encoded"), decoded.encode()) + } +} diff --git a/quartz/tools/cordn-vector-gen/.gitignore b/quartz/tools/cordn-vector-gen/.gitignore new file mode 100644 index 0000000000..504afef81f --- /dev/null +++ b/quartz/tools/cordn-vector-gen/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/quartz/tools/cordn-vector-gen/README.md b/quartz/tools/cordn-vector-gen/README.md new file mode 100644 index 0000000000..7ff28db5d3 --- /dev/null +++ b/quartz/tools/cordn-vector-gen/README.md @@ -0,0 +1,50 @@ +# cordn-vector-gen + +Emits `quartz/src/commonTest/resources/cordn/coordinator-contracts.json` from +**`@cordn/core`** — the package cordn's own reference coordinator and client +both import. Consumed by `CoordinatorContractVectorTest` and +`CordnGroupRefVectorTest`. + +## Why this exists + +Our ts-mls fixtures (`resources/tsmls/`) cover the *crypto* layer: KeyPackages, +Welcomes, exporters, sealed payloads. They say nothing about the layer above — +the eleven coordinator tools, their argument names, which fields are optional, +and the `cordn1…` group ref. That layer had been verified only against our +reading of the spec, which is exactly the kind of agreement that holds right up +until someone runs a real coordinator. + +So this generator goes the other way: it takes the payloads **we** put on the +wire and hands each one to **their** zod schema. A field we named wrong fails at +generation time. Each method also carries `rejects` — payloads their schema must +refuse — because a positive result only means something if the schema is strict +where we assume it is. + +Group refs are round-tripped through their bech32 codec, and the vectors pin +both directions: we must decode what they encode, *and* encode byte-identically. +Only the second half catches TLV ordering, since the spec lets decoders accept +any order. + +## Regenerating + +``` +cd quartz/tools/cordn-vector-gen +npm install +node generate.mjs > ../../src/commonTest/resources/cordn/coordinator-contracts.json +``` + +Output is deterministic — fixed actors, fixed timestamps, no randomness — so a +regeneration that changes the file means cordn changed something. Commit the +result with the reason. + +## Licensing + +`@cordn/core` and `@cordn/cli` are **MIT** and each ship a LICENSE file; this +generator is a dev-only dependency on the first and ships in nothing. + +The rest of the `Cordn-msg/cordn` repository — `packages/coordinator`, +`packages/server`, `packages/test-utils`, and the `ghcr.io/cordn-msg/cordn` +image built from them — carries **no license file and no `license` field**, so +default copyright applies. That is why Tier B (a live coordinator round-trip) +is not wired up here and why these vectors are generated from the two licensed +packages instead. See `quartz/plans/2026-09-17-cordn-interop.md` §7. diff --git a/quartz/tools/cordn-vector-gen/generate.mjs b/quartz/tools/cordn-vector-gen/generate.mjs new file mode 100644 index 0000000000..959a17ca14 --- /dev/null +++ b/quartz/tools/cordn-vector-gen/generate.mjs @@ -0,0 +1,334 @@ +// Emits cordn coordinator-contract + group-ref vectors, validated by cordn's +// OWN schemas and codecs (`@cordn/core`, MIT). +// +// The point is direction: every sample below is what OUR Kotlin client puts on +// the wire, handed to THEIR zod schema. A shape we invented that their +// coordinator would reject fails here, at generation time, instead of silently +// in production. The negative samples do the converse — they prove the schema +// is actually strict where we assume it is, so a passing positive means +// something. +// +// Usage: npm install && node generate.mjs > ../../src/commonTest/resources/cordn/coordinator-contracts.json + +import { + COORDINATOR_METHODS, + publishKeyPackageInputSchema, + publishKeyPackageOutputSchema, + listAvailableKeyPackagesInputSchema, + listAvailableKeyPackagesOutputSchema, + consumeKeyPackageInputSchema, + consumeKeyPackageOutputSchema, + removeKeyPackagesInputSchema, + removeKeyPackagesOutputSchema, + fetchPendingWelcomesInputSchema, + fetchPendingWelcomesOutputSchema, + storeWelcomeInputSchema, + storeWelcomeOutputSchema, + storeJoinRequestInputSchema, + storeJoinRequestOutputSchema, + fetchManyPendingJoinRequestsInputSchema, + fetchManyPendingJoinRequestsOutputSchema, + postGroupMessageInputSchema, + postGroupMessageOutputSchema, + fetchManyGroupMessagesInputSchema, + fetchManyGroupMessagesOutputSchema, + subscribeManyGroupMessagesInputSchema, + subscribeManyGroupMessagesOutputSchema, + groupMessageSchema, + encodeGroupRef, + decodeGroupRef, + isGroupRef, +} from "@cordn/core"; +import { readFileSync } from "node:fs"; + +const coreVersion = JSON.parse( + readFileSync(new URL("./node_modules/@cordn/core/package.json", import.meta.url), "utf8"), +).version; + +// Deterministic actors. 64 lowercase hex, because that is what a cordn +// credential carries as 64 ASCII bytes (spec/00.md §13). +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const COORDINATOR = "c".repeat(64); +const GID = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"; +const KP_REF = "1f".repeat(16); +const KP_REF_2 = "2e".repeat(16); + +const failures = []; + +/** Validates `value` against `schema`, recording (not throwing) on mismatch. */ +function accepts(label, schema, value) { + const parsed = schema.safeParse(value); + if (!parsed.success) { + failures.push(`${label}: cordn's schema REJECTED a payload we send/expect — ${JSON.stringify(parsed.error.issues)}`); + } + return value; +} + +/** The converse: proves the schema really is strict about `label`. */ +function rejects(label, schema, value) { + const parsed = schema.safeParse(value); + if (parsed.success) { + failures.push(`${label}: cordn's schema ACCEPTED a payload we assume it rejects`); + } + return value; +} + +const contracts = { + [COORDINATOR_METHODS.publishKeyPackage]: { + input: accepts("kp_publish.input", publishKeyPackageInputSchema, { + kp_ref: KP_REF, + kp_64: "AAECAwQFBgc=", + }), + output: accepts("kp_publish.output", publishKeyPackageOutputSchema, { + kp_ref: KP_REF, + last_resort: false, + at: 1757000000, + }), + rejects: [ + rejects("kp_publish.input/no-ref", publishKeyPackageInputSchema, { kp_64: "AAECAwQFBgc=" }), + rejects("kp_publish.input/legacy-field", publishKeyPackageInputSchema, { + kp_ref: KP_REF, + keyPackageBase64: "AAECAwQFBgc=", + }), + ], + }, + [COORDINATOR_METHODS.listAvailableKeyPackages]: { + input: accepts("kp_list.input", listAvailableKeyPackagesInputSchema, {}), + output: accepts("kp_list.output", listAvailableKeyPackagesOutputSchema, { + keyPackages: [ + { pk: ALICE, kp_ref: KP_REF, last_resort: false, at: 1757000000 }, + { pk: BOB, kp_ref: KP_REF_2, last_resort: true, at: 1757000100 }, + ], + }), + rejects: [ + rejects("kp_list.output/missing-last_resort", listAvailableKeyPackagesOutputSchema, { + keyPackages: [{ pk: ALICE, kp_ref: KP_REF, at: 1757000000 }], + }), + ], + }, + [COORDINATOR_METHODS.consumeKeyPackage]: { + input: accepts("kp_take.input", consumeKeyPackageInputSchema, { id: KP_REF }), + output: accepts("kp_take.output", consumeKeyPackageOutputSchema, { + keyPackage: { + pk: BOB, + kp_ref: KP_REF_2, + last_resort: true, + at: 1757000100, + event: { + id: "0".repeat(64), + pubkey: BOB, + created_at: 1757000100, + kind: 25910, + tags: [["p", COORDINATOR]], + content: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "kp_publish", arguments: { kp_ref: KP_REF_2, kp_64: "AAECAwQFBgc=" } }, + }), + sig: "0".repeat(128), + }, + }, + }), + // `keyPackage: null` is how the coordinator says "nothing matching" — our + // client returns null rather than raising, so pin that it is legal. + emptyOutput: accepts("kp_take.output/null", consumeKeyPackageOutputSchema, { keyPackage: null }), + rejects: [ + rejects("kp_take.output/no-event", consumeKeyPackageOutputSchema, { + keyPackage: { pk: BOB, kp_ref: KP_REF_2, last_resort: true, at: 1757000100 }, + }), + ], + }, + [COORDINATOR_METHODS.removeKeyPackages]: { + input: accepts("kp_remove.input", removeKeyPackagesInputSchema, { kp_refs: [KP_REF, KP_REF_2] }), + output: accepts("kp_remove.output", removeKeyPackagesOutputSchema, { kp_refs: [KP_REF] }), + rejects: [rejects("kp_remove.input/scalar", removeKeyPackagesInputSchema, { kp_refs: KP_REF })], + }, + [COORDINATOR_METHODS.fetchPendingWelcomes]: { + // Our client omits `consumed` entirely when it has nothing to retire, + // rather than sending an empty array. + input: accepts("welcome_take.input/empty", fetchPendingWelcomesInputSchema, {}), + inputWithConsumed: accepts("welcome_take.input/consumed", fetchPendingWelcomesInputSchema, { + consumed: [{ kp_ref: KP_REF, at: 1757000000 }], + }), + output: accepts("welcome_take.output", fetchPendingWelcomesOutputSchema, { + welcomes: [ + { kp_ref: KP_REF, welcome_64: "V2VsY29tZQ==", at: 1757000200, after: 12 }, + { kp_ref: KP_REF_2, welcome_64: "V2VsY29tZTI=", at: 1757000300 }, + ], + }), + rejects: [ + rejects("welcome_take.input/consumed-without-at", fetchPendingWelcomesInputSchema, { + consumed: [{ kp_ref: KP_REF }], + }), + ], + }, + [COORDINATOR_METHODS.storeWelcome]: { + input: accepts("welcome_store.input", storeWelcomeInputSchema, { + target_pk: BOB, + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + after: 12, + }), + inputWithoutAfter: accepts("welcome_store.input/no-after", storeWelcomeInputSchema, { + target_pk: BOB, + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + }), + output: accepts("welcome_store.output", storeWelcomeOutputSchema, { at: 1757000200 }), + rejects: [ + rejects("welcome_store.input/no-target", storeWelcomeInputSchema, { + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + }), + ], + }, + [COORDINATOR_METHODS.storeJoinRequest]: { + input: accepts("join_request_store.input", storeJoinRequestInputSchema, { gid: GID, kp_ref: KP_REF }), + output: accepts("join_request_store.output", storeJoinRequestOutputSchema, { at: 1757000400 }), + rejects: [rejects("join_request_store.input/no-gid", storeJoinRequestInputSchema, { kp_ref: KP_REF })], + }, + [COORDINATOR_METHODS.fetchManyPendingJoinRequests]: { + input: accepts("join_request_take_many.input", fetchManyPendingJoinRequestsInputSchema, { + groups: [{ gid: GID }], + }), + inputWithConsumed: accepts("join_request_take_many.input/consumed", fetchManyPendingJoinRequestsInputSchema, { + groups: [{ gid: GID }], + consumed: [{ gid: GID, pk: BOB, at: 1757000400 }], + }), + output: accepts("join_request_take_many.output", fetchManyPendingJoinRequestsOutputSchema, { + requests: [{ pk: BOB, kp_ref: KP_REF_2, at: 1757000400, gid: GID }], + }), + rejects: [ + // The many-variant carries `gid` on every request; the single-group + // shape does not, and mixing them is the easy mistake. + rejects("join_request_take_many.output/no-gid", fetchManyPendingJoinRequestsOutputSchema, { + requests: [{ pk: BOB, kp_ref: KP_REF_2, at: 1757000400 }], + }), + rejects("join_request_take_many.input/bare-gids", fetchManyPendingJoinRequestsInputSchema, { groups: [GID] }), + ], + }, + [COORDINATOR_METHODS.postGroupMessage]: { + input: accepts("msg_post.input", postGroupMessageInputSchema, { gid: GID, msg_64: "c2VhbGVk" }), + output: accepts("msg_post.output", postGroupMessageOutputSchema, { gid: GID, cursor: 42, at: 1757000500 }), + rejects: [rejects("msg_post.input/no-msg", postGroupMessageInputSchema, { gid: GID })], + }, + [COORDINATOR_METHODS.fetchManyGroupMessages]: { + // A first-ever fetch omits `after` rather than sending 0 — the schema + // types it as a number, and 0 would be a real cursor value. + input: accepts("msg_fetch_many.input/first", fetchManyGroupMessagesInputSchema, { groups: [{ gid: GID }] }), + inputWithCursor: accepts("msg_fetch_many.input/resume", fetchManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: 42 }], + }), + output: accepts("msg_fetch_many.output", fetchManyGroupMessagesOutputSchema, { + messages: [ + { gid: GID, cursor: 43, msg_64: "c2VhbGVkLTE=", at: 1757000600 }, + { gid: GID, cursor: 44, msg_64: "c2VhbGVkLTI=", at: 1757000700 }, + ], + }), + rejects: [ + rejects("msg_fetch_many.input/string-cursor", fetchManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: "42" }], + }), + ], + }, + [COORDINATOR_METHODS.subscribeManyGroupMessages]: { + input: accepts("msg_sub_many.input", subscribeManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: 42 }], + }), + output: accepts("msg_sub_many.output", subscribeManyGroupMessagesOutputSchema, { + subscribed: true, + groups: [GID], + }), + // Each CEP-41 stream fragment is one of these, NOT a {messages:[…]} page. + streamFragment: accepts("msg_sub_many.fragment", groupMessageSchema, { + gid: GID, + cursor: 45, + msg_64: "c2VhbGVkLTM=", + at: 1757000800, + }), + rejects: [ + rejects("msg_sub_many.output/subscribed-false", subscribeManyGroupMessagesOutputSchema, { + subscribed: false, + groups: [GID], + }), + ], + }, +}; + +// Group refs, round-tripped through THEIR bech32 codec. +const groupRefCases = [ + { gid: GID }, + { gid: GID, coordinatorPubkey: COORDINATOR }, + { gid: GID, coordinatorPubkey: COORDINATOR, relays: ["wss://relay.example.com/"] }, + { + gid: GID, + coordinatorPubkey: COORDINATOR, + relays: ["wss://relay.example.com/", "wss://relay2.example.com/"], + }, + { gid: "a" }, + { gid: "grupo-café-éàü" }, +]; + +const groupRefs = groupRefCases.map((ref) => { + const encoded = encodeGroupRef(ref); + const decoded = decodeGroupRef(encoded); + if (JSON.stringify(decoded) !== JSON.stringify(ref)) { + failures.push(`groupRef: their own round-trip changed ${JSON.stringify(ref)} into ${JSON.stringify(decoded)}`); + } + if (!isGroupRef(encoded)) failures.push(`groupRef: isGroupRef rejected ${encoded}`); + return { ...ref, encoded }; +}); + +// Bech32 forbids mixed case but allows an all-uppercase form. Their +// `isGroupRef` screen rejects uppercase while `decodeGroupRef` accepts it, so +// a ref pasted in caps decodes on both sides — pin that, it is easy to get +// wrong in either direction. +const uppercaseRef = encodeGroupRef({ gid: GID, coordinatorPubkey: COORDINATOR }).toUpperCase(); +if (JSON.stringify(decodeGroupRef(uppercaseRef)) !== JSON.stringify({ gid: GID, coordinatorPubkey: COORDINATOR })) { + failures.push("groupRef: their decoder did not round-trip the uppercase form"); +} +const groupRefUppercase = { gid: GID, coordinatorPubkey: COORDINATOR, encoded: uppercaseRef }; + +// Strings their decoder must refuse. Ours has to refuse them too, or we accept +// group coordinates nobody else would. +const groupRefRejects = [ + "cordn1qqqqq", + "nostr1qqqqq", + "CORDN1QQQQQ", + "cordn", + "", +]; +for (const bad of groupRefRejects) { + let threw = false; + try { + decodeGroupRef(bad); + } catch { + threw = true; + } + if (!threw) failures.push(`groupRef: their decoder ACCEPTED ${JSON.stringify(bad)}, which we treat as invalid`); +} + +if (failures.length > 0) { + console.error("cordn-vector-gen: refusing to emit vectors\n " + failures.join("\n ")); + process.exit(1); +} + +process.stdout.write( + JSON.stringify( + { + _comment: + "Generated by quartz/tools/cordn-vector-gen from @cordn/core (MIT). " + + "Every payload here was validated by cordn's own zod schemas / bech32 codec. Do not hand-edit.", + generator: { cordnCore: coreVersion }, + methods: COORDINATOR_METHODS, + contracts, + groupRefs, + groupRefUppercase, + groupRefRejects, + }, + null, + 2, + ) + "\n", +); diff --git a/quartz/tools/cordn-vector-gen/package.json b/quartz/tools/cordn-vector-gen/package.json new file mode 100644 index 0000000000..73145858fa --- /dev/null +++ b/quartz/tools/cordn-vector-gen/package.json @@ -0,0 +1,9 @@ +{ + "name": "cordn-vector-gen", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "@cordn/core": "0.5.5" + } +}