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.
Files changed (47) hide show
  1. package/README.md +3 -4
  2. package/lib/a2a.mjs +6 -2
  3. package/lib/commands.mjs +62 -12
  4. package/lib/deploy.mjs +42 -19
  5. package/lib/main.mjs +32 -11
  6. package/lib/pair.mjs +47 -3
  7. package/lib/status.mjs +4 -4
  8. package/lib/tunnel.mjs +11 -11
  9. package/lib/update.mjs +1 -1
  10. package/package.json +1 -1
  11. package/worker/cloudflare.config.ts +51 -7
  12. package/worker/deploy.env.example +28 -1
  13. package/worker/migrations/0006_mcp.sql +43 -0
  14. package/worker/migrations/0007_cimd.sql +10 -0
  15. package/worker/migrations/0008_oidc_txns.sql +12 -0
  16. package/worker/package.json +6 -2
  17. package/worker/src/approval-oidc.ts +247 -0
  18. package/worker/src/backup.ts +186 -0
  19. package/worker/src/directory.ts +120 -0
  20. package/worker/src/export.ts +156 -0
  21. package/worker/src/hosted.ts +403 -0
  22. package/worker/src/index.ts +1327 -446
  23. package/worker/src/mcp.ts +336 -0
  24. package/worker/src/migrations.ts +22 -0
  25. package/worker/src/pairing.ts +18 -5
  26. package/worker/src/sql-raw.d.ts +5 -0
  27. package/worker/src/storage.ts +100 -0
  28. package/worker/src/tenancy.ts +450 -0
  29. package/worker/src/wake.ts +14 -2
  30. package/worker/test/alarms.test.ts +231 -0
  31. package/worker/test/approval-oidc.test.ts +331 -0
  32. package/worker/test/bridge-thread.mjs +40 -0
  33. package/worker/test/d1.ts +146 -2
  34. package/worker/test/directory.test.ts +90 -0
  35. package/worker/test/export.test.ts +544 -0
  36. package/worker/test/facade.test.ts +1 -1
  37. package/worker/test/harness-worker.ts +80 -0
  38. package/worker/test/hosted.test.ts +221 -0
  39. package/worker/test/mcp.test.ts +697 -0
  40. package/worker/test/migrations.test.ts +5 -5
  41. package/worker/test/pairing.test.ts +20 -7
  42. package/worker/test/register-hooks.mjs +4 -0
  43. package/worker/test/sql-raw-hook.mjs +22 -0
  44. package/worker/test/storage.test.ts +74 -0
  45. package/worker/test/tenancy.test.ts +217 -0
  46. package/worker/test/wake-retry.test.ts +74 -0
  47. package/worker/test/wake.test.ts +18 -0
package/lib/status.mjs CHANGED
@@ -3,12 +3,11 @@
3
3
  // itself, so agents need no curl (some agent sandboxes flag *.dev URLs in shell commands).
4
4
  import fs from "node:fs";
5
5
  import * as C from "./config.mjs";
6
- import { cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
6
+ import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
7
7
  import { baseUrl, owner, ownerTry } from "./commands.mjs";
8
8
  import * as D from "./deploy.mjs";
9
9
  import * as T from "./tunnel.mjs";
10
10
 
11
- const CLI = "a2a-exposed";
12
11
  /** Presets whose webhook usually listens only locally: wakes need the tunnel (or another reverse proxy). */
13
12
  export const LOCAL_PRESETS = ["openclaw-wake", "openclaw-agent", "hermes"];
14
13
 
@@ -202,7 +201,8 @@ export async function collect() {
202
201
  const pr = await owner("GET", "/owner/pairing");
203
202
  if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
204
203
  s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
205
- setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length };
204
+ setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length,
205
+ ...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}) };
206
206
  } catch { /* older Worker (no pairing yet): leave unknown */ }
207
207
  if (s.upstream) {
208
208
  try { s.facade = await owner("GET", "/owner/facade"); }
@@ -303,7 +303,7 @@ export async function status(o) {
303
303
  if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
304
304
  const pg = s.pairing;
305
305
  if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
306
- : `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
306
+ : `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.oidcIssuer ? `; OpenID Connect (${pg.oidcIssuer})` : ""}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
307
307
  }
308
308
  rows.push(["next step", next.text]);
309
309
  for (const a of next.also || []) rows.push(["also", a]);
package/lib/tunnel.mjs CHANGED
@@ -10,7 +10,7 @@ import path from "node:path";
10
10
  import crypto from "node:crypto";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import * as C from "./config.mjs";
13
- import { CliError, die, fingerprint, httpJson } from "./a2a.mjs";
13
+ import { CLI, CliError, die, fingerprint, httpJson } from "./a2a.mjs";
14
14
  import { owner } from "./commands.mjs";
15
15
  import * as D from "./deploy.mjs";
16
16
 
@@ -63,7 +63,7 @@ export function noZoneHelp(zones) {
63
63
  : "this Cloudflare account has no domain (zone).",
64
64
  "The wake tunnel needs a zone anywhere on the account for its hostname and Access app (whether the inbox is on workers.dev or a custom hostname).",
65
65
  "Without one, use scheduled polling: the inbox works as it is (setup skill: \"Agents without inbound webhooks: polling\").",
66
- "To use the tunnel later, add a domain to the account and run `a2a-exposed tunnel create` (no redeploy needed).",
66
+ `To use the tunnel later, add a domain to the account and run \`${CLI} tunnel create\` (no redeploy needed).`,
67
67
  ].join("\n");
