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
|
say this honestly (2026-09-29). **Still future:** real MLS
|
||||||
(`ts-mls`) instead of group-key crypto, plus Cordn RPC interop. **Lesson: grep
|
(`ts-mls`) instead of group-key crypto, plus Cordn RPC interop. **Lesson: grep
|
||||||
`src` for the feature before building it — this was nearly duplicated.**
|
`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
|
- **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
|
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
|
||||||
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an
|
(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/geolocation": "^8.2.0",
|
||||||
"@capacitor/local-notifications": "^8.3.1",
|
"@capacitor/local-notifications": "^8.3.1",
|
||||||
"@cashu/cashu-ts": "2.8.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/inter": "^5.2.6",
|
||||||
"@fontsource-variable/outfit": "^5.2.8",
|
"@fontsource-variable/outfit": "^5.2.8",
|
||||||
"@fontsource/barlow-condensed": "^5.2.8",
|
"@fontsource/barlow-condensed": "^5.2.8",
|
||||||
|
|
@ -28,6 +30,7 @@
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@netlify/blobs": "^11.1.1",
|
"@netlify/blobs": "^11.1.1",
|
||||||
|
"@noble/ciphers": "2.2.0",
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@nostrify/nostrify": "^0.47.1",
|
"@nostrify/nostrify": "^0.47.1",
|
||||||
|
|
@ -517,14 +520,14 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@contextvm/sdk": {
|
"node_modules/@contextvm/sdk": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.5",
|
||||||
"resolved": "https://registry.npmjs.org/@contextvm/sdk/-/sdk-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@contextvm/sdk/-/sdk-0.12.5.tgz",
|
||||||
"integrity": "sha512-1mQbe337rXhorjJWsXv3+5LuyjWgoeFpC6M5j5mDT9uczQ8aGDtW+qKAf9OOKlGrvLt3chC61uuEDB/G4ArP4g==",
|
"integrity": "sha512-csX9vqjSaMDuPBsgYAK3gk60tV+2n02UZ4A2anl2aByTSRrCLcYkX7+XCi0JQ7lNjpXR9JPlNFlSz9LcvbQNCA==",
|
||||||
"license": "LGPL-3.0-1",
|
"license": "LGPL-3.0-1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@contextvm/mcp-sdk": "^1.29.1",
|
"@contextvm/mcp-sdk": "^1.30.0",
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
"applesauce-relay": "^5.2.0",
|
"applesauce-relay": "^6.0.3",
|
||||||
"canonicalize": "^2.1.0",
|
"canonicalize": "^2.1.0",
|
||||||
"nostr-tools": "~2.18.2",
|
"nostr-tools": "~2.18.2",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
|
|
@ -539,6 +542,15 @@
|
||||||
"typescript": "^5.9.3"
|
"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": {
|
"node_modules/@contextvm/sdk/node_modules/@noble/curves": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||||
|
|
@ -694,6 +706,52 @@
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
|
||||||
|
|
@ -1583,6 +1641,27 @@
|
||||||
"react-hook-form": "^7.0.0"
|
"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": {
|
"node_modules/@html-eslint/eslint-plugin": {
|
||||||
"version": "0.41.0",
|
"version": "0.41.0",
|
||||||
"resolved": "https://registry.npmjs.org/@html-eslint/eslint-plugin/-/eslint-plugin-0.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@html-eslint/eslint-plugin/-/eslint-plugin-0.41.0.tgz",
|
||||||
|
|
@ -2164,10 +2243,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/ciphers": {
|
"node_modules/@noble/ciphers": {
|
||||||
"version": "0.5.3",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz",
|
||||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
"integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
|
|
@ -5473,9 +5555,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/applesauce-core": {
|
"node_modules/applesauce-core": {
|
||||||
"version": "5.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-6.2.0.tgz",
|
||||||
"integrity": "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==",
|
"integrity": "sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
|
|
@ -5490,6 +5572,15 @@
|
||||||
"url": "lightning:nostrudel@geyser.fund"
|
"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": {
|
"node_modules/applesauce-core/node_modules/@noble/curves": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||||
|
|
@ -5607,13 +5698,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/applesauce-relay": {
|
"node_modules/applesauce-relay": {
|
||||||
"version": "5.2.0",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-6.2.1.tgz",
|
||||||
"integrity": "sha512-ty8PzHenocGdTr3x3It8Ql0rMD9rxB6VGCzGRfL5QF6epdstv2YHKuTyr8QdPBvf7yxfc7oZcMi6djSwNxXqkQ==",
|
"integrity": "sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^1.7.1",
|
"@noble/hashes": "^2.2.0",
|
||||||
"applesauce-core": "^5.2.0",
|
"applesauce-core": "^6.2.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"nostr-tools": "~2.19",
|
"nostr-tools": "~2.19",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
|
|
@ -5623,6 +5714,15 @@
|
||||||
"url": "lightning:nostrudel@geyser.fund"
|
"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": {
|
"node_modules/applesauce-relay/node_modules/@noble/curves": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||||
|
|
@ -5648,12 +5748,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/applesauce-relay/node_modules/@noble/hashes": {
|
"node_modules/applesauce-relay/node_modules/@noble/hashes": {
|
||||||
"version": "1.8.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.21.3 || >=16"
|
"node": ">= 20.19.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/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": {
|
"node_modules/nostr-tools/node_modules/@noble/curves": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||||
|
|
@ -12218,6 +12327,44 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
"@capacitor/local-notifications": "^8.3.1",
|
"@capacitor/local-notifications": "^8.3.1",
|
||||||
"@cashu/cashu-ts": "2.8.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/inter": "^5.2.6",
|
||||||
"@fontsource-variable/outfit": "^5.2.8",
|
"@fontsource-variable/outfit": "^5.2.8",
|
||||||
"@fontsource/barlow-condensed": "^5.2.8",
|
"@fontsource/barlow-condensed": "^5.2.8",
|
||||||
|
|
@ -48,6 +50,7 @@
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@netlify/blobs": "^11.1.1",
|
"@netlify/blobs": "^11.1.1",
|
||||||
|
"@noble/ciphers": "2.2.0",
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@nostrify/nostrify": "^0.47.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,8 +1,12 @@
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
// Mock window.matchMedia
|
// jsdom-only mocks. Node-environment test files (e.g. the live Cordn-native
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
// 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,
|
writable: true,
|
||||||
value: vi.fn().mockImplementation((query) => ({
|
value: vi.fn().mockImplementation((query) => ({
|
||||||
matches: false,
|
matches: false,
|
||||||
|
|
@ -14,13 +18,14 @@ Object.defineProperty(window, 'matchMedia', {
|
||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock window.scrollTo
|
// Mock window.scrollTo
|
||||||
Object.defineProperty(window, 'scrollTo', {
|
Object.defineProperty(window, 'scrollTo', {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn(),
|
value: vi.fn(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mock IntersectionObserver
|
// Mock IntersectionObserver
|
||||||
global.IntersectionObserver = vi.fn().mockImplementation((_callback) => ({
|
global.IntersectionObserver = vi.fn().mockImplementation((_callback) => ({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue