a2a-exposed 0.7.9 → 0.7.11
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 +7 -1
- package/lib/pair.mjs +41 -5
- package/package.json +1 -1
- package/worker/src/approval-oidc.ts +17 -0
- package/worker/src/index.ts +23 -11
- package/worker/src/pairing.ts +25 -10
- package/worker/src/theme.ts +14 -0
- package/worker/test/approval-oidc.test.ts +70 -1
- package/worker/test/pairing.test.ts +19 -0
- package/worker/test/theme.test.ts +85 -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
|
|
@@ -179,6 +183,8 @@ Control plane account (only when that deployment's login is on)
|
|
|
179
183
|
page. Creating an agent shows the owner token once. That page is not stored.
|
|
180
184
|
FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
|
|
181
185
|
Unset, there is no limit. The database rejects another insert.
|
|
186
|
+
SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
|
|
187
|
+
A refusal returns to /app?error=invite and the page says Sign-in is invite-only for now.
|
|
182
188
|
The control Worker logs social_sign_in_failed before the
|
|
183
189
|
provider page, social_sign_in_timeout when that sign-in does not
|
|
184
190
|
return, and oauth_callback_failed when the provider sends the browser
|
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
|
@@ -23,6 +23,18 @@ export function issuerHost(issuer: string): string {
|
|
|
23
23
|
try { return new URL(issuer).host; } catch { return ""; }
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Origin to list in `form-action` when OpenID Connect approval is on.
|
|
28
|
+
* Scheme, host, and port only. A path, userinfo, or a non-https issuer is not included.
|
|
29
|
+
*/
|
|
30
|
+
export function formActionOrigin(issuer: string): string {
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(issuer);
|
|
33
|
+
if (u.protocol !== "https:" || u.username || u.password) return "";
|
|
34
|
+
return u.origin;
|
|
35
|
+
} catch { return ""; }
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
export function issuerBase(issuer: string): string {
|
|
27
39
|
return issuer.replace(/\/+$/, "");
|
|
28
40
|
}
|
|
@@ -241,6 +253,11 @@ export async function startOidc(ctx: TenantContext, o: {
|
|
|
241
253
|
let disc: Discovery;
|
|
242
254
|
try { disc = await discover(ctx, gate.issuer); }
|
|
243
255
|
catch { return { ok: false, error: UNREACHABLE, reason: "discovery" }; }
|
|
256
|
+
// form-action lists only the issuer origin. A sign-in address on another origin would be blocked by the browser.
|
|
257
|
+
const authOrigin = formActionOrigin(disc.authorization_endpoint);
|
|
258
|
+
if (!authOrigin || authOrigin !== formActionOrigin(gate.issuer)) {
|
|
259
|
+
return { ok: false, error: "The identity provider's sign-in address is not on the issuer origin. Nothing was approved.", reason: "endpoint_origin" };
|
|
260
|
+
}
|
|
244
261
|
const { verifier, challenge } = await pkce();
|
|
245
262
|
const state = b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
246
263
|
const nonce = b64url(crypto.getRandomValues(new Uint8Array(32)));
|
package/worker/src/index.ts
CHANGED
|
@@ -1325,6 +1325,16 @@ function approvalPageBits(ctx: TenantContext): { oidc?: { label: string }; passw
|
|
|
1325
1325
|
return bits;
|
|
1326
1326
|
}
|
|
1327
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* The OpenID Connect issuer origin, when that approval method is on.
|
|
1330
|
+
* A form on this host that 302s to the issuer is checked against this origin by Chrome's `form-action`.
|
|
1331
|
+
*/
|
|
1332
|
+
function oidcFormOrigins(ctx: TenantContext): string[] {
|
|
1333
|
+
if (!O.approvalPolicy(ctx.gates.approvalOidc).oidc) return [];
|
|
1334
|
+
const origin = O.formActionOrigin(ctx.gates.approvalOidc.issuer);
|
|
1335
|
+
return origin ? [origin] : [];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1328
1338
|
const redirect302 = (location: string) => new Response(null, { status: 302, headers: { location, "cache-control": "no-store", pragma: "no-cache", "referrer-policy": "no-referrer" } });
|
|
1329
1339
|
|
|
1330
1340
|
async function approvalPassword(ctx: TenantContext): Promise<P.PasswordRecord | null> {
|
|
@@ -1350,7 +1360,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
|
|
|
1350
1360
|
const pw = await approvalPassword(ctx);
|
|
1351
1361
|
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
1352
1362
|
const send = (status = 200) => {
|
|
1353
|
-
const h: Record<string, string> = P.pageHeaders(nonce);
|
|
1363
|
+
const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
|
|
1354
1364
|
h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
1355
1365
|
return new Response(P.devicePage(model, nonce), { status, headers: h });
|
|
1356
1366
|
};
|
|
@@ -1408,7 +1418,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
|
|
|
1408
1418
|
if (!started.ok) {
|
|
1409
1419
|
log("oidc_rejected", { kind: "device", reason: started.reason });
|
|
1410
1420
|
await show(form.user_code, { kind: "error", text: started.error });
|
|
1411
|
-
return send(started.reason === "config" ? 400 : 502);
|
|
1421
|
+
return send(started.reason === "config" || started.reason === "endpoint_origin" ? 400 : 502);
|
|
1412
1422
|
}
|
|
1413
1423
|
log("oidc_start", { kind: "device", issuer: O.issuerHost(ctx.gates.approvalOidc.issuer) });
|
|
1414
1424
|
return redirect302(started.location);
|
|
@@ -1455,7 +1465,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL): P
|
|
|
1455
1465
|
const pw = await approvalPassword(ctx);
|
|
1456
1466
|
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
1457
1467
|
const send = (status = 200) => {
|
|
1458
|
-
const h: Record<string, string> = P.pageHeaders(nonce);
|
|
1468
|
+
const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
|
|
1459
1469
|
h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
1460
1470
|
return new Response(P.devicePage(model, nonce), { status, headers: h });
|
|
1461
1471
|
};
|
|
@@ -1497,7 +1507,7 @@ async function liveSetup(ctx: TenantContext, token: string): Promise<{ rec: Setu
|
|
|
1497
1507
|
return A.timingSafeEqualStr(await A.sha256(token), s.rec.hash) ? s : null;
|
|
1498
1508
|
}
|
|
1499
1509
|
|
|
1500
|
-
/** GET/POST /device/setup: set or change the approval password from a one-time link. Same frame
|
|
1510
|
+
/** GET/POST /device/setup: set or change the approval password from a one-time link. Same frame and CSRF as /device. The form posts only to this host, so form-action stays 'self'. */
|
|
1501
1511
|
async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
1502
1512
|
const nonce = P.randomB64(), ip = clientIp(req);
|
|
1503
1513
|
const csrfName = "a2a_device_csrf";
|
|
@@ -1897,10 +1907,9 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
|
|
|
1897
1907
|
const q = req.method === "GET" ? Object.fromEntries(url.searchParams) : form;
|
|
1898
1908
|
const c = await checkAuthorize(ctx, q, ip);
|
|
1899
1909
|
const pw = await approvalPassword(ctx);
|
|
1900
|
-
const page = (status: number, body: string,
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
if (formOrigin) h["content-security-policy"] = h["content-security-policy"].replace("form-action 'self'", `form-action 'self' ${formOrigin}`);
|
|
1910
|
+
const page = (status: number, body: string, formOrigins: string[] = []) => {
|
|
1911
|
+
// Approve and deny 302 to the client. OpenID Connect 302s to the issuer. Chrome checks both against form-action.
|
|
1912
|
+
const h: Record<string, string> = P.pageHeaders(nonce, formOrigins);
|
|
1904
1913
|
h["set-cookie"] = `${csrfName}=${csrf}; Path=/oauth/authorize; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
1905
1914
|
return new Response(P.pageShell("Connect an MCP client", nonce, body), { status, headers: h });
|
|
1906
1915
|
};
|
|
@@ -1909,7 +1918,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
|
|
|
1909
1918
|
const origin = new URL(c.redirect!).origin;
|
|
1910
1919
|
const show = (status: number, notice?: M.ConsentModel["notice"]) => page(status, M.consentBody({ agentName: ctx.AGENT_NAME || "this A2A inbox", cli: cliCommand(ctx),
|
|
1911
1920
|
csrf, clientName: c.client!.client_name || "", clientIdHost: c.client!.cimd ? new URL(c.client!.client_id).host : undefined,
|
|
1912
|
-
redirectUri: c.redirect!, params: c.params, passwordSet: !!pw, ...approvalPageBits(ctx), notice }), origin);
|
|
1921
|
+
redirectUri: c.redirect!, params: c.params, passwordSet: !!pw, ...approvalPageBits(ctx), notice }), [origin, ...oidcFormOrigins(ctx)]);
|
|
1913
1922
|
if (req.method === "GET") return show(200);
|
|
1914
1923
|
const reqOrigin = req.headers.get("origin");
|
|
1915
1924
|
if ((reqOrigin && reqOrigin !== "null" && reqOrigin !== new URL(ctx.PUBLIC_URL).origin && reqOrigin !== url.origin) || fresh || !form.csrf || !A.timingSafeEqualStr(form.csrf, csrf))
|
|
@@ -1925,7 +1934,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
|
|
|
1925
1934
|
});
|
|
1926
1935
|
if (!started.ok) {
|
|
1927
1936
|
log("oidc_rejected", { kind: "mcp", reason: started.reason });
|
|
1928
|
-
return show(started.reason === "config" ? 400 : 502, { kind: "error", text: started.error });
|
|
1937
|
+
return show(started.reason === "config" || started.reason === "endpoint_origin" ? 400 : 502, { kind: "error", text: started.error });
|
|
1929
1938
|
}
|
|
1930
1939
|
log("oidc_start", { kind: "mcp", issuer: O.issuerHost(ctx.gates.approvalOidc.issuer) });
|
|
1931
1940
|
return redirect302(started.location);
|
|
@@ -1963,7 +1972,10 @@ async function issueMcpCode(ctx: TenantContext, clientName: string, redirect: st
|
|
|
1963
1972
|
return { ok: true, response: redirectWith(ctx, redirect, { code, state: params.state }) };
|
|
1964
1973
|
}
|
|
1965
1974
|
|
|
1966
|
-
/** GET /oauth/authorize/oidc/callback. Same state binding as the pairing callback; then the existing code redirect.
|
|
1975
|
+
/** GET /oauth/authorize/oidc/callback. Same state binding as the pairing callback; then the existing code redirect.
|
|
1976
|
+
* The identity provider navigates here with a GET. The code redirect is that navigation's 302, not a form post, so form-action does not apply to the client origin.
|
|
1977
|
+
* The error page has no form.
|
|
1978
|
+
*/
|
|
1967
1979
|
async function mcpOidcCallback(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
1968
1980
|
const nonce = P.randomB64();
|
|
1969
1981
|
const fail = (status: number, text: string) => new Response(P.pageShell("Connect an MCP client", nonce, `<p class="n error">${P.esc(text)}</p>`), { status, headers: P.pageHeaders(nonce) });
|
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
|
|
|
@@ -308,11 +303,31 @@ export function setupPage(m: SetupModel, nonce: string): string {
|
|
|
308
303
|
</form>`);
|
|
309
304
|
}
|
|
310
305
|
|
|
311
|
-
/**
|
|
312
|
-
export function
|
|
306
|
+
/** An http(s) origin (scheme, host, and port) with no userinfo. Anything else, including a path, is dropped. */
|
|
307
|
+
export function cspFormOrigin(raw: string): string {
|
|
308
|
+
try {
|
|
309
|
+
const u = new URL(raw);
|
|
310
|
+
if ((u.protocol !== "https:" && u.protocol !== "http:") || u.username || u.password) return "";
|
|
311
|
+
if (u.origin !== raw) return "";
|
|
312
|
+
return u.origin;
|
|
313
|
+
} catch { return ""; }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Security headers for the /device page: strict CSP (no scripts; only this page's own style), no framing, no caching.
|
|
318
|
+
* `formOrigins` are extra `form-action` origins. Chrome applies `form-action` to a form POST's redirect target,
|
|
319
|
+
* so a page whose POST answers 302 to another origin must list that origin here. With none, the directive stays `form-action 'self'`.
|
|
320
|
+
*/
|
|
321
|
+
export function pageHeaders(nonce: string, formOrigins: readonly string[] = []): Record<string, string> {
|
|
322
|
+
const extra: string[] = [];
|
|
323
|
+
for (const raw of formOrigins) {
|
|
324
|
+
const origin = cspFormOrigin(raw);
|
|
325
|
+
if (origin && !extra.includes(origin)) extra.push(origin);
|
|
326
|
+
}
|
|
327
|
+
const form = ["'self'", ...extra].join(" ");
|
|
313
328
|
return {
|
|
314
329
|
"content-type": "text/html; charset=utf-8",
|
|
315
|
-
"content-security-policy": `default-src 'none'; style-src 'nonce-${nonce}'; form-action
|
|
330
|
+
"content-security-policy": `default-src 'none'; style-src 'nonce-${nonce}'; form-action ${form}; frame-ancestors 'none'; base-uri 'none'`,
|
|
316
331
|
"x-frame-options": "DENY", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer",
|
|
317
332
|
"cache-control": "no-store", "cross-origin-opener-policy": "same-origin",
|
|
318
333
|
};
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
|
|
|
5
5
|
import crypto from "node:crypto";
|
|
6
6
|
import worker, { dispatch } from "../src/index.ts";
|
|
7
7
|
import * as M from "../src/mcp.ts";
|
|
8
|
-
import { approvalPolicy, hostOnZone, reachesControl, sameZoneHost } from "../src/approval-oidc.ts";
|
|
8
|
+
import { approvalPolicy, formActionOrigin, hostOnZone, reachesControl, sameZoneHost } from "../src/approval-oidc.ts";
|
|
9
9
|
import { hostedTenantContext, parseGates, type WorkerBindings } from "../src/tenancy.ts";
|
|
10
10
|
import { d1 } from "./d1.ts";
|
|
11
11
|
|
|
@@ -156,6 +156,70 @@ test("configured /device redirects to the issuer with PKCE and approves on a val
|
|
|
156
156
|
assert.equal(list.data.oidcIssuer, "idp.example");
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
function formAction(headers: Headers): string {
|
|
160
|
+
return /form-action ([^;]+)/.exec(headers.get("content-security-policy") || "")?.[1] || "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("form-action lists only the OpenID Connect issuer origin, and only when that approval is on", async (t) => {
|
|
164
|
+
assert.equal(formActionOrigin(ISSUER), ISSUER);
|
|
165
|
+
assert.equal(formActionOrigin(`${ISSUER}/realms/app`), ISSUER);
|
|
166
|
+
assert.equal(formActionOrigin("https://idp.example:8443/realms/app"), "https://idp.example:8443");
|
|
167
|
+
assert.equal(formActionOrigin("https://user:secret@idp.example"), "");
|
|
168
|
+
assert.equal(formActionOrigin("http://idp.example"), "");
|
|
169
|
+
|
|
170
|
+
const s = setup(oidcEnv); t.after(s.restore);
|
|
171
|
+
const d = await s.start();
|
|
172
|
+
const g = await s.page(d.data.user_code);
|
|
173
|
+
assert.equal(formAction(g.headers), `'self' ${ISSUER}`);
|
|
174
|
+
assert.equal(g.headers.get("content-security-policy")!.includes(`${ISSUER}/`), false);
|
|
175
|
+
|
|
176
|
+
const downFetch = globalThis.fetch;
|
|
177
|
+
globalThis.fetch = (async () => { throw new Error("down"); }) as typeof fetch;
|
|
178
|
+
const failed = await s.postOidc(d.data.user_code);
|
|
179
|
+
globalThis.fetch = downFetch;
|
|
180
|
+
assert.equal(failed.post.status, 502, failed.post.text);
|
|
181
|
+
assert.match(failed.post.text, /Approve with idp\.example/);
|
|
182
|
+
assert.equal(formAction(failed.post.headers), `'self' ${ISSUER}`);
|
|
183
|
+
|
|
184
|
+
const back = await s.call("GET", "/device/oidc/callback?error=access_denied");
|
|
185
|
+
assert.equal(back.status, 400);
|
|
186
|
+
assert.equal(formAction(back.headers), `'self' ${ISSUER}`);
|
|
187
|
+
|
|
188
|
+
const setupPage = await s.call("GET", "/device/setup");
|
|
189
|
+
assert.equal(formAction(setupPage.headers), "'self'");
|
|
190
|
+
const land = await s.call("GET", "/", { headers: { accept: "text/html" } });
|
|
191
|
+
assert.match(land.headers.get("content-security-policy") || "", /form-action 'none'/);
|
|
192
|
+
|
|
193
|
+
const pw = setup({ ...oidcEnv, APPROVAL_METHODS: "password" }); t.after(pw.restore);
|
|
194
|
+
const d2 = await pw.start();
|
|
195
|
+
const g2 = await pw.page(d2.data.user_code);
|
|
196
|
+
assert.equal(g2.text.includes("Approve with"), false);
|
|
197
|
+
assert.equal(formAction(g2.headers), "'self'");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("an authorization endpoint on another origin is not started", async (t) => {
|
|
201
|
+
const s = setup(oidcEnv); t.after(s.restore);
|
|
202
|
+
const inner = globalThis.fetch;
|
|
203
|
+
globalThis.fetch = (async (url: any, init: any) => {
|
|
204
|
+
const u = new URL(String(url));
|
|
205
|
+
if (u.origin === ISSUER && u.pathname === "/.well-known/openid-configuration") {
|
|
206
|
+
return Response.json({
|
|
207
|
+
issuer: ISSUER, authorization_endpoint: "https://login.example/authorize",
|
|
208
|
+
token_endpoint: `${ISSUER}/token`, jwks_uri: `${ISSUER}/jwks`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return inner(url, init);
|
|
212
|
+
}) as typeof fetch;
|
|
213
|
+
const d = await s.start();
|
|
214
|
+
const { post } = await s.postOidc(d.data.user_code as string);
|
|
215
|
+
assert.equal(post.status, 400, post.text);
|
|
216
|
+
assert.match(post.text, /not on the issuer origin/);
|
|
217
|
+
assert.equal(post.headers.get("location"), null);
|
|
218
|
+
const tok = await s.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: d.data.device_code } });
|
|
219
|
+
assert.equal(tok.data.error, "authorization_pending");
|
|
220
|
+
assert.equal(s.logs.join("\n").includes("login.example"), false);
|
|
221
|
+
});
|
|
222
|
+
|
|
159
223
|
test("wrong sub, wrong nonce and expired exp do not approve", async (t) => {
|
|
160
224
|
const s = setup(oidcEnv); t.after(s.restore);
|
|
161
225
|
const bad = async (over: Record<string, unknown>) => {
|
|
@@ -314,6 +378,7 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
|
|
|
314
378
|
const g = await s.call("GET", `/oauth/authorize?${new URLSearchParams(q)}`);
|
|
315
379
|
assert.match(g.text, /Continue with idp\.example/);
|
|
316
380
|
assert.match(g.text, /No approval password is set yet/);
|
|
381
|
+
assert.equal(/form-action ([^;]+)/.exec(g.headers.get("content-security-policy") || "")?.[1], `'self' https://claude.ai ${ISSUER}`);
|
|
317
382
|
const cookie = (g.headers.get("set-cookie") || "").split(";")[0];
|
|
318
383
|
const csrf = /name="csrf" value="([^"]+)"/.exec(g.text)![1];
|
|
319
384
|
const post = await s.call("POST", "/oauth/authorize", { form: { ...q, csrf, decision: "oidc" }, cookie, headers: { origin: BASE } });
|
|
@@ -323,6 +388,10 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
|
|
|
323
388
|
s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")! }));
|
|
324
389
|
const back = await s.call("GET", `/oauth/authorize/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
325
390
|
assert.equal(back.status, 302, back.text);
|
|
391
|
+
const callbackError = await s.call("GET", "/oauth/authorize/oidc/callback?error=access_denied");
|
|
392
|
+
assert.equal(callbackError.status, 400);
|
|
393
|
+
assert.equal(callbackError.text.includes("<form"), false);
|
|
394
|
+
assert.equal(/form-action ([^;]+)/.exec(callbackError.headers.get("content-security-policy") || "")?.[1], "'self'");
|
|
326
395
|
const client = new URL(back.headers.get("location")!);
|
|
327
396
|
assert.equal(client.origin, "https://claude.ai");
|
|
328
397
|
assert.equal(client.searchParams.get("state"), "st-1");
|
|
@@ -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,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
|
});
|