cursedbelt-server 2.1.0 → 3.0.1

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.
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * ── Retention ───────────────────────────────────────────────────────────────
12
12
  * This package never deletes an event. An app whose stream is high-volume opts
13
- * into the fleet standard (`docs/retention.md` in `cursed-satellites`) by
13
+ * into the fleet standard (`cursedbelt-server/retention`, in this package) by
14
14
  * declaring the table, which puts the number under the owner's control in the
15
15
  * station rather than in a constant here:
16
16
  *
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * ── Retention ───────────────────────────────────────────────────────────────
12
12
  * This package never deletes an event. An app whose stream is high-volume opts
13
- * into the fleet standard (`docs/retention.md` in `cursed-satellites`) by
13
+ * into the fleet standard (`cursedbelt-server/retention`, in this package) by
14
14
  * declaring the table, which puts the number under the owner's control in the
15
15
  * station rather than in a constant here:
16
16
  *
@@ -49,5 +49,15 @@ export interface MasterLockGuardOptions extends LockPageOptions {
49
49
  export interface MasterLockGuard {
50
50
  /** `Response` when handled, `null` when the app should serve the request. */
51
51
  handle(req: Request): Promise<Response | null>;
52
+ /**
53
+ * 🔴 **WHICH tenant this request is for** — the answer an app must scope every query by.
54
+ *
55
+ * `null` when the request carries no live unlock, which is exactly when {@link handle}
56
+ * would have refused it. So the safe shape in an app is to read this AFTER the guard has
57
+ * stood aside, and to treat `null` as "serve nothing" rather than "serve the default" —
58
+ * a fallback tenant is how a locked request ends up being answered with somebody's
59
+ * library.
60
+ */
61
+ accountFor(req: Request): string | null;
52
62
  }
53
63
  export declare function createMasterLockGuard(options: MasterLockGuardOptions): MasterLockGuard;
@@ -34,23 +34,23 @@ export function createMasterLockGuard(options) {
34
34
  /** The ONE refusal. Identical for wrong, throttled, unconfigured and not-unlocked. */
35
35
  const refuse = (retryAfterMs = 0) => Response.json({ error: "locked", retryAfterMs }, { status: 401, headers: { ...noStore, [MASTER_LOCK_STATE_HEADER]: "locked" } });
36
36
  /**
37
- * 🔴 May THIS request through — and the difference between the two modes is the whole
38
- * security property of the per-person wall.
37
+ * 🔴 May THIS request through — and the answer is a PRESENTED TOKEN, always.
39
38
  *
40
- * **App-wide**: `unlocked` is enough. The site has one user and one unlock; the token
41
- * is how a caller with no cookie jar says "it's still me", not a per-browser
42
- * credential. Reading the ambient state is the behaviour `apps/collections` and the
43
- * binary-server inspector have always had.
39
+ * Until the accounts model there was a second branch: an app-wide lock let `unlocked`
40
+ * alone open the door, on the reasoning that such a site had one user and one unlock,
41
+ * so the token was merely how a caller with no cookie jar said "it's still me". The
42
+ * per-person wall already refused that shape, and 2026-08-25 measured why — with
43
+ * `unlocked ||` in front, one browser unlocking meant every OTHER browser signed in as
44
+ * that person walked straight through having typed nothing, and an invented
45
+ * `x-master-lock` value was accepted because the check never looked at it.
44
46
  *
45
- * **Per-person**: the token is REQUIRED. `unlocked` there means only "somebody's
46
- * device is open", and honouring that would make the unlock ambient — measured while
47
- * building this (2026-08-25): with `unlocked ||` in front, one browser unlocking meant
48
- * every OTHER browser signed in as that person walked straight through, having typed
49
- * nothing. The owner's phone would ride his desk's unlock, and an invented
50
- * `x-master-lock` value would be accepted because the check never looked at it. Two
51
- * tests in `principals.spec.ts` fail on the `||`, which is why it is gone.
47
+ * The accounts model removes the last reason to keep the branch, and turns it from
48
+ * sloppy into wrong: the password decides WHICH TENANT'S DATA the app serves, so a
49
+ * request with no token has no account, and admitting it means serving whichever
50
+ * library somebody else's browser happens to have open. There is one rule now, for
51
+ * every app.
52
52
  */
53
- const opens = (lock, req) => lock.isEnrollable ? lock.presents(req) : lock.unlocked || lock.presents(req);
53
+ const opens = (lock, req) => lock.presents(req);
54
54
  async function handleOwnRoute(req, url, lock) {
55
55
  const path = url.pathname;
56
56
  const secure = isSecure(url, req);
@@ -66,7 +66,22 @@ export function createMasterLockGuard(options) {
66
66
  });
67
67
  }
68
68
  if (path === MASTER_LOCK_PATHS.status) {
69
- return Response.json(lock.status(), { headers: noStore });
69
+ return Response.json(lock.status(req), { headers: noStore });
70
+ }
71
+ if (path === MASTER_LOCK_PATHS.hints) {
72
+ // 🔴 Readable WHILE LOCKED, and the only surface here that is. See the path's
73
+ // note in `cursedbelt-core/master-lock/wire.ts`: a hint nobody can read until
74
+ // they are already in is not a hint, and everyone who reaches this has passed
75
+ // the app's own owner-only sign-in. It is a separate route, fetched only when
76
+ // somebody clicks "I forgot", so the default lock screen stays byte-identical
77
+ // whether this app has one tenant or ten.
78
+ return Response.json({ hints: lock.hints() }, { headers: noStore });
79
+ }
80
+ if (path === MASTER_LOCK_PATHS.accounts) {
81
+ // Unlike the hints, this needs the site already open — it names every tenant.
82
+ if (!opens(lock, req))
83
+ return refuse();
84
+ return Response.json({ accounts: lock.summaries(req) }, { headers: noStore });
70
85
  }
71
86
  if (path === MASTER_LOCK_PATHS.page || path === MASTER_LOCK_PREFIX) {
72
87
  // Already open FOR THIS CALLER, so there is nothing to type — send them to
@@ -79,6 +94,19 @@ export function createMasterLockGuard(options) {
79
94
  }
80
95
  return new Response("not found", { status: 404, headers: noStore });
81
96
  }
97
+ if (req.method === "PATCH" && path === MASTER_LOCK_PATHS.accounts) {
98
+ if (!opens(lock, req))
99
+ return refuse();
100
+ const body = await readJson(req);
101
+ const ok = lock.editAccount(req, {
102
+ id: typeof body?.id === "string" ? body.id : "",
103
+ ...(typeof body?.label === "string" ? { label: body.label } : {}),
104
+ ...(typeof body?.hint === "string" ? { hint: body.hint } : {}),
105
+ });
106
+ if (!ok)
107
+ return Response.json({ error: "wrong" }, { status: 400, headers: noStore });
108
+ return Response.json({ ok: true, accounts: lock.summaries(req) }, { headers: noStore });
109
+ }
82
110
  if (req.method !== "POST") {
83
111
  return new Response("method not allowed", { status: 405, headers: noStore });
84
112
  }
@@ -93,7 +121,7 @@ export function createMasterLockGuard(options) {
93
121
  // becomes a way to ask whether a given account has a master password yet.
94
122
  if (!result.ok)
95
123
  return refuse();
96
- return Response.json({ ok: true, idleMs: lock.idleMs }, { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } });
124
+ return Response.json({ ok: true, idleMs: lock.idleMs, accountId: result.accountId }, { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } });
97
125
  }
