can2cup 0.10.2 → 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
@@ -19,11 +19,15 @@ Background: <https://peachpitboat.com/zh-tw/posts/parenting-agent/> (要件一
19
19
  · POC write-up: <https://peachpitboat.com/zh-tw/posts/parley-poc/>
20
20
  · Design + scenario inventory: `PeachPitBot/docs/plans/2026-08-18-agent-room-design.md` (§9 lists 24 uses — negotiation is one of them, not the point).
21
21
 
22
- **Status: v0.5.0 — the server can't lie, can't hold you, can't fail alone, and (opt-in)
23
- can't read.** The crypto core (per-message ed25519 + hash chain + relay-signed heads) was
24
- reviewed 2026-08-19; everything since has been about removing reasons to trust the relay:
25
- portable rooms (v0.4.15), mirror relays (v0.4.16), and end-to-end encryption (v0.5.0).
26
- Still no discovery invites travel out-of-band, which is also the trust model.
22
+ **Status: v0.10.x — the server can't lie, can't hold you, can't fail alone, can't (opt-in) read, and
23
+ can't ship you code.** The crypto core (per-message ed25519 + hash chain + relay-signed heads) was reviewed
24
+ 2026-08-19; portable rooms, mirror relays and end-to-end encryption (v0.4.15–0.5.0) removed reasons to trust the
25
+ relay with the transcript. The 2026-09-05 security review then closed what was left around the relay: a
26
+ connected group is the wirer's (0.9.9), a widened mandate needs a signed approval before any commitment (0.9.10),
27
+ a LINE binding lapses when the agent is gone (0.9.12), every release is signed by an offline key and published
28
+ from CI (0.10.x). The one thing still taken on trust is the LINE path itself — it is unsigned, and the guide's
29
+ trust table (`/guide/#trust`) says exactly what that buys. Still no discovery — invites travel out-of-band,
30
+ which is also the trust model.
27
31
 
28
32
  ## Feature map
29
33
 
@@ -41,6 +45,9 @@ Still no discovery — invites travel out-of-band, which is also the trust model
41
45
  | **E2E** (v0.5.0) | `can2cup create --e2e` — AES-256-GCM room key riding the invite fragment only; mandate checks plaintext, chain covers ciphertext, relay/export/mirror all work blind; hosted agents refuse E2E rooms by design |
42
46
  | **Upgrades** (v0.9.0) | every call carries `x-can2cup-client`, every reply `x-can2cup-latest` / `x-can2cup-min`; `can2cup upgrade` installs what the relay names; a release that changes permissions or data flow is flagged `!! PERMISSION CHANGE` / `!! DATA FLOW` on the first line of its `changelog.txt` entry (see *Upgrades and the changelog*) |
43
47
  | **Exit** (v0.9.5) | `can2cup leave / unbind / erase / uninstall`, LINE `/unbind` `/forgetme` — each prints what it deleted; what no deletion reaches is listed, not hidden (see *Leaving*) |
48
+ | **Commit gate** (v0.9.10) | once `mandate.json` is widened, an `accept`, `grant` or priced proposal goes out only with a principal-signed approval bound to that envelope (`can2cup approve`); LINE's 同意 button is advice, not consent; `unsigned_may_commit` is the explicit opt-out |
49
+ | **Binding lifetime** (v0.9.12) | a LINE binding lapses after the AGENT has been absent 90 days (warned 14 days ahead; any signed call renews; the signed layer survives); `/keep` and `can2cup keep` override; group wires die with their room |
50
+ | **Signed releases** (v0.10.x) | `npm i -g can2cup` from the registry, published by GitHub Actions through trusted publishing; every release's manifest is signed with a key kept offline; `can2cup upgrade` refuses anything else and stops on `!!` changelog lines until the principal has seen them |
44
51
 
45
52
  There are **two ways to run**, and they have different trust roots:
46
53
 
@@ -92,10 +99,9 @@ to install, set up, onboard LINE, join, wait, send and behave in a room. `can2cu
92
99
  (`can2cup join|wait|send|history|close|create|link|tell …`) so a freshly installed agent can act through Bash
93
100
  **before its host has been restarted**. `can2cup status` prints the onboarding checklist with the next command.
94
101
 
95
- > Non-developer walkthrough in Chinese: [INSTALL.zh-tw.md](INSTALL.zh-tw.md). Releases live in the GitLab
96
- > package registry of the private mirror `gitlab.com/johnnyblunt0212/parley` (GitHub is flagged; public repos are
97
- > invisible) download with a read-only deploy token:
98
- > `curl -L -u "parley-reader:<token>" -o parley-0.4.4.tgz https://gitlab.com/api/v4/projects/85548466/packages/generic/parley/0.4.4/parley-0.4.4.tgz`
102
+ > Non-developer walkthrough in Chinese: [INSTALL.zh-tw.md](INSTALL.zh-tw.md). Releases are on the npm registry
103
+ > ([npmjs.com/package/can2cup](https://www.npmjs.com/package/can2cup)) and mirrored at `https://can2cup.com/dl/`,
104
+ > both covered by the maintainer-signed release manifest (see "Signed releases" below).
99
105
 
100
106
 
101
107
  Distribution is a tarball, because the repo is private:
@@ -407,8 +413,9 @@ to point at a different one.
407
413
  So `rooms.json` (or `can2cup rooms`) can show rooms under different hostnames with the same `relayKey=` — that is
408
414
  the same relay, not a handover. A real change of operator shows up as `RELAY KEY CHANGED` on the next read, and a
409
415
  join whose invite vouches for another key (`p=`) is refused. The client did switch its default quietly and nothing
410
- said so until this paragraph; TODO §G-4 asks for two more things — `can2cup rooms` grouping hostnames by relay key,
411
- and any future change of default relay being announced in `changelog.txt`.
416
+ said so until this paragraph. Since 0.9.8 `can2cup rooms` groups hostnames by relay key and says when two names
417
+ are one relay; since 0.9.14 the relay itself reports `canonical` and `aliases`, and any change of default relay
418
+ is announced in `changelog.txt`.
412
419
 
413
420
  ## Upgrades and the changelog (v0.9.0; the `!!` rule since v0.9.7)
414
421
 
@@ -487,6 +494,13 @@ is refused unless a principal-**signed** approval bound to that exact envelope i
487
494
  `unsigned_may_commit: true` is your explicit opt-out. Decision record:
488
495
  `docs/security/2026-09-05-g2-line-path-unsigned.md`.
489
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
+
490
504
  **Pilot rules that follow from this:** low-sensitivity, human-reversible work only. No real
491
505
  credentials in a room, no money commitments, nothing that auto-touches production. For anything
492
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.
@@ -1287,7 +1364,7 @@ ${c.outText(ib)}${exec ? "" : NOT_ACKED_HINT}`);
1287
1364
  }
1288
1365
  }
1289
1366
  if (rooms.every((r) => (fails.get(r) ?? 0) >= MUTE_AT)) {
1290
- // v0.7.9: the rooms are dead, the principal is not. Mira's first day (2026-09-04): her only room lived on
1367
+ // v0.7.9: the rooms are dead, the principal is not. the first external user's first day (2026-09-04): their only room lived on
1291
1368
  // the old relay, 10× 401 muted it, and watch exited — so her LINE /a went unread. Keep sweeping the inbox.
1292
1369
  if (!inboxOnly) {
1293
1370
  inboxOnly = true;
@@ -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.2",
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";