cursedbelt-server 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/server/auth/jwt.d.ts +1 -1
  2. package/dist/server/auth/jwt.js +2 -2
  3. package/dist/server/master-lock/accountsPage.d.ts +39 -0
  4. package/dist/server/master-lock/accountsPage.js +455 -0
  5. package/dist/server/master-lock/guard.js +39 -1
  6. package/dist/server/master-lock/index.d.ts +10 -2
  7. package/dist/server/master-lock/index.js +10 -2
  8. package/dist/server/master-lock/lockPage.d.ts +9 -0
  9. package/dist/server/master-lock/lockPage.js +44 -36
  10. package/dist/server/media-bun/streaming.d.ts +0 -1
  11. package/dist/server/media-bun/streaming.js +1 -1
  12. package/dist/server/storage/files/catalogue.d.ts +2 -2
  13. package/dist/server/storage/files/catalogue.js +18 -208
  14. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  15. package/dist/server/storage/files/fileRoutes.js +1 -74
  16. package/dist/server/storage/files/index.d.ts +1 -1
  17. package/dist/server/storage/files/index.js +1 -1
  18. package/dist/server/storage/files/migrations.js +9 -6
  19. package/dist/server/storage/files/stack.d.ts +6 -31
  20. package/dist/server/storage/files/stack.js +0 -27
  21. package/dist/server/storage/files/types.d.ts +10 -60
  22. package/dist/server/storage/files/types.js +9 -0
  23. package/dist/server/storage/index.d.ts +2 -2
  24. package/dist/server/storage/index.js +1 -1
  25. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  26. package/dist/server/storage/types.d.ts +0 -9
  27. package/dist/server/storage/types.js +2 -9
  28. package/package.json +2 -2
  29. package/src/leafSubpathsImportNothing.spec.ts +6 -4
  30. package/src/server/auth/jwt.ts +2 -2
  31. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  32. package/src/server/master-lock/accountsPage.ts +465 -0
  33. package/src/server/master-lock/guard.ts +44 -1
  34. package/src/server/master-lock/index.ts +22 -2
  35. package/src/server/master-lock/lockPage.spec.ts +20 -16
  36. package/src/server/master-lock/lockPage.ts +45 -36
  37. package/src/server/media-bun/streaming.ts +0 -6
  38. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  39. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  40. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  41. package/src/server/storage/files/adapter.spec.ts +0 -1
  42. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  43. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  44. package/src/server/storage/files/catalogue.ts +19 -240
  45. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  46. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  47. package/src/server/storage/files/fileRoutes.ts +2 -96
  48. package/src/server/storage/files/index.ts +1 -4
  49. package/src/server/storage/files/migrations.ts +9 -7
  50. package/src/server/storage/files/stack.spec.ts +11 -111
  51. package/src/server/storage/files/stack.ts +9 -61
  52. package/src/server/storage/files/types.ts +20 -68
  53. package/src/server/storage/index.ts +0 -4
  54. package/src/server/storage/mountMediaRoutes.ts +1 -7
  55. package/src/server/storage/types.ts +2 -11
  56. package/src/shippedFilesAreTracked.spec.ts +3 -2
  57. package/dist/server/private-media/crypto.d.ts +0 -37
  58. package/dist/server/private-media/crypto.js +0 -111
  59. package/dist/server/private-media/index-store.d.ts +0 -31
  60. package/dist/server/private-media/index-store.js +0 -36
  61. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  62. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  63. package/dist/server/private-media/pipeline.d.ts +0 -37
  64. package/dist/server/private-media/pipeline.js +0 -97
  65. package/dist/server/private-media/unlock.d.ts +0 -52
  66. package/dist/server/private-media/unlock.js +0 -55
  67. package/src/server/private-media/blobRoute.spec.ts +0 -165
  68. package/src/server/private-media/crypto.spec.ts +0 -81
  69. package/src/server/private-media/crypto.ts +0 -155
  70. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  71. package/src/server/private-media/index-store.ts +0 -83
  72. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  73. package/src/server/private-media/pipeline.spec.ts +0 -111
  74. package/src/server/private-media/pipeline.ts +0 -170
  75. package/src/server/private-media/routes.spec.ts +0 -265
  76. package/src/server/private-media/unlock.ts +0 -112
  77. package/src/server/storage/files/catalogue.private.spec.ts +0 -106
@@ -10,7 +10,7 @@ export declare function signToken(payload: Omit<AppJwtPayload, 'iat' | 'exp'>, s
10
10
  * signature / expiry / malformed). */
11
11
  export declare function verifyToken<T extends JWTPayload = AppJwtPayload>(token: string, secret: string): Promise<T>;
