cursedbelt-server 4.30.0 → 4.31.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.
@@ -11,9 +11,9 @@ export declare const MASTER_LOCK_ACCOUNTS_PAGE_PATHS: {
11
11
  readonly style: "/__lock/accounts.css";
12
12
  readonly script: "/__lock/accounts.js";
13
13
  };
14
- /** The shortest password worth calling one. Matched by every `minlength` below and by the
15
- * lock page's enrollment form, so a rotation cannot quietly weaken what enrollment demands. */
16
- export declare const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
14
+ /** The shortest password worth calling one — defined beside the lock page's enrollment form
15
+ * (`lockPage.ts`) and re-exported, so a rotation cannot quietly differ from what enrollment demands. */
16
+ export { MASTER_LOCK_MIN_PASSWORD_LENGTH } from "./lockPage.js";
17
17
  /**
18
18
  * The document.
19
19
  *
@@ -33,7 +33,7 @@
33
33
  * only be watching one of them.
34
34
  */
35
35
  import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "cursedbelt-core/master-lock";
36
- import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
36
+ import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_MIN_PASSWORD_LENGTH, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
37
37
  /**
38
38
  * The page's own two assets.
39
39
  *
@@ -47,9 +47,9 @@ export const MASTER_LOCK_ACCOUNTS_PAGE_PATHS = {
47
47
  script: `${MASTER_LOCK_PREFIX}/accounts.js`,
48
48
  };
49
49
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch] ?? ch);
50
- /** The shortest password worth calling one. Matched by every `minlength` below and by the
51
- * lock page's enrollment form, so a rotation cannot quietly weaken what enrollment demands. */
52
- export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
50
+ /** The shortest password worth calling one — defined beside the lock page's enrollment form
51
+ * (`lockPage.ts`) and re-exported, so a rotation cannot quietly differ from what enrollment demands. */
52
+ export { MASTER_LOCK_MIN_PASSWORD_LENGTH } from "./lockPage.js";
53
53
  /**
54
54
  * The document.
55
55
  *
@@ -1,3 +1,9 @@
1
+ /**
2
+ * The shortest master password the wall accepts — every `minlength` on both pages and both
3
+ * scripts' own checks read this one number, so a rotation cannot quietly differ from enrollment.
4
+ * 10 until 2026-09-24; the owner: *"make the minimum password length 6 characters"*.
5
+ */
6
+ export declare const MASTER_LOCK_MIN_PASSWORD_LENGTH = 6;
1
7
  /** Everything the page needs to know about the app it is standing in front of. */
