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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.5.0",
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,7 +33,7 @@ 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"),
@@ -45,24 +46,65 @@ const plain: Record<string, string> = {
45
46
  // Access (never a Tailnet / LAN URL: the Worker can't reach those, and they never appear in the public card)
46
47
  UPSTREAM_URL: v("A2A_UPSTREAM_URL"),
47
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"),
48
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";
49
69
  const envBindings: Record<string, any> = {
50
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 }),
51
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
+ ];
52
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);
53
91
 
54
92
  // Secrets are uploaded separately (never in this file): OWNER_TOKEN, WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, WAKE_HMAC_SECRET,
55
93
  // and in proxy mode UPSTREAM_TOKEN, UPSTREAM_ACCESS_CLIENT_ID, UPSTREAM_ACCESS_CLIENT_SECRET.
56
94
  // They are declared only in development mode (`cf dev`), so local dev reads them from .dev.vars while
57
95
  // production deploys never fail because an optional wake secret is unset.
58
- 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"] : [])];
59
100
 
60
101
  export default defineConfig((ctx) => ({
61
102
  ...(v("CLOUDFLARE_ACCOUNT_ID") ? { accountId: v("CLOUDFLARE_ACCOUNT_ID") } : {}),
62
103
  worker: {
63
104
  name,
64
105
  compatibilityDate: "2026-10-06",
65
- entrypoint,
106
+ entrypoint: hosted ? hostedEntry : selfHost,
107
+ ...(hosted ? { exports: { TenantStore: exports.durableObject({ storage: "sqlite" }) } } : {}),
66
108
  ...(hostname ? { domains: [hostname] } : {}),
67
109
  workersDev,
68
110
  // Optional persisted Workers Logs (A2A_WORKERS_LOGS=1, `init|deploy --workers-logs`): the JSON event lines plus
@@ -71,6 +113,7 @@ export default defineConfig((ctx) => ({
71
113
  env: ctx.mode === "development" ? { ...envBindings, ...Object.fromEntries(SECRETS.map((k) => [k, bindings.secret()])) } : envBindings,
72
114
  // Optional cron flush of debounced wakes (the Worker also flushes opportunistically on every request).
73
115
  // Requires a workers.dev subdomain on the account (always the case for workers.dev deployments).
74
- ...(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 } : {}),
75
118
  },
76
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,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
+ }
@@ -0,0 +1,186 @@
1
+ // Daily SQL snapshots. The R2 binding and the `0 3 * * *` trigger exist only when the deploy sets
2
+ // A2A_BACKUP_BUCKET=1. With that unset there is no bucket and this module is not called.
3
+ // Objects are `tenants/<id>/<YYYY-MM-DD>.sql`. A dated object is removed when its date is 30 or more
4
+ // days before the run (UTC), which keeps 30 daily snapshots.
5
+
6
+ import { exportRows, toSql, ExportError } from "./export.ts";
7
+ import { parseDirectoryEntry, pinDirectoryRegion, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
8
+ import { namespaceForRegion, parseGates, type DoNamespace, type KvNamespace, type SqlDb, type WorkerBindings } from "./tenancy.ts";
9
+
10
+ export const DAILY_CRON = "0 3 * * *";
11
+ export const RETENTION_DAYS = 30;
12
+ /** Self-host has one inbox, so its objects live under this id. */
13
+ export const SELF_TENANT_ID = "self";
14
+
15
+ const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+
18
+ export type R2BucketLike = NonNullable<WorkerBindings["BACKUP_BUCKET"]>;
19
+
20
+ export interface BackupWritten {
21
+ tenantId: string;
22
+ key: string;
23
+ deleted: string[];
24
+ }
25
+
26
+ export interface BackupReport {
27
+ written: BackupWritten[];
28
+ skipped: { tenantId: string; status: string }[];
29
+ failed: { tenantId: string; error: string }[];
30
+ }
31
+
32
+ export function dayUtc(now: Date): string {
33
+ return now.toISOString().slice(0, 10);
34
+ }
35
+
36
+ /** UTC midnight for a real calendar day, or null. */
37
+ export function parseDay(day: string): number | null {
38
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
39
+ if (!m) return null;
40
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
41
+ const t = Date.UTC(y, mo - 1, d);
42
+ const dt = new Date(t);
43
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;
44
+ return t;
45
+ }
46
+
47
+ /** True when `fileDay` is at least {@link RETENTION_DAYS} before `today`. */
48
+ export function olderThanRetention(fileDay: string, today: string): boolean {
49
+ const file = parseDay(fileDay);
50
+ const now = parseDay(today);
51
+ if (file === null || now === null) return false;
52
+ return now - file >= RETENTION_DAYS * DAY_MS;
53
+ }
54
+
55
+ export function snapshotKey(tenantId: string, day: string): string {
56
+ if (!TENANT_ID.test(tenantId)) throw new ExportError("backup: tenant id is invalid");
57
+ if (parseDay(day) === null) throw new ExportError("backup: day is invalid");
58
+ return `tenants/${tenantId}/${day}.sql`;
59
+ }
60
+
61
+ /** The `YYYY-MM-DD` in `tenants/<id>/<day>.sql`, or null when the key is not one of ours. */
62
+ export function objectDay(key: string, tenantId: string): string | null {
63
+ const prefix = `tenants/${tenantId}/`;
64
+ if (!key.startsWith(prefix) || !key.endsWith(".sql")) return null;
65
+ const day = key.slice(prefix.length, -".sql".length);
66
+ return parseDay(day) === null ? null : day;
67
+ }
68
+
69
+ async function listAll(bucket: R2BucketLike, prefix: string): Promise<string[]> {
70
+ const keys: string[] = [];
71
+ let cursor: string | undefined;
72
+ for (;;) {
73
+ const res = await bucket.list({ prefix, cursor, limit: 1000 });
74
+ for (const obj of res.objects ?? []) keys.push(obj.key);
75
+ if (!res.truncated) return keys;
76
+ if (!res.cursor || res.cursor === cursor) throw new ExportError("backup: snapshot listing did not finish");
77
+ cursor = res.cursor;
78
+ }
79
+ }
80
+
81
+ /** Delete dated objects for this tenant that are past retention. The object written today stays. */
82
+ export async function pruneSnapshots(bucket: R2BucketLike, tenantId: string, today: string): Promise<string[]> {
83
+ if (!TENANT_ID.test(tenantId)) throw new ExportError("backup: tenant id is invalid");
84
+ const doomed = (await listAll(bucket, `tenants/${tenantId}/`)).filter((key) => {
85
+ const day = objectDay(key, tenantId);
86
+ return day !== null && olderThanRetention(day, today);
87
+ });
88
+ for (let i = 0; i < doomed.length; i += 1000) await bucket.delete(doomed.slice(i, i + 1000));
89
+ return doomed;
90
+ }
91
+
92
+ export async function writeSnapshot(bucket: R2BucketLike, tenantId: string, sql: string, now = new Date()): Promise<BackupWritten> {
93
+ const day = dayUtc(now);
94
+ const key = snapshotKey(tenantId, day);
95
+ await bucket.put(key, sql, { httpMetadata: { contentType: "application/sql" } });
96
+ const deleted = await pruneSnapshots(bucket, tenantId, day);
97
+ return { tenantId, key, deleted };
98
+ }
99
+
100
+ /** One self-host inbox, under {@link SELF_TENANT_ID}. */
101
+ export async function backupDatabase(bucket: R2BucketLike, db: SqlDb, tenantId = SELF_TENANT_ID, now = new Date()): Promise<BackupWritten> {
102
+ const file = await exportRows(db, now);
103
+ return writeSnapshot(bucket, tenantId, toSql(file), now);
104
+ }
105
+
106
+ interface KvLister extends KvNamespace {
107
+ list?(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<{
108
+ keys: { name: string }[];
109
+ list_complete: boolean;
110
+ cursor?: string;
111
+ }>;
112
+ }
113
+
114
+ async function directoryNames(kv: KvLister): Promise<string[]> {
115
+ if (typeof kv.list !== "function") throw new ExportError("backup: directory cannot be listed");
116
+ const names: string[] = [];
117
+ let cursor: string | undefined;
118
+ for (;;) {
119
+ const res = await kv.list({ prefix: "tenant:", cursor, limit: 1000 });
120
+ for (const k of res.keys ?? []) {
121
+ const name = k.name.startsWith("tenant:") ? k.name.slice("tenant:".length) : "";
122
+ if (TENANT_NAME.test(name)) names.push(name);
123
+ }
124
+ if (res.list_complete !== false) return names;
125
+ if (!res.cursor || res.cursor === cursor) throw new ExportError("backup: directory listing did not finish");
126
+ cursor = res.cursor;
127
+ }
128
+ }
129
+
130
+ function clip(e: unknown): string {
131
+ const msg = e instanceof Error ? e.message : String(e);
132
+ return msg.slice(0, 200);
133
+ }
134
+
135
+ /**
136
+ * Fan-out for a hosted deploy. Active tenants are exported; other statuses are skipped.
137
+ * An entry with no region yet is pinned the same way a request would pin it, then read.
138
+ * One tenant's failure is recorded and the rest still run.
139
+ */
140
+ export async function backupDirectory(
141
+ kv: KvLister,
142
+ bucket: R2BucketLike,
143
+ ns: DoNamespace,
144
+ dataRegion: string,
145
+ now = new Date(),
146
+ ): Promise<BackupReport> {
147
+ const report: BackupReport = { written: [], skipped: [], failed: [] };
148
+ const names = await directoryNames(kv);
149
+ for (const name of names) {
150
+ let entry: DirectoryEntry | null = null;
151
+ try {
152
+ const raw = await kv.get(`tenant:${name}`, "json");
153
+ entry = parseDirectoryEntry(raw);
154
+ if (!entry) throw new ExportError("backup: directory entry is unreadable");
155
+ const pinned = await pinDirectoryRegion(kv, name, entry, dataRegion);
156
+ if (!pinned) throw new ExportError("backup: directory unavailable");
157
+ entry = pinned;
158
+ if (entry.status !== "active") {
159
+ report.skipped.push({ tenantId: entry.id, status: entry.status });
160
+ continue;
161
+ }
162
+ const sql = await exportTenantSql(ns, entry);
163
+ report.written.push(await writeSnapshot(bucket, entry.id, sql.sql, now));
164
+ } catch (e) {
165
+ report.failed.push({ tenantId: entry?.id || name, error: clip(e) });
166
+ }
167
+ }
168
+ return report;
169
+ }
170
+
171
+ export async function exportTenantSql(ns: DoNamespace, entry: DirectoryEntry): Promise<{ sql: string; exportedAt: string }> {
172
+ const bound = namespaceForRegion(ns, tenantRegion(entry));
173
+ const stub = bound.get(bound.idFromName(entry.id)) as { exportSql?: () => Promise<{ sql: string; exportedAt: string }> };
174
+ if (typeof stub.exportSql !== "function") throw new ExportError("exportSql: not available");
175
+ const out = await stub.exportSql();
176
+ if (!out || typeof out.sql !== "string" || typeof out.exportedAt !== "string") throw new ExportError("exportSql: empty");
177
+ return out;
178
+ }
179
+
180
+ /** Hosted daily job. Returns null when this invocation is not the backup cron or the bucket is unset. */
181
+ export async function hostedDailyBackup(controller: { cron?: string }, env: WorkerBindings, now = new Date()): Promise<BackupReport | null> {
182
+ if (controller.cron !== DAILY_CRON || !env.BACKUP_BUCKET) return null;
183
+ if (env.TENANCY !== "host" || !env.TENANT_DO) return null;
184
+ if (!env.TENANT_DIRECTORY) throw new ExportError("backup: directory cannot be listed");
185
+ return backupDirectory(env.TENANT_DIRECTORY, env.BACKUP_BUCKET, env.TENANT_DO, parseGates(env).gates.dataRegion, now);
186
+ }