@wizzlethorpe/vaults 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/asset-refs.js +274 -0
  2. package/dist/asset-refs.js.map +1 -0
  3. package/dist/auth.js.map +1 -1
  4. package/dist/build.js +252 -492
  5. package/dist/build.js.map +1 -1
  6. package/dist/commands/build.js +54 -5
  7. package/dist/commands/build.js.map +1 -1
  8. package/dist/commands/preview.js +0 -4
  9. package/dist/commands/preview.js.map +1 -1
  10. package/dist/commands/push.js +4 -9
  11. package/dist/commands/push.js.map +1 -1
  12. package/dist/commands/role.js +55 -14
  13. package/dist/commands/role.js.map +1 -1
  14. package/dist/config.js +25 -5
  15. package/dist/config.js.map +1 -1
  16. package/dist/foundry-importer.bundle.js +1228 -327
  17. package/dist/foundry-importer.js +2 -7
  18. package/dist/foundry-importer.js.map +1 -1
  19. package/dist/foundry-meta.js +284 -0
  20. package/dist/foundry-meta.js.map +1 -0
  21. package/dist/foundry-module-journal.js +112 -0
  22. package/dist/foundry-module-journal.js.map +1 -0
  23. package/dist/foundry-module-render.js +75 -0
  24. package/dist/foundry-module-render.js.map +1 -0
  25. package/dist/foundry-module.js +1075 -0
  26. package/dist/foundry-module.js.map +1 -0
  27. package/dist/frontmatter-defaults.js +68 -0
  28. package/dist/frontmatter-defaults.js.map +1 -0
  29. package/dist/index.js +9 -9
  30. package/dist/index.js.map +1 -1
  31. package/dist/manifest.js +115 -0
  32. package/dist/manifest.js.map +1 -0
  33. package/dist/render/auth-template.js +323 -35
  34. package/dist/render/auth-template.js.map +1 -1
  35. package/dist/render/bases.js +22 -38
  36. package/dist/render/bases.js.map +1 -1
  37. package/dist/render/cover.js +23 -1
  38. package/dist/render/cover.js.map +1 -1
  39. package/dist/render/handlers/builtin/download.js +90 -0
  40. package/dist/render/handlers/builtin/download.js.map +1 -0
  41. package/dist/render/handlers/builtin/foundry-manifest.js +158 -0
  42. package/dist/render/handlers/builtin/foundry-manifest.js.map +1 -0
  43. package/dist/render/handlers/builtin/index.js +3 -1
  44. package/dist/render/handlers/builtin/index.js.map +1 -1
  45. package/dist/render/pipeline.js +4 -1
  46. package/dist/render/pipeline.js.map +1 -1
  47. package/dist/render/slug.js +0 -5
  48. package/dist/render/slug.js.map +1 -1
  49. package/dist/scan.js +8 -0
  50. package/dist/scan.js.map +1 -1
  51. package/dist/settings.js +112 -6
  52. package/dist/settings.js.map +1 -1
  53. package/package.json +2 -1
@@ -2,11 +2,13 @@
2
2
  // role list and password hashes baked in. Validates session cookies, redirects
3
3
  // to /login on a missing/expired cookie, and rewrites every request to the
4
4
  // matching `_variants/<role>/` path before letting Pages serve it.