68
68
  }
69
69
 
@@ -215,9 +215,9 @@ export function listZones(dir) {
215
215
 
216
216
  // ------------------------------------------------------------------ commands
217
217
  function needDeployment(o) {
218
- if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die("no saved deployment; run `a2a-exposed init` first");
218
+ if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`no saved deployment; run \`${CLI} init\` first`);
219
219
  const dir = D.workerDir(o);
220
- if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`a2a-exposed deploy\` once`);
220
+ if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
221
221
  return dir;
222
222
  }
223
223
 
@@ -293,12 +293,12 @@ export async function create(o) {
293
293
  console.log(url);
294
294
  console.error(`\ntunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
295
295
  console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
296
- console.error(`\nThen (Worker secrets apply within ~15 s): a2a-exposed tunnel status and a2a-exposed wake test`);
296
+ console.error(`\nThen (Worker secrets apply within ~15 s): ${CLI} tunnel status and ${CLI} wake test`);
297
297
  if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
298
298
  console.error(`note: no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET in the environment; keep the ${preset} webhook's own auth too (export it and run \`wake set\`)`);
299
299
  } catch (e) {
300
300
  console.error(`error: ${e.message}\n==> rolling back what was created`);
301
- await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`a2a-exposed tunnel rm\``));
301
+ await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
302
302
  throw e;
303
303
  }
304
304
  }
@@ -335,10 +335,10 @@ export const tunnelComplete = () =>
335
335
  * tunnel must be removed first. */
