a2a-exposed 0.7.10 → 0.7.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/main.mjs +5 -1
- package/lib/pair.mjs +41 -5
- package/package.json +1 -1
- package/worker/src/hosted.ts +46 -8
- package/worker/src/index.ts +48 -1
- package/worker/src/owner-rpc.ts +175 -0
- package/worker/src/pairing.ts +2 -7
- package/worker/src/tenancy.ts +3 -2
- package/worker/src/theme.ts +14 -0
- package/worker/src/wake-secrets.ts +113 -0
- package/worker/src/wake.ts +15 -2
- package/worker/test/hosted.test.ts +130 -0
- package/worker/test/owner-rpc.test.ts +72 -0
- package/worker/test/pairing.test.ts +19 -0
- package/worker/test/theme.test.ts +85 -0
- package/worker/test/wake-secrets.test.ts +49 -0
- package/worker/test/wake.test.ts +16 -0
- package/worker/theme-build.mjs +162 -0
- package/worker/vite.config.ts +12 -1
package/lib/main.mjs
CHANGED
|
@@ -126,7 +126,11 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
|
|
|
126
126
|
peer then swaps the old token for the new one under the same label (older peers
|
|
127
127
|
keep the old token until their owner revokes it). --card-url: the card to name in
|
|
128
128
|
the request (default: this deployment's); a *.ts.net / LAN card is sent as
|
|
129
|
-
informational (flagged "not publicly reachable"), a non-https one is left out
|
|
129
|
+
informational (flagged "not publicly reachable"), a non-https one is left out.
|
|
130
|
+
The name sent is the card named in the request (--card-url, otherwise this
|
|
131
|
+
deployment's). --name overrides it. If that card can't be read, the name is the
|
|
132
|
+
configured agent name, the worker name, or the public host's first label. The
|
|
133
|
+
other inbox labels the token from that name and adds -2, -3, … when it is taken
|
|
130
134
|
pair set-password --web [--ttl MIN] [--json]
|
|
131
135
|
one-time link (default 15 min, single use) where your human sets or changes the
|
|
132
136
|
approval password on a web page. Send it to them privately; never open or fill it
|
package/lib/pair.mjs
CHANGED
|
@@ -193,6 +193,39 @@ export function deviceEndpointsFromCard(card) {
|
|
|
193
193
|
const slug = (s) => String(s || "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/, "").toLowerCase();
|
|
194
194
|
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000 * Number(process.env.A2A_POLL_SCALE || 1)));
|
|
195
195
|
|
|
196
|
+
/** First DNS label of this deployment's public URL, or "" for loopback and an unusable host. */
|
|
197
|
+
export function hostAlias(ownUrl) {
|
|
198
|
+
if (!ownUrl) return "";
|
|
199
|
+
try {
|
|
200
|
+
const host = new URL(ownUrl).hostname;
|
|
201
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return "";
|
|
202
|
+
return slug(host.split(".")[0]);
|
|
203
|
+
} catch { return ""; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* client_name for a pairing request. An explicit `--name` wins, then the card named in the request
|
|
208
|
+
* (`--card-url`, otherwise this deployment's card), then the configured agent name, then the local
|
|
209
|
+
* alias (worker name, else the public host's first label).
|
|
210
|
+
*/
|
|
211
|
+
export function pairingClientName({ explicit = "", cardName = "", agentName = "", workerName = "", ownUrl = "" } = {}) {
|
|
212
|
+
const text = (v) => String(v || "").replace(/\s+/g, " ").trim().slice(0, 64);
|
|
213
|
+
for (const v of [explicit, cardName, agentName, workerName, hostAlias(ownUrl)]) {
|
|
214
|
+
const t = text(v);
|
|
215
|
+
if (t) return t;
|
|
216
|
+
}
|
|
217
|
+
return "a2a-exposed agent";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Name on a card URL, or "" when the card can't be read. */
|
|
221
|
+
async function readCardName(url) {
|
|
222
|
+
if (!url) return "";
|
|
223
|
+
try {
|
|
224
|
+
const { status, data } = await httpJson(url, { timeout: 3000 });
|
|
225
|
+
return status === 200 && data && typeof data.name === "string" ? data.name : "";
|
|
226
|
+
} catch { return ""; }
|
|
227
|
+
}
|
|
228
|
+
|
|
196
229
|
async function postForm(url, params, bearer = null) {
|
|
197
230
|
let r;
|
|
198
231
|
const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
@@ -315,16 +348,19 @@ A different agent at the same URL? Pass another --alias.`);
|
|
|
315
348
|
`Resuming the pairing request to ${peerName} (code ${st.userCode}, link ${st.verificationUriComplete}).`);
|
|
316
349
|
else {
|
|
317
350
|
const own = baseUrl();
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
351
|
+
const sentCard = cardUrlToSend(o["card-url"], own, o.json);
|
|
352
|
+
// The name is the card this request actually names. --card-url (a Tailnet card, before a public
|
|
353
|
+
// deployment exists) wins. With no override, read this deployment's card, including an http one.
|
|
354
|
+
const nameUrl = sentCard || (!o["card-url"] && /^https?:\/\//.test(own || "") ? `${own.replace(/\/$/, "")}/.well-known/agent-card.json` : "");
|
|
355
|
+
const params = { client_name: pairingClientName({ explicit: o.name, cardName: await readCardName(nameUrl), agentName: C.get("A2A_AGENT_NAME"), workerName: C.get("A2A_WORKER_NAME"), ownUrl: own }), client_id: C.get("A2A_WORKER_NAME") || alias };
|
|
356
|
+
if (sentCard) params.agent_card_url = sentCard;
|
|
321
357
|
// replacing: present the old token, so a peer that supports it swaps the token under the same label (no orphan)
|
|
322
358
|
const r = await postForm(ep.device, params, replace && oldToken ? oldToken : null);
|
|
323
359
|
if (r.status !== 200 || !r.data.device_code || !r.data.user_code) {
|
|
324
360
|
const why = r.data.error_description || r.data.error || `HTTP ${r.status}`;
|
|
325
|
-
const privateCard = !!
|
|
361
|
+
const privateCard = !!sentCard && (() => { try { return isPrivateHost(new URL(sentCard).hostname); } catch { return false; } })();
|
|
326
362
|
die(r.status === 429 ? `${peerName} is not accepting more pairing requests right now: ${why}` : r.status === 404 ? `${peerName} has device-flow pairing disabled (${why}); ask its owner for a token`
|
|
327
|
-
: `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(
|
|
363
|
+
: `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(sentCard).host}) is not publicly reachable, and inboxes may accept only public https cards: retry with --card-url set to a public card, or expose your agent through a public façade first (setup skill: "Already have A2A on a Tailnet or LAN")` : ""}`);
|
|
328
364
|
}
|
|
329
365
|
st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
|
|
330
366
|
verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
|
package/package.json
CHANGED
package/worker/src/hosted.ts
CHANGED
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
// Usage push is not in this change. Approval OIDC, when configured (self-host settings, or the pushed tenant
|
|
14
14
|
// `approval` object), uses the same pages as self-host.
|
|
15
15
|
import { DurableObject } from "cloudflare:workers";
|
|
16
|
-
import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite } from "./index.ts";
|
|
16
|
+
import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite, runOwnerRpc } from "./index.ts";
|
|
17
|
+
import { accountIdOf, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
17
18
|
import { doSqlD1, migrateDo, type SqlStorageLike, type TxRunner } from "./storage.ts";
|
|
18
19
|
import { MIGRATIONS } from "./migrations.ts";
|
|
19
20
|
import { bookmarkForTime as readBookmark, exportRows, restoreBookmark as applyBookmark, restartAfterResult, toSql, ExportError, type BookmarkStorage } from "./export.ts";
|
|
20
21
|
import { hostedDailyBackup } from "./backup.ts";
|
|
21
22
|
import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
|
|
22
23
|
import { hostedTenantContext, namespaceForRegion, parseGates, type WorkerBindings } from "./tenancy.ts";
|
|
24
|
+
import { WAKE_SECRET_KEYS, openWakeSecrets } from "./wake-secrets.ts";
|
|
23
25
|
|
|
24
26
|
export { clearDirectoryCache, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME } from "./directory.ts";
|
|
25
27
|
export type { DirectoryEntry };
|
|
@@ -32,7 +34,7 @@ const gone = () => Response.json({ error: "this agent is gone" }, { status: 410,
|
|
|
32
34
|
|
|
33
35
|
const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
34
36
|
|
|
35
|
-
/** Versioned config pushed by the control plane. `secretsEnc` is
|
|
37
|
+
/** Versioned config pushed by the control plane. `secretsEnc` is decrypted into the wake credentials when the context is built. */
|
|
36
38
|
export interface TenantConfigPush {
|
|
37
39
|
version: number;
|
|
38
40
|
tenantId: string;
|
|
@@ -151,14 +153,29 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
151
153
|
});
|
|
152
154
|
}
|
|
153
155
|
|
|
154
|
-
#tenant(fallbackOrigin = "") {
|
|
156
|
+
async #tenant(fallbackOrigin = "") {
|
|
155
157
|
const row = this.#row;
|
|
156
158
|
if (!row) return null;
|
|
157
|
-
const
|
|
159
|
+
const parsed = parseGates(this.env);
|
|
160
|
+
const domain = parsed.gates.tenantDomain;
|
|
158
161
|
const publicUrl = domain ? `https://${row.name}.${domain}` : (fallbackOrigin || this.env.PUBLIC_URL || "");
|
|
162
|
+
const config = { ...row.config };
|
|
163
|
+
// Once a ciphertext is stored, the secret wake fields come only from it. A plaintext copy in
|
|
164
|
+
// config_json is ignored, and a blob that cannot be opened leaves those fields unset.
|
|
165
|
+
if (row.secretsEnc) {
|
|
166
|
+
const opened = await openWakeSecrets(parsed.secretsKey || "", row.tenantId, row.secretsEnc);
|
|
167
|
+
if (!opened) {
|
|
168
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "wake_secrets_unreadable", tenant: row.tenantId }));
|
|
169
|
+
}
|
|
170
|
+
for (const key of WAKE_SECRET_KEYS) {
|
|
171
|
+
const value = opened?.[key];
|
|
172
|
+
if (value) config[key] = value;
|
|
173
|
+
else delete config[key];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
159
176
|
return hostedTenantContext(this.env, {
|
|
160
177
|
id: row.tenantId, db: this.#db, publicUrl, ownerTokenHash: row.ownerTokenHash,
|
|
161
|
-
config
|
|
178
|
+
config, limits: row.limits, approval: row.approval, status: row.status,
|
|
162
179
|
});
|
|
163
180
|
}
|
|
164
181
|
|
|
@@ -198,7 +215,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
198
215
|
await storage.deleteAlarm();
|
|
199
216
|
return;
|
|
200
217
|
}
|
|
201
|
-
const tenant = this.#tenant();
|
|
218
|
+
const tenant = await this.#tenant();
|
|
202
219
|
if (!tenant) return;
|
|
203
220
|
const at = await nextCronAt(tenant);
|
|
204
221
|
if (at == null || !Number.isFinite(at)) {
|
|
@@ -226,7 +243,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
226
243
|
}
|
|
227
244
|
|
|
228
245
|
async #runAlarm(): Promise<void> {
|
|
229
|
-
const tenant = this.#row?.status === "active" ? this.#tenant() : null;
|
|
246
|
+
const tenant = this.#row?.status === "active" ? await this.#tenant() : null;
|
|
230
247
|
if (tenant) {
|
|
231
248
|
try { await cronFlush(tenant, this.#ectx()); }
|
|
232
249
|
catch (e) { console.log(JSON.stringify({ ts: new Date().toISOString(), event: "alarm_failed", error: String(e).slice(0, 200) })); }
|
|
@@ -337,6 +354,27 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
337
354
|
return out;
|
|
338
355
|
}
|
|
339
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Control-plane owner call. The caller has already checked that `accountId` owns this tenant.
|
|
359
|
+
* The path is an allowlist, the account id is logged, and a response that contains a peer token
|
|
360
|
+
* or a decrypted secret is withheld. The network owner API is unchanged and still needs the owner token.
|
|
361
|
+
* An object with no config row answers 404 and writes nothing.
|
|
362
|
+
*/
|
|
363
|
+
async owner(input: OwnerRpcRequest): Promise<OwnerRpcResult> {
|
|
364
|
+
if (!this.#row) {
|
|
365
|
+
const accountId = accountIdOf(input && typeof input === "object" ? (input as { accountId?: unknown }).accountId : undefined);
|
|
366
|
+
if (!accountId) {
|
|
367
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "owner_rpc_refused", tenant: null, reason: "account" }));
|
|
368
|
+
return { status: 400, body: { error: "accountId is invalid" } };
|
|
369
|
+
}
|
|
370
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "owner_rpc_refused", tenant: null, accountId, reason: "absent" }));
|
|
371
|
+
return { status: 404, body: { error: "no such agent" } };
|
|
372
|
+
}
|
|
373
|
+
const tenant = await this.#tenant();
|
|
374
|
+
if (!tenant) return { status: 404, body: { error: "no such agent" } };
|
|
375
|
+
return runOwnerRpc(tenant, this.#ectx(), input);
|
|
376
|
+
}
|
|
377
|
+
|
|
340
378
|
async fetch(req: Request): Promise<Response> {
|
|
341
379
|
const row = this.#row;
|
|
342
380
|
if (!row) return notFound();
|
|
@@ -346,7 +384,7 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
346
384
|
if (row.status === "deleting") return gone();
|
|
347
385
|
return notFound();
|
|
348
386
|
}
|
|
349
|
-
const tenant = this.#tenant(new URL(req.url).origin);
|
|
387
|
+
const tenant = await this.#tenant(new URL(req.url).origin);
|
|
350
388
|
if (!tenant) return notFound();
|
|
351
389
|
// Do not arm from the rows visible here. sendWake runs in waitUntil and, on 429/503, inserts the retry
|
|
352
390
|
// after this response returns. requeueWake arms in that insert. Arm again when those tasks finish so a
|
package/worker/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { renderWake, redact, cloudflareErrorHint, defaultDebounceSeconds, defaul
|
|
|
10
10
|
import { fetchCheckedWake } from "./wake-target.ts";
|
|
11
11
|
import * as P from "./pairing.ts";
|
|
12
12
|
import * as M from "./mcp.ts";
|
|
13
|
+
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
13
14
|
import { resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
|
|
14
15
|
import { exportRows, toSql } from "./export.ts";
|
|
15
16
|
import { backupDatabase, DAILY_CRON } from "./backup.ts";
|
|
@@ -898,7 +899,8 @@ function ownerTaskView(t: Json): Json {
|
|
|
898
899
|
}
|
|
899
900
|
|
|
900
901
|
async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false): Promise<Response> {
|
|
901
|
-
// trusted: an in-process call
|
|
902
|
+
// trusted: an in-process call. MCP tools use ownerCall (paths fixed in the tool code). The hosted
|
|
903
|
+
// owner() RPC uses runOwnerRpc, which has its own allowlist. A request from the network is never trusted.
|
|
902
904
|
if (!trusted && !(await isOwner(ctx, req.headers.get("authorization")))) return json({ error: "unauthorized" }, 401);
|
|
903
905
|
const m = req.method;
|
|
904
906
|
const seg = path.split("/").filter(Boolean).slice(1); // after "owner"
|
|
@@ -2073,6 +2075,51 @@ async function mcpGrantOf(ctx: TenantContext, header: string | null): Promise<Js
|
|
|
2073
2075
|
return await ctx.DB.prepare("SELECT * FROM mcp_grants WHERE access_hash = ? AND revoked_at IS NULL AND access_expires_ms > ?").bind(await A.sha256(m[1]), Date.now()).first();
|
|
2074
2076
|
}
|
|
2075
2077
|
|
|
2078
|
+
/**
|
|
2079
|
+
* Hosted owner RPC. The control plane has already checked that `accountId` owns this tenant.
|
|
2080
|
+
* Only an allowlisted path runs, and the response is withheld when it contains a peer token or a secret.
|
|
2081
|
+
* Self-host (`mode` single) is refused here; its owner API stays the bearer check in `handleOwner`.
|
|
2082
|
+
*/
|
|
2083
|
+
export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, input: OwnerRpcRequest): Promise<OwnerRpcResult> {
|
|
2084
|
+
const raw = input && typeof input === "object" ? input as OwnerRpcRequest & { body?: unknown } : null;
|
|
2085
|
+
const accountId = accountIdOf(raw?.accountId);
|
|
2086
|
+
const tenant = ctx.id || null;
|
|
2087
|
+
const refuse = (status: number, error: string, reason: string): OwnerRpcResult => {
|
|
2088
|
+
log("owner_rpc_refused", { tenant, ...(accountId ? { accountId } : {}), reason });
|
|
2089
|
+
return { status, body: { error } };
|
|
2090
|
+
};
|
|
2091
|
+
if (!accountId) return refuse(400, "accountId is invalid", "account");
|
|
2092
|
+
if (ctx.mode !== "host") return refuse(404, "not found", "tenancy");
|
|
2093
|
+
if (ctx.status !== "active") {
|
|
2094
|
+
const status = ctx.status === "suspended" ? 403 : ctx.status === "deleting" ? 410 : 404;
|
|
2095
|
+
const error = ctx.status === "suspended" ? "this agent is suspended" : ctx.status === "deleting" ? "this agent is gone" : "no such agent";
|
|
2096
|
+
return refuse(status, error, "status");
|
|
2097
|
+
}
|
|
2098
|
+
const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
|
|
2099
|
+
if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
|
|
2100
|
+
if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
|
|
2101
|
+
let origin = "https://tenant.invalid";
|
|
2102
|
+
try {
|
|
2103
|
+
const u = new URL(ctx.PUBLIC_URL);
|
|
2104
|
+
if (u.protocol === "https:" || u.protocol === "http:") origin = u.origin;
|
|
2105
|
+
} catch { /* the handler reads the path, not this origin */ }
|
|
2106
|
+
const url = new URL(origin + matched.route.path + matched.route.search);
|
|
2107
|
+
const req = new Request(url, { method: matched.route.method, headers: { "content-type": "application/json" } });
|
|
2108
|
+
let res: Response;
|
|
2109
|
+
try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
|
|
2110
|
+
catch (e: any) {
|
|
2111
|
+
const status = e instanceof HttpError ? e.status : 400;
|
|
2112
|
+
const error = String(e?.message || "bad request").slice(0, 200);
|
|
2113
|
+
if (ownerResponseLeaks(error)) return refuse(500, "response withheld", "secret");
|
|
2114
|
+
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status });
|
|
2115
|
+
return { status, body: { error } };
|
|
2116
|
+
}
|
|
2117
|
+
const data = await res.json().catch(() => null);
|
|
2118
|
+
if (data === null || ownerResponseLeaks(data)) return refuse(500, "response withheld", "secret");
|
|
2119
|
+
log("owner_rpc", { tenant, accountId, method: matched.route.method, route: matched.route.name, status: res.status });
|
|
2120
|
+
return { status: res.status, body: data };
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2076
2123
|
/** Call an owner API route in-process (MCP tools only; the paths are fixed in the tool code). */
|
|
2077
2124
|
async function ownerCall(ctx: TenantContext, ectx: ExecutionContext, method: string, path: string, body?: Json): Promise<{ status: number; data: Json }> {
|
|
2078
2125
|
const url = new URL(ctx.PUBLIC_URL.replace(/\/$/, "") + path);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Hosted owner RPC allowlist. The control plane calls TenantStore.owner() after it has checked that
|
|
2
|
+
// the account owns the tenant. This list is the only paths that call may run, and it is stricter than
|
|
3
|
+
// the owner HTTP API: routes that mint a peer token, return a setup secret, accept a plaintext peer
|
|
4
|
+
// token, or dump credential material (export) are not here. Self-host never calls this.
|
|
5
|
+
|
|
6
|
+
import { ID_RE } from "./a2a.ts";
|
|
7
|
+
import { previewHeaderPlain } from "./wake.ts";
|
|
8
|
+
|
|
9
|
+
/** Control-plane account id. Slashes and dots are refused so the value cannot be a path. */
|
|
10
|
+
export const ACCOUNT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
11
|
+
|
|
12
|
+
const LABEL = /^[A-Za-z0-9_-]{1,32}$/;
|
|
13
|
+
const MCP_LABEL = /^[A-Za-z0-9_-]{1,64}$/;
|
|
14
|
+
const ALIAS = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
15
|
+
/** Display form WDJB-4827 or the stored form WDJB4827. Vowels and 0/1 are not in a real code. */
|
|
16
|
+
const USER_CODE = /^[BCDFGHJKLMNPQRSTVWXZ]{4}-?[2-9]{4}$/i;
|
|
17
|
+
|
|
18
|
+
const TOKEN_RE = /^(?:a2aow_|s2a_|a2amcp_|a2amcpr_)[A-Za-z0-9_-]{20,}$/;
|
|
19
|
+
const SETUP_RE = /(?:\?|&)t=[A-Za-z0-9_-]{16,}/;
|
|
20
|
+
const USERINFO_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?\s]*@/i;
|
|
21
|
+
/** A non-empty string under one of these keys is a secret, even when it is not a token shape. */
|
|
22
|
+
const SECRET_KEYS = new Set([
|
|
23
|
+
"token", "access_token", "refresh_token", "device_code",
|
|
24
|
+
"ownertoken", "owner_token", "secretsenc", "secrets_enc",
|
|
25
|
+
"clientsecret", "client_secret", "pushtoken", "push_token", "authorization",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export interface OwnerRpcRequest {
|
|
29
|
+
accountId: string;
|
|
30
|
+
method: string;
|
|
31
|
+
path: string;
|
|
32
|
+
/** Allowlisted query parameters only. */
|
|
33
|
+
query?: Record<string, string>;
|
|
34
|
+
/** Refused. The owner HTTP API is where a body is accepted. */
|
|
35
|
+
body?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OwnerRpcResult {
|
|
39
|
+
status: number;
|
|
40
|
+
body: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OwnerRoute {
|
|
44
|
+
method: "GET" | "POST" | "DELETE";
|
|
45
|
+
path: string;
|
|
46
|
+
search: string;
|
|
47
|
+
/** Log name. Ids and codes are not included. */
|
|
48
|
+
name: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type OwnerMatch = { ok: true; route: OwnerRoute } | { ok: false; reason: "path" | "query" };
|
|
52
|
+
|
|
53
|
+
export function accountIdOf(v: unknown): string | null {
|
|
54
|
+
return typeof v === "string" && ACCOUNT_ID_RE.test(v) ? v : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function segments(path: unknown): string[] | null {
|
|
58
|
+
if (typeof path !== "string" || path.length > 300 || !path.startsWith("/owner/")) return null;
|
|
59
|
+
if (path.includes("%") || path.includes("\\") || path.includes("?") || path.includes("#") || path.includes("//")) return null;
|
|
60
|
+
const seg = path.split("/").filter(Boolean);
|
|
61
|
+
if (seg[0] !== "owner" || seg.some((s) => s === "." || s === "..")) return null;
|
|
62
|
+
return seg;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function queryRecord(query: unknown): Record<string, string> | null {
|
|
66
|
+
if (query == null) return {};
|
|
67
|
+
if (typeof query !== "object" || Array.isArray(query)) return null;
|
|
68
|
+
const out: Record<string, string> = {};
|
|
69
|
+
for (const [k, v] of Object.entries(query)) {
|
|
70
|
+
if (typeof v !== "string" || v.length > 128) return null;
|
|
71
|
+
out[k] = v;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function idOk(s: string): boolean {
|
|
77
|
+
return ID_RE.test(s) && s !== "." && s !== "..";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function noQuery(q: Record<string, string>): string | null {
|
|
81
|
+
return Object.keys(q).length === 0 ? "" : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function inboxQuery(q: Record<string, string>): string | null {
|
|
85
|
+
for (const k of Object.keys(q)) if (k !== "context" && k !== "all") return null;
|
|
86
|
+
if (q.context !== undefined && !idOk(q.context)) return null;
|
|
87
|
+
if (q.all !== undefined && q.all !== "1") return null;
|
|
88
|
+
const sp = new URLSearchParams();
|
|
89
|
+
if (q.context) sp.set("context", q.context);
|
|
90
|
+
if (q.all) sp.set("all", "1");
|
|
91
|
+
const s = sp.toString();
|
|
92
|
+
return s ? `?${s}` : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function historyQuery(q: Record<string, string>): string | null {
|
|
96
|
+
for (const k of Object.keys(q)) if (k !== "n") return null;
|
|
97
|
+
if (q.n === undefined) return "";
|
|
98
|
+
if (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000) return null;
|
|
99
|
+
return `?n=${q.n}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface RouteDef {
|
|
103
|
+
method: "GET" | "POST" | "DELETE";
|
|
104
|
+
name: string;
|
|
105
|
+
take: (seg: string[]) => boolean;
|
|
106
|
+
search: (q: Record<string, string>) => string | null;
|
|
107
|
+
path: (seg: string[]) => string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ROUTES: RouteDef[] = [
|
|
111
|
+
{ method: "GET", name: "GET /owner/inbox", take: (s) => s.length === 2 && s[1] === "inbox", search: inboxQuery, path: () => "/owner/inbox" },
|
|
112
|
+
{ method: "GET", name: "GET /owner/tasks/:id", take: (s) => s.length === 3 && s[1] === "tasks" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/tasks/${s[2]}` },
|
|
113
|
+
{ method: "GET", name: "GET /owner/history/:id", take: (s) => s.length === 3 && s[1] === "history" && idOk(s[2]!), search: historyQuery, path: (s) => `/owner/history/${s[2]}` },
|
|
114
|
+
{ method: "GET", name: "GET /owner/outbound/:id", take: (s) => s.length === 3 && s[1] === "outbound" && idOk(s[2]!), search: noQuery, path: (s) => `/owner/outbound/${s[2]}` },
|
|
115
|
+
{ method: "GET", name: "GET /owner/peers", take: (s) => s.length === 2 && s[1] === "peers", search: noQuery, path: () => "/owner/peers" },
|
|
116
|
+
{ method: "DELETE", name: "DELETE /owner/peers/:label", take: (s) => s.length === 3 && s[1] === "peers" && LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/peers/${s[2]}` },
|
|
117
|
+
{ method: "GET", name: "GET /owner/pairing", take: (s) => s.length === 2 && s[1] === "pairing", search: noQuery, path: () => "/owner/pairing" },
|
|
118
|
+
{ method: "POST", name: "POST /owner/pairing/:code/approve", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "approve", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/approve` },
|
|
119
|
+
{ method: "POST", name: "POST /owner/pairing/:code/deny", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "deny", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/deny` },
|
|
120
|
+
{ method: "GET", name: "GET /owner/mcp", take: (s) => s.length === 2 && s[1] === "mcp", search: noQuery, path: () => "/owner/mcp" },
|
|
121
|
+
{ method: "DELETE", name: "DELETE /owner/mcp/:label", take: (s) => s.length === 3 && s[1] === "mcp" && MCP_LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/mcp/${s[2]}` },
|
|
122
|
+
{ method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
|
|
123
|
+
{ method: "DELETE", name: "DELETE /owner/outbound-peers/:alias", take: (s) => s.length === 3 && s[1] === "outbound-peers" && ALIAS.test(s[2]!) && s[2] !== "." && s[2] !== "..", search: noQuery, path: (s) => `/owner/outbound-peers/${s[2]}` },
|
|
124
|
+
{ method: "GET", name: "GET /owner/facade", take: (s) => s.length === 2 && s[1] === "facade", search: noQuery, path: () => "/owner/facade" },
|
|
125
|
+
{ method: "POST", name: "POST /owner/facade/verify", take: (s) => s.length === 3 && s[1] === "facade" && s[2] === "verify", search: noQuery, path: () => "/owner/facade/verify" },
|
|
126
|
+
{ method: "GET", name: "GET /owner/wake/preview", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "preview", search: noQuery, path: () => "/owner/wake/preview" },
|
|
127
|
+
{ method: "POST", name: "POST /owner/wake/test", take: (s) => s.length === 3 && s[1] === "wake" && s[2] === "test", search: noQuery, path: () => "/owner/wake/test" },
|
|
128
|
+
{ method: "GET", name: "GET /owner/contexts", take: (s) => s.length === 2 && s[1] === "contexts", search: noQuery, path: () => "/owner/contexts" },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
|
|
132
|
+
export function matchOwnerRpc(method: unknown, path: unknown, query: unknown): OwnerMatch {
|
|
133
|
+
if (method !== "GET" && method !== "POST" && method !== "DELETE") return { ok: false, reason: "path" };
|
|
134
|
+
const seg = segments(path);
|
|
135
|
+
if (!seg) return { ok: false, reason: "path" };
|
|
136
|
+
const def = ROUTES.find((r) => r.method === method && r.take(seg));
|
|
137
|
+
if (!def) return { ok: false, reason: "path" };
|
|
138
|
+
const q = queryRecord(query);
|
|
139
|
+
if (!q) return { ok: false, reason: "query" };
|
|
140
|
+
const search = def.search(q);
|
|
141
|
+
if (search === null) return { ok: false, reason: "query" };
|
|
142
|
+
return { ok: true, route: { method, path: def.path(seg), search, name: def.name } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stringLeaks(value: string, key: string | null): boolean {
|
|
146
|
+
if (TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value)) return true;
|
|
147
|
+
return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A rendered wake whose credential header was not masked. The header name is the caller's choice. */
|
|
151
|
+
function wakePreviewLeaks(value: object): boolean {
|
|
152
|
+
const headers = (value as { method?: unknown; headers?: unknown }).headers;
|
|
153
|
+
if ((value as { method?: unknown }).method !== "POST" || !headers || typeof headers !== "object" || Array.isArray(headers)) return false;
|
|
154
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
155
|
+
if (typeof v !== "string" || previewHeaderPlain(k) || v.endsWith("…")) continue;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when a JSON value carries a peer token, a setup secret, a URL userinfo, or a secret field. */
|
|
162
|
+
export function ownerResponseLeaks(value: unknown, depth = 0, key: string | null = null): boolean {
|
|
163
|
+
if (depth > 20) return true;
|
|
164
|
+
if (typeof value === "string") return stringLeaks(value, key);
|
|
165
|
+
if (typeof value === "number" || typeof value === "boolean" || value == null) return false;
|
|
166
|
+
if (Array.isArray(value)) return value.some((v) => ownerResponseLeaks(v, depth + 1, null));
|
|
167
|
+
if (typeof value === "object") {
|
|
168
|
+
if (wakePreviewLeaks(value)) return true;
|
|
169
|
+
for (const [k, v] of Object.entries(value)) {
|
|
170
|
+
if (ownerResponseLeaks(v, depth + 1, k)) return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
package/worker/src/pairing.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Device-flow pairing (OAuth 2.0 Device Authorization Grant, RFC 8628): pure helpers (Web Crypto only), so they run
|
|
2
2
|
// in Workers and in Node's test runner. The HTTP handlers live in index.ts.
|
|
3
3
|
import { isPrivateHost } from "./a2a.ts";
|
|
4
|
+
import { pageStyle } from "./theme.ts";
|
|
4
5
|
|
|
5
6
|
/** How every hint tells an agent to run the CLI: npx, latest release, no global install (agent sandboxes block global
|
|
6
7
|
* installs as untrusted code). WAKE_CLI_COMMAND overrides it for the owner's own wake hints only. */
|
|
@@ -223,17 +224,11 @@ export function devicePage(m: PageModel, nonce: string): string {
|
|
|
223
224
|
return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${oidcForm}${ask}`);
|
|
224
225
|
}
|
|
225
226
|
|
|
226
|
-
const STYLE = `body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fafafa}
|
|
227
|
-
h1{font-size:1.3rem}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #ddd;vertical-align:top;word-break:break-all}th{width:40%;font-weight:600;word-break:normal}
|
|
228
|
-
label{display:block;font-weight:600;margin:.8rem 0 .3rem}input{font:inherit;width:100%;box-sizing:border-box;padding:.5rem;border:1px solid #888;border-radius:4px}
|
|
229
|
-
.b{display:flex;gap:.6rem;margin-top:1rem}button{font:inherit;padding:.5rem 1.2rem;border:0;border-radius:4px;background:#1a5fb4;color:#fff;cursor:pointer}button.d{background:#a51d2d}
|
|
230
|
-
h2{font-size:1.05rem;margin-top:1.5rem}ul{padding-left:1.2rem}a{color:#1a5fb4}.n{padding:.6rem .8rem;border-radius:4px}.ok{background:#e6f4ea}.error{background:#fce8e6}.info{background:#e8f0fe}.w{font-size:.9rem;color:#444}code{background:#eee;padding:0 .2rem}`;
|
|
231
|
-
|
|
232
227
|
/** The shared page frame: no scripts, no external assets, one nonce'd style block. `title` is escaped here. */
|
|
233
228
|
export function pageShell(title: string, nonce: string, body: string): string {
|
|
234
229
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer">
|
|
235
230
|
<title>${esc(title)}</title><style nonce="${nonce}">
|
|
236
|
-
${
|
|
231
|
+
${pageStyle()}
|
|
237
232
|
</style></head><body><h1>${esc(title)}</h1>${body}</body></html>`;
|
|
238
233
|
}
|
|
239
234
|
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -47,8 +47,9 @@ export type ConfigFields = { [K in ConfigKey]?: string };
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
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
|
|
51
|
-
*
|
|
50
|
+
* Wake secrets, upstream secrets and OWNER_TOKEN are never inherited. In hosted mode the secret wake
|
|
51
|
+
* fields come from decrypting `secrets_enc` (see the tenant object). A plaintext copy in the pushed
|
|
52
|
+
* config is used only when that ciphertext is absent.
|
|
52
53
|
*/
|
|
53
54
|
const HOSTED_PLATFORM_DEFAULTS: readonly ConfigKey[] = ["MAX_BODY", "RATE_PER_MIN", "PBKDF2_ITERATIONS", "MCP", "PAIRING_APPROVAL"];
|
|
54
55
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutral CSS for the inbox HTML pages (`/`, `/device`, `/device/setup`, MCP consent).
|
|
3
|
+
* It is inlined in one nonce'd `<style>` block. There is no external stylesheet.
|
|
4
|
+
* `WORKER_THEME` (a CSS file path) replaces this module at `cf build`. See `theme-build.mjs`.
|
|
5
|
+
*/
|
|
6
|
+
export const PAGE_STYLE = `body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fafafa}
|
|
7
|
+
h1{font-size:1.3rem}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #ddd;vertical-align:top;word-break:break-all}th{width:40%;font-weight:600;word-break:normal}
|
|
8
|
+
label{display:block;font-weight:600;margin:.8rem 0 .3rem}input{font:inherit;width:100%;box-sizing:border-box;padding:.5rem;border:1px solid #888;border-radius:4px}
|
|
9
|
+
.b{display:flex;gap:.6rem;margin-top:1rem}button{font:inherit;padding:.5rem 1.2rem;border:0;border-radius:4px;background:#1a5fb4;color:#fff;cursor:pointer}button.d{background:#a51d2d}
|
|
10
|
+
h2{font-size:1.05rem;margin-top:1.5rem}ul{padding-left:1.2rem}a{color:#1a5fb4}.n{padding:.6rem .8rem;border-radius:4px}.ok{background:#e6f4ea}.error{background:#fce8e6}.info{background:#e8f0fe}.w{font-size:.9rem;color:#444}code{background:#eee;padding:0 .2rem}`;
|
|
11
|
+
|
|
12
|
+
export function pageStyle(): string {
|
|
13
|
+
return PAGE_STYLE;
|
|
14
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Wake secrets sealed for one hosted tenant. The control plane writes `secrets_enc`; the tenant
|
|
2
|
+
// object decrypts it when it builds the tenant context. Self-host does not use this.
|
|
3
|
+
// Keep control/src/tenants/wake-secrets.ts in step with this file.
|
|
4
|
+
|
|
5
|
+
const INFO = "tenant-wake-secrets v1";
|
|
6
|
+
const SALT = "a2a-exposed";
|
|
7
|
+
|
|
8
|
+
/** Secret wake fields. Anything else in the blob is ignored. */
|
|
9
|
+
export const WAKE_SECRET_KEYS = [
|
|
10
|
+
"WAKE_WEBHOOK_KEY",
|
|
11
|
+
"WAKE_HMAC_SECRET",
|
|
12
|
+
"WAKE_ACCESS_CLIENT_ID",
|
|
13
|
+
"WAKE_ACCESS_CLIENT_SECRET",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type WakeSecretKey = (typeof WAKE_SECRET_KEYS)[number];
|
|
17
|
+
export type WakeSecrets = Partial<Record<WakeSecretKey, string>>;
|
|
18
|
+
|
|
19
|
+
/** One secret. The form uses the same cap. */
|
|
20
|
+
export const MAX_WAKE_VALUE = 4096;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Largest JSON payload `sealWakeSecrets` will write. A character can become `\u0000` (six characters),
|
|
24
|
+
* and every known field may be present. `openWakeSecrets` accepts this and nothing larger.
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_WAKE_PLAIN = (() => {
|
|
27
|
+
let n = 2;
|
|
28
|
+
for (let i = 0; i < WAKE_SECRET_KEYS.length; i++) {
|
|
29
|
+
if (i) n += 1;
|
|
30
|
+
n += WAKE_SECRET_KEYS[i].length + 5 + MAX_WAKE_VALUE * 6;
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
/** `v1.` + base64(12-byte iv) + `.` + base64(utf-8 plaintext + 16-byte tag). */
|
|
36
|
+
export const MAX_WAKE_SEALED = 3 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
|
|
37
|
+
|
|
38
|
+
function b64(bytes: Uint8Array): string {
|
|
39
|
+
let raw = "";
|
|
40
|
+
for (let i = 0; i < bytes.length; i += 0x8000) raw += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
41
|
+
return btoa(raw);
|
|
42
|
+
}
|
|
43
|
+
const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
44
|
+
|
|
45
|
+
async function derive(ikm: string, tenantId: string): Promise<CryptoKey | null> {
|
|
46
|
+
if (!ikm || !tenantId) return null;
|
|
47
|
+
const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(ikm), "HKDF", false, ["deriveKey"]);
|
|
48
|
+
return crypto.subtle.deriveKey(
|
|
49
|
+
{ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(SALT), info: new TextEncoder().encode(`${INFO}\0${tenantId}`) },
|
|
50
|
+
base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
|
|
55
|
+
const payload: Record<string, string> = {};
|
|
56
|
+
for (const key of WAKE_SECRET_KEYS) {
|
|
57
|
+
const value = secrets[key];
|
|
58
|
+
if (value == null || value === "") continue;
|
|
59
|
+
if (typeof value !== "string" || value.length > MAX_WAKE_VALUE) return null;
|
|
60
|
+
payload[key] = value;
|
|
61
|
+
}
|
|
62
|
+
return payload;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `v1.<iv>.<ciphertext>`, or null when the key or a value is unusable. */
|
|
66
|
+
export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: WakeSecrets): Promise<string | null> {
|
|
67
|
+
const key = await derive(ikm, tenantId);
|
|
68
|
+
const payload = payloadOf(secrets);
|
|
69
|
+
if (!key || !payload) return null;
|
|
70
|
+
const json = JSON.stringify(payload);
|
|
71
|
+
const encoded = new TextEncoder().encode(json);
|
|
72
|
+
if (json.length > MAX_WAKE_PLAIN || encoded.length > MAX_WAKE_PLAIN * 3) return null;
|
|
73
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
74
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
75
|
+
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(tenantId) },
|
|
76
|
+
key,
|
|
77
|
+
encoded,
|
|
78
|
+
));
|
|
79
|
+
const sealed = `v1.${b64(iv)}.${b64(ct)}`;
|
|
80
|
+
return sealed.length <= MAX_WAKE_SEALED ? sealed : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The sealed fields, `{}` when there is nothing to open, or null when the blob cannot be read.
|
|
85
|
+
* A null result must not be treated as "no secrets": the caller keeps the previous ciphertext.
|
|
86
|
+
*/
|
|
87
|
+
export async function openWakeSecrets(ikm: string, tenantId: string, sealed: string | null | undefined): Promise<WakeSecrets | null> {
|
|
88
|
+
if (sealed == null || sealed === "") return {};
|
|
89
|
+
if (sealed.length > MAX_WAKE_SEALED) return null;
|
|
90
|
+
const key = await derive(ikm, tenantId);
|
|
91
|
+
if (!key) return null;
|
|
92
|
+
const [version, iv, ct, extra] = sealed.split(".");
|
|
93
|
+
if (version !== "v1" || !iv || !ct || extra !== undefined) return null;
|
|
94
|
+
try {
|
|
95
|
+
const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
|
|
96
|
+
{ name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(tenantId) },
|
|
97
|
+
key,
|
|
98
|
+
unb64(ct),
|
|
99
|
+
));
|
|
100
|
+
if (plain.length > MAX_WAKE_PLAIN) return null;
|
|
101
|
+
const parsed = JSON.parse(plain) as unknown;
|
|
102
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
103
|
+
const out: WakeSecrets = {};
|
|
104
|
+
for (const name of WAKE_SECRET_KEYS) {
|
|
105
|
+
const value = (parsed as Record<string, unknown>)[name];
|
|
106
|
+
if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
|
|
107
|
+
out[name] = value;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/worker/src/wake.ts
CHANGED
|
@@ -203,7 +203,11 @@ export async function renderWake(cfg: WakeConfig, ev: WakeEvent, opts: { nowMs?:
|
|
|
203
203
|
break;
|
|
204
204
|
case "generic":
|
|
205
205
|
default: {
|
|
206
|
-
|
|
206
|
+
// A header the preview shows in full must not carry the key.
|
|
207
|
+
if (cfg.key) {
|
|
208
|
+
const requested = (cfg.keyHeader || "authorization").toLowerCase();
|
|
209
|
+
headers[previewHeaderPlain(requested) ? "authorization" : requested] = (cfg.keyPrefix ?? "Bearer ") + cfg.key;
|
|
210
|
+
}
|
|
207
211
|
headers["x-request-id"] = requestId;
|
|
208
212
|
body = cfg.bodyTemplate ? renderTemplate(cfg.bodyTemplate, ev, cli) : JSON.stringify(wakePayload(ev, cli));
|
|
209
213
|
if (cfg.hmacSecret) await hermesSign(headers, cfg.hmacSecret, body, nowMs);
|
|
@@ -243,11 +247,20 @@ export function cloudflareErrorHint(status: number, body: string): string {
|
|
|
243
247
|
return "";
|
|
244
248
|
}
|
|
245
249
|
|
|
250
|
+
/** Headers a wake preview may show in full. Every other header carries a credential, whatever its name. */
|
|
251
|
+
export const PREVIEW_PLAIN_HEADERS = new Set([
|
|
252
|
+
"content-type", "user-agent", "x-request-id", "idempotency-key", "anthropic-version", "x-webhook-timestamp",
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
export function previewHeaderPlain(name: string): boolean {
|
|
256
|
+
return PREVIEW_PLAIN_HEADERS.has(name.toLowerCase());
|
|
257
|
+
}
|
|
258
|
+
|
|
246
259
|
/** Copy of a rendered request with credentials masked (for previews/logs). */
|
|
247
260
|
export function redact(req: WakeRequest): WakeRequest {
|
|
248
261
|
const h: Record<string, string> = {};
|
|
249
262
|
for (const [k, v] of Object.entries(req.headers))
|
|
250
|
-
h[k] =
|
|
263
|
+
h[k] = previewHeaderPlain(k) ? v : v.replace(/(Bearer\s+)?(.{0,4}).*/s, "$1$2…");
|
|
251
264
|
let url = req.url;
|
|
252
265
|
try { const u = new URL(req.url); url = `${u.protocol}//${u.host}${u.pathname.replace(/\/[^/]{16,}/g, "/…")}`; } catch { /* keep */ }
|
|
253
266
|
return { ...req, url, headers: h };
|
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import { Miniflare, convertV4MiniflareOptions } from "miniflare";
|
|
10
10
|
import worker from "../src/index.ts";
|
|
11
11
|
import { sha256 } from "../src/a2a.ts";
|
|
12
|
+
import { sealWakeSecrets } from "../src/wake-secrets.ts";
|
|
12
13
|
import { d1 } from "./d1.ts";
|
|
13
14
|
|
|
14
15
|
const require = createRequire(new URL("../package.json", import.meta.url));
|
|
@@ -481,3 +482,132 @@ test("an apex issuer is fetched with that Host, and a control worker that does n
|
|
|
481
482
|
assert.equal(stalledPost.status, 502);
|
|
482
483
|
assert.match(await stalledPost.text(), /The identity provider could not be reached/);
|
|
483
484
|
});
|
|
485
|
+
|
|
486
|
+
test("TenantStore.owner runs an allowlisted owner call and does not return a peer token", async (t) => {
|
|
487
|
+
const mf = await start({
|
|
488
|
+
bindings: {
|
|
489
|
+
TENANCY: "host",
|
|
490
|
+
TENANT_DOMAIN: "example.com",
|
|
491
|
+
TENANT_SECRETS_KEY: "platform-secret",
|
|
492
|
+
OWNER_TOKEN: "platform-owner",
|
|
493
|
+
},
|
|
494
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants-owner-rpc" },
|
|
495
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
496
|
+
});
|
|
497
|
+
t.after(() => mf.dispose());
|
|
498
|
+
await mf.ready;
|
|
499
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
|
|
500
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO");
|
|
501
|
+
const hash = await sha256("owner-carol");
|
|
502
|
+
type OwnerStub = {
|
|
503
|
+
owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown }): Promise<{ status: number; body: unknown }>;
|
|
504
|
+
storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
|
|
505
|
+
pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }>;
|
|
506
|
+
};
|
|
507
|
+
const lines: string[] = [];
|
|
508
|
+
const orig = console.log;
|
|
509
|
+
console.log = (...args: unknown[]) => {
|
|
510
|
+
lines.push(args.map((a) => String(a)).join(" "));
|
|
511
|
+
orig(...args);
|
|
512
|
+
};
|
|
513
|
+
t.after(() => { console.log = orig; });
|
|
514
|
+
// Workerd delivers console.log on a later turn. A busy runner (the storage-backend jobs) can
|
|
515
|
+
// return the RPC before that line is in this process.
|
|
516
|
+
const saw = async (part: string) => {
|
|
517
|
+
const start = Date.now();
|
|
518
|
+
while (!lines.some((l) => l.includes(part))) {
|
|
519
|
+
if (Date.now() - start > 2000) return false;
|
|
520
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
521
|
+
}
|
|
522
|
+
return true;
|
|
523
|
+
};
|
|
524
|
+
const empty = ns.get(ns.idFromName("id-empty")) as unknown as OwnerStub;
|
|
525
|
+
const missing = await empty.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
526
|
+
assert.equal(missing.status, 404);
|
|
527
|
+
const emptyStatus = await empty.storageStatus();
|
|
528
|
+
assert.equal(emptyStatus.configured, false);
|
|
529
|
+
assert.deepEqual(emptyStatus.tables, []);
|
|
530
|
+
assert.equal(await saw('"reason":"absent"'), true);
|
|
531
|
+
|
|
532
|
+
const stub = ns.get(ns.idFromName("id-carol")) as unknown as OwnerStub;
|
|
533
|
+
await stub.pushConfig({
|
|
534
|
+
version: 1, tenantId: "id-carol", name: "carol", status: "active", ownerTokenHash: hash,
|
|
535
|
+
approval: { issuer: "https://idp.example", clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["acct_carol"] },
|
|
536
|
+
config: { AGENT_NAME: "Carol" },
|
|
537
|
+
});
|
|
538
|
+
await kv.put("tenant:carol", JSON.stringify({ id: "id-carol", status: "active", region: "", version: 1 }));
|
|
539
|
+
const http = (method: string, path: string, auth: string, json?: unknown) => mf.dispatchFetch(`https://carol.example.com${path}`, {
|
|
540
|
+
method, headers: { authorization: auth, "content-type": "application/json" }, body: json === undefined ? undefined : JSON.stringify(json),
|
|
541
|
+
});
|
|
542
|
+
assert.equal((await http("POST", "/owner/peers", "Bearer platform-owner", { label: "nope" })).status, 401, "self-host owner token still does not unlock a tenant");
|
|
543
|
+
const issued = await (await http("POST", "/owner/peers", "Bearer owner-carol", { label: "ada" })).json() as { token?: string };
|
|
544
|
+
assert.match(issued.token ?? "", /^a2aow_/, "the owner HTTP API still returns the new token");
|
|
545
|
+
|
|
546
|
+
const listed = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
547
|
+
assert.equal(listed.status, 200);
|
|
548
|
+
const peers = listed.body as { label: string; token?: string }[];
|
|
549
|
+
assert.deepEqual(peers.map((p) => p.label), ["ada"]);
|
|
550
|
+
assert.equal(JSON.stringify(listed.body).includes("a2aow_"), false);
|
|
551
|
+
assert.equal(await saw('"route":"GET /owner/peers"'), true);
|
|
552
|
+
|
|
553
|
+
const minted = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/peers", body: { label: "bob" } });
|
|
554
|
+
assert.equal(minted.status, 404);
|
|
555
|
+
assert.equal(JSON.stringify(minted.body).includes("a2aow_"), false);
|
|
556
|
+
const withBody = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
|
|
557
|
+
assert.equal(withBody.status, 400);
|
|
558
|
+
const again = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/peers" });
|
|
559
|
+
assert.equal(again.status, 404);
|
|
560
|
+
const still = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
561
|
+
assert.deepEqual((still.body as { label: string }[]).map((p) => p.label), ["ada"], "a refused call does not mint a token");
|
|
562
|
+
|
|
563
|
+
const setup = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/password-link" });
|
|
564
|
+
assert.equal(setup.status, 404);
|
|
565
|
+
const dumped = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export" });
|
|
566
|
+
assert.equal(dumped.status, 404);
|
|
567
|
+
const oddQuery = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/inbox", query: { format: "sql" } });
|
|
568
|
+
assert.equal(oddQuery.status, 400);
|
|
569
|
+
const pairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
|
|
570
|
+
assert.equal(pairing.status, 200);
|
|
571
|
+
assert.equal(JSON.stringify(pairing.body).includes("tenant-secret-value"), false);
|
|
572
|
+
const preview = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/wake/preview" });
|
|
573
|
+
assert.equal(preview.status, 200);
|
|
574
|
+
assert.equal(JSON.stringify(preview.body).includes("platform-secret"), false);
|
|
575
|
+
assert.equal(JSON.stringify(preview.body).includes("tenant-secret-value"), false);
|
|
576
|
+
|
|
577
|
+
const wakeKey = "z9Qx-secret-value-0199";
|
|
578
|
+
const sealed = await sealWakeSecrets("platform-secret", "id-carol", { WAKE_WEBHOOK_KEY: wakeKey });
|
|
579
|
+
assert.ok(sealed);
|
|
580
|
+
assert.equal(sealed!.includes(wakeKey), false);
|
|
581
|
+
await stub.pushConfig({
|
|
582
|
+
version: 2, tenantId: "id-carol", name: "carol", status: "active", ownerTokenHash: hash,
|
|
583
|
+
secretsEnc: sealed,
|
|
584
|
+
config: {
|
|
585
|
+
AGENT_NAME: "Carol",
|
|
586
|
+
WAKE_WEBHOOK_URL: "https://hooks.example/wake",
|
|
587
|
+
WAKE_PRESET: "generic",
|
|
588
|
+
WAKE_KEY_HEADER: "X-Credential",
|
|
589
|
+
WAKE_WEBHOOK_KEY: "plaintext-key-should-not-appear",
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
const woken = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/wake/preview" });
|
|
593
|
+
assert.equal(woken.status, 200);
|
|
594
|
+
const wokenText = JSON.stringify(woken.body);
|
|
595
|
+
assert.equal(wokenText.includes(wakeKey), false);
|
|
596
|
+
assert.equal(wokenText.includes("plaintext-key-should-not-appear"), false);
|
|
597
|
+
assert.equal(wokenText.includes("platform-secret"), false);
|
|
598
|
+
const wokenBody = woken.body as { hasKey?: boolean; request?: { headers?: Record<string, string> } };
|
|
599
|
+
assert.equal(wokenBody.hasKey, true);
|
|
600
|
+
assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer z9Qx…");
|
|
601
|
+
const httpPreview = await (await http("GET", "/owner/wake/preview", "Bearer owner-carol")).json() as { hasKey?: boolean };
|
|
602
|
+
assert.equal(httpPreview.hasKey, true);
|
|
603
|
+
assert.equal(JSON.stringify(httpPreview).includes(wakeKey), false);
|
|
604
|
+
|
|
605
|
+
await stub.pushConfig({ version: 3, tenantId: "id-carol", name: "carol", status: "suspended", ownerTokenHash: hash, config: { AGENT_NAME: "Carol" } });
|
|
606
|
+
const suspended = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
|
|
607
|
+
assert.equal(suspended.status, 403);
|
|
608
|
+
assert.equal(await saw('"reason":"status"'), true);
|
|
609
|
+
const badAccount = await stub.owner({ accountId: "../acct", method: "GET", path: "/owner/peers" });
|
|
610
|
+
assert.equal(badAccount.status, 400);
|
|
611
|
+
assert.equal(await saw('"reason":"account"'), true);
|
|
612
|
+
assert.equal(lines.some((l) => l.includes("../acct")), false);
|
|
613
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks } from "../src/owner-rpc.ts";
|
|
5
|
+
|
|
6
|
+
test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
|
|
7
|
+
const ok = (method: string, path: string, query?: Record<string, string>) => {
|
|
8
|
+
const m = matchOwnerRpc(method, path, query);
|
|
9
|
+
assert.equal(m.ok, true, `${method} ${path}`);
|
|
10
|
+
return m.ok ? m.route : null;
|
|
11
|
+
};
|
|
12
|
+
assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
|
|
13
|
+
assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
|
|
14
|
+
assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
|
|
15
|
+
assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
|
|
16
|
+
assert.equal(ok("GET", "/owner/outbound/task-1")?.name, "GET /owner/outbound/:id");
|
|
17
|
+
assert.equal(ok("GET", "/owner/peers")?.name, "GET /owner/peers");
|
|
18
|
+
assert.equal(ok("DELETE", "/owner/peers/ada")?.path, "/owner/peers/ada");
|
|
19
|
+
assert.equal(ok("GET", "/owner/pairing")?.name, "GET /owner/pairing");
|
|
20
|
+
assert.equal(ok("POST", "/owner/pairing/WDJB-4827/approve")?.name, "POST /owner/pairing/:code/approve");
|
|
21
|
+
assert.equal(ok("POST", "/owner/pairing/wdjb4827/deny")?.name, "POST /owner/pairing/:code/deny");
|
|
22
|
+
assert.equal(ok("GET", "/owner/mcp")?.name, "GET /owner/mcp");
|
|
23
|
+
assert.equal(ok("DELETE", "/owner/mcp/mcp-claude")?.path, "/owner/mcp/mcp-claude");
|
|
24
|
+
assert.equal(ok("GET", "/owner/outbound-peers")?.name, "GET /owner/outbound-peers");
|
|
25
|
+
assert.equal(ok("DELETE", "/owner/outbound-peers/ada.bot")?.path, "/owner/outbound-peers/ada.bot");
|
|
26
|
+
assert.equal(ok("GET", "/owner/facade")?.name, "GET /owner/facade");
|
|
27
|
+
assert.equal(ok("POST", "/owner/facade/verify")?.name, "POST /owner/facade/verify");
|
|
28
|
+
assert.equal(ok("GET", "/owner/wake/preview")?.name, "GET /owner/wake/preview");
|
|
29
|
+
assert.equal(ok("POST", "/owner/wake/test")?.name, "POST /owner/wake/test");
|
|
30
|
+
assert.equal(ok("GET", "/owner/contexts")?.name, "GET /owner/contexts");
|
|
31
|
+
|
|
32
|
+
const refused = (method: string, path: string, query?: Record<string, string>) => matchOwnerRpc(method, path, query);
|
|
33
|
+
assert.deepEqual(refused("POST", "/owner/peers"), { ok: false, reason: "path" });
|
|
34
|
+
assert.deepEqual(refused("POST", "/owner/pairing/password-link"), { ok: false, reason: "path" });
|
|
35
|
+
assert.deepEqual(refused("PUT", "/owner/pairing/password"), { ok: false, reason: "path" });
|
|
36
|
+
assert.deepEqual(refused("PUT", "/owner/outbound-peers/ada"), { ok: false, reason: "path" });
|
|
37
|
+
assert.deepEqual(refused("GET", "/owner/export"), { ok: false, reason: "path" });
|
|
38
|
+
assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
|
|
39
|
+
assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
|
|
40
|
+
assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
|
|
41
|
+
assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });
|
|
42
|
+
assert.deepEqual(refused("GET", "/owner/%2e%2e/peers"), { ok: false, reason: "path" });
|
|
43
|
+
assert.deepEqual(refused("GET", "/owner/inbox?all=1"), { ok: false, reason: "path" });
|
|
44
|
+
assert.deepEqual(refused("POST", "/owner/pairing/AEIO-4827/approve"), { ok: false, reason: "path" });
|
|
45
|
+
assert.deepEqual(refused("GET", "/owner/inbox", { format: "sql" }), { ok: false, reason: "query" });
|
|
46
|
+
assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
|
|
47
|
+
assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });
|
|
48
|
+
assert.equal(accountIdOf("acct_carol"), "acct_carol");
|
|
49
|
+
assert.equal(accountIdOf("../acct"), null);
|
|
50
|
+
assert.equal(accountIdOf(""), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("owner RPC withholds peer tokens and decrypted secrets", () => {
|
|
54
|
+
const token = "a2aow_" + "a".repeat(43);
|
|
55
|
+
assert.equal(ownerResponseLeaks({ label: "ada", revoked_at: null }), false);
|
|
56
|
+
assert.equal(ownerResponseLeaks([{ text: "hello" }]), false);
|
|
57
|
+
assert.equal(ownerResponseLeaks({ token }), true);
|
|
58
|
+
assert.equal(ownerResponseLeaks({ nested: { access_token: "a2amcp_" + "b".repeat(30) } }), true);
|
|
59
|
+
assert.equal(ownerResponseLeaks({ url: "https://agent.example/device/setup?t=" + "c".repeat(20) }), true);
|
|
60
|
+
assert.equal(ownerResponseLeaks({ clientSecret: "tenant-secret-value" }), true);
|
|
61
|
+
assert.equal(ownerResponseLeaks({ secretsEnc: "v1.ciphertext-value" }), true);
|
|
62
|
+
assert.equal(ownerResponseLeaks("https://user:pass@hooks.example/wake"), true);
|
|
63
|
+
assert.equal(ownerResponseLeaks({ authorization: "Bearer abcd…" }), false);
|
|
64
|
+
assert.equal(ownerResponseLeaks({
|
|
65
|
+
request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supersecretvalue", "content-type": "application/json" }, body: "{}" },
|
|
66
|
+
}), true);
|
|
67
|
+
assert.equal(ownerResponseLeaks({
|
|
68
|
+
request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supe…", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
|
|
69
|
+
}), false);
|
|
70
|
+
assert.equal(ownerResponseLeaks({ fingerprints: { url: "0123456789ab", key: null } }), false);
|
|
71
|
+
assert.equal(ownerResponseLeaks({ token_hash: "ab".repeat(32) }), false);
|
|
72
|
+
});
|
|
@@ -288,6 +288,25 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
288
288
|
assert.equal((await s.call("POST", "/", { json: { jsonrpc: "2.0", id: 1, method: "GetTask", params: { id: "x" } }, headers: { authorization: `Bearer ${p.data.access_token}` } })).status, 401);
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
test("a pairing label comes from the client name, a second one is suffixed, and inbox from uses it", async (t) => {
|
|
292
|
+
const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
|
|
293
|
+
const first = (await s.start({ client_name: "tester-alpha", client_id: "other-alias" }, "203.0.113.40")).data;
|
|
294
|
+
const approved = await s.owner("POST", `/owner/pairing/${first.user_code}/approve`);
|
|
295
|
+
assert.equal(approved.data.label, "tester-alpha");
|
|
296
|
+
const token = (await s.poll(first.device_code)).data.access_token;
|
|
297
|
+
const sent = await s.call("POST", "/", {
|
|
298
|
+
json: { jsonrpc: "2.0", id: 1, method: "SendMessage", params: { message: { role: "ROLE_USER", parts: [{ text: "hello" }] } } },
|
|
299
|
+
headers: { authorization: `Bearer ${token}`, "a2a-version": "1.0" },
|
|
300
|
+
});
|
|
301
|
+
assert.equal(sent.status, 200, JSON.stringify(sent.data));
|
|
302
|
+
const inbox = await s.owner("GET", "/owner/inbox");
|
|
303
|
+
assert.equal(inbox.data.length, 1);
|
|
304
|
+
assert.equal(inbox.data[0].from, "tester-alpha");
|
|
305
|
+
assert.equal(inbox.data[0].text, "hello");
|
|
306
|
+
const second = (await s.start({ client_name: "tester-alpha", client_id: "another" }, "203.0.113.41")).data;
|
|
307
|
+
assert.equal((await s.owner("POST", `/owner/pairing/${second.user_code}/approve`)).data.label, "tester-alpha-2");
|
|
308
|
+
});
|
|
309
|
+
|
|
291
310
|
test("labels are deduplicated; denied and expired requests; JSON bodies; bad requests", async (t) => {
|
|
292
311
|
const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
|
|
293
312
|
await s.owner("POST", "/owner/peers", { label: "barry" });
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Neutral inbox CSS, and the build-time WORKER_THEME check. Run: npm test
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { PAGE_STYLE, pageStyle } from "../src/theme.ts";
|
|
8
|
+
import { pageShell } from "../src/pairing.ts";
|
|
9
|
+
import { applyWorkerTheme, assertInlineCss, cssForChecks } from "../theme-build.mjs";
|
|
10
|
+
|
|
11
|
+
test("the neutral theme is inlined once and loads nothing", () => {
|
|
12
|
+
assert.equal(pageStyle(), PAGE_STYLE);
|
|
13
|
+
assert.equal(PAGE_STYLE.includes("background:#fafafa"), true);
|
|
14
|
+
assert.equal(PAGE_STYLE.includes("background:#1a5fb4"), true);
|
|
15
|
+
assert.doesNotThrow(() => assertInlineCss(PAGE_STYLE));
|
|
16
|
+
const html = pageShell("Inbox", "nonce-1", "<p>Hello</p>");
|
|
17
|
+
assert.equal(html.includes(`<style nonce="nonce-1">\n${PAGE_STYLE}\n</style>`), true);
|
|
18
|
+
assert.equal((html.match(/<style/g) || []).length, 1);
|
|
19
|
+
assert.equal(/<link|<script|<img|url\s*\(|@import/i.test(html), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("WORKER_THEME replaces the module only when the CSS stays inline", () => {
|
|
23
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-theme-"));
|
|
24
|
+
const file = path.join(dir, "theme.css");
|
|
25
|
+
const css = "@media (max-width: 40rem) { body { background: #fff; color: \\23 222; } }";
|
|
26
|
+
fs.writeFileSync(file, css);
|
|
27
|
+
const next = applyWorkerTheme("/work/src/theme.ts", file);
|
|
28
|
+
assert.match(next || "", /export const PAGE_STYLE = /);
|
|
29
|
+
assert.match(next || "", /background: #fff/);
|
|
30
|
+
assert.match(next || "", /\\\\23 222/);
|
|
31
|
+
assert.equal(applyWorkerTheme("/work/src/theme.ts", undefined), null);
|
|
32
|
+
assert.equal(applyWorkerTheme("/work/src/index.ts", file), null);
|
|
33
|
+
|
|
34
|
+
for (const bad of [
|
|
35
|
+
"",
|
|
36
|
+
"body { background: url(https://example.com/a.png); }",
|
|
37
|
+
"@import 'other.css';",
|
|
38
|
+
"@font-face { font-family: x; }",
|
|
39
|
+
"body { background: red; } </style><script>",
|
|
40
|
+
"body { background: expression(alert(1)); }",
|
|
41
|
+
"a { color: javascript:alert(1); }",
|
|
42
|
+
"body{background:u\\72l(https://example.com/a.png)}",
|
|
43
|
+
"body{background:\\75\\72\\6c(https://example.com/a.png)}",
|
|
44
|
+
"body{background:u\\000072l(https://example.com/a.png)}",
|
|
45
|
+
"@\\69mport \"https://example.com/x.css\"",
|
|
46
|
+
"@\\69 mport \"https://example.com/x.css\"",
|
|
47
|
+
"@\\69\\6d\\70\\6f\\72\\74 \"https://example.com/x.css\"",
|
|
48
|
+
"body{background:url/*x*/(https://example.com/a.png)}",
|
|
49
|
+
"@font\\2d face { font-family: x; }",
|
|
50
|
+
"a{\"/*\";background:url(https://example.com/a.png);content:\"*/\"}",
|
|
51
|
+
"a{content:\"foo\\\"bar\";background:url(https://example.com/a.png)}",
|
|
52
|
+
"a{content:\"foo\\\nbar\";background:url(https://example.com/a.png)}",
|
|
53
|
+
"a{content:\"url(https://example.com/a.png)\nbackground:url(https://example.com/b.png)}",
|
|
54
|
+
"body{background-image:image-set(\"https://example.com/p.png\" 1x)}",
|
|
55
|
+
"body{background-image:image(\"https://example.com/p.png\")}",
|
|
56
|
+
"body{background-image:-webkit-image-set(\"https://example.com/p.png\" 1x)}",
|
|
57
|
+
"body{background-image:image-\\73 et(\"https://example.com/p.png\" 1x)}",
|
|
58
|
+
"body{background-image:\\69mage(\"https://example.com/p.png\")}",
|
|
59
|
+
"body{background-image:image-set/*x*/(\"https://example.com/p.png\" 1x)}",
|
|
60
|
+
]) {
|
|
61
|
+
fs.writeFileSync(file, bad);
|
|
62
|
+
assert.throws(() => applyWorkerTheme("/work/src/theme.ts", file), /WORKER_THEME was refused/);
|
|
63
|
+
}
|
|
64
|
+
for (const ok of [
|
|
65
|
+
"/* url(https://example.com/a.png) */ body { color: red }",
|
|
66
|
+
"a { content: \"url(https://example.com/a.png)\" }",
|
|
67
|
+
"a { content: \"foo\\\nurl(https://example.com/a.png)\" }",
|
|
68
|
+
"body { color: \\3c }",
|
|
69
|
+
"body { background-image: linear-gradient(#fff, #000); }",
|
|
70
|
+
"a { content: \"image-set(https://example.com/p.png)\" }",
|
|
71
|
+
]) {
|
|
72
|
+
fs.writeFileSync(file, ok);
|
|
73
|
+
assert.doesNotThrow(() => applyWorkerTheme("/work/src/theme.ts", file));
|
|
74
|
+
}
|
|
75
|
+
assert.throws(() => applyWorkerTheme("/work/src/theme.ts", path.join(dir, "missing.css")), /could not be read/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the resource check decodes escapes and keeps strings and comments closed", () => {
|
|
79
|
+
assert.match(cssForChecks("u\\72l(https://example.com/a.png)"), /url\(https:\/\/example.com\/a.png\)/);
|
|
80
|
+
assert.match(cssForChecks("@\\69 mport \"x.css\""), /@import ""/);
|
|
81
|
+
assert.equal(/url\s*\(/i.test(cssForChecks("/* url(https://example.com/a.png) */ body{color:red}")), false);
|
|
82
|
+
assert.equal(/url\s*\(/i.test(cssForChecks("a{content:\"url(https://example.com/a.png)\"}")), false);
|
|
83
|
+
assert.match(cssForChecks("a{\"/*\";background:url(https://example.com/a.png)}"), /url\(https:\/\/example.com\/a.png\)/);
|
|
84
|
+
assert.match(cssForChecks("color:\\23 222"), /color:#222/);
|
|
85
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { MAX_WAKE_SEALED, MAX_WAKE_VALUE, openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
|
|
4
|
+
|
|
5
|
+
const key = "platform-secret";
|
|
6
|
+
const tenant = "ten_a";
|
|
7
|
+
|
|
8
|
+
test("wake secrets round-trip for one tenant and stay unreadable for another", async () => {
|
|
9
|
+
const secrets = { WAKE_WEBHOOK_KEY: "z9Qx-secret-value-0199", WAKE_HMAC_SECRET: "hmac-value" };
|
|
10
|
+
const sealed = await sealWakeSecrets(key, tenant, secrets);
|
|
11
|
+
assert.ok(sealed);
|
|
12
|
+
assert.equal(sealed!.includes("z9Qx-secret-value-0199"), false);
|
|
13
|
+
assert.equal(sealed!.startsWith("v1."), true);
|
|
14
|
+
assert.deepEqual(await openWakeSecrets(key, tenant, sealed), secrets);
|
|
15
|
+
assert.equal(await openWakeSecrets(key, "ten_b", sealed), null);
|
|
16
|
+
assert.equal(await openWakeSecrets("other-key", tenant, sealed), null);
|
|
17
|
+
assert.equal(await openWakeSecrets("", tenant, sealed), null);
|
|
18
|
+
assert.deepEqual(await openWakeSecrets(key, tenant, null), {});
|
|
19
|
+
assert.equal(await openWakeSecrets(key, tenant, sealed + ".extra"), null);
|
|
20
|
+
assert.equal(await openWakeSecrets(key, tenant, "v1.not-base64.nope"), null);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a wake secret blob keeps only the known fields", async () => {
|
|
24
|
+
const sealed = await sealWakeSecrets(key, tenant, {
|
|
25
|
+
WAKE_WEBHOOK_KEY: "hook-key",
|
|
26
|
+
WAKE_ACCESS_CLIENT_SECRET: "access-secret",
|
|
27
|
+
});
|
|
28
|
+
const opened = await openWakeSecrets(key, tenant, sealed);
|
|
29
|
+
assert.deepEqual(opened, { WAKE_WEBHOOK_KEY: "hook-key", WAKE_ACCESS_CLIENT_SECRET: "access-secret" });
|
|
30
|
+
assert.equal(await sealWakeSecrets(key, tenant, { WAKE_WEBHOOK_KEY: "x".repeat(4097) }), null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("four maximum-length wake secrets round-trip", async () => {
|
|
34
|
+
const value = "x".repeat(MAX_WAKE_VALUE);
|
|
35
|
+
const secrets = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, value]));
|
|
36
|
+
const sealed = await sealWakeSecrets(key, tenant, secrets);
|
|
37
|
+
assert.ok(sealed);
|
|
38
|
+
assert.ok(sealed!.length > 16384);
|
|
39
|
+
assert.ok(sealed!.length <= MAX_WAKE_SEALED);
|
|
40
|
+
assert.deepEqual(await openWakeSecrets(key, tenant, sealed), secrets);
|
|
41
|
+
assert.equal(await openWakeSecrets(key, tenant, "v1." + "a".repeat(MAX_WAKE_SEALED)), null);
|
|
42
|
+
|
|
43
|
+
const escaped = "\u0000".repeat(MAX_WAKE_VALUE);
|
|
44
|
+
const worst = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, escaped]));
|
|
45
|
+
const sealedWorst = await sealWakeSecrets(key, tenant, worst);
|
|
46
|
+
assert.ok(sealedWorst);
|
|
47
|
+
assert.ok(sealedWorst!.length <= MAX_WAKE_SEALED);
|
|
48
|
+
assert.deepEqual(await openWakeSecrets(key, tenant, sealedWorst), worst);
|
|
49
|
+
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -116,6 +116,22 @@ test("generic custom key header is rendered with prefix and redacted in previews
|
|
|
116
116
|
assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
test("a key header the preview shows in full is not used for the key", async () => {
|
|
120
|
+
const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "Content-Type", keyPrefix: "" }, ev))!;
|
|
121
|
+
assert.equal(req.headers["authorization"], "supersecretvalue");
|
|
122
|
+
assert.equal(req.headers["content-type"], "application/json");
|
|
123
|
+
assert.equal(redact(req).headers["authorization"], "supe…");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("generic key header is redacted even when its name is not a secret word", async () => {
|
|
127
|
+
const req = (await renderWake({ preset: "generic", url: "https://hooks.example.com/abc", key: "supersecretvalue", keyHeader: "X-Credential", keyPrefix: "" }, ev))!;
|
|
128
|
+
assert.equal(req.headers["x-credential"], "supersecretvalue");
|
|
129
|
+
const red = redact(req);
|
|
130
|
+
assert.equal(red.headers["x-credential"], "supe…");
|
|
131
|
+
assert.equal(red.headers["content-type"], "application/json");
|
|
132
|
+
assert.ok(!JSON.stringify(red).includes("supersecretvalue"));
|
|
133
|
+
});
|
|
134
|
+
|
|
119
135
|
// ---------------------------------------------------------------- Cloudflare Access service token (tunnel wakes)
|
|
120
136
|
const ACCESS = { accessClientId: "abc123.access", accessClientSecret: "s3cr3t-value-0123456789" };
|
|
121
137
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Build-time check for WORKER_THEME. The Worker inlines the result in a nonce'd style block and
|
|
2
|
+
// does not load a stylesheet, font, or image. Unsafe CSS is refused so the build stops.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const MAX = 16384;
|
|
7
|
+
|
|
8
|
+
function isHex(char) {
|
|
9
|
+
return char !== undefined && ((char >= "0" && char <= "9") || (char >= "a" && char <= "f") || (char >= "A" && char <= "F"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isCssSpace(char) {
|
|
13
|
+
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isNewline(char) {
|
|
17
|
+
return char === "\n" || char === "\r" || char === "\f";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function consumeNewline(css, i) {
|
|
21
|
+
if (css[i] === "\r" && css[i + 1] === "\n") return i + 2;
|
|
22
|
+
return i + 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One CSS escape at a backslash. A backslash before a newline is not an escape (`newline`);
|
|
27
|
+
* the caller decides whether that pair continues a string or stays in the sheet.
|
|
28
|
+
* @param {string} css
|
|
29
|
+
* @param {number} i index of the backslash
|
|
30
|
+
*/
|
|
31
|
+
function consumeEscape(css, i) {
|
|
32
|
+
i++;
|
|
33
|
+
if (i >= css.length) return { type: "char", char: "\uFFFD", next: i };
|
|
34
|
+
if (isNewline(css[i])) return { type: "newline", next: i };
|
|
35
|
+
if (isHex(css[i])) {
|
|
36
|
+
let hex = "";
|
|
37
|
+
while (hex.length < 6 && i < css.length && isHex(css[i])) hex += css[i++];
|
|
38
|
+
if (i < css.length && isCssSpace(css[i])) i = isNewline(css[i]) ? consumeNewline(css, i) : i + 1;
|
|
39
|
+
const codePoint = Number.parseInt(hex, 16);
|
|
40
|
+
const surrogate = codePoint >= 0xd800 && codePoint <= 0xdfff;
|
|
41
|
+
const char = codePoint === 0 || surrogate || codePoint > 0x10ffff ? "\uFFFD" : String.fromCodePoint(codePoint);
|
|
42
|
+
return { type: "char", char, next: i };
|
|
43
|
+
}
|
|
44
|
+
return { type: "char", char: css[i], next: i + 1 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Stylesheet text with escapes decoded, comments removed, and string contents removed.
|
|
49
|
+
* Markers are recognized on the raw text, so a string cannot pretend to be a comment,
|
|
50
|
+
* and a decoded character is not read again as a quote or a comment.
|
|
51
|
+
* @param {string} css
|
|
52
|
+
*/
|
|
53
|
+
export function cssForChecks(css) {
|
|
54
|
+
let out = "";
|
|
55
|
+
let i = 0;
|
|
56
|
+
/** @type {"normal" | "comment" | "string"} */
|
|
57
|
+
let mode = "normal";
|
|
58
|
+
let quote = "";
|
|
59
|
+
while (i < css.length) {
|
|
60
|
+
if (mode === "comment") {
|
|
61
|
+
if (css[i] === "*" && css[i + 1] === "/") {
|
|
62
|
+
mode = "normal";
|
|
63
|
+
i += 2;
|
|
64
|
+
} else i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (mode === "string") {
|
|
68
|
+
if (css[i] === "\\") {
|
|
69
|
+
const esc = consumeEscape(css, i);
|
|
70
|
+
i = esc.type === "newline" ? consumeNewline(css, esc.next) : esc.next;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (isNewline(css[i])) {
|
|
74
|
+
mode = "normal";
|
|
75
|
+
out += "\n";
|
|
76
|
+
i = consumeNewline(css, i);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (css[i] === quote) {
|
|
80
|
+
mode = "normal";
|
|
81
|
+
out += quote;
|
|
82
|
+
i++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (css[i] === "/" && css[i + 1] === "*") {
|
|
89
|
+
out += " ";
|
|
90
|
+
mode = "comment";
|
|
91
|
+
i += 2;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (css[i] === '"' || css[i] === "'") {
|
|
95
|
+
quote = css[i];
|
|
96
|
+
out += quote;
|
|
97
|
+
mode = "string";
|
|
98
|
+
i++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (css[i] === "\\") {
|
|
102
|
+
const esc = consumeEscape(css, i);
|
|
103
|
+
if (esc.type === "newline") {
|
|
104
|
+
out += "\\";
|
|
105
|
+
i = esc.next;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
out += esc.char;
|
|
109
|
+
i = esc.next;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
out += css[i++];
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {string} css */
|
|
118
|
+
export function assertInlineCss(css) {
|
|
119
|
+
if (typeof css !== "string") throw new Error("the theme is not text");
|
|
120
|
+
const text = css.replace(/^\uFEFF/, "");
|
|
121
|
+
if (!text.trim()) throw new Error("the theme is empty");
|
|
122
|
+
if (text.length > MAX) throw new Error("the theme is too long");
|
|
123
|
+
if (/[<>]/.test(text)) throw new Error("the theme has a tag");
|
|
124
|
+
const parsed = cssForChecks(text);
|
|
125
|
+
if (/@import\b/i.test(parsed)) throw new Error("the theme imports a stylesheet");
|
|
126
|
+
if (/@font-face\b/i.test(parsed)) throw new Error("the theme loads a font");
|
|
127
|
+
if (/url\s*\(/i.test(parsed)) throw new Error("the theme loads a url");
|
|
128
|
+
// image() and image-set() take a string as an image source, so the address never appears as url().
|
|
129
|
+
if (/\bimage-set\s*\(/i.test(parsed) || /\bimage\s*\(/i.test(parsed)) throw new Error("the theme loads an image");
|
|
130
|
+
if (/expression\s*\(/i.test(parsed) || /javascript\s*:/i.test(parsed)) throw new Error("the theme has a script");
|
|
131
|
+
if (/-moz-binding\s*:/i.test(parsed) || /behavior\s*:/i.test(parsed)) throw new Error("the theme has a binding");
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Module source with the same exports as `src/theme.ts`. */
|
|
136
|
+
export function themeModule(css) {
|
|
137
|
+
const safe = assertInlineCss(css);
|
|
138
|
+
return `export const PAGE_STYLE = ${JSON.stringify(safe)};\nexport function pageStyle(): string {\n\treturn PAGE_STYLE;\n}\n`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Replacement for `src/theme.ts` when `file` is set. Null leaves the neutral module in place.
|
|
143
|
+
* @param {string} id
|
|
144
|
+
* @param {string | undefined} file
|
|
145
|
+
*/
|
|
146
|
+
export function applyWorkerTheme(id, file) {
|
|
147
|
+
if (!file) return null;
|
|
148
|
+
const clean = id.split("?")[0].replaceAll("\\", "/");
|
|
149
|
+
if (!clean.endsWith("/src/theme.ts")) return null;
|
|
150
|
+
let css;
|
|
151
|
+
try {
|
|
152
|
+
css = fs.readFileSync(path.resolve(file), "utf8");
|
|
153
|
+
} catch {
|
|
154
|
+
throw new Error("WORKER_THEME could not be read");
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return themeModule(css);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const reason = error instanceof Error ? error.message : "the theme was refused";
|
|
160
|
+
throw new Error(`WORKER_THEME was refused (${reason})`);
|
|
161
|
+
}
|
|
162
|
+
}
|
package/worker/vite.config.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { cloudflare } from "@cloudflare/vite-plugin";
|
|
2
2
|
import { defineConfig } from "vite";
|
|
3
|
+
import { applyWorkerTheme } from "./theme-build.mjs";
|
|
3
4
|
|
|
4
5
|
export default defineConfig({
|
|
5
|
-
plugins: [
|
|
6
|
+
plugins: [
|
|
7
|
+
{
|
|
8
|
+
name: "inbox-theme",
|
|
9
|
+
enforce: "pre",
|
|
10
|
+
transform(_code, id) {
|
|
11
|
+
const next = applyWorkerTheme(id, process.env.WORKER_THEME);
|
|
12
|
+
return next ? { code: next, map: null } : null;
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
cloudflare(),
|
|
16
|
+
],
|
|
6
17
|
});
|