5
+ import { htmlAttr, htmlEscape } from "../escape.js";
5
6
  export function renderAuthMiddleware(cfg) {
6
7
  const rolesLiteral = JSON.stringify(cfg.roles);
7
8
  const passwordsLiteral = JSON.stringify(cfg.rolePasswords);
8
9
  const patreonLiteral = JSON.stringify(cfg.patreon ?? null);
9
10
  const oidcLiteral = JSON.stringify(cfg.oidc ?? null);
11
+ const foundryLiteral = JSON.stringify(!!cfg.foundry);
10
12
  return `// Auto-generated by the vaults CLI. Do not edit by hand.
11
13
  // Roles, password hashes, and routing live here so the deployed Function
12
14
  // is fully self-contained and doesn't need any other binding besides
@@ -18,6 +20,9 @@ const ROLES = ${rolesLiteral};
18
20
  const PASSWORDS = ${passwordsLiteral};
19
21
  const PATREON = ${patreonLiteral};
20
22
  const OIDC = ${oidcLiteral};
23
+ // False on a deploy that opted out of the Foundry integration; the /_batch
24
+ // endpoints below are the API its module syncs through and nothing else uses.
25
+ const FOUNDRY = ${foundryLiteral};
21
26
  const COOKIE_NAME = "vault_role";
22
27
  // Non-HttpOnly companion cookie carrying the role name only; the auth check
23
28
  // uses COOKIE_NAME (which is signed and HttpOnly), this one is purely for UI.
@@ -26,6 +31,20 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
26
31
  // Bearer tokens (used by Foundry, MCP clients) get a much longer lifetime
27
32
  // since refreshing means reopening a browser-based approval flow.
28
33
  const BEARER_MAX_AGE = 60 * 60 * 24 * 90; // 90 days
34
+ // Long enough to paste a URL somewhere and press install, short enough that a
35
+ // leaked one is worthless. Deliberately not single-use: a use-count needs
36
+ // server-side state, and this deploy has none by design, so the honest
37
+ // stateless equivalent is a small window. Pre-signed URLs everywhere else
38
+ // work the same way for the same reason.
39
+ const LINK_MAX_AGE = 60 * 10; // 10 minutes
40
+ // Tokens carry their purpose so the two are not interchangeable: without
41
+ // this a 7-day session cookie and a 90-day bearer were byte-identical in
42
+ // format, so either could be replayed as the other.
43
+ const TOKEN_TYPE_SESSION = "s";
44
+ const TOKEN_TYPE_BEARER = "b";
45
+ // A link token. Separate from a bearer because it is honoured on ordinary
46
+ // navigation, which a bearer deliberately is not — see readRole.
47
+ const TOKEN_TYPE_LINK = "l";
29
48
  const PBKDF2_DEFAULT_ITERATIONS = 100000;
30
49
  // Same shape as a real PBKDF2 hash (iterations:saltHex:hashHex with the
31
50
  // expected lengths) but with all-zero salt + hash. Used to keep the
@@ -37,6 +56,25 @@ const DUMMY_PASSWORD_HASH = "100000:" + "0".repeat(32) + ":" + "0".repeat(64);
37
56
  // ── Public middleware entry ────────────────────────────────────────────────
38
57
 
39
58
  export const onRequest = async (ctx) => {
59
+ return withSecurityHeaders(await handleRequest(ctx));
60
+ };
61
+
62
+ /**
63
+ * Headers every response gets, added at the one exit so redirects and error
64
+ * responses are covered too.
65
+ *
66
+ * Referrer-Policy is load-bearing here rather than hygiene: the Foundry sync
67
+ * passes its bearer as ?_token= in the URL, and without this any external
68
+ * link on a page fetched that way would carry the token in the Referer.
69
+ */
70
+ function withSecurityHeaders(response) {
71
+ const out = new Response(response.body, response);
72
+ out.headers.set("Referrer-Policy", "no-referrer");
73
+ out.headers.set("X-Content-Type-Options", "nosniff");
74
+ return out;
75
+ }
76
+
77
+ const handleRequest = async (ctx) => {
40
78
  const { request, env, next } = ctx;
41
79
  const url = new URL(request.url);
42
80
 
@@ -88,6 +126,14 @@ export const onRequest = async (ctx) => {
88
126
  return handleConnectApprove(request, env);
89
127
  }
90
128
 
129
+ // /_link. Mints a short-lived, self-authenticating URL for one path, for
130
+ // consumers that cannot carry the session cookie. Foundry's module
131
+ // installer is the motivating one: it runs on the Foundry server, not in
132
+ // the browser, so it needs a URL that stands on its own.
133
+ if (url.pathname === "/_link" && request.method === "GET") {
134
+ return handleLink(request, env);
135
+ }
136
+
91
137
  // /auth/patreon/* — only mounted when the build saw a Patreon config.
92
138
  // Issues the same signed session cookie as password login on success;
93
139
  // failure paths bounce back to /login with an error param.
@@ -115,7 +161,7 @@ export const onRequest = async (ctx) => {
115
161
  // newline-separated paths under text/plain so the request stays CORS-
116
162
  // simple (no preflight per file → no OPTIONS rate-limit). Response is
117
163
  // JSON: { files: { path: content }, missing: [path, ...] }.
118
- if (url.pathname === "/_batch" && request.method === "POST") {
164
+ if (FOUNDRY && url.pathname === "/_batch" && request.method === "POST") {
119
165
  return withCors(await handleBatch(request, env), request);
120
166
  }
121
167
 
@@ -123,7 +169,7 @@ export const onRequest = async (ctx) => {
123
169
  // /_batch but each file is base64-encoded so it can ride in JSON. Used by
124
170
  // the Foundry image cache so a 300-image sync is a handful of HTTP calls
125
171
  // instead of 300 GETs that hit Cloudflare's per-IP rate limit.
126
- if (url.pathname === "/_batch-images" && request.method === "POST") {
172
+ if (FOUNDRY && url.pathname === "/_batch-images" && request.method === "POST") {
127
173
  return withCors(await handleBatchBinary(request, env), request);
128
174
  }
129
175
 
@@ -135,6 +181,20 @@ export const onRequest = async (ctx) => {
135
181
  // Determine the user's role from the session cookie (default = lowest).
136
182
  const role = await readRole(request, env);
137
183
 
184
+ // Installing a Foundry module is two fetches: the manifest, then the zip its
185
+ // download field names. A gated manifest is a static file, so it cannot
186
+ // carry a live token for that second fetch, and the zip would 401 — the
187
+ // install fails halfway with nothing useful said about why.
188
+ //
189
+ // So a manifest fetched with a link token gets its download URL signed to
190
+ // match, with the same short expiry. The rewrite only ever fires when a
191
+ // valid link token is present and the field points back at this site, so it
192
+ // cannot be used to attach our signature to somebody else's URL.
193
+ const linkToken = new URL(request.url).searchParams.get("_token");
194
+ const linkRole = linkToken
195
+ ? await verifyToken(linkToken, env.SESSION_SECRET, TOKEN_TYPE_LINK)
196
+ : null;
197
+
138
198
  // env.ASSETS canonicalizes URLs (strips .html, strips index.html, redirects
139
199
  // with 308s); passing those redirects through to the browser would expose
140
200
  // the /_variants/<role>/ path, which the guard at the top of this function
@@ -150,6 +210,11 @@ export const onRequest = async (ctx) => {
150
210
  response = await env.ASSETS.fetch(rewritten);
151
211
  }
152
212
  }
213
+ if (linkRole && response.ok && /\.json$/i.test(url.pathname)) {
214
+ const signed = await signManifestDownload(response, url, env, linkRole);
215
+ if (signed) return signed;
216
+ }
217
+
153
218
  // Replace bare 404s with the variant's styled 404 page so the reader stays
154
219
  // inside the site (sidebar, search, sitemap intact). Only HTML navigation
155
220
  // requests get the page; asset/API requests get the bare 404 unchanged.
@@ -280,10 +345,31 @@ async function handleBatchInner(request, env, maxPaths, encode) {
280
345
  if (!isSafePath(p)) return batchError(400, "Invalid path: " + p);
281
346
  }
282
347
 
283
- // ASSETS.fetch is internal to the worker, so fan-out is cheap.
348
+ // Which rendering to return, which is not always the caller's own.
349
+ //
350
+ // A page carries its own role, and the sync client marks a page readable by
351
+ // players when that role is below the DM tier. If it then filled that page
352
+ // with the *DM's* rendering — which is what happens when the variant is
353
+ // always the caller's — a public page ends up holding DM content and
354
+ // readable by players. A base view filtered by role is exactly that shape:
355
+ // one row per creature for the DM, one for everyone else, same page.
356
+ //
357
+ // So a caller may ask for any variant at or below their own tier. Below,
358
+ // because that is content they can already read; never above.
284
359
  const url = new URL(request.url);
360
+ const requested = url.searchParams.get("role");
361
+ let variant = role;
362
+ if (requested !== null) {
363
+ const wantIdx = ROLES.indexOf(requested);
364
+ if (wantIdx === -1 || wantIdx > ROLES.indexOf(role)) {
365
+ return batchError(403, "Cannot request that variant.");
366
+ }
367
+ variant = requested;
368
+ }
369
+
370
+ // ASSETS.fetch is internal to the worker, so fan-out is cheap.
285
371
  const entries = await Promise.all(paths.map(async (p) => {
286
- const target = new URL("/_variants/" + role + "/" + encodeVariantPath(p), url.origin).toString();
372
+ const target = new URL("/_variants/" + variant + "/" + encodeVariantPath(p), url.origin).toString();
287
373
  const res = await env.ASSETS.fetch(target);
288
374
  if (!res.ok) return [p, null];
289
375
  return [p, await encode(res)];
@@ -337,10 +423,11 @@ async function handleConnectGet(request, env) {
337
423
  const url = new URL(request.url);
338
424
  const app = url.searchParams.get("app") || "an external app";
339
425
 
340
- // Require login first; the user's role is what we're authorising.
426
+ // Require login first; the user's role is what we're authorising. The
427
+ // default role is the unauthenticated one, so it is the whole test —
428
+ // any other role can only have come from a verified cookie or token.
341
429
  const role = await readRole(request, env);
342
- const isLoggedIn = role !== ROLES[0] || PASSWORDS[role] != null;
343
- if (!isLoggedIn || role === ROLES[0]) {
430
+ if (role === ROLES[0]) {
344
431
  // Default role; redirect to login first, come back here on success.
345
432
  const next = url.pathname + url.search;
346
433
  return new Response(null, {
@@ -363,11 +450,98 @@ async function handleConnectApprove(request, env) {
363
450
  return new Response("Not signed in.", { status: 401 });
364
451
  }
365
452
 
366
- const token = await signToken(role, env.SESSION_SECRET, BEARER_MAX_AGE);
453
+ const token = await signToken(role, env.SESSION_SECRET, BEARER_MAX_AGE, TOKEN_TYPE_BEARER);
367
454
  const html = renderConnectCopyPage({ token, role, app });
368
455
  return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
369
456
  }
370
457
 
458
+ /**
459
+ * Issue a short-lived URL for a single vault path.
460
+ *
461
+ * The token carries the caller's own role, so this grants nothing they could
462
+ * not already fetch with their cookie; it only moves that authority into a
463
+ * URL, and puts a short clock on it.
464
+ *
465
+ * The path is confined to this deploy: it is resolved against the origin and
466
+ * rejected if it leaves, so a caller cannot get us to sign a link pointing at
467
+ * somewhere else.
468
+ */
469
+ async function handleLink(request, env) {
470
+ const url = new URL(request.url);
471
+ // Minting requires a session or a bearer, never a link token: otherwise one
472
+ // leaked install URL renews itself indefinitely instead of expiring.
473
+ const role = await readRole(request, env, { rejectLinkTokens: true });
474
+ if (role === ROLES[0]) {
475
+ return jsonResponse({ error: "not signed in" }, 401);
476
+ }
477
+ const requested = url.searchParams.get("path") || "";
478
+ if (!requested) {
479
+ return jsonResponse({ error: "missing path" }, 400);
480
+ }
481
+ let target;
482
+ try {
483
+ target = new URL(requested, url.origin);
484
+ } catch {
485
+ return jsonResponse({ error: "bad path" }, 400);
486
+ }
487
+ if (target.origin !== url.origin) {
488
+ return jsonResponse({ error: "path must be on this site" }, 400);
489
+ }
490
+ const token = await signToken(role, env.SESSION_SECRET, LINK_MAX_AGE, TOKEN_TYPE_LINK);
491
+ target.searchParams.set("_token", token);
492
+ return jsonResponse({
493
+ url: target.toString(),
494
+ role,
495
+ expiresInMinutes: Math.round(LINK_MAX_AGE / 60),
496
+ }, 200);
497
+ }
498
+
499
+ /**
500
+ * Re-sign a module manifest's download URL so the second half of a Foundry
501
+ * install authenticates too.
502
+ *
503
+ * Returns null and leaves the response untouched for anything that is not a
504
+ * manifest with a same-origin download field, so an ordinary JSON asset
505
+ * fetched with a link token is served exactly as stored.
506
+ */
507
+ async function signManifestDownload(response, url, env, role) {
508
+ let manifest;
509
+ try { manifest = await response.clone().json(); }
510
+ catch { return null; }
511
+ if (!manifest || typeof manifest.download !== "string") return null;
512
+
513
+ // Relative resolves onto whichever host this request arrived on, which is
514
+ // the point: a vault can be served from several (a pages.dev name and a
515
+ // custom domain), and a manifest that hard-codes one of them is wrong on
516
+ // the others whether or not tokens are involved.
517
+ //
518
+ // An absolute URL naming a different host is left alone. That is the guard
519
+ // against attaching our signature to somebody else's URL, and it cannot
520
+ // distinguish the vault's own second hostname from anyone else's — so a
521
+ // manifest that hard-codes the pages.dev name will not be signed when
522
+ // fetched over the custom domain. Write the field relative. The CLI warns
523
+ // when it is not.
524
+ let target;
525
+ try { target = new URL(manifest.download, url.origin); }
526
+ catch { return null; }
527
+ if (target.origin !== url.origin) return null;
528
+
529
+ const token = await signToken(role, env.SESSION_SECRET, LINK_MAX_AGE, TOKEN_TYPE_LINK);
530
+ target.searchParams.set("_token", token);
531
+ manifest.download = target.toString();
532
+ return new Response(JSON.stringify(manifest), {
533
+ status: 200,
534
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
535
+ });
536
+ }
537
+
538
+ function jsonResponse(body, status) {
539
+ return new Response(JSON.stringify(body), {
540
+ status,
541
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
542
+ });
543
+ }
544
+
371
545
  function renderApprovePage({ app, role }) {
372
546
  const escapedApp = escHtml(app);
373
547
  const escapedRole = escHtml(role);
@@ -809,6 +983,15 @@ function base64UrlEncode(bytes) {
809
983
  return btoa(s).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
810
984
  }
811
985
 
986
+ /** Inverse of base64UrlEncode for UTF-8 text. */
987
+ function base64UrlDecodeUtf8(s) {
988
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
989
+ const bin = atob(b64);
990
+ const bytes = new Uint8Array(bin.length);
991
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
992
+ return new TextDecoder().decode(bytes);
993
+ }
994
+
812
995
  // State cookie: stores { state, next } (plus the PKCE verifier for OIDC)
813
996
  // signed with SESSION_SECRET, used to verify the OAuth callback came from
814
997
  // our /start handler and not a forgery. Shared by both OAuth flows.
@@ -819,7 +1002,12 @@ async function signStateCookie(payload, secret) {
819
1002
  const exp = Math.floor(Date.now() / 1000) + STATE_TTL;
820
1003
  const data = JSON.stringify({ ...payload, exp });
821
1004
  const sig = await hmac(data, secret);
822
- const value = btoa(data) + "." + sig;
1005
+ // The payload carries the next path, and btoa throws on any code point
1006
+ // above U+00FF — so a page whose name uses a non-Latin1 character
1007
+ // (Sunawi with a w-circumflex, Ordogok, Japanese) turned the /start
1008
+ // handler into an unhandled 500. Encode the UTF-8 bytes instead.
1009
+ // (No backticks in this comment: it is inside the middleware template.)
1010
+ const value = base64UrlEncode(new TextEncoder().encode(data)) + "." + sig;
823
1011
  return STATE_COOKIE + "=" + value
824
1012
  + "; Path=/; Secure; SameSite=Lax; HttpOnly; Max-Age=" + STATE_TTL;
825
1013
  }
@@ -833,10 +1021,12 @@ async function readStateCookie(request, secret) {
833
1021
  const dataB64 = raw.slice(0, dot);
834
1022
  const sig = raw.slice(dot + 1);
835
1023
  let data;
836
- try { data = atob(dataB64); }
1024
+ try { data = base64UrlDecodeUtf8(dataB64); }
837
1025
  catch { return null; }
838
1026
  const expected = await hmac(data, secret);
839
- if (sig !== expected) return null;
1027
+ // Constant-time, matching verifyToken; a plain !== leaks the signature
1028
+ // byte-by-byte through response timing.
1029
+ if (!constantTimeEqual(sig, expected)) return null;
840
1030
  let parsed;
841
1031
  try { parsed = JSON.parse(data); }
842
1032
  catch { return null; }
@@ -846,7 +1036,7 @@ async function readStateCookie(request, secret) {
846
1036
 
847
1037
  // ── Cookie + role lookup ──────────────────────────────────────────────────
848
1038
 
849
- async function readRole(request, env) {
1039
+ async function readRole(request, env, opts) {
850
1040
  const fallback = ROLES[0];
851
1041
  if (!env.SESSION_SECRET) return fallback;
852
1042
 
@@ -856,35 +1046,78 @@ async function readRole(request, env) {
856
1046
  const auth = request.headers.get("Authorization") || "";
857
1047
  const bearerMatch = /^Bearer\\s+(.+)$/i.exec(auth);
858
1048
  if (bearerMatch) {
859
- const role = await verifyToken(bearerMatch[1], env.SESSION_SECRET);
1049
+ const role = await verifyToken(bearerMatch[1], env.SESSION_SECRET, TOKEN_TYPE_BEARER);
860
1050
  if (role && ROLES.includes(role)) return role;
861
1051
  }
862
1052
 
863
1053
  // ?_token=<token>; used by the Foundry module so cross-origin GETs stay
864
1054
  // CORS-simple and don't trigger a preflight per file (Cloudflare rate-
865
1055
  // limits OPTIONS bursts and a sync is hundreds of unique URLs).
1056
+ //
1057
+ // Only honoured on a request that says it is a subresource fetch. A token
1058
+ // in a URL is a shareable credential that lands in browser history, access
1059
+ // logs and Referer, and bearers last 90 days — so a pasted link must not
1060
+ // quietly browse the site at someone else's role.
1061
+ //
1062
+ // Fails CLOSED: an absent Sec-Fetch-Mode means the token is ignored. The
1063
+ // earlier version only rejected an explicit "navigate", which left the hole
1064
+ // open for anything that does not send Fetch Metadata — a proxy that strips
1065
+ // it, or a browser older than Chrome 76 / Firefox 90 / Safari 16.4. The
1066
+ // query param exists purely so the sync client's cross-origin GETs stay
1067
+ // CORS-simple, and that client is a browser, so it always sends the header.
1068
+ // Anything that cannot has no reason to prefer the param: it can set an
1069
+ // Authorization: Bearer header freely, which is checked above.
866
1070
  const queryToken = new URL(request.url).searchParams.get("_token");
867
- if (queryToken) {
868
- const role = await verifyToken(queryToken, env.SESSION_SECRET);
1071
+ if (queryToken && isSubresourceFetch(request)) {
1072
+ const role = await verifyToken(queryToken, env.SESSION_SECRET, TOKEN_TYPE_BEARER);
1073
+ if (role && ROLES.includes(role)) return role;
1074
+ }
1075
+
1076
+ // A link token IS honoured on navigation, which is the whole reason it is a
1077
+ // separate type. The rule above rests on bearers lasting 90 days; a link
1078
+ // token lasts ten minutes, so the same reasoning does not reach it. It also
1079
+ // has to work this way: Foundry's module installer accepts a URL and
1080
+ // nothing else, so it cannot send the Authorization header the rule above
1081
+ // points anything header-capable towards.
1082
+ //
1083
+ // It authenticates one request and sets no cookie, so a shared link does
1084
+ // not turn into a session at someone else's role.
1085
+ // Not accepted where a caller could use one to obtain another. A link token
1086
+ // is a ten-minute grant, and a request that can mint a fresh one renews
1087
+ // itself forever — which would quietly turn the short window that justifies
1088
+ // honouring these on navigation into no window at all.
1089
+ if (queryToken && !opts?.rejectLinkTokens) {
1090
+ const role = await verifyToken(queryToken, env.SESSION_SECRET, TOKEN_TYPE_LINK);
869
1091
  if (role && ROLES.includes(role)) return role;
870
1092
  }
871
1093
 
872
1094
  const cookie = parseCookie(request.headers.get("Cookie") || "")[COOKIE_NAME];
873
1095
  if (!cookie) return fallback;
874
- const role = await verifyToken(cookie, env.SESSION_SECRET);
1096
+ const role = await verifyToken(cookie, env.SESSION_SECRET, TOKEN_TYPE_SESSION);
875
1097
  return role && ROLES.includes(role) ? role : fallback;
876
1098
  }
877
1099
 
1100
+ /**
1101
+ * True only when the request explicitly identifies itself as a subresource
1102
+ * fetch rather than a navigation. Absent headers count as "not a fetch", so
1103
+ * the caller refuses rather than guesses.
1104
+ */
1105
+ function isSubresourceFetch(request) {
1106
+ const mode = request.headers.get("Sec-Fetch-Mode");
1107
+ if (!mode || mode === "navigate") return false;
1108
+ return request.headers.get("Sec-Fetch-Dest") !== "document";
1109
+ }
1110
+
878
1111
  // Format: <role>.<expiryUnix>.<hmacHex>
879
- async function signToken(role, secret, maxAgeSeconds) {
1112
+ async function signToken(role, secret, maxAgeSeconds, typ) {
880
1113
  const exp = Math.floor(Date.now() / 1000) + maxAgeSeconds;
881
- const payload = role + "." + exp;
1114
+ const payload = typ + "." + role + "." + exp;
882
1115
  const sig = await hmac(payload, secret);
883
1116
  return payload + "." + sig;
884
1117
  }
885
1118
 
886
1119
  async function signSessionCookie(role, secret) {
887
- const value = await signToken(role, secret, COOKIE_MAX_AGE);
1120
+ const value = await signToken(role, secret, COOKIE_MAX_AGE, TOKEN_TYPE_SESSION);
888
1121
  // SameSite=None + Partitioned (CHIPS); required so the cookie persists
889
1122
  // when the vault is loaded inside a cross-origin iframe (the Foundry
890
1123
  // connect dialog). Partitioned scopes the cookie per parent origin, so
@@ -894,13 +1127,20 @@ async function signSessionCookie(role, secret) {
894
1127
  + "; Path=/; HttpOnly; Secure; SameSite=None; Partitioned; Max-Age=" + COOKIE_MAX_AGE;
895
1128
  }
896
1129
 
897
- async function verifyToken(token, secret) {
1130
+ async function verifyToken(token, secret, expectedTyp) {
898
1131
  const parts = token.split(".");
899
- if (parts.length !== 3) return null;
900
- const [role, expStr, sig] = parts;
1132
+ // 4 parts is the typed form; 3 is the untyped one issued before session
1133
+ // cookies and bearers were distinguishable. Legacy tokens still verify, for
1134
+ // either purpose, so nobody is logged out by the upgrade — they age out on
1135
+ // their own within 90 days. Drop this branch after that.
1136
+ const legacy = parts.length === 3;
1137
+ if (parts.length !== 4 && !legacy) return null;
1138
+ const [typ, role, expStr, sig] = legacy ? [expectedTyp, ...parts] : parts;
1139
+ if (typ !== expectedTyp) return null;
901
1140
  const exp = Number(expStr);
902
1141
  if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return null;
903
- const expected = await hmac(role + "." + expStr, secret);
1142
+ const signed = legacy ? role + "." + expStr : typ + "." + role + "." + expStr;
1143
+ const expected = await hmac(signed, secret);
904
1144
  return constantTimeEqual(sig, expected) ? role : null;
905
1145
  }
906
1146
 
@@ -985,7 +1225,7 @@ function isSharedAsset(pathname) {
985
1225
  // - styles.css / user.css — build.ts (writeFile join(outputDir, ...))
986
1226
  // - _handlers.js / _handlers.css — build.ts (handler asset bundles, root)
987
1227
  // - katex/… — build.ts (copyKatexAssets; math vaults only)
988
- // - _foundry/importer.js + version.json — build.ts (foundry-importer bundle)
1228
+ // - _foundry/importer.js — build.ts (foundry-importer bundle)
989
1229
  // - login.html — build.ts (multi-role only)
990
1230
  // - favicon.ico — build.ts (buildFavicon)
991
1231
  // - functions/_middleware.js — build.ts (multi-role only; not served)
@@ -1002,15 +1242,56 @@ function isSharedAsset(pathname) {
1002
1242
  if (pathname === "/_handlers.css") return true;
1003
1243
  if (pathname === "/katex/katex.min.css" || pathname.startsWith("/katex/fonts/")) return true;
1004
1244
  if (pathname === "/_foundry/importer.js") return true;
1005
- if (pathname === "/_foundry/version.json") return true;
1006
1245
  if (pathname === "/login.html") return true;
1007
1246
  if (pathname === "/favicon.ico" || pathname === "/favicon.svg") return true;
1008
1247
  if (pathname === "/robots.txt") return true;
1248
+ if (pathname === "/sitemap.xml") return true;
1009
1249
  return false;
1010
1250
  }
1011
1251
  `;
1012
1252
  }