336
336
  async function resume(dir, o) {
337
337
  if (!tunnelComplete())
338
- die("a previous `tunnel create` did not finish (some ids are saved, see `a2a-exposed status`); run `a2a-exposed tunnel rm`, then `a2a-exposed tunnel create` again");
338
+ die(`a previous \`tunnel create\` did not finish (some ids are saved, see \`${CLI} status\`); run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\` again`);
339
339
  const host = C.get("A2A_TUNNEL_HOSTNAME");
340
340
  if (o["tunnel-hostname"] && o["tunnel-hostname"].toLowerCase() !== host)
341
- die(`a tunnel already exists on ${host}; to use another hostname run \`a2a-exposed tunnel rm\` first`);
341
+ die(`a tunnel already exists on ${host}; to use another hostname run \`${CLI} tunnel rm\` first`);
342
342
  if (!tokenFileOk()) {
343
343
  step(`the connector token file ${C.get("A2A_TUNNEL_TOKEN_FILE")} is missing or empty: downloading the tunnel token again`);
344
344
  fetchTokenFile(dir, C.get("A2A_TUNNEL_ID"));
@@ -356,11 +356,11 @@ async function resume(dir, o) {
356
356
  }
357
357
  const preset = C.get("WAKE_PRESET", "generic");
358
358
  if (p && !p.hasKey && !p.hasHmacSecret && !Object.keys(agentAuth).length && preset !== "generic")
359
- console.error(`warning: the Worker has no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, so the ${preset} webhook will reject wakes; export it and run \`a2a-exposed tunnel create\` again`);
359
+ console.error(`warning: the Worker has no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, so the ${preset} webhook will reject wakes; export it and run \`${CLI} tunnel create\` again`);
360
360
  console.log(url);
361
361
  console.error(`tunnel already set up: wakes go to ${url} -> ${C.get("A2A_TUNNEL_ORIGIN")}${C.get("A2A_TUNNEL_PATH")} (nothing new created; to change it: tunnel rm, then tunnel create)`);
362
362
  console.error(connectorInstructions(C.get("A2A_TUNNEL_TOKEN_FILE")));
363
- console.error("Then: a2a-exposed tunnel status and a2a-exposed wake test");
363
+ console.error(`Then: ${CLI} tunnel status and ${CLI} wake test`);
364
364
  }
365
365
 
366
366
  function putSecrets(dir, worker, map) {
@@ -370,7 +370,7 @@ function putSecrets(dir, worker, map) {
370
370
  export async function status(o) {
371
371
  const dir = needDeployment(o);
372
372
  const host = C.get("A2A_TUNNEL_HOSTNAME");
373
- if (!host) return console.log("no tunnel configured (a2a-exposed tunnel create)");
373
+ if (!host) return console.log(`no tunnel configured (${CLI} tunnel create)`);
374
374
  const info = { hostname: host, wakeUrl: wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/"), origin: C.get("A2A_TUNNEL_ORIGIN") };
375
375
  const tid = C.get("A2A_TUNNEL_ID");
376
376
  if (tid) {
package/lib/update.mjs CHANGED
@@ -31,7 +31,7 @@ export function newer(a, b) {
31
31
  /** The notice for `current`, given the cache contents (or "" when up to date / unknown). */
32
32
  export function notice(current, cache) {
33
33
  if (!cache || !cache.latest || !newer(cache.latest, current)) return "";
34
- return `a2a-exposed ${cache.latest} is available (you have ${current}): npm i -g a2a-exposed@latest, then a2a-exposed deploy (updates the Worker; applies new D1 migrations). Silence: A2A_NO_UPDATE_CHECK=1`;
34
+ return `a2a-exposed ${cache.latest} is available (you have ${current}): run npx -y a2a-exposed@latest deploy (updates the Worker; applies new D1 migrations). With a global install, npm i -g a2a-exposed@latest first. Silence: A2A_NO_UPDATE_CHECK=1`;
35
35
  }
36
36
 
37
37
  export const due = (cache, now = Date.now()) => !cache || !(now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,9 @@
1
1
  // a2a-exposed Worker configuration. Everything deployment-specific comes from environment
2
- // variables at deploy time (set by `npx a2a-exposed init|deploy`, or export them yourself;
2
+ // variables at deploy time (set by `npx -y a2a-exposed@latest init|deploy`, or export them yourself;
3
3
  // see deploy.env.example). Nothing account- or owner-specific is committed here.
4
- import { bindings, defineConfig, triggers } from "cf/config";
5
- import * as entrypoint from "./src/index.ts" with { type: "cf-worker" };
4
+ import { bindings, defineConfig, exports, triggers } from "cf/config";
5
+ import * as selfHost from "./src/index.ts" with { type: "cf-worker" };
6
+ import * as hostedEntry from "./src/hosted.ts" with { type: "cf-worker" };
6
7
 
7
8
  const e: Record<string, string | undefined> = (globalThis as any).process?.env ?? {};
8
9
  const v = (k: string) => (e[k] ?? "").trim();
@@ -32,36 +33,78 @@ const plain: Record<string, string> = {
32
33
  WAKE_KEY_HEADER: v("WAKE_KEY_HEADER"), // generic preset (default "authorization")
33
34
  WAKE_KEY_PREFIX: e.WAKE_KEY_PREFIX ?? "", // generic preset; unset = "Bearer "
34
35
  WAKE_BODY_TEMPLATE: v("WAKE_BODY_TEMPLATE"), // generic preset JSON template
35
- WAKE_CLI_COMMAND: v("WAKE_CLI_COMMAND"), // how the woken agent runs the CLI (default "npx a2a-exposed")
36
+ WAKE_CLI_COMMAND: v("WAKE_CLI_COMMAND"), // how the woken agent runs the CLI (default "npx -y a2a-exposed@latest")
36
37
  WAKE_DEBOUNCE_SECONDS: v("WAKE_DEBOUNCE_SECONDS"),
37
38
  WAKE_MAX_PER_HOUR: v("WAKE_MAX_PER_HOUR"),
38
39
  MAX_BODY: v("A2A_MAX_BODY"),
39
40
  RATE_PER_MIN: v("A2A_RATE_PER_MIN"),
40
41
  RETIRED_HOSTNAMES: v("A2A_RETIRED_HOSTNAMES"), // custom domains this agent moved away from (see movedResponse)
41
42
  PAIRING_APPROVAL: v("PAIRING_APPROVAL") || "human", // device-flow pairing: human (approval password on /device) | agent | off
43
+ MCP: v("A2A_MCP"), // remote MCP server at /mcp (connector for Claude etc.): on unless "off"; never in proxy mode
42
44
  PBKDF2_ITERATIONS: v("A2A_PBKDF2_ITERATIONS"), // approval password hashing; default 100000 (the Workers maximum), at least 50000
43
45
  // proxy / expose mode: forward A2A to an agent that already speaks it, published through a Tunnel hostname behind
44
46
  // Access (never a Tailnet / LAN URL: the Worker can't reach those, and they never appear in the public card)
45
47
  UPSTREAM_URL: v("A2A_UPSTREAM_URL"),
46
48
  UPSTREAM_CARD_URL: v("A2A_UPSTREAM_CARD_URL"), // default <upstream origin>/.well-known/agent-card.json
49
+ // Hosted multi-tenant gates. All empty by default, and empty is omitted, so a self-host deploy is unchanged.
50
+ // TENANCY=host switches the entrypoint to src/hosted.ts and adds TENANT_DO + TENANT_DIRECTORY below.
51
+ // QUOTAS, USAGE_SINK, WAKE_TARGET_POLICY, SIGNUP_URL and BRANDING are parsed and reserved.
52
+ // APPROVAL_OIDC_* turn on OpenID Connect approval when issuer, client id, secret and allowlist are all set.
53
+ TENANCY: v("TENANCY"),
54
+ TENANT_DOMAIN: v("TENANT_DOMAIN"),
55
+ DATA_REGION: v("DATA_REGION"), // eu | fedramp; unset = Cloudflare's default placement
56
+ QUOTAS: v("QUOTAS"),
57
+ USAGE_SINK: v("USAGE_SINK"),
58
+ WAKE_TARGET_POLICY: v("WAKE_TARGET_POLICY"),
59
+ SIGNUP_URL: v("SIGNUP_URL"),
60
+ BRANDING: v("BRANDING"),
61
+ APPROVAL_OIDC_ISSUER: v("APPROVAL_OIDC_ISSUER"),
62
+ APPROVAL_OIDC_CLIENT_ID: v("APPROVAL_OIDC_CLIENT_ID"),
63
+ APPROVAL_OIDC_ALLOWED_SUBJECTS: v("APPROVAL_OIDC_ALLOWED_SUBJECTS"),
64
+ APPROVAL_METHODS: v("APPROVAL_METHODS"),
47
65
  };
66
+ const hosted = v("TENANCY") === "host";
67
+ // Daily R2 snapshots. Unset: no binding and no extra cron, so a self-host deploy stays the single-tenant Worker.
68
+ const backups = v("A2A_BACKUP_BUCKET") === "1";
48
69
  const envBindings: Record<string, any> = {
49
70
  DB: bindings.d1(v("A2A_D1_ID") ? { name: v("A2A_D1_NAME") || name, id: v("A2A_D1_ID") } : { name: v("A2A_D1_NAME") || name }),
50
71
  };
72
+ // Per-tenant SQLite and the name directory exist only on a hosted deploy. A self-host build does not export the
73
+ // Durable Object class and does not declare these bindings, so `cf deploy` stays the single-tenant Worker on D1.
74
+ if (hosted) {
75
+ envBindings.TENANT_DO = bindings.durableObject({ worker: name, exportName: "TenantStore" });
76
+ envBindings.TENANT_DIRECTORY = bindings.kv();
77
+ }
78
+ if (backups) {
79
+ const region = v("DATA_REGION");
80
+ const bucketName = v("A2A_BACKUP_BUCKET_NAME");
81
+ envBindings.BACKUP_BUCKET = bindings.r2({
82
+ ...(bucketName ? { name: bucketName } : {}),
83
+ ...(region === "eu" || region === "fedramp" ? { jurisdiction: region } : {}),
84
+ });
85
+ }
86
+ const crons = [
87
+ ...(v("A2A_ENABLE_CRON") === "1" ? [triggers.scheduled({ schedule: "* * * * *" })] : []),
88
+ ...(backups ? [triggers.scheduled({ schedule: "0 3 * * *" })] : []),
89
+ ];
51
90
  for (const [k, val] of Object.entries(plain)) if (val !== "" || (k === "WAKE_KEY_PREFIX" && "WAKE_KEY_PREFIX" in e)) envBindings[k] = bindings.text(val);
52
91
 
53
92
  // Secrets are uploaded separately (never in this file): OWNER_TOKEN, WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, WAKE_HMAC_SECRET,
54
93
  // and in proxy mode UPSTREAM_TOKEN, UPSTREAM_ACCESS_CLIENT_ID, UPSTREAM_ACCESS_CLIENT_SECRET.
55
94
  // They are declared only in development mode (`cf dev`), so local dev reads them from .dev.vars while
56
95
  // production deploys never fail because an optional wake secret is unset.
57
- const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET"];
96
+ const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
97
+ // Declared for `cf dev` only when a deploy asks for them, so a self-host .dev.vars without these still starts.
98
+ ...(v("TENANCY") === "host" ? ["TENANT_SECRETS_KEY"] : []),
99
+ ...(v("TENANCY") === "host" || v("APPROVAL_OIDC_ISSUER") ? ["APPROVAL_OIDC_CLIENT_SECRET"] : [])];
58
100
 
59
101
  export default defineConfig((ctx) => ({
60
102
  ...(v("CLOUDFLARE_ACCOUNT_ID") ? { accountId: v("CLOUDFLARE_ACCOUNT_ID") } : {}),
61
103
  worker: {
62
104
  name,
63
105
  compatibilityDate: "2026-10-06",
64
- entrypoint,
106
+ entrypoint: hosted ? hostedEntry : selfHost,
107
+ ...(hosted ? { exports: { TenantStore: exports.durableObject({ storage: "sqlite" }) } } : {}),
65
108
  ...(hostname ? { domains: [hostname] } : {}),
66
109
  workersDev,
67
110
  // Optional persisted Workers Logs (A2A_WORKERS_LOGS=1, `init|deploy --workers-logs`): the JSON event lines plus
@@ -70,6 +113,7 @@ export default defineConfig((ctx) => ({
70
113
  env: ctx.mode === "development" ? { ...envBindings, ...Object.fromEntries(SECRETS.map((k) => [k, bindings.secret()])) } : envBindings,
71
114
  // Optional cron flush of debounced wakes (the Worker also flushes opportunistically on every request).
72
115
  // Requires a workers.dev subdomain on the account (always the case for workers.dev deployments).
73
- ...(v("A2A_ENABLE_CRON") === "1" ? { triggers: [triggers.scheduled({ schedule: "* * * * *" })] } : {}),
116
+ // A2A_BACKUP_BUCKET=1 adds a second trigger, 03:00 UTC, which writes SQL snapshots and does not flush wakes.
117
+ ...(crons.length ? { triggers: crons } : {}),
74
118
  },
75
119
  }));
@@ -18,7 +18,7 @@ WAKE_PRESET=generic
18
18
  # WAKE_KEY_HEADER=authorization
19
19
  # WAKE_KEY_PREFIX=Bearer
20
20
  # WAKE_BODY_TEMPLATE={"text":"{{summary}}"}
21
- # Command shown in wake hints (CLI: --cli-command; default "npx a2a-exposed"). Until the CLI is on npm:
21
+ # Command shown in wake hints (CLI: --cli-command; default "npx -y a2a-exposed@latest"). For a source checkout:
22
22
  # WAKE_CLI_COMMAND=node /path/to/a2a-exposed/cli/bin/a2a-exposed.mjs
23
23
  # WAKE_DEBOUNCE_SECONDS=10
24
24
  # WAKE_MAX_PER_HOUR=0
@@ -31,3 +31,30 @@ WAKE_PRESET=generic
31
31
  # Cloudflare Access WAKE_ACCESS_CLIENT_ID / WAKE_ACCESS_CLIENT_SECRET; in proxy mode UPSTREAM_TOKEN,
32
32
  # UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET) go in a separate chmod-600
33
33
  # file passed with `cf deploy --secrets-file <file>`; never commit them.
34
+ #
35
+ # Optional multi-tenant gates. Leave them unset for a self-hosted inbox (`npx -y a2a-exposed@latest init`
36
+ # never sets them). Unset means today's single-tenant Worker on D1, and the build does not export a Durable Object.
37
+ # TENANCY=host switches the entry to the multi-tenant router and adds the TENANT_DO and TENANT_DIRECTORY bindings.
38
+ # The other values are reserved (parsed, not enforced yet). Do not set TENANCY for a self-hosted inbox.
39
+ # TENANCY=
40
+ # TENANT_DOMAIN=example.com
41
+ # DATA_REGION=eu
42
+ # Written onto a directory entry the first time that entry has no region. After that the stored region places the tenant.
43
+ # QUOTAS=on
44
+ # USAGE_SINK=d1
45
+ # WAKE_TARGET_POLICY=public-https
46
+ # SIGNUP_URL=https://example.com
47
+ # BRANDING=hosted
48
+ # OpenID Connect approval (optional). All four of issuer, client id, allowlist and the client secret
49
+ # must be set or the pages stay password-only. APPROVAL_METHODS=oidc hides the password once that
50
+ # config is complete; APPROVAL_METHODS=password keeps the password even if an issuer is set.
51
+ # APPROVAL_OIDC_ISSUER=https://example.com
52
+ # APPROVAL_OIDC_CLIENT_ID=
53
+ # APPROVAL_OIDC_ALLOWED_SUBJECTS=
54
+ # APPROVAL_METHODS=password
55
+ # Secrets (same secrets file, never here): APPROVAL_OIDC_CLIENT_SECRET, and for a hosted deploy TENANT_SECRETS_KEY.
56
+ # Hosted approval uses the tenant's pushed approval object, not these Worker settings.
57
+ # Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
58
+ # A2A_BACKUP_BUCKET=1
59
+ # A2A_BACKUP_BUCKET_NAME=a2a-exposed-backups
60
+ # With DATA_REGION=eu or fedramp the bucket is created in that jurisdiction. Objects are kept 30 days.
@@ -0,0 +1,43 @@
1
+ -- Remote MCP server (/mcp): OAuth 2.1 clients (dynamic registration), one-time authorization codes, and the grants
2
+ -- (owner-approved, inbox-scoped tokens) that `token list` shows and `token revoke` ends. Peer tokens stay in `peers`
3
+ -- and are never accepted on /mcp; MCP tokens are never accepted on the A2A endpoint or /owner/*.
4
+ CREATE TABLE IF NOT EXISTS oauth_clients (
5
+ client_id TEXT PRIMARY KEY,
6
+ client_name TEXT,
7
+ redirect_uris_json TEXT NOT NULL,
8
+ created_ms INTEGER NOT NULL,
9
+ ip TEXT
10
+ );
11
+ CREATE TABLE IF NOT EXISTS oauth_codes (
12
+ code_hash TEXT PRIMARY KEY,
13
+ client_id TEXT NOT NULL,
14
+ redirect_uri TEXT NOT NULL,
15
+ code_challenge TEXT NOT NULL,
16
+ scope TEXT NOT NULL,
17
+ resource TEXT NOT NULL,
18
+ created_ms INTEGER NOT NULL,
19
+ expires_ms INTEGER NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS mcp_grants (
22
+ label TEXT PRIMARY KEY,
23
+ client_id TEXT NOT NULL,
24
+ client_name TEXT,
25
+ redirect_host TEXT,
26
+ scope TEXT NOT NULL,
27
+ access_hash TEXT UNIQUE,
28
+ access_expires_ms INTEGER,
29
+ refresh_hash TEXT UNIQUE,
30
+ refresh_expires_ms INTEGER,
31
+ created_at TEXT NOT NULL,
32
+ last_used_at TEXT,
33
+ revoked_at TEXT
34
+ );
35
+ -- Peers this inbox sends to from the Worker (MCP `send` / `poll_outbound`), uploaded by `peers sync`. The token is
36
+ -- AES-GCM ciphertext under a key derived from the Worker's OWNER_TOKEN secret, so a D1 export alone doesn't reveal it.
37
+ CREATE TABLE IF NOT EXISTS outbound_peers (
38
+ alias TEXT PRIMARY KEY,
39
+ url TEXT NOT NULL,
40
+ token_enc TEXT,
41
+ created_at TEXT NOT NULL,
42
+ updated_at TEXT NOT NULL
43
+ );
@@ -0,0 +1,10 @@
1
+ -- MCP authorization (2026-07-28): OAuth Client ID Metadata Documents. A client_id that is an https URL names a JSON
2
+ -- document the Worker fetches (SSRF-guarded, 5 KB, 5 s) and validates; valid documents are cached here for their
3
+ -- Cache-Control max-age (60 s - 24 h, default 1 h). Errors and invalid documents are never cached.
4
+ CREATE TABLE IF NOT EXISTS oauth_client_metadata (
5
+ client_id TEXT PRIMARY KEY,
6
+ client_name TEXT,
7
+ redirect_uris_json TEXT NOT NULL,
8
+ fetched_ms INTEGER NOT NULL,
9
+ expires_ms INTEGER NOT NULL
10
+ );
@@ -0,0 +1,12 @@
1
+ -- Short-lived OpenID Connect sign-in (authorization code + PKCE). The callback deletes the row;
2
+ -- expired rows are removed when a new one is stored and by the pairing cleanup.
3
+ CREATE TABLE IF NOT EXISTS oidc_txns (
4
+ state TEXT PRIMARY KEY,
5
+ nonce TEXT NOT NULL,
6
+ verifier TEXT NOT NULL,
7
+ kind TEXT NOT NULL,
8
+ csrf TEXT NOT NULL,
9
+ user_code TEXT,
10
+ payload_json TEXT NOT NULL,
11
+ expires_ms INTEGER NOT NULL
12
+ );
@@ -7,13 +7,17 @@
7
7
  "scripts": {
8
8
  "build": "cf build",
9
9
  "deploy": "cf deploy",
10
- "test": "node --test test/*.test.ts",
10
+ "test": "node --import ./test/register-hooks.mjs --test test/*.test.ts",
11
11
  "typecheck": "cf workers types && tsc"
12
12
  },
13
- "engines": { "node": ">=22.18" },
13
+ "engines": {
14
+ "node": ">=22.18"
15
+ },
14
16
  "devDependencies": {
15
17
  "@cloudflare/vite-plugin": "beta",
16
18
  "cf": "^1.0.0-beta.13",
19
+ "esbuild": "^0.28.1",
20
+ "miniflare": "^5.20261006.0-alpha",
17
21
  "typescript": "^7.0.2",
18
22
  "vite": "^8.3.0"
19
23
  }
@@ -0,0 +1,247 @@
1
+ // Optional OpenID Connect approval for /device and /oauth/authorize.
2
+ // Active only when the issuer (https), client id, client secret and at least one allowed subject are all set,
3
+ // and the method list includes oidc (or APPROVAL_METHODS was left unset). Password stays the default.
4
+ // The client secret is read from TenantContext and is never logged or placed on the gate object.
5
+ import * as A from "./a2a.ts";
6
+ import type { ApprovalOidcGate, TenantContext } from "./tenancy.ts";
7
+
8
+ export const TXN_TTL_MS = 10 * 60 * 1000;
9
+ const ID_TOKEN_MAX = 8192;
10
+ const SKEW_S = 60;
11
+ const FETCH_MS = 5000;
12
+
13
+ export type OidcKind = "device" | "mcp";
14
+
15
+ export interface ApprovalPolicy {
16
+ oidc: boolean;
17
+ password: boolean;
18
+ /** Button label: configured label, otherwise the issuer host. */
19
+ label: string;
20
+ }
21
+
22
+ export function issuerHost(issuer: string): string {
23
+ try { return new URL(issuer).host; } catch { return ""; }
24
+ }
25
+
26
+ export function issuerBase(issuer: string): string {
27
+ return issuer.replace(/\/+$/, "");
28
+ }
29
+
30
+ /**
31
+ * What the approval pages offer.
32
+ * OIDC is on when it is fully configured and methods include oidc, or when methods were not specified.
33
+ * Password stays on unless methods were specified as oidc only AND the OIDC config is complete
34
+ * (an incomplete `APPROVAL_METHODS=oidc` must not lock the operator out).
35
+ * `APPROVAL_METHODS=password` keeps the password and does not turn OIDC on.
36
+ */
37
+ export function approvalPolicy(gate: ApprovalOidcGate): ApprovalPolicy {
38
+ const configured = !!(gate.issuer && gate.clientId && gate.hasClientSecret && gate.allowedSubjects.length);
39
+ const oidc = configured && (gate.methods.includes("oidc") || !gate.methodsSpecified);
40
+ const oidcOnly = gate.methodsSpecified && gate.methods.includes("oidc") && !gate.methods.includes("password");
41
+ return { oidc, password: !(oidcOnly && configured), label: gate.label || issuerHost(gate.issuer) };
42
+ }
43
+
44
+ export type StartResult = { ok: true; location: string } | { ok: false; error: string; reason: string };
45
+ export type FinishResult =
46
+ | { ok: true; userCode: string | null; payload: Record<string, unknown>; subject: string }
47
+ | { ok: false; error: string; reason: string; userCode: string | null };
48
+
49
+ const FAIL = "Sign-in could not be verified. Nothing was approved.";
50
+ const UNREACHABLE = "The identity provider could not be reached. Nothing was approved.";
51
+
52
+ function b64url(bytes: Uint8Array): string {
53
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
54
+ }
55
+
56
+ function b64urlDecode(s: string): Uint8Array {
57
+ if (!/^[A-Za-z0-9_-]+$/.test(s) || s.length > ID_TOKEN_MAX) throw new Error("b64");
58
+ const pad = "=".repeat((4 - (s.length % 4)) % 4);
59
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
60
+ const out = new Uint8Array(bin.length);
61
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
62
+ return out;
63
+ }
64
+
65
+ async function pkce(): Promise<{ verifier: string; challenge: string }> {
66
+ const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
67
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
68
+ return { verifier, challenge: b64url(new Uint8Array(digest)) };
69
+ }
70
+
71
+ function httpsEndpoint(v: unknown): string {
72
+ if (typeof v !== "string" || !v) throw new Error("endpoint");
73
+ const u = new URL(v);
74
+ if (u.protocol !== "https:" || u.username || u.password) throw new Error("endpoint");
75
+ return u.toString();
76
+ }
77
+
78
+ async function readJson(res: Response, max: number): Promise<any> {
79
+ const len = Number(res.headers.get("content-length") || 0);
80
+ if (len > max) throw new Error("large");
81
+ const text = await res.text();
82
+ if (text.length > max) throw new Error("large");
83
+ return JSON.parse(text);
84
+ }
85
+
86
+ async function getJson(url: string): Promise<any> {
87
+ const res = await fetch(url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(FETCH_MS) });
88
+ if (!res.ok) throw new Error("http");
89
+ return readJson(res, 65536);
90
+ }
91
+
92
+ interface Discovery {
93
+ authorization_endpoint: string;
94
+ token_endpoint: string;
95
+ jwks_uri: string;
96
+ }
97
+
98
+ async function discover(issuer: string): Promise<Discovery> {
99
+ const doc = await getJson(`${issuerBase(issuer)}/.well-known/openid-configuration`);
100
+ if (issuerBase(String(doc?.issuer || "")) !== issuerBase(issuer)) throw new Error("issuer");
101
+ return {
102
+ authorization_endpoint: httpsEndpoint(doc.authorization_endpoint),
103
+ token_endpoint: httpsEndpoint(doc.token_endpoint),
104
+ jwks_uri: httpsEndpoint(doc.jwks_uri),
105
+ };
106
+ }
107
+
108
+ /** Begin a sign-in. `userCode` is stored with the state and is not put on the identity provider's URL. */
109
+ export async function startOidc(ctx: TenantContext, o: {
110
+ kind: OidcKind; redirectUri: string; csrf: string; userCode: string | null; payload: Record<string, unknown>;
111
+ }): Promise<StartResult> {
112
+ const gate = ctx.gates.approvalOidc;
113
+ if (!approvalPolicy(gate).oidc || !ctx.approvalClientSecret()) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config" };
114
+ let disc: Discovery;
115
+ try { disc = await discover(gate.issuer); }
116
+ catch { return { ok: false, error: UNREACHABLE, reason: "discovery" }; }
117
+ const { verifier, challenge } = await pkce();
118
+ const state = b64url(crypto.getRandomValues(new Uint8Array(32)));
119
+ const nonce = b64url(crypto.getRandomValues(new Uint8Array(32)));
120
+ const payload = JSON.stringify(o.payload);
121
+ if (payload.length > 16384) return { ok: false, error: "This approval could not be started.", reason: "payload" };
122
+ const now = Date.now();
123
+ await ctx.DB.prepare("DELETE FROM oidc_txns WHERE expires_ms < ?").bind(now).run();
124
+ await ctx.DB.prepare("INSERT INTO oidc_txns (state, nonce, verifier, kind, csrf, user_code, payload_json, expires_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
125
+ .bind(state, nonce, verifier, o.kind, o.csrf, o.userCode, payload, now + TXN_TTL_MS).run();
126
+ const u = new URL(disc.authorization_endpoint);
127
+ u.searchParams.set("response_type", "code");
128
+ u.searchParams.set("client_id", gate.clientId);
129
+ u.searchParams.set("redirect_uri", o.redirectUri);
130
+ // `email` is required for an email allowlist: providers omit `email` and `email_verified` unless this scope is requested.
131
+ u.searchParams.set("scope", "openid email");
132
+ u.searchParams.set("state", state);
133
+ u.searchParams.set("nonce", nonce);
134
+ u.searchParams.set("code_challenge", challenge);
135
+ u.searchParams.set("code_challenge_method", "S256");
136
+ return { ok: true, location: u.toString() };
137
+ }
138
+
139
+ function onAllowlist(value: unknown, subjects: string[]): boolean {
140
+ if (typeof value !== "string") return false;
141
+ for (const want of subjects) if (A.timingSafeEqualStr(value, want)) return true;
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * OIDC ID Token validation: `aud` must contain this client. When `aud` has more than one value, `azp` is required
147
+ * and must be this client id. A present `azp` must match even when there is only one audience.
148
+ */
149
+ function audienceOk(aud: unknown, azp: unknown, clientId: string): boolean {
150
+ const raw = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : [];
151
+ if (!onAllowlist(clientId, raw.filter((a): a is string => typeof a === "string"))) return false;
152
+ const multi = raw.length > 1;
153
+ if (!multi && azp === undefined) return true;
154
+ return typeof azp === "string" && A.timingSafeEqualStr(azp, clientId);
155
+ }
156
+
157
+ /**
158
+ * Prefer `sub`. An `email` match is a separate path and is ignored unless `email_verified` is boolean true,
159
+ * so an unverified address cannot approve.
160
+ */
161
+ function subjectAllowed(claims: { sub?: unknown; email?: unknown; email_verified?: unknown }, subjects: string[]): boolean {
162
+ if (onAllowlist(claims.sub, subjects)) return true;
163
+ if (claims.email_verified !== true) return false;
164
+ return onAllowlist(claims.email, subjects);
165
+ }
166
+
167
+ async function verifyIdToken(token: string, jwks: { keys?: any[] }, expect: { iss: string; aud: string; nonce: string; subjects: string[] }): Promise<{ subject: string } | { reason: string }> {
168
+ if (typeof token !== "string" || token.length < 20 || token.length > ID_TOKEN_MAX) return { reason: "token" };
169
+ const parts = token.split(".");
170
+ if (parts.length !== 3) return { reason: "token" };
171
+ let header: any;
172
+ try { header = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[0]))); }
173
+ catch { return { reason: "token" }; }
174
+ // RS256 only. `none` and HMAC (which would use the client secret as a MAC key) are rejected before verification.
175
+ if (header.alg !== "RS256") return { reason: "alg" };
176
+ const keys: any[] = Array.isArray(jwks.keys) ? jwks.keys : [];
177
+ const jwk = header.kid ? keys.find((k) => k && k.kid === header.kid) : keys.length === 1 ? keys[0] : null;
178
+ if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e || (jwk.alg && jwk.alg !== "RS256")) return { reason: "key" };
179
+ let key: CryptoKey;
180
+ try {
181
+ key = await crypto.subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true },
182
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
183
+ } catch { return { reason: "key" }; }
184
+ let sig: Uint8Array;
185
+ try { sig = b64urlDecode(parts[2]); } catch { return { reason: "sig" }; }
186
+ const ok = await crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, key, sig, new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
187
+ if (!ok) return { reason: "sig" };
188
+ let claims: any;
189
+ try { claims = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1]))); }
190
+ catch { return { reason: "token" }; }
191
+ if (issuerBase(String(claims.iss || "")) !== issuerBase(expect.iss)) return { reason: "iss" };
192
+ if (!audienceOk(claims.aud, claims.azp, expect.aud)) return { reason: "aud" };
193
+ if (typeof claims.nonce !== "string" || !A.timingSafeEqualStr(claims.nonce, expect.nonce)) return { reason: "nonce" };
194
+ const nowS = Date.now() / 1000;
195
+ if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || claims.exp + SKEW_S < nowS) return { reason: "exp" };
196
+ if (typeof claims.nbf === "number" && claims.nbf - SKEW_S > nowS) return { reason: "nbf" };
197
+ if (!subjectAllowed(claims, expect.subjects)) return { reason: "sub" };
198
+ return { subject: typeof claims.sub === "string" ? claims.sub : String(claims.email) };
199
+ }
200
+
201
+ /**
202
+ * Complete a sign-in. The state row is deleted on every callback, including failure, so it cannot be replayed.
203
+ * The CSRF cookie is not read here: SameSite=Strict is dropped on the cross-site return from the identity
204
+ * provider. The start POST already checked that cookie and stored it with this state.
205
+ */
206
+ export async function finishOidc(ctx: TenantContext, o: {
207
+ kind: OidcKind; redirectUri: string; state: string; code: string | null; error: string | null;
208
+ }): Promise<FinishResult> {
209
+ const state = o.state || "";
210
+ if (!/^[A-Za-z0-9_-]{20,128}$/.test(state)) return { ok: false, error: "This sign-in expired or was already used. Nothing was approved.", reason: "state", userCode: null };
211
+ const row: any = await ctx.DB.prepare("DELETE FROM oidc_txns WHERE state = ? RETURNING *").bind(state).first();
212
+ if (!row || row.kind !== o.kind) return { ok: false, error: "This sign-in expired or was already used. Nothing was approved.", reason: "state", userCode: null };
213
+ const userCode = row.user_code || null;
214
+ if (Date.now() >= row.expires_ms) return { ok: false, error: "This sign-in expired. Nothing was approved.", reason: "expired", userCode };
215
+ if (o.error || !o.code) return { ok: false, error: "Sign-in was cancelled. Nothing was approved.", reason: "cancelled", userCode };
216
+ const gate = ctx.gates.approvalOidc;
217
+ const secret = ctx.approvalClientSecret();
218
+ if (!approvalPolicy(gate).oidc || !secret) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
219
+ let disc: Discovery;
220
+ try { disc = await discover(gate.issuer); }
221
+ catch { return { ok: false, error: UNREACHABLE, reason: "discovery", userCode }; }
222
+ let idToken = "";
223
+ try {
224
+ const body = new URLSearchParams({
225
+ grant_type: "authorization_code", code: o.code, redirect_uri: o.redirectUri,
226
+ client_id: gate.clientId, client_secret: secret, code_verifier: row.verifier,
227
+ });
228
+ const res = await fetch(disc.token_endpoint, {
229
+ method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
230
+ body, redirect: "error", signal: AbortSignal.timeout(FETCH_MS),
231
+ });
232
+ if (!res.ok) return { ok: false, error: UNREACHABLE, reason: "token", userCode };
233
+ const tok = await readJson(res, 65536);
234
+ idToken = typeof tok.id_token === "string" ? tok.id_token : "";
235
+ } catch { return { ok: false, error: UNREACHABLE, reason: "token", userCode }; }
236
+ let jwks: { keys?: any[] };
237
+ try { jwks = await getJson(disc.jwks_uri); }
238
+ catch { return { ok: false, error: UNREACHABLE, reason: "jwks", userCode }; }
239
+ const verified = await verifyIdToken(idToken, jwks, { iss: gate.issuer, aud: gate.clientId, nonce: row.nonce, subjects: gate.allowedSubjects });
240
+ if ("reason" in verified) return { ok: false, error: FAIL, reason: verified.reason, userCode };
241
+ let payload: Record<string, unknown> = {};
242
+ try {
243
+ const parsed = JSON.parse(row.payload_json || "{}");
244
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) payload = parsed;
245
+ } catch { /* empty payload */ }
246
+ return { ok: true, userCode, payload, subject: verified.subject };
247
+ }