a2a-exposed 0.4.1 → 0.6.0

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.
Files changed (47) hide show
  1. package/README.md +3 -4
  2. package/lib/a2a.mjs +6 -2
  3. package/lib/commands.mjs +62 -12
  4. package/lib/deploy.mjs +42 -19
  5. package/lib/main.mjs +32 -11
  6. package/lib/pair.mjs +47 -3
  7. package/lib/status.mjs +4 -4
  8. package/lib/tunnel.mjs +11 -11
  9. package/lib/update.mjs +1 -1
  10. package/package.json +1 -1
  11. package/worker/cloudflare.config.ts +51 -7
  12. package/worker/deploy.env.example +28 -1
  13. package/worker/migrations/0006_mcp.sql +43 -0
  14. package/worker/migrations/0007_cimd.sql +10 -0
  15. package/worker/migrations/0008_oidc_txns.sql +12 -0
  16. package/worker/package.json +6 -2
  17. package/worker/src/approval-oidc.ts +247 -0
  18. package/worker/src/backup.ts +186 -0
  19. package/worker/src/directory.ts +120 -0
  20. package/worker/src/export.ts +156 -0
  21. package/worker/src/hosted.ts +403 -0
  22. package/worker/src/index.ts +1327 -446
  23. package/worker/src/mcp.ts +336 -0
  24. package/worker/src/migrations.ts +22 -0
  25. package/worker/src/pairing.ts +18 -5
  26. package/worker/src/sql-raw.d.ts +5 -0
  27. package/worker/src/storage.ts +100 -0
  28. package/worker/src/tenancy.ts +450 -0
  29. package/worker/src/wake.ts +14 -2
  30. package/worker/test/alarms.test.ts +231 -0
  31. package/worker/test/approval-oidc.test.ts +331 -0
  32. package/worker/test/bridge-thread.mjs +40 -0
  33. package/worker/test/d1.ts +146 -2
  34. package/worker/test/directory.test.ts +90 -0
  35. package/worker/test/export.test.ts +544 -0
  36. package/worker/test/facade.test.ts +1 -1
  37. package/worker/test/harness-worker.ts +80 -0
  38. package/worker/test/hosted.test.ts +221 -0
  39. package/worker/test/mcp.test.ts +697 -0
  40. package/worker/test/migrations.test.ts +5 -5
  41. package/worker/test/pairing.test.ts +20 -7
  42. package/worker/test/register-hooks.mjs +4 -0
  43. package/worker/test/sql-raw-hook.mjs +22 -0
  44. package/worker/test/storage.test.ts +74 -0
  45. package/worker/test/tenancy.test.ts +217 -0
  46. package/worker/test/wake-retry.test.ts +74 -0
  47. package/worker/test/wake.test.ts +18 -0
@@ -57,7 +57,7 @@ test("migration files: NNNN_name.sql, unique numbers, no gaps, 0002_wake_budget
57
57
  test("fresh database: every migration applies in order; a second run applies nothing", () => {
58
58
  const db = new DatabaseSync(":memory:");
59
59
  assert.deepEqual(applyMigrations(db), [...FILES].sort(byNumber));
60
- for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners"]) assert.ok(tables(db).includes(t), t);
60
+ for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata", "oidc_txns"]) assert.ok(tables(db).includes(t), t);
61
61
  assert.ok(columns(db, "peers").includes("source"));
62
62
  assert.ok(columns(db, "device_requests").includes("replaces_label"));
63
63
  assert.ok(columns(db, "device_requests").includes("replaces_hash"));
@@ -67,15 +67,15 @@ test("fresh database: every migration applies in order; a second run applies not
67
67
  test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
68
68
  const db = existingDb([sql("0001_init.sql"), sql("0002_wake_budget.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0002_wake_budget.sql", "0003_device_pairing.sql"]);
69
69
  db.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s) VALUES ('h', 'WDJB4827', 'pending', 1, 2, 5)").run();
70
- assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql"]);
70
+ assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql"]);
71
71
  assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
72
72
  });
73
73
 
74
74
  test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
75
75
  const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
76
76
  db.prepare("INSERT INTO wake_budget (hour, count) VALUES (1, 7)").run();
77
- assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql"]);
78
- assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql"]);
77
+ assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql"]);
78
+ assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql"]);
79
79
  assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
80
80
  });
81
81
 
@@ -107,7 +107,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
107
107
  assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
108
108
  assert.equal(wakes.length, 0);
109
109
 
110
- assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql"]);
110
+ assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql"]);
111
111
  assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
112
112
  assert.deepEqual(applyMigrations(db), []);
113
113
 
