Cordn native: real MLS client (Level 2 foundation)
Add src/lib/cordn/native/ — a browser-safe Cordn client doing real RFC 9420 MLS over a ContextVM coordinator, mirroring Cordn's wire contract (reimplemented, not copied from @cordn/cli). - mls.ts: identity, key packages, group create/add/join, application messages, the ChaCha20-Poly1305 coordinator wrapper, causal prev tags. - coordinator.ts: ContextVM/MCP-over-Nostr client on @contextvm/sdk, validated with @cordn/core schemas. - session.ts: CordnSession (key packages, createGroup, addMember->Welcome, joinGroup, send, fetch) with persistence behind an injectable CordnStore (MemoryCordnStore today; IndexedDB adapter TODO). - Verified end-to-end against our own coordinator: a two-member round trip (CORDN_LIVE=1 vitest run native.live.test.ts) — create, add, join, and bidirectional encrypted messages with proven senders. Deps: @cordn/core, @contextvm/mcp-sdk, @noble/ciphers pinned 2.2.0 (fixes the previously invalid tree: ts-mls wants 2.2.0, a stale root copy was 0.5.3). src/test/setup.ts is now guarded for node-environment test files. Not wired to any UI yet; nothing imports the module, so the web bundle is unchanged.
This commit is contained in:
parent
9251aea5b1
commit
1dcaf8cea3
9 changed files with 1382 additions and 42 deletions
23
AGENTS.md
23
AGENTS.md
|
|
@ -1323,6 +1323,29 @@ session with **no other memory of this project**. So:
|
|||
say this honestly (2026-09-29). **Still future:** real MLS
|
||||
(`ts-mls`) instead of group-key crypto, plus Cordn RPC interop. **Lesson: grep
|
||||
`src` for the feature before building it — this was nearly duplicated.**
|
||||
- **Cordn native — real MLS (Level 2 foundation, 2026-10-12):**
|
||||
`src/lib/cordn/native/` is a **browser-safe Cordn client doing real RFC 9420
|
||||
MLS** (not the sealed-relay stand-in above), mirroring Cordn's wire contract —
|
||||
reimplemented, we do not copy `@cordn/cli`. Files: `mls.ts` (identity, key
|
||||
packages, group create/add/join, application messages, the ChaCha20-Poly1305
|
||||
coordinator wrapper, causal `prev` tags), `coordinator.ts` (ContextVM client
|
||||
over `@contextvm/sdk`, validated with `@cordn/core` schemas), `session.ts`
|
||||
(`CordnSession`: `generateKeyPackage`/`publishKeyPackage`/`createGroup`/
|
||||
`addMember`→Welcome/`joinGroup`/`send`/`fetch`; persistence behind a
|
||||
`CordnStore`, `MemoryCordnStore` today — IndexedDB adapter still TODO) and
|
||||
`index.ts`. Deps added: `@cordn/core`, `@contextvm/mcp-sdk`, and
|
||||
`@noble/ciphers` pinned **2.2.0** (the tree was `invalid`: `ts-mls` wants 2.2.0
|
||||
while a stale root copy was 0.5.3). ⚠️ **Verified end-to-end against our own
|
||||
coordinator** — `CORDN_LIVE=1 npx vitest run
|
||||
src/lib/cordn/native/native.live.test.ts` does a **two-member round trip** (A
|
||||
creates group + adds B → Welcome → B joins → A↔B messages encrypt/decrypt with
|
||||
proven senders). ⚠️ That live test runs in the **node** vitest environment
|
||||
(`// @vitest-environment node`) — jsdom's realm makes a `Uint8Array` fail
|
||||
`instanceof` inside the SDK's nested nostr-tools/@noble/hashes; `src/test/setup.ts`
|
||||
is now guarded with `typeof window !== 'undefined'` so node-env files collect.
|
||||
**Not wired to any UI yet** — the app still uses the sealed-relay crew chat.
|
||||
Next: IndexedDB store + a crew chat panel + the coordinator welcome/join-request
|
||||
flow. Nothing imports the native module, so the web bundle is unchanged.
|
||||
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
|
||||
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
|
||||
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an
|
||||
|
|
|
|||
189
package-lock.json
generated
189
package-lock.json
generated
|
|
@ -16,7 +16,9 @@
|
|||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/local-notifications": "^8.3.1",
|
||||
"@cashu/cashu-ts": "2.8.1",
|
||||
"@contextvm/sdk": "^0.12.0",
|
||||
"@contextvm/mcp-sdk": "^1.30.0",
|
||||
"@contextvm/sdk": "^0.12.5",
|
||||
"@cordn/core": "^0.6.0",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource/barlow-condensed": "^5.2.8",
|
||||
|
|
@ -28,6 +30,7 @@
|
|||
"@hookform/resolvers": "^3.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@netlify/blobs": "^11.1.1",
|
||||
"@noble/ciphers": "2.2.0",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@nostrify/nostrify": "^0.47.1",
|
||||
|
|
@ -517,14 +520,14 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@contextvm/sdk": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@contextvm/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-1mQbe337rXhorjJWsXv3+5LuyjWgoeFpC6M5j5mDT9uczQ8aGDtW+qKAf9OOKlGrvLt3chC61uuEDB/G4ArP4g==",
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@contextvm/sdk/-/sdk-0.12.5.tgz",
|
||||
"integrity": "sha512-csX9vqjSaMDuPBsgYAK3gk60tV+2n02UZ4A2anl2aByTSRrCLcYkX7+XCi0JQ7lNjpXR9JPlNFlSz9LcvbQNCA==",
|
||||
"license": "LGPL-3.0-1",
|
||||
"dependencies": {
|
||||
"@contextvm/mcp-sdk": "^1.29.1",
|
||||
"@contextvm/mcp-sdk": "^1.30.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"applesauce-relay": "^5.2.0",
|
||||
"applesauce-relay": "^6.0.3",
|
||||
"canonicalize": "^2.1.0",
|
||||
"nostr-tools": "~2.18.2",
|
||||
"pino": "^10.3.1",
|
||||
|
|
@ -539,6 +542,15 @@
|
|||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@contextvm/sdk/node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@contextvm/sdk/node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
|
|
@ -694,6 +706,52 @@
|
|||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@cordn/core": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@cordn/core/-/core-0.6.0.tgz",
|
||||
"integrity": "sha512-rhsF4r2kwQ4/EmYoU+bi+clFDGnXpUHx8IDldIiwzKPQ8PxCKM8CDdJ6CeF+lDuyt719uUK/butTjwwdqWiwDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "^2.2.0",
|
||||
"ts-mls": "2.0.0-rc.12",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@cordn/core/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cordn/core/node_modules/@scure/base": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz",
|
||||
"integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cordn/core/node_modules/zod": {
|
||||
"version": "4.6.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz",
|
||||
"integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
|
||||
|
|
@ -1583,6 +1641,27 @@
|
|||
"react-hook-form": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hpke/common": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz",
|
||||
"integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hpke/core": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz",
|
||||
"integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hpke/common": "^1.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@html-eslint/eslint-plugin": {
|
||||
"version": "0.41.0",
|
||||
"resolved": "https://registry.npmjs.org/@html-eslint/eslint-plugin/-/eslint-plugin-0.41.0.tgz",
|
||||
|
|
@ -2164,10 +2243,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz",
|
||||
"integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
|
|
@ -5473,9 +5555,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/applesauce-core": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.2.0.tgz",
|
||||
"integrity": "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-6.2.0.tgz",
|
||||
"integrity": "sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
|
|
@ -5490,6 +5572,15 @@
|
|||
"url": "lightning:nostrudel@geyser.fund"
|
||||
}
|
||||
},
|
||||
"node_modules/applesauce-core/node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/applesauce-core/node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
|
|
@ -5607,13 +5698,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/applesauce-relay": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-5.2.0.tgz",
|
||||
"integrity": "sha512-ty8PzHenocGdTr3x3It8Ql0rMD9rxB6VGCzGRfL5QF6epdstv2YHKuTyr8QdPBvf7yxfc7oZcMi6djSwNxXqkQ==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-6.2.1.tgz",
|
||||
"integrity": "sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.7.1",
|
||||
"applesauce-core": "^5.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"applesauce-core": "^6.2.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"nostr-tools": "~2.19",
|
||||
"rxjs": "^7.8.1"
|
||||
|
|
@ -5623,6 +5714,15 @@
|
|||
"url": "lightning:nostrudel@geyser.fund"
|
||||
}
|
||||
},
|
||||
"node_modules/applesauce-relay/node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/applesauce-relay/node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
|
|
@ -5648,12 +5748,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/applesauce-relay/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
|
|
@ -9469,6 +9569,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
|
|
@ -12218,6 +12327,44 @@
|
|||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ts-mls": {
|
||||
"version": "2.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/ts-mls/-/ts-mls-2.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-VAjd6uvRuydk529B/On9GgIinrUiDGGENkf7k8nDCaYZzAN7aOmJIQs9IsG8A2q7o+Ldp1bm0Ds2hhlgeqL7cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hpke/core": "1.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hpke/chacha20poly1305": "1.8.0",
|
||||
"@hpke/dhkem-x448": "1.8.0",
|
||||
"@hpke/hybridkem-x-wing": "0.7.0",
|
||||
"@hpke/ml-kem": "0.3.0",
|
||||
"@noble/ciphers": "2.2.0",
|
||||
"@noble/curves": "2.2.0",
|
||||
"@noble/post-quantum": "0.6.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@hpke/chacha20poly1305": {
|
||||
"optional": true
|
||||
},
|
||||
"@hpke/dhkem-x448": {
|
||||
"optional": true
|
||||
},
|
||||
"@hpke/hybridkem-x-wing": {
|
||||
"optional": true
|
||||
},
|
||||
"@hpke/ml-kem": {
|
||||
"optional": true
|
||||
},
|
||||
"@noble/curves": {
|
||||
"optional": true
|
||||
},
|
||||
"@noble/post-quantum": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@
|
|||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/local-notifications": "^8.3.1",
|
||||
"@cashu/cashu-ts": "2.8.1",
|
||||
"@contextvm/sdk": "^0.12.0",
|
||||
"@contextvm/mcp-sdk": "^1.30.0",
|
||||
"@contextvm/sdk": "^0.12.5",
|
||||
"@cordn/core": "^0.6.0",
|
||||
"@fontsource-variable/inter": "^5.2.6",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource/barlow-condensed": "^5.2.8",
|
||||
|
|
@ -48,6 +50,7 @@
|
|||
"@hookform/resolvers": "^3.9.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@netlify/blobs": "^11.1.1",
|
||||
"@noble/ciphers": "2.2.0",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@nostrify/nostrify": "^0.47.1",
|
||||
|
|
|
|||
152
src/lib/cordn/native/coordinator.ts
Normal file
152
src/lib/cordn/native/coordinator.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Cordn native — coordinator transport (ContextVM / MCP over Nostr).
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* A browser-safe Cordn coordinator client: the same ContextVM request/response
|
||||
* the reference `cordn` CLI speaks, built on `@contextvm/sdk` and validated with
|
||||
* `@cordn/core`'s wire schemas. The coordinator routes **opaque MLS ciphertext**
|
||||
* plus metadata (which group, when) and never sees plaintext.
|
||||
*
|
||||
* Reimplemented from the Cordn wire contract — not copied from `@cordn/cli`.
|
||||
*/
|
||||
|
||||
import { Client } from '@contextvm/mcp-sdk/client';
|
||||
import {
|
||||
ApplesauceRelayPool,
|
||||
EncryptionMode,
|
||||
NostrClientTransport,
|
||||
PrivateKeySigner,
|
||||
} from '@contextvm/sdk';
|
||||
import {
|
||||
COORDINATOR_METHODS,
|
||||
consumeKeyPackageOutputSchema,
|
||||
decodeBase64,
|
||||
decodeKeyPackage,
|
||||
fetchManyGroupMessagesOutputSchema,
|
||||
groupMessageSchema,
|
||||
listAvailableKeyPackagesOutputSchema,
|
||||
postGroupMessageOutputSchema,
|
||||
publishKeyPackageOutputSchema,
|
||||
type FetchManyGroupMessagesOutput,
|
||||
type GroupMessage,
|
||||
type ListAvailableKeyPackagesOutput,
|
||||
type PostGroupMessageOutput,
|
||||
type PublishKeyPackageOutput,
|
||||
} from '@cordn/core';
|
||||
import type { KeyPackage } from 'ts-mls';
|
||||
|
||||
/** The coordinator's consumed-key-package record (its publication event). */
|
||||
interface ConsumedKeyPackageRecord {
|
||||
pk: string;
|
||||
kp_ref: string;
|
||||
last_resort: boolean;
|
||||
at: number;
|
||||
event: { content: string };
|
||||
}
|
||||
|
||||
export interface CordnCoordinatorConfig {
|
||||
/** Coordinator service pubkey (hex). */
|
||||
serverPubkey: string;
|
||||
/** ContextVM relays that reach the coordinator. */
|
||||
relays: string[];
|
||||
/** Client identity secret (hex). */
|
||||
privateKeyHex: string;
|
||||
/** Gift-wrap requests so relays cannot read the method/group id. */
|
||||
encryptTransport?: boolean;
|
||||
}
|
||||
|
||||
export class CordnCoordinatorClient {
|
||||
private readonly client: Client;
|
||||
private readonly transport: NostrClientTransport;
|
||||
private readonly connected: Promise<void>;
|
||||
|
||||
constructor(config: CordnCoordinatorConfig) {
|
||||
this.client = new Client({ name: 'SkateSpotsCordn', version: '1.0.0' });
|
||||
this.transport = new NostrClientTransport({
|
||||
serverPubkey: config.serverPubkey,
|
||||
signer: new PrivateKeySigner(config.privateKeyHex),
|
||||
relayHandler: new ApplesauceRelayPool(config.relays),
|
||||
isStateless: true,
|
||||
logLevel: 'silent',
|
||||
...(config.encryptTransport
|
||||
? { encryptionMode: EncryptionMode.REQUIRED }
|
||||
: { encryptionMode: EncryptionMode.DISABLED }),
|
||||
openStream: { enabled: true },
|
||||
oversizedTransfer: { enabled: true },
|
||||
});
|
||||
this.connected = Promise.resolve(this.client.connect(this.transport)).then(() => undefined);
|
||||
}
|
||||
|
||||
private async call<S>(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
schema: { parse: (value: unknown) => S },
|
||||
): Promise<S> {
|
||||
await this.connected;
|
||||
const result = await this.client.callTool(
|
||||
{ name, arguments: args },
|
||||
undefined,
|
||||
{ onprogress: () => undefined, resetTimeoutOnProgress: true },
|
||||
);
|
||||
return schema.parse(result.structuredContent);
|
||||
}
|
||||
|
||||
/** Publish a key package for others to add this identity to a group. */
|
||||
async publishKeyPackage(kpRef: string, keyPackageBase64: string): Promise<PublishKeyPackageOutput> {
|
||||
return this.call(
|
||||
COORDINATOR_METHODS.publishKeyPackage,
|
||||
{ kp_ref: kpRef, kp_64: keyPackageBase64 },
|
||||
publishKeyPackageOutputSchema,
|
||||
);
|
||||
}
|
||||
|
||||
/** List the key packages other members have published. */
|
||||
async listAvailableKeyPackages(): Promise<ListAvailableKeyPackagesOutput> {
|
||||
return this.call(COORDINATOR_METHODS.listAvailableKeyPackages, {}, listAvailableKeyPackagesOutputSchema);
|
||||
}
|
||||
|
||||
/** Fetch a member's full key package and mark it consumed for this identity. */
|
||||
async consumeKeyPackage(id: string): Promise<KeyPackage> {
|
||||
const result = await this.call(
|
||||
COORDINATOR_METHODS.consumeKeyPackage,
|
||||
{ id },
|
||||
consumeKeyPackageOutputSchema,
|
||||
);
|
||||
if (!result.keyPackage) throw new Error(`Coordinator has no key package for ${id}`);
|
||||
return decodeConsumedKeyPackage(result.keyPackage);
|
||||
}
|
||||
|
||||
/** Post a sealed MLS message to a group; returns the stream cursor. */
|
||||
async postGroupMessage(gid: string, sealedBase64: string): Promise<PostGroupMessageOutput> {
|
||||
return this.call(
|
||||
COORDINATOR_METHODS.postGroupMessage,
|
||||
{ msg_64: sealedBase64, gid },
|
||||
postGroupMessageOutputSchema,
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch group messages after a cursor (one entry per group). */
|
||||
async fetchManyGroupMessages(
|
||||
groups: Array<{ gid: string; after?: number }>,
|
||||
): Promise<FetchManyGroupMessagesOutput> {
|
||||
const result = await this.call<{ messages: unknown[] }>(
|
||||
COORDINATOR_METHODS.fetchManyGroupMessages,
|
||||
{ groups },
|
||||
fetchManyGroupMessagesOutputSchema,
|
||||
);
|
||||
return { messages: result.messages.map((m) => groupMessageSchema.parse(m)) as GroupMessage[] };
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.connected.catch(() => undefined);
|
||||
await this.transport.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a consumed key package (its publication event carries the kp_64). */
|
||||
export function decodeConsumedKeyPackage(consumed: ConsumedKeyPackageRecord): KeyPackage {
|
||||
const parsed: unknown = JSON.parse(consumed.event.content);
|
||||
const args = (parsed as { params?: { arguments?: Record<string, unknown> } })?.params?.arguments;
|
||||
const kp64 = args?.kp_64 ?? args?.keyPackageBase64;
|
||||
if (typeof kp64 !== 'string') throw new Error('Consumed key package has no kp_64');
|
||||
return decodeKeyPackage(decodeBase64(kp64), 'consumed key package');
|
||||
}
|
||||
45
src/lib/cordn/native/index.ts
Normal file
45
src/lib/cordn/native/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Cordn native (Level 2) — a browser-safe Cordn/MLS client.
|
||||
*
|
||||
* Replaces the sealed-relay stand-in in `../client.ts` with real RFC 9420 MLS
|
||||
* (via `ts-mls`) delivered through a Cordn coordinator over ContextVM.
|
||||
*
|
||||
* Usage:
|
||||
* const session = await CordnSession.open({ coordinator, store });
|
||||
* await session.generateKeyPackage();
|
||||
* await session.publishKeyPackage();
|
||||
* const group = await session.createGroup({ name: 'Crew' });
|
||||
* const { welcomeBase64 } = await session.addMember(group.gid, memberKeyPackage);
|
||||
* await session.send(group.gid, 'hello');
|
||||
* await session.fetch(group.gid);
|
||||
*
|
||||
* Everything is isomorphic; persistence is injected via {@link CordnStore}.
|
||||
* See `native.live.test.ts` for a verified two-member round trip.
|
||||
*/
|
||||
|
||||
export {
|
||||
CordnSession,
|
||||
MemoryCordnStore,
|
||||
type CordnStore,
|
||||
type CordnSnapshot,
|
||||
type CordnSessionOptions,
|
||||
type CordnChatMessage,
|
||||
type CordnGroupInfo,
|
||||
type StoredKeyPackage,
|
||||
} from './session';
|
||||
|
||||
export {
|
||||
CordnCoordinatorClient,
|
||||
type CordnCoordinatorConfig,
|
||||
} from './coordinator';
|
||||
|
||||
export {
|
||||
createIdentity,
|
||||
createKeyPackage,
|
||||
serializeState,
|
||||
deserializeState,
|
||||
groupIdOf,
|
||||
listGroupMembers,
|
||||
type CordnMlsIdentity,
|
||||
type CordnKeyPackage,
|
||||
} from './mls';
|
||||
462
src/lib/cordn/native/mls.ts
Normal file
462
src/lib/cordn/native/mls.ts
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
/**
|
||||
* Cordn native — MLS primitives.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* The browser-safe piece of a real Cordn client: RFC 9420 MLS group state,
|
||||
* key packages, welcomes, and the ChaCha20-Poly1305 "group payload" wrapper the
|
||||
* coordinator relays but cannot read.
|
||||
*
|
||||
* This mirrors Cordn's own reference client (`@cordn/cli`), reimplemented here
|
||||
* against the shared wire definitions in `@cordn/core` and the `ts-mls` engine —
|
||||
* we do not copy its source. Everything in this module is isomorphic: no Node
|
||||
* built-ins, no DOM. Persistence lives in `session.ts` behind an injected store.
|
||||
*/
|
||||
|
||||
import {
|
||||
createApplicationMessage,
|
||||
createCommit,
|
||||
createGroup,
|
||||
defaultCredentialTypes,
|
||||
defaultProposalTypes,
|
||||
encode as tlsEncode,
|
||||
generateKeyPackage,
|
||||
getCiphersuiteImpl,
|
||||
joinGroup,
|
||||
makeKeyPackageRef,
|
||||
mlsExporter,
|
||||
mlsMessageDecoder,
|
||||
mlsMessageEncoder,
|
||||
nobleCryptoProvider,
|
||||
processMessage,
|
||||
unsafeTestingAuthenticationService,
|
||||
clientStateDecoder,
|
||||
clientStateEncoder,
|
||||
keyPackageEncoder,
|
||||
privateKeyPackageEncoder,
|
||||
type ClientState,
|
||||
type IncomingMessageCallback,
|
||||
type KeyPackage,
|
||||
type PrivateKeyPackage,
|
||||
type Welcome,
|
||||
} from 'ts-mls';
|
||||
import {
|
||||
CORDN_GROUP_METADATA_EXTENSION_TYPE,
|
||||
APP_DATA_DICTIONARY_EXTENSION_TYPE,
|
||||
createCordnMetadataCapabilities,
|
||||
decodeBase64,
|
||||
encodeBase64,
|
||||
ensureLastResortKeyPackageExtension,
|
||||
getCordnGroupMetadataExtension,
|
||||
isLastResortKeyPackage,
|
||||
makeCordnGroupMetadataExtension,
|
||||
type CordnGroupMetadata,
|
||||
} from '@cordn/core';
|
||||
import { getEventHash, getPublicKey } from 'nostr-tools';
|
||||
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
|
||||
import { concatBytes, randomBytes } from '@noble/ciphers/utils.js';
|
||||
|
||||
/** Cordn's ciphersuite — must match the coordinator and every other client. */
|
||||
export const CORDN_CIPHERSUITE = 'MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519' as const;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
/* ── hex ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const HEX = '0123456789abcdef';
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
let out = '';
|
||||
for (const b of bytes) out += HEX[b >> 4] + HEX[b & 15];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const clean = hex.trim().toLowerCase();
|
||||
if (clean.length % 2 !== 0 || !/^[0-9a-f]*$/.test(clean)) {
|
||||
throw new Error('Invalid hex string');
|
||||
}
|
||||
const out = new Uint8Array(clean.length / 2);
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── identity ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** A device identity: the Nostr secret is also the MLS credential identity. */
|
||||
export interface CordnMlsIdentity {
|
||||
/** 32-byte secret, hex. */
|
||||
privateKeyHex: string;
|
||||
/** The "stable pubkey" — the Nostr pubkey derived from the secret. */
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
export function createIdentity(privateKeyHex?: string): CordnMlsIdentity {
|
||||
const secret = privateKeyHex
|
||||
? hexToBytes(privateKeyHex)
|
||||
: randomBytes(32);
|
||||
const hex = bytesToHex(secret);
|
||||
return { privateKeyHex: hex, pubkey: getPublicKey(secret) };
|
||||
}
|
||||
|
||||
/* ── ciphersuite (cached) ─────────────────────────────────────────────────── */
|
||||
|
||||
let cipherSuitePromise: ReturnType<typeof getCiphersuiteImpl> | undefined;
|
||||
|
||||
export function getCipherSuite(): ReturnType<typeof getCiphersuiteImpl> {
|
||||
cipherSuitePromise ??= getCiphersuiteImpl(CORDN_CIPHERSUITE, nobleCryptoProvider);
|
||||
return cipherSuitePromise;
|
||||
}
|
||||
|
||||
/* ── key packages ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CordnKeyPackage {
|
||||
keyPackage: KeyPackage;
|
||||
privateKeyPackage: PrivateKeyPackage;
|
||||
keyPackageRef: string;
|
||||
keyPackageBase64: string;
|
||||
isLastResort: boolean;
|
||||
}
|
||||
|
||||
function createCredential(stablePubkey: string) {
|
||||
return {
|
||||
credentialType: defaultCredentialTypes.basic,
|
||||
identity: textEncoder.encode(stablePubkey),
|
||||
};
|
||||
}
|
||||
|
||||
function keyPackageCapabilities(lastResort: boolean) {
|
||||
const capabilities = createCordnMetadataCapabilities();
|
||||
if (lastResort) {
|
||||
capabilities.extensions = [
|
||||
...new Set([...capabilities.extensions, APP_DATA_DICTIONARY_EXTENSION_TYPE]),
|
||||
];
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
export async function createKeyPackage(
|
||||
stablePubkey: string,
|
||||
options: { lastResort?: boolean } = {},
|
||||
): Promise<CordnKeyPackage> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
const generated = await generateKeyPackage({
|
||||
credential: createCredential(stablePubkey),
|
||||
cipherSuite,
|
||||
capabilities: keyPackageCapabilities(Boolean(options.lastResort)),
|
||||
extensions: options.lastResort ? ensureLastResortKeyPackageExtension([]) : undefined,
|
||||
});
|
||||
const ref = bytesToHex(await makeKeyPackageRef(generated.publicPackage, cipherSuite.hash));
|
||||
return {
|
||||
keyPackage: generated.publicPackage,
|
||||
privateKeyPackage: generated.privatePackage,
|
||||
keyPackageRef: ref,
|
||||
keyPackageBase64: encodeBase64(tlsEncode(keyPackageEncoder, generated.publicPackage)),
|
||||
isLastResort: isLastResortKeyPackage(generated.publicPackage),
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a peer's key package advertises Cordn group-metadata support. */
|
||||
export function keyPackageSupportsCordnMetadata(keyPackage: KeyPackage): boolean {
|
||||
return keyPackage.leafNode.capabilities.extensions.includes(
|
||||
CORDN_GROUP_METADATA_EXTENSION_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── state serialization ──────────────────────────────────────────────────── */
|
||||
|
||||
export function serializeState(state: ClientState): string {
|
||||
return encodeBase64(tlsEncode(clientStateEncoder, state));
|
||||
}
|
||||
|
||||
export function deserializeState(base64: string): ClientState {
|
||||
const decoded = clientStateDecoder(decodeBase64(base64), 0);
|
||||
if (!decoded) throw new Error('Failed to decode MLS client state');
|
||||
return decoded[0];
|
||||
}
|
||||
|
||||
export function serializeKeyPackage(kp: KeyPackage): string {
|
||||
return encodeBase64(tlsEncode(keyPackageEncoder, kp));
|
||||
}
|
||||
|
||||
export function serializePrivateKeyPackage(kp: PrivateKeyPackage): string {
|
||||
return encodeBase64(tlsEncode(privateKeyPackageEncoder, kp));
|
||||
}
|
||||
|
||||
/* ── group lifecycle ──────────────────────────────────────────────────────── */
|
||||
|
||||
/** The delivery group id — decoded verbatim from the MLS group context. */
|
||||
export function groupIdOf(state: ClientState): string {
|
||||
return textDecoder.decode(state.groupContext.groupId);
|
||||
}
|
||||
|
||||
export function groupMetadataOf(state: ClientState): CordnGroupMetadata | undefined {
|
||||
return getCordnGroupMetadataExtension(state);
|
||||
}
|
||||
|
||||
export async function createGroupState(params: {
|
||||
groupId: string;
|
||||
keyPackage: KeyPackage;
|
||||
privateKeyPackage: PrivateKeyPackage;
|
||||
metadata?: CordnGroupMetadata;
|
||||
}): Promise<ClientState> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
return createGroup({
|
||||
context: { cipherSuite, authService: unsafeTestingAuthenticationService },
|
||||
groupId: textEncoder.encode(params.groupId),
|
||||
keyPackage: params.keyPackage,
|
||||
privateKeyPackage: params.privateKeyPackage,
|
||||
extensions: params.metadata ? [makeCordnGroupMetadataExtension(params.metadata)] : [],
|
||||
});
|
||||
}
|
||||
|
||||
export interface MemberArtifacts {
|
||||
newState: ClientState;
|
||||
/** Base64 of the framed MLS Commit. */
|
||||
commitMessageBase64: string;
|
||||
/** Raw Welcome (needed by the added member to join). */
|
||||
welcome: Welcome;
|
||||
}
|
||||
|
||||
export async function addMemberToGroup(params: {
|
||||
state: ClientState;
|
||||
memberKeyPackage: KeyPackage;
|
||||
}): Promise<MemberArtifacts> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
const result = await createCommit({
|
||||
context: { cipherSuite, authService: unsafeTestingAuthenticationService },
|
||||
state: params.state,
|
||||
ratchetTreeExtension: true,
|
||||
extraProposals: [{ proposalType: defaultProposalTypes.add, add: { keyPackage: params.memberKeyPackage } }],
|
||||
});
|
||||
if (!result.welcome) throw new Error('MLS commit produced no Welcome');
|
||||
return {
|
||||
newState: result.newState,
|
||||
commitMessageBase64: encodeMlsMessage(result.commit),
|
||||
welcome: result.welcome.welcome,
|
||||
};
|
||||
}
|
||||
|
||||
export async function joinFromWelcome(params: {
|
||||
welcome: Welcome;
|
||||
keyPackage: KeyPackage;
|
||||
privateKeyPackage: PrivateKeyPackage;
|
||||
}): Promise<ClientState> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
return joinGroup({
|
||||
context: { cipherSuite, authService: unsafeTestingAuthenticationService },
|
||||
welcome: params.welcome,
|
||||
keyPackage: params.keyPackage,
|
||||
privateKeys: params.privateKeyPackage,
|
||||
});
|
||||
}
|
||||
|
||||
/** All non-blank members of the current ratchet tree. */
|
||||
export function listGroupMembers(state: ClientState): Array<{ leafIndex: number; pubkey: string }> {
|
||||
const tree = state.ratchetTree;
|
||||
if (!tree) return [];
|
||||
const members: Array<{ leafIndex: number; pubkey: string }> = [];
|
||||
for (let index = 0; index < tree.length; index += 1) {
|
||||
const node = tree[index];
|
||||
if (node?.nodeType !== 1) continue;
|
||||
const credential = node.leaf?.credential;
|
||||
if (credential && 'identity' in credential && credential.identity) {
|
||||
members.push({ leafIndex: index / 2, pubkey: textDecoder.decode(credential.identity) });
|
||||
}
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
/* ── messages ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function encodeMlsMessage(message: Parameters<typeof mlsMessageEncoder>[0]): string {
|
||||
return encodeBase64(tlsEncode(mlsMessageEncoder, message));
|
||||
}
|
||||
|
||||
export function decodeMlsMessage(base64: string) {
|
||||
const decoded = mlsMessageDecoder(decodeBase64(base64), 0);
|
||||
if (!decoded) throw new Error('Invalid MLS message');
|
||||
return decoded[0];
|
||||
}
|
||||
|
||||
/* -- application envelope (the kind:9 event carried inside MLS) ------------- */
|
||||
|
||||
export interface CordnEnvelope {
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function createEnvelope(params: {
|
||||
pubkey: string;
|
||||
content: string;
|
||||
tags?: string[][];
|
||||
createdAt?: number;
|
||||
}): CordnEnvelope {
|
||||
return {
|
||||
pubkey: params.pubkey,
|
||||
created_at: params.createdAt ?? Math.floor(Date.now() / 1000),
|
||||
kind: 9,
|
||||
tags: params.tags ?? [],
|
||||
content: params.content,
|
||||
};
|
||||
}
|
||||
|
||||
export function finalizeEnvelope(envelope: CordnEnvelope): CordnEnvelope {
|
||||
const unsigned = {
|
||||
pubkey: envelope.pubkey,
|
||||
created_at: envelope.created_at,
|
||||
kind: envelope.kind,
|
||||
tags: envelope.tags,
|
||||
content: envelope.content,
|
||||
};
|
||||
return { ...unsigned, id: getEventHash(unsigned) };
|
||||
}
|
||||
|
||||
export function encodeEnvelope(envelope: CordnEnvelope): Uint8Array {
|
||||
return textEncoder.encode(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
export function decodeEnvelope(bytes: Uint8Array): CordnEnvelope {
|
||||
const parsed: unknown = JSON.parse(textDecoder.decode(bytes));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Invalid cordn message envelope');
|
||||
}
|
||||
const candidate = parsed as Record<string, unknown>;
|
||||
if ('sig' in candidate) throw new Error('Cordn envelope must not include sig');
|
||||
if (typeof candidate.id !== 'string') throw new Error('Invalid cordn envelope');
|
||||
const unsigned = {
|
||||
pubkey: String(candidate.pubkey ?? ''),
|
||||
created_at: Number(candidate.created_at ?? 0),
|
||||
kind: Number(candidate.kind ?? 0),
|
||||
tags: Array.isArray(candidate.tags) ? (candidate.tags as string[][]) : [],
|
||||
content: String(candidate.content ?? ''),
|
||||
};
|
||||
if (getEventHash(unsigned) !== candidate.id) {
|
||||
throw new Error('Cordn envelope id mismatch');
|
||||
}
|
||||
return { ...unsigned, id: candidate.id };
|
||||
}
|
||||
|
||||
export function prevLinksOf(tags: string[][]): string[] {
|
||||
return tags.filter((tag) => tag[0] === 'prev').map((tag) => tag[1]);
|
||||
}
|
||||
|
||||
/** Tips of the known causal DAG: ids no known record links as a parent. */
|
||||
export function causalPrevTags(ids: string[], prevLinks: string[][]): string[][] {
|
||||
const referenced = new Set<string>();
|
||||
for (const links of prevLinks) for (const id of links) referenced.add(id);
|
||||
return ids.filter((id) => !referenced.has(id)).map((id) => ['prev', id]);
|
||||
}
|
||||
|
||||
export interface CordnAppMessage {
|
||||
envelope: CordnEnvelope;
|
||||
/** Proven MLS sender (from the authenticated data), not the envelope's claim. */
|
||||
sender: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and MLS-encrypt an application message. Returns the new local state
|
||||
* (the sender's ratchet advances here — it must NOT re-process its own
|
||||
* message from the stream) and the framed MLS message (base64).
|
||||
*/
|
||||
export async function createGroupApplicationMessage(params: {
|
||||
state: ClientState;
|
||||
sender: string;
|
||||
content: string;
|
||||
tags?: string[][];
|
||||
}): Promise<{ newState: ClientState; messageBase64: string; envelope: CordnEnvelope }> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
const envelope = finalizeEnvelope(
|
||||
createEnvelope({ pubkey: params.sender, content: params.content, tags: params.tags }),
|
||||
);
|
||||
const result = await createApplicationMessage({
|
||||
context: { cipherSuite, authService: unsafeTestingAuthenticationService },
|
||||
state: params.state,
|
||||
message: encodeEnvelope(envelope),
|
||||
authenticatedData: textEncoder.encode(params.sender),
|
||||
});
|
||||
return { newState: result.newState, messageBase64: encodeMlsMessage(result.message), envelope };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a framed MLS message. Application messages yield the decrypted
|
||||
* envelope plus the proven sender (from the authenticated data); commits and
|
||||
* proposals advance state and carry no payload.
|
||||
*/
|
||||
export async function processGroupMessage(params: {
|
||||
state: ClientState;
|
||||
messageBase64: string;
|
||||
callback?: IncomingMessageCallback;
|
||||
}): Promise<
|
||||
| { kind: 'application'; newState: ClientState; message: CordnAppMessage }
|
||||
| { kind: 'state'; newState: ClientState }
|
||||
> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
const message = decodeMlsMessage(params.messageBase64);
|
||||
if (message.wireformat !== 2 && message.wireformat !== 1) {
|
||||
throw new Error('Expected framed MLS message');
|
||||
}
|
||||
const result = await processMessage({
|
||||
context: { cipherSuite, authService: unsafeTestingAuthenticationService },
|
||||
state: params.state,
|
||||
message,
|
||||
callback: params.callback,
|
||||
});
|
||||
if (result.kind === 'applicationMessage') {
|
||||
const envelope = decodeEnvelope(result.message);
|
||||
const sender = textDecoder.decode(result.aad);
|
||||
return { kind: 'application', newState: result.newState, message: { envelope, sender } };
|
||||
}
|
||||
return { kind: 'state', newState: result.newState };
|
||||
}
|
||||
|
||||
/* ── coordinator payload wrapper ──────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Seal a framed MLS message for the coordinator: ChaCha20-Poly1305 under a key
|
||||
* derived from the current epoch's MLS exporter secret. The coordinator stores
|
||||
* opaque bytes; only group members can open them.
|
||||
*/
|
||||
export async function sealForCoordinator(params: {
|
||||
state: ClientState;
|
||||
messageBase64: string;
|
||||
}): Promise<string> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
const key = await mlsExporter(
|
||||
params.state.keySchedule.exporterSecret,
|
||||
'cordn',
|
||||
textEncoder.encode('group-payload'),
|
||||
32,
|
||||
cipherSuite,
|
||||
);
|
||||
const nonce = randomBytes(12);
|
||||
const ciphertext = chacha20poly1305(key, nonce, new Uint8Array(0)).encrypt(
|
||||
decodeBase64(params.messageBase64),
|
||||
);
|
||||
return encodeBase64(concatBytes(nonce, ciphertext));
|
||||
}
|
||||
|
||||
/** Open a sealed coordinator payload back into framed MLS bytes (base64). */
|
||||
export async function openFromCoordinator(params: {
|
||||
state: ClientState;
|
||||
sealedBase64: string;
|
||||
}): Promise<string> {
|
||||
const cipherSuite = await getCipherSuite();
|
||||
const payload = decodeBase64(params.sealedBase64);
|
||||
const nonce = payload.subarray(0, 12);
|
||||
const ciphertext = payload.subarray(12);
|
||||
const key = await mlsExporter(
|
||||
params.state.keySchedule.exporterSecret,
|
||||
'cordn',
|
||||
textEncoder.encode('group-payload'),
|
||||
32,
|
||||
cipherSuite,
|
||||
);
|
||||
const plaintext = chacha20poly1305(key, nonce, new Uint8Array(0)).decrypt(ciphertext);
|
||||
return encodeBase64(plaintext);
|
||||
}
|
||||
54
src/lib/cordn/native/native.live.test.ts
Normal file
54
src/lib/cordn/native/native.live.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// @vitest-environment node
|
||||
/**
|
||||
* Live Cordn-native smoke test — runs ONLY with `CORDN_LIVE=1` (it talks to the
|
||||
* real coordinator at `wss://relay.skate-spots.org`). Kept out of the default
|
||||
* `npm test` run so CI never depends on the network.
|
||||
*
|
||||
* CORDN_LIVE=1 npx vitest run src/lib/cordn/native/native.live.test.ts
|
||||
*
|
||||
* Runs in the Node environment (not jsdom): the ContextVM transport's nested
|
||||
* nostr-tools/@noble/hashes reject a Uint8Array from another realm.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CordnSession, MemoryCordnStore } from './session';
|
||||
import { SKATESPOTS_COORDINATOR } from '../../coordinator';
|
||||
|
||||
const live = process.env.CORDN_LIVE === '1';
|
||||
|
||||
describe.skipIf(!live)('cordn native (live)', () => {
|
||||
it('creates a group, adds a member, and exchanges MLS messages', async () => {
|
||||
const coordinator = {
|
||||
serverPubkey: SKATESPOTS_COORDINATOR.pubkey,
|
||||
relays: SKATESPOTS_COORDINATOR.relays,
|
||||
};
|
||||
|
||||
const a = await CordnSession.open({ coordinator, store: new MemoryCordnStore() });
|
||||
const b = await CordnSession.open({ coordinator, store: new MemoryCordnStore() });
|
||||
|
||||
await a.generateKeyPackage('a');
|
||||
const bKeyPackage = await b.generateKeyPackage('b');
|
||||
await a.publishKeyPackage('a');
|
||||
await b.publishKeyPackage('b');
|
||||
|
||||
const groupId = `skate-native-${Date.now()}`;
|
||||
const group = await a.createGroup({ groupId, name: 'Native test' });
|
||||
const { welcomeBase64 } = await a.addMember(group.gid, bKeyPackage.keyPackage);
|
||||
|
||||
const bGroup = await b.joinGroup({ welcomeBase64, keyPackage: bKeyPackage, name: 'Native test' });
|
||||
expect(bGroup.members.map((m) => m.pubkey)).toContain(a.pubkey);
|
||||
expect(bGroup.members.map((m) => m.pubkey)).toContain(b.pubkey);
|
||||
|
||||
await a.send(group.gid, 'hello from A');
|
||||
const receivedByB = await b.fetch(bGroup.gid);
|
||||
expect(receivedByB.map((m) => m.content)).toContain('hello from A');
|
||||
expect(receivedByB.find((m) => m.content === 'hello from A')?.sender).toBe(a.pubkey);
|
||||
|
||||
await b.send(bGroup.gid, 'hi from B');
|
||||
const receivedByA = await a.fetch(group.gid);
|
||||
expect(receivedByA.map((m) => m.content)).toContain('hi from B');
|
||||
expect(receivedByA.find((m) => m.content === 'hi from B')?.sender).toBe(b.pubkey);
|
||||
|
||||
await a.disconnect();
|
||||
await b.disconnect();
|
||||
}, 180_000);
|
||||
});
|
||||
449
src/lib/cordn/native/session.ts
Normal file
449
src/lib/cordn/native/session.ts
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
/**
|
||||
* Cordn native — session.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* A browser-safe Cordn client session: identity, key packages, group creation,
|
||||
* MLS message send/receive and bounded catch-up, all against a coordinator. This
|
||||
* is the "Level 2" foundation — real RFC 9420 MLS, not the sealed-relay
|
||||
* stand-in in `../client.ts`.
|
||||
*
|
||||
* The session is isomorphic (no Node built-ins, no DOM); persistence is behind
|
||||
* the injected {@link CordnStore} so the browser can back it with IndexedDB and
|
||||
* tests with {@link MemoryCordnStore}.
|
||||
*/
|
||||
|
||||
import {
|
||||
createKeyPackage,
|
||||
createGroupState,
|
||||
addMemberToGroup,
|
||||
joinFromWelcome,
|
||||
createGroupApplicationMessage,
|
||||
processGroupMessage,
|
||||
sealForCoordinator,
|
||||
openFromCoordinator,
|
||||
groupIdOf,
|
||||
groupMetadataOf,
|
||||
listGroupMembers,
|
||||
serializeState,
|
||||
deserializeState,
|
||||
serializePrivateKeyPackage,
|
||||
createIdentity,
|
||||
causalPrevTags,
|
||||
type CordnMlsIdentity,
|
||||
} from './mls';
|
||||
import { CordnCoordinatorClient, type CordnCoordinatorConfig } from './coordinator';
|
||||
import {
|
||||
decodeBase64,
|
||||
decodeKeyPackage,
|
||||
decodePrivateKeyPackage,
|
||||
decodeWelcome,
|
||||
encodeBase64,
|
||||
encodeKeyPackage,
|
||||
encodeWelcome,
|
||||
} from '@cordn/core';
|
||||
import type { ClientState, KeyPackage, PrivateKeyPackage } from 'ts-mls';
|
||||
|
||||
/* ── stored shapes ────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface StoredKeyPackage {
|
||||
alias: string;
|
||||
keyPackage: KeyPackage;
|
||||
privateKeyPackage: PrivateKeyPackage;
|
||||
keyPackageRef: string;
|
||||
isLastResort: boolean;
|
||||
publishedAt?: number;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
export interface CordnChatMessage {
|
||||
id: string;
|
||||
/** Proven MLS sender pubkey (from the MLS authenticated data). */
|
||||
sender: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
cursor: number;
|
||||
direction: 'inbound' | 'outbound';
|
||||
/** Envelope ids this message causally follows (`prev` tags). */
|
||||
prev: string[];
|
||||
}
|
||||
|
||||
export interface CordnGroupInfo {
|
||||
gid: string;
|
||||
name?: string;
|
||||
members: Array<{ pubkey: string; leafIndex: number }>;
|
||||
cursor: number;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
interface RuntimeGroup {
|
||||
gid: string;
|
||||
name?: string;
|
||||
state: ClientState;
|
||||
cursor: number;
|
||||
messages: CordnChatMessage[];
|
||||
/** Sealed wrappers we posted, so self-echoes are skipped on fetch. */
|
||||
posted: Set<string>;
|
||||
}
|
||||
|
||||
/* ── persistence ──────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CordnSnapshot {
|
||||
version: 1;
|
||||
privateKeyHex: string;
|
||||
keyPackages: Array<{
|
||||
alias: string;
|
||||
keyPackageBase64: string;
|
||||
privateKeyPackageBase64: string;
|
||||
keyPackageRef: string;
|
||||
isLastResort: boolean;
|
||||
publishedAt?: number;
|
||||
consumed: boolean;
|
||||
}>;
|
||||
groups: Array<{
|
||||
gid: string;
|
||||
name?: string;
|
||||
stateBase64: string;
|
||||
cursor: number;
|
||||
messages: CordnChatMessage[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CordnStore {
|
||||
load(): Promise<CordnSnapshot | null>;
|
||||
save(snapshot: CordnSnapshot): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
/** In-memory store — used by tests and as the default fallback. */
|
||||
export class MemoryCordnStore implements CordnStore {
|
||||
private snapshot: CordnSnapshot | null = null;
|
||||
async load(): Promise<CordnSnapshot | null> {
|
||||
return this.snapshot;
|
||||
}
|
||||
async save(snapshot: CordnSnapshot): Promise<void> {
|
||||
this.snapshot = snapshot;
|
||||
}
|
||||
async clear(): Promise<void> {
|
||||
this.snapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── session ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface CordnSessionOptions {
|
||||
coordinator: Pick<CordnCoordinatorConfig, 'serverPubkey' | 'relays'>;
|
||||
/** Existing secret (hex); omit to mint a fresh identity. */
|
||||
privateKeyHex?: string;
|
||||
store?: CordnStore;
|
||||
encryptTransport?: boolean;
|
||||
}
|
||||
|
||||
export class CordnSession {
|
||||
readonly identity: CordnMlsIdentity;
|
||||
private readonly options: CordnSessionOptions;
|
||||
private client: CordnCoordinatorClient | undefined;
|
||||
private readonly store: CordnStore;
|
||||
private keyPackages: StoredKeyPackage[] = [];
|
||||
private groups = new Map<string, RuntimeGroup>();
|
||||
private saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private constructor(identity: CordnMlsIdentity, options: CordnSessionOptions) {
|
||||
this.identity = identity;
|
||||
this.options = options;
|
||||
this.store = options.store ?? new MemoryCordnStore();
|
||||
}
|
||||
|
||||
|
||||
/** Open a session, restoring persisted state when a store has it. */
|
||||
static async open(options: CordnSessionOptions): Promise<CordnSession> {
|
||||
const store = options.store ?? new MemoryCordnStore();
|
||||
const snapshot = await store.load();
|
||||
const identity = createIdentity(options.privateKeyHex ?? snapshot?.privateKeyHex);
|
||||
const session = new CordnSession(identity, { ...options, store });
|
||||
if (snapshot) session.restore(snapshot);
|
||||
return session;
|
||||
}
|
||||
|
||||
get pubkey(): string {
|
||||
return this.identity.pubkey;
|
||||
}
|
||||
|
||||
private getClient(): CordnCoordinatorClient {
|
||||
this.client ??= new CordnCoordinatorClient({
|
||||
serverPubkey: this.options.coordinator.serverPubkey,
|
||||
relays: this.options.coordinator.relays,
|
||||
privateKeyHex: this.identity.privateKeyHex,
|
||||
encryptTransport: this.options.encryptTransport,
|
||||
});
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.client?.disconnect();
|
||||
this.client = undefined;
|
||||
}
|
||||
|
||||
/* ── snapshot ───────────────────────────────────────────────────────────── */
|
||||
|
||||
private restore(snapshot: CordnSnapshot): void {
|
||||
this.keyPackages = snapshot.keyPackages.map((kp) => ({
|
||||
alias: kp.alias,
|
||||
keyPackage: decodeKeyPackage(decodeBase64(kp.keyPackageBase64), 'snapshot key package'),
|
||||
privateKeyPackage: decodePrivateKeyPackage(decodeBase64(kp.privateKeyPackageBase64), 'snapshot private key package'),
|
||||
keyPackageRef: kp.keyPackageRef,
|
||||
isLastResort: kp.isLastResort,
|
||||
publishedAt: kp.publishedAt,
|
||||
consumed: kp.consumed,
|
||||
}));
|
||||
this.groups = new Map(
|
||||
snapshot.groups.map((g) => [
|
||||
g.gid,
|
||||
{
|
||||
gid: g.gid,
|
||||
name: g.name,
|
||||
state: deserializeState(g.stateBase64),
|
||||
cursor: g.cursor,
|
||||
messages: g.messages,
|
||||
posted: new Set<string>(),
|
||||
} satisfies RuntimeGroup,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private snapshot(): CordnSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
privateKeyHex: this.identity.privateKeyHex,
|
||||
keyPackages: this.keyPackages.map((kp) => ({
|
||||
alias: kp.alias,
|
||||
keyPackageBase64: encodeBase64(encodeKeyPackage(kp.keyPackage)),
|
||||
privateKeyPackageBase64: serializePrivateKeyPackage(kp.privateKeyPackage),
|
||||
keyPackageRef: kp.keyPackageRef,
|
||||
isLastResort: kp.isLastResort,
|
||||
publishedAt: kp.publishedAt,
|
||||
consumed: kp.consumed,
|
||||
})),
|
||||
groups: [...this.groups.values()].map((g) => ({
|
||||
gid: g.gid,
|
||||
name: g.name,
|
||||
stateBase64: serializeState(g.state),
|
||||
cursor: g.cursor,
|
||||
messages: g.messages,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private persist(): Promise<void> {
|
||||
this.saveQueue = this.saveQueue
|
||||
.catch(() => undefined)
|
||||
.then(() => this.store.save(this.snapshot()));
|
||||
return this.saveQueue;
|
||||
}
|
||||
|
||||
/* ── key packages ───────────────────────────────────────────────────────── */
|
||||
|
||||
listKeyPackages(): StoredKeyPackage[] {
|
||||
return this.keyPackages;
|
||||
}
|
||||
|
||||
/** Generate (and store) a key package others can use to add this identity. */
|
||||
async generateKeyPackage(alias = `kp-${this.keyPackages.length + 1}`): Promise<StoredKeyPackage> {
|
||||
const created = await createKeyPackage(this.identity.pubkey);
|
||||
const stored: StoredKeyPackage = {
|
||||
alias,
|
||||
keyPackage: created.keyPackage,
|
||||
privateKeyPackage: created.privateKeyPackage,
|
||||
keyPackageRef: created.keyPackageRef,
|
||||
isLastResort: created.isLastResort,
|
||||
consumed: false,
|
||||
};
|
||||
this.keyPackages.push(stored);
|
||||
await this.persist();
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** Publish a stored key package to the coordinator. */
|
||||
async publishKeyPackage(alias?: string): Promise<StoredKeyPackage> {
|
||||
const stored =
|
||||
(alias ? this.keyPackages.find((kp) => kp.alias === alias) : undefined) ??
|
||||
this.keyPackages.find((kp) => !kp.consumed && kp.publishedAt === undefined) ??
|
||||
(await this.generateKeyPackage());
|
||||
const result = await this.getClient().publishKeyPackage(
|
||||
stored.keyPackageRef,
|
||||
encodeBase64(encodeKeyPackage(stored.keyPackage)),
|
||||
);
|
||||
stored.publishedAt = result.at;
|
||||
stored.isLastResort = result.last_resort;
|
||||
await this.persist();
|
||||
return stored;
|
||||
}
|
||||
|
||||
/* ── groups ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
listGroups(): CordnGroupInfo[] {
|
||||
return [...this.groups.values()].map((g) => this.describeGroup(g));
|
||||
}
|
||||
|
||||
private describeGroup(g: RuntimeGroup): CordnGroupInfo {
|
||||
return {
|
||||
gid: g.gid,
|
||||
name: g.name ?? groupMetadataOf(g.state)?.name,
|
||||
members: listGroupMembers(g.state),
|
||||
cursor: g.cursor,
|
||||
messageCount: g.messages.length,
|
||||
};
|
||||
}
|
||||
|
||||
private requireGroup(gid: string): RuntimeGroup {
|
||||
const group = this.groups.get(gid);
|
||||
if (!group) throw new Error(`Unknown group: ${gid}`);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Create a new MLS group. `groupId` defaults to a random id. */
|
||||
async createGroup(params: { groupId?: string; name?: string } = {}): Promise<CordnGroupInfo> {
|
||||
const keyPackage =
|
||||
this.keyPackages.find((kp) => !kp.consumed) ?? (await this.generateKeyPackage());
|
||||
const groupId = params.groupId ?? crypto.randomUUID();
|
||||
const state = await createGroupState({
|
||||
groupId,
|
||||
keyPackage: keyPackage.keyPackage,
|
||||
privateKeyPackage: keyPackage.privateKeyPackage,
|
||||
metadata: params.name ? { name: params.name } : undefined,
|
||||
});
|
||||
keyPackage.consumed = true;
|
||||
const group: RuntimeGroup = {
|
||||
gid: groupId,
|
||||
name: params.name,
|
||||
state,
|
||||
cursor: 0,
|
||||
messages: [],
|
||||
posted: new Set(),
|
||||
};
|
||||
this.groups.set(group.gid, group);
|
||||
await this.persist();
|
||||
return this.describeGroup(group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member from their key package and return the Welcome the member needs
|
||||
* to join. The commit is posted to the coordinator and the local epoch adopts
|
||||
* the new state.
|
||||
*/
|
||||
async addMember(gid: string, memberKeyPackage: KeyPackage): Promise<{ welcomeBase64: string }> {
|
||||
const group = this.requireGroup(gid);
|
||||
const { newState, commitMessageBase64, welcome } = await addMemberToGroup({
|
||||
state: group.state,
|
||||
memberKeyPackage,
|
||||
});
|
||||
// Seal the commit with the PRE-commit epoch so every existing member can
|
||||
// decrypt it (mirrors the reference client).
|
||||
const sealed = await sealForCoordinator({ state: group.state, messageBase64: commitMessageBase64 });
|
||||
const posted = await this.getClient().postGroupMessage(group.gid, sealed);
|
||||
group.posted.add(sealed);
|
||||
group.state = newState;
|
||||
group.cursor = Math.max(group.cursor, posted.cursor);
|
||||
await this.persist();
|
||||
return { welcomeBase64: encodeBase64(encodeWelcome(welcome)) };
|
||||
}
|
||||
|
||||
/** Join a group from a Welcome produced by {@link addMember}. */
|
||||
async joinGroup(params: {
|
||||
welcomeBase64: string;
|
||||
keyPackage: StoredKeyPackage;
|
||||
name?: string;
|
||||
}): Promise<CordnGroupInfo> {
|
||||
const state = await joinFromWelcome({
|
||||
welcome: decodeWelcome(decodeBase64(params.welcomeBase64), 'welcome'),
|
||||
keyPackage: params.keyPackage.keyPackage,
|
||||
privateKeyPackage: params.keyPackage.privateKeyPackage,
|
||||
});
|
||||
params.keyPackage.consumed = true;
|
||||
const gid = groupIdOf(state);
|
||||
const group: RuntimeGroup = {
|
||||
gid,
|
||||
name: params.name,
|
||||
state,
|
||||
cursor: 0,
|
||||
messages: [],
|
||||
posted: new Set(),
|
||||
};
|
||||
this.groups.set(gid, group);
|
||||
await this.persist();
|
||||
return this.describeGroup(group);
|
||||
}
|
||||
|
||||
/* ── messaging ──────────────────────────────────────────────────────────── */
|
||||
|
||||
messages(gid: string): CordnChatMessage[] {
|
||||
return [...this.requireGroup(gid).messages].sort((a, b) => a.cursor - b.cursor);
|
||||
}
|
||||
|
||||
/** Encrypt, post and locally adopt an application message. */
|
||||
async send(gid: string, content: string): Promise<CordnChatMessage> {
|
||||
const group = this.requireGroup(gid);
|
||||
const { newState, messageBase64, envelope } = await createGroupApplicationMessage({
|
||||
state: group.state,
|
||||
sender: this.identity.pubkey,
|
||||
content,
|
||||
tags: causalPrevTags(
|
||||
group.messages.map((m) => m.id),
|
||||
group.messages.map((m) => m.prev),
|
||||
),
|
||||
});
|
||||
const sealed = await sealForCoordinator({ state: group.state, messageBase64 });
|
||||
const posted = await this.getClient().postGroupMessage(group.gid, sealed);
|
||||
group.state = newState;
|
||||
group.posted.add(sealed);
|
||||
const stored: CordnChatMessage = {
|
||||
id: envelope.id ?? '',
|
||||
sender: this.identity.pubkey,
|
||||
content,
|
||||
createdAt: envelope.created_at,
|
||||
cursor: posted.cursor,
|
||||
direction: 'outbound',
|
||||
prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]),
|
||||
};
|
||||
group.messages.push(stored);
|
||||
group.cursor = Math.max(group.cursor, posted.cursor);
|
||||
await this.persist();
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** Bounded catch-up: fetch and decrypt everything after this group's cursor. */
|
||||
async fetch(gid: string): Promise<CordnChatMessage[]> {
|
||||
const group = this.requireGroup(gid);
|
||||
const result = await this.getClient().fetchManyGroupMessages([
|
||||
{ gid: group.gid, after: group.cursor > 0 ? group.cursor : undefined },
|
||||
]);
|
||||
const received: CordnChatMessage[] = [];
|
||||
const sorted = [...result.messages].sort((a, b) => a.cursor - b.cursor);
|
||||
for (const message of sorted) {
|
||||
group.cursor = Math.max(group.cursor, message.cursor);
|
||||
if (group.posted.has(message.msg_64)) continue; // our own self-echo
|
||||
let messageBase64: string;
|
||||
try {
|
||||
messageBase64 = await openFromCoordinator({ state: group.state, sealedBase64: message.msg_64 });
|
||||
} catch {
|
||||
continue; // sealed under an epoch we have not joined yet
|
||||
}
|
||||
const processed = await processGroupMessage({ state: group.state, messageBase64 });
|
||||
group.state = processed.newState;
|
||||
if (processed.kind !== 'application') continue;
|
||||
const { envelope, sender } = processed.message;
|
||||
const stored: CordnChatMessage = {
|
||||
id: envelope.id ?? message.msg_64,
|
||||
sender,
|
||||
content: envelope.content,
|
||||
createdAt: envelope.created_at,
|
||||
cursor: message.cursor,
|
||||
direction: sender === this.identity.pubkey ? 'outbound' : 'inbound',
|
||||
prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]),
|
||||
};
|
||||
group.messages.push(stored);
|
||||
received.push(stored);
|
||||
}
|
||||
await this.persist();
|
||||
return received;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,26 +1,31 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
// jsdom-only mocks. Node-environment test files (e.g. the live Cordn-native
|
||||
// test) have no `window`, so everything here is guarded — otherwise merely
|
||||
// collecting such a file throws "window is not defined".
|
||||
if (typeof window !== 'undefined') {
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock window.scrollTo
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
// Mock window.scrollTo
|
||||
Object.defineProperty(window, 'scrollTo', {
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation((_callback) => ({
|
||||
|
|
@ -37,4 +42,4 @@ global.ResizeObserver = vi.fn().mockImplementation((_callback) => ({
|
|||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in a new issue