a2a-exposed 0.5.0 → 0.7.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 (46) 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 +51 -7
  12. package/worker/deploy.env.example +32 -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 +748 -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-target.ts +202 -0
  28. package/worker/src/wake.ts +3 -2
  29. package/worker/test/alarms.test.ts +231 -0
  30. package/worker/test/approval-oidc.test.ts +331 -0
  31. package/worker/test/bridge-thread.mjs +40 -0
  32. package/worker/test/d1.ts +146 -2
  33. package/worker/test/directory.test.ts +90 -0
  34. package/worker/test/export.test.ts +544 -0
  35. package/worker/test/facade.test.ts +1 -1
  36. package/worker/test/harness-worker.ts +80 -0
  37. package/worker/test/hosted.test.ts +221 -0
  38. package/worker/test/migrations.test.ts +5 -5
  39. package/worker/test/pairing.test.ts +5 -4
  40. package/worker/test/register-hooks.mjs +4 -0
  41. package/worker/test/sql-raw-hook.mjs +22 -0
  42. package/worker/test/storage.test.ts +74 -0
  43. package/worker/test/tenancy.test.ts +217 -0
  44. package/worker/test/wake-retry.test.ts +24 -1
  45. package/worker/test/wake-target.test.ts +187 -0
  46. package/worker/test/wake.test.ts +3 -0
