@wizzlethorpe/vaults 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,13 +5,16 @@
5
5
  export function renderAuthMiddleware(cfg) {
6
6
  const rolesLiteral = JSON.stringify(cfg.roles);
7
7
  const passwordsLiteral = JSON.stringify(cfg.rolePasswords);
8
+ const patreonLiteral = JSON.stringify(cfg.patreon ?? null);
8
9
  return `// Auto-generated by the vaults CLI. Do not edit by hand.
9
10
  // Roles, password hashes, and routing live here so the deployed Function
10
11
  // is fully self-contained and doesn't need any other binding besides
11
- // SESSION_SECRET (set via wrangler secret).
12
+ // SESSION_SECRET (set via wrangler secret). When Patreon is configured,
13
+ // PATREON_CLIENT_SECRET also has to be set as a Wrangler secret.
12
14
 
13
15
  const ROLES = ${rolesLiteral};
14
16
  const PASSWORDS = ${passwordsLiteral};
17
+ const PATREON = ${patreonLiteral};
15
18
  const COOKIE_NAME = "vault_role";
16
19
  // Non-HttpOnly companion cookie carrying the role name only; the auth check
17
20
  // uses COOKIE_NAME (which is signed and HttpOnly), this one is purely for UI.
@@ -21,6 +24,12 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
21
24
  // since refreshing means reopening a browser-based approval flow.
22
25
  const BEARER_MAX_AGE = 60 * 60 * 24 * 90; // 90 days
23
26
  const PBKDF2_DEFAULT_ITERATIONS = 100000;
27
+ // Same shape as a real PBKDF2 hash (iterations:saltHex:hashHex with the
28
+ // expected lengths) but with all-zero salt + hash. Used to keep the
29
+ // /login response time consistent when the role doesn't exist or has no
30
+ // password — running PBKDF2 against this dummy means an attacker can't
31
+ // distinguish "role exists" from "role doesn't exist" by timing.
32
+ const DUMMY_PASSWORD_HASH = "100000:" + "0".repeat(32) + ":" + "0".repeat(64);
24
33
 
25
34
  // ── Public middleware entry ────────────────────────────────────────────────
26
35
 
@@ -76,6 +85,18 @@ export const onRequest = async (ctx) => {
76
85
  return handleConnectApprove(request, env);
77
86
  }
78
87
 
88
+ // /auth/patreon/* — only mounted when the build saw a Patreon config.
89
+ // Issues the same signed session cookie as password login on success;
90
+ // failure paths bounce back to /login with an error param.
91
+ if (PATREON) {
92
+ if (url.pathname === "/auth/patreon/start" && request.method === "GET") {
93
+ return handlePatreonStart(request, env);
94
+ }
95
+ if (url.pathname === "/auth/patreon/callback" && request.method === "GET") {
96
+ return handlePatreonCallback(request, env);
97
+ }
98
+ }
99
+
79
100
  // /_batch; bulk source fetch for sync clients (Foundry). Body is
80
101
  // newline-separated paths under text/plain so the request stays CORS-
81
102
  // simple (no preflight per file → no OPTIONS rate-limit). Response is
@@ -163,13 +184,20 @@ async function handleLogin(request, env) {
163
184
  const password = String(form.get("password") || "");
164
185
  const next = safeNext(form.get("next"));
165
186
 
166
- if (!ROLES.includes(role) || !PASSWORDS[role]) {
167
- return loginRedirect(next, "invalid_role");
187
+ // Generic "auth_failed" error for both "no such role / no password set"
188
+ // and "wrong password" branches — distinct codes would let an attacker
189
+ // enumerate which roles exist and which have passwords. Constant-time
190
+ // PBKDF2 comparison handles the timing side; this handles the message
191
+ // side. Run verifyPassword against a dummy hash on the no-role branch
192
+ // so the response time profile stays roughly the same.
193
+ const passwordHash = (ROLES.includes(role) && PASSWORDS[role])
194
+ ? PASSWORDS[role]
195
+ : DUMMY_PASSWORD_HASH;
196
+ const ok = await verifyPassword(password, passwordHash);
197
+ if (!ok || !ROLES.includes(role) || !PASSWORDS[role]) {
198
+ return loginRedirect(next, "auth_failed");
168
199
  }
169
200
 
170
- const ok = await verifyPassword(password, PASSWORDS[role]);
171
- if (!ok) return loginRedirect(next, "wrong_password");
172
-
173
201
  const cookie = await signSessionCookie(role, env.SESSION_SECRET);
174
202
  const headers = new Headers({ Location: next });
175
203
  headers.append("Set-Cookie", cookie);
@@ -296,8 +324,14 @@ async function handleConnectGet(request, env) {
296
324
  const returnTo = url.searchParams.get("return_to") || "";
297
325
  const app = url.searchParams.get("app") || "an external app";
298
326
  const state = url.searchParams.get("state") || "";
299
-
300
- if (!returnTo || !isValidReturnTo(returnTo)) {
327
+ const delivery = url.searchParams.get("delivery") === "copy" ? "copy" : "redirect";
328
+
329
+ // The "redirect" delivery mode (legacy iframe/popup flow) round-trips a
330
+ // token to a host app via return_to. The "copy" delivery mode is the
331
+ // GitHub-CLI-style device flow: the user copy-pastes the token themselves,
332
+ // so return_to is irrelevant and Patreon login works because we're never
333
+ // inside an iframe.
334
+ if (delivery === "redirect" && (!returnTo || !isValidReturnTo(returnTo))) {
301
335
  return new Response("Invalid or missing return_to. Must be an http(s) URL.", { status: 400 });
302
336
  }
303
337
 
@@ -313,10 +347,10 @@ async function handleConnectGet(request, env) {
313
347
  });
314
348
  }
315
349
 
316
- const returnHost = (() => {
350
+ const returnHost = delivery === "copy" ? "" : (() => {
317
351
  try { return new URL(returnTo).host; } catch { return returnTo; }
318
352
  })();
319
- const html = renderApprovePage({ app, role, returnTo, returnHost, state });
353
+ const html = renderApprovePage({ app, role, returnTo, returnHost, state, delivery });
320
354
  return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
321
355
  }
322
356
 
@@ -324,8 +358,10 @@ async function handleConnectApprove(request, env) {
324
358
  const form = await request.formData();
325
359
  const returnTo = String(form.get("return_to") || "");
326
360
  const state = String(form.get("state") || "");
361
+ const delivery = String(form.get("delivery") || "") === "copy" ? "copy" : "redirect";
362
+ const app = String(form.get("app") || "an external app");
327
363
 
328
- if (!returnTo || !isValidReturnTo(returnTo)) {
364
+ if (delivery === "redirect" && (!returnTo || !isValidReturnTo(returnTo))) {
329
365
  return new Response("Invalid return_to.", { status: 400 });
330
366
  }
331
367
  const role = await readRole(request, env);
@@ -335,13 +371,12 @@ async function handleConnectApprove(request, env) {
335
371
  }
336
372
 
337
373
  const token = await signToken(role, env.SESSION_SECRET, BEARER_MAX_AGE);
338
- // Render a page that delivers the token via postMessage when running
339
- // inside an iframe or popup; that avoids a cross-site top-level
340
- // navigation back to the host app, which can blow away SPA sessions
341
- // (Foundry logs the user out on full reloads). Falls back to a top-
342
- // level redirect with the token in the query string for CLI / direct
343
- // browser flows that have neither a parent nor an opener.
344
- const html = renderConnectDeliveryPage({ token, state, returnTo });
374
+ const html = delivery === "copy"
375
+ ? renderConnectCopyPage({ token, role, app })
376
+ // Legacy delivery: postMessage to parent/opener; falls back to top-
377
+ // level redirect with the token in the query string. Kept for any
378
+ // existing iframe-based clients; new clients should use delivery=copy.
379
+ : renderConnectDeliveryPage({ token, state, returnTo });
345
380
  return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
346
381
  }
347
382
 
@@ -413,12 +448,15 @@ function isValidReturnTo(s) {
413
448
  } catch { return false; }
414
449
  }
415
450
 
416
- function renderApprovePage({ app, role, returnTo, returnHost, state }) {
451
+ function renderApprovePage({ app, role, returnTo, returnHost, state, delivery }) {
417
452
  const escapedApp = escHtml(app);
418
453
  const escapedRole = escHtml(role);
419
454
  const escapedHost = escHtml(returnHost);
420
455
  const escapedReturnTo = escAttr(returnTo);
421
456
  const escapedState = escAttr(state);
457
+ const escapedDelivery = escAttr(delivery);
458
+ const escapedAppAttr = escAttr(app);
459
+ const isCopy = delivery === "copy";
422
460
  return \`<!doctype html>
423
461
  <html lang="en">
424
462
  <head>
@@ -460,14 +498,15 @@ function renderApprovePage({ app, role, returnTo, returnHost, state }) {
460
498
  <strong>\${escapedApp}</strong> wants access to your vault as
461
499
  <strong>\${escapedRole}</strong>.
462
500
  </p>
463
- <p class="info">After approval, you'll be redirected to:</p>
464
- <code class="return-host">\${escapedHost}</code>
465
- <p class="warning">
466
- Verify the destination above looks right. If you didn't initiate
467
- this request, click Deny.
468
- </p>
501
+ \${isCopy
502
+ ? \`<p class="info">After approval, this page will display a token to copy and paste back into <strong>\${escapedApp}</strong>.</p>\`
503
+ : \`<p class="info">After approval, you'll be redirected to:</p>
504
+ <code class="return-host">\${escapedHost}</code>
505
+ <p class="warning">⚠ Verify the destination above looks right. If you didn't initiate this request, click Deny.</p>\`}
469
506
  <input type="hidden" name="return_to" value="\${escapedReturnTo}">
470
507
  <input type="hidden" name="state" value="\${escapedState}">
508
+ <input type="hidden" name="delivery" value="\${escapedDelivery}">
509
+ <input type="hidden" name="app" value="\${escapedAppAttr}">
471
510
  <div class="actions">
472
511
  <a class="deny" href="/">Deny</a>
473
512
  <button type="submit">Approve</button>
@@ -477,9 +516,280 @@ function renderApprovePage({ app, role, returnTo, returnHost, state }) {
477
516
  </html>\`;
478
517
  }
479
518
 
519
+ function renderConnectCopyPage({ token, role, app }) {
520
+ const escapedApp = escHtml(app);
521
+ const escapedRole = escHtml(role);
522
+ const escapedToken = escAttr(token);
523
+ return \`<!doctype html>
524
+ <html lang="en">
525
+ <head>
526
+ <meta charset="utf-8">
527
+ <meta name="viewport" content="width=device-width,initial-scale=1">
528
+ <title>Authorised \${escapedApp}</title>
529
+ <link rel="stylesheet" href="/styles.css">
530
+ <style>
531
+ body { display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
532
+ .copy-card {
533
+ max-width: 32rem; width: 90%; padding: 2rem;
534
+ border: 1px solid var(--rule); border-radius: 6px; background: var(--bg);
535
+ }
536
+ .copy-card h1 { margin: 0 0 0.75rem; font-size: 1.3rem; }
537
+ .copy-card .info { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.75rem; }
538
+ .copy-card .info strong { color: var(--accent); }
539
+ .copy-card .token {
540
+ display: block; width: 100%; box-sizing: border-box;
541
+ margin: 0.75rem 0;
542
+ padding: 0.75rem 0.85rem;
543
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
544
+ font-size: 0.82rem; line-height: 1.45;
545
+ background: var(--wikilink-bg);
546
+ color: var(--fg);
547
+ border: 1px solid var(--rule); border-radius: 4px;
548
+ word-break: break-all; resize: vertical;
549
+ }
550
+ .copy-card .copy-btn {
551
+ width: 100%; padding: 0.6rem 1rem; margin-top: 0.5rem; font: inherit; font-size: 0.95rem;
552
+ background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 4px; cursor: pointer;
553
+ }
554
+ .copy-card .copy-status {
555
+ text-align: center; color: var(--muted); font-size: 0.78rem; margin-top: 0.5rem; min-height: 1em;
556
+ }
557
+ .copy-card .copy-status.ok { color: #2a8b58; }
558
+ .copy-card ol { font-size: 0.88rem; color: var(--muted); padding-left: 1.25rem; margin: 0.75rem 0 0; }
559
+ .copy-card ol li { margin: 0.25rem 0; }
560
+ </style>
561
+ </head>
562
+ <body>
563
+ <div class="copy-card">
564
+ <h1>✓ Authorised \${escapedApp}</h1>
565
+ <p class="info">
566
+ A token for the <strong>\${escapedRole}</strong> tier has been generated.
567
+ Copy it back into <strong>\${escapedApp}</strong> to complete sign-in.
568
+ </p>
569
+ <textarea class="token" id="token" readonly rows="4" onclick="this.select()">\${escapedToken}</textarea>
570
+ <button type="button" class="copy-btn" id="copy-btn">Copy token</button>
571
+ <p class="copy-status" id="copy-status"></p>
572
+ <ol>
573
+ <li>Click <strong>Copy token</strong> above.</li>
574
+ <li>Switch back to \${escapedApp}.</li>
575
+ <li>Paste into the token field and confirm.</li>
576
+ </ol>
577
+ </div>
578
+ <script>
579
+ (function () {
580
+ var btn = document.getElementById('copy-btn');
581
+ var ta = document.getElementById('token');
582
+ var status = document.getElementById('copy-status');
583
+ btn.addEventListener('click', async function () {
584
+ try {
585
+ await navigator.clipboard.writeText(ta.value);
586
+ status.textContent = 'Copied to clipboard.';
587
+ status.className = 'copy-status ok';
588
+ } catch (e) {
589
+ ta.select();
590
+ status.textContent = 'Press Ctrl/Cmd-C to copy.';
591
+ status.className = 'copy-status';
592
+ }
593
+ setTimeout(function () { status.textContent = ''; }, 4000);
594
+ });
595
+ })();
596
+ </script>
597
+ </body>
598
+ </html>\`;
599
+ }
600
+
480
601
  function escHtml(s) { return String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c])); }
481
602
  function escAttr(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
482
603
 
604
+ // ── Patreon OAuth (optional, additive to password login) ─────────────────
605
+ // Bound only when PATREON !== null (i.e. the build saw a configured
606
+ // patreon block with at least one role mapped to a tier). Issues the same
607
+ // signed session cookie as password login on success; bounces back to
608
+ // /login with an error param on failure. CSRF state rides through a short-
609
+ // lived signed cookie so the callback can verify the round-trip.
610
+
611
+ const PATREON_STATE_COOKIE = "vault_patreon_state";
612
+ const PATREON_STATE_TTL = 600; // 10 minutes — long enough for the user to authorise
613
+
614
+ async function handlePatreonStart(request, env) {
615
+ if (!env.PATREON_CLIENT_SECRET) {
616
+ return new Response("Patreon login is misconfigured: PATREON_CLIENT_SECRET secret is missing.", { status: 500 });
617
+ }
618
+ const url = new URL(request.url);
619
+ const next = safeNext(url.searchParams.get("next"));
620
+ // Bind state to next so a forged callback can't drop the user on an
621
+ // attacker-chosen URL post-login. The signed cookie carries both back.
622
+ const stateValue = crypto.randomUUID();
623
+ const stateCookie = await signStateCookie({ state: stateValue, next }, env.SESSION_SECRET);
624
+
625
+ const redirectUri = url.origin + "/auth/patreon/callback";
626
+ const authorize = new URL("https://www.patreon.com/oauth2/authorize");
627
+ authorize.searchParams.set("response_type", "code");
628
+ authorize.searchParams.set("client_id", PATREON.clientId);
629
+ authorize.searchParams.set("redirect_uri", redirectUri);
630
+ authorize.searchParams.set("scope", "identity identity.memberships");
631
+ authorize.searchParams.set("state", stateValue);
632
+
633
+ const headers = new Headers({ Location: authorize.toString() });
634
+ headers.append("Set-Cookie", stateCookie);
635
+ return new Response(null, { status: 302, headers });
636
+ }
637
+
638
+ async function handlePatreonCallback(request, env) {
639
+ if (!env.PATREON_CLIENT_SECRET) {
640
+ return loginRedirect("/", "patreon_misconfigured");
641
+ }
642
+ const url = new URL(request.url);
643
+ const code = url.searchParams.get("code");
644
+ const state = url.searchParams.get("state");
645
+ if (!code || !state) return loginRedirect("/", "patreon_failed");
646
+
647
+ const stateData = await readStateCookie(request, env.SESSION_SECRET);
648
+ // Always clear the state cookie regardless of outcome.
649
+ const clearState = clearCookieVariants(PATREON_STATE_COOKIE);
650
+
651
+ if (!stateData || stateData.state !== state) {
652
+ const headers = new Headers({ Location: "/login?error=patreon_state_mismatch" });
653
+ for (const v of clearState) headers.append("Set-Cookie", v);
654
+ return new Response(null, { status: 302, headers });
655
+ }
656
+ const next = stateData.next || "/";
657
+
658
+ // Exchange code → access token. The token is short-lived; we don't store
659
+ // it. Only used once, immediately, to look up the visitor's memberships.
660
+ let access;
661
+ try {
662
+ access = await patreonExchangeCode(code, url.origin + "/auth/patreon/callback", env.PATREON_CLIENT_SECRET);
663
+ } catch (err) {
664
+ console.warn("Patreon token exchange failed:", err);
665
+ const headers = new Headers({ Location: "/login?error=patreon_token_exchange" });
666
+ for (const v of clearState) headers.append("Set-Cookie", v);
667
+ return new Response(null, { status: 302, headers });
668
+ }
669
+
670
+ let identity;
671
+ try { identity = await patreonFetchIdentity(access); }
672
+ catch (err) {
673
+ console.warn("Patreon identity fetch failed:", err);
674
+ const headers = new Headers({ Location: "/login?error=patreon_identity" });
675
+ for (const v of clearState) headers.append("Set-Cookie", v);
676
+ return new Response(null, { status: 302, headers });
677
+ }
678
+
679
+ const role = matchHighestRole(identity, PATREON.campaignId, PATREON.tiers);
680
+ if (!role) {
681
+ // Authenticated, but no qualifying tier on this campaign. Send them
682
+ // back to /login with a specific error so the UI can explain.
683
+ const headers = new Headers({ Location: "/login?error=patreon_no_tier&next=" + encodeURIComponent(next) });
684
+ for (const v of clearState) headers.append("Set-Cookie", v);
685
+ return new Response(null, { status: 302, headers });
686
+ }
687
+
688
+ const cookie = await signSessionCookie(role, env.SESSION_SECRET);
689
+ const headers = new Headers({ Location: next });
690
+ headers.append("Set-Cookie", cookie);
691
+ headers.append("Set-Cookie", DISPLAY_COOKIE_NAME + "=" + encodeURIComponent(role)
692
+ + "; Path=/; Secure; SameSite=None; Partitioned; Max-Age=" + COOKIE_MAX_AGE);
693
+ for (const v of clearState) headers.append("Set-Cookie", v);
694
+ return new Response(null, { status: 302, headers });
695
+ }
696
+
697
+ /**
698
+ * Walk the visitor's identity payload, find memberships scoped to OUR
699
+ * campaign, and return the highest-ranked role whose mapped tier ID
700
+ * appears in the visitor's currently-entitled tiers. Returns null if no
701
+ * tier matches — the visitor may be a patron of someone else, or the
702
+ * creator's own account, or just not entitled to any mapped tier.
703
+ */
704
+ function matchHighestRole(identity, campaignId, tiers) {
705
+ const memberships = (identity?.included || []).filter((it) => it.type === "member");
706
+ // Filter to memberships on our campaign (Patreon nests campaign under relationships).
707
+ const ourCampaignMemberships = memberships.filter((m) => {
708
+ const camp = m.relationships?.campaign?.data;
709
+ return camp && camp.type === "campaign" && String(camp.id) === String(campaignId);
710
+ });
711
+ const entitledTierIds = new Set();
712
+ for (const m of ourCampaignMemberships) {
713
+ const ents = m.relationships?.currently_entitled_tiers?.data || [];
714
+ for (const t of ents) if (t.type === "tier") entitledTierIds.add(String(t.id));
715
+ }
716
+ if (entitledTierIds.size === 0) return null;
717
+ // Roles are ordered low → high; iterate from highest to find the best match.
718
+ for (let i = ROLES.length - 1; i >= 0; i--) {
719
+ const r = ROLES[i];
720
+ const tier = tiers[r];
721
+ if (tier && entitledTierIds.has(String(tier))) return r;
722
+ }
723
+ return null;
724
+ }
725
+
726
+ async function patreonExchangeCode(code, redirectUri, clientSecret) {
727
+ const body = new URLSearchParams({
728
+ grant_type: "authorization_code",
729
+ code,
730
+ client_id: PATREON.clientId,
731
+ client_secret: clientSecret,
732
+ redirect_uri: redirectUri,
733
+ });
734
+ const res = await fetch("https://www.patreon.com/api/oauth2/token", {
735
+ method: "POST",
736
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
737
+ body: body.toString(),
738
+ });
739
+ if (!res.ok) throw new Error("token exchange status " + res.status);
740
+ const data = await res.json();
741
+ if (!data.access_token) throw new Error("no access_token in response");
742
+ return data.access_token;
743
+ }
744
+
745
+ async function patreonFetchIdentity(accessToken) {
746
+ // We need the visitor's memberships AND, for each membership, which tiers
747
+ // they're currently entitled to. Patreon's JSON:API include syntax pulls
748
+ // both into one round-trip.
749
+ const url = new URL("https://www.patreon.com/api/oauth2/v2/identity");
750
+ url.searchParams.set("include", "memberships,memberships.currently_entitled_tiers,memberships.campaign");
751
+ url.searchParams.set("fields[member]", "patron_status");
752
+ const res = await fetch(url.toString(), {
753
+ headers: { Authorization: "Bearer " + accessToken },
754
+ });
755
+ if (!res.ok) throw new Error("identity status " + res.status);
756
+ return await res.json();
757
+ }
758
+
759
+ // State cookie: stores { state, next } signed with SESSION_SECRET, used to
760
+ // verify the OAuth callback came from our /start handler and not a forgery.
761
+ // Lifetime is short (PATREON_STATE_TTL); kept distinct from the long-lived
762
+ // session cookie so a stolen state cookie can't impersonate a session.
763
+
764
+ async function signStateCookie(payload, secret) {
765
+ const exp = Math.floor(Date.now() / 1000) + PATREON_STATE_TTL;
766
+ const data = JSON.stringify({ ...payload, exp });
767
+ const sig = await hmac(data, secret);
768
+ const value = btoa(data) + "." + sig;
769
+ return PATREON_STATE_COOKIE + "=" + value
770
+ + "; Path=/; Secure; SameSite=Lax; HttpOnly; Max-Age=" + PATREON_STATE_TTL;
771
+ }
772
+
773
+ async function readStateCookie(request, secret) {
774
+ const cookies = parseCookie(request.headers.get("Cookie") || "");
775
+ const raw = cookies[PATREON_STATE_COOKIE];
776
+ if (!raw) return null;
777
+ const dot = raw.lastIndexOf(".");
778
+ if (dot < 0) return null;
779
+ const dataB64 = raw.slice(0, dot);
780
+ const sig = raw.slice(dot + 1);
781
+ let data;
782
+ try { data = atob(dataB64); }
783
+ catch { return null; }
784
+ const expected = await hmac(data, secret);
785
+ if (sig !== expected) return null;
786
+ let parsed;
787
+ try { parsed = JSON.parse(data); }
788
+ catch { return null; }
789
+ if (!parsed || typeof parsed.exp !== "number" || parsed.exp < Math.floor(Date.now() / 1000)) return null;
790
+ return parsed;
791
+ }
792
+
483
793
  // ── Cookie + role lookup ──────────────────────────────────────────────────
484
794
 
485
795
  async function readRole(request, env) {
@@ -610,9 +920,19 @@ function parseCookie(header) {
610
920
 
611
921
  function isSharedAsset(pathname) {
612
922
  // Allowlist of root-served files that are intentionally public to every
613
- // visitor (no role gate). Everything else (including images) goes
614
- // through the variant rewrite so role-restricted content is structurally
615
- // unreachable on under-tier deploys.
923
+ // visitor (no role gate). Everything else including images, .body.html
924
+ // fragments, .preview.json, the search index goes through the variant
925
+ // rewrite so role-restricted content is structurally unreachable on
926
+ // under-tier deploys.
927
+ //
928
+ // SYNCHRONISATION POINT: every file the build writes to the deploy
929
+ // ROOT (vs. into _variants/<role>/) must be listed here, otherwise it
930
+ // 404s for every visitor. Build code that places root-level files:
931
+ // - styles.css / user.css — build.ts (writeFile join(outputDir, ...))
932
+ // - login.html — build.ts (multi-role only)
933
+ // - favicon.ico — build.ts (buildFavicon)
934
+ // - functions/_middleware.js — build.ts (multi-role only; not served)
935
+ // If you add another, add it both here AND in build.ts.
616
936
  if (pathname === "/styles.css") return true;
617
937
  if (pathname === "/user.css") return true;
618
938
  if (pathname === "/login.html") return true;
@@ -648,19 +968,62 @@ export const LOGIN_HTML = `<!doctype html>
648
968
  background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 4px; cursor: pointer;
649
969
  }
