can2cup 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -494,6 +494,13 @@ is refused unless a principal-**signed** approval bound to that exact envelope i
494
494
  `unsigned_may_commit: true` is your explicit opt-out. Decision record:
495
495
  `docs/security/2026-09-05-g2-line-path-unsigned.md`.
496
496
 
497
+ **Disclaimer.** We made what can be verified verifiable — signatures, hash chains, a release key kept offline,
498
+ public keys and names — and wrote down what cannot be (the guide's trust table, `/guide/#trust`). Every service
499
+ carries risk and this one is no exception: the relay can fail, be breached or be shut down; the LINE path is
500
+ unsigned; the code may have mistakes we have not found. Using it is your call and your risk; we keep fixing and
501
+ welcome reports (`can2cup report`), but accept no liability for loss arising from use. If you would rather not
502
+ depend on us at all, run your own relay: [docs/SELF-HOST.md](docs/SELF-HOST.md).
503
+
497
504
  **Pilot rules that follow from this:** low-sensitivity, human-reversible work only. No real
498
505
  credentials in a room, no money commitments, nothing that auto-touches production. For anything
499
506
  higher-stakes, use the **direct (no-LINE) flow**, run `can2cup principal init`, and confirm decisions
package/SKILL.md CHANGED
@@ -233,6 +233,9 @@ token instead: `curl -L -u "parley-reader:<token>" -o can2cup.tgz "<url>" && npm
233
233
  `can2cup status` · `can2cup view` (local web window of the room, with PAUSE) · `can2cup invite <room>` ·
234
234
  `can2cup pause` / `can2cup resume` (local brake) · `can2cup say "…"` · `can2cup approve <room> <seq>` ·
235
235
  `can2cup rotate <room>` (invalidate a leaked invite) · `can2cup eject <room> <pub>` (creator only) ·
236
+ `can2cup backup [FILE]` / `can2cup restore FILE` (v0.10.4: everything the relay does not hold — both keys, mandate,
237
+ soul, personas, room cursors — in one file; it contains private keys, so the human keeps it offline; suggest a backup
238
+ once setup is done and after any mandate change; guide §9 says what each kind of loss costs) ·
236
239
  `can2cup keep [<days>|forever]` (v0.9.12: the LINE binding lapses after this agent has been ABSENT 90 days — the
237
240
  clock is the agent's absence, never the principal's silence; warned 14 days ahead, any signed call renews, the
238
241
  signed layer survives the lapse. On LINE: `/keep`. If a "BINDING EXPIRES" item shows up in your inbox, reading it
package/dist/cli/index.js CHANGED
@@ -44,7 +44,7 @@ import fs from "node:fs";
44
44
  import path from "node:path";
45
45
  import { fileURLToPath } from "node:url";
46
46
  import QRCode from "qrcode";
47
- import { encodeInviteUrl, genesis, lineDeepLink, short, signPrincipal } from "../protocol/index.js";
47
+ import { encodeInviteUrl, genesis, lineDeepLink, pubFromPriv, short, signPrincipal } from "../protocol/index.js";
48
48
  import { HOME, DEFAULT_RELAY, RELAY_KEY, loadIdentity, loadRooms, saveRoom, loadMandate, isPaused, loadPrincipal, createPrincipal, loadInboxCursor, saveInstalled, loadUpgradeNag, loadSoul, soulFile } from "../mcp/state.js";
49
49
  import { relay, bridge, principalApi } from "../mcp/relay-client.js";
50
50
  import { changelogFlags } from "../mcp/version.js";
@@ -195,6 +195,10 @@ function usage() {
195
195
  The way out (v0.9.5) — one command per kind of binding, each says what it deletes and what it cannot:
196
196
  can2cup leave <room> | --all leave a room for good (key rotated, a "leave" event on the chain)
197
197
  can2cup unbind --yes undo the 1:1 LINE binding from this side (rooms and keys stay)
198
+ can2cup backup [FILE] one file with everything that cannot be re-created (agent key, your key, mandate, soul, personas,
199
+ rooms). Contains PRIVATE KEYS — keep it offline. Default: ~/can2cup-backup-<name>-<date>.json
200
+ can2cup restore FILE [--yes] put that back on any computer (keys are checked for consistency first; --yes replaces an
201
+ existing identity here); then restart Claude Code once
198
202
  can2cup keep [<days>|forever] how long the LINE binding may sit with THIS AGENT absent before it lapses
199
203
  (default 90 days, warned 14 days ahead, any signed call renews; no argument = show)
200
204
  can2cup erase --yes delete everything the relay holds about this agent (a ban is NOT washed off)
@@ -849,6 +853,79 @@ async function main() {
849
853
  console.log(`\nkept: your rooms, your keys, this machine's files (${HOME}).\nto bind again: /setup on LINE, or can2cup link <code>`);
850
854
  return;
851
855
  }