2
8
  export interface LockPageOptions {
3
9
  /** What to call this app — "Collections", "binary-server inspector". */
@@ -21,18 +27,7 @@ export interface LockPageOptions {
21
27
  */
22
28
  export declare function lockPageHtml(options: LockPageOptions): string;
23
29
  /** 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.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.secret{position:relative;display:block}\n.secret input{padding-right:68px}\n.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;\n min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);\n font-size:12.5px;font-weight:600}\n.secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}\n.secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}\n";
25
- /**
26
- * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
27
- * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
28
- *
29
- * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
30
- * `<button type="button" data-reveal>` after it — the inputs keep every attribute, `autocomplete`
31
- * included, so a password manager still sees a password field. Behaviour:
32
- * {@link MASTER_LOCK_REVEAL_SOURCE}, interpolated into BOTH same-origin scripts, because the wall's
33
- * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
34
- * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
35
- */
30
+ 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.secret{position:relative;display:block}\n.secret input{padding-right:48px}\n.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:36px;height:32px;\n display:grid;place-items:center;padding:0;border-radius:7px;background:none;color:var(--ml-muted)}\n.secret .reveal svg{width:18px;height:18px}\n.secret .reveal .eye-off,.secret .reveal[aria-pressed=\"true\"] .eye{display:none}\n.secret .reveal[aria-pressed=\"true\"] .eye-off{display:block}\n.secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}\n.secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}\n";
36
31
  export declare function withRevealToggles(html: string): string;
37
32
  /**
38
33
  * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
@@ -40,7 +35,7 @@ export declare function withRevealToggles(html: string): string;
40
35
  * `password` when its form submits — so a manager saving the credential sees a password field.
41
36
  * Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
42
37
  */
43
- export declare const MASTER_LOCK_REVEAL_SOURCE = "function wireMasterLockReveal(doc) {\n const buttons = doc.querySelectorAll(\"[data-reveal]\");\n for (const button of buttons) {\n const input = button.previousElementSibling;\n if (!input || input.tagName !== \"INPUT\") continue;\n const show = (on) => {\n input.type = on ? \"text\" : \"password\";\n button.setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n button.setAttribute(\"aria-label\", on ? \"Hide password\" : \"Show password\");\n button.textContent = on ? \"Hide\" : \"Show\";\n };\n button.addEventListener(\"click\", () => {\n show(input.type === \"password\");\n input.focus();\n });\n input.form?.addEventListener(\"submit\", () => show(false), true);\n }\n return buttons.length;\n}\nglobalThis.wireMasterLockReveal = wireMasterLockReveal;";
38
+ export declare const MASTER_LOCK_REVEAL_SOURCE = "function wireMasterLockReveal(doc) {\n const buttons = doc.querySelectorAll(\"[data-reveal]\");\n for (const button of buttons) {\n const input = button.previousElementSibling;\n if (!input || input.tagName !== \"INPUT\") continue;\n const show = (on) => {\n input.type = on ? \"text\" : \"password\";\n button.setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n button.setAttribute(\"aria-label\", on ? \"Hide password\" : \"Show password\");\n };\n button.addEventListener(\"click\", () => {\n show(input.type === \"password\");\n input.focus();\n });\n input.form?.addEventListener(\"submit\", () => show(false), true);\n }\n return buttons.length;\n}\nglobalThis.wireMasterLockReveal = wireMasterLockReveal;";
44
39
  /**
45
40
  * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
46
41
  * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
@@ -26,6 +26,12 @@
26
26
  * scripts interpolate this string, and the spec asserts that they do.
27
27
  */
28
28
  import { MASTER_LOCK_PATHS } from "cursedbelt-core/master-lock";
29
+ /**
30
+ * The shortest master password the wall accepts — every `minlength` on both pages and both
31
+ * scripts' own checks read this one number, so a rotation cannot quietly differ from enrollment.
32
+ * 10 until 2026-09-24; the owner: *"make the minimum password length 6 characters"*.
33
+ */
34
+ export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 6;
29
35
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch] ?? ch);
30
36
  /**
31
37
  * The document. Deliberately says the app's NAME and nothing else about it — whoever is
@@ -61,14 +67,14 @@ export function lockPageHtml(options) {
61
67
  <span>${enroll ? "New master password" : "Master password"}</span>
62
68
  <input id="ml-input" type="password" name="master-password"
63
69
  autocomplete="${enroll ? "new-password" : "current-password"}"
64
- autofocus required spellcheck="false" enterkeyhint="${enroll ? "next" : "go"}"${enroll ? ' minlength="10"' : ""}>
70
+ autofocus required spellcheck="false" enterkeyhint="${enroll ? "next" : "go"}"${enroll ? ` minlength="${MASTER_LOCK_MIN_PASSWORD_LENGTH}"` : ""}>
65
71
  </label>${enroll
66
72
  ? `
67
73
  <label class="field">
68
74
  <span>Confirm master password</span>
69
75
  <input id="ml-confirm" type="password" name="confirm-master-password"
70
76
  autocomplete="new-password" required spellcheck="false" enterkeyhint="go"
71
- minlength="10">
77
+ minlength="${MASTER_LOCK_MIN_PASSWORD_LENGTH}">
72
78
  </label>
73
79
  <label class="field">
74
80
  <span>Hint <em>optional</em></span>
@@ -129,15 +135,17 @@ button.quiet[disabled]{opacity:.5}
129
135
  .hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
130
136
  .hints dd:last-child{margin-bottom:0}
131
137
  .secret{position:relative;display:block}
132
- .secret input{padding-right:68px}
133
- .secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;
134
- min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);
135
- font-size:12.5px;font-weight:600}
138
+ .secret input{padding-right:48px}
139
+ .secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:36px;height:32px;
140
+ display:grid;place-items:center;padding:0;border-radius:7px;background:none;color:var(--ml-muted)}
141
+ .secret .reveal svg{width:18px;height:18px}
142
+ .secret .reveal .eye-off,.secret .reveal[aria-pressed="true"] .eye{display:none}
143
+ .secret .reveal[aria-pressed="true"] .eye-off{display:block}
136
144
  .secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}
137
145
  .secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}
138
146
  `;
139
147
  /**
140
- * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
148
+ * 🔴 Every password field on the wall gets an eye (show/hide) control (task 077-438's library share) — a
141
149
  * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
142
150
  *
143
151
  * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
@@ -147,8 +155,16 @@ button.quiet[disabled]{opacity:.5}
147
155
  * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
148
156
  * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
149
157
  */
