a2a-exposed 0.5.0 → 0.7.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 (46) hide show
  1. package/README.md +3 -4
  2. package/lib/a2a.mjs +6 -2
  3. package/lib/commands.mjs +24 -15
  4. package/lib/deploy.mjs +20 -17
  5. package/lib/main.mjs +24 -9
  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 +32 -1
  13. package/worker/migrations/0008_oidc_txns.sql +12 -0
  14. package/worker/package.json +6 -2
  15. package/worker/src/approval-oidc.ts +247 -0
  16. package/worker/src/backup.ts +186 -0
  17. package/worker/src/directory.ts +120 -0
  18. package/worker/src/export.ts +156 -0
  19. package/worker/src/hosted.ts +403 -0
  20. package/worker/src/index.ts +748 -584
  21. package/worker/src/mcp.ts +30 -12
  22. package/worker/src/migrations.ts +22 -0
  23. package/worker/src/pairing.ts +17 -4
  24. package/worker/src/sql-raw.d.ts +5 -0
  25. package/worker/src/storage.ts +100 -0
  26. package/worker/src/tenancy.ts +450 -0
  27. package/worker/src/wake-target.ts +202 -0
  28. package/worker/src/wake.ts +3 -2
  29. package/worker/test/alarms.test.ts +231 -0
  30. package/worker/test/approval-oidc.test.ts +331 -0
  31. package/worker/test/bridge-thread.mjs +40 -0
  32. package/worker/test/d1.ts +146 -2
  33. package/worker/test/directory.test.ts +90 -0
  34. package/worker/test/export.test.ts +544 -0
  35. package/worker/test/facade.test.ts +1 -1
  36. package/worker/test/harness-worker.ts +80 -0
  37. package/worker/test/hosted.test.ts +221 -0
  38. package/worker/test/migrations.test.ts +5 -5
  39. package/worker/test/pairing.test.ts +5 -4
  40. package/worker/test/register-hooks.mjs +4 -0
  41. package/worker/test/sql-raw-hook.mjs +22 -0
  42. package/worker/test/storage.test.ts +74 -0
  43. package/worker/test/tenancy.test.ts +217 -0
  44. package/worker/test/wake-retry.test.ts +24 -1
  45. package/worker/test/wake-target.test.ts +187 -0
  46. package/worker/test/wake.test.ts +3 -0