1013
- export const LOGIN_HTML = `<!doctype html>
1253
+ /**
1254
+ * Render `login.html` for a deploy, showing only the sign-in methods it
1255
+ * actually has.
1256
+ *
1257
+ * A role is reachable by password only if a hash was set for it; Patreon and
1258
+ * OIDC each carry their own role mappings. A deploy authenticating purely
1259
+ * through a provider therefore gets no password form and no role selector —
1260
+ * the selector only ever chose which password to check, so with one password
1261
+ * role (or none) it is noise, and with none it would be a form that cannot
1262
+ * succeed.
1263
+ */
1264
+ export function renderLoginPage(opts) {
1265
+ const { passwordRoles, patreonRoles, oidcDisplayName } = opts;
1266
+ let form = "";
1267
+ if (passwordRoles.length > 0) {
1268
+ // One password role means there is nothing to choose, so the role rides
1269
+ // as a hidden field and the visitor just types a password.
1270
+ const rolePicker = passwordRoles.length === 1
1271
+ ? ` <input type="hidden" name="role" value="${htmlAttr(passwordRoles[0])}">`
1272
+ : ` <label for="role">Role</label>\n`
1273
+ + ` <select id="role" name="role">`
1274
+ + passwordRoles.map((r) => `<option value="${htmlAttr(r)}">${htmlEscape(r)}</option>`).join("")
1275
+ + `</select>`;
1276
+ form = ` <form id="login-form" method="POST" action="/login">\n`
1277
+ + `${rolePicker}\n`
1278
+ + ` <label for="password">Password</label>\n`
1279
+ + ` <input id="password" type="password" name="password" autocomplete="current-password" required>\n`
1280
+ + ` <input type="hidden" name="next" id="next">\n`
1281
+ + ` <button type="submit">Sign in</button>\n`
1282
+ + ` </form>`;
1283
+ }
1284
+ // Replacer *functions*, not strings: a string replacement interprets `$&`
1285
+ // and friends, and oidcDisplayName is free text, so a provider named
1286
+ // "Acme $& Co" would splice the placeholder back into the page.
1287
+ const literal = (value) => () => value;
1288
+ return LOGIN_HTML
1289
+ .replace("__PASSWORD_FORM__", literal(form))
1290
+ .replace("__PATREON_ROLES_ATTR__", literal(patreonRoles.length > 0
1291
+ ? ` data-patreon-roles="${htmlAttr(patreonRoles.join(","))}"` : ""))
1292
+ .replace("__OIDC_ATTR__", literal(oidcDisplayName ? ` data-oidc="${htmlAttr(oidcDisplayName)}"` : ""));
1293
+ }
1294
+ const LOGIN_HTML = `<!doctype html>
1014
1295
  <html lang="en">
