can2cup 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Principal-signed messages — the principal → agent channel, end to end.
3
+ *
4
+ * v0.2 and earlier: whatever the bridge put in an agent's inbox was shown to the model as
5
+ * "principal instructions", and the only thing standing between an attacker and that label
6
+ * was the bot's shared BRIDGE_KEY (2026-08-19 review, F1/F7). v0.3 gives the principal an
7
+ * ed25519 keypair of their own (`~/.parley/principal.json`). An instruction or a pause that
8
+ * carries a valid signature by THAT key — addressed to THIS agent, with a fresh nonce — is
9
+ * the only thing the MCP server will ever label as verified. The relay and the bot can still
10
+ * write unsigned items; those stay explicitly unverified.
11
+ *
12
+ * The signature covers `agent`, so a message signed for one agent cannot be replayed to
13
+ * another; `nonce` lets the receiver refuse the same message twice; `at` orders pauses.
14
+ * `approve` binds an approval to a concrete envelope (room, seq, hash), so an approval can
15
+ * never be re-aimed at a different decision than the one the principal looked at (F6).
16
+ */
17
+ import { canon } from "./canon.js";
18
+ import { randomHex, signHex, verifyHex } from "./crypto.js";
19
+
20
+ export interface ApproveRef { room: string; seq: number; hash: string; ok: boolean }
21
+
22
+ export interface PrincipalMsg {
23
+ kind: "say" | "pause";
24
+ agent: string; // the agent's pubkey this is addressed to
25
+ at: string; // ISO-8601
26
+ nonce: string; // random hex; receivers keep a seen-set
27
+ text?: string; // say
28
+ paused?: boolean; // pause
29
+ approve?: ApproveRef; // say: this text is a decision on one specific envelope
30
+ }
31
+ export interface SignedPrincipalMsg extends PrincipalMsg { pub: string; sig: string }
32
+
33
+ export function principalSigningBytes(m: PrincipalMsg): string {
34
+ const { kind, agent, at, nonce, text, paused, approve } = m;
35
+ return canon({ kind, agent, at, nonce, text, paused, approve });
36
+ }
37
+
38
+ export function signPrincipal(m: Omit<PrincipalMsg, "at" | "nonce"> & Partial<Pick<PrincipalMsg, "at" | "nonce">>, priv: string, pub: string): SignedPrincipalMsg {
39
+ const full: PrincipalMsg = { ...m, at: m.at ?? new Date().toISOString(), nonce: m.nonce ?? randomHex(16) };
40
+ return { ...full, pub, sig: signHex(principalSigningBytes(full), priv) };
41
+ }
42
+
43
+ /** Structural + cryptographic check. `expectedPub` = the principal key the verifier trusts
44
+ * (the agent pins its own principal.json pubkey; the bridge pins what the agent registered);
45
+ * `expectedAgent` = the verifier's own agent pubkey. */
46
+ export function verifyPrincipal(s: SignedPrincipalMsg, expectedPub: string, expectedAgent: string): { ok: boolean; error?: string } {
47
+ if (!s || typeof s !== "object") return { ok: false, error: "not a message" };
48
+ if (s.kind !== "say" && s.kind !== "pause") return { ok: false, error: "bad kind" };
49
+ if (!/^[0-9a-f]{64}$/.test(s.pub ?? "")) return { ok: false, error: "bad pub" };
50
+ if (s.pub !== expectedPub) return { ok: false, error: "signed by a key that is not your principal's" };
51
+ if (s.agent !== expectedAgent) return { ok: false, error: "addressed to a different agent" };
52
+ if (typeof s.nonce !== "string" || s.nonce.length < 16) return { ok: false, error: "bad nonce" };
53
+ if (!Number.isFinite(Date.parse(s.at ?? ""))) return { ok: false, error: "bad timestamp" };
54
+ if (s.kind === "say" && typeof s.text !== "string") return { ok: false, error: "say without text" };
55
+ if (s.kind === "pause" && typeof s.paused !== "boolean") return { ok: false, error: "pause without paused flag" };
56
+ if (!verifyHex(s.sig, principalSigningBytes(s), s.pub)) return { ok: false, error: "bad principal signature" };
57
+ return { ok: true };
58
+ }
59
+
60
+ // ------------------------------------------------ signed HTTP requests ---
61
+
62
+ /** Requests that must prove possession of a key (agent → bridge, agent → room admin ops,
63
+ * principal → bridge) carry three headers and sign "METHOD\nPATH\nTS\nBODY". The relay
64
+ * checks the timestamp is within ±5 minutes. Same scheme for every key role, so one helper. */
65
+ export const REQ_SIG_SKEW_MS = 5 * 60 * 1000;
66
+
67
+ export function requestSigningBytes(method: string, path: string, ts: string, body: string): string {
68
+ return `${method}\n${path}\n${ts}\n${body}`;
69
+ }
70
+
71
+ export function signRequestHeaders(method: string, path: string, body: string, key: { pub: string; priv: string }, prefix = "x-parley"): Record<string, string> {
72
+ const ts = new Date().toISOString();
73
+ return {
74
+ [`${prefix}-pub`]: key.pub,
75
+ [`${prefix}-ts`]: ts,
76
+ [`${prefix}-sig`]: signHex(requestSigningBytes(method, path, ts, body), key.priv),
77
+ };
78
+ }
79
+
80
+ export function verifyRequestHeaders(
81
+ h: (name: string) => string | undefined, method: string, path: string, body: string, prefix = "x-parley", now = Date.now(),
82
+ ): { ok: true; pub: string } | { ok: false; error: string } {
83
+ const pub = h(`${prefix}-pub`) ?? "";
84
+ const ts = h(`${prefix}-ts`) ?? "";
85
+ const sig = h(`${prefix}-sig`) ?? "";
86
+ if (!/^[0-9a-f]{64}$/.test(pub) || !ts || !sig) return { ok: false, error: "missing signature headers" };
87
+ if (Math.abs(now - Date.parse(ts)) > REQ_SIG_SKEW_MS) return { ok: false, error: "signature timestamp too old" };
88
+ if (!verifyHex(sig, requestSigningBytes(method, path, ts, body), pub)) return { ok: false, error: "bad signature" };
89
+ return { ok: true, pub };
90
+ }
@@ -0,0 +1,35 @@
1
+ // v0.10.0 (security G-3, P2): release signing.
2
+ //
3
+ // The relay serves the install tarball; the maintainer SIGNS it. These are different people in the threat model
4
+ // (or the same person on different days), so the key that vouches for a release must not live on the relay.
5
+ // The private half stays offline on the maintainer's machine (scripts/release-key.mjs); this file carries the
6
+ // public half, compiled into every client. A client trusts a release only if manifest.sig verifies against one
7
+ // of these keys. Rotation: add the new key here, ship, drop the old one in the following release.
8
+ import { canon } from "./canon.js";
9
+ import { verifyHex } from "./crypto.js";
10
+
11
+ export const RELEASE_PUBS: string[] = [
12
+ "7a025a45418ea95d0aaf6738749cf72d40207965610d1a31cb3db6233f50d571", // 2026-09-05, the maintainer's home machine
13
+ ];
14
+
15
+ export interface ReleaseManifest {
16
+ v: number;
17
+ version: string;
18
+ date: string;
19
+ /** file name → sha256 hex; every alias the relay serves maps to the same hash */
20
+ files: Record<string, string>;
21
+ changelogSha256?: string;
22
+ permissionChange?: boolean;
23
+ dataFlowChange?: boolean;
24
+ minClient?: string;
25
+ }
26
+
27
+ /** Signature over canon(manifest) — the same canonical JSON every envelope uses; no new format. */
28
+ export function verifyManifest(m: unknown, sigHex: string, pubs: string[]): { ok: true; pub: string } | { ok: false; reason: string } {
29
+ const x = m as Partial<ReleaseManifest> | null;
30
+ if (!x || typeof x !== "object" || x.v !== 1 || typeof x.version !== "string" || !x.files || typeof x.files !== "object") return { ok: false, reason: "release manifest is malformed" };
31
+ if (!/^[0-9a-f]{128}$/.test(sigHex)) return { ok: false, reason: "release manifest signature is malformed" };
32
+ const msg = canon(x);
33
+ for (const pub of pubs) if (verifyHex(sigHex, msg, pub)) return { ok: true, pub };
34
+ return { ok: false, reason: `release manifest signature does not verify against any release key this client trusts (${pubs.map((p) => p.slice(0, 8) + "…").join(", ")}) — release key not trusted by this client, or the manifest was altered` };
35
+ }
@@ -0,0 +1,115 @@
1
+ /** Shared room shapes and the invite encodings. */
2
+ export interface RoomPolicy {
3
+ maxMessages: number; // hard cap per room; hitting it forces a close
4
+ ttlSec: number; // relay refuses appends after createdAt + ttl
5
+ }
6
+ export const DEFAULT_POLICY: RoomPolicy = { maxMessages: 200, ttlSec: 6 * 3600 };
7
+
8
+ export interface Participant { name: string; joinedAt: string; removed?: string /* ISO, set by eject */ }
9
+
10
+ export interface RoomInfo {
11
+ id: string;
12
+ name: string;
13
+ policy: RoomPolicy;
14
+ participants: Record<string, Participant>; // pubkey -> participant (ejected ones stay listed, with `removed`)
15
+ createdAt: string;
16
+ createdBy: string;
17
+ state: "open" | "closed";
18
+ lastSeq: number;
19
+ lastHash: string;
20
+ relayPub?: string; // the relay's signing pubkey (v0.3+); clients pin it per room
21
+ secret?: string; // the CURRENT invite secret — only returned to a caller authenticated by their own cap
22
+ e2e?: boolean; // v0.5.0: bodies are ciphertext; the key lives in the invite fragment, never here
23
+ // Portable rooms / mirrors (v0.4.15+):
24
+ pastRelayPubs?: string[]; // relay keys this room lived under before it was imported here
25
+ role?: "mirror"; // absent = primary (writable); "mirror" = read-only replica fed by /replicate
26
+ origin?: string; // mirror only: the primary relay this replica follows
27
+ mirrors?: string[]; // primary only: relays every append is replicated to
28
+ }
29
+
30
+ /** What GET /rooms/:id/export returns and POST /rooms/:id/import accepts. The chain is
31
+ * re-verified on import — an export is claimed evidence, never trusted evidence. */
32
+ export interface RoomExport {
33
+ format: "parley-export-1";
34
+ exportedAt: string;
35
+ room: {
36
+ id: string; name: string; policy: RoomPolicy; participants: Record<string, Participant>;
37
+ createdAt: string; createdBy: string; state: "open" | "closed"; e2e?: boolean;
38
+ };
39
+ secret?: string; // only present for a cap-authenticated exporter (same rule as info)
40
+ messages: unknown[]; // Envelope[]; typed loosely so the importer must verify, not assume
41
+ relayPub?: string; // the exporting relay's signing key — system events verify against it
42
+ head?: unknown; // the exporting relay's signed head over the tail
43
+ }
44
+
45
+ /**
46
+ * Invite = everything the other side needs: relay (u), room id (r), room secret (s), name (n).
47
+ *
48
+ * Two encodings of the same thing:
49
+ * URL https://<relay>/j/<room>?n=<name>#<secret> ← the one to hand to humans
50
+ * token parley1.<base64url json> ← compact, for logs / agent-only paths
51
+ *
52
+ * The URL is the primary form: a human can click it (the relay serves a landing page
53
+ * that explains what to paste to their agent), a phone can scan it as a QR code, and
54
+ * an agent can paste it straight into can2cup_join. The secret rides in the fragment,
55
+ * so it never appears in the relay's request logs. Whoever holds either form can read
56
+ * and post in the room — treat an invite like a Telegram join link, not like a URL.
57
+ */
58
+ /** k (v0.5.0): the E2E room key. It rides in the URL fragment after the secret
59
+ * (`#<secret>.<key>`), so like the secret it never reaches any server. */
60
+ export interface Invite { u: string; r: string; s: string; n?: string; p?: string /* relay signing pubkey, vouched by the inviter */; k?: string }
61
+
62
+ const PREFIX = "parley1.";
63
+ const TOKEN_RE = /parley1.[A-Za-z0-9_-]{16,}/;
64
+ const URL_RE = /https?:\/\/[^\s"'<>]+\/j\/[0-9a-f]{12}[^\s"'<>]*/;
65
+
66
+ export function encodeInvite(i: Invite): string {
67
+ return PREFIX + b64url(JSON.stringify(i));
68
+ }
69
+
70
+ export function encodeInviteUrl(i: Invite): string {
71
+ const base = i.u.replace(/\/+$/, "");
72
+ const qs = new URLSearchParams();
73
+ if (i.n) qs.set("n", i.n);
74
+ if (i.p) qs.set("p", i.p);
75
+ const qstr = qs.toString();
76
+ const q = qstr ? `?${qstr}` : "";
77
+ return `${base}/j/${i.r}${q}#${i.s}${i.k ? "." + i.k : ""}`;
78
+ }
79
+
80
+ /** Accepts a token, a URL, or any text that contains one of them (agents paste whole chat lines). */
81
+ export function decodeInvite(s: string): Invite {
82
+ const t = s.trim();
83
+ const url = URL_RE.exec(t)?.[0];
84
+ if (url) return decodeInviteUrl(url);
85
+ const tok = TOKEN_RE.exec(t)?.[0] ?? (t.startsWith(PREFIX) ? t : undefined);
86
+ if (!tok) throw new Error("not a can2cup invite (expected a https://…/j/<room>#<secret> link or a parley1.… token)");
87
+ const i = JSON.parse(unb64url(tok.slice(PREFIX.length))) as Invite;
88
+ if (!i.u || !i.r || !i.s) throw new Error("malformed invite");
89
+ return i;
90
+ }
91
+
92
+ export function decodeInviteUrl(url: string): Invite {
93
+ const u = new URL(url);
94
+ const m = /^(.*)\/j\/([0-9a-f]{12})$/.exec(u.pathname);
95
+ if (!m) throw new Error("malformed invite link (expected /j/<room>)");
96
+ const [s, k] = u.hash.replace(/^#/, "").split(".");
97
+ if (!/^[0-9a-f]{16,}$/.test(s)) throw new Error("invite link is missing its secret (the part after #) — copy the whole link");
98
+ const n = u.searchParams.get("n") ?? undefined;
99
+ const p = u.searchParams.get("p") ?? undefined;
100
+ return { u: u.origin + m[1], r: m[2], s, ...(n ? { n } : {}), ...(p && /^[0-9a-f]{64}$/.test(p) ? { p } : {}), ...(k && /^[0-9a-f]{64}$/.test(k) ? { k } : {}) };
101
+ }
102
+
103
+ function b64url(s: string): string {
104
+ const bytes = new TextEncoder().encode(s);
105
+ let bin = "";
106
+ for (const b of bytes) bin += String.fromCharCode(b);
107
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
108
+ }
109
+ function unb64url(s: string): string {
110
+ const b = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
111
+ const bin = atob(b);
112
+ const bytes = new Uint8Array(bin.length);
113
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
114
+ return new TextDecoder().decode(bytes);
115
+ }
@@ -0,0 +1,14 @@
1
+ /** v0.9.0 upgrade protocol: the only comparison both relay and client need. "1.2.3" style; unknown → 0.0.0. */
2
+ export function parseSemver(v: string | null | undefined): [number, number, number] {
3
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec((v ?? "").trim());
4
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0];
5
+ }
6
+
7
+ /** negative when a < b, 0 when equal, positive when a > b. */
8
+ export function cmpSemver(a: string | null | undefined, b: string | null | undefined): number {
9
+ const x = parseSemver(a), y = parseSemver(b);
10
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
11
+ return 0;
12
+ }
13
+
14
+ export const NO_VERSION = "0.0.0";
@@ -0,0 +1,212 @@
1
+ /**
2
+ * A2A (Agent2Agent, Linux Foundation) conformance layer — v1.0.0 of the spec.
3
+ *
4
+ * Why this exists: A2A assumes an agent is an HTTP server at a domain. A can2cup
5
+ * agent is a Claude Code process on somebody's laptop — no domain, no inbound
6
+ * port, not always awake. The relay already stands in for that laptop, so it is
7
+ * also the natural place to host the agent's A2A surface.
8
+ *
9
+ * What is conformant here and what deliberately is not:
10
+ * - The Agent Card, the JSON-RPC envelope, the error codes and the security
11
+ * scheme follow the spec exactly. Do not invent fields.
12
+ * - can2cup's own semantics (the principal's outbound mandate, and the ed25519
13
+ * per-message signature over a hash-chained transcript) have no home in the
14
+ * spec — A2A does per-agent-card JWS but explicitly no per-message signing,
15
+ * and expresses no principal->agent authority at all. They are therefore
16
+ * declared as A2A extensions, which is the sanctioned way to add meaning
17
+ * without forking. Both are `required: false`, so an agent that has never
18
+ * heard of can2cup can still talk to us.
19
+ * - The room stays can2cup's model. A2A's Task is a delegated unit of work with
20
+ * a terminal state; a room is an ongoing multi-party ordered log. We map
21
+ * `contextId` to the room id at the boundary and keep RoomDO unchanged.
22
+ *
23
+ * INGEST IS CLOSED BY DEFAULT. An inbound A2A message cannot be signed by the
24
+ * sender's can2cup key (the relay holds no private key, and an external agent has
25
+ * no can2cup identity), so admitting one means writing a relay-attested envelope
26
+ * into a transcript whose whole value is that every entry is participant-signed.
27
+ * That is the same trust downgrade as the LINE `/a` path, which is why it is
28
+ * marked UNVERIFIED there. Until a room opts in, message/send answers with the
29
+ * spec's UnsupportedOperation — which is also exactly what a non-opted-in room
30
+ * will answer once ingest ships, so this endpoint does not lie about itself.
31
+ */
32
+ import { PROTOCOL_VERSION } from "../protocol/index.js";
33
+
34
+ export const A2A_PROTOCOL_VERSION = "1.0.0";
35
+ export const A2A_AGENT_VERSION = "0.6.0";
36
+
37
+ /** JSON-RPC 2.0 plus the A2A-specific range. */
38
+ export const RPC = {
39
+ parseError: -32700,
40
+ invalidRequest: -32600,
41
+ methodNotFound: -32601,
42
+ invalidParams: -32602,
43
+ internalError: -32603,
44
+ taskNotFound: -32001,
45
+ taskNotCancelable: -32002,
46
+ pushNotificationNotSupported: -32003,
47
+ unsupportedOperation: -32004,
48
+ contentTypeNotSupported: -32005,
49
+ } as const;
50
+
51
+ /** Extension identifiers are namespaced under the relay's own origin, so they
52
+ * are dereferenceable by whoever is actually running this deployment. */
53
+ export const extUri = (origin: string, name: string): string => `${origin}/ext/${name}/v1`;
54
+
55
+ export interface CardOptions {
56
+ origin: string;
57
+ relayPub?: string;
58
+ }
59
+
60
+ export function agentCard(o: CardOptions): unknown {
61
+ return {
62
+ protocolVersion: A2A_PROTOCOL_VERSION,
63
+ name: "can2cup relay",
64
+ description:
65
+ "Hosts the A2A surface for personal agents that have no server of their own. " +
66
+ "Each can2cup room is an ordered, hash-chained transcript between agents that each answer " +
67
+ "to a different human principal; this gateway lets an A2A caller reach one.",
68
+ version: A2A_AGENT_VERSION,
69
+ url: `${o.origin}/a2a`,
70
+ preferredTransport: "JSONRPC",
71
+ provider: { organization: "can2cup", url: o.origin },
72
+ documentationUrl: `${o.origin}/`,
73
+
74
+ defaultInputModes: ["text/plain", "application/json"],
75
+ defaultOutputModes: ["text/plain", "application/json"],
76
+
77
+ capabilities: {
78
+ // Truthful, not aspirational. Flip these in the same commit that implements them.
79
+ streaming: false,
80
+ pushNotifications: false,
81
+ stateTransitionHistory: false,
82
+ extensions: [
83
+ {
84
+ uri: extUri(o.origin, "principal-mandate"),
85
+ description:
86
+ "Outbound messages are gated on the sending agent's machine by a mandate its human principal " +
87
+ "wrote: disclosure limits, a commitment ceiling, and what authority it may delegate. A blocked " +
88
+ "message is never sent, and the agent reports task state input-required rather than failed. " +
89
+ "A2A expresses no principal-to-agent authority, so this is carried out of band and declared here.",
90
+ required: false,
91
+ },
92
+ {
93
+ uri: extUri(o.origin, "signed-transcript"),
94
+ description:
95
+ "Every participant message carries an ed25519 signature over a canonical envelope whose prev field " +
96
+ "is the hash of the previous entry, so the room is a hash chain. The relay additionally signs a " +
97
+ "transcript head (seq, hash, time) on every read, which lets a client that pinned the relay key " +
98
+ "prove a fork or a truncated tail after the fact. A2A signs the agent card (JWS) but specifies " +
99
+ "no per-message signing; callers that do not implement this extension are stored relay-attested " +
100
+ "and shown to participants as UNVERIFIED.",
101
+ required: false,
102
+ },
103
+ ],
104
+ },
105
+
106
+ securitySchemes: {
107
+ participantCap: {
108
+ type: "http",
109
+ scheme: "bearer",
110
+ description:
111
+ "A participant capability for one room, issued by POST /rooms/:id/join to the key that signed the " +
112
+ "join. The invite secret is a join-and-read key and is not accepted here.",
113
+ },
114
+ },
115
+ security: [{ participantCap: [] }],
116
+
117
+ skills: [
118
+ {
119
+ id: "room-message",
120
+ name: "Send a message into a can2cup room",
121
+ description:
122
+ "Deliver a message to the participants of one room. contextId must be the room's 12-hex id and the " +
123
+ "bearer token must be a participant capability for that room. Ingest is opt-in per room: a room that " +
124
+ "has not enabled it answers -32004 UnsupportedOperation.",
125
+ tags: ["messaging", "relay", "room", "agent-to-agent"],
126
+ examples: ["Send a review request into room 77ff7ca1be69"],
127
+ inputModes: ["text/plain"],
128
+ outputModes: ["text/plain"],
129
+ },
130
+ {
131
+ id: "room-transcript",
132
+ name: "Read a room transcript",
133
+ description:
134
+ "Return a room's ordered, hash-chained transcript from a given sequence number, together with the " +
135
+ "relay-signed head. Available today over the native REST surface at GET /rooms/:id/messages.",
136
+ tags: ["history", "audit", "hash-chain"],
137
+ inputModes: ["application/json"],
138
+ outputModes: ["application/json"],
139
+ },
140
+ ],
141
+
142
+ // Informational: how to reach the native surface this gateway fronts.
143
+ additionalInterfaces: [{ transport: "HTTP+JSON", url: `${o.origin}/rooms` }],
144
+ };
145
+ }
146
+
147
+ interface RpcReq { jsonrpc?: string; id?: unknown; method?: unknown; params?: unknown }
148
+
149
+ const err = (id: unknown, code: number, message: string, data?: unknown): Response =>
150
+ Response.json({ jsonrpc: "2.0", id: id ?? null, error: { code, message, ...(data ? { data } : {}) } });
151
+
152
+ /**
153
+ * JSON-RPC entry point. Every method the card claims is routed here; the ones we
154
+ * have not built answer with the spec's own codes rather than a 404, so a caller
155
+ * gets a machine-readable reason instead of a broken endpoint.
156
+ */
157
+ export async function handleA2A(req: Request, origin: string): Promise<Response> {
158
+ if (req.method !== "POST") return err(null, RPC.invalidRequest, "A2A JSON-RPC requires POST");
159
+
160
+ let body: RpcReq;
161
+ try {
162
+ body = (await req.json()) as RpcReq;
163
+ } catch {
164
+ return err(null, RPC.parseError, "invalid JSON");
165
+ }
166
+
167
+ const id = body.id ?? null;
168
+ if (body.jsonrpc !== "2.0" || typeof body.method !== "string") {
169
+ return err(id, RPC.invalidRequest, 'jsonrpc must be "2.0" and method must be a string');
170
+ }
171
+
172
+ const auth = req.headers.get("authorization") ?? "";
173
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
174
+
175
+ switch (body.method) {
176
+ case "message/send":
177
+ case "message/stream": {
178
+ if (!token) {
179
+ return err(id, RPC.invalidRequest, "a participant capability is required (Authorization: Bearer <cap>)");
180
+ }
181
+ const p = body.params as { message?: { contextId?: string } } | undefined;
182
+ const room = p?.message?.contextId ?? "";
183
+ if (!/^[0-9a-f]{12}$/.test(room)) {
184
+ return err(id, RPC.invalidParams, "message.contextId must be a 12-hex can2cup room id");
185
+ }
186
+ return err(id, RPC.unsupportedOperation, `room ${room} has not enabled A2A ingest`, {
187
+ reason: "ingest-not-enabled",
188
+ detail:
189
+ "A can2cup transcript is participant-signed end to end. An inbound A2A message cannot carry that " +
190
+ "signature, so admitting it writes a relay-attested entry that participants see as UNVERIFIED. " +
191
+ "The room's creator must opt in before this is allowed.",
192
+ extension: extUri(origin, "signed-transcript"),
193
+ });
194
+ }
195
+
196
+ case "tasks/get":
197
+ return err(id, RPC.taskNotFound, "no tasks exist: this gateway does not yet create A2A tasks");
198
+
199
+ case "tasks/pushNotificationConfig/set":
200
+ case "tasks/pushNotificationConfig/get":
201
+ return err(id, RPC.pushNotificationNotSupported, "push notifications are not implemented on this gateway");
202
+
203
+ case "agent/getAuthenticatedExtendedCard":
204
+ return Response.json({ jsonrpc: "2.0", id, result: agentCard({ origin }) });
205
+
206
+ default:
207
+ return err(id, RPC.methodNotFound, `unknown method: ${body.method}`);
208
+ }
209
+ }
210
+
211
+ /** Compile-time tie to the can2cup protocol version this gateway fronts. */
212
+ export const PARLEY_PROTOCOL = PROTOCOL_VERSION;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * RFC 3161 trusted timestamps over the transcript head.
3
+ *
4
+ * The problem this closes: the relay signs the head, so a client that pinned the
5
+ * relay key can prove a fork after the fact — but only against a relay that keeps
6
+ * its key honest. The operator holds that key. For a transcript meant to be cited
7
+ * later, "trust the operator's clock" is exactly the objection the reader will
8
+ * raise. A Time Stamping Authority is a neutral third party that signs
9
+ * "this digest existed at this time", and it is the primitive eIDAS/PAdES/CAdES
10
+ * already build on, so the attestation is one a lawyer recognises.
11
+ *
12
+ * Scope, deliberately: we BUILD the TimeStampReq and STORE the TimeStampResp as
13
+ * an opaque DER blob. We do not verify the CMS SignedData or walk the TSA's X.509
14
+ * chain here — doing that properly needs a real PKI stack, and a half-verified
15
+ * token is worse than an unverified one. Verification belongs where the tooling
16
+ * exists: `openssl ts -verify`. The relay only checks PKIStatus so it never
17
+ * stores a rejection as if it were evidence.
18
+ *
19
+ * Disabled unless TSA_URL is set. An unset relay answers "not configured" rather
20
+ * than silently skipping, so nobody believes they have an anchor they do not have.
21
+ */
22
+
23
+ // ------------------------------------------------------------------ DER ---
24
+
25
+ const tag = (t: number, body: Uint8Array): Uint8Array => {
26
+ const len = body.length;
27
+ let header: number[];
28
+ if (len < 0x80) header = [t, len];
29
+ else {
30
+ const bytes: number[] = [];
31
+ for (let n = len; n > 0; n = Math.floor(n / 256)) bytes.unshift(n % 256);
32
+ header = [t, 0x80 | bytes.length, ...bytes];
33
+ }
34
+ const out = new Uint8Array(header.length + len);
35
+ out.set(header, 0);
36
+ out.set(body, header.length);
37
+ return out;
38
+ };
39
+
40
+ const concat = (...parts: Uint8Array[]): Uint8Array => {
41
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
42
+ let at = 0;
43
+ for (const p of parts) { out.set(p, at); at += p.length; }
44
+ return out;
45
+ };
46
+
47
+ const SEQUENCE = 0x30, INTEGER = 0x02, OCTET_STRING = 0x04, NULL = 0x05, BOOLEAN = 0x01, OID = 0x06;
48
+
49
+ /** 2.16.840.1.101.3.4.2.1 — id-sha256 */
50
+ const OID_SHA256 = new Uint8Array([0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]);
51
+
52
+ export function hexToBytes(hex: string): Uint8Array {
53
+ if (!/^[0-9a-f]*$/i.test(hex) || hex.length % 2) throw new Error("not hex");
54
+ const out = new Uint8Array(hex.length / 2);
55
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
56
+ return out;
57
+ }
58
+
59
+ export const bytesToB64 = (b: Uint8Array): string => {
60
+ let s = "";
61
+ for (const x of b) s += String.fromCharCode(x);
62
+ return btoa(s);
63
+ };
64
+
65
+ /**
66
+ * TimeStampReq ::= SEQUENCE {
67
+ * version INTEGER {v1(1)}, messageImprint MessageImprint,
68
+ * reqPolicy TSAPolicyId OPTIONAL, nonce INTEGER OPTIONAL,
69
+ * certReq BOOLEAN DEFAULT FALSE, extensions [0] IMPLICIT Extensions OPTIONAL }
70
+ *
71
+ * `digest` is the sha256 the TSA will attest to — for us, the chain head hash,
72
+ * which is already a sha256 over the canonical envelope.
73
+ */
74
+ export function buildTimeStampReq(digest: Uint8Array, nonce: Uint8Array): Uint8Array {
75
+ if (digest.length !== 32) throw new Error("sha256 digest must be 32 bytes");
76
+ const algId = tag(SEQUENCE, concat(tag(OID, OID_SHA256), tag(NULL, new Uint8Array(0))));
77
+ const messageImprint = tag(SEQUENCE, concat(algId, tag(OCTET_STRING, digest)));
78
+ // DER INTEGER is signed: a leading bit of 1 would read as negative.
79
+ const n = nonce[0] & 0x80 ? concat(new Uint8Array([0]), nonce) : nonce;
80
+ return tag(SEQUENCE, concat(
81
+ tag(INTEGER, new Uint8Array([1])),
82
+ messageImprint,
83
+ tag(INTEGER, n),
84
+ tag(BOOLEAN, new Uint8Array([0xff])), // certReq: ask the TSA to include its cert
85
+ ));
86
+ }
87
+
88
+ /**
89
+ * Read PKIStatus out of a TimeStampResp. Structure:
90
+ * TimeStampResp ::= SEQUENCE { status PKIStatusInfo, timeStampToken TST OPTIONAL }
91
+ * PKIStatusInfo ::= SEQUENCE { status INTEGER, ... }
92
+ * 0 = granted, 1 = grantedWithMods; anything else is a rejection.
93
+ */
94
+ export function readPkiStatus(resp: Uint8Array): number | undefined {
95
+ let i = 0;
96
+ const readLen = (): number => {
97
+ let len = resp[i++];
98
+ if (len & 0x80) {
99
+ const n = len & 0x7f;
100
+ len = 0;
101
+ for (let k = 0; k < n; k++) len = len * 256 + resp[i++];
102
+ }
103
+ return len;
104
+ };
105
+ if (resp[i++] !== SEQUENCE) return undefined; // TimeStampResp
106
+ readLen();
107
+ if (resp[i++] !== SEQUENCE) return undefined; // PKIStatusInfo
108
+ readLen();
109
+ if (resp[i++] !== INTEGER) return undefined;
110
+ const len = readLen();
111
+ let v = 0;
112
+ for (let k = 0; k < len; k++) v = v * 256 + resp[i++];
113
+ return v;
114
+ }
115
+
116
+ // ------------------------------------------------------------- request ---
117
+
118
+ export interface Anchor {
119
+ seq: number;
120
+ hash: string; // the transcript head this attests to
121
+ requestedAt: string; // relay clock — informational only; the TSA's clock is inside the token
122
+ tsa: string;
123
+ status: number; // PKIStatus: 0 granted, 1 grantedWithMods
124
+ token: string; // base64 DER TimeStampResp — verify with `openssl ts -verify`
125
+ }
126
+
127
+ export class AnchorError extends Error {}
128
+
129
+ /** POST an RFC 3161 query and return the stored anchor. Throws AnchorError on any
130
+ * outcome that is not a granted token, so a failure is never stored as evidence. */
131
+ export async function requestTimestamp(
132
+ tsaUrl: string, seq: number, headHash: string, nonceHex: string,
133
+ ): Promise<Anchor> {
134
+ const req = buildTimeStampReq(hexToBytes(headHash), hexToBytes(nonceHex));
135
+ let res: Response;
136
+ try {
137
+ res = await fetch(tsaUrl, {
138
+ method: "POST",
139
+ headers: { "content-type": "application/timestamp-query" },
140
+ // workers-types' BodyInit predates TS's generic Uint8Array<ArrayBufferLike>; the
141
+ // runtime accepts a typed array here. Narrow cast rather than copying the buffer.
142
+ body: req as unknown as BodyInit,
143
+ });
144
+ } catch (e) {
145
+ throw new AnchorError(`TSA unreachable: ${(e as Error).message}`);
146
+ }
147
+ if (!res.ok) throw new AnchorError(`TSA returned HTTP ${res.status}`);
148
+
149
+ const body = new Uint8Array(await res.arrayBuffer());
150
+ if (body.length === 0) throw new AnchorError("TSA returned an empty body");
151
+ const status = readPkiStatus(body);
152
+ if (status === undefined) throw new AnchorError("TSA response is not a TimeStampResp");
153
+ if (status !== 0 && status !== 1) throw new AnchorError(`TSA rejected the request (PKIStatus ${status})`);
154
+
155
+ return {
156
+ seq, hash: headHash, requestedAt: new Date().toISOString(),
157
+ tsa: tsaUrl, status, token: bytesToB64(body),
158
+ };
159
+ }