a2a-exposed 0.5.0 → 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.
- package/README.md +3 -4
- package/lib/a2a.mjs +6 -2
- package/lib/commands.mjs +24 -15
- package/lib/deploy.mjs +20 -17
- package/lib/main.mjs +24 -9
- package/lib/pair.mjs +47 -3
- package/lib/status.mjs +4 -4
- package/lib/tunnel.mjs +11 -11
- package/lib/update.mjs +1 -1
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +50 -7
- package/worker/deploy.env.example +28 -1
- package/worker/migrations/0008_oidc_txns.sql +12 -0
- package/worker/package.json +6 -2
- package/worker/src/approval-oidc.ts +247 -0
- package/worker/src/backup.ts +186 -0
- package/worker/src/directory.ts +120 -0
- package/worker/src/export.ts +156 -0
- package/worker/src/hosted.ts +403 -0
- package/worker/src/index.ts +737 -584
- package/worker/src/mcp.ts +30 -12
- package/worker/src/migrations.ts +22 -0
- package/worker/src/pairing.ts +17 -4
- package/worker/src/sql-raw.d.ts +5 -0
- package/worker/src/storage.ts +100 -0
- package/worker/src/tenancy.ts +450 -0
- package/worker/src/wake.ts +3 -2
- package/worker/test/alarms.test.ts +231 -0
- package/worker/test/approval-oidc.test.ts +331 -0
- package/worker/test/bridge-thread.mjs +40 -0
- package/worker/test/d1.ts +146 -2
- package/worker/test/directory.test.ts +90 -0
- package/worker/test/export.test.ts +544 -0
- package/worker/test/facade.test.ts +1 -1
- package/worker/test/harness-worker.ts +80 -0
- package/worker/test/hosted.test.ts +221 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +5 -4
- package/worker/test/register-hooks.mjs +4 -0
- package/worker/test/sql-raw-hook.mjs +22 -0
- package/worker/test/storage.test.ts +74 -0
- package/worker/test/tenancy.test.ts +217 -0
- package/worker/test/wake-retry.test.ts +24 -1
- 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,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
|
+
});
|