12
12
  /**
13
- * Verify a single-purpose token (vault/private-media/files/yt-oauth) AND assert its
13
+ * Verify a single-purpose token (vault/files/yt-oauth) AND assert its
14
14
  * `purpose` claim matches — so an access token (or a token for a *different*
15
15
  * feature) can never be replayed as, say, a vault-unlock token. Throws on a bad
16
16
  * signature, expiry, or a purpose mismatch.
@@ -2,7 +2,7 @@ import { jwtVerify, SignJWT } from 'jose';
2
2
  /**
3
3
  * JWT sign/verify on `jose` (Web Crypto HMAC — native on Bun + Node, no native
4
4
  * deps; replaces `jsonwebtoken`). Every token system in the apps — the access
5
- * token plus the short-lived vault/private-media/files/yt-oauth unlock tokens —
5
+ * token plus the short-lived vault/files/yt-oauth unlock tokens —
6
6
  * goes through these, so there is exactly ONE jose integration to reason about.
7
7
  *
8
8
  * jose verifies any standard HS256 JWT regardless of which library signed it, so a
@@ -42,7 +42,7 @@ export async function verifyToken(token, secret) {
42
42
  return payload;
43
43
  }
44
44
  /**
45
- * Verify a single-purpose token (vault/private-media/files/yt-oauth) AND assert its
45
+ * Verify a single-purpose token (vault/files/yt-oauth) AND assert its
46
46
  * `purpose` claim matches — so an access token (or a token for a *different*
47
47
  * feature) can never be replayed as, say, a vault-unlock token. Throws on a bad
48
48
  * signature, expiry, or a purpose mismatch.
@@ -0,0 +1,39 @@
1
+ import { type LockPageOptions } from "./lockPage";
2
+ /**
3
+ * The page's own two assets.
4
+ *
5
+ * Authored HERE rather than in `cursedbelt-core`'s `MASTER_LOCK_PATHS`, because nothing but
6
+ * this server ever names them: the document that links them is served by the same process,
7
+ * and no client constructs either URL. Adding them to the shared wire would mean publishing
8
+ * `cursedbelt-core` for a string only one file reads.
9
+ */
10
+ export declare const MASTER_LOCK_ACCOUNTS_PAGE_PATHS: {
11
+ readonly style: "/__lock/accounts.css";
12
+ readonly script: "/__lock/accounts.js";
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;
17
+ /**
18
+ * The document.
19
+ *
20
+ * It links the LOCK's stylesheet first and its own second, so the two pages share one set of
21
+ * colour tokens and a change to the palette cannot land on only one of them.
22
+ */
23
+ export declare function accountsPageHtml(options: LockPageOptions): string;
24
+ /**
25
+ * The page-specific stylesheet. Loaded AFTER `lock.css` and inheriting every `--ml-*` token
26
+ * from it, so this file only ever describes layout this page adds.
27
+ */
28
+ export declare const MASTER_LOCK_ACCOUNTS_STYLE = "body{display:block;padding:32px 20px 64px}\n.sheet{max-width:46rem;margin:0 auto;display:flex;flex-direction:column;gap:18px}\n.sheet-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;\n padding:0 2px 2px}\n.sheet-head h1{margin:0 0 4px;font-size:22px;font-weight:660;letter-spacing:-.015em}\n.sheet-head .note{margin:0}\n.back{flex:none;font-size:12.5px;font-weight:600;color:var(--ml-muted);text-decoration:none;\n border:1px solid var(--ml-border);border-radius:8px;padding:7px 12px;background:var(--ml-card)}\n.back:hover{color:var(--ml-text);border-color:var(--ml-muted)}\n.panel{background:var(--ml-card);border:1px solid var(--ml-border);border-radius:14px;\n padding:22px 22px 20px;box-shadow:var(--ml-shadow);text-align:left}\n.panel h2{margin:0 0 6px;font-size:15px;font-weight:650;letter-spacing:-.01em}\n.panel .note{margin:0 0 18px;max-width:42rem}\n.panel form{display:flex;flex-direction:column}\n.panel button{margin-top:4px;align-self:flex-start;width:auto;min-width:13rem;padding:0 18px}\n.panel .status{margin:12px 0 0}\n.status[data-tone=\"ok\"]{color:var(--ml-text)}\n.accounts{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}\n.accounts .empty{color:var(--ml-muted);font-size:13px}\n.row{border:1px solid var(--ml-border);border-radius:11px;padding:14px 16px;background:var(--ml-bg)}\n.row[data-current=\"true\"]{border-color:var(--ml-accent)}\n.row-head{display:flex;align-items:center;gap:8px;margin-bottom:2px}\n.row-head strong{font-size:14px;font-weight:640}\n.badge{font-size:11px;font-weight:650;letter-spacing:.02em;text-transform:uppercase;\n color:var(--ml-accent-text);background:var(--ml-accent);border-radius:999px;padding:2px 8px}\n.row .meta{margin:0;font-size:12.5px;color:var(--ml-muted);overflow-wrap:anywhere}\n.row form{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-top:12px}\n.row .field{flex:1 1 12rem;margin-bottom:0}\n.row button{margin-top:0;min-width:0;width:auto;padding:0 16px;flex:none}\n@media (max-width:640px){\n .sheet-head{flex-direction:column}\n .panel button{align-self:stretch;width:100%}\n}\n";
29
+ /**
30
+ * The page's script.
31
+ *
32
+ * Every password is turned into a verifier HERE and only the verifier is sent, exactly as on
33
+ * the lock page — so a rotation never puts either the old or the new password on the wire.
34
+ *
35
+ * 🔴 It never sends a `kdf`. The params are one app-wide descriptor that every account
36
+ * derives under; minting a fresh salt on a rotation is precisely the bug this page exists to
37
+ * replace, and `guard.ts` answers that shape `409 stale-client` no matter who sends it.
38
+ */
39
+ export declare const MASTER_LOCK_ACCOUNTS_SCRIPT: string;
@@ -0,0 +1,455 @@
1
+ /**
2
+ * The ACCOUNTS page — where the owner changes his master password.
3
+ *
4
+ * ── Why it is here and not in the app ───────────────────────────────────────
5
+ * `cursedbelt@2.5.1`'s React `MasterLockMenuItems` dialog minted a FRESH `kdf` salt on every
6
+ * rotation, which is what the wire looked like before accounts existed. Every account on an
7
+ * app now derives under ONE shared descriptor, so honouring that salt would store a verifier
8
+ * hash nothing could ever match: the owner sets a new password, the rotation logs him out,
9
+ * and neither the old nor the new one opens the app. `guard.ts` therefore answers that shape
10
+ * `409 {"error":"stale-client"}`. That is the safe failure; this page is the fix.
11
+ *
12
+ * It could not be fixed where it broke. `cursedbelt@3.0.0` deleted `src/server` outright and
13
+ * `apps/collections` is two majors behind on the React design system, so reaching the in-app
14
+ * dialog means a migration that is its own program. The lock page already proves that a
15
+ * server-rendered surface under `/__lock/` reaches every app that mounts the guard — no
16
+ * bundle, no framework, no version to migrate, and no app-local copy to drift. So the
17
+ * rotation lives next to the lock, and an app needs one link to it.
18
+ *
19
+ * The owner asked for the rotation in as many words (2026-08-15): *"give me a way to change
20
+ * it to a value I want it to be anytime later."* `bun run accounts` in an app cannot do it —
21
+ * nobody can change a password they have never seen, and this server has never seen one.
22
+ *
23
+ * ── What it is allowed to show ──────────────────────────────────────────────
24
+ * 🔴 It is served ONLY to a request carrying a live unlock, because listing the accounts says
25
+ * how many master passwords exist and what each is called — the one fact the lock page is
26
+ * built never to disclose. The guard enforces that; this file renders a shell that contains
27
+ * no account data at all and fetches the list over `GET /__lock/accounts`, so there is one
28
+ * source of truth for who may read it.
29
+ *
30
+ * ── The derivation ──────────────────────────────────────────────────────────
31
+ * 🔴 Interpolated from {@link MASTER_LOCK_DERIVE_SOURCE}, never re-typed. A second hand copy
32
+ * would be a second thing to keep in step with `cursedbelt-core`, and the pinning spec would
33
+ * only be watching one of them.
34
+ */
35
+ import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "cursedbelt-core/master-lock";
36
+ import { MASTER_LOCK_DERIVE_SOURCE } from "./lockPage";
37
+ /**
38
+ * The page's own two assets.
39
+ *
40
+ * Authored HERE rather than in `cursedbelt-core`'s `MASTER_LOCK_PATHS`, because nothing but
41
+ * this server ever names them: the document that links them is served by the same process,
42
+ * and no client constructs either URL. Adding them to the shared wire would mean publishing
43
+ * `cursedbelt-core` for a string only one file reads.
44
+ */
45
+ export const MASTER_LOCK_ACCOUNTS_PAGE_PATHS = {
46
+ style: `${MASTER_LOCK_PREFIX}/accounts.css`,
47
+ script: `${MASTER_LOCK_PREFIX}/accounts.js`,
48
+ };
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;
53
+ /**
54
+ * The document.
55
+ *
56
+ * It links the LOCK's stylesheet first and its own second, so the two pages share one set of
57
+ * colour tokens and a change to the palette cannot land on only one of them.
58
+ */
59
+ export function accountsPageHtml(options) {
60
+ const label = escapeHtml(options.appLabel);
61
+ const min = MASTER_LOCK_MIN_PASSWORD_LENGTH;
62
+ return `<!doctype html>
63
+ <html lang="en">
64
+ <head>
65
+ <meta charset="utf-8">
66
+ <meta name="viewport" content="width=device-width, initial-scale=1">
67
+ <meta name="robots" content="noindex, nofollow">
68
+ <title>Master passwords · ${label}</title>
69
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔑</text></svg>">
70
+ <link rel="stylesheet" href="${MASTER_LOCK_PATHS.style}">
71
+ <link rel="stylesheet" href="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style}">
72
+ </head>
73
+ <body>
74
+ <main class="sheet">
75
+ <header class="sheet-head">
76
+ <div>
77
+ <h1>Master passwords</h1>
78
+ <p class="note">${label} · signed in as <strong id="ml-current">…</strong></p>
79
+ </div>
80
+ <a class="back" href="/">Back to the app</a>
81
+ </header>
82
+
83
+ <section class="panel" aria-labelledby="ml-rotate-title">
84
+ <h2 id="ml-rotate-title">Change this account's master password</h2>
85
+ <p class="note">
86
+ It opens the same content afterwards — the password is how you get in, not what you
87
+ get in to. Every device you have open is locked the moment it changes.
88
+ </p>
89
+ <form id="ml-rotate" autocomplete="off">
90
+ <label class="field">
91
+ <span>Current master password</span>
92
+ <input type="password" name="current" autocomplete="current-password" required
93
+ spellcheck="false">
94
+ </label>
95
+ <label class="field">
96
+ <span>New master password</span>
97
+ <input type="password" name="next" autocomplete="new-password" required
98
+ spellcheck="false" minlength="${min}">
99
+ </label>
100
+ <label class="field">
101
+ <span>Confirm new master password</span>
102
+ <input type="password" name="confirm" autocomplete="new-password" required
103
+ spellcheck="false" minlength="${min}">
104
+ </label>
105
+ <button type="submit">Change master password</button>
106
+ <p class="status" role="status" aria-live="polite"></p>
107
+ </form>
108
+ </section>
109
+
110
+ <section class="panel" aria-labelledby="ml-list-title">
111
+ <h2 id="ml-list-title">Accounts on this site</h2>
112
+ <p class="note">
113
+ Each master password is its own account, with its own content. You can rename the one
114
+ you are signed in to and rewrite its hint; the others are only editable from inside them.
115
+ </p>
116
+ <ul id="ml-accounts" class="accounts"><li class="empty">Loading…</li></ul>
117
+ </section>
118
+
119
+ <section class="panel" aria-labelledby="ml-add-title">
120
+ <h2 id="ml-add-title">Add another master password</h2>
121
+ <p class="note">
122
+ A new password is a new, empty account. Adding one does not sign you into it — you stay
123
+ here until you type it on the lock screen. Nobody can recover it, so leave yourself a hint.
124
+ </p>
125
+ <form id="ml-add" autocomplete="off">
126
+ <label class="field">
127
+ <span>Your current master password</span>
128
+ <input type="password" name="current" autocomplete="current-password" required
129
+ spellcheck="false">
130
+ </label>
131
+ <label class="field">
132
+ <span>The new account's master password</span>
133
+ <input type="password" name="next" autocomplete="new-password" required
134
+ spellcheck="false" minlength="${min}">
135
+ </label>
136
+ <label class="field">
137
+ <span>Confirm it</span>
138
+ <input type="password" name="confirm" autocomplete="new-password" required
139
+ spellcheck="false" minlength="${min}">
140
+ </label>
141
+ <label class="field">
142
+ <span>Name</span>
143
+ <input type="text" name="label" required maxlength="60" spellcheck="false"
144
+ autocomplete="off" placeholder="Work, Family, …">
145
+ </label>
146
+ <label class="field">
147
+ <span>Hint <em>optional</em></span>
148
+ <input type="text" name="hint" maxlength="200" spellcheck="false" autocomplete="off"
149
+ placeholder="Something only you would understand">
150
+ </label>
151
+ <button type="submit">Add master password</button>
152
+ <p class="status" role="status" aria-live="polite"></p>
153
+ </form>
154
+ </section>
155
+ </main>
156
+ <script type="module" src="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script}"></script>
157
+ </body>
158
+ </html>
159
+ `;
160
+ }
161
+ /**
162
+ * The page-specific stylesheet. Loaded AFTER `lock.css` and inheriting every `--ml-*` token
163
+ * from it, so this file only ever describes layout this page adds.
164
+ */
165
+ export const MASTER_LOCK_ACCOUNTS_STYLE = `body{display:block;padding:32px 20px 64px}
166
+ .sheet{max-width:46rem;margin:0 auto;display:flex;flex-direction:column;gap:18px}
167
+ .sheet-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;
168
+ padding:0 2px 2px}
169
+ .sheet-head h1{margin:0 0 4px;font-size:22px;font-weight:660;letter-spacing:-.015em}
170
+ .sheet-head .note{margin:0}
171
+ .back{flex:none;font-size:12.5px;font-weight:600;color:var(--ml-muted);text-decoration:none;
172
+ border:1px solid var(--ml-border);border-radius:8px;padding:7px 12px;background:var(--ml-card)}
173
+ .back:hover{color:var(--ml-text);border-color:var(--ml-muted)}
174
+ .panel{background:var(--ml-card);border:1px solid var(--ml-border);border-radius:14px;
175
+ padding:22px 22px 20px;box-shadow:var(--ml-shadow);text-align:left}
176
+ .panel h2{margin:0 0 6px;font-size:15px;font-weight:650;letter-spacing:-.01em}
177
+ .panel .note{margin:0 0 18px;max-width:42rem}
178
+ .panel form{display:flex;flex-direction:column}
179
+ .panel button{margin-top:4px;align-self:flex-start;width:auto;min-width:13rem;padding:0 18px}
180
+ .panel .status{margin:12px 0 0}
181
+ .status[data-tone="ok"]{color:var(--ml-text)}
182
+ .accounts{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
183
+ .accounts .empty{color:var(--ml-muted);font-size:13px}
184
+ .row{border:1px solid var(--ml-border);border-radius:11px;padding:14px 16px;background:var(--ml-bg)}
185
+ .row[data-current="true"]{border-color:var(--ml-accent)}
186
+ .row-head{display:flex;align-items:center;gap:8px;margin-bottom:2px}
187
+ .row-head strong{font-size:14px;font-weight:640}
188
+ .badge{font-size:11px;font-weight:650;letter-spacing:.02em;text-transform:uppercase;
189
+ color:var(--ml-accent-text);background:var(--ml-accent);border-radius:999px;padding:2px 8px}
190
+ .row .meta{margin:0;font-size:12.5px;color:var(--ml-muted);overflow-wrap:anywhere}
191
+ .row form{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-top:12px}
192
+ .row .field{flex:1 1 12rem;margin-bottom:0}
193
+ .row button{margin-top:0;min-width:0;width:auto;padding:0 16px;flex:none}
194
+ @media (max-width:640px){
195
+ .sheet-head{flex-direction:column}
196
+ .panel button{align-self:stretch;width:100%}
197
+ }
198
+ `;
199
+ /**
200
+ * The page's script.
201
+ *
202
+ * Every password is turned into a verifier HERE and only the verifier is sent, exactly as on
203
+ * the lock page — so a rotation never puts either the old or the new password on the wire.
204
+ *
205
+ * 🔴 It never sends a `kdf`. The params are one app-wide descriptor that every account
206
+ * derives under; minting a fresh salt on a rotation is precisely the bug this page exists to
207
+ * replace, and `guard.ts` answers that shape `409 stale-client` no matter who sends it.
208
+ */
209
+ export const MASTER_LOCK_ACCOUNTS_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
210
+ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
211
+
212
+ // ── derivation ───────────────────────────────────────────────────────────────
213
+ ${MASTER_LOCK_DERIVE_SOURCE}
214
+
215
+ const rotateForm = document.getElementById("ml-rotate");
216
+ const addForm = document.getElementById("ml-add");
217
+ const list = document.getElementById("ml-accounts");
218
+ const currentName = document.getElementById("ml-current");
219
+
220
+ /** The app's KDF params, read once. Every derivation on this page uses these and no other. */
221
+ let kdf = null;
222
+
223
+ function say(form, message, tone) {
224
+ const status = form.querySelector(".status");
225
+ status.textContent = message;
226
+ if (tone) status.setAttribute("data-tone", tone);
227
+ else status.removeAttribute("data-tone");
228
+ }
229
+
230
+ async function post(path, body, method) {
231
+ const res = await fetch(path, {
232
+ method: method || "POST",
233
+ credentials: "same-origin",
234
+ headers: { "content-type": "application/json" },
235
+ body: JSON.stringify(body),
236
+ });
237
+ const parsed = await res.json().catch(() => ({}));
238
+ return { ok: res.ok, status: res.status, body: parsed || {} };
239
+ }
240
+
241
+ /**
242
+ * One refusal turned into one sentence.
243
+ *
244
+ * \`stale-client\` can only appear if something OTHER than this page is posting, since this
245
+ * one never sends a kdf — so it is named rather than folded into "something went wrong",
246
+ * which is what sent the owner looking at a 409 with no explanation in the first place.
247
+ */
248
+ function refusalText(result) {
249
+ if (result.status === 401) return "Your unlock lapsed. Reload and enter the master password again.";
250
+ if (result.status === 409) return "This site refused an out-of-date client. Reload the page and try again.";
251
+ const reason = result.body && result.body.error;
252
+ if (reason === "wrong") return "That is not your current master password.";
253
+ if (reason === "duplicate") return "That password already opens one of these accounts. Pick a different one.";
254
+ if (reason === "invalid") return "That password cannot be used here — it may already open another account.";
255
+ if (reason === "unconfigured") return "This site has no master password set.";
256
+ if (reason === "locked") return "Your unlock lapsed. Reload and enter the master password again.";
257
+ return "That could not be saved. Reload and try again.";
258
+ }
259
+
260
+ /** The two checks the browser can make that the server cannot: length, and the retype. */
261
+ function passwordFrom(form) {
262
+ const next = form.elements.next.value;
263
+ const confirm = form.elements.confirm.value;
264
+ if (next.length < MIN_LENGTH) {
265
+ say(form, "Use at least " + MIN_LENGTH + " characters.", "error");
266
+ return null;
267
+ }
268
+ if (next !== confirm) {
269
+ say(form, "Those two do not match.", "error");
270
+ form.elements.confirm.value = "";
271
+ form.elements.confirm.focus();
272
+ return null;
273
+ }
274
+ return next;
275
+ }
276
+
277
+ async function busy(form, message, work) {
278
+ const button = form.querySelector("button[type=submit]");
279
+ if (button.disabled) return;
280
+ button.disabled = true;
281
+ say(form, message);
282
+ try {
283
+ await work();
284
+ } catch {
285
+ say(form, "Cannot reach this site right now.", "error");
286
+ } finally {
287
+ button.disabled = false;
288
+ }
289
+ }
290
+
291
+ // ── the list ─────────────────────────────────────────────────────────────────
292
+ // 🔴 textContent for every value, never innerHTML. A label and a hint are free text the
293
+ // owner typed, and this page runs under a CSP that permits no inline script — writing them
294
+ // as markup would be the one way to get some in.
295
+ function renderAccounts(accounts) {
296
+ list.replaceChildren();
297
+ if (accounts.length === 0) {
298
+ const li = document.createElement("li");
299
+ li.className = "empty";
300
+ li.textContent = "No master passwords are set.";
301
+ list.append(li);
302
+ return;
303
+ }
304
+ for (const account of accounts) {
305
+ const li = document.createElement("li");
306
+ li.className = "row";
307
+ li.dataset.current = String(account.current === true);
308
+
309
+ const head = document.createElement("div");
310
+ head.className = "row-head";
311
+ const name = document.createElement("strong");
312
+ name.textContent = account.label;
313
+ head.append(name);
314
+ if (account.current) {
315
+ const badge = document.createElement("span");
316
+ badge.className = "badge";
317
+ badge.textContent = "signed in";
318
+ head.append(badge);
319
+ }
320
+ li.append(head);
321
+
322
+ const meta = document.createElement("p");
323
+ meta.className = "meta";
324
+ meta.textContent = account.hint ? "Hint: " + account.hint : "No hint written.";
325
+ li.append(meta);
326
+
327
+ if (account.current) li.append(editForm(account));
328
+ list.append(li);
329
+ }
330
+ }
331
+
332
+ /** Rename / re-hint, and only ever for the account the caller's own unlock opened — the
333
+ * server refuses a sibling, so offering the form for one would be a button that lies. */
334
+ function editForm(account) {
335
+ const form = document.createElement("form");
336
+ form.autocomplete = "off";
337
+
338
+ const labelField = field("Name", "label", account.label, 60);
339
+ const hintField = field("Hint", "hint", account.hint, 200);
340
+ const button = document.createElement("button");
341
+ button.type = "submit";
342
+ button.textContent = "Save";
343
+ const status = document.createElement("p");
344
+ status.className = "status";
345
+ status.setAttribute("role", "status");
346
+ status.setAttribute("aria-live", "polite");
347
+ form.append(labelField, hintField, button, status);
348
+
349
+ form.addEventListener("submit", (event) => {
350
+ event.preventDefault();
351
+ busy(form, "Saving…", async () => {
352
+ const result = await post(
353
+ paths.accounts,
354
+ {
355
+ id: account.id,
356
+ label: form.elements.label.value,
357
+ hint: form.elements.hint.value,
358
+ },
359
+ "PATCH",
360
+ );
361
+ if (!result.ok) {
362
+ say(form, refusalText(result), "error");
363
+ return;
364
+ }
365
+ renderAccounts(result.body.accounts || []);
366
+ });
367
+ });
368
+ return form;
369
+ }
370
+
371
+ function field(text, name, value, maxLength) {
372
+ const label = document.createElement("label");
373
+ label.className = "field";
374
+ const span = document.createElement("span");
375
+ span.textContent = text;
376
+ const input = document.createElement("input");
377
+ input.type = "text";
378
+ input.name = name;
379
+ input.value = value;
380
+ input.maxLength = maxLength;
381
+ input.spellcheck = false;
382
+ input.autocomplete = "off";
383
+ label.append(span, input);
384
+ return label;
385
+ }
386
+
387
+ async function load() {
388
+ try {
389
+ const status = await fetch(paths.status, { credentials: "same-origin" });
390
+ if (status.ok) {
391
+ const body = await status.json();
392
+ kdf = body.kdf;
393
+ currentName.textContent = body.accountLabel || "this account";
394
+ }
395
+ const res = await fetch(paths.accounts, { credentials: "same-origin" });
396
+ if (res.status === 401) { location.href = paths.page; return; }
397
+ const body = await res.json();
398
+ renderAccounts(body.accounts || []);
399
+ } catch {
400
+ list.replaceChildren();
401
+ const li = document.createElement("li");
402
+ li.className = "empty";
403
+ li.textContent = "Cannot reach this site right now.";
404
+ list.append(li);
405
+ }
406
+ }
407
+
408
+ // ── the rotation ─────────────────────────────────────────────────────────────
409
+ rotateForm.addEventListener("submit", (event) => {
410
+ event.preventDefault();
411
+ const next = passwordFrom(rotateForm);
412
+ if (next === null) return;
413
+ busy(rotateForm, "Changing… this takes a moment.", async () => {
414
+ if (!kdf) { say(rotateForm, "Cannot reach this site right now.", "error"); return; }
415
+ const result = await post(paths.change, {
416
+ currentVerifier: await deriveVerifier(rotateForm.elements.current.value, kdf),
417
+ verifier: await deriveVerifier(next, kdf),
418
+ });
419
+ if (!result.ok) {
420
+ say(rotateForm, refusalText(result), "error");
421
+ rotateForm.elements.current.value = "";
422
+ return;
423
+ }
424
+ // The rotation locked every device, this one included, and dropped the cookie with it.
425
+ // So there is nothing to stay on: send them to the lock screen to type the new one.
426
+ say(rotateForm, "Changed. Every device is locked — sign in with the new password.", "ok");
427
+ setTimeout(() => { location.href = paths.page; }, 1200);
428
+ });
429
+ });
430
+
431
+ // ── adding one ───────────────────────────────────────────────────────────────
432
+ addForm.addEventListener("submit", (event) => {
433
+ event.preventDefault();
434
+ const next = passwordFrom(addForm);
435
+ if (next === null) return;
436
+ busy(addForm, "Adding… this takes a moment.", async () => {
437
+ if (!kdf) { say(addForm, "Cannot reach this site right now.", "error"); return; }
438
+ const result = await post(paths.accounts, {
439
+ currentVerifier: await deriveVerifier(addForm.elements.current.value, kdf),
440
+ verifier: await deriveVerifier(next, kdf),
441
+ label: addForm.elements.label.value,
442
+ hint: addForm.elements.hint.value,
443
+ });
444
+ if (!result.ok) {
445
+ say(addForm, refusalText(result), "error");
446
+ return;
447
+ }
448
+ addForm.reset();
449
+ say(addForm, "Added. It is empty until you sign in with it.", "ok");
450
+ await load();
451
+ });
452
+ });
453
+
454
+ load();
455
+ `;
@@ -12,6 +12,7 @@
12
12
  * second wall, not a replacement for the first.
13
13
  */
14
14
  import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, masterLockCookieName, } from "cursedbelt-core/master-lock";
15
+ import { MASTER_LOCK_ACCOUNTS_PAGE_PATHS, MASTER_LOCK_ACCOUNTS_SCRIPT, MASTER_LOCK_ACCOUNTS_STYLE, accountsPageHtml, } from "./accountsPage";
15
16
  import { LOCK_SCRIPT, LOCK_STYLE, lockPageHtml } from "./lockPage";
16
17
  const noStore = { "cache-control": "no-store, no-cache, must-revalidate", pragma: "no-cache" };
17
18
  /**
@@ -77,10 +78,29 @@ export function createMasterLockGuard(options) {
77
78
  // whether this app has one tenant or ten.
78
79
  return Response.json({ hints: lock.hints() }, { headers: noStore });
79
80
  }
81
+ if (path === MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style) {
82
+ return new Response(MASTER_LOCK_ACCOUNTS_STYLE, {
83
+ headers: { "content-type": "text/css; charset=utf-8", ...noStore },
84
+ });
85
+ }
86
+ if (path === MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script) {
87
+ return new Response(MASTER_LOCK_ACCOUNTS_SCRIPT, {
88
+ headers: { "content-type": "text/javascript; charset=utf-8", ...noStore },
89
+ });
90
+ }
80
91
  if (path === MASTER_LOCK_PATHS.accounts) {
81
92
  // Unlike the hints, this needs the site already open — it names every tenant.
93
+ // A DOCUMENT request while locked is answered with the lock page in place, for
94
+ // the reason the app's own paths are: unlocking then reloads to where they were
95
+ // going, which is the management page, instead of dropping them at the root.
82
96
  if (!opens(lock, req))
83
- return refuse();
97
+ return wantsDocument(req) ? lockPage(options, lock) : refuse();
98
+ // 🔴 Content-negotiated, one route. The browser asks for a PAGE and gets the
99
+ // management surface; the page's own `fetch` asks for DATA and gets the list it
100
+ // renders. One path means one rule about who may read it, so the page can never
101
+ // be reachable by a caller the JSON would have refused.
102
+ if (wantsDocument(req))
103
+ return accountsPage(options);
84
104
  return Response.json({ accounts: lock.summaries(req) }, { headers: noStore });
85
105
  }
86
106
  if (path === MASTER_LOCK_PATHS.page || path === MASTER_LOCK_PREFIX) {
@@ -243,6 +263,24 @@ function lockPage(options, lock) {
243
263
  },
244
264
  });
245
265
  }
266
+ /**
267
+ * The accounts page. Same wall as the lock page — one CSP, no inline script, never framed —
268
+ * because it carries the one form that can change a master password.
269
+ *
270
+ * It is handed NO account data: the shell fetches the list over this same route's JSON half,
271
+ * so there is exactly one place that decides who may read it.
272
+ */
273
+ function accountsPage(options) {
274
+ return new Response(accountsPageHtml(options), {
275
+ status: 200,
276
+ headers: {
277
+ "content-type": "text/html; charset=utf-8",
278
+ "content-security-policy": LOCK_CSP,
279
+ "x-content-type-options": "nosniff",
280
+ ...noStore,
281
+ },
282
+ });
283
+ }
246
284
  /** Is this the browser asking for a PAGE, as opposed to the app asking for data? */
247
285
  function wantsDocument(req) {
248
286
  if (req.method !== "GET")
@@ -7,14 +7,22 @@
7
7
  * Adopting it in an app is three things:
8
8
  * 1. a {@link MasterLock} built over whatever key/value the app already has,
9
9
  * 2. `createMasterLockGuard(...).handle(req)` in front of everything the app serves,
10
- * 3. `cursedbelt/react/master-lock` in the header, for the manual lock and the rotation.
10
+ * 3. one link to `/__lock/accounts` wherever the app keeps its account menu.
11
11
  *
12
12
  * Removing it is deleting those three. That is the shape the owner asked for: *"the reusable
13
13
  * functionality for this should be in a place that makes it easy to share to add or remove
14
14
  * from apps in the code, such as cb."*
15
+ *
16
+ * 🔴 Step 3 used to be `cursedbelt/react/master-lock` in the header, and it is a LINK now.
17
+ * That dialog minted a fresh `kdf` salt on every rotation — the pre-accounts wire — which the
18
+ * server has to refuse, so the owner could lock a site and enroll a first password but could
19
+ * not change one. `accountsPage.ts` carries the reasoning; the short version is that a
20
+ * server-rendered surface under `/__lock/` reaches every app that mounts the guard with no
21
+ * bundle, no framework version to migrate, and no app-local copy to drift.
15
22
  */
23
+ export { MASTER_LOCK_ACCOUNTS_PAGE_PATHS, MASTER_LOCK_ACCOUNTS_SCRIPT, MASTER_LOCK_ACCOUNTS_STYLE, MASTER_LOCK_MIN_PASSWORD_LENGTH, accountsPageHtml, } from "./accountsPage";
16
24
  export { type MasterLockGuard, type MasterLockGuardOptions, createMasterLockGuard } from "./guard";
17
- export { LOCK_SCRIPT, LOCK_STYLE, type LockPageOptions, lockPageHtml } from "./lockPage";
25
+ export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, type LockPageOptions, lockPageHtml, } from "./lockPage";
18
26
  export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, type MasterLockDirectoryOptions, masterLockPrincipalKey, } from "./principals";
19
27
  export { FIRST_ACCOUNT_ID, MasterLock, type MasterLockAccount, type MasterLockAddAccountResult, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock";
20
28
  export { DEFAULT_SEED_IDLE_MS, generateStagePassword, type MintMasterLockSeedOptions, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed";
@@ -7,14 +7,22 @@
7
7
  * Adopting it in an app is three things:
8
8
  * 1. a {@link MasterLock} built over whatever key/value the app already has,
9
9
  * 2. `createMasterLockGuard(...).handle(req)` in front of everything the app serves,
10
- * 3. `cursedbelt/react/master-lock` in the header, for the manual lock and the rotation.
10
+ * 3. one link to `/__lock/accounts` wherever the app keeps its account menu.
11
11
  *
12
12
  * Removing it is deleting those three. That is the shape the owner asked for: *"the reusable
13
13
  * functionality for this should be in a place that makes it easy to share to add or remove
14
14
  * from apps in the code, such as cb."*
15
+ *
16
+ * 🔴 Step 3 used to be `cursedbelt/react/master-lock` in the header, and it is a LINK now.
17
+ * That dialog minted a fresh `kdf` salt on every rotation — the pre-accounts wire — which the
18
+ * server has to refuse, so the owner could lock a site and enroll a first password but could
19
+ * not change one. `accountsPage.ts` carries the reasoning; the short version is that a
20
+ * server-rendered surface under `/__lock/` reaches every app that mounts the guard with no
21
+ * bundle, no framework version to migrate, and no app-local copy to drift.
15
22
  */
23
+ export { MASTER_LOCK_ACCOUNTS_PAGE_PATHS, MASTER_LOCK_ACCOUNTS_SCRIPT, MASTER_LOCK_ACCOUNTS_STYLE, MASTER_LOCK_MIN_PASSWORD_LENGTH, accountsPageHtml, } from "./accountsPage";
16
24
  export { createMasterLockGuard } from "./guard";
17
- export { LOCK_SCRIPT, LOCK_STYLE, lockPageHtml } from "./lockPage";
25
+ export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, lockPageHtml, } from "./lockPage";
18
26
  export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, masterLockPrincipalKey, } from "./principals";
19
27
  export { FIRST_ACCOUNT_ID, MasterLock, readRecord, } from "./masterLock";
20
28
  export { DEFAULT_SEED_IDLE_MS, generateStagePassword, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed";