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,120 @@
1
+ // Host → tenant name directory. The hosted router reads this before it touches a Durable Object.
2
+ // Control (later) writes `tenant:<name>` = { id, status, region, version }. No secrets live here.
3
+ // An entry with an empty region is pinned on first sight (`eu`, `fedramp`, or `default`) and then only read.
4
+ // The isolate caches each lookup, including "not in the directory", for 60 seconds, capped so a scan of
5
+ // random names cannot grow it without bound.
6
+
7
+ import type { KvNamespace } from "./tenancy.ts";
8
+
9
+ /** 4–32 characters: letter or digit, then letters, digits or hyphens, ending in a letter or digit. */
10
+ export const TENANT_NAME = /^[a-z0-9](?:[a-z0-9-]{2,30}[a-z0-9])$/;
11
+
12
+ export const DIRECTORY_TTL_MS = 60_000;
13
+ /** Isolate cap. A scan of random names must not keep a map entry for every miss. */
14
+ export const DIRECTORY_MAX = 1024;
15
+
16
+ export interface DirectoryEntry {
17
+ id: string;
18
+ status: string;
19
+ /**
20
+ * Placement, pinned once. `eu` and `fedramp` select that jurisdiction. `default` is the namespace's
21
+ * default placement. `""` means not pinned yet.
22
+ */
23
+ region: string;
24
+ version: number;
25
+ }
26
+
27
+ const cache = new Map<string, { at: number; entry: DirectoryEntry | null }>();
28
+
29
+ export function clearDirectoryCache(): void {
30
+ cache.clear();
31
+ }
32
+
33
+ /** Move `name` to the newest end and drop expired, then oldest, entries past the cap. */
34
+ function remember(name: string, at: number, entry: DirectoryEntry | null, now: number): void {
35
+ cache.delete(name);
36
+ cache.set(name, { at, entry });
37
+ if (cache.size <= DIRECTORY_MAX) return;
38
+ for (const [k, v] of cache) if (now - v.at >= DIRECTORY_TTL_MS) cache.delete(k);
39
+ while (cache.size > DIRECTORY_MAX) {
40
+ const oldest = cache.keys().next().value;
41
+ if (oldest === undefined) break;
42
+ cache.delete(oldest);
43
+ }
44
+ }
45
+
46
+ export function directoryKey(name: string): string {
47
+ return `tenant:${name}`;
48
+ }
49
+
50
+ /**
51
+ * First label under `domain`, or null. The apex, a nested name (`a.b.domain`) and a different parent do not match.
52
+ * `domain` is compared already normalised (lowercase, no surrounding dots).
53
+ */
54
+ export function tenantNameFromHost(host: string, domain: string): string | null {
55
+ const h = host.toLowerCase();
56
+ const d = domain.toLowerCase();
57
+ if (!d || h === d || !h.endsWith("." + d)) return null;
58
+ const name = h.slice(0, -(d.length + 1));
59
+ return TENANT_NAME.test(name) ? name : null;
60
+ }
61
+
62
+ /** `eu` or `fedramp` when the directory entry says so. `default` and anything else use the default namespace. */
63
+ export function tenantRegion(entry: DirectoryEntry): string {
64
+ return entry.region === "eu" || entry.region === "fedramp" ? entry.region : "";
65
+ }
66
+
67
+ /**
68
+ * Write a placement into an entry that does not have one yet, then return that entry.
69
+ * `DATA_REGION` is read only here. A later change to it does not move a tenant whose row already
70
+ * has `eu`, `fedramp`, or `default`. Returns null when the write fails; the caller must not address
71
+ * an object until the placement is stored.
72
+ */
73
+ export async function pinDirectoryRegion(
74
+ kv: Pick<KvNamespace, "put">,
75
+ name: string,
76
+ entry: DirectoryEntry,
77
+ dataRegion: string,
78
+ now = Date.now(),
79
+ ): Promise<DirectoryEntry | null> {
80
+ if (entry.region !== "") return entry;
81
+ const region = dataRegion === "eu" || dataRegion === "fedramp" ? dataRegion : "default";
82
+ const pinned: DirectoryEntry = { ...entry, region };
83
+ try {
84
+ await kv.put(directoryKey(name), JSON.stringify(pinned));
85
+ } catch {
86
+ return null;
87
+ }
88
+ remember(name, now, pinned, now);
89
+ return pinned;
90
+ }
91
+
92
+ export function parseDirectoryEntry(raw: unknown): DirectoryEntry | null {
93
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
94
+ const o = raw as Record<string, unknown>;
95
+ if (typeof o.id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(o.id)) return null;
96
+ if (typeof o.status !== "string" || !/^[a-z]{1,32}$/.test(o.status)) return null;
97
+ const region = typeof o.region === "string" ? o.region : "";
98
+ const version = typeof o.version === "number" && Number.isSafeInteger(o.version) ? o.version : 0;
99
+ return { id: o.id, status: o.status, region, version };
100
+ }
101
+
102
+ /**
103
+ * Cached directory lookup. A missing or unreadable value is cached as "unknown" so a scan of random names
104
+ * does not hit KV on every request. Pass `now` from tests.
105
+ */
106
+ export async function lookupDirectory(kv: Pick<KvNamespace, "get">, name: string, now = Date.now()): Promise<DirectoryEntry | null> {
107
+ const hit = cache.get(name);
108
+ if (hit && now - hit.at < DIRECTORY_TTL_MS) {
109
+ remember(name, hit.at, hit.entry, now);
110
+ return hit.entry;
111
+ }
112
+ let entry: DirectoryEntry | null = null;
113
+ try {
114
+ entry = parseDirectoryEntry(await kv.get(directoryKey(name), "json"));
115
+ } catch {
116
+ entry = null;
117
+ }
118
+ remember(name, now, entry, now);
119
+ return entry;
120
+ }
@@ -0,0 +1,156 @@
1
+ // Inbox export. Both the owner HTTP API and the hosted Durable Object use this.
2
+ // The allowlist is the application tables. `d1_migrations` and the hosted `tenant_config` row (it holds
3
+ // `secrets_enc`) are left out. The SQL text is a dump the operator already owns.
4
+
5
+ import type { SqlDb } from "./tenancy.ts";
6
+
7
+ export class ExportError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "ExportError";
11
+ }
12
+ }
13
+
14
+ /** Application tables, in a stable order. Schema changes add a table here in the same change as the migration. */
15
+ export const EXPORT_TABLES = [
16
+ "peers", "tasks", "messages", "history", "outbound", "wakes",
17
+ "rate", "wake_budget", "device_requests", "pairing_rate", "settings",
18
+ "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants",
19
+ "outbound_peers", "oauth_client_metadata", "oidc_txns",
20
+ ] as const;
21
+
22
+ export type ExportTable = (typeof EXPORT_TABLES)[number];
23
+
24
+ export interface ExportFile {
25
+ format: "a2a-exposed-export";
26
+ version: 1;
27
+ exportedAt: string;
28
+ tables: { [K in ExportTable]?: Record<string, unknown>[] };
29
+ }
30
+
31
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+
33
+ function quoteIdent(name: string): string {
34
+ if (!IDENT.test(name)) throw new ExportError("export: bad column");
35
+ return `"${name}"`;
36
+ }
37
+
38
+ function cell(v: unknown): unknown {
39
+ if (v === null || typeof v === "string" || typeof v === "boolean") return v;
40
+ if (typeof v === "number") {
41
+ if (!Number.isFinite(v)) throw new ExportError("export: non-finite number");
42
+ return v;
43
+ }
44
+ if (typeof v === "bigint") {
45
+ if (v > BigInt(Number.MAX_SAFE_INTEGER) || v < BigInt(Number.MIN_SAFE_INTEGER)) throw new ExportError("export: integer out of range");
46
+ return Number(v);
47
+ }
48
+ throw new ExportError("export: unsupported value");
49
+ }
50
+
51
+ function normalizeRow(row: Record<string, unknown>): Record<string, unknown> {
52
+ const out: Record<string, unknown> = {};
53
+ for (const [k, v] of Object.entries(row)) {
54
+ if (!IDENT.test(k)) throw new ExportError("export: bad column");
55
+ out[k] = cell(v);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** A SQL string literal. SQLite rejects a script that contains a NUL byte, so U+0000 is `char(0)` instead of a raw byte. */
61
+ function sqlString(v: string): string {
62
+ const parts = v.split("\u0000");
63
+ if (parts.length === 1) return `'${v.replaceAll("'", "''")}'`;
64
+ const bits: string[] = [];
65
+ for (let i = 0; i < parts.length; i++) {
66
+ if (parts[i]) bits.push(`'${parts[i].replaceAll("'", "''")}'`);
67
+ if (i < parts.length - 1) bits.push("char(0)");
68
+ }
69
+ return bits.length === 1 ? bits[0] : `(${bits.join(" || ")})`;
70
+ }
71
+
72
+ function sqlLit(v: unknown): string {
73
+ if (v === null) return "NULL";
74
+ if (typeof v === "boolean") return v ? "1" : "0";
75
+ if (typeof v === "number") return Number.isInteger(v) ? String(v) : JSON.stringify(v);
76
+ if (typeof v === "string") return sqlString(v);
77
+ throw new ExportError("export: unsupported value");
78
+ }
79
+
80
+ /**
81
+ * Every allowlisted table, including ones with no rows.
82
+ * One `batch` is one transaction. D1 runs the batch as a single transaction, so a concurrent write cannot
83
+ * land between tables. The Durable Object adapter runs that batch inside `transactionSync`, which blocks
84
+ * other requests for the whole read. Do not split this back into per-table queries.
85
+ */
86
+ export async function exportRows(db: SqlDb, now = new Date()): Promise<ExportFile> {
87
+ const out = await db.batch(EXPORT_TABLES.map((name) => db.prepare(`SELECT * FROM ${quoteIdent(name)}`)));
88
+ const tables: ExportFile["tables"] = {};
89
+ EXPORT_TABLES.forEach((name, i) => {
90
+ tables[name] = ((out[i]?.results ?? []) as Record<string, unknown>[]).map(normalizeRow);
91
+ });
92
+ return { format: "a2a-exposed-export", version: 1, exportedAt: now.toISOString(), tables };
93
+ }
94
+
95
+ /** DELETE then INSERT for every allowlisted table. Safe to run on a database that already has the schema. */
96
+ export function toSql(file: ExportFile): string {
97
+ if (file.format !== "a2a-exposed-export" || file.version !== 1) throw new ExportError("export: bad file");
98
+ if (!/^[\dTZ:.-]+$/.test(file.exportedAt)) throw new ExportError("export: bad timestamp");
99
+ const lines = [
100
+ `-- a2a-exposed-export ${file.version}`,
101
+ `-- exported_at ${file.exportedAt}`,
102
+ ];
103
+ for (const name of EXPORT_TABLES) {
104
+ lines.push(`DELETE FROM ${quoteIdent(name)};`);
105
+ for (const row of file.tables[name] ?? []) {
106
+ const cols = Object.keys(row);
107
+ if (!cols.length) continue;
108
+ lines.push(`INSERT INTO ${quoteIdent(name)} (${cols.map(quoteIdent).join(", ")}) VALUES (${cols.map((c) => sqlLit(row[c])).join(", ")});`);
109
+ }
110
+ }
111
+ return lines.join("\n") + "\n";
112
+ }
113
+
114
+ export interface BookmarkStorage {
115
+ getBookmarkForTime?(timeMs: number): Promise<string>;
116
+ onNextSessionRestoreBookmark?(bookmark: string): Promise<unknown> | unknown;
117
+ }
118
+
119
+ /** Admin RPC helper. The storage runtime's method is called only when it exists. */
120
+ export async function bookmarkForTime(storage: BookmarkStorage, timeMs: number): Promise<{ bookmark: string }> {
121
+ if (typeof timeMs !== "number" || !Number.isFinite(timeMs)) throw new ExportError("bookmarkForTime: timeMs must be a finite number");
122
+ if (typeof storage.getBookmarkForTime !== "function") throw new ExportError("bookmarkForTime: point-in-time restore is not available on this storage");
123
+ const bookmark = await storage.getBookmarkForTime(timeMs);
124
+ if (typeof bookmark !== "string" || !bookmark) throw new ExportError("bookmarkForTime: storage returned no bookmark");
125
+ return { bookmark };
126
+ }
127
+
128
+ /**
129
+ * How long the RPC result is given to leave before `ctx.abort()`.
130
+ * The reset waits out this turn and a busy caller: aborting sooner drops the result.
131
+ */
132
+ export const RESTORE_RESTART_DELAY_MS = 1000;
133
+
134
+ /**
135
+ * Schedule `bookmark` for the next session and return `{ ok: true }`. Does not reset the object: `ctx.abort()` in
136
+ * this turn would drop the RPC. The Durable Object calls `restartAfterResult` after this returns. A storage error
137
+ * is thrown and the current database keeps running.
138
+ */
139
+ export async function restoreBookmark(storage: BookmarkStorage, bookmark: string): Promise<{ ok: true; undo?: string }> {
140
+ if (typeof bookmark !== "string" || bookmark.length < 1 || bookmark.length > 4096) throw new ExportError("restoreBookmark: bookmark is invalid");
141
+ if (typeof storage.onNextSessionRestoreBookmark !== "function") throw new ExportError("restoreBookmark: point-in-time restore is not available on this storage");
142
+ let raw: unknown;
143
+ try {
144
+ raw = await storage.onNextSessionRestoreBookmark(bookmark);
145
+ } catch (e) {
146
+ if (e instanceof ExportError) throw e;
147
+ throw new ExportError(`restoreBookmark: ${e instanceof Error ? e.message : String(e)}`);
148
+ }
149
+ const undo = typeof raw === "string" && raw.length > 0 ? raw : undefined;
150
+ return undo ? { ok: true, undo } : { ok: true };
151
+ }
152
+
153
+ /** Queue `restart` so the caller can return first. The timer is longer than this turn, so the RPC result is delivered. */
154
+ export function restartAfterResult(waitUntil: (p: Promise<unknown>) => void, restart: () => void, wait: (ms: number) => Promise<void>): void {
155
+ waitUntil(wait(RESTORE_RESTART_DELAY_MS).then(() => { restart(); }));
156
+ }
@@ -0,0 +1,403 @@
1
+ // Hosted entrypoint. Used only when the deploy sets TENANCY=host (see cloudflare.config.ts).
2
+ // With that set and a TENANT_DO binding, `<name>.<TENANT_DOMAIN>` is looked up in the KV directory and forwarded
3
+ // to that tenant's SQLite Durable Object. The object runs the same inbox as self-host (`dispatch` in index.ts)
4
+ // against its own database. Anything else — TENANCY unset, or no TENANT_DO binding — calls the self-host fetch
5
+ // handler on env.DB.
6
+ //
7
+ // Tenant config is a versioned row pushed into the object (`pushConfig`). It does not travel on the request.
8
+ // An object with no row answers 404 and does not create application tables.
9
+ //
10
+ // Hosted wake debounce and the cron's housekeeping run from this object's alarm. The Worker minute cron stays a no-op
11
+ // while TENANCY=host, so there is no shared database to flush. Self-host still uses its minute cron.
12
+ // The daily backup cron runs only when BACKUP_BUCKET is bound, and it returns before any flush.
13
+ // Usage push is not in this change. Approval OIDC, when configured (self-host settings, or the pushed tenant
14
+ // `approval` object), uses the same pages as self-host.
15
+ import { DurableObject } from "cloudflare:workers";
16
+ import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite } from "./index.ts";
17
+ import { doSqlD1, migrateDo, type SqlStorageLike, type TxRunner } from "./storage.ts";
18
+ import { MIGRATIONS } from "./migrations.ts";
19
+ import { bookmarkForTime as readBookmark, exportRows, restoreBookmark as applyBookmark, restartAfterResult, toSql, ExportError, type BookmarkStorage } from "./export.ts";
20
+ import { hostedDailyBackup } from "./backup.ts";
21
+ import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
22
+ import { hostedTenantContext, namespaceForRegion, parseGates, type WorkerBindings } from "./tenancy.ts";
23
+
24
+ export { clearDirectoryCache, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME } from "./directory.ts";
25
+ export type { DirectoryEntry };
26
+
27
+ const JSON_HDR = { "content-type": "application/json", "cache-control": "no-store" };
28
+ const notFound = () => Response.json({ error: "no such agent" }, { status: 404, headers: JSON_HDR });
29
+ const unavailable = () => Response.json({ error: "directory unavailable" }, { status: 503, headers: JSON_HDR });
30
+ const suspended = () => Response.json({ error: "this agent is suspended" }, { status: 403, headers: JSON_HDR });
31
+ const gone = () => Response.json({ error: "this agent is gone" }, { status: 410, headers: JSON_HDR });
32
+
33
+ const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
34
+
35
+ /** Versioned config pushed by the control plane. `secretsEnc` is stored and not decrypted here. */
36
+ export interface TenantConfigPush {
37
+ version: number;
38
+ tenantId: string;
39
+ name: string;
40
+ status: string;
41
+ config?: Record<string, unknown>;
42
+ limits?: Record<string, unknown>;
43
+ approval?: Record<string, unknown>;
44
+ ownerTokenHash?: string | null;
45
+ secretsEnc?: string | null;
46
+ }
47
+
48
+ interface StoredConfig {
49
+ version: number;
50
+ tenantId: string;
51
+ name: string;
52
+ status: string;
53
+ config: Record<string, string | undefined>;
54
+ limits: Record<string, unknown>;
55
+ approval: Record<string, unknown>;
56
+ ownerTokenHash: string | null;
57
+ secretsEnc: string | null;
58
+ }
59
+
60
+ const CREATE_CONFIG = `CREATE TABLE IF NOT EXISTS tenant_config (
61
+ id INTEGER PRIMARY KEY CHECK (id = 1),
62
+ version INTEGER NOT NULL,
63
+ tenant_id TEXT NOT NULL,
64
+ name TEXT NOT NULL,
65
+ status TEXT NOT NULL,
66
+ config_json TEXT NOT NULL,
67
+ limits_json TEXT NOT NULL,
68
+ approval_json TEXT NOT NULL,
69
+ owner_token_hash TEXT,
70
+ secrets_enc TEXT,
71
+ updated_at TEXT NOT NULL
72
+ )`;
73
+
74
+ function readRow(sql: SqlStorageLike): StoredConfig | null {
75
+ const exists = sql.exec("SELECT 1 AS n FROM sqlite_master WHERE type = 'table' AND name = 'tenant_config'").toArray();
76
+ if (!exists.length) return null;
77
+ const rows = sql.exec("SELECT version, tenant_id, name, status, config_json, limits_json, approval_json, owner_token_hash, secrets_enc FROM tenant_config WHERE id = 1").toArray();
78
+ const r = rows[0];
79
+ if (!r) return null;
80
+ return {
81
+ version: Number(r.version),
82
+ tenantId: String(r.tenant_id),
83
+ name: String(r.name),
84
+ status: String(r.status),
85
+ config: stringRecord(JSON.parse(String(r.config_json || "{}"))),
86
+ limits: objectRecord(JSON.parse(String(r.limits_json || "{}"))),
87
+ approval: objectRecord(JSON.parse(String(r.approval_json || "{}"))),
88
+ ownerTokenHash: r.owner_token_hash == null ? null : String(r.owner_token_hash),
89
+ secretsEnc: r.secrets_enc == null ? null : String(r.secrets_enc),
90
+ };
91
+ }
92
+
93
+ function stringRecord(v: unknown): Record<string, string | undefined> {
94
+ const out: Record<string, string | undefined> = {};
95
+ if (!v || typeof v !== "object" || Array.isArray(v)) return out;
96
+ for (const [k, val] of Object.entries(v)) if (typeof val === "string") out[k] = val;
97
+ return out;
98
+ }
99
+
100
+ function objectRecord(v: unknown): Record<string, unknown> {
101
+ if (!v || typeof v !== "object" || Array.isArray(v)) return {};
102
+ return v as Record<string, unknown>;
103
+ }
104
+
105
+ function requireObject(v: unknown, what: string): Record<string, unknown> {
106
+ if (v == null) return {};
107
+ if (typeof v !== "object" || Array.isArray(v)) throw new Error(`pushConfig: ${what} must be an object`);
108
+ return v as Record<string, unknown>;
109
+ }
110
+
111
+ function checkPush(body: TenantConfigPush): void {
112
+ if (!body || typeof body !== "object") throw new Error("pushConfig: body is required");
113
+ if (typeof body.version !== "number" || !Number.isSafeInteger(body.version) || body.version < 1) throw new Error("pushConfig: version must be an integer >= 1");
114
+ if (typeof body.tenantId !== "string" || !TENANT_ID.test(body.tenantId)) throw new Error("pushConfig: tenantId is invalid");
115
+ if (typeof body.name !== "string" || !TENANT_NAME.test(body.name)) throw new Error("pushConfig: name is invalid");
116
+ if (typeof body.status !== "string" || !/^[a-z]{1,32}$/.test(body.status)) throw new Error("pushConfig: status is invalid");
117
+ if (body.ownerTokenHash != null && body.ownerTokenHash !== "" && !/^[0-9a-fA-F]{64}$/.test(body.ownerTokenHash)) throw new Error("pushConfig: ownerTokenHash must be 64 hex characters");
118
+ if (body.secretsEnc != null && typeof body.secretsEnc !== "string") throw new Error("pushConfig: secretsEnc must be a string");
119
+ }
120
+
121
+ /** Both are required. TENANCY=host alone, or a binding without the flag, stays on env.DB. */
122
+ function hostedOn(env: WorkerBindings): env is WorkerBindings & { TENANT_DO: NonNullable<WorkerBindings["TENANT_DO"]> } {
123
+ return env.TENANCY === "host" && !!env.TENANT_DO;
124
+ }
125
+
126
+ function appTables(sql: SqlStorageLike): string[] {
127
+ return sql.exec("SELECT name FROM sqlite_master WHERE type = 'table'").toArray()
128
+ .map((r) => String(r.name))
129
+ .filter((n) => !n.startsWith("sqlite_") && !n.startsWith("_cf_"))
130
+ .sort();
131
+ }
132
+
133
+ export class TenantStore extends DurableObject<WorkerBindings> {
134
+ #sql: SqlStorageLike;
135
+ #tx: TxRunner;
136
+ #db: ReturnType<typeof doSqlD1>;
137
+ #row: StoredConfig | null = null;
138
+
139
+ constructor(ctx: DurableObjectState, env: WorkerBindings) {
140
+ super(ctx, env);
141
+ const storage = ctx.storage as unknown as { sql: SqlStorageLike; transactionSync: TxRunner };
142
+ this.#sql = storage.sql;
143
+ // Call through `storage` so `this` stays bound. An extracted method throws Illegal invocation.
144
+ this.#tx = (fn) => storage.transactionSync(fn);
145
+ this.#db = doSqlD1(this.#sql, this.#tx);
146
+ // Read only. Migrations run once a config row exists, so a scan that instantiates an object writes nothing.
147
+ ctx.blockConcurrencyWhile(async () => {
148
+ this.#row = readRow(this.#sql);
149
+ if (this.#row) migrateDo(this.#sql, this.#tx, MIGRATIONS);
150
+ if (this.#row?.status === "active") await this.#enqueueArm(false);
151
+ });
152
+ }
153
+
154
+ #tenant(fallbackOrigin = "") {
155
+ const row = this.#row;
156
+ if (!row) return null;
157
+ const domain = parseGates(this.env).gates.tenantDomain;
158
+ const publicUrl = domain ? `https://${row.name}.${domain}` : (fallbackOrigin || this.env.PUBLIC_URL || "");
159
+ return hostedTenantContext(this.env, {
160
+ id: row.tenantId, db: this.#db, publicUrl, ownerTokenHash: row.ownerTokenHash,
161
+ config: row.config, limits: row.limits, approval: row.approval, status: row.status,
162
+ });
163
+ }
164
+
165
+ #arming: Promise<void> = Promise.resolve();
166
+
167
+ /** Run alarm updates one at a time, so an earlier read cannot setAlarm over a retry row written later. */
168
+ #enqueueArm(afterRun: boolean): Promise<void> {
169
+ const run = this.#arming.then(() => this.#arm(afterRun), () => this.#arm(afterRun));
170
+ this.#arming = run.then(() => undefined, () => undefined);
171
+ return run;
172
+ }
173
+
174
+ #ectx(background?: Promise<unknown>[]): ExecutionContext {
175
+ const ectx = {
176
+ waitUntil: (p: Promise<unknown>) => {
177
+ const settled = Promise.resolve(p).then(() => undefined, () => undefined);
178
+ if (background) background.push(settled);
179
+ // Arm when the task finishes, not when it was scheduled. sendWake inserts the retry before it resolves.
180
+ this.ctx.waitUntil(settled.then(() => this.#enqueueArm(false)));
181
+ },
182
+ passThroughOnException() {},
183
+ } as ExecutionContext;
184
+ // requeueWake calls this in the same turn as the insert, including after the inbox response has returned.
185
+ onWakeWrite(ectx, () => this.#enqueueArm(false));
186
+ return ectx;
187
+ }
188
+
189
+ /** One alarm per object. `afterRun` waits a second when work is still due, so a full batch cannot spin. */
190
+ async #arm(afterRun: boolean): Promise<void> {
191
+ const storage = this.ctx.storage as unknown as {
192
+ setAlarm(scheduledTime: number): Promise<void>;
193
+ deleteAlarm(): Promise<void>;
194
+ };
195
+ const row = this.#row;
196
+ try {
197
+ if (!row || row.status !== "active") {
198
+ await storage.deleteAlarm();
199
+ return;
200
+ }
201
+ const tenant = this.#tenant();
202
+ if (!tenant) return;
203
+ const at = await nextCronAt(tenant);
204
+ if (at == null || !Number.isFinite(at)) {
205
+ await storage.deleteAlarm();
206
+ return;
207
+ }
208
+ const now = Date.now();
209
+ const when = afterRun && at <= now ? now + 1000 : Math.max(at, now);
210
+ await storage.setAlarm(when);
211
+ } catch (e) {
212
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "alarm_arm_failed", error: String(e).slice(0, 200) }));
213
+ }
214
+ }
215
+
216
+ /** Same flush and deletes as the self-host minute cron, for this tenant only. */
217
+ async alarm(): Promise<void> {
218
+ await this.#runAlarm();
219
+ }
220
+
221
+ /**
222
+ * Run the alarm body now. `alarm` itself is reserved and cannot be called over RPC, so tests use this.
223
+ */
224
+ async runAlarm(): Promise<void> {
225
+ await this.#runAlarm();
226
+ }
227
+
228
+ async #runAlarm(): Promise<void> {
229
+ const tenant = this.#row?.status === "active" ? this.#tenant() : null;
230
+ if (tenant) {
231
+ try { await cronFlush(tenant, this.#ectx()); }
232
+ catch (e) { console.log(JSON.stringify({ ts: new Date().toISOString(), event: "alarm_failed", error: String(e).slice(0, 200) })); }
233
+ }
234
+ await this.#enqueueArm(true);
235
+ }
236
+
237
+ /** When the alarm is set to run, or null. No secrets. */
238
+ async alarmAt(): Promise<number | null> {
239
+ const storage = this.ctx.storage as unknown as { getAlarm(): Promise<number | null> };
240
+ try {
241
+ const at = await storage.getAlarm();
242
+ return typeof at === "number" && Number.isFinite(at) ? at : null;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Control-plane push. A version less than or equal to the stored one is ignored, so a reordered or repeated
250
+ * push does not roll the row backwards. The first accepted push creates `tenant_config` and then runs migrations.
251
+ */
252
+ async pushConfig(body: TenantConfigPush): Promise<{ version: number; applied: boolean }> {
253
+ return await this.ctx.blockConcurrencyWhile(async () => {
254
+ checkPush(body);
255
+ const current = this.#row ?? readRow(this.#sql);
256
+ if (current && body.version <= current.version) {
257
+ // A previous push may have stored this version and then failed while migrating. Catch up; don't rewrite the row.
258
+ migrateDo(this.#sql, this.#tx, MIGRATIONS);
259
+ this.#row = current;
260
+ await this.#enqueueArm(false);
261
+ return { version: current.version, applied: false };
262
+ }
263
+ const config = requireObject(body.config, "config");
264
+ const limits = requireObject(body.limits, "limits");
265
+ const approval = requireObject(body.approval, "approval");
266
+ const next: StoredConfig = {
267
+ version: body.version,
268
+ tenantId: body.tenantId,
269
+ name: body.name,
270
+ status: body.status,
271
+ config: stringRecord(config),
272
+ limits,
273
+ approval,
274
+ ownerTokenHash: body.ownerTokenHash ? body.ownerTokenHash.toLowerCase() : null,
275
+ secretsEnc: body.secretsEnc ?? null,
276
+ };
277
+ const now = new Date().toISOString();
278
+ this.#tx(() => {
279
+ this.#sql.exec(CREATE_CONFIG);
280
+ this.#sql.exec(
281
+ `INSERT INTO tenant_config (id, version, tenant_id, name, status, config_json, limits_json, approval_json, owner_token_hash, secrets_enc, updated_at)
282
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
283
+ ON CONFLICT(id) DO UPDATE SET
284
+ version = excluded.version, tenant_id = excluded.tenant_id, name = excluded.name, status = excluded.status,
285
+ config_json = excluded.config_json, limits_json = excluded.limits_json, approval_json = excluded.approval_json,
286
+ owner_token_hash = excluded.owner_token_hash, secrets_enc = excluded.secrets_enc, updated_at = excluded.updated_at`,
287
+ next.version, next.tenantId, next.name, next.status,
288
+ JSON.stringify(config), JSON.stringify(limits), JSON.stringify(approval),
289
+ next.ownerTokenHash, next.secretsEnc, now,
290
+ );
291
+ });
292
+ try {
293
+ migrateDo(this.#sql, this.#tx, MIGRATIONS);
294
+ } finally {
295
+ // Remember the row even if a migration throws, so a later request is not stuck on the empty-object 404.
296
+ this.#row = readRow(this.#sql) ?? next;
297
+ }
298
+ await this.#enqueueArm(false);
299
+ return { version: this.#row.version, applied: true };
300
+ });
301
+ }
302
+
303
+ /** No secrets. Tests use this to show a 404 did not create application tables. */
304
+ async storageStatus(): Promise<{ configured: boolean; version: number; tables: string[] }> {
305
+ return { configured: this.#row !== null, version: this.#row?.version ?? 0, tables: appTables(this.#sql) };
306
+ }
307
+
308
+ /** SQL dump of the application tables. Refuses an object that has no config row. */
309
+ async exportSql(): Promise<{ sql: string; exportedAt: string }> {
310
+ return await this.ctx.blockConcurrencyWhile(async () => {
311
+ if (!this.#row) throw new ExportError("exportSql: tenant is not configured");
312
+ const file = await exportRows(this.#db);
313
+ return { sql: toSql(file), exportedAt: file.exportedAt };
314
+ });
315
+ }
316
+
317
+ /** Point-in-time bookmark for this object only. The storage method is required; otherwise this throws. */
318
+ async bookmarkForTime(timeMs: number): Promise<{ bookmark: string }> {
319
+ if (!this.#row) throw new ExportError("bookmarkForTime: tenant is not configured");
320
+ return readBookmark(this.ctx.storage as BookmarkStorage, timeMs);
321
+ }
322
+
323
+ /**
324
+ * Schedule `bookmark` and return `{ ok: true }`. The reset is queued for a later turn: `ctx.abort()` here would
325
+ * fail the RPC before the caller saw that result. An in-flight alarm was for the pre-restore database, so it is not retried.
326
+ */
327
+ async restoreBookmark(bookmark: string): Promise<{ ok: true; undo?: string }> {
328
+ if (!this.#row) throw new ExportError("restoreBookmark: tenant is not configured");
329
+ const tenantId = this.#row.tenantId;
330
+ const out = await applyBookmark(this.ctx.storage as BookmarkStorage, bookmark);
331
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "bookmark_restore", tenant: tenantId, undo: out.undo ?? null }));
332
+ restartAfterResult(
333
+ (p) => this.ctx.waitUntil(p),
334
+ () => this.ctx.abort("restore bookmark", { retryAlarm: false }),
335
+ (ms) => scheduler.wait(ms),
336
+ );
337
+ return out;
338
+ }
339
+
340
+ async fetch(req: Request): Promise<Response> {
341
+ const row = this.#row;
342
+ if (!row) return notFound();
343
+ if (row.status !== "active") {
344
+ await this.#enqueueArm(false);
345
+ if (row.status === "suspended") return suspended();
346
+ if (row.status === "deleting") return gone();
347
+ return notFound();
348
+ }
349
+ const tenant = this.#tenant(new URL(req.url).origin);
350
+ if (!tenant) return notFound();
351
+ // Do not arm from the rows visible here. sendWake runs in waitUntil and, on 429/503, inserts the retry
352
+ // after this response returns. requeueWake arms in that insert. Arm again when those tasks finish so a
353
+ // quiet tenant is on Retry-After, not the rate-row timer from an earlier read.
354
+ const background: Promise<unknown>[] = [];
355
+ const res = await dispatch(req, tenant, this.#ectx(background));
356
+ if (background.length) {
357
+ const pending = background.slice();
358
+ this.ctx.waitUntil(Promise.allSettled(pending).then(() => this.#enqueueArm(false)));
359
+ }
360
+ return res;
361
+ }
362
+ }
363
+
364
+ function routeStatus(entry: DirectoryEntry): Response | null {
365
+ if (entry.status === "deleted") return notFound();
366
+ if (entry.status === "suspended") return suspended();
367
+ if (entry.status === "deleting") return gone();
368
+ if (entry.status !== "active") return notFound();
369
+ return null;
370
+ }
371
+
372
+ export default {
373
+ async fetch(req: Request<unknown, IncomingRequestCfProperties>, env: WorkerBindings, ectx: ExecutionContext): Promise<Response> {
374
+ if (!hostedOn(env)) return worker.fetch(req, env, ectx);
375
+ const domain = parseGates(env).gates.tenantDomain;
376
+ const name = tenantNameFromHost(new URL(req.url).hostname, domain);
377
+ if (!name || !env.TENANT_DIRECTORY) return notFound();
378
+ const entry = await lookupDirectory(env.TENANT_DIRECTORY, name);
379
+ if (!entry) return notFound();
380
+ // Pin once, including for a suspended or deleted row, so a later DATA_REGION change cannot move the object.
381
+ const pinned = await pinDirectoryRegion(env.TENANT_DIRECTORY, name, entry, parseGates(env).gates.dataRegion);
382
+ if (!pinned) return unavailable();
383
+ const early = routeStatus(pinned);
384
+ if (early) return early;
385
+ const region = tenantRegion(pinned);
386
+ const ns = namespaceForRegion(env.TENANT_DO, region);
387
+ // The spike carried the tenant row on this header. Drop it so a client cannot supply config.
388
+ const headers = new Headers(req.headers);
389
+ headers.delete("x-a2a-tenant");
390
+ return ns.get(ns.idFromName(entry.id)).fetch(new Request(req, { headers }));
391
+ },
392
+ // Self-host cron flushes env.DB. Hosted has no single database; each tenant's alarm runs that flush.
393
+ // The daily backup cron (only present when BACKUP_BUCKET is bound) exports each active tenant and returns.
394
+ async scheduled(controller: ScheduledController, env: WorkerBindings, ectx: ExecutionContext): Promise<void> {
395
+ const backup = await hostedDailyBackup(controller, env);
396
+ if (backup) {
397
+ for (const f of backup.failed) console.log(JSON.stringify({ ts: new Date().toISOString(), event: "backup_failed", tenant: f.tenantId, error: f.error }));
398
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "backup_done", written: backup.written.length, skipped: backup.skipped.length, failed: backup.failed.length }));
399
+ return;
400
+ }
401
+ if (!hostedOn(env)) await worker.scheduled(controller, env, ectx);
402
+ },
403
+ };