@@ -108,7 +108,7 @@ test("agent card, RFC 8414 metadata and the 401 hint advertise the device flow;
108
108
  const meta = await s.call("GET", "/.well-known/oauth-authorization-server");
109
109
  assert.equal(meta.status, 200);
110
110
  assert.deepEqual([meta.data.issuer, meta.data.device_authorization_endpoint, meta.data.token_endpoint], [BASE, `${BASE}/oauth/device_authorization`, `${BASE}/oauth/token`]);
111
- assert.deepEqual(meta.data.grant_types_supported, [GRANT]);
111
+ assert.deepEqual(meta.data.grant_types_supported, [GRANT, "authorization_code", "refresh_token"], "device flow + the MCP connector's grants");
112
112
  assert.deepEqual(meta.data.token_endpoint_auth_methods_supported, ["none"]);
113
113
 
114
114
  for (const [method, v] of [["message/send", "0.3"], ["SendMessage", "1.0"]]) {
@@ -121,6 +121,7 @@ test("agent card, RFC 8414 metadata and the 401 hint advertise the device flow;
121
121
  assert.equal(r.data.error.data.pairing.device_authorization_endpoint, `${BASE}/oauth/device_authorization`);
122
122
  assert.equal(r.data.error.data.pairing.token_endpoint, `${BASE}/oauth/token`);
123
123
  assert.equal(r.data.error.data.pairing.grant_type, GRANT);
124
+ assert.equal(r.data.error.data.pairing.cli, `npx -y a2a-exposed@latest connect ${BASE}`, "npx, latest, no global install");
124
125
  }
125
126
  const bad = await s.call("POST", "/", { json: { jsonrpc: "2.0", id: "req-7", method: "SendMessage", params: {} }, headers: { authorization: "Bearer nope" } });
126
127
  assert.equal(bad.headers.get("www-authenticate"), `Bearer realm="a2a", resource_metadata="${BASE}/.well-known/oauth-protected-resource", error="invalid_token", error_description="the bearer token is not valid (revoked, rotated or mistyped)"`);
@@ -157,7 +158,16 @@ test("/.well-known/agent.json serves an A2A 0.3 card (url, preferredTransport, p
157
158
  });
158
159
 
159
160
  test("PAIRING_APPROVAL=off: no pairing scheme, endpoints 404, plain 401", async (t) => {
160
- const s = setup({ PAIRING_APPROVAL: "off" }); t.after(s.restore);
161
+ // with MCP on (the default) the authorization server stays up for the connector, without the device flow
162
+ const m = setup({ PAIRING_APPROVAL: "off" }); t.after(m.restore);
163
+ const meta = await m.call("GET", "/.well-known/oauth-authorization-server");
164
+ assert.equal(meta.status, 200);
165
+ assert.equal(meta.data.device_authorization_endpoint, undefined);
166
+ assert.deepEqual(meta.data.grant_types_supported, ["authorization_code", "refresh_token"]);
167
+ for (const [mm, p] of [["POST", "/oauth/device_authorization"], ["GET", "/device"]]) assert.equal((await m.call(mm, p, mm === "POST" ? { form: {} } : {})).status, 404, p);
168
+ assert.equal((await m.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: "x" } })).data.error, "unsupported_grant_type");
169
+ m.restore();
170
+ const s = setup({ PAIRING_APPROVAL: "off", MCP: "off" }); t.after(s.restore);
161
171
  const card = (await s.call("GET", "/.well-known/agent-card.json")).data;
162
172
  assert.deepEqual(Object.keys(card.securitySchemes), ["bearer"]);
163
173
  assert.deepEqual(card.securityRequirements, [{ schemes: { bearer: { list: [] } } }]);
@@ -194,7 +204,7 @@ test("human approval: pending, slow_down, page approve with the password, single
194
204
  assert.equal(s.wakes.length, 1);
195
205
  const w = s.wakes[0].body;
196
206
  assert.equal(w.kind, "pairing_request");
197
- assert.equal(w.hint, "npx a2a-exposed pair list");
207
+ assert.equal(w.hint, "npx -y a2a-exposed@latest pair list");
198
208
  assert.deepEqual([w.pairing.userCode, w.pairing.verificationUriComplete, w.pairing.approval, w.pairing.clientId], [d.user_code, d.verification_uri_complete, "human", "barry"]);
199
209
  assert.equal(w.pairing.agentCardUrl, "https://barry.example.org/.well-known/agent-card.json");
200
210
  assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
@@ -228,7 +238,7 @@ test("human approval: pending, slow_down, page approve with the password, single
228
238
  assert.equal(g.headers.get("x-frame-options"), "DENY");
229
239
  assert.match(g.headers.get("set-cookie")!, /^a2a_device_csrf=[A-Za-z0-9_-]{24}; Path=\/device; Secure; HttpOnly; SameSite=Strict/);
230
240
  assert.ok(!/<script|src=|href=/i.test(g.text), "no scripts or external assets");
231
- assert.match(g.text, /No approval password is set yet[\s\S]*npx a2a-exposed pair set-password/);
241
+ assert.match(g.text, /No approval password is set yet[\s\S]*npx -y a2a-exposed@latest pair set-password/);
232
242
  assert.ok(!g.text.includes('name="password"'));
233
243
  assert.equal((await s.decidePage(d.user_code, PASSWORD, "approve")).status, 409);
234
244
 
@@ -362,7 +372,7 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
362
372
  test("pairing wakes: summary for each preset; requester text only where previews are allowed", async () => {
363
373
  const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Barry", preview: "", kind: "pairing_request", publicUrl: BASE,
364
374
  pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn: 600 } };
365
- const sum = wakeSummary(ev, "npx a2a-exposed");
375
+ const sum = wakeSummary(ev, "npx -y a2a-exposed@latest");
366
376
  assert.match(sum, /^A2A pairing request: an agent calling itself "Barry" \(claimed card: "https:\/\/barry\.example\.org\/card"\) asks to connect to your inbox \(code WDJB-2345, expires in 10 minutes\)\./);
367
377
  assert.match(sum, /never approve on your own\. Show them the code WDJB-2345 and give them this link: https:\/\/agent\.example\.com\/device\?user_code=WDJB-2345/);
368
378
  const agentSum = wakeSummary({ ...ev, pairing: { ...ev.pairing!, approval: "agent" } }, "a2a");
@@ -581,8 +591,11 @@ test("password setup link: expiry, mismatch, too short, CSRF, a new link invalid
581
591
  for (let i = 0; i < 20; i++) await s.call("GET", "/device/setup?t=nope", { ip: "192.0.2.200" });
582
592
  const limited = await s.call("GET", new URL(l.url).pathname + new URL(l.url).search, { ip: "192.0.2.200" });
583
593
  assert.equal(limited.status, 429);
584
- // off mode: no link
594
+ // pairing off: the link still works while MCP is on (the connector consent needs the password), not with both off
585
595
  s.env.PAIRING_APPROVAL = "off";
596
+ assert.equal((await s.owner("POST", "/owner/pairing/password-link", {})).status, 200);
597
+ assert.equal((await s.call("GET", "/device/setup?t=x", { ip: "192.0.2.201" })).status, 404, "reachable (unknown setup token)");
598
+ s.env.MCP = "off";
586
599
  assert.equal((await s.owner("POST", "/owner/pairing/password-link", {})).status, 409);
587
600
  assert.equal((await s.call("GET", "/device/setup?t=x")).status, 404);
588
601
  });
@@ -621,7 +634,7 @@ test("GET /: a browser gets an HTML landing page (strict CSP, no scripts); curl/
621
634
  assert.match(h.text, /A2A \(Agent2Agent\)<\/a> endpoint/);
622
635
  assert.ok(h.text.includes(`href="/.well-known/agent-card.json"`) && h.text.includes(`${BASE}/.well-known/agent-card.json`));
623
636
  assert.ok(h.text.includes(`href="/device"`), "pairing link");
624
- assert.ok(h.text.includes(`npx a2a-exposed connect ${BASE}`));
637
+ assert.ok(h.text.includes(`npx -y a2a-exposed@latest connect ${BASE}`));
625
638
  assert.match(h.text, /1\.0, 0\.3/);
626
639
  // the style block carries the CSP nonce
627
640
  const nonce = /style-src 'nonce-([^']+)'/.exec(csp)![1];