@@ -0,0 +1,221 @@
1
+ // Hosted router and TenantStore, in workerd via Miniflare. Self-host behaviour is the node suite (and the
2
+ // gates-unset case below). Run: npm test
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { createRequire } from "node:module";
8
+ import { fileURLToPath } from "node:url";
9
+ import { Miniflare, convertV4MiniflareOptions } from "miniflare";
10
+ import worker from "../src/index.ts";
11
+ import { sha256 } from "../src/a2a.ts";
12
+ import { d1 } from "./d1.ts";
13
+
14
+ const require = createRequire(new URL("../package.json", import.meta.url));
15
+ const MIGRATIONS = new URL("../migrations/", import.meta.url);
16
+ const ectx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} };
17
+
18
+ function bundleHosted(): Promise<string> {
19
+ const { build } = require("esbuild") as typeof import("esbuild");
20
+ return build({
21
+ entryPoints: [fileURLToPath(new URL("../src/hosted.ts", import.meta.url))],
22
+ bundle: true,
23
+ format: "esm",
24
+ write: false,
25
+ platform: "neutral",
26
+ external: ["cloudflare:*"],
27
+ target: "es2022",
28
+ logLevel: "silent",
29
+ plugins: [{
30
+ name: "sql-raw",
31
+ setup(b) {
32
+ b.onResolve({ filter: /\.sql\?raw$/ }, (args) => ({
33
+ path: path.resolve(args.resolveDir, args.path.replace(/\?raw$/, "")),
34
+ namespace: "sql-raw",
35
+ }));
36
+ b.onLoad({ filter: /.*/, namespace: "sql-raw" }, (args) => ({
37
+ contents: `export default ${JSON.stringify(fs.readFileSync(args.path, "utf8"))};`,
38
+ loader: "js",
39
+ }));
40
+ },
41
+ }],
42
+ }).then((built) => {
43
+ const file = built.outputFiles?.[0];
44
+ if (!file) throw new Error("hosted bundle was empty");
45
+ return file.text;
46
+ });
47
+ }
48
+
49
+ const SCRIPT = bundleHosted();
50
+
51
+ async function start(opts: Record<string, unknown>) {
52
+ const mf = new Miniflare(convertV4MiniflareOptions({
53
+ name: "a2a",
54
+ modules: true,
55
+ script: await SCRIPT,
56
+ compatibilityDate: "2026-10-06",
57
+ d1Databases: { DB: `db-${Math.random().toString(36).slice(2)}` },
58
+ ...opts,
59
+ } as never));
60
+ return mf;
61
+ }
62
+
63
+ function sqlStatements(sql: string): string[] {
64
+ return sql.split("\n").map((l) => l.replace(/--.*$/, "")).join("\n").split(";").map((s) => s.trim()).filter(Boolean);
65
+ }
66
+
67
+ async function migrateD1(mf: Miniflare) {
68
+ const db = await mf.getD1Database("DB");
69
+ for (const name of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith(".sql")).sort()) {
70
+ const stmts = sqlStatements(fs.readFileSync(new URL(name, MIGRATIONS), "utf8"));
71
+ if (stmts.length) await db.batch(stmts.map((q) => db.prepare(q)));
72
+ }
73
+ }
74
+
75
+ async function text(res: Response): Promise<string> {
76
+ return await res.text();
77
+ }
78
+
79
+ test("gates unset: the hosted entry matches the self-host handler, including TENANCY=host without TENANT_DO", async (t) => {
80
+ const base = "https://agent.example.com";
81
+ const nodeEnv = { DB: d1(MIGRATIONS), PUBLIC_URL: base, OWNER_TOKEN: "owner-secret" };
82
+ const nodeHealth = await text(await worker.fetch(new Request(base + "/health"), nodeEnv as never, ectx as never));
83
+ const nodeDenied = await text(await worker.fetch(new Request(base + "/", {
84
+ method: "POST", headers: { "content-type": "application/json" },
85
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
86
+ }), nodeEnv as never, ectx as never));
87
+
88
+ const plain = await start({ bindings: { PUBLIC_URL: base, OWNER_TOKEN: "owner-secret" }, durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } } });
89
+ t.after(() => plain.dispose());
90
+ await plain.ready;
91
+ await migrateD1(plain);
92
+ assert.equal(await text(await plain.dispatchFetch(base + "/health")), nodeHealth);
93
+ assert.equal(await text(await plain.dispatchFetch(base + "/", {
94
+ method: "POST", headers: { "content-type": "application/json" },
95
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
96
+ })), nodeDenied);
97
+ const issued = await (await plain.dispatchFetch(base + "/owner/peers", {
98
+ method: "POST", headers: { authorization: "Bearer owner-secret", "content-type": "application/json" },
99
+ body: JSON.stringify({ label: "ada" }),
100
+ })).json() as { token: string };
101
+ assert.match(issued.token, /^a2aow_/);
102
+ const peers = await (await plain.getD1Database("DB")).prepare("SELECT label FROM peers").all();
103
+ assert.deepEqual(peers.results?.map((r) => (r as { label: string }).label), ["ada"]);
104
+ assert.deepEqual(await plain.listDurableObjectIds("TENANT_DO"), [], "the flag is off, so the object is never addressed");
105
+
106
+ // TENANCY=host but no TENANT_DO binding: still the self-host path. The class is registered unbound so the
107
+ // script can load; the router only switches when the binding is present.
108
+ const hostOnly = await start({
109
+ bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret", PUBLIC_URL: base, OWNER_TOKEN: "owner-secret" },
110
+ additionalUnboundDurableObjects: [{ className: "TenantStore", useSQLite: true }],
111
+ });
112
+ t.after(() => hostOnly.dispose());
113
+ await hostOnly.ready;
114
+ await migrateD1(hostOnly);
115
+ assert.equal(await text(await hostOnly.dispatchFetch(base + "/health")), nodeHealth);
116
+ const again = await (await hostOnly.dispatchFetch(base + "/owner/peers", {
117
+ method: "POST", headers: { authorization: "Bearer owner-secret", "content-type": "application/json" },
118
+ body: JSON.stringify({ label: "ada" }),
119
+ })).json() as { token?: string; error?: string };
120
+ assert.equal(again.error, undefined);
121
+ assert.match(again.token ?? "", /^a2aow_/);
122
+ });
123
+
124
+ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
125
+ const mf = await start({
126
+ bindings: {
127
+ TENANCY: "host",
128
+ TENANT_DOMAIN: "example.com",
129
+ TENANT_SECRETS_KEY: "platform-secret",
130
+ OWNER_TOKEN: "platform-owner",
131
+ DATA_REGION: "",
132
+ },
133
+ kvNamespaces: { TENANT_DIRECTORY: "tenants" },
134
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
135
+ });
136
+ t.after(() => mf.dispose());
137
+ await mf.ready;
138
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
139
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO") as DurableObjectNamespace & {
140
+ jurisdiction(loc: string): DurableObjectNamespace;
141
+ };
142
+ const tokenA = "owner-alice";
143
+ const tokenB = "owner-bobby";
144
+ const hashA = await sha256(tokenA);
145
+ const hashB = await sha256(tokenB);
146
+
147
+ const before = await mf.listDurableObjectIds("TENANT_DO");
148
+ assert.equal((await mf.dispatchFetch("https://nope.example.com/health")).status, 404);
149
+ assert.equal((await mf.dispatchFetch("https://example.com/health")).status, 404);
150
+ assert.equal((await mf.dispatchFetch("https://a.b.example.com/health")).status, 404);
151
+ assert.deepEqual(await mf.listDurableObjectIds("TENANT_DO"), before, "unknown names do not create an object");
152
+
153
+ await kv.put("tenant:paused", JSON.stringify({ id: "id-paused", status: "suspended", region: "", version: 1 }));
154
+ assert.equal((await mf.dispatchFetch("https://paused.example.com/health")).status, 403);
155
+ assert.deepEqual(await mf.listDurableObjectIds("TENANT_DO"), before, "suspended is answered from the directory");
156
+
157
+ await kv.put("tenant:dave", JSON.stringify({ id: "id-dave", status: "active", region: "", version: 1 }));
158
+ assert.equal((await mf.dispatchFetch("https://dave.example.com/health")).status, 404);
159
+ assert.equal((await kv.get("tenant:dave", "json") as { region: string }).region, "default", "an empty region is pinned before the object is addressed");
160
+ const dave = ns.get(ns.idFromName("id-dave")) as unknown as { storageStatus(): Promise<{ configured: boolean; tables: string[] }> };
161
+ const daveStatus = await dave.storageStatus();
162
+ assert.equal(daveStatus.configured, false);
163
+ assert.deepEqual(daveStatus.tables, [], "a fetch before pushConfig writes no tables");
164
+
165
+ type Push = { version: number; applied: boolean };
166
+ const push = async (id: string, body: Record<string, unknown>, target: DurableObjectNamespace = ns) => {
167
+ const stub = target.get(target.idFromName(id)) as unknown as { pushConfig(b: Record<string, unknown>): Promise<Push>; fetch(r: Request): Promise<Response> };
168
+ return { stub, result: await stub.pushConfig(body) };
169
+ };
170
+
171
+ const alice = await push("id-alice", { version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice" } });
172
+ assert.equal(alice.result.applied, true, JSON.stringify(alice.result));
173
+ const stale = await alice.stub.pushConfig({ version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Stale" } });
174
+ assert.equal(stale.version, 1);
175
+ assert.equal(stale.applied, false);
176
+ await kv.put("tenant:alice", JSON.stringify({ id: "id-alice", status: "active", region: "", version: 1 }));
177
+
178
+ const bobPush = await push("id-bobby", { version: 1, tenantId: "id-bobby", name: "bobby", status: "active", ownerTokenHash: hashB, config: { AGENT_NAME: "Bob" } });
179
+ assert.equal(bobPush.result.applied, true, JSON.stringify(bobPush.result));
180
+ await kv.put("tenant:bobby", JSON.stringify({ id: "id-bobby", status: "active", region: "", version: 1 }));
181
+
182
+ const card = async (name: string, headers?: HeadersInit) => (await mf.dispatchFetch(`https://${name}.example.com/.well-known/agent-card.json`, { headers })).json() as Promise<{ name: string }>;
183
+ assert.equal((await card("alice")).name, "Alice");
184
+ assert.equal((await card("bobby")).name, "Bob");
185
+ assert.equal((await card("alice", { "x-a2a-tenant": JSON.stringify({ name: "bob", config: { AGENT_NAME: "Bob" } }) })).name, "Alice");
186
+
187
+ const bumped = await alice.stub.pushConfig({ version: 3, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice 3" } });
188
+ assert.equal(bumped.version, 3);
189
+ assert.equal(bumped.applied, true);
190
+ const rolled = await alice.stub.pushConfig({ version: 2, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA, config: { AGENT_NAME: "Rolled" } });
191
+ assert.equal(rolled.version, 3);
192
+ assert.equal(rolled.applied, false);
193
+ assert.equal((await card("alice")).name, "Alice 3");
194
+
195
+ const call = (url: string, method: string, auth: string, json?: unknown) => mf.dispatchFetch(url, {
196
+ method, headers: { authorization: auth, "content-type": "application/json" }, body: json === undefined ? undefined : JSON.stringify(json),
197
+ });
198
+ assert.equal((await call("https://alice.example.com/owner/peers", "GET", "Bearer platform-owner")).status, 401, "the platform OWNER_TOKEN is not a tenant owner");
199
+ const issued = await (await call("https://alice.example.com/owner/peers", "POST", `Bearer ${tokenA}`, { label: "ada" })).json() as { token: string };
200
+ assert.match(issued.token, /^a2aow_/);
201
+ const sent = await call("https://alice.example.com/", "POST", `Bearer ${issued.token}`, {
202
+ jsonrpc: "2.0", id: 1, method: "message/send",
203
+ params: { message: { kind: "message", role: "user", messageId: "m1", parts: [{ kind: "text", text: "hello alice" }] } },
204
+ });
205
+ assert.equal(sent.status, 200, await sent.clone().text());
206
+ assert.equal((await call("https://bobby.example.com/", "POST", `Bearer ${issued.token}`, {
207
+ jsonrpc: "2.0", id: 1, method: "message/send",
208
+ params: { message: { kind: "message", role: "user", messageId: "m2", parts: [{ kind: "text", text: "for bob?" }] } },
209
+ })).status, 401);
210
+ assert.equal((await call("https://bobby.example.com/owner/peers", "GET", `Bearer ${tokenA}`)).status, 401);
211
+ const bobInbox = await (await call("https://bobby.example.com/owner/inbox", "GET", `Bearer ${tokenB}`)).json() as unknown[];
212
+ assert.deepEqual(bobInbox, []);
213
+ const aliceInbox = await (await call("https://alice.example.com/owner/inbox?all=1", "GET", `Bearer ${tokenA}`)).json() as { text: string }[];
214
+ assert.equal(aliceInbox.length, 1);
215
+ assert.match(aliceInbox[0]!.text, /hello alice/);
216
+
217
+ // workerd's local runtime throws "Jurisdiction restrictions are not implemented" on namespace.jurisdiction().
218
+ // namespaceForRegion() is covered in tenancy.test.ts; production Durable Objects do honour eu / fedramp.
219
+ await alice.stub.pushConfig({ version: 4, tenantId: "id-alice", name: "alice", status: "suspended", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice 3" } });
220
+ assert.equal((await mf.dispatchFetch("https://alice.example.com/health")).status, 403, "the object enforces a newer status than the directory cache");
221
+ });
@@ -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", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata"]) 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", "0006_mcp.sql", "0007_cimd.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", "0006_mcp.sql", "0007_cimd.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"]);
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", "0006_mcp.sql", "0007_cimd.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
 
@@ -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)"`);
@@ -203,7 +204,7 @@ test("human approval: pending, slow_down, page approve with the password, single
203
204
  assert.equal(s.wakes.length, 1);
204
205
  const w = s.wakes[0].body;
205
206
  assert.equal(w.kind, "pairing_request");
206
- assert.equal(w.hint, "npx a2a-exposed pair list");
207
+ assert.equal(w.hint, "npx -y a2a-exposed@latest pair list");
207
208
  assert.deepEqual([w.pairing.userCode, w.pairing.verificationUriComplete, w.pairing.approval, w.pairing.clientId], [d.user_code, d.verification_uri_complete, "human", "barry"]);
208
209
  assert.equal(w.pairing.agentCardUrl, "https://barry.example.org/.well-known/agent-card.json");
209
210
  assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
@@ -237,7 +238,7 @@ test("human approval: pending, slow_down, page approve with the password, single
237
238
  assert.equal(g.headers.get("x-frame-options"), "DENY");
238
239
  assert.match(g.headers.get("set-cookie")!, /^a2a_device_csrf=[A-Za-z0-9_-]{24}; Path=\/device; Secure; HttpOnly; SameSite=Strict/);
239
240
  assert.ok(!/<script|src=|href=/i.test(g.text), "no scripts or external assets");
240
- 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/);
241
242
  assert.ok(!g.text.includes('name="password"'));
242
243
  assert.equal((await s.decidePage(d.user_code, PASSWORD, "approve")).status, 409);
243
244
 
@@ -371,7 +372,7 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
371
372
  test("pairing wakes: summary for each preset; requester text only where previews are allowed", async () => {
372
373
  const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Barry", preview: "", kind: "pairing_request", publicUrl: BASE,
373
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 } };
374
- const sum = wakeSummary(ev, "npx a2a-exposed");
375
+ const sum = wakeSummary(ev, "npx -y a2a-exposed@latest");
375
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\)\./);
376
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/);
377
378
  const agentSum = wakeSummary({ ...ev, pairing: { ...ev.pairing!, approval: "agent" } }, "a2a");
@@ -633,7 +634,7 @@ test("GET /: a browser gets an HTML landing page (strict CSP, no scripts); curl/
633
634
  assert.match(h.text, /A2A \(Agent2Agent\)<\/a> endpoint/);
634
635
  assert.ok(h.text.includes(`href="/.well-known/agent-card.json"`) && h.text.includes(`${BASE}/.well-known/agent-card.json`));
635
636
  assert.ok(h.text.includes(`href="/device"`), "pairing link");
636
- assert.ok(h.text.includes(`npx a2a-exposed connect ${BASE}`));
637
+ assert.ok(h.text.includes(`npx -y a2a-exposed@latest connect ${BASE}`));
637
638
  assert.match(h.text, /1\.0, 0\.3/);
638
639
  // the style block carries the CSP nonce
639
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
+ });