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:
SkateSpots 2026-10-12 22:14:20 +07:00
parent 9251aea5b1
commit 1dcaf8cea3
9 changed files with 1382 additions and 42 deletions

View file

@ -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
View file

@ -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",

View file

@ -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",

View 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');
}

View 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
View 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);
}

View 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);
});

View 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;
}
}

View file

@ -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(),
}));
}));