1015
1296
  <head>
1016
1297
  <meta charset="utf-8">
@@ -1065,14 +1346,7 @@ export const LOGIN_HTML = `<!doctype html>
1065
1346
  <div class="login-card"__PATREON_ROLES_ATTR____OIDC_ATTR__>
1066
1347
  <h1>Sign in</h1>
1067
1348
  <p id="err" class="login-error" hidden></p>
1068
- <form method="POST" action="/login">
1069
- <label for="role">Role</label>
1070
- <select id="role" name="role">__ROLE_OPTIONS__</select>
1071
- <label for="password">Password</label>
1072
- <input id="password" type="password" name="password" autocomplete="current-password" required>
1073
- <input type="hidden" name="next" id="next">
1074
- <button type="submit">Sign in</button>
1075
- </form>
1349
+ __PASSWORD_FORM__
1076
1350
  <div id="patreon-section" hidden>
1077
1351
  <div class="login-divider">or</div>
1078
1352
  <a id="patreon-btn" class="patreon-btn" href="#">
@@ -1095,7 +1369,10 @@ export const LOGIN_HTML = `<!doctype html>
1095
1369
  <script>
1096
1370
  const params = new URLSearchParams(location.search);
1097
1371
  const next = params.get("next") || "/";
1098
- document.getElementById("next").value = next;
1372
+ // The password form is omitted entirely on a deploy where no role has a
1373
+ // password, so nothing here may assume its fields exist.
1374
+ const nextField = document.getElementById("next");
1375
+ if (nextField) nextField.value = next;
1099
1376
  const err = params.get("error");