158
+ /*
159
+ * An eye, and an eye struck through — the owner, 2026-09-24: *"use an eye icon instead of words
160
+ * like 'Show'"*. Both are in the button; the stylesheet shows one by `aria-pressed`, so the
161
+ * script only flips state and the accessible name, never markup. Same glyphs as cursedbelt's
162
+ * React `SecretInput`, drawn inline because this page ships no icon font and no framework.
163
+ */
164
+ const EYE = '<svg class="eye" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
165
+ const EYE_OFF = '<svg class="eye-off" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.2A10.4 10.4 0 0 1 12 4c6.5 0 10 8 10 8a17.6 17.6 0 0 1-2.3 3.3"/><path d="M6.6 6.6C3.9 8.4 2 12 2 12s3.5 7 10 7c1.8 0 3.4-.5 4.8-1.3"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="M2 2l20 20"/></svg>';
150
166
  export function withRevealToggles(html) {
151
- return html.replace(/(<input\b[^>]*\btype="password"[^>]*>)/g, '<span class="secret">$1<button type="button" class="reveal" data-reveal aria-label="Show password" aria-pressed="false">Show</button></span>');
167
+ return html.replace(/(<input\b[^>]*\btype="password"[^>]*>)/g, `<span class="secret">$1<button type="button" class="reveal" data-reveal aria-label="Show password" aria-pressed="false">${EYE}${EYE_OFF}</button></span>`);
152
168
  }
153
169
  /**
154
170
  * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
@@ -165,7 +181,6 @@ export const MASTER_LOCK_REVEAL_SOURCE = `function wireMasterLockReveal(doc) {
165
181
  input.type = on ? "text" : "password";
166
182
  button.setAttribute("aria-pressed", on ? "true" : "false");
167
183
  button.setAttribute("aria-label", on ? "Hide password" : "Show password");
168
- button.textContent = on ? "Hide" : "Show";
169
184
  };
170
185
  button.addEventListener("click", () => {
171
186
  show(input.type === "password");
@@ -252,7 +267,7 @@ const enrolling = document.body.dataset.mode === "enroll";
252
267
 
253
268
  /** The shortest password worth calling one. Matched by the input's own minlength, and
254
269
  * checked here too because a pasted value can beat the constraint on some engines. */
255
- const MIN_LENGTH = 10;
270
+ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
256
271
 
257
272
  let kdf = null;
258
273
  let busy = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.30.0",
3
+ "version": "4.31.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -340,7 +340,7 @@
340
340
  "dependencies": {
341
341
  "argon2id": "1.0.1",
342
342
  "cursedbelt-core": "^2.1.1",
343
- "cursedops": "^0.8.1",
343
+ "cursedops": ">=0.8.1 <0.10.0",
344
344
  "cwip": "^4.6.0",
345
345
  "jose": "^6.2.3"
346
346
  },
@@ -33,7 +33,7 @@
33
33
  * only be watching one of them.
34
34
  */
35
35
  import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "cursedbelt-core/master-lock";
36
- import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_REVEAL_SOURCE, type LockPageOptions, withRevealToggles } from "./lockPage.js";
36
+ import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_MIN_PASSWORD_LENGTH, MASTER_LOCK_REVEAL_SOURCE, type LockPageOptions, withRevealToggles } from "./lockPage.js";
37
37
 
