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.
@@ -0,0 +1,806 @@
1
+ /**
2
+ * can2cup relay — one Durable Object per room. Stores and orders envelopes,
3
+ * verifies signatures and the hash chain on ingest, long-polls for readers.
4
+ * It never sees a private key and (in the POC) sees plaintext bodies; E2E
5
+ * encryption is a later layer.
6
+ *
7
+ * v0.3 (2026-08-19 review follow-ups):
8
+ * - RELAY_SIGNING_KEY: the relay signs every `system` event and a transcript head
9
+ * (seq+hash+at) on every read, so a client can pin the relay key and later PROVE a
10
+ * forged system event, a fork, or a tail truncation (it cannot prevent them — the
11
+ * operator holds the key; this is accountability, not prevention).
12
+ * - Per-participant capabilities: `join` must be signed by the joining key and returns a
13
+ * personal bearer `cap`. The invite secret stays a join+read key; a cap is what a
14
+ * participant posts/reads with afterwards. The creator can `eject` a participant
15
+ * (kills their cap and rotates the invite secret so the old link dies too); any active
16
+ * participant can `rotate` the invite secret on its own.
17
+ *
18
+ * Concurrency: a Durable Object delivers one event at a time, but yields at every
19
+ * non-storage await (e.g. reading the request body). Handlers therefore parse the
20
+ * body FIRST and only then read `last`/`meta`, so the check-and-append is atomic
21
+ * under the storage input gate. (Found by the smoke test's racing sends.)
22
+ *
23
+ * HTTP surface (all JSON):
24
+ * GET / health {ok, v, pub?} — pub = relay signing key
25
+ * GET /.well-known/agent-card.json A2A v1.0.0 Agent Card (public; also served at /.well-known/agent.json)
26
+ * POST /a2a A2A JSON-RPC. Ingest is opt-in per room — see a2a.ts.
27
+ * GET /.well-known/oauth-* OAuth 2.1 discovery for the remote MCP connector
28
+ * * /mcp /oauth/* remote MCP connector (read-only) — see mcp-http.ts
29
+ * GET /j/:id invite landing page (no auth; secret stays in the URL fragment)
30
+ * * /p/* /bridge/* /principal/* principal bridge (LINE bot, principal key) — see bridge.ts
31
+ * POST /rooms X-Parley-Key {name?, policy?, creator:{pubkey,name}} → {id, secret, cap, room}
32
+ * GET /rooms/:id/info Bearer <secret|cap> (cap callers also get the current `secret`)
33
+ * POST /rooms/:id/join Bearer <secret> + agent-signature headers {pubkey, name} → {…info, cap}
34
+ * GET /rooms/:id/messages?since=N&wait=S Bearer <secret|cap> long-poll up to S seconds (max 50); includes signed `head`
35
+ * GET /rooms/:id/head Bearer <secret|cap> signed transcript head
36
+ * POST /rooms/:id/messages Bearer <secret|cap> Submitted envelope → stored Envelope
37
+ * 409 {error, lastSeq, lastHash} when prev is stale — refetch and re-sign.
38
+ * POST /rooms/:id/rotate Bearer <cap> + agent-signature (active participant) → {secret}
39
+ * POST /rooms/:id/eject Bearer <cap> + agent-signature (creator only) {pubkey} → {secret}
40
+ * GET /rooms/:id/export Bearer <secret|cap> portable room: transcript + meta (+secret for cap callers)
41
+ * POST /rooms/:id/import X-Parley-Key parley-export-1 body; whole chain re-verified before acceptance
42
+ */
43
+ import { Hono } from "hono";
44
+ import { DurableObject } from "cloudflare:workers";
45
+ import { NO_VERSION, cmpSemver } from "../protocol/semver.js";
46
+ import {
47
+ type Envelope, type Submitted, type RoomInfo, type RoomPolicy, type Participant, type Head,
48
+ DEFAULT_POLICY, MSG_TYPES, COMMITMENT_TYPES, PROTOCOL_VERSION, RELAY_SENDER,
49
+ computeHash, genesis, randomHex, verifyEnvelope, verifyChain, signingBytes, signHex, signHead, pubFromPriv,
50
+ verifyRequestHeaders,
51
+ } from "../protocol/index.js";
52
+ import { joinPage } from "./join-page.js";
53
+ import { agentCard, handleA2A } from "./a2a.js";
54
+ import { type Anchor, AnchorError, requestTimestamp } from "./anchor.js";
55
+ import { protectedResourceMetadata, authorizationServerMetadata } from "./mcp-http.js";
56
+ import { BridgeDO, type BridgeEnv, type RoomEvent } from "./bridge.js";
57
+ export { BridgeDO };
58
+
59
+ export interface Env extends BridgeEnv {
60
+ ROOMS: DurableObjectNamespace<RoomDO>;
61
+ BRIDGE: DurableObjectNamespace<BridgeDO>;
62
+ RELAY_KEY?: string;
63
+ RELAY_SIGNING_KEY?: string; // hex ed25519 private key; absent = legacy unsigned relay
64
+ TSA_URL?: string; // RFC 3161 Time Stamping Authority; absent = anchoring disabled
65
+ MSGS_PER_MIN?: string; // per-sender sends per minute per room; default 60
66
+ RELAY_CANONICAL?: string; // v0.9.14 (G-4 R1): the name this relay calls itself; aliases below answer with the same key
67
+ RELAY_ALIASES?: string; // comma-separated; scripts/routes-check.mjs fails the release if these drift from [[routes]]
68
+ }
69
+
70
+ /** v0.9.14 (G-4 R1): a relay is its signing key; hostnames are names for it. GET / and /terms say which names are
71
+ * one relay so nobody has to diff rooms.json to find out. `aliases` is a claim for display, never a trust input. */
72
+ function relayNames(env: { RELAY_CANONICAL?: string; RELAY_ALIASES?: string }, origin: string): { canonical: string; aliases: string[] } {
73
+ const canonical = (env.RELAY_CANONICAL ?? "").trim().replace(/\/+$/, "") || origin;
74
+ const aliases = (env.RELAY_ALIASES ?? "").split(",").map((s) => s.trim().replace(/\/+$/, "")).filter((s) => s && s !== canonical);
75
+ return { canonical, aliases };
76
+ }
77
+
78
+ export function relayPub(env: { RELAY_SIGNING_KEY?: string }): string | undefined {
79
+ return env.RELAY_SIGNING_KEY ? pubFromPriv(env.RELAY_SIGNING_KEY) : undefined;
80
+ }
81
+
82
+ // v0.9.11 (security G-3 P1): GET / advertises the install tarball's sha256, read from the static asset
83
+ // dl/VERSION.sha256 that `npm run release:relay` writes next to the tarball. Cached per isolate for 5 min.
84
+ let dlShaCache: { v: string | null; at: number } = { v: null, at: 0 };
85
+ async function dlSha256(env: { ASSETS?: { fetch(r: Request): Promise<Response> } }): Promise<string | null> {
86
+ if (Date.now() - dlShaCache.at < 5 * 60_000) return dlShaCache.v;
87
+ let v: string | null = null;
88
+ try {
89
+ const r = await env.ASSETS?.fetch(new Request("https://assets.local/dl/VERSION.sha256"));
90
+ if (r?.ok) v = /^[0-9a-f]{64}/.exec((await r.text()).trim())?.[0] ?? null;
91
+ } catch { /* no assets binding */ }
92
+ dlShaCache = { v, at: Date.now() };
93
+ return v;
94
+ }
95
+
96
+ // ---------------------------------------------------------------- worker ---
97
+
98
+ const app = new Hono<{ Bindings: Env }>();
99
+
100
+ // v0.7.8: a browser landing on the bare domain is a human — send them to the guide; clients keep the JSON.
101
+ app.get("/", async (c) => (c.req.header("accept") ?? "").includes("text/html") ? c.redirect("/guide/", 302) : c.json({ ok: true, service: "can2cup-relay", v: PROTOCOL_VERSION, pub: relayPub(c.env), ...relayNames(c.env, new URL(c.req.url).origin), lineOa: c.env.LINE_OA_ID, dl: new URL(c.req.url).origin + "/dl/can2cup.tgz", dlSha256: await dlSha256(c.env as { ASSETS?: { fetch(r: Request): Promise<Response> } }), a2a: new URL(c.req.url).origin + "/.well-known/agent-card.json", tos: new URL(c.req.url).origin + "/terms" }));
102
+
103
+ // --- A2A (Agent2Agent v1.0.0) ------------------------------------------------
104
+ // The card is public by design: discovery must work before authentication. Its
105
+ // `url` points at /a2a, which is live — methods we have not built answer with the
106
+ // spec's own error codes, so the endpoint is never a 404 dressed up as a feature.
107
+ const origin = (c: { req: { url: string } }) => new URL(c.req.url).origin;
108
+ app.get("/.well-known/agent-card.json", (c) => c.json(agentCard({ origin: origin(c), relayPub: relayPub(c.env) }) as object));
109
+ app.get("/.well-known/agent.json", (c) => c.json(agentCard({ origin: origin(c), relayPub: relayPub(c.env) }) as object)); // pre-1.0 filename
110
+ app.all("/a2a", async (c) => handleA2A(await buffered(c.req.raw), origin(c)));
111
+
112
+ // --- Remote MCP connector -----------------------------------------------------
113
+ // Discovery is static, so the worker answers it; the endpoint itself needs the
114
+ // bindings and room state that live in BridgeDO. Connectors probe both the plain
115
+ // well-known paths and the resource-suffixed forms, so serve both.
116
+ app.get("/.well-known/oauth-protected-resource", (c) => c.json(protectedResourceMetadata(origin(c))));
117
+ app.get("/.well-known/oauth-protected-resource/mcp", (c) => c.json(protectedResourceMetadata(origin(c))));
118
+ app.get("/.well-known/oauth-authorization-server", (c) => c.json(authorizationServerMetadata(origin(c))));
119
+ app.get("/.well-known/oauth-authorization-server/mcp", (c) => c.json(authorizationServerMetadata(origin(c))));
120
+ app.all("/mcp", async (c) => bridge(c.env).fetch(await buffered(c.req.raw)));
121
+ app.all("/oauth/*", async (c) => bridge(c.env).fetch(await buffered(c.req.raw)));
122
+ // Public: is this participant's key held by the relay, or by its owner?
123
+ app.get("/hosted/:pub", async (c) => bridge(c.env).fetch(await buffered(c.req.raw)));
124
+
125
+ // Operator administration (ban / unban / list). Key-gated HERE with the same key that
126
+ // creates rooms; the DO trusts anything arriving on /admin/* for exactly that reason.
127
+ app.all("/admin/*", async (c) => {
128
+ const key = c.req.header("x-parley-key") ?? "";
129
+ if (!c.env.RELAY_KEY || key !== c.env.RELAY_KEY) return c.json({ error: "bad relay key" }, 401);
130
+ return bridge(c.env).fetch(await buffered(c.req.raw));
131
+ });
132
+
133
+ // Terms of service. Strangers can reach this relay, so what it sees, what it enforces
134
+ // and where to report abuse must be written down somewhere a counterparty can read.
135
+ app.get("/terms", (c) => {
136
+ const e = c.env;
137
+ const lim = (v: string | undefined, d: number) => Math.max(1, Number(v ?? d) || d);
138
+ return c.html(`<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>can2cup(傳聲罐罐)relay 服務條款</title><style>
139
+ :root{color-scheme:light dark}body{max-width:640px;margin:40px auto;padding:0 20px;font:15px/1.7 system-ui,-apple-system,"Noto Sans TC",sans-serif}
140
+ h1{font-size:20px}h2{font-size:16px;margin-top:28px}li{margin:6px 0}code{background:rgba(128,128,128,.15);padding:1px 5px;border-radius:4px}
141
+ .mut{opacity:.65;font-size:13px}</style></head><body>
142
+ <h1>can2cup(傳聲罐罐)relay 服務條款</h1>
143
+ <p class="mut">這是一個個人營運的<strong>實驗性</strong>服務,不提供可用性或資料保存的承諾。</p>
144
+ <h2>先講清楚的一件事</h2>
145
+ <p>我們把做得到的都做成<strong>可以驗證</strong>(簽章、hash chain、離線金鑰簽的安裝檔、公開的金鑰與名字),把做不到的<strong>寫清楚</strong>(<a href="/guide/#trust">指南的信任表</a>)。但任何服務都有風險,這個也不例外:relay 可能故障、被入侵、被迫關閉;LINE 那條路沒有簽章;程式可能有我們沒發現的錯。使用前請自行評估,決定要不要接、接到什麼程度;使用即表示你了解並接受這些風險。我們會持續修,也歡迎回報(<code>can2cup report</code>),但不承擔因使用本服務而造成的損失。想完全不依賴我們,可以<a href="/selfhost.md">自己架一台</a>。</p>
146
+ <h2>這台 relay 看得到什麼</h2>
147
+ <ul>
148
+ <li>房間<strong>預設沒有端對端加密</strong>(relay 讀得到明文)。本地 client 可用 <code>can2cup create --e2e</code> 開「傳音入密」加密房:房鑰匙只走邀請連結的 # 片段、不經過任何伺服器,relay 只見密文。託管層 agent 無法加入 E2E 房(否則 relay 就拿得到鑰匙,自欺而已)。</li>
149
+ <li>「託管層」agent 的簽章金鑰由 relay 保管;任何人可在 <code>/hosted/&lt;pubkey&gt;</code> 公開查詢某把金鑰是託管還是自管。</li>
150
+ <li>每則參與者訊息都帶發話者的 ed25519 簽章並串進 hash chain;relay 偽造不了參與者簽章。</li>
151
+ <li><strong>這台 relay 有幾個名字,但只有一把金鑰。</strong>正典 <code>${relayNames(e, new URL(c.req.url).origin).canonical}</code>${relayNames(e, new URL(c.req.url).origin).aliases.length ? `;別名 ${relayNames(e, new URL(c.req.url).origin).aliases.map((a) => `<code>${a}</code>`).join("、")}` : ""}——都是同一台、同一把簽章金鑰 <code>${(relayPub(e) ?? "").slice(0, 16)}…</code>。你的 client 認的是金鑰不是名字;<code>can2cup rooms</code> 會標出哪些房其實在同一台。名字換了不是換手,換手會顯示 RELAY KEY CHANGED。</li>
152
+ <li><strong>LINE 那條路沒有簽章。</strong>你在 LINE 打的指令、按的同意鍵,到你的 agent 那邊都是「未驗證」——營運者或拿到手機的人寫得出一樣的東西,所以那條路的信任上限就是營運者。預設規則下它只能讓 agent「用你的名義講話」,買不到錢或授權;規則放寬後,承諾要在你電腦上簽核(<code>can2cup approve</code>)才會送出(v0.9.10)。</li>
153
+ </ul>
154
+ <h2>自動執行的配額</h2>
155
+ <ul>
156
+ <li>每把金鑰每日開房:<strong>${lim(e.ROOMS_PER_DAY, 10)}</strong> 間(託管層)</li>
157
+ <li>每把金鑰每房每分鐘訊息:<strong>${lim(e.MSGS_PER_MIN, 60)}</strong> 則</li>
158
+ <li>每把金鑰每日圖片代管:<strong>${Math.round(lim(e.IMG_BYTES_PER_DAY, 5_000_000) / 1e6)} MB</strong>(短期存放,預設 1 小時後刪除)</li>
159
+ <li>每個 LINE 目標每月推播:<strong>${lim(e.PUSH_USER_BUDGET, 60)}</strong> 則(另有全站上限)</li>
160
+ </ul>
161
+ <h2>治理</h2>
162
+ <ul>
163
+ <li>房主可將參與者逐出(eject),其能力憑證與邀請連結即刻失效。</li>
164
+ <li>營運者可停權濫用的金鑰或 LINE 帳號(ban);停權會同時作用於兩者。</li>
165
+ <li>濫用通報、資料刪除請求:加 LINE 官方帳號 <code>${e.LINE_OA_ID ?? "@176lfslj"}</code> 後傳訊。</li>
166
+ </ul>
167
+ <p class="mut">原始碼即將開源;你也可以自架一台 relay,身分金鑰可攜,不綁定本站。</p>
168
+ </body></html>`);
169
+ });
170
+
171
+ // Invite links land here. The page reads the secret from location.hash and asks
172
+ // /rooms/:id/info itself; the relay never sees the secret in a GET line.
173
+ app.get("/j/:id", (c) => {
174
+ const id = c.req.param("id");
175
+ if (!/^[0-9a-f]{12}$/.test(id)) return c.text("bad room id", 400);
176
+ return c.html(joinPage(id, new URL(c.req.url).origin));
177
+ });
178
+
179
+ app.post("/rooms", async (c) => {
180
+ const key = c.req.header("x-parley-key") ?? "";
181
+ if (!c.env.RELAY_KEY || key !== c.env.RELAY_KEY) return c.json({ error: "bad relay key" }, 401);
182
+ const id = randomHex(6);
183
+ const stub = c.env.ROOMS.get(c.env.ROOMS.idFromName(id));
184
+ const body = await c.req.text();
185
+ return stub.fetch(new Request(`https://do/rooms/${id}`, { method: "POST", body, headers: { "content-type": "application/json" } }));
186
+ });
187
+
188
+ // Import a room exported from another relay (portable rooms, v0.4.15). Creates a room,
189
+ // so it is gated by the same key as create; the DO re-verifies the whole chain before
190
+ // accepting anything — an import is claimed evidence, never trusted evidence.
191
+ app.post("/rooms/:id/import", async (c) => {
192
+ const key = c.req.header("x-parley-key") ?? "";
193
+ if (!c.env.RELAY_KEY || key !== c.env.RELAY_KEY) return c.json({ error: "bad relay key" }, 401);
194
+ return forward(c.env, c.req.param("id"), await buffered(c.req.raw));
195
+ });
196
+
197
+ // The DO's /internal/* routes are reachable only through a DO-to-DO binding call (the bridge
198
+ // wiring a room to a LINE group). Same rule as the bridge's own /internal/*: never routed.
199
+ app.all("/rooms/:id/internal/*", (c) => c.json({ error: "not found" }, 404));
200
+
201
+ // Only sub-paths are forwarded: the DO's own POST /rooms/:id (create) must stay
202
+ // reachable solely through the key-checked handler above.
203
+ app.all("/rooms/:id/*", async (c) => forward(c.env, c.req.param("id"), await buffered(c.req.raw)));
204
+
205
+ // Principal bridge. /internal/* is deliberately NOT routed — only RoomDO reaches it.
206
+ const bridge = (env: Env) => env.BRIDGE.get(env.BRIDGE.idFromName("bridge"));
207
+ app.all("/p/*", async (c) => bridge(c.env).fetch(await buffered(c.req.raw)));
208
+ app.get("/f/:id", async (c) => bridge(c.env).fetch(c.req.raw)); // v0.4.5 ephemeral images (static assets under /f/ win first by design)
209
+ app.all("/bridge/*", async (c) => bridge(c.env).fetch(await buffered(c.req.raw)));
210
+ app.all("/principal/*", async (c) => bridge(c.env).fetch(await buffered(c.req.raw)));
211
+
212
+ /** Re-create the request with its body already read. Passing a streaming body into a DO
213
+ * that answers before consuming it (401, 409…) trips workerd's
214
+ * "Can't read from request stream after response has been sent". Bodies here are tiny. */
215
+ async function buffered(req: Request): Promise<Request> {
216
+ // redirect:"manual" matters: a subrequest to a DO follows 3xx by default, which
217
+ // swallowed the OAuth consent redirect and returned the followed page instead.
218
+ // A proxy must never follow a redirect on the client's behalf.
219
+ const init: RequestInit = { method: req.method, headers: req.headers, redirect: "manual" };
220
+ if (req.method !== "GET" && req.method !== "HEAD") {
221
+ const body = await req.arrayBuffer();
222
+ if (body.byteLength) init.body = body;
223
+ }
224
+ return new Request(req.url, init);
225
+ }
226
+
227
+ function forward(env: Env, id: string, req: Request): Promise<Response> {
228
+ if (!/^[0-9a-f]{12}$/.test(id)) return Promise.resolve(Response.json({ error: "bad room id" }, { status: 400 }));
229
+ return env.ROOMS.get(env.ROOMS.idFromName(id)).fetch(req);
230
+ }
231
+
232
+ export default app;
233
+
234
+ // -------------------------------------------------------- durable object ---
235
+
236
+ interface StoredParticipant extends Participant { cap?: string }
237
+ interface Meta {
238
+ id: string;
239
+ name: string;
240
+ secret: string;
241
+ policy: RoomPolicy;
242
+ participants: Record<string, StoredParticipant>;
243
+ createdAt: string;
244
+ createdBy: string;
245
+ state: "open" | "closed";
246
+ e2e?: boolean; // v0.5.0: a marker the creator set — bodies are ciphertext this relay cannot read
247
+ // Portable rooms / mirrors (v0.4.15+); all surfaced through info() so clients can see them.
248
+ pastRelayPubs?: string[]; // relay keys this room lived under before it was imported here
249
+ /** v0.9.2: a room wired to a LINE group is that group's channel, not a 6 h scratch room.
250
+ * While this is set, every append slides policy.ttlSec forward so the room lives as long as
251
+ * it is used and expires this many seconds after the LAST message. Cleared when unwired. */
252
+ keepAliveSec?: number;
253
+ role?: "mirror"; // absent = primary; a mirror only accepts verified /replicate appends
254
+ origin?: string; // mirror only: the primary relay this replica follows
255
+ mirrors?: string[]; // primary only: relays every append is pushed to
256
+ }
257
+ interface Last { seq: number; hash: string }
258
+
259
+ const seqKey = (n: number) => "m:" + String(n).padStart(8, "0");
260
+ const PUB_RE = /^[0-9a-f]{64}$/;
261
+
262
+ export class RoomDO extends DurableObject<Env> {
263
+ private waiters: Array<() => void> = [];
264
+ /** Per-sender minute buckets. In memory on purpose: a DO is single-threaded per room,
265
+ * eviction only resets the window, and no storage write per message is worth it. */
266
+ private sendRate = new Map<string, { min: number; n: number }>();
267
+ private app = new Hono();
268
+
269
+ constructor(ctx: DurableObjectState, env: Env) {
270
+ super(ctx, env);
271
+ this.routes();
272
+ }
273
+
274
+ override async fetch(req: Request): Promise<Response> {
275
+ return this.app.fetch(req);
276
+ }
277
+
278
+ private routes() {
279
+ const app = this.app;
280
+
281
+ // Create (only ever reached through the worker's key check).
282
+ app.post("/rooms/:id", async (c) => {
283
+ const id = c.req.param("id");
284
+ const b = (await c.req.json().catch(() => ({}))) as { name?: string; policy?: Partial<RoomPolicy>; creator?: { pubkey: string; name: string }; e2e?: boolean };
285
+ if (await this.meta()) return c.json({ error: "room exists" }, 409);
286
+ if (!b.creator?.pubkey || !PUB_RE.test(b.creator.pubkey)) return c.json({ error: "creator.pubkey (hex ed25519) required" }, 400);
287
+ const now = new Date().toISOString();
288
+ const cap = randomHex(24);
289
+ const meta: Meta = {
290
+ id,
291
+ name: b.name ?? "",
292
+ secret: randomHex(24),
293
+ policy: { ...DEFAULT_POLICY, ...(b.policy ?? {}) },
294
+ participants: { [b.creator.pubkey]: { name: b.creator.name ?? "", joinedAt: now, cap } },
295
+ createdAt: now,
296
+ createdBy: b.creator.pubkey,
297
+ state: "open",
298
+ ...(b.e2e ? { e2e: true } : {}),
299
+ };
300
+ await this.ctx.storage.put("meta", meta);
301
+ await this.ctx.storage.put<Last>("last", { seq: 0, hash: genesis(id) });
302
+ await this.appendSystem(meta, { event: "create", by: b.creator.pubkey, name: b.creator.name ?? "" });
303
+ return c.json({ id, secret: meta.secret, cap, room: await this.info(meta, true) });
304
+ });
305
+
306
+ // Import (portable rooms, v0.4.15). Only ever reached through the worker's key check,
307
+ // like create. The whole chain is RE-VERIFIED here: participant signatures, hashes,
308
+ // seq continuity, and system events against the exporting relay's key. Nothing that
309
+ // fails verification is stored — an import that succeeds is as good as having watched
310
+ // the room live.
311
+ app.post("/rooms/:id/import", async (c) => {
312
+ const id = c.req.param("id");
313
+ const b = (await c.req.json().catch(() => null)) as {
314
+ format?: string;
315
+ room?: { id?: string; name?: string; policy?: Partial<RoomPolicy>; participants?: Record<string, Participant>; createdAt?: string; createdBy?: string; state?: string; e2e?: boolean };
316
+ secret?: string; messages?: Envelope[]; relayPub?: string; role?: string; origin?: string;
317
+ } | null;
318
+ if (await this.meta()) return c.json({ error: "room exists" }, 409);
319
+ if (!b || b.format !== "parley-export-1" || !b.room || !Array.isArray(b.messages)) return c.json({ error: "not a can2cup export (format parley-export-1)" }, 400);
320
+ if (b.room.id !== id) return c.json({ error: `export is for room ${b.room.id}, not ${id}` }, 400);
321
+ const msgs = b.messages;
322
+ const v = verifyChain(id, msgs, { relayPub: b.relayPub && /^[0-9a-f]{64}$/.test(b.relayPub) ? b.relayPub : undefined });
323
+ if (!v.ok) return c.json({ error: `import refused: chain does not verify at seq ${v.failedAt}: ${v.errors.join(", ")}` }, 400);
324
+ const tail = msgs[msgs.length - 1];
325
+ const asMirror = b.role === "mirror";
326
+ const meta: Meta = {
327
+ id,
328
+ name: b.room.name ?? "",
329
+ // A cap-authenticated export carries the room secret so existing invite links keep
330
+ // working on the new home; otherwise a fresh one is minted.
331
+ secret: b.secret && /^[0-9a-f]{16,}$/.test(b.secret) ? b.secret : randomHex(24),
332
+ policy: { ...DEFAULT_POLICY, ...(b.room.policy ?? {}) },
333
+ participants: Object.fromEntries(Object.entries(b.room.participants ?? {}).map(([pk, p]) => [pk, { name: p.name ?? "", joinedAt: p.joinedAt ?? "", ...(p.removed ? { removed: p.removed } : {}) }])),
334
+ createdAt: b.room.createdAt ?? new Date().toISOString(),
335
+ createdBy: b.room.createdBy ?? "",
336
+ state: b.room.state === "closed" ? "closed" : "open",
337
+ ...(b.room.e2e ? { e2e: true } : {}),
338
+ ...(b.relayPub && /^[0-9a-f]{64}$/.test(b.relayPub) ? { pastRelayPubs: [b.relayPub] } : {}),
339
+ ...(asMirror ? { role: "mirror" as const, origin: typeof b.origin === "string" ? b.origin : "" } : {}),
340
+ };
341
+ for (const m of msgs) await this.ctx.storage.put(seqKey(m.seq), m);
342
+ await this.ctx.storage.put<Last>("last", tail ? { seq: tail.seq, hash: tail.hash } : { seq: 0, hash: genesis(id) });
343
+ await this.ctx.storage.put("meta", meta);
344
+ // The custody transfer is recorded in-band, signed by THIS relay's key. A mirror stays
345
+ // byte-identical to its primary, so it appends nothing.
346
+ if (!asMirror) await this.appendSystem(meta, { event: "import", fromRelayPub: b.relayPub ?? "", atSeq: tail?.seq ?? 0 });
347
+ return c.json({ id, secret: meta.secret, imported: msgs.length, room: await this.info(meta, true) });
348
+ });
349
+
350
+ // Replicate (mirrors, v0.4.16). No bearer: the verification IS the authorisation —
351
+ // only envelopes that carry valid participant/relay signatures AND extend this exact
352
+ // chain are accepted, and those are by definition the true transcript. Worst case a
353
+ // stranger replays the real room at us, which is what mirroring is.
354
+ app.post("/rooms/:id/replicate", async (c) => {
355
+ const arr = (await c.req.json().catch(() => null)) as Envelope[] | null;
356
+ // re-read AFTER the body await: from here on only storage awaits (input gate = atomic)
357
+ const meta = await this.meta();
358
+ if (!meta) return c.json({ error: "no such room — seed the mirror with an import (role: mirror) first" }, 404);
359
+ if (meta.role !== "mirror") return c.json({ error: "this room is not a mirror" }, 403);
360
+ if (!Array.isArray(arr) || arr.length === 0) return c.json({ error: "array of envelopes required" }, 400);
361
+ const last = (await this.ctx.storage.get<Last>("last"))!;
362
+ let seq = last.seq;
363
+ let prev = last.hash;
364
+ let dirtyMeta = false;
365
+ for (const m of arr) {
366
+ if (typeof m?.seq !== "number" || m.seq <= seq) continue; // already replicated
367
+ if (m.seq !== seq + 1) return c.json({ error: "gap: send everything after lastSeq", lastSeq: seq, lastHash: prev }, 409);
368
+ const opts = meta.pastRelayPubs?.length ? { relayPub: meta.pastRelayPubs[0], pastRelayPubs: meta.pastRelayPubs } : {};
369
+ const v = verifyEnvelope(m, prev, opts);
370
+ if (!v.ok) return c.json({ error: `rejected at seq ${m.seq}: ${v.errors.join(", ")}`, lastSeq: seq }, 400);
371
+ await this.store(m);
372
+ seq = m.seq;
373
+ prev = m.hash;
374
+ // Keep the read-side meta roughly in step with the chain it mirrors.
375
+ if (m.type === "close") { meta.state = "closed"; dirtyMeta = true; }
376
+ if (m.type === "system") {
377
+ const b = (m.body ?? {}) as { event?: string; by?: string; name?: string; target?: string };
378
+ if (b.event === "join" && b.by && !meta.participants[b.by]) { meta.participants[b.by] = { name: b.name ?? "", joinedAt: m.ts }; dirtyMeta = true; }
379
+ if (b.event === "eject" && b.target && meta.participants[b.target]) { meta.participants[b.target].removed = m.ts; dirtyMeta = true; }
380
+ }
381
+ }
382
+ if (dirtyMeta) await this.ctx.storage.put("meta", meta);
383
+ return c.json({ ok: true, lastSeq: seq });
384
+ });
385
+
386
+ // Promote (failover, v0.4.16): a participant turns a mirror into the primary after the
387
+ // original relay died. Signature-only — caps never existed on the mirror. The promote
388
+ // event is signed by THIS relay's key; clients absorb the key change on re-join.
389
+ app.post("/rooms/:id/promote", async (c) => {
390
+ const raw = await c.req.text();
391
+ const sig = verifyRequestHeaders((n) => c.req.header(n), c.req.method, c.req.path, raw);
392
+ if (!sig.ok) return c.json({ error: `promote must be signed: ${sig.error}` }, 401);
393
+ const meta = await this.meta();
394
+ if (!meta) return c.json({ error: "no such room" }, 404);
395
+ if (meta.role !== "mirror") return c.json({ error: "this room is already a primary" }, 409);
396
+ const p = meta.participants[sig.pub];
397
+ if (!p || p.removed) return c.json({ error: "only a participant of the room can promote its mirror" }, 403);
398
+ const origin = meta.origin;
399
+ delete meta.role;
400
+ delete meta.origin;
401
+ await this.ctx.storage.put("meta", meta);
402
+ await this.appendSystem(meta, { event: "promote", by: sig.pub, from: origin ?? "" });
403
+ return c.json({ ok: true, secret: meta.secret });
404
+ });
405
+
406
+ // Wiring a room to a LINE group (BridgeDO only — the worker refuses /rooms/:id/internal/*
407
+ // from outside, so this needs no key of its own). Turning it on never shortens a room's life:
408
+ // it raises ttlSec so the room lives at least `keepAliveSec` from now, and every later append
409
+ // slides it forward again.
410
+ // v0.9.12: what the bridge needs to keep group wires honest — is the room alive, and until when.
411
+ app.get("/rooms/:id/internal/meta", async (c) => {
412
+ const meta = await this.meta();
413
+ if (!meta) return c.json({ error: "no such room" }, 404);
414
+ return c.json({ state: meta.state, expiresAt: new Date(Date.parse(meta.createdAt) + meta.policy.ttlSec * 1000).toISOString(), keepAliveSec: meta.keepAliveSec ?? 0 });
415
+ });
416
+ app.post("/rooms/:id/internal/keepalive", async (c) => {
417
+ const b = (await c.req.json().catch(() => ({}))) as { on?: boolean; keepAliveSec?: number; touch?: boolean };
418
+ const meta = await this.meta();
419
+ if (!meta) return c.json({ error: "no such room" }, 404);
420
+ // v0.9.12 touch: the principal spoke to the agent from the wired group. Same slide an append would give
421
+ // the room; changes nothing when the room has no keep-alive (an unwired room is not the bridge's to extend).
422
+ if (b.touch) {
423
+ if (meta.keepAliveSec && meta.state === "open") {
424
+ const elapsed = Math.round((Date.now() - Date.parse(meta.createdAt)) / 1000);
425
+ meta.policy = { ...meta.policy, ttlSec: Math.max(meta.policy.ttlSec, elapsed + meta.keepAliveSec) };
426
+ await this.ctx.storage.put("meta", meta);
427
+ }
428
+ return c.json({ ok: true, keepAliveSec: meta.keepAliveSec ?? 0, expiresAt: new Date(Date.parse(meta.createdAt) + meta.policy.ttlSec * 1000).toISOString() });
429
+ }
430
+ if (b.on === false) {
431
+ delete meta.keepAliveSec;
432
+ await this.ctx.storage.put("meta", meta);
433
+ return c.json({ ok: true, keepAliveSec: 0, expiresAt: new Date(Date.parse(meta.createdAt) + meta.policy.ttlSec * 1000).toISOString() });
434
+ }
435
+ const keep = Math.max(3600, Math.min(365 * 24 * 3600, Math.round(Number(b.keepAliveSec) || 0) || 30 * 24 * 3600));
436
+ const elapsed = Math.round((Date.now() - Date.parse(meta.createdAt)) / 1000);
437
+ meta.keepAliveSec = keep;
438
+ meta.policy = { ...meta.policy, ttlSec: Math.max(meta.policy.ttlSec, elapsed + keep) };
439
+ await this.ctx.storage.put("meta", meta);
440
+ return c.json({ ok: true, keepAliveSec: keep, expiresAt: new Date(Date.parse(meta.createdAt) + meta.policy.ttlSec * 1000).toISOString() });
441
+ });
442
+
443
+ // Everything below needs the room secret or a participant's cap.
444
+ app.use("/rooms/:id/*", async (c, next) => {
445
+ const meta = await this.meta();
446
+ if (!meta) return c.json({ error: "no such room" }, 404);
447
+ const auth = c.req.header("authorization") ?? "";
448
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
449
+ if (!token) return c.json({ error: "bad room secret" }, 401);
450
+ let authPub = "";
451
+ if (token !== meta.secret) {
452
+ const hit = Object.entries(meta.participants).find(([, p]) => p.cap && p.cap === token && !p.removed);
453
+ if (!hit) return c.json({ error: "bad room secret" }, 401);
454
+ authPub = hit[0];
455
+ }
456
+ c.set("authPub" as never, authPub as never);
457
+ await next();
458
+ });
459
+
460
+ app.get("/rooms/:id/info", async (c) => {
461
+ const meta = (await this.meta())!;
462
+ return c.json(await this.info(meta, !!(c.get("authPub" as never) as string)));
463
+ });
464
+
465
+ app.post("/rooms/:id/join", async (c) => {
466
+ const raw = await c.req.text();
467
+ const sig = verifyRequestHeaders((n) => c.req.header(n), c.req.method, c.req.path, raw);
468
+ if (!sig.ok) return c.json({ error: `join must be signed by the joining key: ${sig.error}` }, 401);
469
+ let b: { pubkey?: string; name?: string } = {};
470
+ try { b = JSON.parse(raw || "{}"); } catch { /* handled below */ }
471
+ const meta = (await this.meta())!; // re-read AFTER the body await: from here to the put, only storage awaits (input gate = atomic)
472
+ if (!b.pubkey || !PUB_RE.test(b.pubkey)) return c.json({ error: "pubkey (hex ed25519) required" }, 400);
473
+ if (b.pubkey !== sig.pub) return c.json({ error: "pubkey does not match the signing key" }, 401);
474
+ if (meta.role === "mirror") return c.json({ error: `this room is a read-only mirror${meta.origin ? ` of ${meta.origin}` : ""} — join at the primary (or promote first)` }, 409);
475
+ if (meta.state !== "open") return c.json({ error: "room closed" }, 409);
476
+ const existing = meta.participants[b.pubkey];
477
+ if (existing?.removed) return c.json({ error: "you were removed from this room" }, 403);
478
+ if (!existing) {
479
+ meta.participants[b.pubkey] = { name: b.name ?? "", joinedAt: new Date().toISOString(), cap: randomHex(24) };
480
+ await this.ctx.storage.put("meta", meta);
481
+ await this.appendSystem(meta, { event: "join", by: b.pubkey, name: b.name ?? "" });
482
+ } else if (!existing.cap) {
483
+ existing.cap = randomHex(24); // participant from before caps existed: issue one now
484
+ await this.ctx.storage.put("meta", meta);
485
+ }
486
+ return c.json({ ...(await this.info(meta, true)), cap: meta.participants[b.pubkey].cap });
487
+ });
488
+
489
+ app.get("/rooms/:id/messages", async (c) => {
490
+ const since = Math.max(0, Number(c.req.query("since") ?? 0) || 0);
491
+ const wait = Math.min(50, Math.max(0, Number(c.req.query("wait") ?? 0) || 0));
492
+ let msgs = await this.since(since);
493
+ if (msgs.length === 0 && wait > 0) {
494
+ await Promise.race([
495
+ new Promise<void>((r) => this.waiters.push(r)),
496
+ new Promise<void>((r) => setTimeout(r, wait * 1000)),
497
+ ]);
498
+ msgs = await this.since(since);
499
+ }
500
+ const last = (await this.ctx.storage.get<Last>("last"))!;
501
+ const meta = (await this.meta())!;
502
+ return c.json({ messages: msgs, lastSeq: last.seq, lastHash: last.hash, state: meta.state, head: this.head(meta.id, last), relayPub: relayPub(this.env) });
503
+ });
504
+
505
+ app.get("/rooms/:id/head", async (c) => {
506
+ const last = (await this.ctx.storage.get<Last>("last"))!;
507
+ const meta = (await this.meta())!;
508
+ const h = this.head(meta.id, last);
509
+ if (!h) return c.json({ error: "relay has no signing key" }, 404);
510
+ return c.json(h);
511
+ });
512
+
513
+ // Export (portable rooms, v0.4.15): everything another relay needs to re-home this
514
+ // room. Caps never leave. The invite secret rides along only for a cap-authenticated
515
+ // caller (same rule as info), so existing links keep working after a move.
516
+ app.get("/rooms/:id/export", async (c) => {
517
+ const meta = (await this.meta())!;
518
+ const last = (await this.ctx.storage.get<Last>("last"))!;
519
+ const msgs = await this.since(0);
520
+ const participants: Record<string, Participant> = {};
521
+ for (const [pk, p] of Object.entries(meta.participants)) { const { cap: _c, ...rp } = p; participants[pk] = rp; }
522
+ const withSecret = !!(c.get("authPub" as never) as string);
523
+ return c.json({
524
+ format: "parley-export-1",
525
+ exportedAt: new Date().toISOString(),
526
+ room: { id: meta.id, name: meta.name, policy: meta.policy, participants, createdAt: meta.createdAt, createdBy: meta.createdBy, state: meta.state, ...(meta.e2e ? { e2e: true } : {}) },
527
+ ...(withSecret ? { secret: meta.secret } : {}),
528
+ messages: msgs,
529
+ relayPub: relayPub(this.env),
530
+ head: this.head(meta.id, last),
531
+ });
532
+ });
533
+
534
+ // ---- RFC 3161 anchoring ---------------------------------------------------------------
535
+ // A neutral third party attests that the head existed at a time the operator did not choose.
536
+ // GET returns the latest anchor; POST takes a fresh one over the current head.
537
+ app.get("/rooms/:id/anchor", async (c) => {
538
+ if (!this.env.TSA_URL) return c.json({ error: "anchoring not configured on this relay" }, 501);
539
+ const a = await this.ctx.storage.get<Anchor>("anchor:last");
540
+ if (!a) return c.json({ error: "this room has never been anchored" }, 404);
541
+ const last = (await this.ctx.storage.get<Last>("last"))!;
542
+ // An anchor attests to the head it was taken over, not to whatever the room says now.
543
+ return c.json({ ...a, current: { seq: last.seq, hash: last.hash }, stale: a.hash !== last.hash });
544
+ });
545
+
546
+ app.post("/rooms/:id/anchor", async (c) => {
547
+ if (!this.env.TSA_URL) return c.json({ error: "anchoring not configured on this relay" }, 501);
548
+ const last = (await this.ctx.storage.get<Last>("last"))!;
549
+ try {
550
+ const a = await this.anchor(last);
551
+ return c.json(a);
552
+ } catch (e) {
553
+ // Never store or report a failed anchor as if it were evidence.
554
+ return c.json({ error: e instanceof AnchorError ? e.message : "anchor failed" }, 502);
555
+ }
556
+ });
557
+
558
+ app.post("/rooms/:id/messages", async (c) => {
559
+ const s = (await c.req.json().catch(() => null)) as Submitted | null;
560
+ if (!s || typeof s !== "object") return c.json({ error: "envelope required" }, 400);
561
+ // Everything below must stay free of non-storage awaits: the DO input gate then makes
562
+ // read-last → check-prev → store atomic, so two racing sends cannot both take seq N.
563
+ const authPub = c.get("authPub" as never) as string;
564
+ // v0.9.0 upgrade protocol: a client that announces a version below the relay's minimum may read but not
565
+ // speak. Header-less callers (the bridge's own mirror appends, pre-0.9 clients) are gated on /p/* instead.
566
+ const ver = (c.req.header("x-can2cup-client") ?? "").trim();
567
+ if (ver && cmpSemver(ver, (this.env.MIN_CLIENT ?? "").trim() || NO_VERSION) < 0) return c.json({ error: `upgrade required: can2cup ${ver} is below this relay's minimum ${(this.env.MIN_CLIENT ?? "").trim()} — run \`can2cup upgrade\` on that computer, then restart Claude Code once`, min: (this.env.MIN_CLIENT ?? "").trim(), cmd: "can2cup upgrade" }, 426);
568
+ const meta = (await this.meta())!;
569
+ const last = (await this.ctx.storage.get<Last>("last"))!;
570
+ if (meta.role === "mirror") return c.json({ error: `this room is a read-only mirror${meta.origin ? ` of ${meta.origin}` : ""} — write to the primary`, lastSeq: last.seq, lastHash: last.hash }, 409);
571
+ if (meta.state !== "open") return c.json({ error: "room closed", lastSeq: last.seq, lastHash: last.hash }, 409);
572
+ const expiresAt = Date.parse(meta.createdAt) + meta.policy.ttlSec * 1000;
573
+ if (Date.now() > expiresAt) {
574
+ return c.json({
575
+ error: `room ttl expired at ${new Date(expiresAt).toISOString()} (rooms live ${Math.round(meta.policy.ttlSec / 3600)} h from creation${meta.keepAliveSec ? ", sliding while in use" : ""}) — open a new room; if this room was a LINE group's channel, type /room in that group`,
576
+ expiredAt: new Date(expiresAt).toISOString(),
577
+ }, 410);
578
+ }
579
+ if (last.seq >= meta.policy.maxMessages) return c.json({ error: "room message cap reached; close it" }, 429);
580
+ if (s.room !== meta.id) return c.json({ error: "envelope.room mismatch" }, 400);
581
+ if (s.from === RELAY_SENDER || s.type === "system") return c.json({ error: "system events are relay-only" }, 403);
582
+ const p = meta.participants[s.from];
583
+ if (!p || p.removed) return c.json({ error: "sender is not a participant" }, 403);
584
+ const rateLim = Math.max(1, Number(this.env.MSGS_PER_MIN ?? 60) || 60);
585
+ const nowMin = Math.floor(Date.now() / 60000);
586
+ const rate = this.sendRate.get(s.from);
587
+ if (!rate || rate.min !== nowMin) this.sendRate.set(s.from, { min: nowMin, n: 1 });
588
+ else if (++rate.n > rateLim) return c.json({ error: `rate limited: over ${rateLim} messages in a minute from this key`, retryInSec: 60 - Math.floor((Date.now() % 60000) / 1000) }, 429);
589
+ if (authPub && authPub !== s.from) return c.json({ error: "cap does not belong to envelope.from" }, 403);
590
+ if (!MSG_TYPES.includes(s.type)) return c.json({ error: "unknown type" }, 400);
591
+ if (s.prev !== last.hash) return c.json({ error: "stale prev; refetch and re-sign", lastSeq: last.seq, lastHash: last.hash }, 409);
592
+ const seq = last.seq + 1;
593
+ const partial = { ...s, seq };
594
+ const env: Envelope = { ...partial, hash: computeHash(partial) };
595
+ const v = verifyEnvelope(env, last.hash);
596
+ if (!v.ok) return c.json({ error: "rejected", details: v.errors }, 400);
597
+ if (env.type === "close") meta.state = "closed";
598
+ await this.store(env);
599
+ // v0.9.2: a wired room's clock runs from its last message, not from its creation. Only
600
+ // written when the remaining life has dropped below half, so busy rooms do not pay a
601
+ // storage write per message.
602
+ let slid = false;
603
+ if (meta.keepAliveSec && expiresAt - Date.now() < (meta.keepAliveSec * 1000) / 2) {
604
+ meta.policy = { ...meta.policy, ttlSec: Math.round((Date.now() - Date.parse(meta.createdAt)) / 1000) + meta.keepAliveSec };
605
+ slid = true;
606
+ }
607
+ if (env.type === "close" || slid) await this.ctx.storage.put("meta", meta);
608
+ await this.tellBridge(env, meta); // after everything is committed; storage-only on the far side
609
+ // A commitment (accept / grant) is the moment the transcript may later be cited, so that
610
+ // is what we anchor. Off the response path: the TSA round trip must not hold the input
611
+ // gate, and a TSA outage must never make a valid send fail.
612
+ if (this.env.TSA_URL && COMMITMENT_TYPES.includes(env.type)) {
613
+ this.ctx.waitUntil(this.anchor({ seq: env.seq, hash: env.hash }).catch(() => undefined));
614
+ }
615
+ return c.json(env);
616
+ });
617
+
618
+ // ---- room administration: signed by a participant's key ------------------------------
619
+ app.post("/rooms/:id/rotate", async (c) => {
620
+ const raw = await c.req.text();
621
+ const sig = verifyRequestHeaders((n) => c.req.header(n), c.req.method, c.req.path, raw);
622
+ if (!sig.ok) return c.json({ error: `rotate must be signed: ${sig.error}` }, 401);
623
+ const meta = (await this.meta())!;
624
+ if (meta.role === "mirror") return c.json({ error: "a mirror is read-only — rotate at the primary" }, 409);
625
+ const p = meta.participants[sig.pub];
626
+ if (!p || p.removed) return c.json({ error: "only an active participant can rotate" }, 403);
627
+ meta.secret = randomHex(24);
628
+ await this.ctx.storage.put("meta", meta);
629
+ await this.appendSystem(meta, { event: "rotate", by: sig.pub });
630
+ return c.json({ ok: true, secret: meta.secret });
631
+ });
632
+
633
+ app.post("/rooms/:id/eject", async (c) => {
634
+ const raw = await c.req.text();
635
+ const sig = verifyRequestHeaders((n) => c.req.header(n), c.req.method, c.req.path, raw);
636
+ if (!sig.ok) return c.json({ error: `eject must be signed: ${sig.error}` }, 401);
637
+ let b: { pubkey?: string } = {};
638
+ try { b = JSON.parse(raw || "{}"); } catch { /* below */ }
639
+ const meta = (await this.meta())!;
640
+ if (meta.role === "mirror") return c.json({ error: "a mirror is read-only — eject at the primary" }, 409);
641
+ if (sig.pub !== meta.createdBy) return c.json({ error: "only the room creator can eject" }, 403);
642
+ const target = b.pubkey ?? "";
643
+ const t = meta.participants[target];
644
+ if (!t) return c.json({ error: "no such participant" }, 404);
645
+ if (target === meta.createdBy) return c.json({ error: "the creator cannot eject themselves; close the room instead" }, 400);
646
+ if (!t.removed) {
647
+ t.removed = new Date().toISOString();
648
+ delete t.cap;
649
+ meta.secret = randomHex(24); // their invite link must die with their cap
650
+ await this.ctx.storage.put("meta", meta);
651
+ await this.appendSystem(meta, { event: "eject", by: sig.pub, target, name: t.name });
652
+ }
653
+ return c.json({ ok: true, secret: meta.secret });
654
+ });
655
+
656
+ /** v0.9.5: leave a room you are in. Signed by the leaver — nobody can be walked out by
657
+ * someone else through this route (that is `eject`, and only the creator may call it).
658
+ *
659
+ * Rotates the secret, like eject: someone who has left still knows the old one, and "I left"
660
+ * has to mean they cannot walk back in. Everyone else's cap keeps working; only pending
661
+ * invite links have to be re-issued.
662
+ *
663
+ * The transcript stays. The other participants hold a signed copy of every message either
664
+ * side wrote, and deleting this relay's copy would not retract theirs. */
665
+ app.post("/rooms/:id/leave", async (c) => {
666
+ const raw = await c.req.text();
667
+ const sig = verifyRequestHeaders((n) => c.req.header(n), c.req.method, c.req.path, raw);
668
+ if (!sig.ok) return c.json({ error: `leave must be signed: ${sig.error}` }, 401);
669
+ const meta = (await this.meta())!;
670
+ if (meta.role === "mirror") return c.json({ error: "a mirror is read-only — leave at the primary" }, 409);
671
+ const me = meta.participants[sig.pub];
672
+ if (!me || me.removed) return c.json({ error: "you are not in this room" }, 404);
673
+ if (sig.pub === meta.createdBy) {
674
+ const others = Object.entries(meta.participants).filter(([pk, p]) => pk !== sig.pub && !p.removed);
675
+ if (others.length) return c.json({ error: "you opened this room — close it (with a summary the others can read) instead of walking out of it" }, 400);
676
+ }
677
+ me.removed = new Date().toISOString();
678
+ delete me.cap;
679
+ meta.secret = randomHex(24);
680
+ const left = Object.values(meta.participants).filter((p) => !p.removed).length;
681
+ if (!left) meta.state = "closed"; // nobody is in it any more; stop it lingering as "open"
682
+ await this.ctx.storage.put("meta", meta);
683
+ await this.appendSystem(meta, { event: "leave", by: sig.pub, name: me.name });
684
+ return c.json({ ok: true, remaining: left, roomClosed: !left });
685
+ });
686
+
687
+ // ---- mirrors (v0.4.16): where every append of this room is pushed --------------------
688
+ // Signed by an active participant, like rotate. The mirror room itself must already
689
+ // exist on the target relay (seeded with an import, role: mirror).
690
+ app.post("/rooms/:id/mirrors", async (c) => {
691
+ const raw = await c.req.text();
692
+ const sig = verifyRequestHeaders((n) => c.req.header(n), c.req.method, c.req.path, raw);
693
+ if (!sig.ok) return c.json({ error: `mirrors must be signed: ${sig.error}` }, 401);
694
+ let b: { add?: string; remove?: string } = {};
695
+ try { b = JSON.parse(raw || "{}"); } catch { /* below */ }
696
+ const meta = (await this.meta())!;
697
+ if (meta.role === "mirror") return c.json({ error: "a mirror cannot have mirrors of its own" }, 409);
698
+ const p = meta.participants[sig.pub];
699
+ if (!p || p.removed) return c.json({ error: "only an active participant can manage mirrors" }, 403);
700
+ const cur = new Set(meta.mirrors ?? []);
701
+ if (b.add) {
702
+ if (!/^https?:\/\/\S+$/.test(b.add)) return c.json({ error: "add must be a relay base URL" }, 400);
703
+ cur.add(b.add.replace(/\/+$/, ""));
704
+ } else if (b.remove) {
705
+ cur.delete(b.remove.replace(/\/+$/, ""));
706
+ } else return c.json({ error: "add or remove required" }, 400);
707
+ meta.mirrors = [...cur];
708
+ await this.ctx.storage.put("meta", meta);
709
+ // In-band, so every participant sees where copies of the room live.
710
+ await this.appendSystem(meta, { event: b.add ? "mirror" : "unmirror", by: sig.pub, url: (b.add ?? b.remove ?? "").replace(/\/+$/, "") });
711
+ return c.json({ ok: true, mirrors: meta.mirrors });
712
+ });
713
+ }
714
+
715
+ private async meta(): Promise<Meta | undefined> {
716
+ return this.ctx.storage.get<Meta>("meta");
717
+ }
718
+
719
+ /** Public view. Caps never leave; the invite secret only goes to a cap-authenticated caller. */
720
+ private async info(meta: Meta, withSecret: boolean): Promise<RoomInfo> {
721
+ const last = (await this.ctx.storage.get<Last>("last")) ?? { seq: 0, hash: genesis(meta.id) };
722
+ const { secret, participants, ...rest } = meta;
723
+ const pub: Record<string, Participant> = {};
724
+ for (const [pk, p] of Object.entries(participants)) {
725
+ const { cap: _c, ...rp } = p;
726
+ pub[pk] = rp;
727
+ }
728
+ return { ...rest, participants: pub, lastSeq: last.seq, lastHash: last.hash, relayPub: relayPub(this.env), ...(withSecret ? { secret } : {}) };
729
+ }
730
+
731
+ private head(room: string, last: Last): Head | undefined {
732
+ const k = this.env.RELAY_SIGNING_KEY;
733
+ if (!k) return undefined;
734
+ return signHead({ room, seq: last.seq, hash: last.hash, at: new Date().toISOString() }, k);
735
+ }
736
+
737
+ /** Take an RFC 3161 timestamp over a head and keep it. Kept by seq as well as
738
+ * "last" so an old commitment's anchor survives later traffic. */
739
+ private async anchor(at: Last): Promise<Anchor> {
740
+ const a = await requestTimestamp(this.env.TSA_URL!, at.seq, at.hash, randomHex(8));
741
+ await this.ctx.storage.put("anchor:last", a);
742
+ await this.ctx.storage.put("anchor:" + String(a.seq).padStart(8, "0"), a);
743
+ return a;
744
+ }
745
+
746
+ private async since(n: number): Promise<Envelope[]> {
747
+ const map = await this.ctx.storage.list<Envelope>({ prefix: "m:", start: seqKey(n + 1) });
748
+ return [...map.values()];
749
+ }
750
+
751
+ private async store(env: Envelope): Promise<void> {
752
+ await this.ctx.storage.put(seqKey(env.seq), env);
753
+ await this.ctx.storage.put<Last>("last", { seq: env.seq, hash: env.hash });
754
+ const w = this.waiters;
755
+ this.waiters = [];
756
+ for (const r of w) r();
757
+ // Off the response path: a mirror being down must never slow or fail an append.
758
+ this.ctx.waitUntil(this.pushMirrors().catch(() => undefined));
759
+ }
760
+
761
+ /** Replicate the tail to every mirror. Stateless on purpose: push the newest envelope;
762
+ * a 409 carries the mirror's cursor and we resend everything after it. Failures are
763
+ * silent — the next append retries, and `can2cup mirror --add` re-syncs from scratch. */
764
+ private async pushMirrors(): Promise<void> {
765
+ const meta = await this.meta();
766
+ if (!meta?.mirrors?.length || meta.role === "mirror") return;
767
+ const last = await this.ctx.storage.get<Last>("last");
768
+ if (!last?.seq) return;
769
+ for (const m of meta.mirrors) {
770
+ try {
771
+ const url = `${m}/rooms/${meta.id}/replicate`;
772
+ const post = (body: Envelope[]) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000) });
773
+ let res = await post(await this.since(last.seq - 1));
774
+ if (res.status === 409) {
775
+ const j = (await res.json().catch(() => ({}))) as { lastSeq?: number };
776
+ if (typeof j.lastSeq === "number") res = await post(await this.since(j.lastSeq));
777
+ }
778
+ } catch { /* mirror unreachable — next append retries */ }
779
+ }
780
+ }
781
+
782
+ /** Tell the principal bridge (LINE pushes, group mirrors, room knowledge). Awaited so it is
783
+ * reliable, but only ever called after the append is committed, and never allowed to throw. */
784
+ private async tellBridge(env: Envelope, meta: Meta): Promise<void> {
785
+ const parts: Record<string, { name: string }> = {};
786
+ for (const [pk, p] of Object.entries(meta.participants)) if (!p.removed) parts[pk] = { name: p.name };
787
+ const ev: RoomEvent = { room: meta.id, name: meta.name, state: meta.state, participants: parts, envelope: env };
788
+ try {
789
+ const stub = this.env.BRIDGE.get(this.env.BRIDGE.idFromName("bridge"));
790
+ await stub.fetch(new Request("https://do/internal/event", { method: "POST", body: JSON.stringify(ev), headers: { "content-type": "application/json" } }));
791
+ } catch { /* bridge trouble must not surface to participants */ }
792
+ }
793
+
794
+ private async appendSystem(meta: Meta, body: unknown): Promise<void> {
795
+ const last = (await this.ctx.storage.get<Last>("last"))!;
796
+ const unsigned = {
797
+ v: PROTOCOL_VERSION, room: meta.id, from: RELAY_SENDER, ts: new Date().toISOString(),
798
+ type: "system" as const, body, prev: last.hash,
799
+ };
800
+ const k = this.env.RELAY_SIGNING_KEY;
801
+ const partial = { ...unsigned, sig: k ? signHex(signingBytes(unsigned), k) : "", seq: last.seq + 1 };
802
+ const env: Envelope = { ...partial, hash: computeHash(partial) };
803
+ await this.store(env);
804
+ await this.tellBridge(env, meta);
805
+ }
806
+ }