650
970
  .login-error { color: #b94a3a; font-size: 0.85rem; margin: 0.75rem 0 0; }
971
+ .login-divider {
972
+ display: flex; align-items: center; gap: 0.6rem;
973
+ margin: 1.25rem 0 1rem;
974
+ font-size: 0.78rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em;
975
+ }
976
+ .login-divider::before, .login-divider::after {
977
+ content: ""; flex: 1; height: 1px; background: var(--rule);
978
+ }
979
+ .patreon-btn {
980
+ display: flex; align-items: center; justify-content: center; gap: 0.5rem;
981
+ width: 100%; padding: 0.55rem 1rem; font: inherit; font-size: 0.95rem;
982
+ background: #ff424d; color: white; border: 0; border-radius: 4px;
983
+ cursor: pointer; text-decoration: none;
984
+ }
985
+ .patreon-btn:hover { filter: brightness(1.05); }
986
+ .patreon-hint { color: var(--muted); font-size: 0.78rem; margin-top: 0.6rem; text-align: center; }
987
+ .patreon-iframe-note {
988
+ color: var(--muted); font-size: 0.78rem; line-height: 1.45;
989
+ margin-top: 0.6rem; padding: 0.6rem 0.7rem;
990
+ border: 1px dashed var(--rule); border-radius: 4px;
991
+ }
651
992
  </style>
652
993
  </head>
653
994
  <body>
654
- <form class="login-card" method="POST" action="/login">
995
+ <div class="login-card"__PATREON_ROLES_ATTR__>
655
996
  <h1>Sign in</h1>
656
997
  <p id="err" class="login-error" hidden></p>
657
- <label for="role">Role</label>
658
- <select id="role" name="role">__ROLE_OPTIONS__</select>
659
- <label for="password">Password</label>
660
- <input id="password" type="password" name="password" autocomplete="current-password" required autofocus>
661
- <input type="hidden" name="next" id="next">
662
- <button type="submit">Sign in</button>
663
- </form>
998
+ <form method="POST" action="/login">
999
+ <label for="role">Role</label>
1000
+ <select id="role" name="role">__ROLE_OPTIONS__</select>
1001
+ <label for="password">Password</label>
1002
+ <input id="password" type="password" name="password" autocomplete="current-password" required>
1003
+ <input type="hidden" name="next" id="next">
1004
+ <button type="submit">Sign in</button>
1005
+ </form>
1006
+ <div id="patreon-section" hidden>
1007
+ <div class="login-divider">or</div>
1008
+ <a id="patreon-btn" class="patreon-btn" href="#">
1009
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
1010
+ <circle cx="14.5" cy="9.5" r="6.5"/>
1011
+ <rect x="2" y="3" width="3.5" height="18"/>
1012
+ </svg>
1013
+ Sign in with Patreon
1014
+ </a>
1015
+ <p class="patreon-hint">
1016
+ Active patrons of the configured tiers can sign in directly. The role
1017
+ selector above only matters for password sign-in.
1018
+ </p>
1019
+ <p class="patreon-iframe-note" hidden>
1020
+ Patreon refuses to be embedded in iframes, so its sign-in button is
1021
+ hidden when this page is loaded in one (e.g. a Foundry connect
1022
+ dialog). Use a password here, or open the wiki in a normal browser
1023
+ tab to sign in with Patreon.
1024
+ </p>
1025
+ </div>
1026
+ </div>
664
1027
  <script>
665
1028
  const params = new URLSearchParams(location.search);
666
1029
  const next = params.get("next") || "/";
@@ -668,9 +1031,43 @@ export const LOGIN_HTML = `<!doctype html>
668
1031
  const err = params.get("error");
669
1032
  if (err) {
670
1033
  const el = document.getElementById("err");
671
- el.textContent = err === "wrong_password" ? "Wrong password." : "Invalid role.";
1034
+ const messages = {
1035
+ // Single message for any password-path failure — distinct error
1036
+ // codes would let an attacker enumerate which roles exist.
1037
+ auth_failed: "Invalid role or password.",
1038
+ patreon_state_mismatch: "Patreon sign-in expired or was tampered with. Please try again.",
1039
+ patreon_token_exchange: "Couldn't exchange the Patreon authorisation. Try again.",
1040
+ patreon_identity: "Patreon authenticated but we couldn't load your tier information.",
1041
+ patreon_no_tier: "Signed in to Patreon, but your current pledge doesn't grant any of the configured tiers.",
1042
+ patreon_misconfigured: "Patreon login is misconfigured for this deploy.",
1043
+ patreon_failed: "Patreon sign-in failed.",
1044
+ };
1045
+ el.textContent = messages[err] || "Sign-in failed.";
672
1046
  el.hidden = false;
673
1047
  }
1048
+ // Show the Patreon button only if the build emitted a roles list for it
1049
+ // (i.e. patreon is configured AND at least one role has a tier mapping).
1050
+ // The Foundry module's connect flow now uses delivery=copy (paste-token),
1051
+ // not an iframe, so Patreon's X-Frame-Options is no longer a problem in
1052
+ // the supported flow. The iframe-detection note still fires defensively
1053
+ // for any third-party that embeds /login.html directly.
1054
+ const card = document.querySelector(".login-card");
1055
+ if (card.dataset.patreonRoles) {
1056
+ const section = document.getElementById("patreon-section");
1057
+ section.hidden = false;
1058
+ const inIframe = window.parent !== window;
1059
+ if (inIframe) {
1060
+ document.getElementById("patreon-btn").hidden = true;
1061
+ document.querySelector(".patreon-hint").hidden = true;
1062
+ document.querySelector(".patreon-iframe-note").hidden = false;
1063
+ } else {
1064
+ document.getElementById("patreon-btn").href = "/auth/patreon/start?next=" + encodeURIComponent(next);
1065
+ }
1066
+ }
1067
+ // Autofocus moved here so it picks the right field whether the password
1068
+ // form is visible or the user is going for the Patreon button.
1069
+ const pwd = document.getElementById("password");
1070
+ if (pwd && !err) pwd.focus();
674
1071
  </script>
675
1072
  </body>
676
1073
  </html>`;
@@ -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;AASnE,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;IAE3D,OAAO;;;;;gBAKO,YAAY;oBACR,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAimBnC,CAAC;AACF,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAmDlB,CAAC"}
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;AAsBnE,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;IAE3D,OAAO;;;;;;gBAMO,YAAY;oBACR,gBAAgB;kBAClB,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA85B/B,CAAC;AACF,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAgIlB,CAAC"}
@@ -0,0 +1,42 @@
1
+ // Pure tier-to-role matching logic, extracted so tests can exercise it
2
+ // without spinning up a Pages Function. The shipped middleware in
3
+ // auth-template.ts duplicates this logic verbatim — it has to live there
4
+ // as plain JS since the worker can't import TS modules at runtime. Keep
5
+ // the two copies in sync (small enough that drift is easy to spot in
6
+ // review).
7
+ /**
8
+ * Walk the visitor's identity payload, find memberships scoped to the
9
+ * configured campaign, and return the highest-ranked role whose mapped
10
+ * tier ID appears in the visitor's currently-entitled tiers. Returns
11
+ * null when no tier matches — the visitor may be a patron of someone
12
+ * else's campaign, the creator's own account viewing their own deploy,
13
+ * or a pledge that's downgraded out of all mapped tiers.
14
+ *
15
+ * `roles` is the lowest→highest ordered tier list; we iterate from the
16
+ * top down so a patron entitled to multiple tiers gets the most
17
+ * permissive role.
18
+ */
19
+ export function matchHighestRole(identity, campaignId, tiers, roles) {
20
+ const memberships = (identity?.included ?? []).filter((it) => it.type === "member");
21
+ const ourCampaign = memberships.filter((m) => {
22
+ const camp = m.relationships?.campaign?.data;
23
+ return camp && camp.type === "campaign" && String(camp.id) === String(campaignId);
24
+ });
25
+ const entitled = new Set();
26
+ for (const m of ourCampaign) {
27
+ for (const t of m.relationships?.currently_entitled_tiers?.data ?? []) {
28
+ if (t.type === "tier")
29
+ entitled.add(String(t.id));
30
+ }
31
+ }
32
+ if (entitled.size === 0)
33
+ return null;
34
+ for (let i = roles.length - 1; i >= 0; i--) {
35
+ const r = roles[i];
36
+ const tier = tiers[r];
37
+ if (tier && entitled.has(String(tier)))
38
+ return r;
39
+ }
40
+ return null;
41
+ }
42
+ //# sourceMappingURL=patreon-match.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patreon-match.js","sourceRoot":"","sources":["../../src/render/patreon-match.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,kEAAkE;AAClE,yEAAyE;AACzE,wEAAwE;AACxE,qEAAqE;AACrE,WAAW;AAgBX;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,gBAAgB,CAC9B,QAAyB,EACzB,UAAkB,EAClB,KAA6B,EAC7B,KAAe;IAEf,MAAM,WAAW,GAAG,CAAC,QAAQ,EAAE,QAAQ,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC;IACpF,MAAM,WAAW,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE;QAC3C,MAAM,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,QAAQ,EAAE,IAAI,CAAC;QAC7C,OAAO,IAAI,IAAI,IAAI,CAAC,IAAI,KAAK,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,KAAK,MAAM,CAAC,UAAU,CAAC,CAAC;IACpF,CAAC,CAAC,CAAC;IACH,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAC;IACnC,KAAK,MAAM,CAAC,IAAI,WAAW,EAAE,CAAC;QAC5B,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,wBAAwB,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC;YACtE,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM;gBAAE,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;IACD,IAAI,QAAQ,CAAC,IAAI,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,KAAK,IAAI,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3C,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;QACpB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACtB,IAAI,IAAI,IAAI,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YAAE,OAAO,CAAC,CAAC;IACnD,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}