diff --git a/STATUS.md b/STATUS.md index 9344db38..97af9477 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # Infrastructure Status -Last Updated: 2026-05-30 16:34 UTC +Last Updated: 2026-05-30 16:40 UTC ## 🖥️ MCP Servers | Server | Status | Latency | diff --git a/replicas-matrix-bridge/src/dispatch.ts b/replicas-matrix-bridge/src/dispatch.ts index 133117aa..f69abeb8 100644 --- a/replicas-matrix-bridge/src/dispatch.ts +++ b/replicas-matrix-bridge/src/dispatch.ts @@ -260,6 +260,7 @@ function replicasHeaders(env: Env): HeadersInit { return { Authorization: `Bearer ${env.REPLICAS_API_KEY}`, "Replicas-Org-Id": env.REPLICAS_ORG_ID, + "Content-Type": "application/json", }; } diff --git a/replicas-matrix-bridge/src/index.ts b/replicas-matrix-bridge/src/index.ts index b079e7e3..d5a09f40 100644 --- a/replicas-matrix-bridge/src/index.ts +++ b/replicas-matrix-bridge/src/index.ts @@ -31,6 +31,42 @@ export interface Env { // wrangler.toml for the Claude Max $200 plan; override per-account. USAGE_QUOTA_5H_TOK?: string; USAGE_QUOTA_7D_TOK?: string; + // Bearer token gating /admin/*, /debug/*, and /dispatch. When unset the + // endpoints fall through with a warning log (migration mode) so existing + // operator curl flows keep working. Once set, every protected route + // requires `Authorization: Bearer ${ADMIN_TOKEN}`. Set via + // `wrangler secret put ADMIN_TOKEN`. + ADMIN_TOKEN?: string; +} + +// Constant-time string compare to avoid leaking ADMIN_TOKEN length / prefix +// via response-time differences. Falls back to a length-mismatch fast path +// (both branches still walk both strings). +function timingSafeEqual(a: string, b: string): boolean { + const la = a.length; + const lb = b.length; + const len = Math.max(la, lb); + let diff = la ^ lb; + for (let i = 0; i < len; i++) { + const ca = i < la ? a.charCodeAt(i) : 0; + const cb = i < lb ? b.charCodeAt(i) : 0; + diff |= ca ^ cb; + } + return diff === 0; +} + +function requireAuth(req: Request, env: Env, pathname: string): Response | null { + if (!env.ADMIN_TOKEN) { + console.log(`[auth] WARNING: ${pathname} called without ADMIN_TOKEN configured — allowing (migration mode). Set ADMIN_TOKEN secret to lock down.`); + return null; + } + const header = req.headers.get("Authorization") ?? ""; + const match = /^Bearer\s+(.+)$/i.exec(header); + const presented = match?.[1]?.trim() ?? ""; + if (!presented || !timingSafeEqual(presented, env.ADMIN_TOKEN)) { + return new Response("unauthorized", { status: 401 }); + } + return null; } export { ReplicaPoller } from "./poller"; @@ -51,6 +87,20 @@ export default { return new Response("ok"); } + // Gate every privileged path. /health stays public; everything else + // (admin/debug + dispatch + start-listener) requires ADMIN_TOKEN + // once it's set. While unset, the helper logs a warning and lets + // the request through so we don't break operator flows mid-migration. + const isProtected = + url.pathname.startsWith("/admin/") || + url.pathname.startsWith("/debug/") || + url.pathname === "/dispatch" || + url.pathname === "/start-listener"; + if (isProtected) { + const denied = requireAuth(req, env, url.pathname); + if (denied) return denied; + } + if (req.method === "POST" && url.pathname === "/start-listener") { const stub = env.LISTENER.get(env.LISTENER.idFromName("global")); await stub.fetch("https://listener/start", { method: "POST" }); diff --git a/replicas-matrix-bridge/src/listener.ts b/replicas-matrix-bridge/src/listener.ts index 8436c61d..5db84f04 100644 --- a/replicas-matrix-bridge/src/listener.ts +++ b/replicas-matrix-bridge/src/listener.ts @@ -265,6 +265,16 @@ export class MatrixListener { if (ev.type === "m.room.message") { const content = ev.content ?? {}; + // Skip m.replace edits. When a user edits a prior message + // the homeserver emits a fresh m.room.message whose body + // is the fallback `* ` string and whose + // m.relates_to.rel_type === "m.replace". Treating this + // like a new prompt would spawn a turn whose body starts + // with `* ` and waste credits. The user's intent was to + // amend, not re-ask. Future enhancement: cancel the + // in-flight turn + relaunch with the edited content. + const rel = (content as { "m.relates_to"?: { rel_type?: string } })["m.relates_to"]; + if (rel?.rel_type === "m.replace") continue; msgtype = content.msgtype as string | undefined; body = content.body as string | undefined; } else if (ev.type === "m.room.encrypted") { @@ -370,9 +380,12 @@ export class MatrixListener { const { plaintext } = await decryptMegolm(sessionKey, ciphertext); const inner = JSON.parse(plaintext) as { type?: string; - content?: { msgtype?: string; body?: string }; + content?: { msgtype?: string; body?: string; "m.relates_to"?: { rel_type?: string } }; }; if (inner.type !== "m.room.message") return undefined; + // Skip m.replace edits in the encrypted path too — see the + // matching check in the unencrypted branch above. + if (inner.content?.["m.relates_to"]?.rel_type === "m.replace") return undefined; return { msgtype: inner.content?.msgtype ?? "", body: inner.content?.body ?? "", @@ -570,13 +583,25 @@ export class MatrixListener { const r = await fetch(url, { headers: { Authorization: `Bearer ${this.env.MATRIX_ACCESS_TOKEN}` }, }); - if (!r.ok) return 2; // fail-open to "treat as DM" so we don't go silent + if (!r.ok) { + // Fail CLOSED on cold-cache lookup failure. Prior fail-open + // (return 2) treated unknown rooms as DMs, so a transient 401 + // or rate-limit during the first message in a 50-person + // channel caused the bot to dispatch on every message until + // the next successful lookup — empirically observed as + // "bot exploded into a group chat" complaints. Returning a + // large sentinel keeps it quiet until mention rules pass or + // the count is genuinely known. + console.log(`[listener] joined_members ${r.status} for ${roomId} — fail-closed`); + return 100; + } const j = (await r.json()) as { joined?: Record }; const n = Object.keys(j.joined ?? {}).length; await this.env.MAP.put(cacheKey, String(n), { expirationTtl: 3600 }); return n; - } catch { - return 2; + } catch (e) { + console.log(`[listener] joined_members threw for ${roomId}: ${e instanceof Error ? e.message : e} — fail-closed`); + return 100; } } diff --git a/replicas-matrix-bridge/src/poller.ts b/replicas-matrix-bridge/src/poller.ts index b42f08a7..05c6dbe0 100644 --- a/replicas-matrix-bridge/src/poller.ts +++ b/replicas-matrix-bridge/src/poller.ts @@ -1706,6 +1706,7 @@ function replicasHeaders(env: Env): HeadersInit { return { Authorization: `Bearer ${env.REPLICAS_API_KEY}`, "Replicas-Org-Id": env.REPLICAS_ORG_ID, + "Content-Type": "application/json", }; }