38
38
  /**
39
39
  * The page's own two assets.
@@ -54,9 +54,9 @@ const escapeHtml = (value: string): string =>
54
54
  (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch] ?? ch,
55
55
  );
56
56
 
57
- /** The shortest password worth calling one. Matched by every `minlength` below and by the
58
- * lock page's enrollment form, so a rotation cannot quietly weaken what enrollment demands. */
59
- export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
57
+ /** The shortest password worth calling one — defined beside the lock page's enrollment form
58
+ * (`lockPage.ts`) and re-exported, so a rotation cannot quietly differ from what enrollment demands. */
59
+ export { MASTER_LOCK_MIN_PASSWORD_LENGTH } from "./lockPage.js";
60
60
 
61
61
  /**
62
62
  * The document.
@@ -19,6 +19,7 @@ import {
19
19
  LOCK_SCRIPT,
20
20
  LOCK_STYLE,
21
21
  MASTER_LOCK_DERIVE_SOURCE,
22
+ MASTER_LOCK_MIN_PASSWORD_LENGTH,
22
23
  MASTER_LOCK_REVEAL_SOURCE,
23
24
  lockPageHtml,
24
25
  withRevealToggles,
@@ -107,7 +108,7 @@ describe("🔴 every password field on the wall can be shown and hidden (task 07
107
108
  };
108
109
 
109
110
  for (const [name, html] of Object.entries(pages)) {
110
- test(`${name}: one Show control per password input, each input keeping its autocomplete`, () => {
111
+ test(`${name}: one eye control per password input, each input keeping its autocomplete`, () => {
111
112
  const inputs = [...html.matchAll(/<input\b[^>]*\btype="password"[^>]*>/g)].map((m) => m[0]);
112
113
  expect(inputs.length).toBeGreaterThan(0);
113
114
  expect((html.match(/data-reveal/g) ?? []).length).toBe(inputs.length);
@@ -117,6 +118,20 @@ describe("🔴 every password field on the wall can be shown and hidden (task 07
117
118
  });
118
119
  }
119
120
 
121
+ test("🔴 the control is an EYE, not a word, and every minimum is 6 (owner, 2026-09-24)", () => {
122
+ for (const html of Object.values(pages)) {
123
+ for (const button of html.match(/<button[^>]*data-reveal[^>]*>[\s\S]*?<\/button>/g) ?? []) {
124
+ expect(button).toContain('class="eye"');
125
+ expect(button).toContain('class="eye-off"');
126
+ // No visible word: strip the markup and nothing readable is left.
127
+ expect(button.replace(/<[^>]+>/g, "").trim()).toBe("");
128
+ }
129
+ for (const m of html.matchAll(/minlength="(\d+)"/g)) expect(m[1]).toBe("6");
130
+ }
131
+ expect(MASTER_LOCK_MIN_PASSWORD_LENGTH).toBe(6);
132
+ expect(LOCK_SCRIPT).toContain("const MIN_LENGTH = 6;");
133
+ });
134
+
120
135
  test("both served scripts carry the ONE reveal source and call it", () => {
121
136
  for (const script of [LOCK_SCRIPT, MASTER_LOCK_ACCOUNTS_SCRIPT]) {
122
137
  expect(script).toContain(MASTER_LOCK_REVEAL_SOURCE);
@@ -27,6 +27,13 @@
27
27
  */
28
28
  import { MASTER_LOCK_PATHS } from "cursedbelt-core/master-lock";
29
29
 
30
+ /**
31
+ * The shortest master password the wall accepts — every `minlength` on both pages and both
32
+ * scripts' own checks read this one number, so a rotation cannot quietly differ from enrollment.
33
+ * 10 until 2026-09-24; the owner: *"make the minimum password length 6 characters"*.
34
+ */
35
+ export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 6;
36
+
30
37
  /** Everything the page needs to know about the app it is standing in front of. */
