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.
Files changed (44) 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 +50 -7
  12. package/worker/deploy.env.example +28 -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 +737 -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.ts +3 -2
  28. package/worker/test/alarms.test.ts +231 -0
  29. package/worker/test/approval-oidc.test.ts +331 -0
  30. package/worker/test/bridge-thread.mjs +40 -0
  31. package/worker/test/d1.ts +146 -2
  32. package/worker/test/directory.test.ts +90 -0
  33. package/worker/test/export.test.ts +544 -0
  34. package/worker/test/facade.test.ts +1 -1
  35. package/worker/test/harness-worker.ts +80 -0
  36. package/worker/test/hosted.test.ts +221 -0
  37. package/worker/test/migrations.test.ts +5 -5
  38. package/worker/test/pairing.test.ts +5 -4
  39. package/worker/test/register-hooks.mjs +4 -0
  40. package/worker/test/sql-raw-hook.mjs +22 -0
  41. package/worker/test/storage.test.ts +74 -0
  42. package/worker/test/tenancy.test.ts +217 -0
  43. package/worker/test/wake-retry.test.ts +24 -1
  44. package/worker/test/wake.test.ts +3 -0
@@ -0,0 +1,544 @@
1
+ // Inbox export and the gated daily R2 snapshot. Run: npm test
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { createRequire } from "node:module";
7
+ import { fileURLToPath } from "node:url";
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import { Miniflare, convertV4MiniflareOptions } from "miniflare";
10
+ import worker from "../src/index.ts";
11
+ import { d1 } from "./d1.ts";
12
+ import { sha256 } from "../src/a2a.ts";
13
+ import { exportRows, toSql, bookmarkForTime, restoreBookmark, restartAfterResult, ExportError, EXPORT_TABLES, RESTORE_RESTART_DELAY_MS } from "../src/export.ts";
14
+ import { doSqlD1, type SqlStorageLike, type TxRunner } from "../src/storage.ts";
15
+ import { backupDatabase, backupDirectory, exportTenantSql, hostedDailyBackup, olderThanRetention, pruneSnapshots, snapshotKey, DAILY_CRON, type R2BucketLike } from "../src/backup.ts";
16
+ import { namespaceForRegion, type DoNamespace } from "../src/tenancy.ts";
17
+ import type { DirectoryEntry } from "../src/directory.ts";
18
+
19
+ const BASE = "https://agent.example.com";
20
+ const MIGRATIONS = new URL("../migrations/", import.meta.url);
21
+ const ectx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} };
22
+
23
+ function db() {
24
+ return d1(MIGRATIONS);
25
+ }
26
+
27
+ test("export SQL round-trips application rows and skips hosted config", async () => {
28
+ const DB = db();
29
+ const now = "2026-10-09T03:00:00.000Z";
30
+ await DB.prepare("INSERT INTO peers (label, token_hash, created_at, revoked_at) VALUES (?, ?, ?, NULL)").bind("o'brien", "hash-1", now).run();
31
+ await DB.prepare("INSERT INTO history (context_id, ts, dir, text) VALUES (?, ?, 'in', ?)").bind("ctx-1", now, "a'b\n-- drop").run();
32
+ const file = await exportRows(DB, new Date(now));
33
+ assert.equal(file.format, "a2a-exposed-export");
34
+ assert.equal(file.version, 1);
35
+ assert.equal(file.tables.peers?.[0]?.label, "o'brien");
36
+ assert.deepEqual(Object.keys(file.tables).sort(), [...EXPORT_TABLES].sort());
37
+ const sql = toSql(file);
38
+ assert.match(sql, /o''brien/);
39
+ assert.match(sql, /'a''b\n-- drop'/);
40
+ assert.doesNotMatch(sql, /tenant_config/);
41
+ assert.doesNotMatch(sql, /d1_migrations/);
42
+ assert.doesNotMatch(sql, /secrets_enc/);
43
+
44
+ const fresh = new DatabaseSync(":memory:");
45
+ for (const name of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith(".sql")).sort())
46
+ fresh.exec(fs.readFileSync(new URL(name, MIGRATIONS), "utf8"));
47
+ fresh.exec(sql);
48
+ const peer = fresh.prepare("SELECT label, token_hash FROM peers").get() as { label: string; token_hash: string };
49
+ assert.equal(peer.label, "o'brien");
50
+ assert.equal(peer.token_hash, "hash-1");
51
+ const text = fresh.prepare("SELECT text FROM history").get() as { text: string };
52
+ assert.equal(text.text, "a'b\n-- drop");
53
+ });
54
+
55
+ test("export SQL writes a NUL byte as char(0) so the script stays valid", () => {
56
+ const raw = "a'\0b\0";
57
+ const sql = toSql({
58
+ format: "a2a-exposed-export",
59
+ version: 1,
60
+ exportedAt: "2026-10-09T03:00:00.000Z",
61
+ tables: { history: [{ context_id: "ctx-nul", ts: "2026-10-09T03:00:00.000Z", dir: "in", text: raw }] },
62
+ });
63
+ assert.equal(sql.includes("\u0000"), false);
64
+ assert.match(sql, /\('a''' \|\| char\(0\) \|\| 'b' \|\| char\(0\)\)/);
65
+ const fresh = new DatabaseSync(":memory:");
66
+ for (const name of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith(".sql")).sort())
67
+ fresh.exec(fs.readFileSync(new URL(name, MIGRATIONS), "utf8"));
68
+ fresh.exec(sql);
69
+ const hex = fresh.prepare("SELECT hex(text) AS h FROM history").get() as { h: string };
70
+ assert.equal(hex.h, Buffer.from(raw, "utf8").toString("hex").toUpperCase());
71
+ });
72
+
73
+ test("exportRows reads every application table in one batch", async () => {
74
+ const DB = db();
75
+ await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();
76
+ await DB.prepare("INSERT INTO history (context_id, ts, dir, text) VALUES ('c', 't', 'in', 'hi')").run();
77
+ let batches = 0;
78
+ const wrapped = {
79
+ prepare(sql: string) {
80
+ const s = DB.prepare(sql);
81
+ const view = (stmt: { __sql: string; __args: unknown[]; bind: (...a: unknown[]) => unknown }) => ({
82
+ __sql: stmt.__sql,
83
+ __args: stmt.__args,
84
+ bind(...a: unknown[]) { return view(stmt.bind(...a) as typeof stmt); },
85
+ all: async () => { throw new Error("per-table read"); },
86
+ first: () => { throw new Error("per-table read"); },
87
+ run: () => { throw new Error("per-table read"); },
88
+ });
89
+ return view(s);
90
+ },
91
+ async batch(stmts: { __sql: string }[]) {
92
+ batches++;
93
+ assert.deepEqual(stmts.map((s) => s.__sql), EXPORT_TABLES.map((n) => `SELECT * FROM "${n}"`));
94
+ return DB.batch(stmts);
95
+ },
96
+ };
97
+ const file = await exportRows(wrapped as never);
98
+ assert.equal(batches, 1);
99
+ assert.equal(file.tables.peers?.[0]?.label, "ada");
100
+ assert.equal(file.tables.history?.[0]?.text, "hi");
101
+ });
102
+
103
+ test("exportRows on a Durable Object runs inside one transactionSync", async () => {
104
+ const raw = new DatabaseSync(":memory:");
105
+ for (const name of fs.readdirSync(MIGRATIONS).filter((n) => n.endsWith(".sql")).sort())
106
+ raw.exec(fs.readFileSync(new URL(name, MIGRATIONS), "utf8"));
107
+ raw.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();
108
+ raw.prepare("INSERT INTO history (context_id, ts, dir, text) VALUES ('c', 't', 'in', 'hi')").run();
109
+ let depth = 0;
110
+ let txCalls = 0;
111
+ let outside = 0;
112
+ const sql: SqlStorageLike = {
113
+ exec(query: string, ...bindings: unknown[]) {
114
+ if (/^\s*select\b/i.test(query) && depth === 0) outside++;
115
+ const stmt = raw.prepare(query);
116
+ if (/^\s*select\b/i.test(query)) {
117
+ const rows = stmt.all(...(bindings as [])) as Record<string, unknown>[];
118
+ return { toArray: () => rows, raw: () => rows.map((r) => Object.values(r)), rowsRead: rows.length, rowsWritten: 0 };
119
+ }
120
+ const info = stmt.run(...(bindings as []));
121
+ return { toArray: () => [], raw: () => [], rowsRead: 0, rowsWritten: Number(info.changes) };
122
+ },
123
+ };
124
+ const tx: TxRunner = (fn) => {
125
+ txCalls++;
126
+ depth++;
127
+ raw.exec("BEGIN");
128
+ try { const v = fn(); raw.exec("COMMIT"); return v; }
129
+ finally { depth--; }
130
+ };
131
+ const file = await exportRows(doSqlD1(sql, tx));
132
+ assert.equal(txCalls, 1);
133
+ assert.equal(outside, 0);
134
+ assert.equal(file.tables.peers?.[0]?.label, "ada");
135
+ assert.equal(file.tables.history?.[0]?.text, "hi");
136
+ assert.equal(Object.keys(file.tables).length, EXPORT_TABLES.length);
137
+ });
138
+
139
+ test("owner export: auth, hashes, and the minute cron left in place", async () => {
140
+ const DB = db();
141
+ const bucket = fakeBucket();
142
+ const env: any = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", BACKUP_BUCKET: bucket };
143
+ const call = async (method: string, path: string, json?: unknown, auth = "Bearer owner-secret") => {
144
+ const res = await worker.fetch(new Request(BASE + path, {
145
+ method, headers: { authorization: auth, "content-type": "application/json" },
146
+ body: json === undefined ? undefined : JSON.stringify(json),
147
+ }) as any, env, ectx as any);
148
+ const text = await res.text();
149
+ let data: any = text;
150
+ try { data = JSON.parse(text); } catch { /* sql */ }
151
+ return { status: res.status, data, text };
152
+ };
153
+ assert.equal((await call("GET", "/owner/export", undefined, "")).status, 401);
154
+ const issued = await call("POST", "/owner/peers", { label: "ada" });
155
+ assert.match(issued.data.token, /^a2aow_/);
156
+ const dumped = await call("GET", "/owner/export");
157
+ assert.equal(dumped.status, 200);
158
+ assert.equal(dumped.data.tables.peers[0].label, "ada");
159
+ assert.equal(dumped.data.tables.peers[0].token_hash, await sha256(issued.data.token));
160
+ assert.equal(JSON.stringify(dumped.data).includes(issued.data.token), false);
161
+ assert.equal(JSON.stringify(dumped.data).includes("owner-secret"), false);
162
+ const sql = await call("GET", "/owner/export?format=sql");
163
+ assert.match(sql.text, /DELETE FROM "peers"/);
164
+ assert.equal(sql.text.includes(issued.data.token), false);
165
+ assert.equal(sql.text.includes("tenant_config"), false);
166
+ assert.equal((await call("POST", "/owner/import", dumped.data)).status, 404);
167
+
168
+ await DB.prepare("INSERT INTO rate (peer, minute, count) VALUES ('p', 1, 4)").run();
169
+ await worker.scheduled({ cron: DAILY_CRON } as any, env, ectx as any);
170
+ assert.equal((await DB.prepare("SELECT count FROM rate WHERE peer = 'p'").first<{ count: number }>())?.count, 4);
171
+ const key = `tenants/self/${new Date().toISOString().slice(0, 10)}.sql`;
172
+ assert.equal(bucket.puts.some((p) => p.key === key && p.contentType === "application/sql"), true);
173
+ assert.match(bucket.objects.get(key) || "", /DELETE FROM "peers"/);
174
+
175
+ await worker.scheduled({ cron: "* * * * *" } as any, env, ectx as any);
176
+ assert.equal(await DB.prepare("SELECT count FROM rate WHERE peer = 'p'").first(), null);
177
+
178
+ const bare = db();
179
+ await bare.prepare("INSERT INTO rate (peer, minute, count) VALUES ('q', 1, 1)").run();
180
+ await worker.scheduled({ cron: DAILY_CRON } as any, { DB: bare, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret" } as any, ectx as any);
181
+ assert.equal(await bare.prepare("SELECT count FROM rate WHERE peer = 'q'").first(), null);
182
+ });
183
+
184
+ test("retention drops a snapshot 30 days old and keeps the newer one", () => {
185
+ assert.equal(olderThanRetention("2026-09-09", "2026-10-09"), true);
186
+ assert.equal(olderThanRetention("2026-09-10", "2026-10-09"), false);
187
+ assert.equal(olderThanRetention("2026-10-09", "2026-10-09"), false);
188
+ assert.equal(olderThanRetention("2026-02-31", "2026-10-09"), false);
189
+ assert.equal(snapshotKey("self", "2026-10-09"), "tenants/self/2026-10-09.sql");
190
+ assert.throws(() => snapshotKey("a/b", "2026-10-09"), /tenant id/);
191
+ });
192
+
193
+ test("hosted backup lists the directory, pins region, skips inactive tenants, and keeps going", async () => {
194
+ const now = new Date("2026-10-09T03:00:00.000Z");
195
+ const bucket = fakeBucket({
196
+ "tenants/id-alice/2026-09-09.sql": "old",
197
+ "tenants/id-alice/2026-09-10.sql": "keep",
198
+ "tenants/id-alice/notes.sql": "leave",
199
+ });
200
+ const kv = fakeKv({
201
+ alice: { id: "id-alice", status: "active", region: "", version: 1 },
202
+ paused: { id: "id-paused", status: "suspended", region: "default", version: 1 },
203
+ broke: { id: "id-bad", status: "active", region: "default", version: 1 },
204
+ });
205
+ const seen: string[] = [];
206
+ const ns = {
207
+ idFromName: (id: string) => id,
208
+ get: (id: string) => ({
209
+ async exportSql() {
210
+ seen.push(id);
211
+ if (id === "id-bad") throw new Error("exportSql: boom");
212
+ return { sql: `-- ${id}\n`, exportedAt: now.toISOString() };
213
+ },
214
+ }),
215
+ jurisdiction() { throw new Error("jurisdiction"); },
216
+ };
217
+ const env = { TENANCY: "host", TENANT_DO: ns, TENANT_DIRECTORY: kv, DB: db(), BACKUP_BUCKET: bucket };
218
+ assert.equal(await hostedDailyBackup({ cron: "* * * * *" }, env), null);
219
+ assert.equal(await hostedDailyBackup({ cron: DAILY_CRON }, { ...env, BACKUP_BUCKET: undefined }), null);
220
+ const report = await hostedDailyBackup({ cron: DAILY_CRON }, env, now);
221
+ assert.ok(report);
222
+ assert.deepEqual(seen.sort(), ["id-alice", "id-bad"]);
223
+ assert.equal(report.written.length, 1);
224
+ assert.equal(report.written[0]?.key, "tenants/id-alice/2026-10-09.sql");
225
+ assert.deepEqual(report.written[0]?.deleted, ["tenants/id-alice/2026-09-09.sql"]);
226
+ assert.equal(bucket.objects.get("tenants/id-alice/2026-09-10.sql"), "keep");
227
+ assert.equal(bucket.objects.get("tenants/id-alice/notes.sql"), "leave");
228
+ assert.equal(bucket.puts[0]?.contentType, "application/sql");
229
+ assert.deepEqual(report.skipped, [{ tenantId: "id-paused", status: "suspended" }]);
230
+ assert.equal(report.failed.length, 1);
231
+ assert.match(report.failed[0]?.error || "", /boom/);
232
+ assert.equal(JSON.parse(kv.store.get("tenant:alice") || "{}").region, "default");
233
+ });
234
+
235
+ test("hosted backup follows every directory page and refuses a stuck cursor", async () => {
236
+ const entries: Record<string, DirectoryEntry> = {};
237
+ for (let i = 0; i < 101; i++) {
238
+ const name = `t${String(i).padStart(4, "0")}`;
239
+ entries[name] = { id: `id-${name}`, status: "active", region: "default", version: 1 };
240
+ }
241
+ const kv = fakeKv(entries);
242
+ const seen: string[] = [];
243
+ const ns = {
244
+ idFromName: (id: string) => id,
245
+ get: (id: string) => ({
246
+ async exportSql() {
247
+ seen.push(id);
248
+ return { sql: `-- ${id}\n`, exportedAt: "2026-10-09T03:00:00.000Z" };
249
+ },
250
+ }),
251
+ jurisdiction() { throw new Error("jurisdiction"); },
252
+ };
253
+ const report = await backupDirectory(kv, fakeBucket(), ns as never, "default", new Date("2026-10-09T03:00:00.000Z"));
254
+ assert.equal(seen.length, 101);
255
+ assert.equal(report.written.length, 101);
256
+ assert.equal(report.failed.length, 0);
257
+ const stuck = {
258
+ async get() { return null; },
259
+ async put() {},
260
+ async list() { return { keys: [{ name: "tenant:alice" }], list_complete: false }; },
261
+ };
262
+ await assert.rejects(() => backupDirectory(stuck, fakeBucket(), ns as never, "default"), /directory listing did not finish/);
263
+ });
264
+
265
+ test("snapshot prune follows every object page", async () => {
266
+ const objects: Record<string, string> = {};
267
+ for (let i = 0; i < 101; i++) objects[`tenants/id-alice/${new Date(Date.UTC(2020, 0, 1 + i)).toISOString().slice(0, 10)}.sql`] = "old";
268
+ const bucket = fakeBucket(objects);
269
+ const deleted = await pruneSnapshots(bucket, "id-alice", "2026-10-09");
270
+ assert.equal(deleted.length, 101);
271
+ assert.equal(bucket.objects.size, 0);
272
+ });
273
+
274
+ test("a pinned eu tenant is exported through that jurisdiction", async () => {
275
+ const eu = {
276
+ idFromName: (id: string) => `eu:${id}`,
277
+ get: (id: string) => ({ exportSql: async () => ({ sql: `-- ${id}\n`, exportedAt: "2026-10-09T00:00:00.000Z" }) }),
278
+ };
279
+ let asked = "";
280
+ const ns = {
281
+ idFromName: (id: string) => id,
282
+ get: () => { throw new Error("default namespace"); },
283
+ jurisdiction(loc: string) { asked = loc; return eu; },
284
+ };
285
+ const entry: DirectoryEntry = { id: "id-alice", status: "active", region: "eu", version: 1 };
286
+ const out = await exportTenantSql(ns, entry);
287
+ assert.equal(asked, "eu");
288
+ assert.match(out.sql, /eu:id-alice/);
289
+ assert.equal(namespaceForRegion(ns as DoNamespace, "eu"), eu);
290
+ });
291
+
292
+ test("point-in-time helpers require the storage methods", async () => {
293
+ await assert.rejects(() => bookmarkForTime({}, Date.now()), (e: unknown) => e instanceof ExportError && /not available/.test(e.message));
294
+ await assert.rejects(() => restoreBookmark({}, "bm"), (e: unknown) => e instanceof ExportError && /not available/.test(e.message));
295
+ const storage = {
296
+ async getBookmarkForTime(t: number) { return `bm-${t}`; },
297
+ async onNextSessionRestoreBookmark(b: string) { return `undo-${b}`; },
298
+ };
299
+ assert.equal((await bookmarkForTime(storage, 10)).bookmark, "bm-10");
300
+ assert.deepEqual(await restoreBookmark(storage, "bm-10"), { ok: true, undo: "undo-bm-10" });
301
+ await assert.rejects(() => restoreBookmark(storage, ""), /invalid/);
302
+ });
303
+
304
+ test("restoreBookmark returns before the object restarts", async () => {
305
+ const order: string[] = [];
306
+ const storage = {
307
+ async onNextSessionRestoreBookmark(b: string) {
308
+ order.push(`schedule:${b}`);
309
+ return `undo-${b}`;
310
+ },
311
+ };
312
+ const out = await restoreBookmark(storage, "bm-1");
313
+ order.push("returned");
314
+ let released!: () => void;
315
+ const gate = new Promise<void>((resolve) => { released = resolve; });
316
+ let waited = -1;
317
+ restartAfterResult((p) => { void p.then(() => order.push("restarted")); }, () => {}, (ms) => { waited = ms; return gate; });
318
+ assert.equal(waited, RESTORE_RESTART_DELAY_MS);
319
+ assert.deepEqual(out, { ok: true, undo: "undo-bm-1" });
320
+ assert.deepEqual(order, ["schedule:bm-1", "returned"]);
321
+ released();
322
+ await gate;
323
+ await Promise.resolve();
324
+ assert.deepEqual(order, ["schedule:bm-1", "returned", "restarted"]);
325
+
326
+ const failing = { async onNextSessionRestoreBookmark() { throw new Error("rejected bookmark"); } };
327
+ await assert.rejects(() => restoreBookmark(failing, "bm-2"), (e: unknown) => e instanceof ExportError && /rejected bookmark/.test((e as Error).message));
328
+ await assert.rejects(() => restoreBookmark(storage, ""), /invalid/);
329
+ });
330
+
331
+ test("backupDatabase writes the self-host snapshot", async () => {
332
+ const DB = db();
333
+ await DB.prepare("INSERT INTO settings (key, value) VALUES ('k', 'v')").run();
334
+ const bucket = fakeBucket();
335
+ const written = await backupDatabase(bucket, DB, "self", new Date("2026-10-09T03:00:00.000Z"));
336
+ assert.equal(written.key, "tenants/self/2026-10-09.sql");
337
+ assert.match(bucket.objects.get(written.key) || "", /INSERT INTO "settings"/);
338
+ });
339
+
340
+ function fakeBucket(initial: Record<string, string> = {}): R2BucketLike & { objects: Map<string, string>; puts: { key: string; contentType?: string }[] } {
341
+ const objects = new Map(Object.entries(initial));
342
+ const puts: { key: string; contentType?: string }[] = [];
343
+ return {
344
+ objects,
345
+ puts,
346
+ async put(key, value, options) {
347
+ objects.set(key, value);
348
+ puts.push({ key, contentType: options?.httpMetadata?.contentType });
349
+ },
350
+ async list({ prefix, cursor } = {}) {
351
+ const keys = [...objects.keys()].filter((k) => !prefix || k.startsWith(prefix)).sort();
352
+ const start = cursor ? Number(cursor) : 0;
353
+ const slice = keys.slice(start, start + 1);
354
+ const next = start + slice.length;
355
+ return { objects: slice.map((key) => ({ key })), truncated: next < keys.length, cursor: next < keys.length ? String(next) : undefined };
356
+ },
357
+ async delete(keys) { for (const k of keys) objects.delete(k); },
358
+ };
359
+ }
360
+
361
+ function fakeKv(entries: Record<string, DirectoryEntry>) {
362
+ const store = new Map(Object.entries(entries).map(([name, entry]) => [`tenant:${name}`, JSON.stringify(entry)]));
363
+ return {
364
+ store,
365
+ async get(key: string, type?: string) {
366
+ const raw = store.get(key) ?? null;
367
+ if (type === "json") return raw ? JSON.parse(raw) : null;
368
+ return raw;
369
+ },
370
+ async put(key: string, value: string) { store.set(key, value); },
371
+ async list({ prefix = "", cursor }: { prefix?: string; cursor?: string } = {}) {
372
+ const keys = [...store.keys()].filter((k) => k.startsWith(prefix)).sort();
373
+ const start = cursor ? keys.indexOf(cursor) + 1 : 0;
374
+ const slice = keys.slice(start, start + 1);
375
+ const last = slice[slice.length - 1];
376
+ const more = slice.length > 0 && start + slice.length < keys.length;
377
+ return { keys: slice.map((name) => ({ name })), list_complete: !more, cursor: more ? last : undefined };
378
+ },
379
+ };
380
+ }
381
+
382
+ const require = createRequire(new URL("../package.json", import.meta.url));
383
+
384
+ function bundleHosted(): Promise<string> {
385
+ const { build } = require("esbuild") as typeof import("esbuild");
386
+ return build({
387
+ entryPoints: [fileURLToPath(new URL("../src/hosted.ts", import.meta.url))],
388
+ bundle: true,
389
+ format: "esm",
390
+ write: false,
391
+ platform: "neutral",
392
+ external: ["cloudflare:*"],
393
+ target: "es2022",
394
+ logLevel: "silent",
395
+ plugins: [{
396
+ name: "sql-raw",
397
+ setup(b) {
398
+ b.onResolve({ filter: /\.sql\?raw$/ }, (args) => ({
399
+ path: path.resolve(args.resolveDir, args.path.replace(/\?raw$/, "")),
400
+ namespace: "sql-raw",
401
+ }));
402
+ b.onLoad({ filter: /.*/, namespace: "sql-raw" }, (args) => ({
403
+ contents: `export default ${JSON.stringify(fs.readFileSync(args.path, "utf8"))};`,
404
+ loader: "js",
405
+ }));
406
+ },
407
+ }],
408
+ }).then((built) => {
409
+ const file = built.outputFiles?.[0];
410
+ if (!file) throw new Error("hosted bundle was empty");
411
+ return file.text;
412
+ });
413
+ }
414
+
415
+ test("TenantStore exportSql dumps a configured tenant", async (t) => {
416
+ const mf = new Miniflare(convertV4MiniflareOptions({
417
+ name: "a2a",
418
+ modules: true,
419
+ script: await bundleHosted(),
420
+ compatibilityDate: "2026-10-06",
421
+ d1Databases: { DB: "unused" },
422
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
423
+ bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret" },
424
+ } as never));
425
+ t.after(() => mf.dispose());
426
+ await mf.ready;
427
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
428
+ const stub = ns.get(ns.idFromName("id-alice")) as unknown as {
429
+ storageStatus(): Promise<{ configured: boolean }>;
430
+ pushConfig(b: Record<string, unknown>): Promise<{ applied: boolean }>;
431
+ exportSql(): Promise<{ sql: string; exportedAt: string }>;
432
+ bookmarkForTime(t: number): Promise<{ bookmark: string }>;
433
+ fetch(r: Request): Promise<Response>;
434
+ };
435
+ assert.equal((await stub.storageStatus()).configured, false);
436
+ const hash = await sha256("owner-alice");
437
+ assert.equal((await stub.pushConfig({ version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hash, config: { AGENT_NAME: "Alice" } })).applied, true);
438
+ const issued = await (await stub.fetch(new Request("https://alice.example.com/owner/peers", {
439
+ method: "POST",
440
+ headers: { authorization: "Bearer owner-alice", "content-type": "application/json" },
441
+ body: JSON.stringify({ label: "ada" }),
442
+ }))).json() as { token?: string };
443
+ assert.match(issued.token || "", /^a2aow_/);
444
+ const dumped = await stub.exportSql();
445
+ assert.match(dumped.sql, /INSERT INTO "peers"/);
446
+ assert.match(dumped.sql, /ada/);
447
+ assert.equal(dumped.sql.includes(issued.token || "a2aow_"), false);
448
+ assert.equal(dumped.sql.includes("tenant_config"), false);
449
+ assert.equal(dumped.sql.includes("platform-secret"), false);
450
+
451
+ const restored = stub as unknown as { restoreBookmark(b: string): Promise<{ ok: true; undo?: string }> };
452
+ await assert.rejects(() => restored.restoreBookmark("bm-local"));
453
+ assert.equal((await stub.storageStatus()).configured, true, "a storage error does not reset the object");
454
+ assert.match((await stub.exportSql()).sql, /ada/);
455
+ });
456
+
457
+ test("TenantStore.restoreBookmark returns ok and the next session is a new object", async (t) => {
458
+ // Local workerd's onNextSessionRestoreBookmark throws. The probe replaces that method, then calls the production RPC.
459
+ const script = `${await bundleHosted()}
460
+ export class Probe extends TenantStore {
461
+ constructor(ctx, env) { super(ctx, env); this.born = Date.now(); }
462
+ async bornAt() { return this.born; }
463
+ async restoreLive(bookmark) {
464
+ this.ctx.storage.onNextSessionRestoreBookmark = async () => "undo-live";
465
+ return await this.restoreBookmark(bookmark);
466
+ }
467
+ }
468
+ `;
469
+ const mf = new Miniflare(convertV4MiniflareOptions({
470
+ name: "restore",
471
+ modules: true,
472
+ script,
473
+ compatibilityDate: "2026-10-06",
474
+ durableObjects: { TENANT_DO: { className: "Probe", useSQLite: true } },
475
+ bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret" },
476
+ } as never));
477
+ t.after(() => mf.dispose());
478
+ await mf.ready;
479
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
480
+ const id = ns.idFromName("id-alice");
481
+ const stub = ns.get(id) as unknown as {
482
+ bornAt(): Promise<number>;
483
+ pushConfig(b: Record<string, unknown>): Promise<{ applied: boolean }>;
484
+ restoreLive(bookmark: string): Promise<{ ok: boolean; undo: string }>;
485
+ };
486
+ const hash = await sha256("owner-alice");
487
+ assert.equal((await stub.pushConfig({
488
+ version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hash, config: { AGENT_NAME: "Alice" },
489
+ })).applied, true);
490
+ const born = await stub.bornAt();
491
+ const restored = await stub.restoreLive("bm-live");
492
+ assert.equal(await restored.ok, true);
493
+ assert.equal(await restored.undo, "undo-live");
494
+ const start = Date.now();
495
+ let again = born;
496
+ while (again === born && Date.now() - start < 4000) {
497
+ await new Promise((r) => setTimeout(r, 50));
498
+ try {
499
+ again = await (ns.get(id) as typeof stub).bornAt();
500
+ } catch {
501
+ // The reset is in progress. The next call is a new session.
502
+ }
503
+ }
504
+ assert.notEqual(again, born, "abort after the result starts a new session");
505
+ });
506
+
507
+ test("hosted daily cron writes the snapshot and leaves the self-host database alone", async (t) => {
508
+ const mf = new Miniflare({
509
+ ...convertV4MiniflareOptions({
510
+ name: "a2a",
511
+ modules: true,
512
+ script: await bundleHosted(),
513
+ compatibilityDate: "2026-10-06",
514
+ d1Databases: { DB: "self" },
515
+ kvNamespaces: { TENANT_DIRECTORY: "tenants" },
516
+ r2Buckets: { BACKUP_BUCKET: "backups" },
517
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
518
+ bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret", OWNER_TOKEN: "platform-owner" },
519
+ } as never),
520
+ unsafeTriggerHandlers: true,
521
+ });
522
+ t.after(() => mf.dispose());
523
+ await mf.ready;
524
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
525
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
526
+ const hash = await sha256("owner-alice");
527
+ const stub = ns.get(ns.idFromName("id-alice")) as unknown as {
528
+ pushConfig(b: Record<string, unknown>): Promise<{ applied: boolean }>;
529
+ };
530
+ assert.equal((await stub.pushConfig({
531
+ version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hash, config: { AGENT_NAME: "Alice" },
532
+ })).applied, true);
533
+ await kv.put("tenant:alice", JSON.stringify({ id: "id-alice", status: "active", region: "default", version: 1 }));
534
+ const fire = (cron: string) => mf.dispatchFetch(`https://example.com/cdn-cgi/local/scheduled?cron=${encodeURIComponent(cron)}&format=json`);
535
+ const daily = await fire(DAILY_CRON);
536
+ assert.equal(daily.status, 200, await daily.text());
537
+ const bucket = await mf.getR2Bucket("BACKUP_BUCKET");
538
+ const listed = await bucket.list({ prefix: "tenants/id-alice/" });
539
+ assert.equal(listed.objects.length, 1);
540
+ assert.match(listed.objects[0]?.key || "", /^tenants\/id-alice\/\d{4}-\d{2}-\d{2}\.sql$/);
541
+ // The minute trigger must not run the self-host flush. That database has no tables, so a flush would fail this call.
542
+ const minute = await fire("* * * * *");
543
+ assert.equal(minute.status, 200, await minute.text());
544
+ });
@@ -403,7 +403,7 @@ test("wake text for a private claimed card says it is not publicly reachable", a
403
403
  const { wakeSummary } = await import("../src/wake.ts");
404
404
  const txt = wakeSummary({ contextId: "pairing", taskId: "none", taskIds: [], from: "Jean", preview: "", kind: "pairing_request", publicUrl: BASE,
405
405
  pairing: { userCode: "WDJB-4827", verificationUriComplete: `${BASE}/device?user_code=WDJB-4827`, approval: "human", clientName: "Jean", clientId: "",
406
- agentCardUrl: `${TAILNET}/.well-known/agent-card.json`, agentCardPrivate: true, expiresIn: 600 } }, "npx a2a-exposed");
406
+ agentCardUrl: `${TAILNET}/.well-known/agent-card.json`, agentCardPrivate: true, expiresIn: 600 } }, "npx -y a2a-exposed@latest");
407
407
  assert.match(txt, /private network: not publicly reachable/);
