mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(cordn): verify the coordinator wire against cordn's own contracts
Everything cordn-side was verified against ts-mls, the MLS library their client happens to use. That covers the crypto layer and says nothing about the layer above it: the eleven coordinator tools, their argument names, which fields are optional, and the `cordn1…` group ref. That layer rested on our reading of the spec. `quartz/tools/cordn-vector-gen` closes it from their side. It generates `resources/cordn/coordinator-contracts.json` from `@cordn/core` (MIT) -- the package their reference coordinator and client both import -- by handing each payload we send to their zod schema, so a field we named wrong fails at generation time. Each method also carries a `rejects` set their schema must refuse, because a positive only means something if the schema is strict where we assume it is. Group refs round-trip through their bech32 codec. - CoordinatorContractVectorTest (15): drives CoordinatorClient through the real ContextVM transport, asserts the arguments the coordinator sees equal those vectors, then replays their result shapes back through our parser. - CordnGroupRefVectorTest (5): both directions, including the uppercase form (their `decodeGroupRef` accepts it though their `isGroupRef` screen does not) and a non-ASCII gid, which §4.1 requires to survive byte for byte. - CordnFixtureCoordinator now records each call's arguments and can serve a scripted result, which is what lets the same fixture do both directions. Mutation-checked, since 20 tests passing on the first run means nothing on its own: sending `after = 0` on a first-ever fetch 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. Also settles §4.1 (credential encoding) by implementing both rather than waiting on an agreement neither ecosystem has an incentive to reach. Marmot writes a pubkey as 32 raw bytes, cordn as 64 ASCII hex bytes; they are mutually exclusive by length, so no leaf can be misread as the other profile's. BothCredentialProfilesTest runs both groups on one engine and pins the `memberIdentityHex` hex-of-hex trap that would otherwise drop cordn members from a member list. Two findings recorded in the plan: - Tier B is blocked on licensing, not tooling. `packages/coordinator`, `packages/server` and `packages/test-utils` carry no LICENSE file and no `license` field, and neither does the repo root -- so the reference coordinator, and the ghcr image built from it, are unlicensed. Only `packages/core` and `packages/cli` are MIT. The plan's claim that "everything under Cordn-msg is MIT" was wrong and is corrected. - Their current `kp_publish` schema rejects the legacy `keyPackageBase64` field, so our fallback is for parsing old publication events only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
# Cordn interop: extract the MLS core, then add a second binding
|
# 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/`
|
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;
|
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,
|
`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)
|
group refs and the sync rules, verified against ts-mls in both directions **and against cordn's
|
||||||
are open. §4.1 turned out not to gate the binding — see Stage 3 — but remains a real
|
own wire contracts** (`quartz/tools/cordn-vector-gen` → `resources/cordn/`). Stage 4 (app
|
||||||
incompatibility between the two ecosystems.
|
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
|
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;
|
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
|
- `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
|
confirm deployed defaults, never as an implementation source
|
||||||
|
|
||||||
Verification status: `:quartz:jvmTest` passes in a container at 5330 tests, covering the MLS
|
Verification status: `:quartz:jvmTest` passes in a container, covering the MLS engine, ContextVM
|
||||||
engine, ContextVM and cordn, so the interop claims in §3 are execution-verified rather than
|
and cordn, so the interop claims in §3 are execution-verified rather than read-verified.
|
||||||
read-verified. `:quartz:testAndroidHostTest` passes too, apart from four pre-existing failures in
|
`:quartz:testAndroidHostTest` passes as well — the four failures this plan previously recorded as
|
||||||
`NostrServerTest` and `LiveNegentropyIndexStoreTest` that predate this work (confirmed by running
|
pre-existing (`NostrServerTest`, `LiveNegentropyIndexStoreTest`) were fixed on 2026-09-18: the
|
||||||
them at the parent commit).
|
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
|
## 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
|
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.
|
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
|
**Decision (2026-09-18): support both encodings; do not wait for an agreement.** Neither
|
||||||
one does.
|
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
|
### 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
|
- 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.
|
(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
|
🔴 **Correction (2026-09-18, verified): "everything under `Cordn-msg` is MIT" was wrong.** Only
|
||||||
and to implement against.
|
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
|
## 8. What the coordinator can see
|
||||||
|
|
||||||
@@ -623,9 +672,38 @@ looking like a group chat.
|
|||||||
|
|
||||||
## 9. Plan
|
## 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
|
1. **Restore executable verification.** Get `:quartz:jvmTest` green in a clean container
|
||||||
(the 429 in the header) and confirm `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest` and
|
(the 429 in the header) and confirm `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest` and
|
||||||
@@ -966,9 +1044,19 @@ Still open in Stage 3:
|
|||||||
|
|
||||||
## 10. Open questions
|
## 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
|
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
|
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
|
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.
|
(no §4.1 dependency for group creation among Amethyst users) and strictly less valuable.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
+27
-8
@@ -70,11 +70,23 @@ class CordnFixtureCoordinator(
|
|||||||
val method: String,
|
val method: String,
|
||||||
/** The caller pubkey the coordinator learned, via CEP-16 `_meta`. */
|
/** The caller pubkey the coordinator learned, via CEP-16 `_meta`. */
|
||||||
val callerPubKey: HexKey?,
|
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. */
|
/** Every call seen, with who made it. The privacy surface, made observable. */
|
||||||
val calls = mutableListOf<Call>()
|
val calls = mutableListOf<Call>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<String, JsonObject>()
|
||||||
|
|
||||||
private val welcomes = mutableListOf<JsonObject>()
|
private val welcomes = mutableListOf<JsonObject>()
|
||||||
private val joinRequests = mutableListOf<JsonObject>()
|
private val joinRequests = mutableListOf<JsonObject>()
|
||||||
private val messages = mutableMapOf<String, MutableList<JsonObject>>()
|
private val messages = mutableMapOf<String, MutableList<JsonObject>>()
|
||||||
@@ -139,7 +151,9 @@ class CordnFixtureCoordinator(
|
|||||||
?.get("clientPubkey")
|
?.get("clientPubkey")
|
||||||
?.jsonPrimitive
|
?.jsonPrimitive
|
||||||
?.content
|
?.content
|
||||||
calls += Call(name, caller)
|
calls += Call(name, caller, args)
|
||||||
|
|
||||||
|
scriptedResults[name]?.let { return success(id, it) }
|
||||||
|
|
||||||
val structured =
|
val structured =
|
||||||
when (name) {
|
when (name) {
|
||||||
@@ -264,15 +278,20 @@ class CordnFixtureCoordinator(
|
|||||||
else -> buildJsonObject {}
|
else -> buildJsonObject {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonRpcSuccess(
|
return success(id, structured)
|
||||||
id,
|
|
||||||
buildJsonObject {
|
|
||||||
put("content", buildJsonArray {})
|
|
||||||
put(CoordinatorFields.STRUCTURED_CONTENT, 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. */
|
/** The messages each requested group has after its cursor. */
|
||||||
fun after(args: JsonObject): List<JsonObject> =
|
fun after(args: JsonObject): List<JsonObject> =
|
||||||
args[CoordinatorFields.GROUPS]!!.jsonArray.flatMap { entry ->
|
args[CoordinatorFields.GROUPS]!!.jsonArray.flatMap { entry ->
|
||||||
|
|||||||
+139
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+339
@@ -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 <T> 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<AvailableKeyPackage>
|
||||||
|
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<PendingWelcome>
|
||||||
|
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<JoinRequest>
|
||||||
|
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<GroupMessage>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -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 { "<empty>" }}") { 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "cordn-vector-gen",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@cordn/core": "0.5.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user