31
38
  export interface LockPageOptions {
32
39
  /** What to call this app — "Collections", "binary-server inspector". */
@@ -88,7 +95,7 @@ export function lockPageHtml(options: LockPageOptions): string {
88
95
  <input id="ml-input" type="password" name="master-password"
89
96
  autocomplete="${enroll ? "new-password" : "current-password"}"
90
97
  autofocus required spellcheck="false" enterkeyhint="${enroll ? "next" : "go"}"${
91
- enroll ? ' minlength="10"' : ""
98
+ enroll ? ` minlength="${MASTER_LOCK_MIN_PASSWORD_LENGTH}"` : ""
92
99
  }>
93
100
  </label>${
94
101
  enroll
@@ -97,7 +104,7 @@ export function lockPageHtml(options: LockPageOptions): string {
97
104
  <span>Confirm master password</span>
98
105
  <input id="ml-confirm" type="password" name="confirm-master-password"
99
106
  autocomplete="new-password" required spellcheck="false" enterkeyhint="go"
100
- minlength="10">
107
+ minlength="${MASTER_LOCK_MIN_PASSWORD_LENGTH}">
101
108
  </label>
102
109
  <label class="field">
103
110
  <span>Hint <em>optional</em></span>
@@ -162,16 +169,18 @@ button.quiet[disabled]{opacity:.5}
162
169
  .hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
163
170
  .hints dd:last-child{margin-bottom:0}
164
171
  .secret{position:relative;display:block}
165
- .secret input{padding-right:68px}
166
- .secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;
167
- min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);
168
- font-size:12.5px;font-weight:600}
172
+ .secret input{padding-right:48px}
173
+ .secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:36px;height:32px;
174
+ display:grid;place-items:center;padding:0;border-radius:7px;background:none;color:var(--ml-muted)}
175
+ .secret .reveal svg{width:18px;height:18px}
176
+ .secret .reveal .eye-off,.secret .reveal[aria-pressed="true"] .eye{display:none}
177
+ .secret .reveal[aria-pressed="true"] .eye-off{display:block}
169
178
  .secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}
170
179
  .secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}
171
180
  `;
172
181
 
173
182
  /**
174
- * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
183
+ * 🔴 Every password field on the wall gets an eye (show/hide) control (task 077-438's library share) — a
175
184
  * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
176
185
  *
177
186
  * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
@@ -181,10 +190,21 @@ button.quiet[disabled]{opacity:.5}
181
190
  * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
182
191
  * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
183
192
  */
193
+ /*
194
+ * An eye, and an eye struck through — the owner, 2026-09-24: *"use an eye icon instead of words
195
+ * like 'Show'"*. Both are in the button; the stylesheet shows one by `aria-pressed`, so the
196
+ * script only flips state and the accessible name, never markup. Same glyphs as cursedbelt's
197
+ * React `SecretInput`, drawn inline because this page ships no icon font and no framework.
198
+ */
199
+ const EYE =
200
+ '<svg class="eye" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
201
+ const EYE_OFF =
202
+ '<svg class="eye-off" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.2A10.4 10.4 0 0 1 12 4c6.5 0 10 8 10 8a17.6 17.6 0 0 1-2.3 3.3"/><path d="M6.6 6.6C3.9 8.4 2 12 2 12s3.5 7 10 7c1.8 0 3.4-.5 4.8-1.3"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="M2 2l20 20"/></svg>';
203
+
184
204
  export function withRevealToggles(html: string): string {
185
205
  return html.replace(
186
206
  /(<input\b[^>]*\btype="password"[^>]*>)/g,
187
- '<span class="secret">$1<button type="button" class="reveal" data-reveal aria-label="Show password" aria-pressed="false">Show</button></span>',
207
+ `<span class="secret">$1<button type="button" class="reveal" data-reveal aria-label="Show password" aria-pressed="false">${EYE}${EYE_OFF}</button></span>`,
188
208
  );
189
209
  }
190
210
 
@@ -203,7 +223,6 @@ export const MASTER_LOCK_REVEAL_SOURCE = `function wireMasterLockReveal(doc) {
203
223
  input.type = on ? "text" : "password";
204
224
  button.setAttribute("aria-pressed", on ? "true" : "false");
205
225
  button.setAttribute("aria-label", on ? "Hide password" : "Show password");
206
- button.textContent = on ? "Hide" : "Show";
207
226
  };
208
227
  button.addEventListener("click", () => {
209
228
  show(input.type === "password");
@@ -292,7 +311,7 @@ const enrolling = document.body.dataset.mode === "enroll";
292
311
 
293
312
  /** The shortest password worth calling one. Matched by the input's own minlength, and
294
313
  * checked here too because a pasted value can beat the constraint on some engines. */
295
- const MIN_LENGTH = 10;
314
+ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
296
315
 
297
316
  let kdf = null;
298
317
  let busy = false;