98
126
  if (path === MASTER_LOCK_PATHS.unlock) {
99
127
  const body = await readJson(req);
@@ -101,7 +129,11 @@ export function createMasterLockGuard(options) {
101
129
  const result = await lock.unlock(verifier);
102
130
  if (!result.ok)
103
131
  return refuse(result.retryAfterMs);
104
- return Response.json({ ok: true, idleMs: lock.idleMs }, { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } });
132
+ // 🔴 The account id is in the REPLY and not only in the cookie, because the page
133
+ // that just unlocked has to reload into the right tenant. It names the account
134
+ // that opened and never the ones that did not, so a wrong password still learns
135
+ // nothing about what else is here.
136
+ return Response.json({ ok: true, idleMs: lock.idleMs, accountId: result.accountId }, { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } });
105
137
  }
106
138
  // Everything below CHANGES something and therefore needs the site already open.
107
139
  if (!lock.presents(req))
@@ -119,11 +151,43 @@ export function createMasterLockGuard(options) {
119
151
  const idleMs = lock.setIdleMs(Number(body?.idleMs));
120
152
  return Response.json({ ok: true, idleMs }, { headers: noStore });
121
153
  }
154
+ if (path === MASTER_LOCK_PATHS.accounts) {
155
+ const body = await readJson(req);
156
+ const result = await lock.addAccount(req, {
157
+ currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
158
+ verifier: typeof body?.verifier === "string" ? body.verifier : "",
159
+ label: typeof body?.label === "string" ? body.label : "",
160
+ hint: typeof body?.hint === "string" ? body.hint : "",
161
+ });
162
+ if (!result.ok) {
163
+ return Response.json({ error: result.reason }, { status: 400, headers: noStore });
164
+ }
165
+ // 🔴 No cookie. Creating a tenant does not enter it — switching means typing that
166
+ // tenant's password, which is the entire boundary this feature is made of.
167
+ return Response.json({ ok: true, account: result.account }, { headers: noStore });
168
+ }
122
169
  if (path === MASTER_LOCK_PATHS.change) {
123
170
  const body = await readJson(req);
124
- const result = await lock.change({
171
+ /*
172
+ * 🔴 An OLDER client sends a freshly-minted `kdf` with a rotation, because that is
173
+ * what the wire looked like before accounts existed. Ignoring it silently would be
174
+ * a lockout, not a compatibility shim: the verifier in the same body was derived
175
+ * under THAT salt, so storing it would leave a hash nothing can ever match — the
176
+ * owner would set a new password, be logged out by the rotation, and find that
177
+ * neither the old nor the new one opens the app.
178
+ *
179
+ * There is no safe way to honour it either, since every account on this app shares
180
+ * one descriptor. So it is refused, loudly, with the one thing that distinguishes
181
+ * it from a wrong password.
182
+ */
183
+ if (body?.kdf !== undefined) {
184
+ const supplied = body.kdf;
185
+ if (!lock.kdf || supplied?.salt !== lock.kdf.salt) {
186
+ return Response.json({ error: "stale-client" }, { status: 409, headers: noStore });
187
+ }
188
+ }
189
+ const result = await lock.change(req, {
125
190
  currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
126
- kdf: body?.kdf,
127
191
  verifier: typeof body?.verifier === "string" ? body.verifier : "",
128
192
  });
129
193
  if (!result.ok) {
@@ -136,6 +200,11 @@ export function createMasterLockGuard(options) {
136
200
  return new Response("not found", { status: 404, headers: noStore });
137
201
  }
138
202
  return {
203
+ accountFor(req) {
204
+ // The SAME resolution the guard itself uses, so an app can never be told a
205
+ // different tenant from the one the wall admitted.
206
+ return resolve(req)?.accountFor(req) ?? null;
207
+ },
139
208
  async handle(req) {
140
209
  const url = new URL(req.url);
141
210
  const own = url.pathname === MASTER_LOCK_PREFIX || url.pathname.startsWith(`${MASTER_LOCK_PREFIX}/`);
@@ -16,6 +16,6 @@
16
16
  export { type MasterLockGuard, type MasterLockGuardOptions, createMasterLockGuard } from "./guard";
17
17
  export { LOCK_SCRIPT, LOCK_STYLE, type LockPageOptions, lockPageHtml } from "./lockPage";
18
18
  export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, type MasterLockDirectoryOptions, masterLockPrincipalKey, } from "./principals";
19
- export { MasterLock, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock";
19
+ export { FIRST_ACCOUNT_ID, MasterLock, type MasterLockAccount, type MasterLockAddAccountResult, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock";
20
20
  export { DEFAULT_SEED_IDLE_MS, generateStagePassword, type MintMasterLockSeedOptions, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed";
21
21
  export { MASTER_LOCK_SETTING_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store";
@@ -16,6 +16,6 @@
16
16
  export { createMasterLockGuard } from "./guard";
17
17
  export { LOCK_SCRIPT, LOCK_STYLE, lockPageHtml } from "./lockPage";
18
18
  export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, masterLockPrincipalKey, } from "./principals";
19
- export { MasterLock, readRecord, } from "./masterLock";
19
+ export { FIRST_ACCOUNT_ID, MasterLock, readRecord, } from "./masterLock";
20
20
  export { DEFAULT_SEED_IDLE_MS, generateStagePassword, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed";
21
21
  export { MASTER_LOCK_SETTING_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store";
@@ -21,7 +21,7 @@ export interface LockPageOptions {
21
21
  */
22
22
  export declare function lockPageHtml(options: LockPageOptions): string;
23
23
  /** The stylesheet. Follows the viewer's theme and commits to nothing else. */
24
- export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n";
24
+ export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n";
25
25
  /**
26
26
  * The page's script.
27
27
  *
@@ -34,7 +34,7 @@ export function lockPageHtml(options) {
34
34
  (enroll
35
35
  ? "You are signed in, but this site needs a master password of your own. " +
36
36
  "Choose one now. Nobody else can see it and nobody else can reset it — " +
37
- "if you forget it, it cannot be recovered."
37
+ "if you forget it, it cannot be recovered, so leave yourself a hint."
38
38
  : "This site is locked. Signing in is not enough — enter the master password to continue."));
39
39
  return `<!doctype html>
40
40
  <html lang="en">
@@ -64,11 +64,21 @@ export function lockPageHtml(options) {
64
64
  <input id="ml-confirm" type="password" name="confirm-master-password"
65
65
  autocomplete="new-password" required spellcheck="false" enterkeyhint="go"
66
66
  minlength="10">
67
+ </label>
68
+ <label class="field">
69
+ <span>Hint <em>optional</em></span>
70
+ <input id="ml-hint" type="text" name="master-password-hint" maxlength="200"
71
+ spellcheck="false" autocomplete="off"
72
+ placeholder="Something only you would understand">
67
73
  </label>`
68
74
  : ""}
69
75
  <button id="ml-submit" type="submit">${enroll ? "Set master password" : "Unlock"}</button>
70
76
  </form>
71
- <p id="ml-status" class="status" role="status" aria-live="polite"></p>
77
+ <p id="ml-status" class="status" role="status" aria-live="polite"></p>${enroll
78
+ ? ""
79
+ : `
80
+ <button id="ml-forgot" type="button" class="quiet">I forgot which password</button>
81
+ <dl id="ml-hints" class="hints" hidden></dl>`}
72
82
  </main>
73
83
  <script type="module" src="${MASTER_LOCK_PATHS.script}"></script>
74
84
  </body>
@@ -103,6 +113,16 @@ button{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-acc
103
113
  button[disabled]{opacity:.6;cursor:progress}
104
114
  .status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}
105
115
  .status[data-tone="error"]{color:var(--ml-danger)}
116
+ .field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}
117
+ button.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;
118
+ font-size:12.5px;text-decoration:underline;text-underline-offset:3px}
119
+ button.quiet:hover{color:var(--ml-text)}
120
+ button.quiet[disabled]{opacity:.5}
121
+ .hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);
122
+ border-radius:10px;background:var(--ml-bg);font-size:12.5px}
123
+ .hints dt{font-weight:650;color:var(--ml-text)}
124
+ .hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
125
+ .hints dd:last-child{margin-bottom:0}
106
126
  `;
107
127
  /**
108
128
  * The page's script.
@@ -118,8 +138,11 @@ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
118
138
  const form = document.getElementById("ml-form");
119
139
  const input = document.getElementById("ml-input");
120
140
  const confirmInput = document.getElementById("ml-confirm");
141
+ const hintInput = document.getElementById("ml-hint");
121
142
  const submit = document.getElementById("ml-submit");
122
143
  const status = document.getElementById("ml-status");
144
+ const forgot = document.getElementById("ml-forgot");
145
+ const hints = document.getElementById("ml-hints");
123
146
 
124
147
  function say(message, tone) {
125
148
  status.textContent = message;
@@ -217,7 +240,11 @@ async function doEnroll(password) {
217
240
  method: "POST",
218
241
  credentials: "same-origin",
219
242
  headers: { "content-type": "application/json" },
220
- body: JSON.stringify({ kdf: params, verifier: verifier }),
243
+ body: JSON.stringify({
244
+ kdf: params,
245
+ verifier: verifier,
246
+ hint: hintInput ? hintInput.value : "",
247
+ }),
221
248
  });
222
249
  if (res.ok) { say("Master password set."); location.reload(); return true; }
223
250
  // The server collapses every enrollment refusal into one, so this cannot say more
@@ -243,6 +270,44 @@ async function doUnlock(password) {
243
270
  return false;
244
271
  }
245
272
 
273
+ // ── the hints, and why they are behind a click ───────────────────────────────
274
+ // Reading them says how many master passwords this app has and what each is called, which
275
+ // is the one fact the rest of this page is built never to disclose. Fetching them on load
276
+ // would leak it to anyone who merely opened the door; a button makes it something the
277
+ // owner ASKS for. Everyone who gets this far has already passed the app's own sign-in.
278
+ if (forgot && hints) {
279
+ forgot.addEventListener("click", async () => {
280
+ if (!hints.hidden) { hints.hidden = true; return; }
281
+ forgot.disabled = true;
282
+ try {
283
+ const res = await fetch(paths.hints, { credentials: "same-origin" });
284
+ const body = await res.json();
285
+ const rows = (body && body.hints) || [];
286
+ hints.replaceChildren();
287
+ if (rows.length === 0) {
288
+ const dd = document.createElement("dd");
289
+ dd.textContent = "No hints have been written yet.";
290
+ hints.append(dd);
291
+ }
292
+ for (const row of rows) {
293
+ const dt = document.createElement("dt");
294
+ dt.textContent = row.label;
295
+ const dd = document.createElement("dd");
296
+ // 🔴 textContent, never innerHTML. The hint is free text the owner typed and this
297
+ // page runs under a CSP that permits no inline script — writing it as markup would
298
+ // be the one way to get some in.
299
+ dd.textContent = row.hint || "— no hint —";
300
+ hints.append(dt, dd);
301
+ }
302
+ hints.hidden = false;
303
+ } catch {
304
+ say("Cannot reach this site right now.", "error");
305
+ } finally {
306
+ forgot.disabled = false;
307
+ }
308
+ });
309
+ }
310
+
246
311
  form.addEventListener("submit", async (event) => {
247
312
  event.preventDefault();
248
313
  if (busy || submit.disabled) return;