a2a-exposed 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/lib/a2a.mjs +6 -2
- package/lib/commands.mjs +62 -12
- package/lib/deploy.mjs +42 -19
- package/lib/main.mjs +32 -11
- 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 +51 -7
- package/worker/deploy.env.example +28 -1
- package/worker/migrations/0006_mcp.sql +43 -0
- package/worker/migrations/0007_cimd.sql +10 -0
- 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 +1327 -446
- package/worker/src/mcp.ts +336 -0
- package/worker/src/migrations.ts +22 -0
- package/worker/src/pairing.ts +18 -5
- 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 +14 -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/mcp.test.ts +697 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +20 -7
- 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 +74 -0
- package/worker/test/wake.test.ts +18 -0
package/worker/test/d1.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
// Minimal D1 stand-in over node:sqlite (in-memory), with the repo's migrations applied: lets tests drive the real
|
|
2
2
|
// Worker fetch handler. Covers what the Worker uses: prepare().bind().first()/all()/run(), RETURNING, and batch().
|
|
3
|
+
//
|
|
4
|
+
// STORAGE_BACKEND=d1|do runs the same calls against workerd (Miniflare): d1 is a real D1 binding, do is a SQLite
|
|
5
|
+
// Durable Object behind src/storage.ts. The Worker code stays in this process; only storage is remote. Calls are
|
|
6
|
+
// synchronous via a worker thread and Atomics, so `s.DB.db.prepare(...).get()` keeps working. Unset (the default,
|
|
7
|
+
// and what migrations.test.ts's d1On and the CLI tests use) stays on node:sqlite.
|
|
3
8
|
import { DatabaseSync } from "node:sqlite";
|
|
4
9
|
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { Worker, MessageChannel, receiveMessageOnPort } from "node:worker_threads";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const BACKEND = process.env.STORAGE_BACKEND || "node";
|
|
5
16
|
|
|
6
17
|
export function d1(migrationsDir: URL) {
|
|
18
|
+
if (BACKEND !== "node") return remote(migrationsDir);
|
|
7
19
|
const db = new DatabaseSync(":memory:");
|
|
8
20
|
for (const f of fs.readdirSync(migrationsDir).filter((n) => n.endsWith(".sql")).sort())
|
|
9
21
|
db.exec(fs.readFileSync(new URL(f, migrationsDir), "utf8"));
|
|
@@ -13,6 +25,8 @@ export function d1(migrationsDir: URL) {
|
|
|
13
25
|
/** The same D1 stand-in over an existing node:sqlite database (e.g. one whose schema a test built step by step). */
|
|
14
26
|
export function d1On(db: DatabaseSync) {
|
|
15
27
|
const stmt = (sql: string, args: unknown[] = []): any => ({
|
|
28
|
+
__sql: sql,
|
|
29
|
+
__args: args,
|
|
16
30
|
bind: (...a: unknown[]) => stmt(sql, a),
|
|
17
31
|
first: async (col?: string) => {
|
|
18
32
|
const r: any = db.prepare(sql).get(...(args as any[]));
|
|
@@ -29,10 +43,140 @@ export function d1On(db: DatabaseSync) {
|
|
|
29
43
|
return {
|
|
30
44
|
db,
|
|
31
45
|
prepare: (sql: string) => stmt(sql),
|
|
46
|
+
// One transaction, matching D1: a batch rolls back together, and SELECT results are returned.
|
|
47
|
+
// Statements are run from their SQL so a caller that only implements batch (no per-table all()) still works.
|
|
32
48
|
batch: async (stmts: any[]) => {
|
|
33
49
|
db.exec("BEGIN");
|
|
34
|
-
try {
|
|
35
|
-
|
|
50
|
+
try {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const s of stmts) {
|
|
53
|
+
const sql = s.__sql as string | undefined;
|
|
54
|
+
const args = (s.__args ?? []) as any[];
|
|
55
|
+
if (!sql) { out.push(await s.run()); continue; }
|
|
56
|
+
if (/^\s*(select|pragma|with)\b/i.test(sql)) {
|
|
57
|
+
const rows = db.prepare(sql).all(...args).map((r: any) => ({ ...r }));
|
|
58
|
+
out.push({ results: rows, meta: { changes: 0 } });
|
|
59
|
+
} else if (/\breturning\b/i.test(sql)) {
|
|
60
|
+
const rows = db.prepare(sql).all(...args).map((r: any) => ({ ...r }));
|
|
61
|
+
out.push({ results: rows, meta: { changes: rows.length } });
|
|
62
|
+
} else {
|
|
63
|
+
const r = db.prepare(sql).run(...args);
|
|
64
|
+
out.push({ results: [], meta: { changes: Number(r.changes) } });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
db.exec("COMMIT");
|
|
68
|
+
return out;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
try { db.exec("ROLLBACK"); } catch { /* the statement failed before BEGIN committed */ }
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
36
73
|
},
|
|
37
74
|
};
|
|
38
75
|
}
|
|
76
|
+
|
|
77
|
+
const POOL = 8;
|
|
78
|
+
let bridge: { port: import("node:worker_threads").MessagePort; flag: Int32Array } | null = null;
|
|
79
|
+
let next = 0;
|
|
80
|
+
export const remoteStats = { calls: 0, ms: 0 };
|
|
81
|
+
|
|
82
|
+
async function bundleHarness(): Promise<string> {
|
|
83
|
+
const require = createRequire(new URL("../package.json", import.meta.url));
|
|
84
|
+
const { build } = require("esbuild") as typeof import("esbuild");
|
|
85
|
+
const built = await build({
|
|
86
|
+
entryPoints: [fileURLToPath(new URL("./harness-worker.ts", import.meta.url))],
|
|
87
|
+
bundle: true,
|
|
88
|
+
format: "esm",
|
|
89
|
+
write: false,
|
|
90
|
+
platform: "neutral",
|
|
91
|
+
external: ["cloudflare:*"],
|
|
92
|
+
target: "es2022",
|
|
93
|
+
logLevel: "silent",
|
|
94
|
+
plugins: [{
|
|
95
|
+
name: "sql-raw",
|
|
96
|
+
setup(b) {
|
|
97
|
+
b.onResolve({ filter: /\.sql\?raw$/ }, (args) => ({
|
|
98
|
+
path: path.resolve(args.resolveDir, args.path.replace(/\?raw$/, "")),
|
|
99
|
+
namespace: "sql-raw",
|
|
100
|
+
}));
|
|
101
|
+
b.onLoad({ filter: /.*/, namespace: "sql-raw" }, (args) => ({
|
|
102
|
+
contents: `export default ${JSON.stringify(fs.readFileSync(args.path, "utf8"))};`,
|
|
103
|
+
loader: "js",
|
|
104
|
+
}));
|
|
105
|
+
},
|
|
106
|
+
}],
|
|
107
|
+
});
|
|
108
|
+
const file = built.outputFiles?.[0];
|
|
109
|
+
if (!file) throw new Error("storage harness bundle was empty");
|
|
110
|
+
return file.text;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let harnessScript: string | null = null;
|
|
114
|
+
if (BACKEND !== "node") harnessScript = await bundleHarness();
|
|
115
|
+
|
|
116
|
+
function bridgeCall(reqPath: string, body: unknown): any {
|
|
117
|
+
if (!bridge) {
|
|
118
|
+
const script = harnessScript;
|
|
119
|
+
if (!script) throw new Error("storage harness was not bundled");
|
|
120
|
+
const { port1, port2 } = new MessageChannel();
|
|
121
|
+
const flag = new Int32Array(new SharedArrayBuffer(4));
|
|
122
|
+
const w = new Worker(new URL("./bridge-thread.mjs", import.meta.url), {
|
|
123
|
+
workerData: { port: port2, flag: flag.buffer, script, pool: POOL },
|
|
124
|
+
transferList: [port2],
|
|
125
|
+
});
|
|
126
|
+
w.on("message", (m) => { if (m?.error) console.error(m.error); });
|
|
127
|
+
bridge = { port: port1, flag };
|
|
128
|
+
if (Atomics.wait(flag, 0, 0, 120_000) === "timed-out") throw new Error("storage bridge did not start");
|
|
129
|
+
const ready = receiveMessageOnPort(port1)?.message;
|
|
130
|
+
if (ready && ready.ready === false) throw new Error(ready.error || "storage bridge did not start");
|
|
131
|
+
// Listening for `message` refs the worker. Drop that ref once Miniflare is up so the test
|
|
132
|
+
// process can exit; workerd stays up for later calls and dies with the process.
|
|
133
|
+
w.unref();
|
|
134
|
+
port1.unref();
|
|
135
|
+
}
|
|
136
|
+
const t0 = performance.now();
|
|
137
|
+
Atomics.store(bridge.flag, 0, 0);
|
|
138
|
+
bridge.port.postMessage({ path: reqPath, body });
|
|
139
|
+
if (Atomics.wait(bridge.flag, 0, 0, 30_000) === "timed-out") throw new Error("storage bridge timeout");
|
|
140
|
+
const m = receiveMessageOnPort(bridge.port)!.message;
|
|
141
|
+
remoteStats.calls++;
|
|
142
|
+
remoteStats.ms += performance.now() - t0;
|
|
143
|
+
if (!m.ok) throw new Error(m.error);
|
|
144
|
+
return m.value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function remote(migrationsDir: URL) {
|
|
148
|
+
let reqPath: string;
|
|
149
|
+
if (BACKEND === "d1") {
|
|
150
|
+
reqPath = `/DB${next++ % POOL}`;
|
|
151
|
+
const files = fs.readdirSync(migrationsDir).filter((n) => n.endsWith(".sql")).sort()
|
|
152
|
+
.map((name) => ({ name, sql: fs.readFileSync(new URL(name, migrationsDir), "utf8") }));
|
|
153
|
+
bridgeCall(reqPath, { op: "reset", files });
|
|
154
|
+
} else if (BACKEND === "do") {
|
|
155
|
+
reqPath = `/do/t-${process.pid}-${next++}-${Math.random().toString(36).slice(2)}`;
|
|
156
|
+
} else throw new Error("STORAGE_BACKEND must be node, d1 or do");
|
|
157
|
+
const call = (body: unknown) => bridgeCall(reqPath, body);
|
|
158
|
+
const stmt = (sql: string, args: unknown[] = []): any => ({
|
|
159
|
+
__sql: sql, __args: args,
|
|
160
|
+
bind: (...a: unknown[]) => stmt(sql, a),
|
|
161
|
+
first: async (col?: string) => call({ op: "first", sql, args, col }),
|
|
162
|
+
all: async () => call({ op: "all", sql, args }),
|
|
163
|
+
run: async () => call({ op: "run", sql, args }),
|
|
164
|
+
raw: async () => call({ op: "raw", sql, args }),
|
|
165
|
+
});
|
|
166
|
+
const db = {
|
|
167
|
+
prepare: (sql: string) => ({
|
|
168
|
+
get: (...args: unknown[]) => call({ op: "first", sql, args }) ?? undefined,
|
|
169
|
+
all: (...args: unknown[]) => call({ op: "all", sql, args }).results,
|
|
170
|
+
run: (...args: unknown[]) => { const r = call({ op: "run", sql, args }); return { changes: r.meta.changes }; },
|
|
171
|
+
}),
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
db, path: reqPath,
|
|
175
|
+
prepare: (sql: string) => stmt(sql),
|
|
176
|
+
batch: async (stmts: any[]) => call({ op: "batch", stmts: stmts.map((s) => ({ sql: s.__sql, args: s.__args })) }),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (BACKEND !== "node") process.on("exit", () => {
|
|
181
|
+
if (remoteStats.calls) process.stdout.write(`# storage[${BACKEND}] ${remoteStats.calls} ops, avg ${(remoteStats.ms / remoteStats.calls).toFixed(3)} ms/op (incl. thread+HTTP bridge)\n`);
|
|
182
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Name directory: host parsing and the 60s isolate cache. Run: npm test
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { clearDirectoryCache, DIRECTORY_MAX, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, TENANT_NAME, tenantNameFromHost, tenantRegion } from "../src/directory.ts";
|
|
5
|
+
|
|
6
|
+
test("tenant names are 4–32 characters and one label under the domain", () => {
|
|
7
|
+
assert.equal(TENANT_NAME.test("abcd"), true);
|
|
8
|
+
assert.equal(TENANT_NAME.test("abc"), false);
|
|
9
|
+
assert.equal(TENANT_NAME.test("a".repeat(32)), true);
|
|
10
|
+
assert.equal(TENANT_NAME.test("a".repeat(33)), false);
|
|
11
|
+
assert.equal(TENANT_NAME.test("-abcd"), false);
|
|
12
|
+
assert.equal(TENANT_NAME.test("abcd-"), false);
|
|
13
|
+
assert.equal(tenantNameFromHost("abcd.example.com", "example.com"), "abcd");
|
|
14
|
+
assert.equal(tenantNameFromHost("ABCD.Example.COM", "example.com"), "abcd");
|
|
15
|
+
assert.equal(tenantNameFromHost("example.com", "example.com"), null);
|
|
16
|
+
assert.equal(tenantNameFromHost("a.b.example.com", "example.com"), null);
|
|
17
|
+
assert.equal(tenantNameFromHost("abcd.example.com.evil.example", "example.com"), null);
|
|
18
|
+
assert.equal(tenantNameFromHost("abcd.example.org", "example.com"), null);
|
|
19
|
+
assert.equal(tenantNameFromHost("abcd.example.com", ""), null);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("directory entries need an id and a status; only a stored eu or fedramp selects a jurisdiction", () => {
|
|
23
|
+
assert.equal(parseDirectoryEntry(null), null);
|
|
24
|
+
assert.equal(parseDirectoryEntry({ id: "../x", status: "active" }), null);
|
|
25
|
+
assert.equal(parseDirectoryEntry({ id: "t1", status: "Active" }), null);
|
|
26
|
+
const ok = parseDirectoryEntry({ id: "t1", status: "active", region: "eu", version: 3 });
|
|
27
|
+
assert.deepEqual(ok, { id: "t1", status: "active", region: "eu", version: 3 });
|
|
28
|
+
assert.equal(tenantRegion(ok!), "eu");
|
|
29
|
+
assert.equal(tenantRegion({ id: "t1", status: "active", region: "default", version: 1 }), "");
|
|
30
|
+
assert.equal(tenantRegion({ id: "t1", status: "active", region: "", version: 1 }), "");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("an empty region is pinned once; a later DATA_REGION does not move the tenant", async () => {
|
|
34
|
+
clearDirectoryCache();
|
|
35
|
+
const store = new Map<string, string>();
|
|
36
|
+
const kv = {
|
|
37
|
+
async put(key: string, value: string) { store.set(key, value); },
|
|
38
|
+
};
|
|
39
|
+
const entry = { id: "t1", status: "active", region: "", version: 1 };
|
|
40
|
+
const pinned = await pinDirectoryRegion(kv, "abcd", entry, "fedramp", 1_000);
|
|
41
|
+
assert.equal(pinned?.region, "fedramp");
|
|
42
|
+
assert.equal(JSON.parse(store.get(directoryKey("abcd"))!).region, "fedramp");
|
|
43
|
+
const again = await pinDirectoryRegion(kv, "abcd", pinned!, "eu", 2_000);
|
|
44
|
+
assert.equal(again?.region, "fedramp");
|
|
45
|
+
assert.equal(tenantRegion(again!), "fedramp");
|
|
46
|
+
const fresh = { id: "t2", status: "active", region: "", version: 1 };
|
|
47
|
+
const fallback = await pinDirectoryRegion(kv, "efgh", fresh, "", 3_000);
|
|
48
|
+
assert.equal(fallback?.region, "default");
|
|
49
|
+
assert.equal(tenantRegion(fallback!), "");
|
|
50
|
+
const failed = await pinDirectoryRegion({ async put() { throw new Error("kv down"); } }, "ijkl", fresh, "eu", 4_000);
|
|
51
|
+
assert.equal(failed, null);
|
|
52
|
+
clearDirectoryCache();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("lookups are cached for 60s, including unknown names", async () => {
|
|
56
|
+
clearDirectoryCache();
|
|
57
|
+
const store = new Map<string, string>();
|
|
58
|
+
const kv = {
|
|
59
|
+
async get(key: string, type?: string) {
|
|
60
|
+
const v = store.get(key);
|
|
61
|
+
if (v == null) return null;
|
|
62
|
+
return type === "json" ? JSON.parse(v) : v;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
store.set(directoryKey("abcd"), JSON.stringify({ id: "t1", status: "active", region: "", version: 1 }));
|
|
66
|
+
assert.equal((await lookupDirectory(kv, "abcd", 1_000))?.id, "t1");
|
|
67
|
+
store.set(directoryKey("abcd"), JSON.stringify({ id: "t2", status: "suspended", region: "eu", version: 2 }));
|
|
68
|
+
assert.equal((await lookupDirectory(kv, "abcd", 1_000 + 59_000))?.id, "t1", "still the cached row");
|
|
69
|
+
assert.equal((await lookupDirectory(kv, "abcd", 1_000 + 60_000))?.status, "suspended");
|
|
70
|
+
assert.equal(await lookupDirectory(kv, "nope", 0), null);
|
|
71
|
+
store.set(directoryKey("nope"), JSON.stringify({ id: "t9", status: "active", region: "", version: 1 }));
|
|
72
|
+
assert.equal(await lookupDirectory(kv, "nope", 30_000), null, "a miss stays cached");
|
|
73
|
+
assert.equal((await lookupDirectory(kv, "nope", 60_000))?.id, "t9");
|
|
74
|
+
clearDirectoryCache();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the directory cache evicts the oldest name once it is full", async () => {
|
|
78
|
+
clearDirectoryCache();
|
|
79
|
+
let gets = 0;
|
|
80
|
+
const kv = { async get() { gets++; return null; } };
|
|
81
|
+
for (let i = 0; i < DIRECTORY_MAX; i++) await lookupDirectory(kv, "k" + i, 1);
|
|
82
|
+
assert.equal(gets, DIRECTORY_MAX);
|
|
83
|
+
await lookupDirectory(kv, "overflow", 1);
|
|
84
|
+
assert.equal(gets, DIRECTORY_MAX + 1);
|
|
85
|
+
await lookupDirectory(kv, "k0", 1);
|
|
86
|
+
assert.equal(gets, DIRECTORY_MAX + 2, "the oldest miss was evicted");
|
|
87
|
+
await lookupDirectory(kv, "overflow", 1);
|
|
88
|
+
assert.equal(gets, DIRECTORY_MAX + 2, "a name just stored is still cached");
|
|
89
|
+
clearDirectoryCache();
|
|
90
|
+
});
|