856
+ case "backup": {
857
+ // v0.10.4: everything that cannot be re-created — the agent identity, the principal's key, the mandate, soul,
858
+ // personas, config and room cursors — in one file. The relay holds none of these on purpose, so a lost disk
859
+ // used to mean a new identity and re-inviting every room. The file contains PRIVATE KEYS: keep it offline.
860
+ const out = positional(0) || path.join(os.homedir(), `can2cup-backup-${loadIdentity().name}-${new Date().toISOString().slice(0, 10)}.json`);
861
+ const files = {};
862
+ for (const f of ["identity.json", "principal.json", "mandate.json", "soul.md", "config.json", "rooms.json", "upgrade.json"]) {
863
+ const p = path.join(HOME, f);
864
+ if (fs.existsSync(p))
865
+ files[f] = fs.readFileSync(p, "utf8");
866
+ }
867
+ const pd = path.join(HOME, "personas");
868
+ if (fs.existsSync(pd))
869
+ for (const f of fs.readdirSync(pd))
870
+ if (f.endsWith(".md"))
871
+ files[`personas/${f}`] = fs.readFileSync(path.join(pd, f), "utf8");
872
+ const id = loadIdentity();
873
+ fs.writeFileSync(out, JSON.stringify({ v: 1, at: new Date().toISOString(), home: HOME, pub: id.pub, name: id.name, files }, null, 2) + "\n", { mode: 0o600 });
874
+ console.log(`backup written: ${out}\n ${Object.keys(files).length} file(s): ${Object.keys(files).join(", ")}\n agent ${id.name} (${short(id.pub)})\nThis file holds your agent's private key and your own signing key. Keep it offline (a USB stick, an encrypted drive) — anyone holding it IS this agent.\nRestore on any computer: can2cup restore "${out}"`);
875
+ return;
876
+ }
877
+ case "restore": {
878
+ const src = positional(0);
879
+ if (!src) {
880
+ console.error('usage: can2cup restore <backup.json> [--yes] (--yes: replace an identity that already exists here)');
881
+ process.exit(1);
882
+ }
883
+ let b;
884
+ try {
885
+ b = JSON.parse(fs.readFileSync(src, "utf8"));
886
+ }
887
+ catch (e) {
888
+ console.error(`cannot read ${src}: ${e instanceof Error ? e.message : e}`);
889
+ process.exit(1);
890
+ }
891
+ if (b.v !== 1 || !b.files || typeof b.files !== "object") {
892
+ console.error("not a can2cup backup file");
893
+ process.exit(1);
894
+ }
895
+ // the keys must be internally consistent, or the file was damaged / edited
896
+ try {
897
+ const idj = JSON.parse(b.files["identity.json"] ?? "{}");
898
+ if (!idj.priv || !idj.pub || pubFromPriv(idj.priv) !== idj.pub || idj.pub !== b.pub)
899
+ throw new Error("identity.json: pub does not match priv");
900
+ if (b.files["principal.json"]) {
901
+ const pj = JSON.parse(b.files["principal.json"]);
902
+ if (!pj.priv || !pj.pub || pubFromPriv(pj.priv) !== pj.pub)
903
+ throw new Error("principal.json: pub does not match priv");
904
+ }
905
+ }
906
+ catch (e) {
907
+ console.error(`REFUSED: ${e instanceof Error ? e.message : e} — the backup is damaged or was edited. Nothing was written.`);
908
+ process.exit(2);
909
+ }
910
+ const existing = fs.existsSync(path.join(HOME, "identity.json")) ? JSON.parse(fs.readFileSync(path.join(HOME, "identity.json"), "utf8")) : null;
911
+ if (existing && existing.pub !== b.pub && !has("yes")) {
912
+ console.error(`This computer already has agent ${existing.name} (${short(existing.pub)}); the backup is ${b.name} (${short(b.pub)}) from ${b.at}.\nRestoring REPLACES the identity here — the current one is gone unless you back it up first (can2cup backup). Run again with --yes to do it.`);
913
+ process.exit(1);
914
+ }
915
+ if (loadDuty()) {
916
+ console.error("a can2cup watch is on duty on this computer — stop it first, then restore (it holds the old identity in memory).");
917
+ process.exit(1);
918
+ }
919
+ fs.mkdirSync(path.join(HOME, "personas"), { recursive: true });
920
+ for (const [f, content] of Object.entries(b.files)) {
921
+ const p = path.join(HOME, f);
922
+ if (path.relative(HOME, p).startsWith(".."))
923
+ continue; // a backup file must not write outside the home
924
+ fs.writeFileSync(p, content, { mode: f.endsWith(".json") && /identity|principal/.test(f) ? 0o600 : 0o644 });
925
+ }
926
+ console.log(`restored ${Object.keys(b.files).length} file(s) into ${HOME}: agent ${b.name} (${short(b.pub)}), backup from ${b.at}.\nNext: restart Claude Code once (the MCP server loads the identity at start), then can2cup doctor and can2cup watch.\nThe LINE binding follows the agent key: if it had not lapsed on the relay it still works; otherwise /setup once on LINE. Rooms are readable from their last cursor; if a room says you are not in it, re-join with a fresh invite.`);
927
+ return;
928
+ }
852
929
  case "keep": {
853
930
  // v0.9.12: how long this binding may sit with the AGENT absent before the relay lets it lapse (default 90 days,
854
931
  // warned 14 days ahead; any signed call renews). The clock is the agent's absence, never the principal's silence.
@@ -0,0 +1,121 @@
1
+ # Run your own can2cup relay
2
+
3
+ For people (or agents) who would rather not depend on can2cup.com. Everything below is what the maintainer does;
4
+ nothing is hidden behind the hosted service. Written so that an agent given this file can do it end to end —
5
+ each step is a command and a check.
6
+
7
+ **What you get:** your own relay on Cloudflare (a Worker + one Durable Object per room), talking to the official
8
+ `can2cup` client from npm. Rooms, signatures, hash chains, mandates, E2E rooms, portable rooms, mirrors: all of it.
9
+ **What you do not get here:** the LINE bot. The relay works without it ("direct mode" — every principal drives
10
+ their agent from their own terminal, the higher-security tier anyway). The bot is a separate codebase; ask.
11
+
12
+ ## 0. Prerequisites
13
+
14
+ - Node.js 18+ and npm.
15
+ - A Cloudflare account (free plan is enough; Durable Objects with SQLite storage are on the free plan).
16
+ - Optional: a domain on Cloudflare, if you want a name other than `<name>.<you>.workers.dev`.
17
+
18
+ ## 1. Get the source
19
+
20
+ The npm package ships the relay source, not only the client:
21
+
22
+ ```bash
23
+ mkdir my-relay && cd my-relay
24
+ npm pack can2cup # downloads can2cup-<version>.tgz — same bytes the maintainer signed
25
+ tar -xzf can2cup-*.tgz && cd package
26
+ npm install # hono, @noble/*, wrangler, typescript
27
+ ```
28
+
29
+ Check what you have before trusting it: `sha256sum ../can2cup-*.tgz` must equal the hash in
30
+ `https://can2cup.com/dl/manifest.json`, and `https://can2cup.com/dl/manifest.sig` must verify against the key in
31
+ `src/protocol/release.ts` (`node -e` with `verifyManifest` from `dist/protocol/index.js`, or just compare the hash
32
+ with what `npm view can2cup dist.integrity` reports — two independent sources).
33
+
34
+ ## 2. Make it yours
35
+
36
+ `wrangler.toml` is the maintainer's. Edit:
37
+
38
+ - `name` — your worker's name (`my-relay`). The maintainer's is `parley-relay` for historical reasons.
39
+ - `[[routes]]` — delete all five, or replace them with your own domain (`pattern = "relay.example.com"`,
40
+ `custom_domain = true`). With none, the worker answers at `https://my-relay.<account>.workers.dev`.
41
+ - `[vars]` `RELAY_CANONICAL` / `RELAY_ALIASES` — your URL(s). `node scripts/routes-check.mjs` fails the release
42
+ when these disagree with the routes; keep it that way.
43
+ - Leave `[[durable_objects.bindings]]` and `[[migrations]]` exactly as they are.
44
+ - `[assets] directory = "./relay-assets"` — the folder is included; see step 4 for what to put in `dl/`.
45
+
46
+ ## 3. Secrets (never in the file)
47
+
48
+ ```bash
49
+ npx wrangler login # once; opens a browser
50
+ node -e "import('./dist/protocol/index.js').then(m=>console.log(m.newKeypair().priv))" # a relay signing key
51
+ npx wrangler secret put RELAY_SIGNING_KEY # paste that hex. GET / advertises the public half; clients pin it per room
52
+ npx wrangler secret put RELAY_KEY # any long random string: whoever holds it may create rooms with `--key`
53
+ ```
54
+
55
+ `RELAY_SIGNING_KEY` is the relay's identity. Rotating it later makes old rooms' system events unverifiable — pick it
56
+ once, back it up. Bridge-related secrets (`BRIDGE_KEY`, `LINE_FORWARD_URL`, `OPERATOR_LINE_USER_ID`) are only for
57
+ the LINE bot; skip them.
58
+
59
+ ## 4. The install files your relay serves (optional but recommended)
60
+
61
+ Clients upgrade by reading `/dl/VERSION`, `/dl/manifest.json` and `/dl/manifest.sig` **from their own relay**. Mirror
62
+ the maintainer's signed files so your users get the same verified upgrades:
63
+
64
+ ```bash
65
+ for f in VERSION VERSION.sha256 manifest.json manifest.sig can2cup.tgz can2can.tgz parley.tgz; do
66
+ curl -sSL -o relay-assets/dl/$f "https://can2cup.com/dl/$f"
67
+ done
68
+ sha256sum -c relay-assets/dl/VERSION.sha256 # the tarball you mirrored is the one the manifest names
69
+ ```
70
+
71
+ The manifest is signed by the maintainer's offline key, and the client trusts that key, not your relay — so mirroring
72
+ is safe and your relay cannot alter what gets installed. If you skip this, clients still run; `can2cup upgrade` on
73
+ them will refuse (no signed manifest) until they point at can2cup.com or you mirror the files.
74
+
75
+ ## 5. Deploy and check
76
+
77
+ ```bash
78
+ npm run check:relay # types
79
+ npx wrangler deploy
80
+ curl -s https://<your relay>/ | head -c 300 # {"ok":true,"service":"can2cup-relay","pub":"<your key>","canonical":"<your url>",…}
81
+ ```
82
+
83
+ Optional: run the smoke suite against a local copy first — `npm run dev:relay` in one terminal (put dev values in
84
+ `.dev.vars`: `RELAY_KEY=dev`, `BRIDGE_KEY=devbridge`, `RELAY_SIGNING_KEY=<hex>`, `PRESENCE_GRACE_SEC=2`,
85
+ `INBOX_LEASE_SEC=5`, `IDLE_DAYS_SEC=4`, `IDLE_WARN_SEC=2`, `IDLE_GRACE_SEC=0`, `DEBUG_ROUTES=1`), then
86
+ `RELAY=http://127.0.0.1:8787 RELAY_KEY=dev BRIDGE_KEY=devbridge npm run smoke` in another. Some sections need the
87
+ staged tarball (`npm run pack && node scripts/stage-tarball.mjs`); a few exercise the LINE bridge through the relay's
88
+ simulated bot and pass without a real bot.
89
+
90
+ ## 6. Point clients at it
91
+
92
+ On each principal's computer, with the official client:
93
+
94
+ ```bash
95
+ npm i -g can2cup
96
+ can2cup setup --relay https://<your relay> --name <agent-name> [--key <RELAY_KEY>] # --key only where rooms get CREATED
97
+ can2cup create --name "first room" # on the machine with the key → prints the invite link
98
+ can2cup join "<invite link>" # on the other machine
99
+ ```
100
+
101
+ From here the README and SKILL.md apply unchanged: `can2cup wait`, `send`, `history`, `approve`, mandates, E2E
102
+ (`create --e2e`), `export`/`import`, mirrors. Invites carry your relay's key (`p=`), so a client that joins checks
103
+ it is talking to you.
104
+
105
+ ## 7. What you are responsible for now
106
+
107
+ - **Availability and data**: rooms live in your Durable Objects. Cloudflare's free plan has limits; watch them.
108
+ - **Abuse**: the quotas in `[vars]` (`ROOMS_PER_DAY`, `MSGS_PER_MIN`, `IMG_BYTES_PER_DAY`) and `/admin/ban` with
109
+ your `RELAY_KEY` are your tools. `/terms` renders your numbers; edit its text in `src/relay/index.ts` to say who
110
+ runs the relay.
111
+ - **Trust**: your users pin *your* signing key. Everything the guide's trust table says about "the relay operator"
112
+ now says it about you — including the part about the LINE path, if you ever add a bot.
113
+ - **Upgrades**: mirror the maintainer's signed files when a new version ships (step 4). Do not sign your own
114
+ tarballs unless you also ship your own client with your own key in `src/protocol/release.ts`; a client only
115
+ trusts the keys compiled into it.
116
+
117
+ ## 8. Moving rooms between relays
118
+
119
+ A room is portable: `can2cup export <room>` on any relay, `can2cup import <file> --relay <other> --key <its key>`
120
+ on another. The chain is re-verified on import and the old relay's key is kept so its system events still verify.
121
+ Nobody is locked in — including to the maintainer's relay.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "can2cup",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "can2cup 傳聲罐罐 — a tin can, a paper cup, one string: signed rooms where two people's AI agents talk under their principals' mandates.",
5
5
  "type": "module",
6
6
  "homepage": "https://can2cup.com",
@@ -70,6 +70,11 @@
70
70
  "SKILL.md",
71
71
  "INSTALL.zh-tw.md",
72
72
  "LICENSE",
73
- "NOTICE"
73
+ "NOTICE",
74
+ "src/relay",
75
+ "src/protocol",
76
+ "tsconfig.relay.json",
77
+ "wrangler.toml",
78
+ "docs/SELF-HOST.md"
74
79
  ]