1100
1377
  if (err) {
1101
1378
  const el = document.getElementById("err");
@@ -1135,6 +1412,17 @@ export const LOGIN_HTML = `<!doctype html>
1135
1412
  oidcBtn.textContent = "Sign in with " + card.dataset.oidc;
1136
1413
  oidcBtn.href = "/auth/oidc/start?next=" + encodeURIComponent(next);
1137
1414
  }
1415
+ // Each provider section leads with an "or" divider, which only reads
1416
+ // correctly as an alternative to the password form. With no form, the
1417
+ // first provider is the only way in, so its divider comes off.
1418
+ if (!document.getElementById("login-form")) {
1419
+ const sections = [document.getElementById("patreon-section"), document.getElementById("oidc-section")];
1420
+ const first = sections.find((s) => s && !s.hidden);
1421
+ if (first) {
1422
+ const divider = first.querySelector(".login-divider");
1423
+ if (divider) divider.remove();
1424
+ }
1425
+ }
1138
1426
  // Autofocus moved here so it picks the right field whether the password
1139
1427
  // form is visible or the user is going for the Patreon button.
1140
1428
  const pwd = document.getElementById("password");
@@ -1 +1 @@
1
- {"version":3,"file":"auth-template.js","sourceRoot":"","sources":["../../src/render/auth-template.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+EAA+E;AAC/E,2EAA2E;AAC3E,mEAAmE;AAuCnE,MAAM,UAAU,oBAAoB,CAAC,GAAuB;IAC1D,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAC/C,MAAM,gBAAgB,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAC3D,MAAM,cAAc,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC;IAC3D,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC;IAErD,OAAO;;;;;;;gBAOO,YAAY;oBACR,gBAAgB;kBAClB,cAAc;eACjB,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+9BzB,CAAC;AACF,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAmIlB,CAAC"}
1
+ {"version":3,"file":"auth-template.js","sourceRoot":"","sources":["../../src/render/auth-template.ts"],"names":[],"mappings":"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"}