cursedbelt-server 3.0.1 → 4.1.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/auth/jwt.d.ts +1 -1
- package/dist/server/auth/jwt.js +2 -2
- package/dist/server/master-lock/accountsPage.d.ts +39 -0
- package/dist/server/master-lock/accountsPage.js +455 -0
- package/dist/server/master-lock/guard.js +39 -1
- package/dist/server/master-lock/index.d.ts +10 -2
- package/dist/server/master-lock/index.js +10 -2
- package/dist/server/master-lock/lockPage.d.ts +9 -0
- package/dist/server/master-lock/lockPage.js +44 -36
- package/dist/server/media-bun/streaming.d.ts +0 -1
- package/dist/server/media-bun/streaming.js +1 -1
- package/dist/server/storage/binaryStore.d.ts +385 -0
- package/dist/server/storage/binaryStore.js +739 -0
- package/dist/server/storage/binaryStoreFake.d.ts +56 -0
- package/dist/server/storage/binaryStoreFake.js +63 -0
- package/dist/server/storage/files/catalogue.d.ts +2 -2
- package/dist/server/storage/files/catalogue.js +18 -208
- package/dist/server/storage/files/fileRoutes.d.ts +2 -20
- package/dist/server/storage/files/fileRoutes.js +1 -74
- package/dist/server/storage/files/index.d.ts +1 -1
- package/dist/server/storage/files/index.js +1 -1
- package/dist/server/storage/files/migrations.js +9 -6
- package/dist/server/storage/files/stack.d.ts +6 -31
- package/dist/server/storage/files/stack.js +0 -27
- package/dist/server/storage/files/types.d.ts +10 -60
- package/dist/server/storage/files/types.js +9 -0
- package/dist/server/storage/index.d.ts +2 -2
- package/dist/server/storage/index.js +1 -1
- package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
- package/dist/server/storage/types.d.ts +0 -9
- package/dist/server/storage/types.js +2 -9
- package/package.json +14 -2
- package/src/leafSubpathsImportNothing.spec.ts +30 -4
- package/src/server/auth/jwt.ts +2 -2
- package/src/server/master-lock/accountsPage.spec.ts +231 -0
- package/src/server/master-lock/accountsPage.ts +465 -0
- package/src/server/master-lock/guard.ts +44 -1
- package/src/server/master-lock/index.ts +22 -2
- package/src/server/master-lock/lockPage.spec.ts +20 -16
- package/src/server/master-lock/lockPage.ts +45 -36
- package/src/server/media-bun/streaming.ts +0 -6
- package/src/server/storage/binaryStore.spec.ts +908 -0
- package/src/server/storage/binaryStore.ts +1049 -0
- package/src/server/storage/binaryStoreFake.ts +111 -0
- package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
- package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
- package/src/server/storage/files/adapter.photo.spec.ts +2 -2
- package/src/server/storage/files/adapter.spec.ts +0 -1
- package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
- package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
- package/src/server/storage/files/catalogue.ts +19 -240
- package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
- package/src/server/storage/files/fileRoutes.spec.ts +3 -97
- package/src/server/storage/files/fileRoutes.ts +2 -96
- package/src/server/storage/files/index.ts +1 -4
- package/src/server/storage/files/migrations.ts +9 -7
- package/src/server/storage/files/stack.spec.ts +11 -111
- package/src/server/storage/files/stack.ts +9 -61
- package/src/server/storage/files/types.ts +20 -68
- package/src/server/storage/index.ts +0 -4
- package/src/server/storage/mountMediaRoutes.ts +1 -7
- package/src/server/storage/types.ts +2 -11
- package/src/shippedFilesAreTracked.spec.ts +3 -2
- package/dist/server/private-media/crypto.d.ts +0 -37
- package/dist/server/private-media/crypto.js +0 -111
- package/dist/server/private-media/index-store.d.ts +0 -31
- package/dist/server/private-media/index-store.js +0 -36
- package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
- package/dist/server/private-media/mountPrivateRoutes.js +0 -219
- package/dist/server/private-media/pipeline.d.ts +0 -37
- package/dist/server/private-media/pipeline.js +0 -97
- package/dist/server/private-media/unlock.d.ts +0 -52
- package/dist/server/private-media/unlock.js +0 -55
- package/src/server/private-media/blobRoute.spec.ts +0 -165
- package/src/server/private-media/crypto.spec.ts +0 -81
- package/src/server/private-media/crypto.ts +0 -155
- package/src/server/private-media/gatePolicy.spec.ts +0 -98
- package/src/server/private-media/index-store.ts +0 -83
- package/src/server/private-media/mountPrivateRoutes.ts +0 -265
- package/src/server/private-media/pipeline.spec.ts +0 -111
- package/src/server/private-media/pipeline.ts +0 -170
- package/src/server/private-media/routes.spec.ts +0 -265
- package/src/server/private-media/unlock.ts +0 -112
- 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/
|
|
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.
|
package/dist/server/auth/jwt.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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.
|
|
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.
|
|
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";
|