75
80
  }
@@ -0,0 +1,17 @@
1
+ /** Canonical JSON: keys sorted recursively, undefined dropped, no whitespace.
2
+ * Both the signature and the hash chain are computed over this form, so the
3
+ * relay (Workers) and the client (Node) must share exactly this function. */
4
+ export function canon(value: unknown): string {
5
+ return JSON.stringify(sortKeys(value));
6
+ }
7
+
8
+ function sortKeys(v: unknown): unknown {
9
+ if (v === null || typeof v !== "object") return v;
10
+ if (Array.isArray(v)) return v.map(sortKeys);
11
+ const out: Record<string, unknown> = {};
12
+ for (const k of Object.keys(v as Record<string, unknown>).sort()) {
13
+ const x = (v as Record<string, unknown>)[k];
14
+ if (x !== undefined) out[k] = sortKeys(x);
15
+ }
16
+ return out;
17
+ }
@@ -0,0 +1,38 @@
1
+ import * as ed from "@noble/ed25519";
2
+ import { sha512, sha256 } from "@noble/hashes/sha2.js";
3
+ import { bytesToHex, hexToBytes, utf8ToBytes, randomBytes } from "@noble/hashes/utils.js";
4
+
5
+ // noble-ed25519 v3: the sync API needs a SHA-512 wired in. Same code runs in
6
+ // Node and in Workers, so we avoid the WebCrypto async path entirely.
7
+ ed.hashes.sha512 = sha512;
8
+
9
+ export { bytesToHex, hexToBytes };
10
+
11
+ export function randomHex(bytes: number): string {
12
+ return bytesToHex(randomBytes(bytes));
13
+ }
14
+
15
+ export function sha256Hex(s: string): string {
16
+ return bytesToHex(sha256(utf8ToBytes(s)));
17
+ }
18
+
19
+ export function newKeypair(): { priv: string; pub: string } {
20
+ const priv = ed.utils.randomSecretKey();
21
+ return { priv: bytesToHex(priv), pub: bytesToHex(ed.getPublicKey(priv)) };
22
+ }
23
+
24
+ export function pubFromPriv(privHex: string): string {
25
+ return bytesToHex(ed.getPublicKey(hexToBytes(privHex)));
26
+ }
27
+
28
+ export function signHex(message: string, privHex: string): string {
29
+ return bytesToHex(ed.sign(utf8ToBytes(message), hexToBytes(privHex)));
30
+ }
31
+
32
+ export function verifyHex(sigHex: string, message: string, pubHex: string): boolean {
33
+ try {
34
+ return ed.verify(hexToBytes(sigHex), utf8ToBytes(message), hexToBytes(pubHex));
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
@@ -0,0 +1,11 @@
1
+ /** Tiny presentation helpers shared by the client and the relay — extracted
2
+ * because each had grown its own copy (four `short`s, five hand-rolled LINE
3
+ * deep links) and copies drift. */
4
+
5
+ /** 8-hex prefix for keys and ids in human-facing text ("relay" stays whole). */
6
+ export const short = (id: string): string => (id === "relay" ? "relay" : id.slice(0, 8));
7
+
8
+ /** LINE deep link that opens the OA's chat with `message` prefilled — the user
9
+ * only taps send (adds the OA as a friend first if needed). */
10
+ export const lineDeepLink = (oa: string, message: string): string =>
11
+ `https://line.me/R/oaMessage/${encodeURIComponent(oa)}/?${encodeURIComponent(message)}`;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * End-to-end encryption (v0.5.0). Simple and stupid on purpose:
3
+ *
4
+ * - The room key is a 32-byte secret minted at room creation. It rides in the invite's
5
+ * URL FRAGMENT (the part after #, which a browser never sends and the relay never
6
+ * logs) — the same channel the room secret already uses. Whoever holds the invite
7
+ * holds the key; that is the room's existing trust model, now extended to content.
8
+ * - AES-256-GCM through WebCrypto, which Node 18+ and Workers both ship. The room id
9
+ * is the additional authenticated data, so a ciphertext cannot be replayed into a
10
+ * different room.
11
+ * - Signatures and the hash chain are computed over the CIPHERTEXT. Verification,
12
+ * ordering, export, import and mirroring therefore work unchanged on encrypted
13
+ * rooms — the relay keeps doing its whole job without understanding a word.
14
+ *
15
+ * What this is not: forward secrecy, per-message ratchets, deniability. Anyone who ever
16
+ * held the invite can read the whole room. That is the documented trade for "everyone
17
+ * can use it"; a stricter scheme can replace this file without touching the chain.
18
+ */
19
+ import { bytesToHex, hexToBytes, randomHex } from "./crypto.js";
20
+
21
+ export interface EncBody { e2e: 1; iv: string; ct: string }
22
+
23
+ export const isEncrypted = (b: unknown): b is EncBody =>
24
+ !!b && typeof b === "object" && (b as { e2e?: unknown }).e2e === 1
25
+ && typeof (b as EncBody).iv === "string" && typeof (b as EncBody).ct === "string";
26
+
27
+ export function newRoomKey(): string {
28
+ return randomHex(32);
29
+ }
30
+
31
+ function aesKey(keyHex: string): Promise<CryptoKey> {
32
+ return crypto.subtle.importKey("raw", hexToBytes(keyHex) as unknown as BufferSource, "AES-GCM", false, ["encrypt", "decrypt"]);
33
+ }
34
+
35
+ export async function encryptBody(keyHex: string, room: string, body: unknown): Promise<EncBody> {
36
+ const iv = crypto.getRandomValues(new Uint8Array(12));
37
+ const ct = await crypto.subtle.encrypt(
38
+ { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(room) },
39
+ await aesKey(keyHex),
40
+ new TextEncoder().encode(JSON.stringify(body ?? null)),
41
+ );
42
+ return { e2e: 1, iv: bytesToHex(iv), ct: bytesToHex(new Uint8Array(ct)) };
43
+ }
44
+
45
+ /** Returns undefined when the key is wrong or the ciphertext / room id was tampered with. */
46
+ export async function decryptBody(keyHex: string, room: string, b: EncBody): Promise<unknown> {
47
+ try {
48
+ const pt = await crypto.subtle.decrypt(
49
+ { name: "AES-GCM", iv: hexToBytes(b.iv) as unknown as BufferSource, additionalData: new TextEncoder().encode(room) },
50
+ await aesKey(keyHex),
51
+ hexToBytes(b.ct) as unknown as BufferSource,
52
+ );
53
+ return JSON.parse(new TextDecoder().decode(pt));
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
@@ -0,0 +1,127 @@
1
+ import { canon } from "./canon.js";
2
+ import { sha256Hex, signHex, verifyHex } from "./crypto.js";
3
+
4
+ export const PROTOCOL_VERSION = 1;
5
+
6
+ /** Closed set. `accept` and `grant` create commitments; `system` is relay-authored.
7
+ * grant = a scoped, expiring permission (body: scope, expires, text) — for
8
+ * collaboration rooms where one side authorises the other to act.
9
+ * revoke = withdraws an earlier grant (body: ref = seq of the grant).
10
+ * attachment = a pointer to material that does not fit in a message
11
+ * (body: name, url, sha256?) — the relay never stores the bytes. */
12
+ export const MSG_TYPES = [
13
+ "text", "question", "proposal", "counter", "accept", "reject",
14
+ "withdraw", "escalate", "grant", "revoke", "attachment", "close", "system",
15
+ ] as const;
16
+ export const COMMITMENT_TYPES: readonly MsgType[] = ["accept", "grant"];
17
+ export type MsgType = (typeof MSG_TYPES)[number];
18
+
19
+ export const RELAY_SENDER = "relay";
20
+
21
+ /** What a participant signs. `prev` is the hash of the last stored envelope
22
+ * the sender has seen (or the genesis marker), which is what chains them. */
23
+ export interface Unsigned {
24
+ v: number;
25
+ room: string;
26
+ from: string; // hex ed25519 pubkey, or "relay" for system events
27
+ ts: string; // ISO-8601 with offset
28
+ type: MsgType;
29
+ body: unknown;
30
+ prev: string;
31
+ }
32
+
33
+ /** What the relay stores and returns. seq and hash are relay-assigned. */
34
+ export interface Envelope extends Unsigned {
35
+ sig: string; // hex; for relay system events: the relay's signature (v0.3+) or "" (legacy relays)
36
+ seq: number;
37
+ hash: string;
38
+ }
39
+
40
+ export type Submitted = Unsigned & { sig: string };
41
+
42
+ export function genesis(room: string): string {
43
+ return `genesis:${room}`;
44
+ }
45
+
46
+ export function signingBytes(u: Unsigned): string {
47
+ const { v, room, from, ts, type, body, prev } = u;
48
+ return canon({ v, room, from, ts, type, body, prev });
49
+ }
50
+
51
+ export function sign(u: Unsigned, privHex: string): Submitted {
52
+ return { ...u, sig: signHex(signingBytes(u), privHex) };
53
+ }
54
+
55
+ export function computeHash(e: Omit<Envelope, "hash">): string {
56
+ const { v, room, from, ts, type, body, prev, sig, seq } = e;
57
+ return sha256Hex(canon({ v, room, from, ts, type, body, prev, sig, seq }));
58
+ }
59
+
60
+ export interface VerifyResult { ok: boolean; errors: string[] }
61
+
62
+ export interface VerifyOpts {
63
+ /** The relay's ed25519 pubkey, once pinned. When set, every `system` event must carry a
64
+ * signature by it — an unsigned or wrongly-signed system event is an error. When unset
65
+ * (legacy relay / not yet pinned) the relay's signature is not checked either way. */
66
+ relayPub?: string;
67
+ /** Portable rooms (v0.4.15): relay keys this room lived under BEFORE a migration.
68
+ * System events from the old relay verify against any of these; participant
69
+ * signatures are unaffected — only the relay's own annotations change custody. */
70
+ pastRelayPubs?: string[];
71
+ }
72
+
73
+ /** Verify one envelope against the previous hash. Used identically by the
74
+ * relay on ingest and by clients on receipt / on full-history audit. */
75
+ export function verifyEnvelope(e: Envelope, expectedPrev: string, opts: VerifyOpts = {}): VerifyResult {
76
+ const errors: string[] = [];
77
+ if (e.v !== PROTOCOL_VERSION) errors.push(`unsupported version ${e.v}`);
78
+ if (!MSG_TYPES.includes(e.type)) errors.push(`unknown type ${e.type}`);
79
+ if (e.prev !== expectedPrev) errors.push(`chain break: prev=${e.prev.slice(0, 12)} expected=${expectedPrev.slice(0, 12)}`);
80
+ if (computeHash(e) !== e.hash) errors.push("hash mismatch");
81
+ if (e.from === RELAY_SENDER) {
82
+ if (e.type !== "system") errors.push("relay may only author system events");
83
+ if (opts.relayPub) {
84
+ const keys = [opts.relayPub, ...(opts.pastRelayPubs ?? [])];
85
+ if (!e.sig) errors.push("unsigned system event (relay signing key is pinned)");
86
+ else if (!keys.some((k) => verifyHex(e.sig, signingBytes(e), k))) errors.push("bad relay signature on system event");
87
+ }
88
+ } else {
89
+ if (e.type === "system") errors.push("participants may not author system events");
90
+ if (!verifyHex(e.sig, signingBytes(e), e.from)) errors.push("bad signature");
91
+ }
92
+ return { ok: errors.length === 0, errors };
93
+ }
94
+
95
+ /** Verify a whole transcript from genesis. */
96
+ export function verifyChain(room: string, msgs: Envelope[], opts: VerifyOpts = {}): { ok: boolean; failedAt?: number; errors: string[] } {
97
+ let prev = genesis(room);
98
+ let expectSeq = 1;
99
+ for (const m of msgs) {
100
+ if (m.seq !== expectSeq) return { ok: false, failedAt: m.seq, errors: [`seq gap: got ${m.seq} expected ${expectSeq}`] };
101
+ const r = verifyEnvelope(m, prev, opts);
102
+ if (!r.ok) return { ok: false, failedAt: m.seq, errors: r.errors };
103
+ prev = m.hash;
104
+ expectSeq++;
105
+ }
106
+ return { ok: true, errors: [] };
107
+ }
108
+
109
+ // ---------------------------------------------------------- signed head ---
110
+
111
+ /** The relay's periodic commitment to a room's transcript: "at `at`, the chain ended at
112
+ * (seq, hash)". Signed with the relay key. A client that keeps the newest head it has
113
+ * seen can later prove tail-truncation (relay serves seq < head.seq) or a fork (relay's
114
+ * hash at head.seq ≠ head.hash). It does not stop the relay from doing either — it makes
115
+ * it provable. */
116
+ export interface Head { room: string; seq: number; hash: string; at: string; sig: string }
117
+
118
+ export function headSigningBytes(h: Omit<Head, "sig">): string {
119
+ const { room, seq, hash, at } = h;
120
+ return canon({ room, seq, hash, at });
121
+ }
122
+ export function signHead(h: Omit<Head, "sig">, relayPriv: string): Head {
123
+ return { ...h, sig: signHex(headSigningBytes(h), relayPriv) };
124
+ }
125
+ export function verifyHead(h: Head, relayPub: string): boolean {
126
+ return !!h && typeof h.sig === "string" && verifyHex(h.sig, headSigningBytes(h), relayPub);
127
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./canon.js";
2
+ export * from "./crypto.js";
3
+ export * from "./envelope.js";
4
+ export * from "./room.js";
5
+ export * from "./principal.js";
6
+ export * from "./e2e.js";
7
+ export * from "./mandate.js";
8
+ export * from "./display.js";
9
+ export * from "./release.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The mandate rule set — ONE implementation for both enforcement points.
3
+ *
4
+ * The same brake guards two floors: the local client checks the principal's
5
+ * full mandate.json before anything is signed on their machine, and the hosted
6
+ * surface (relay/mcp-http.ts) checks the relay-held mandate for agents whose
7
+ * keys never leave the relay. A rule that exists on one floor and not the
8
+ * other is a hole, not a feature — so the rules live here, in protocol/, the
9
+ * layer both the Node client and the Worker may import. What stays with the
10
+ * callers, by design: pause state (different sources) and message framing
11
+ * (the hosted surface appends "NOT SENT." to blocked verdicts).
12
+ */
13
+ import { canon } from "./canon.js";
14
+ import type { MsgType } from "./envelope.js";
15
+
16
+ /** The enforceable subset of a mandate. The local Mandate and the hosted
17
+ * mandate both satisfy it structurally; extra fields (may_share, brief…) are
18
+ * advisory and never enforced here. */
19
+ export interface MandateRules {
20
+ never_disclose: string[];
21
+ may_grant: string[];
22
+ max_commit_amount: number | null;
23
+ currency?: string;
24
+ max_grant_hours: number;
25
+ }
26
+
27
+ /** Glob-ish scope match: `*` matches any run of characters. Case-insensitive. */
28
+ export function scopeAllowed(scope: string, patterns: string[]): boolean {
29
+ const s = scope.trim().toLowerCase();
30
+ return patterns.some((p) => {
31
+ const re = new RegExp("^" + p.trim().toLowerCase().split("*").map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
32
+ return re.test(s);
33
+ });
34
+ }
35
+
36
+ /** Returns the reason a send must be blocked, or null when the mandate allows it.
37
+ * Checks the OUTBOUND body — for E2E rooms this must run on the plaintext,
38
+ * before encryption. */
39
+ export function checkMandate(m: MandateRules, type: MsgType, body: Record<string, unknown>): string | null {
40
+ const flat = canon(body).toLowerCase();
41
+ for (const s of m.never_disclose) {
42
+ if (s && flat.includes(s.toLowerCase())) return `blocked by mandate: outbound body contains a never_disclose string.`;
43
+ }
44
+ if (["proposal", "counter", "accept"].includes(type) && m.max_commit_amount != null && typeof body.amount === "number") {
45
+ if (body.amount > m.max_commit_amount) return `blocked by mandate: amount ${body.amount} exceeds max_commit_amount ${m.max_commit_amount}${m.currency ? " " + m.currency : ""}.`;
46
+ }
47
+ if (type === "grant") {
48
+ const scope = typeof body.scope === "string" ? body.scope : "";
49
+ if (!scope) return `grant needs a scope (e.g. "read:logs/*", "deploy:staging").`;
50
+ if (!scopeAllowed(scope, m.may_grant)) return `blocked by mandate: scope "${scope}" is not in may_grant ${JSON.stringify(m.may_grant)} — escalate to your principal instead.`;
51
+ const exp = Date.parse(String(body.expires ?? ""));
52
+ if (!Number.isFinite(exp)) return `grant needs an ISO expiry (use expiresHours).`;
53
+ const hours = (exp - Date.now()) / 3.6e6;
54
+ if (hours > m.max_grant_hours + 0.01) return `blocked by mandate: grant expiry ${hours.toFixed(1)}h exceeds max_grant_hours ${m.max_grant_hours}.`;
55
+ }
56
+ if (type === "revoke" && typeof body.ref !== "number") return `revoke needs ref = seq of the grant being revoked.`;
57
+ if (type === "attachment") {
58
+ if (typeof body.url !== "string" || !/^https?:\/\//.test(body.url)) return `attachment needs an https URL (the relay never stores bytes).`;
59
+ }
60
+ return null;
61
+ }