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.
- package/dist/server/master-lock/accountsPage.d.ts +3 -3
- package/dist/server/master-lock/accountsPage.js +4 -4
- package/dist/server/master-lock/lockPage.d.ts +8 -13
- package/dist/server/master-lock/lockPage.js +25 -10
- package/package.json +2 -2
- package/src/server/master-lock/accountsPage.ts +4 -4
- package/src/server/master-lock/lockPage.spec.ts +16 -1
- package/src/server/master-lock/lockPage.ts +29 -10
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
export
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] ?? ch);
|
|
50
|
-
/** The shortest password worth calling one
|
|
51
|
-
*
|
|
52
|
-
export
|
|
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:
|
|
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
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 ?
|
|
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="
|
|
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:
|
|
133
|
-
.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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.
|
|
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": "
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] ?? ch,
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
-
/** The shortest password worth calling one
|
|
58
|
-
*
|
|
59
|
-
export
|
|
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
|
|
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 ?
|
|
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="
|
|
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:
|
|
166
|
-
.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
314
|
+
const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
|
|
296
315
|
|
|
297
316
|
let kdf = null;
|
|
298
317
|
let busy = false;
|