408
408
  });
409
409
 
@@ -0,0 +1,80 @@
1
+ // Test harness Worker (workerd under Miniflare). The Node suite sends storage ops here.
2
+ // STORAGE_BACKEND=d1 hits a real D1 binding. STORAGE_BACKEND=do hits a SQLite Durable Object through storage.ts.
3
+ // This object applies migrations on construct so the existing suite sees a ready inbox. The production TenantStore
4
+ // does not: it waits for pushConfig. That path is covered by hosted.test.ts.
5
+ import { DurableObject } from "cloudflare:workers";
6
+ import { doSqlD1, migrateDo } from "../src/storage.ts";
7
+ import { MIGRATIONS } from "../src/migrations.ts";
8
+
9
+ type Op = {
10
+ op: "first" | "all" | "run" | "raw" | "batch" | "reset" | "migrations";
11
+ sql?: string;
12
+ args?: unknown[];
13
+ col?: string;
14
+ stmts?: { sql: string; args: unknown[] }[];
15
+ files?: { name: string; sql: string }[];
16
+ };
17
+
18
+ async function run(db: ReturnType<typeof doSqlD1> | D1Database, o: Op) {
19
+ const s = (sql: string, args: unknown[] = []) => db.prepare(sql).bind(...args);
20
+ switch (o.op) {
21
+ case "first": return o.col ? s(o.sql!, o.args).first(o.col) : s(o.sql!, o.args).first();
22
+ case "all": return s(o.sql!, o.args).all();
23
+ case "run": return s(o.sql!, o.args).run();
24
+ case "raw": return s(o.sql!, o.args).raw();
25
+ case "batch": return db.batch(o.stmts!.map((x) => s(x.sql, x.args)));
26
+ default: throw new Error("bad op " + o.op);
27
+ }
28
+ }
29
+
30
+ /** Split a migration file into statements. This repo's migrations have no triggers and no BEGIN/END blocks. */
31
+ function split(sql: string): string[] {
32
+ return sql.split("\n").map((l) => l.replace(/--.*$/, "")).join("\n").split(";").map((x) => x.trim()).filter(Boolean);
33
+ }
34
+
35
+ export class Store extends DurableObject {
36
+ db: ReturnType<typeof doSqlD1>;
37
+ applied: string[] = [];
38
+ constructor(ctx: DurableObjectState, env: Record<string, unknown>) {
39
+ super(ctx, env);
40
+ const storage = ctx.storage as unknown as { sql: Parameters<typeof doSqlD1>[0]; transactionSync: Parameters<typeof doSqlD1>[1] };
41
+ const tx: Parameters<typeof doSqlD1>[1] = (fn) => storage.transactionSync(fn);
42
+ this.db = doSqlD1(storage.sql, tx);
43
+ ctx.blockConcurrencyWhile(async () => { this.applied = migrateDo(storage.sql, tx, MIGRATIONS); });
44
+ }
45
+ async fetch(req: Request) {
46
+ const o = await req.json() as Op;
47
+ try {
48
+ if (o.op === "migrations") {
49
+ const recorded = this.ctx.storage.sql.exec("SELECT name FROM d1_migrations ORDER BY id").toArray().map((r) => String(r.name));
50
+ return Response.json({ ok: true, value: { applied: this.applied, recorded } });
51
+ }
52
+ return Response.json({ ok: true, value: await run(this.db, o) ?? null });
53
+ } catch (e) { return Response.json({ ok: false, error: e instanceof Error ? e.message : String(e) }); }
54
+ }
55
+ }
56
+
57
+ export default {
58
+ async fetch(req: Request, env: Record<string, D1Database | DurableObjectNamespace>) {
59
+ const url = new URL(req.url);
60
+ const o = await req.json() as Op;
61
+ if (url.pathname.startsWith("/do/")) {
62
+ const ns = env.STORE as DurableObjectNamespace;
63
+ const id = ns.idFromName(url.pathname.slice(4));
64
+ return ns.get(id).fetch("https://do/", { method: "POST", body: JSON.stringify(o) });
65
+ }
66
+ const db = env[url.pathname.slice(1)] as D1Database;
67
+ try {
68
+ if (o.op === "reset") {
69
+ const tables = (await db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'").all()).results?.map((r) => String((r as { name: string }).name)) ?? [];
70
+ if (tables.length) await db.batch(["PRAGMA foreign_keys=OFF", ...tables.map((t) => `DROP TABLE IF EXISTS "${t}"`)].map((q) => db.prepare(q)));
71
+ for (const f of o.files ?? []) {
72
+ const st = split(f.sql);
73
+ if (st.length) await db.batch(st.map((q) => db.prepare(q)));
74
+ }
75
+ return Response.json({ ok: true, value: null });
76
+ }
77
+ return Response.json({ ok: true, value: await run(db, o) ?? null });
78
+ } catch (e) { return Response.json({ ok: false, error: e instanceof Error ? e.message : String(e) }); }
79
+ },
80
+ };