@@ -0,0 +1,4 @@
1
+ // Lets `node --test` import `*.sql?raw` the way Vite does at build time. `--import` waits for this module.
2
+ import { register } from "node:module";
3
+
4
+ await register("./sql-raw-hook.mjs", import.meta.url);
@@ -0,0 +1,22 @@
1
+ // Resolve `../migrations/0001_init.sql?raw` to the file text as a default export.
2
+ export async function resolve(specifier, context, nextResolve) {
3
+ if (typeof specifier === "string" && specifier.includes(".sql?raw")) {
4
+ const resolved = await nextResolve(specifier.replace(/\?raw$/, ""), context);
5
+ const url = new URL(resolved.url);
6
+ url.searchParams.set("raw", "1");
7
+ return { url: url.href, shortCircuit: true };
8
+ }
9
+ return nextResolve(specifier, context);
10
+ }
11
+
12
+ export async function load(url, context, nextLoad) {
13
+ const u = new URL(url);
14
+ if (u.searchParams.get("raw") === "1" && u.pathname.endsWith(".sql")) {
15
+ const { readFileSync } = await import("node:fs");
16
+ const { fileURLToPath } = await import("node:url");
17
+ u.search = "";
18
+ const text = readFileSync(fileURLToPath(u), "utf8");
19
+ return { format: "module", source: `export default ${JSON.stringify(text)};\n`, shortCircuit: true };
20
+ }
21
+ return nextLoad(url, context);
22
+ }
@@ -0,0 +1,74 @@
1
+ // Adapter and migration runner over node:sqlite. The workerd backends are STORAGE_BACKEND=d1|do. Run: npm test
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { doSqlD1, migrateDo, type SqlStorageLike, type TxRunner } from "../src/storage.ts";
7
+ import { MIGRATIONS } from "../src/migrations.ts";
8
+
9
+ function asSql(db: DatabaseSync): SqlStorageLike {
10
+ return {
11
+ exec(query: string, ...bindings: unknown[]) {
12
+ const multi = query.includes(";") && !/^\s*select\b/i.test(query);
13
+ if (bindings.length === 0 && multi) {
14
+ db.exec(query);
15
+ return { toArray: () => [], raw: () => [], rowsRead: 0, rowsWritten: 0 };
16
+ }
17
+ const stmt = db.prepare(query);
18
+ if (/^\s*select\b/i.test(query)) {
19
+ const rows = stmt.all(...(bindings as [])) as Record<string, unknown>[];
20
+ return { toArray: () => rows, raw: () => rows.map((r) => Object.values(r)), rowsRead: rows.length, rowsWritten: 0 };
21
+ }
22
+ const info = stmt.run(...(bindings as []));
23
+ return { toArray: () => [], raw: () => [], rowsRead: 0, rowsWritten: Number(info.changes) };
24
+ },
25
+ };
26
+ }
27
+
28
+ function txOf(db: DatabaseSync): TxRunner {
29
+ return (fn) => {
30
+ db.exec("BEGIN");
31
+ try { const v = fn(); db.exec("COMMIT"); return v; }
32
+ catch (e) { db.exec("ROLLBACK"); throw e; }
33
+ };
34
+ }
35
+
36
+ test("bundled migrations match worker/migrations", () => {
37
+ const disk = fs.readdirSync(new URL("../migrations/", import.meta.url)).filter((n) => n.endsWith(".sql")).sort();
38
+ assert.deepEqual(MIGRATIONS.map((m) => m.name), disk);
39
+ for (const m of MIGRATIONS) {
40
+ const file = fs.readFileSync(new URL(`../migrations/${m.name}`, import.meta.url), "utf8");
41
+ assert.equal(m.sql, file, m.name);
42
+ }
43
+ });
44
+
45
+ test("migrateDo applies in numeric order and a later lower number still runs", () => {
46
+ const db = new DatabaseSync(":memory:");
47
+ const sql = asSql(db);
48
+ const tx = txOf(db);
49
+ const early = MIGRATIONS.filter((m) => m.name === "0001_init.sql" || m.name === "0003_device_pairing.sql");
50
+ assert.deepEqual(migrateDo(sql, tx, early), ["0001_init.sql", "0003_device_pairing.sql"]);
51
+ const rest = migrateDo(sql, tx, MIGRATIONS);
52
+ assert.equal(rest[0], "0002_wake_budget.sql");
53
+ assert.deepEqual(rest, MIGRATIONS.map((m) => m.name).filter((n) => n !== "0001_init.sql" && n !== "0003_device_pairing.sql"));
54
+ assert.deepEqual(migrateDo(sql, tx, MIGRATIONS), []);
55
+ const names = db.prepare("SELECT name FROM d1_migrations ORDER BY id").all().map((r) => (r as { name: string }).name);
56
+ assert.deepEqual(names, ["0001_init.sql", "0003_device_pairing.sql", ...rest]);
57
+ });
58
+
59
+ test("doSqlD1 matches D1 binding rules: booleans, undefined, missing columns, batch rollback", async () => {
60
+ const db = new DatabaseSync(":memory:");
61
+ const d = doSqlD1(asSql(db), txOf(db));
62
+ await d.exec("CREATE TABLE t (n INTEGER)");
63
+ await d.prepare("INSERT INTO t (n) VALUES (?)").bind(true).run();
64
+ assert.equal((await d.prepare("SELECT n FROM t").first<{ n: number }>())?.n, 1);
65
+ const wrote = await d.prepare("INSERT INTO t (n) VALUES (?)").bind(4).run();
66
+ assert.equal(wrote.meta.changes, 1);
67
+ await assert.rejects(() => d.prepare("SELECT n FROM t WHERE n = ?").bind(undefined).first(), /D1_TYPE_ERROR/);
68
+ await assert.rejects(() => d.prepare("SELECT n FROM t").first("missing"), /D1_COLUMN_NOTFOUND/);
69
+ await assert.rejects(() => d.batch([
70
+ d.prepare("INSERT INTO t (n) VALUES (9)"),
71
+ d.prepare("INSERT INTO missing (n) VALUES (1)"),
72
+ ]));
73
+ assert.equal((await d.prepare("SELECT COUNT(*) AS c FROM t WHERE n = 9").first<{ c: number }>())?.c, 0);
74
+ });
@@ -0,0 +1,217 @@
1
+ // Tenant context: gates default off, self-host owner auth and peer-token key stay compatible, hosted uses a hash
2
+ // and TENANT_SECRETS_KEY. Run: npm test
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import worker from "../src/index.ts";
6
+ import { sha256 } from "../src/a2a.ts";
7
+ import { openPeerToken, openPeerTokenWithKey, sealPeerToken, sealPeerTokenWithKey } from "../src/mcp.ts";
8
+ import { hostedTenantContext, namespaceForRegion, parseGates, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
9
+ import { d1 } from "./d1.ts";
10
+
11
+ const dummy = { prepare() { throw new Error("storage should not be touched"); } } as any;
12
+ const BASE = "https://agent.example.com";
13
+
14
+ function selfEnv(over: Record<string, unknown> = {}): WorkerBindings {
15
+ return { DB: dummy, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", ...over } as WorkerBindings;
16
+ }
17
+
18
+ test("gates: unset is single-tenant and every reserved gate is off", () => {
19
+ const g = parseGates({ DB: dummy } as WorkerBindings).gates;
20
+ assert.equal(g.tenancy, "single");
21
+ assert.equal(g.hostedStorage, false);
22
+ assert.equal(g.quotas, false);
23
+ assert.equal(g.usageSink, "");
24
+ assert.equal(g.wakeTargetPolicy, "");
25
+ assert.equal(g.signupUrl, "");
26
+ assert.equal(g.branding, "");
27
+ assert.equal(g.dataRegion, "");
28
+ assert.equal(g.tenantDomain, "");
29
+ assert.deepEqual(g.approvalOidc, { issuer: "", clientId: "", hasClientSecret: false, allowedSubjects: [], methods: ["password"], methodsSpecified: false, label: "" });
30
+ });
31
+
32
+ test("gates: only exact values turn on; typos and non-https URLs stay off", () => {
33
+ const g = parseGates({
34
+ DB: dummy, TENANCY: "Host", QUOTAS: "yes", USAGE_SINK: "log", WAKE_TARGET_POLICY: "https",
35
+ BRANDING: "a2a", DATA_REGION: "us", SIGNUP_URL: "http://example.com", APPROVAL_OIDC_ISSUER: "http://example.com",
36
+ APPROVAL_METHODS: "magic", TENANT_DOMAIN: ".Example.COM.",
37
+ } as WorkerBindings).gates;
38
+ assert.equal(g.tenancy, "single");
39
+ assert.equal(g.quotas, false);
40
+ assert.equal(g.usageSink, "");
41
+ assert.equal(g.wakeTargetPolicy, "");
42
+ assert.equal(g.branding, "");
43
+ assert.equal(g.dataRegion, "");
44
+ assert.equal(g.signupUrl, "");
45
+ assert.equal(g.approvalOidc.issuer, "");
46
+ assert.deepEqual(g.approvalOidc.methods, ["password"]);
47
+ assert.equal(g.approvalOidc.methodsSpecified, false);
48
+ assert.equal(g.tenantDomain, "example.com");
49
+
50
+ const on = parseGates({
51
+ DB: dummy, TENANCY: "host", TENANT_DO: {} as any, QUOTAS: "on", USAGE_SINK: "d1",
52
+ WAKE_TARGET_POLICY: "public-https", BRANDING: "hosted", DATA_REGION: "EU",
53
+ SIGNUP_URL: "https://example.com/start", APPROVAL_OIDC_ISSUER: "https://example.com",
54
+ APPROVAL_OIDC_CLIENT_ID: "client", APPROVAL_OIDC_CLIENT_SECRET: "secret",
55
+ APPROVAL_OIDC_ALLOWED_SUBJECTS: " acct_1 , acct_2 ", APPROVAL_METHODS: "oidc,password",
56
+ TENANT_DOMAIN: "example.com",
57
+ } as WorkerBindings).gates;
58
+ assert.equal(on.tenancy, "host");
59
+ assert.equal(on.hostedStorage, true);
60
+ assert.equal(on.quotas, true);
61
+ assert.equal(on.usageSink, "d1");
62
+ assert.equal(on.wakeTargetPolicy, "public-https");
63
+ assert.equal(on.branding, "hosted");
64
+ assert.equal(on.dataRegion, "eu");
65
+ assert.equal(on.signupUrl, "https://example.com/start");
66
+ assert.equal(on.approvalOidc.issuer, "https://example.com");
67
+ assert.equal(on.approvalOidc.hasClientSecret, true);
68
+ assert.deepEqual(on.approvalOidc.allowedSubjects, ["acct_1", "acct_2"]);
69
+ assert.deepEqual(on.approvalOidc.methods, ["oidc", "password"]);
70
+ assert.equal(on.approvalOidc.methodsSpecified, true);
71
+ assert.equal(on.approvalOidc.label, "");
72
+ });
73
+
74
+ test("TENANCY=host without TENANT_DO does not flip hostedStorage", () => {
75
+ assert.equal(parseGates({ DB: dummy, TENANCY: "host" } as WorkerBindings).gates.hostedStorage, false);
76
+ });
77
+
78
+ test("self-host peer key decrypts tokens sealed from OWNER_TOKEN, and ignores TENANT_SECRETS_KEY", async () => {
79
+ const sealed = await sealPeerToken("owner-secret", "alpha", "peer-token");
80
+ const ctx = resolveTenant(selfEnv({ TENANT_SECRETS_KEY: "platform-key-not-used", TENANCY: "host" }));
81
+ assert.equal(ctx.mode, "single");
82
+ assert.equal(ctx.id, "");
83
+ const key = await ctx.peerKey();
84
+ assert.ok(key);
85
+ assert.equal(await openPeerTokenWithKey(key!, "alpha", sealed), "peer-token");
86
+ assert.equal(await openPeerToken("owner-secret", "alpha", sealed), "peer-token");
87
+ assert.equal(await ctx.peerKey(), key, "the key is derived once per context");
88
+ });
89
+
90
+ test("self-host owner check is OWNER_TOKEN equality and rejects a missing token before accepting anyone", async () => {
91
+ const ctx = resolveTenant(selfEnv());
92
+ assert.equal(await ctx.isOwner("Bearer owner-secret"), true);
93
+ assert.equal(await ctx.isOwner("bearer owner-secret"), true);
94
+ assert.equal(await ctx.isOwner("Bearer owner-secret "), true);
95
+ assert.equal(await ctx.isOwner("Bearer other"), false);
96
+ assert.equal(await ctx.isOwner(null), false);
97
+ assert.equal(await ctx.isOwner("Basic owner-secret"), false);
98
+ const none = resolveTenant(selfEnv({ OWNER_TOKEN: undefined }));
99
+ assert.equal(await none.isOwner("Bearer owner-secret"), false);
100
+ assert.equal(await none.peerKey(), null);
101
+ });
102
+
103
+ test("hosted owner check uses the token hash and never the platform OWNER_TOKEN", async () => {
104
+ const token = "hosted-owner-token";
105
+ const hash = await sha256(token);
106
+ const ctx = hostedTenantContext(selfEnv({ TENANT_SECRETS_KEY: "platform-key" }), {
107
+ id: "ten_a", db: dummy, publicUrl: "https://a.example.com", ownerTokenHash: hash.toUpperCase(),
108
+ });
109
+ assert.equal(ctx.mode, "host");
110
+ assert.equal(await ctx.isOwner(`Bearer ${token}`), true);
111
+ assert.equal(await ctx.isOwner("Bearer owner-secret"), false, "platform OWNER_TOKEN does not authenticate");
112
+ assert.equal(await ctx.isOwner("Bearer wrong"), false);
113
+ const bad = hostedTenantContext(selfEnv(), {
114
+ id: "ten_a", db: dummy, publicUrl: "https://a.example.com", ownerTokenHash: "not-a-hash",
115
+ });
116
+ assert.equal(await bad.isOwner(`Bearer ${token}`), false);
117
+ const missing = hostedTenantContext(selfEnv(), { id: "ten_a", db: dummy, publicUrl: "https://a.example.com" });
118
+ assert.equal(await missing.isOwner(`Bearer ${token}`), false);
119
+ });
120
+
121
+ test("hosted peer key is HKDF(TENANT_SECRETS_KEY, tenant id) and does not open another tenant or a self-host seal", async () => {
122
+ const env = selfEnv({ TENANT_SECRETS_KEY: "platform-key" });
123
+ const a = hostedTenantContext(env, { id: "ten_a", db: dummy, publicUrl: "https://a.example.com", ownerTokenHash: "ab".repeat(32) });
124
+ const b = hostedTenantContext(env, { id: "ten_b", db: dummy, publicUrl: "https://b.example.com", ownerTokenHash: "ab".repeat(32) });
125
+ const keyA = await a.peerKey();
126
+ assert.ok(keyA);
127
+ const sealed = await sealPeerTokenWithKey(keyA!, "alpha", "peer-token");
128
+ assert.equal(await openPeerTokenWithKey(keyA!, "alpha", sealed), "peer-token");
129
+ assert.equal(await openPeerTokenWithKey((await b.peerKey())!, "alpha", sealed), null);
130
+ assert.equal(await openPeerToken("owner-secret", "alpha", sealed), null);
131
+ assert.equal(await openPeerToken("platform-key", "alpha", sealed), null);
132
+ const selfSealed = await sealPeerToken("owner-secret", "alpha", "peer-token");
133
+ assert.equal(await openPeerTokenWithKey(keyA!, "alpha", selfSealed), null);
134
+ const noKey = hostedTenantContext(selfEnv(), { id: "ten_a", db: dummy, publicUrl: "https://a.example.com" });
135
+ assert.equal(await noKey.peerKey(), null);
136
+ });
137
+
138
+ test("hosted config does not inherit Worker secrets; operational caps do inherit and can be overridden", () => {
139
+ const ctx = hostedTenantContext(selfEnv({
140
+ WAKE_WEBHOOK_URL: "https://hooks.example.net/wake", WAKE_WEBHOOK_KEY: "hook-key",
141
+ UPSTREAM_TOKEN: "upstream", RATE_PER_MIN: "60", AGENT_NAME: "Platform name",
142
+ }), {
143
+ id: "ten_a", db: dummy, publicUrl: "https://a.example.com",
144
+ config: { AGENT_NAME: "Tenant A", WAKE_WEBHOOK_URL: "https://hooks.example.net/a" },
145
+ });
146
+ assert.equal(ctx.AGENT_NAME, "Tenant A");
147
+ assert.equal(ctx.PUBLIC_URL, "https://a.example.com");
148
+ assert.equal(ctx.WAKE_WEBHOOK_URL, "https://hooks.example.net/a");
149
+ assert.equal(ctx.WAKE_WEBHOOK_KEY, undefined);
150
+ assert.equal(ctx.UPSTREAM_TOKEN, undefined);
151
+ assert.equal(ctx.RATE_PER_MIN, "60");
152
+ const capped = hostedTenantContext(selfEnv({ RATE_PER_MIN: "60" }), {
153
+ id: "ten_a", db: dummy, publicUrl: "https://a.example.com", config: { RATE_PER_MIN: "10" },
154
+ });
155
+ assert.equal(capped.RATE_PER_MIN, "10");
156
+ });
157
+
158
+ test("DATA_REGION selects a DO jurisdiction only for eu and fedramp", () => {
159
+ const calls: string[] = [];
160
+ const ns = { id: "default", jurisdiction(loc: string) { calls.push(loc); return { id: loc, jurisdiction: ns.jurisdiction }; } };
161
+ assert.equal(namespaceForRegion(ns, "").id, "default");
162
+ assert.equal(namespaceForRegion(ns, "eu").id, "eu");
163
+ assert.equal(namespaceForRegion(ns, "fedramp").id, "fedramp");
164
+ assert.equal(namespaceForRegion(ns, "us").id, "default");
165
+ assert.deepEqual(calls, ["eu", "fedramp"]);
166
+ assert.equal(namespaceForRegion({ id: "plain" }, "eu").id, "plain");
167
+ });
168
+
169
+ test("hosted approval comes from the tenant overlay and ignores Worker APPROVAL_OIDC_*", () => {
170
+ const platform = selfEnv({
171
+ APPROVAL_OIDC_ISSUER: "https://platform.example", APPROVAL_OIDC_CLIENT_ID: "platform",
172
+ APPROVAL_OIDC_CLIENT_SECRET: "platform-secret", APPROVAL_OIDC_ALLOWED_SUBJECTS: "platform-user",
173
+ APPROVAL_METHODS: "oidc",
174
+ });
175
+ const ctx = hostedTenantContext(platform, {
176
+ id: "ten_a", db: dummy, publicUrl: "https://a.example.com",
177
+ approval: { issuer: "https://idp.example", clientId: "tenant-client", clientSecret: "tenant-secret", allowedSubjects: ["owner-1"], label: "Example IdP" },
178
+ });
179
+ assert.equal(ctx.gates.approvalOidc.issuer, "https://idp.example");
180
+ assert.equal(ctx.gates.approvalOidc.clientId, "tenant-client");
181
+ assert.deepEqual(ctx.gates.approvalOidc.allowedSubjects, ["owner-1"]);
182
+ assert.equal(ctx.gates.approvalOidc.label, "Example IdP");
183
+ assert.equal(ctx.gates.approvalOidc.hasClientSecret, true);
184
+ assert.equal(ctx.approvalClientSecret(), "tenant-secret");
185
+ assert.equal(JSON.stringify(ctx.approval).includes("tenant-secret"), false);
186
+ assert.equal(ctx.gates.approvalOidc.methodsSpecified, false, "omitted methods stay unspecified so both password and OIDC are offered");
187
+ const empty = hostedTenantContext(platform, { id: "ten_a", db: dummy, publicUrl: "https://a.example.com" });
188
+ assert.equal(empty.gates.approvalOidc.issuer, "");
189
+ assert.equal(empty.approvalClientSecret(), "");
190
+ assert.equal(empty.gates.approvalOidc.hasClientSecret, false);
191
+ });
192
+
193
+ test("fetch path: gates set or unset, the self-host Worker still answers from OWNER_TOKEN and env.DB", async () => {
194
+ const DB = d1(new URL("../migrations/", import.meta.url));
195
+ const base = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox" };
196
+ const gates = {
197
+ TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", TENANT_DOMAIN: "example.com", DATA_REGION: "eu",
198
+ QUOTAS: "on", USAGE_SINK: "d1", WAKE_TARGET_POLICY: "public-https", SIGNUP_URL: "https://example.com",
199
+ BRANDING: "hosted", APPROVAL_OIDC_ISSUER: "https://example.com", APPROVAL_OIDC_CLIENT_ID: "client",
200
+ APPROVAL_OIDC_CLIENT_SECRET: "secret", APPROVAL_OIDC_ALLOWED_SUBJECTS: "acct_1", APPROVAL_METHODS: "oidc",
201
+ };
202
+ const call = async (env: Record<string, unknown>, path: string, headers: Record<string, string> = {}) => {
203
+ const res = await worker.fetch(new Request(BASE + path, { headers }) as any, env as any, { waitUntil() {}, passThroughOnException() {} } as any);
204
+ return { status: res.status, data: await res.json() as any };
205
+ };
206
+ const plain = await call(base, "/health");
207
+ const reserved = await call({ ...base, ...gates }, "/health");
208
+ const empty = await call({ ...base, TENANCY: "", QUOTAS: "", DATA_REGION: "", SIGNUP_URL: "" }, "/health");
209
+ assert.deepEqual(plain.data, { ok: true, publicUrl: BASE, mode: "inbox", mcp: true });
210
+ assert.deepEqual(reserved.data, plain.data);
211
+ assert.deepEqual(empty.data, plain.data);
212
+
213
+ const own = await call({ ...base, ...gates }, "/owner/inbox", { authorization: "Bearer owner-secret" });
214
+ assert.equal(own.status, 200, JSON.stringify(own.data));
215
+ const no = await call({ ...base, ...gates }, "/owner/inbox", { authorization: "Bearer platform-key" });
216
+ assert.equal(no.status, 401);
217
+ });
@@ -0,0 +1,74 @@
1
+ // A wake endpoint that answers 429 / 503 (e.g. a Claude Code routine over its fire limit): the wake is kept pending and
2
+ // re-sent after Retry-After, at most 3 times. Run: npm test
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import worker, { nextCronAt } from "../src/index.ts";
6
+ import { d1 } from "./d1.ts";
7
+ import { resolveTenant } from "../src/tenancy.ts";
8
+
9
+ const BASE = "https://agent.example.com";
10
+ const realNow = Date.now;
11
+ let offset = 0;
12
+ Date.now = () => realNow() + offset;
13
+
14
+ test("429 with Retry-After: the wake is re-sent after the wait, merged with newer tasks, and dropped after 3 retries", async (t) => {
15
+ const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "claude-code",
16
+ WAKE_WEBHOOK_URL: "https://api.anthropic.com/v1/claude_code/routines/trig_x/fire", WAKE_WEBHOOK_KEY: "rt", WAKE_DEBOUNCE_SECONDS: "20" };
17
+ const sent: any[] = [];
18
+ let answer = 429;
19
+ const orig = globalThis.fetch;
20
+ globalThis.fetch = (async (_u: any, init: any) => { sent.push(JSON.parse(init.body)); return new Response("{}", { status: answer, headers: { "retry-after": "120" } }); }) as any;
21
+ t.after(() => { globalThis.fetch = orig; Date.now = realNow; });
22
+ const call = async (method: string, path: string, json?: any, auth = "Bearer owner-secret") => {
23
+ const pending: Promise<unknown>[] = [];
24
+ const res = await worker.fetch(new Request(BASE + path, { method, headers: { "content-type": "application/json", authorization: auth }, body: json ? JSON.stringify(json) : undefined }) as any,
25
+ env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
26
+ // settle the request's background work (the 20 s debounce timer is never needed here)
27
+ await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, 200))]);
28
+ return res.json().catch(() => null);
29
+ };
30
+ const tok = (await call("POST", "/owner/peers", { label: "barry" })).token;
31
+ const msg = (text: string) => call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: crypto.randomUUID(), contextId: "ctx-r", parts: [{ kind: "text", text }] } } }, `Bearer ${tok}`);
32
+ await msg("one");
33
+ assert.equal(sent.length, 1);
34
+ const row = () => env.DB.db.prepare("SELECT * FROM wakes WHERE context_id = 'ctx-r'").get() as any;
35
+ assert.ok(row().pending_json, "kept pending after the 429");
36
+ offset += 60_000;
37
+ await call("GET", "/health");
38
+ assert.equal(sent.length, 1, "not before Retry-After");
39
+ offset += 61_000;
40
+ await call("GET", "/health");
41
+ assert.equal(sent.length, 2, "re-sent after Retry-After");
42
+ assert.equal(JSON.parse(row().pending_json).attempts, 2);
43
+ for (let i = 0; i < 3; i++) { offset += 121_000; await call("GET", "/health"); }
44
+ assert.equal(sent.length, 4, "3 retries, then dropped");
45
+ assert.equal(row().pending_json, null);
46
+ // a success clears it for good
47
+ answer = 200;
48
+ offset += 3_600_000;
49
+ await msg("two");
50
+ assert.equal(sent.length, 5);
51
+ assert.equal(row().pending_json, null);
52
+ });
53
+
54
+ test("a 429 is due at Retry-After when the debounce is longer", async (t) => {
55
+ const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
56
+ WAKE_WEBHOOK_URL: "https://example.com/hook", WAKE_WEBHOOK_KEY: "k", WAKE_DEBOUNCE_SECONDS: "120" };
57
+ const sent: any[] = [];
58
+ const orig = globalThis.fetch;
59
+ globalThis.fetch = (async () => { sent.push(1); return new Response("{}", { status: 429, headers: { "retry-after": "30" } }); }) as any;
60
+ t.after(() => { globalThis.fetch = orig; });
61
+ const pending: Promise<unknown>[] = [];
62
+ const ectx = { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} };
63
+ const call = async (method: string, path: string, json?: any, auth = "Bearer owner-secret") => {
64
+ const res = await worker.fetch(new Request(BASE + path, { method, headers: { "content-type": "application/json", authorization: auth }, body: json ? JSON.stringify(json) : undefined }) as any, env, ectx as any);
65
+ await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, 200))]);
66
+ return res.json().catch(() => null);
67
+ };
68
+ const tok = (await call("POST", "/owner/peers", { label: "barry" })).token;
69
+ const before = Date.now();
70
+ await call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m1", contextId: "ctx-long", parts: [{ kind: "text", text: "one" }] } } }, `Bearer ${tok}`);
71
+ assert.equal(sent.length, 1);
72
+ const at = await nextCronAt(resolveTenant(env), Date.now());
73
+ assert.ok(at != null && at >= before + 25_000 && at <= before + 35_000, `alarm ${at} is Retry-After, not the 120s debounce`);
74
+ });
@@ -30,6 +30,24 @@ test("claude-code: anthropic-version + text body", async () => {
30
30
  assert.deepEqual(Object.keys(b), ["text"]);
31
31
  assert.match(b.text, /2 new messages from peer "peer-a"/);
32
32
  assert.match(b.text, /untrusted/);
33
+ // a fresh routine session learns what it needs and what to do when setup is missing
34
+ assert.match(b.text, /A2A_BASE_URL and A2A_OWNER_TOKEN/);
35
+ assert.match(b.text, /a2a-exposed connector \(MCP tools/, "the connector comes first");
36
+ assert.ok(b.text.indexOf("connector") < b.text.indexOf("A2A_OWNER_TOKEN"), "the CLI is the fallback");
37
+ assert.match(b.text, /never ask for the token in chat/);
38
+ assert.match(b.text, /npx -y a2a-exposed@latest inbox/, "the default hint runs the CLI through npx");
39
+ assert.match(b.text, /run as `npx -y a2a-exposed@latest`, no global install/);
40
+ assert.ok(!/npm i(nstall)? -g/.test(b.text), "never asks for a global install");
41
+ assert.ok(!b.text.includes("Bearer rt") && !b.text.includes(" rt "), "no secret in the text");
42
+ });
43
+
44
+ test("claude-code: a human-approved pairing wake stays CLI-free (no cold-start note)", async () => {
45
+ const pe = { ...ev, kind: "pairing_request" as const, taskIds: [], pairing: { userCode: "WDJB-4827", verificationUriComplete: "https://agent.example.com/device?user_code=WDJB-4827",
46
+ approval: "human" as const, clientName: "Peer", clientId: "", agentCardUrl: "", expiresIn: 600 } };
47
+ const r = (await renderWake({ preset: "claude-code", url: "https://api.anthropic.com/v1/claude_code/routines/trig_x/fire", key: "rt" }, pe))!;
48
+ const b = JSON.parse(r.body);
49
+ assert.match(b.text, /WDJB-4827/);
50
+ assert.ok(!b.text.includes("A2A_OWNER_TOKEN"), "human pairing needs no CLI or owner token");
33
51
  });
34
52
 
35
53
  test("openclaw-wake: no peer text, mode now, agentId", async () => {