@@ -0,0 +1,450 @@
1
+ // Tenant context for the data-plane Worker.
2
+ //
3
+ // Self-host (TENANCY unset, the default): one implicit tenant, config and OWNER_TOKEN taken from the Worker env.
4
+ // Handlers receive this object instead of reading env.* themselves. With every gate unset, owner checks and the
5
+ // outbound peer-token key are the same operations as before (existing ciphertext still decrypts).
6
+ //
7
+ // Hosted (a TenantContext built with a pushed tenant overlay): owner auth is a constant-time compare against
8
+ // owner_token_hash, and the peer-token key is derived from TENANT_SECRETS_KEY and the tenant id. The Worker
9
+ // fetch path does not build that overlay; the hosted router does, and only when TENANCY=host and TENANT_DO are
10
+ // both set. Settings below are parsed either way. Nothing here turns on quotas, metering, wake policy, branding
11
+ // those stay reserved until a later change reads ctx.gates. Approval OIDC is enforced by the approval pages when
12
+ // the issuer, client id, client secret and an allowlist are all set (approval-oidc.ts).
13
+
14
+ import * as A from "./a2a.ts";
15
+
16
+ /** Subset of D1 used by the handlers. A Durable Object adapter satisfies the same shape. */
17
+ export interface SqlDb {
18
+ prepare(query: string): SqlStmt;
19
+ batch(statements: SqlStmt[]): Promise<{ results?: unknown[]; meta: { changes?: number; last_row_id?: number } }[]>;
20
+ exec?(query: string): Promise<{ count: number; duration: number }>;
21
+ }
22
+
23
+ export interface SqlStmt {
24
+ bind(...values: unknown[]): SqlStmt;
25
+ first<T = unknown>(colName?: string): Promise<T | null>;
26
+ all<T = unknown>(): Promise<{ results?: T[] }>;
27
+ run(): Promise<{ results?: unknown[]; meta: { changes?: number; last_row_id?: number } }>;
28
+ raw?<T = unknown>(options?: { columnNames?: boolean }): Promise<T[]>;
29
+ }
30
+
31
+ /** Plain config copied onto the context. Secrets are not in this list. */
32
+ export const CONFIG_KEYS = [
33
+ "RETIRED_HOSTNAMES",
34
+ "AGENT_NAME", "AGENT_DESCRIPTION", "AGENT_VERSION", "AGENT_SKILLS",
35
+ "PROVIDER_ORGANIZATION", "PROVIDER_URL", "DOCUMENTATION_URL",
36
+ "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET",
37
+ "WAKE_PRESET", "WAKE_AGENT_ID", "WAKE_KEY_HEADER", "WAKE_KEY_PREFIX", "WAKE_BODY_TEMPLATE", "WAKE_CLI_COMMAND",
38
+ "WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR",
39
+ "MAX_BODY", "RATE_PER_MIN",
40
+ "PAIRING_APPROVAL", "PBKDF2_ITERATIONS",
41
+ "UPSTREAM_URL", "UPSTREAM_CARD_URL", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
42
+ "MCP",
43
+ ] as const;
44
+
45
+ export type ConfigKey = (typeof CONFIG_KEYS)[number];
46
+ export type ConfigFields = { [K in ConfigKey]?: string };
47
+
48
+ /**
49
+ * Non-secret platform defaults a hosted tenant inherits when its pushed config omits them.
50
+ * Wake secrets, upstream secrets and OWNER_TOKEN are never inherited: in hosted mode they come only from the
51
+ * tenant overlay (or not at all). Decrypting `secrets_enc` into wake credentials is a follow-up.
52
+ */
53
+ const HOSTED_PLATFORM_DEFAULTS: readonly ConfigKey[] = ["MAX_BODY", "RATE_PER_MIN", "PBKDF2_ITERATIONS", "MCP", "PAIRING_APPROVAL"];
54
+
55
+ /** Bindings the Worker entry sees. Gate bindings are optional and empty by default. */
56
+ export interface WorkerBindings extends ConfigFields {
57
+ DB: SqlDb;
58
+ PUBLIC_URL?: string;
59
+ OWNER_TOKEN?: string;
60
+ // --- gates (spec §6 / plan §4.3). Unset = today's behaviour. Reserved ones are parsed onto ctx.gates only. ---
61
+ TENANCY?: string;
62
+ TENANT_SECRETS_KEY?: string;
63
+ TENANT_DOMAIN?: string;
64
+ /** KV name directory: tenant name → {id,status,region,version}. Read by the hosted router. */
65
+ TENANT_DIRECTORY?: KvNamespace;
66
+ /**
67
+ * Daily SQL snapshots. Present only when the deploy sets A2A_BACKUP_BUCKET=1.
68
+ * Unset: no backup cron and no bucket.
69
+ */
70
+ BACKUP_BUCKET?: {
71
+ put(key: string, value: string, options?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;
72
+ list(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<{
73
+ objects?: { key: string }[];
74
+ truncated?: boolean;
75
+ cursor?: string;
76
+ }>;
77
+ delete(keys: string[]): Promise<void>;
78
+ };
79
+ /** SQLite Durable Object namespace, one object per tenant. Storage stays env.DB until this is bound. */
80
+ TENANT_DO?: DoNamespace;
81
+ /** unset | eu | fedramp. Applied as a DO jurisdiction when the hosted router addresses a tenant. */
82
+ DATA_REGION?: string;
83
+ /** "on" reserves quota enforcement (not applied yet). */
84
+ QUOTAS?: string;
85
+ /** "d1" reserves usage metering (not applied yet). */
86
+ USAGE_SINK?: string;
87
+ /** "public-https" refuses non-https, private, and metadata wake targets. Anything else is unset. */
88
+ WAKE_TARGET_POLICY?: string;
89
+ /** https URL shown later on the landing page and over-quota errors. Parsed, not rendered yet. */
90
+ SIGNUP_URL?: string;
91
+ /** "hosted" reserves footer / plan badge. Parsed, not rendered yet. */
92
+ BRANDING?: string;
93
+ APPROVAL_OIDC_ISSUER?: string;
94
+ APPROVAL_OIDC_CLIENT_ID?: string;
95
+ APPROVAL_OIDC_CLIENT_SECRET?: string;
96
+ /** Comma-separated `sub` or `email` values allowed to approve. Email matches only when `email_verified` is true. */
97
+ APPROVAL_OIDC_ALLOWED_SUBJECTS?: string;
98
+ /** Comma-separated: password, oidc. Unset keeps the password and also offers OIDC once it is fully configured. */
99
+ APPROVAL_METHODS?: string;
100
+ }
101
+
102
+ export interface KvNamespace {
103
+ get(key: string, type: "json"): Promise<unknown>;
104
+ get(key: string): Promise<string | null>;
105
+ put(key: string, value: string): Promise<void>;
106
+ /** Present on Cloudflare KV. Optional so test doubles that only get and put still typecheck. */
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
+ /** Enough of a Durable Object namespace to address a tenant. `jurisdiction` is optional on older typings. */
115
+ export interface DoNamespace {
116
+ idFromName(name: string): unknown;
117
+ get(id: unknown): { fetch(req: Request): Promise<Response> };
118
+ jurisdiction?(loc: string): DoNamespace;
119
+ }
120
+
121
+ export type TenancyMode = "single" | "host";
122
+ export type DataRegion = "" | "eu" | "fedramp";
123
+
124
+ export interface ApprovalOidcGate {
125
+ /** Issuer URL, or "" when unset / not https. */
126
+ issuer: string;
127
+ clientId: string;
128
+ /** True when a client secret is present. The secret itself stays off the gate. */
129
+ hasClientSecret: boolean;
130
+ allowedSubjects: string[];
131
+ /** Unset → ["password"]. See `methodsSpecified` for whether that default was explicit. */
132
+ methods: string[];
133
+ /** True only when the setting listed at least one of password or oidc. Unset is false. */
134
+ methodsSpecified: boolean;
135
+ /** Short button label from hosted approval config, or "". */
136
+ label: string;
137
+ }
138
+
139
+ export interface FeatureGates {
140
+ tenancy: TenancyMode;
141
+ /**
142
+ * True only when TENANCY is exactly "host" and TENANT_DO is bound. That is the switch for per-tenant storage.
143
+ * Any other combination, including TENANCY=host alone, keeps the self-host path on env.DB.
144
+ */
145
+ hostedStorage: boolean;
146
+ quotas: boolean;
147
+ usageSink: "" | "d1";
148
+ wakeTargetPolicy: "" | "public-https";
149
+ signupUrl: string;
150
+ branding: "" | "hosted";
151
+ dataRegion: DataRegion;
152
+ tenantDomain: string;
153
+ approvalOidc: ApprovalOidcGate;
154
+ }
155
+
156
+ /** Pushed tenant row (control plane → DO). PR1 tests build a context from this; the DO stores it in PR2. */
157
+ export interface HostedOverlay {
158
+ id: string;
159
+ db: SqlDb;
160
+ publicUrl: string;
161
+ ownerTokenHash?: string | null;
162
+ /** Non-secret and, when the control plane includes them, secret config fields. Never OWNER_TOKEN. */
163
+ config?: Record<string, string | undefined>;
164
+ limits?: Record<string, unknown>;
165
+ approval?: Record<string, unknown>;
166
+ status?: string;
167
+ }
168
+
169
+ export interface ParsedGates {
170
+ gates: FeatureGates;
171
+ secretsKey?: string;
172
+ }
173
+
174
+ const HEX64 = /^[0-9a-f]{64}$/;
175
+
176
+ /** HKDF info for outbound peer tokens. Self-host uses the bare label so existing ciphertext decrypts. */
177
+ export const PEER_KEY_INFO = "outbound-peer-token v1";
178
+ const PEER_KEY_SALT = "a2a-exposed";
179
+
180
+ /** Hosted info binds the key to one tenant. The NUL keeps a tenant id from colliding with the version label. */
181
+ export function peerKeyInfoForTenant(tenantId: string): string {
182
+ return `${PEER_KEY_INFO}\0${tenantId}`;
183
+ }
184
+
185
+ /** AES-GCM key for outbound peer tokens. `info` defaults to the self-host label. */
186
+ export async function derivePeerKey(ikm: string, info = PEER_KEY_INFO): Promise<CryptoKey> {
187
+ const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(ikm), "HKDF", false, ["deriveKey"]);
188
+ return crypto.subtle.deriveKey(
189
+ { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(PEER_KEY_SALT), info: new TextEncoder().encode(info) },
190
+ base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
191
+ );
192
+ }
193
+
194
+ function csv(v: string | undefined): string[] {
195
+ return (v || "").split(",").map((s) => s.trim()).filter(Boolean);
196
+ }
197
+
198
+ /** https URL with no userinfo, or "" when the value is missing or not https. The string is kept as configured. */
199
+ function httpsUrl(v: string | undefined): string {
200
+ const s = (v || "").trim();
201
+ if (!s) return "";
202
+ try {
203
+ const u = new URL(s);
204
+ if (u.protocol !== "https:" || u.username || u.password) return "";
205
+ return s;
206
+ } catch { return ""; }
207
+ }
208
+
209
+ const LABEL_MAX = 40;
210
+
211
+ function recognizedMethods(raw: string[]): { methods: string[]; specified: boolean } {
212
+ const methods = raw.map((s) => s.trim().toLowerCase()).filter((s) => s === "password" || s === "oidc");
213
+ return { methods: methods.length ? methods : ["password"], specified: methods.length > 0 };
214
+ }
215
+
216
+ function shortLabel(v: unknown): string {
217
+ if (typeof v !== "string") return "";
218
+ const s = v.trim();
219
+ if (!s || s.length > LABEL_MAX || /[\u0000-\u001f<>]/.test(s)) return "";
220
+ return s;
221
+ }
222
+
223
+ function subjectsOf(v: unknown): string[] {
224
+ const list = Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : typeof v === "string" ? csv(v) : [];
225
+ return list.map((s) => s.trim()).filter(Boolean).slice(0, 100);
226
+ }
227
+
228
+ /** Approval gate from a hosted tenant's pushed `approval` object (camelCase). The client secret is returned beside the gate. */
229
+ export function approvalFromRecord(raw: Record<string, unknown> | undefined): { gate: ApprovalOidcGate; clientSecret: string; publicRecord: Record<string, unknown> } {
230
+ const { methods, specified } = recognizedMethods(Array.isArray(raw?.methods) ? raw!.methods.map((s) => String(s)) : typeof raw?.methods === "string" ? csv(raw.methods) : []);
231
+ const clientSecret = typeof raw?.clientSecret === "string" ? raw.clientSecret.trim() : "";
232
+ const publicRecord: Record<string, unknown> = raw ? { ...raw } : {};
233
+ delete publicRecord.clientSecret;
234
+ delete publicRecord.client_secret;
235
+ return {
236
+ clientSecret,
237
+ publicRecord,
238
+ gate: {
239
+ issuer: httpsUrl(typeof raw?.issuer === "string" ? raw.issuer : ""),
240
+ clientId: typeof raw?.clientId === "string" ? raw.clientId.trim() : "",
241
+ hasClientSecret: !!clientSecret,
242
+ allowedSubjects: subjectsOf(raw?.allowedSubjects),
243
+ methods, methodsSpecified: specified, label: shortLabel(raw?.label),
244
+ },
245
+ };
246
+ }
247
+
248
+ /** Exact tokens only. Anything else is the unset behaviour, so a typo cannot turn a gate on. */
249
+ export function parseGates(env: WorkerBindings): ParsedGates {
250
+ const tenancy: TenancyMode = env.TENANCY === "host" ? "host" : "single";
251
+ const regionRaw = (env.DATA_REGION || "").trim().toLowerCase();
252
+ const dataRegion: DataRegion = regionRaw === "eu" || regionRaw === "fedramp" ? regionRaw : "";
253
+ const parsedMethods = recognizedMethods(csv(env.APPROVAL_METHODS));
254
+ return {
255
+ secretsKey: (env.TENANT_SECRETS_KEY || "").trim() || undefined,
256
+ gates: {
257
+ tenancy,
258
+ hostedStorage: tenancy === "host" && !!env.TENANT_DO,
259
+ quotas: env.QUOTAS === "on",
260
+ usageSink: env.USAGE_SINK === "d1" ? "d1" : "",
261
+ wakeTargetPolicy: env.WAKE_TARGET_POLICY === "public-https" ? "public-https" : "",
262
+ signupUrl: httpsUrl(env.SIGNUP_URL),
263
+ branding: env.BRANDING === "hosted" ? "hosted" : "",
264
+ dataRegion,
265
+ tenantDomain: (env.TENANT_DOMAIN || "").trim().toLowerCase().replace(/^\.+|\.+$/g, ""),
266
+ approvalOidc: {
267
+ issuer: httpsUrl(env.APPROVAL_OIDC_ISSUER),
268
+ clientId: (env.APPROVAL_OIDC_CLIENT_ID || "").trim(),
269
+ hasClientSecret: !!(env.APPROVAL_OIDC_CLIENT_SECRET || "").trim(),
270
+ allowedSubjects: csv(env.APPROVAL_OIDC_ALLOWED_SUBJECTS),
271
+ methods: parsedMethods.methods,
272
+ methodsSpecified: parsedMethods.specified,
273
+ label: "",
274
+ },
275
+ },
276
+ };
277
+ }
278
+
279
+ /**
280
+ * DO namespace for a tenant. `eu` and `fedramp` select that jurisdiction; anything else uses the namespace's
281
+ * default placement. The same name in another jurisdiction is a different object. Callers pass the region stored
282
+ * on the directory entry (`pinDirectoryRegion` writes it once).
283
+ */
284
+ export function namespaceForRegion<T extends { jurisdiction?(loc: string): T }>(ns: T, region: string): T {
285
+ if ((region === "eu" || region === "fedramp") && typeof ns.jurisdiction === "function") return ns.jurisdiction(region);
286
+ return ns;
287
+ }
288
+
289
+ function pickConfig(src: ConfigFields | Record<string, string | undefined> | undefined, keys: readonly ConfigKey[]): ConfigFields {
290
+ const out: ConfigFields = {};
291
+ if (!src) return out;
292
+ for (const k of keys) {
293
+ const v = src[k];
294
+ if (typeof v === "string") out[k] = v;
295
+ }
296
+ return out;
297
+ }
298
+
299
+ /**
300
+ * One tenant's view of config, storage and auth. Handlers take this and do not read Worker bindings themselves.
301
+ * `mode: "single"` is every self-host deploy and any process that has not been handed a hosted overlay.
302
+ */
303
+ export class TenantContext implements ConfigFields {
304
+ readonly id: string;
305
+ readonly mode: TenancyMode;
306
+ readonly status: string;
307
+ readonly DB: SqlDb;
308
+ readonly PUBLIC_URL: string;
309
+ readonly gates: FeatureGates;
310
+ readonly limits: Record<string, unknown>;
311
+ readonly approval: Record<string, unknown>;
312
+ readonly RETIRED_HOSTNAMES?: string;
313
+ readonly AGENT_NAME?: string;
314
+ readonly AGENT_DESCRIPTION?: string;
315
+ readonly AGENT_VERSION?: string;
316
+ readonly AGENT_SKILLS?: string;
317
+ readonly PROVIDER_ORGANIZATION?: string;
318
+ readonly PROVIDER_URL?: string;
319
+ readonly DOCUMENTATION_URL?: string;
320
+ readonly WAKE_WEBHOOK_URL?: string;
321
+ readonly WAKE_WEBHOOK_KEY?: string;
322
+ readonly WAKE_HMAC_SECRET?: string;
323
+ readonly WAKE_ACCESS_CLIENT_ID?: string;
324
+ readonly WAKE_ACCESS_CLIENT_SECRET?: string;
325
+ readonly WAKE_PRESET?: string;
326
+ readonly WAKE_AGENT_ID?: string;
327
+ readonly WAKE_KEY_HEADER?: string;
328
+ readonly WAKE_KEY_PREFIX?: string;
329
+ readonly WAKE_BODY_TEMPLATE?: string;
330
+ readonly WAKE_CLI_COMMAND?: string;
331
+ readonly WAKE_DEBOUNCE_SECONDS?: string;
332
+ readonly WAKE_MAX_PER_HOUR?: string;
333
+ readonly MAX_BODY?: string;
334
+ readonly RATE_PER_MIN?: string;
335
+ readonly PAIRING_APPROVAL?: string;
336
+ readonly PBKDF2_ITERATIONS?: string;
337
+ readonly UPSTREAM_URL?: string;
338
+ readonly UPSTREAM_CARD_URL?: string;
339
+ readonly UPSTREAM_TOKEN?: string;
340
+ readonly UPSTREAM_ACCESS_CLIENT_ID?: string;
341
+ readonly UPSTREAM_ACCESS_CLIENT_SECRET?: string;
342
+ readonly MCP?: string;
343
+
344
+ /** Self-host only. Never set on a hosted context, so a platform OWNER_TOKEN cannot unlock a tenant. */
345
+ readonly #ownerToken?: string;
346
+ /** Hosted only: lowercase hex SHA-256 of the owner token (the same hash peer tokens use, not PBKDF2). */
347
+ readonly #ownerTokenHash?: string;
348
+ readonly #secretsKey?: string;
349
+ /** OpenID Connect client secret. Never logged and never copied onto `gates`. */
350
+ readonly #approvalClientSecret?: string;
351
+ #peerKey: Promise<CryptoKey | null> | null = null;
352
+
353
+ constructor(init: {
354
+ id: string; mode: TenancyMode; status: string; db: SqlDb; publicUrl: string;
355
+ gates: FeatureGates; limits: Record<string, unknown>; approval: Record<string, unknown>;
356
+ config: ConfigFields; ownerToken?: string; ownerTokenHash?: string; secretsKey?: string;
357
+ approvalClientSecret?: string;
358
+ }) {
359
+ this.id = init.id;
360
+ this.mode = init.mode;
361
+ this.status = init.status;
362
+ this.DB = init.db;
363
+ this.PUBLIC_URL = init.publicUrl;
364
+ this.gates = init.gates;
365
+ this.limits = init.limits;
366
+ this.approval = init.approval;
367
+ this.#ownerToken = init.ownerToken;
368
+ this.#ownerTokenHash = init.ownerTokenHash;
369
+ this.#secretsKey = init.secretsKey;
370
+ this.#approvalClientSecret = init.approvalClientSecret;
371
+ Object.assign(this, init.config);
372
+ }
373
+
374
+ /** OpenID Connect client secret, or "" when unset. Callers must not log the return value. */
375
+ approvalClientSecret(): string {
376
+ return this.#approvalClientSecret || "";
377
+ }
378
+
379
+ /**
380
+ * Owner bearer check.
381
+ * Self-host: hash both the presented token and OWNER_TOKEN, then compare (unchanged).
382
+ * Hosted: hash the presented token once and compare to the stored owner_token_hash. No plaintext owner token.
383
+ * Both compares are constant-time on the 64-char digests. A missing credential returns before hashing, as today
384
+ * for an unset OWNER_TOKEN.
385
+ */
386
+ async isOwner(header: string | null): Promise<boolean> {
387
+ if (!header || !/^bearer /i.test(header)) return false;
388
+ const presented = header.slice(7).trim();
389
+ if (this.mode === "single") {
390
+ if (!this.#ownerToken) return false;
391
+ return A.timingSafeEqualStr(await A.sha256(presented), await A.sha256(this.#ownerToken));
392
+ }
393
+ if (!this.#ownerTokenHash || !HEX64.test(this.#ownerTokenHash)) return false;
394
+ const got = await A.sha256(presented);
395
+ // Digest length is fixed, so the length check inside timingSafeEqualStr does not leak the secret.
396
+ return A.timingSafeEqualStr(got, this.#ownerTokenHash);
397
+ }
398
+
399
+ /**
400
+ * Key that seals `outbound_peers.token_enc`.
401
+ * Self-host: HKDF(OWNER_TOKEN) with the historical salt and info (byte-identical to the previous helper).
402
+ * Hosted: HKDF(TENANT_SECRETS_KEY) with info bound to the tenant id. Null when the material is missing.
403
+ */
404
+ peerKey(): Promise<CryptoKey | null> {
405
+ if (!this.#peerKey) this.#peerKey = this.#derivePeerKey();
406
+ return this.#peerKey;
407
+ }
408
+
409
+ async #derivePeerKey(): Promise<CryptoKey | null> {
410
+ if (this.mode === "single") return this.#ownerToken ? derivePeerKey(this.#ownerToken) : null;
411
+ if (!this.#secretsKey || !this.id) return null;
412
+ return derivePeerKey(this.#secretsKey, peerKeyInfoForTenant(this.id));
413
+ }
414
+ }
415
+
416
+ /** Self-host context from Worker bindings. `req` rewrites PUBLIC_URL the way the fetch handler always has. */
417
+ export function resolveTenant(env: WorkerBindings, req?: Request): TenantContext {
418
+ const { gates } = parseGates(env);
419
+ const publicUrl = req ? A.publicOrigin(env.PUBLIC_URL, req.url) : (env.PUBLIC_URL || "");
420
+ return new TenantContext({
421
+ id: "", mode: "single", status: "active", db: env.DB, publicUrl, gates,
422
+ limits: {}, approval: {}, config: pickConfig(env, CONFIG_KEYS),
423
+ ownerToken: env.OWNER_TOKEN, secretsKey: undefined,
424
+ approvalClientSecret: (env.APPROVAL_OIDC_CLIENT_SECRET || "").trim(),
425
+ });
426
+ }
427
+
428
+ /**
429
+ * Hosted context from a pushed overlay plus platform gates. Ignores env.OWNER_TOKEN and does not inherit secret
430
+ * config from the Worker. Callers (the tenant DO) pass the overlay; the self-host fetch path never does.
431
+ */
432
+ export function hostedTenantContext(env: WorkerBindings, overlay: HostedOverlay): TenantContext {
433
+ const parsed = parseGates(env);
434
+ // Operational caps default from the Worker; everything else, including every secret, comes only from the overlay.
435
+ const config: ConfigFields = pickConfig(env, HOSTED_PLATFORM_DEFAULTS);
436
+ for (const k of CONFIG_KEYS) {
437
+ const v = overlay.config?.[k];
438
+ if (typeof v === "string") config[k] = v;
439
+ }
440
+ const hash = (overlay.ownerTokenHash || "").trim().toLowerCase();
441
+ // Hosted approval is the pushed tenant object only. Worker APPROVAL_OIDC_* does not approve a tenant.
442
+ const approval = approvalFromRecord(overlay.approval);
443
+ return new TenantContext({
444
+ id: overlay.id, mode: "host", status: overlay.status || "active", db: overlay.db,
445
+ publicUrl: overlay.publicUrl, gates: { ...parsed.gates, approvalOidc: approval.gate },
446
+ limits: overlay.limits || {}, approval: approval.publicRecord,
447
+ config, ownerTokenHash: hash || undefined, secretsKey: parsed.secretsKey,
448
+ approvalClientSecret: approval.clientSecret,
449
+ });
450
+ }
@@ -0,0 +1,202 @@
1
+ // Wake URL checks for WAKE_TARGET_POLICY=public-https. Unset stays the previous fetch
2
+ // (any URL the operator configured, redirects followed by the platform).
3
+ import { isPrivateHost } from "./a2a.ts";
4
+ import { isPublicIp, net, parseHttpResponse, type PinnedSocket } from "./mcp.ts";
5
+
6
+ /** DNS-over-HTTPS resolver. Same endpoint the client-metadata fetch uses. */
7
+ export const DOH_URL = "https://cloudflare-dns.com/dns-query";
8
+
9
+ /** Original request plus this many redirects. Each hop is checked before it is requested. */
10
+ const MAX_REDIRECTS = 3;
11
+
12
+ /** Host to addresses. `null` means the lookup failed and the caller must fail closed. */
13
+ export type AddressLookup = (host: string) => Promise<string[] | null>;
14
+
15
+ type Connect = NonNullable<typeof net.connect>;
16
+
17
+ export type WakeDeps = {
18
+ lookup?: AddressLookup;
19
+ connect?: Connect;
20
+ };
21
+
22
+ function bareHost(host: string): string {
23
+ return host.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
24
+ }
25
+
26
+ /** An address literal (IPv4, or IPv6 with or without brackets). Names go through DNS. */
27
+ export function isIpLiteral(host: string): boolean {
28
+ const h = bareHost(host);
29
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(h)) return true;
30
+ return h.includes(":") && /^[0-9a-f:.]+$/.test(h);
31
+ }
32
+
33
+ /**
34
+ * A and AAAA for `host`, CNAMEs followed by the resolver. `null` on any DNS or transport failure.
35
+ * An empty list is a real answer with no address (NXDOMAIN or no A/AAAA).
36
+ */
37
+ export async function lookupAddresses(host: string, signal?: AbortSignal): Promise<string[] | null> {
38
+ const ips: string[] = [];
39
+ for (const type of ["A", "AAAA"]) {
40
+ let body: { Status?: number; Answer?: { type?: number; data?: string }[] };
41
+ try {
42
+ const res = await fetch(`${DOH_URL}?name=${encodeURIComponent(host)}&type=${type}`, {
43
+ headers: { accept: "application/dns-json" },
44
+ redirect: "manual",
45
+ signal,
46
+ });
47
+ if (!res.ok) return null;
48
+ body = await res.json();
49
+ } catch {
50
+ return null;
51
+ }
52
+ // Status 3 is NXDOMAIN. AAAA is often NXDOMAIN when A exists; that is not a failure.
53
+ if (body?.Status !== 0 && !(body?.Status === 3 && type === "AAAA")) return null;
54
+ for (const answer of Array.isArray(body.Answer) ? body.Answer : []) {
55
+ if (answer?.type === 1 || answer?.type === 28) ips.push(String(answer.data));
56
+ }
57
+ }
58
+ return ips;
59
+ }
60
+
61
+ /**
62
+ * Why this URL must not be requested under public-https. Empty means it may be requested.
63
+ * Names are resolved. Every returned address must be a public unicast address: private, loopback,
64
+ * link-local (including metadata), CGNAT, multicast, and documentation ranges are refused.
65
+ * A lookup that cannot be completed is refused.
66
+ */
67
+ export async function publicWakeProblem(url: string, lookup: AddressLookup = lookupAddresses): Promise<string> {
68
+ const checked = await checkedWakeAddress(url, lookup);
69
+ return "refused" in checked ? checked.refused : "";
70
+ }
71
+
72
+ /** The address the next request must use. A name is resolved once; the returned IP is from that answer. */
73
+ export async function checkedWakeAddress(url: string, lookup: AddressLookup = lookupAddresses): Promise<{ ip: string } | { refused: string }> {
74
+ let parsed: URL;
75
+ try { parsed = new URL(url); } catch { return { refused: "not a URL" }; }
76
+ if (parsed.protocol !== "https:") return { refused: "must be https" };
77
+ if (parsed.username || parsed.password) return { refused: "must not contain credentials" };
78
+ const host = parsed.hostname;
79
+ if (!host || isPrivateHost(host)) return { refused: "private or internal host" };
80
+ const bare = bareHost(host);
81
+ if (isIpLiteral(host)) return isPublicIp(bare) ? { ip: bare } : { refused: "private or reserved address" };
82
+ let ips: string[] | null;
83
+ try { ips = await lookup(host); } catch { return { refused: "its address could not be checked" }; }
84
+ if (ips == null) return { refused: "its address could not be checked" };
85
+ if (!ips.length) return { refused: "the host has no address" };
86
+ if (!ips.every((ip) => isPublicIp(ip))) return { refused: "resolves to a private or reserved address" };
87
+ return { ip: ips[0] };
88
+ }
89
+
90
+ function headerEntries(headers: HeadersInit | undefined): [string, string][] {
91
+ const out: [string, string][] = [];
92
+ const list = new Headers(headers);
93
+ list.forEach((value, key) => out.push([key, value]));
94
+ return out;
95
+ }
96
+
97
+ function requestBytes(method: string, url: URL, headers: HeadersInit | undefined, body: Uint8Array): Uint8Array {
98
+ const lines = [`${method} ${url.pathname}${url.search} HTTP/1.1`, `host: ${url.host}`];
99
+ for (const [key, value] of headerEntries(headers)) {
100
+ if (key === "host" || key === "content-length" || key === "connection" || key === "transfer-encoding") continue;
101
+ if (/[\r\n]/.test(key) || /[\r\n]/.test(value)) throw new Error("invalid header");
102
+ lines.push(`${key}: ${value}`);
103
+ }
104
+ if (body.byteLength) lines.push(`content-length: ${body.byteLength}`);
105
+ lines.push("connection: close");
106
+ const head = new TextEncoder().encode(lines.join("\r\n") + "\r\n\r\n");
107
+ const raw = new Uint8Array(head.byteLength + body.byteLength);
108
+ raw.set(head);
109
+ raw.set(body, head.byteLength);
110
+ return raw;
111
+ }
112
+
113
+ async function readSocket(sock: PinnedSocket, cap: number): Promise<Uint8Array> {
114
+ const reader = sock.readable.getReader();
115
+ const chunks: Uint8Array[] = [];
116
+ let n = 0;
117
+ for (;;) {
118
+ const { done, value } = await reader.read();
119
+ if (done) break;
120
+ n += value.byteLength;
121
+ if (n > cap) break;
122
+ chunks.push(value);
123
+ }
124
+ const raw = new Uint8Array(Math.min(n, cap));
125
+ let o = 0;
126
+ for (const chunk of chunks) {
127
+ const take = Math.min(chunk.byteLength, raw.byteLength - o);
128
+ raw.set(chunk.subarray(0, take), o);
129
+ o += take;
130
+ }
131
+ return raw;
132
+ }
133
+
134
+ /**
135
+ * HTTPS to `ip`, with TLS checked for the URL's hostname. The platform does not resolve the name again.
136
+ */
137
+ async function pinnedRequest(ip: string, url: string, method: string, headers: HeadersInit | undefined, body: Uint8Array, connect: Connect): Promise<Response> {
138
+ const parsed = new URL(url);
139
+ const sock = connect({ hostname: ip, port: Number(parsed.port || 443) }, { secureTransport: "starttls" }).startTls({ expectedServerHostname: parsed.hostname });
140
+ try {
141
+ const writer = sock.writable.getWriter();
142
+ await writer.write(requestBytes(method, parsed, headers, body));
143
+ writer.releaseLock();
144
+ const raw = await readSocket(sock, 65536);
145
+ const message = parseHttpResponse(raw);
146
+ if (typeof message === "string") throw new Error(message);
147
+ const responseHeaders = new Headers();
148
+ for (const [key, value] of Object.entries(message.headers)) responseHeaders.set(key, value);
149
+ const payload = message.status === 204 || message.status === 304 ? null : message.body;
150
+ return new Response(payload, { status: message.status, headers: responseHeaders });
151
+ } finally {
152
+ await sock.close().catch(() => {});
153
+ }
154
+ }
155
+
156
+ function bodyBytes(body: BodyInit | null | undefined): Uint8Array {
157
+ if (body == null) return new Uint8Array();
158
+ if (typeof body === "string") return new TextEncoder().encode(body);
159
+ if (body instanceof Uint8Array) return body;
160
+ throw new Error("unsupported wake body");
161
+ }
162
+
163
+ /**
164
+ * POST (or whatever `init` says) to `url` on the address that passed the public-https check.
165
+ * A redirect is followed only when it stays on the same origin, and that hop is checked and pinned again.
166
+ * A cross-origin redirect is refused, so wake credentials are not sent to another host.
167
+ * 301, 302, and 303 drop the body. 307 and 308 keep it.
168
+ */
169
+ export async function fetchCheckedWake(
170
+ url: string,
171
+ init: RequestInit,
172
+ deps: WakeDeps = {},
173
+ ): Promise<{ response: Response } | { refused: string }> {
174
+ const lookup = deps.lookup ?? lookupAddresses;
175
+ const connect = async (): Promise<Connect> => {
176
+ if (deps.connect) return deps.connect;
177
+ if (net.connect) return net.connect;
178
+ return (await import("cloudflare:sockets")).connect as unknown as Connect;
179
+ };
180
+ let current = url;
181
+ let method = (init.method || "GET").toUpperCase();
182
+ let body = bodyBytes(method === "GET" || method === "HEAD" ? undefined : init.body);
183
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
184
+ const checked = await checkedWakeAddress(current, lookup);
185
+ if ("refused" in checked) return checked;
186
+ const res = await pinnedRequest(checked.ip, current, method, init.headers, method === "GET" || method === "HEAD" ? new Uint8Array() : body, await connect());
187
+ const location = res.headers.get("location");
188
+ if (res.status < 300 || res.status >= 400 || !location) return { response: res };
189
+ if (hop === MAX_REDIRECTS) return { refused: "too many redirects" };
190
+ let next: URL;
191
+ try { next = new URL(location, current); } catch { return { refused: "not a URL" }; }
192
+ const ahead = await checkedWakeAddress(next.href, lookup);
193
+ if ("refused" in ahead) return ahead;
194
+ if (next.origin !== new URL(current).origin) return { refused: "cross-origin redirect" };
195
+ if (res.status === 303 || ((res.status === 301 || res.status === 302) && method !== "GET" && method !== "HEAD")) {
196
+ method = "GET";
197
+ body = new Uint8Array();
198
+ }
199
+ current = next.href;
200
+ }
201
+ return { refused: "too many redirects" };
202
+ }
@@ -1,5 +1,6 @@
1
1
  // Wake presets: how the Worker pokes the agent when something lands in the inbox.
2
2
  // Pure functions (Web Crypto only) so they run in Workers and in Node's test runner.
3
+ import { DEFAULT_CLI } from "./pairing.ts";
3
4
 
4
5
  export type WakeEvent = {
5
6
  contextId: string;
@@ -129,7 +130,7 @@ export function claudeCodeColdStart(ev: WakeEvent, cli: string): string {
129
130
  if (ev.kind === "pairing_request" && ev.pairing?.approval === "human") return "";
130
131
  return [
131
132
  `Preferred: if this session has the a2a-exposed connector (MCP tools inbox, show_task, reply, send, pairing_requests), use those tools instead of the CLI commands above; they need nothing else in the environment.`,
132
- `Fallback without the connector: this routine session needs Node 22.18+, the a2a-exposed CLI (\`${cli}\`; \`npx -y a2a-exposed@latest\` works) and the environment variables A2A_BASE_URL and A2A_OWNER_TOKEN from the routine's cloud environment, with network access to ${ev.publicUrl.replace(/\/$/, "")}.`,
133
+ `Fallback without the connector: this routine session needs Node 22.18+, the a2a-exposed CLI (run as \`${cli}\`, no global install) and the environment variables A2A_BASE_URL and A2A_OWNER_TOKEN from the routine's cloud environment, with network access to ${ev.publicUrl.replace(/\/$/, "")}.`,
133
134
  `If any of that is missing (\`A2A_BASE_URL / A2A_OWNER_TOKEN missing\`, or a 403 host_not_allowed), don't work around it and never ask for the token in chat: tell your human the a2a-exposed Claude Code setup is incomplete (add the connector at claude.ai/settings/connectors: the inbox URL + /mcp; or see the Claude Code checklist in the setup skill's wake reference).`,
134
135
  ].join("\n");
135
136
  }
@@ -169,7 +170,7 @@ async function hermesSign(headers: Record<string, string>, secret: string, body:
169
170
  /** Build the HTTP request for a wake, or return null when the wake is not configured. */
170
171
  export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?: number; requestId?: string } = {}): Promise<WakeRequest | null> {
171
172
  if (!cfg.url) return null;
172
- const cli = cfg.cliCommand || "npx a2a-exposed";
173
+ const cli = cfg.cliCommand || DEFAULT_CLI;
173
174
  const nowMs = opts.nowMs ?? Date.now();
174
175
  const requestId = opts.requestId ?? crypto.randomUUID();
175
176
  const headers: Record<string, string> = { "content-type": "application/json", "user-agent": "a2a-exposed" };