@openparachute/hub 0.5.14-rc.8 → 0.6.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 (87) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-revoke-token.test.ts +384 -0
  9. package/src/__tests__/api-users.test.ts +7 -2
  10. package/src/__tests__/auth.test.ts +157 -30
  11. package/src/__tests__/cli.test.ts +44 -5
  12. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  13. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  14. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  15. package/src/__tests__/expose.test.ts +52 -2
  16. package/src/__tests__/hub-server.test.ts +97 -0
  17. package/src/__tests__/hub.test.ts +85 -6
  18. package/src/__tests__/init.test.ts +102 -1
  19. package/src/__tests__/lifecycle.test.ts +464 -2
  20. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  21. package/src/__tests__/oauth-ui.test.ts +12 -1
  22. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  23. package/src/__tests__/resource-binding.test.ts +97 -0
  24. package/src/__tests__/scope-explanations.test.ts +77 -12
  25. package/src/__tests__/services-manifest.test.ts +122 -4
  26. package/src/__tests__/setup-wizard.test.ts +335 -15
  27. package/src/__tests__/status.test.ts +36 -0
  28. package/src/__tests__/two-factor-flow.test.ts +602 -0
  29. package/src/__tests__/two-factor.test.ts +183 -0
  30. package/src/__tests__/upgrade.test.ts +78 -1
  31. package/src/__tests__/users.test.ts +68 -0
  32. package/src/__tests__/vault-auth-status.test.ts +47 -6
  33. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  34. package/src/account-home-ui.ts +488 -38
  35. package/src/account-vault-token.ts +282 -0
  36. package/src/admin-handlers.ts +159 -4
  37. package/src/admin-login-ui.ts +49 -5
  38. package/src/admin-vaults.ts +48 -15
  39. package/src/api-account.ts +14 -0
  40. package/src/api-mint-token.ts +132 -24
  41. package/src/api-modules-ops.ts +49 -11
  42. package/src/api-revoke-token.ts +107 -21
  43. package/src/api-users.ts +29 -3
  44. package/src/cli.ts +26 -21
  45. package/src/clients.ts +18 -6
  46. package/src/cloudflare/config.ts +10 -4
  47. package/src/cloudflare/detect.ts +39 -44
  48. package/src/commands/auth.ts +165 -24
  49. package/src/commands/expose-2fa-warning.ts +34 -32
  50. package/src/commands/expose-auth-preflight.ts +89 -78
  51. package/src/commands/expose-cloudflare.ts +370 -12
  52. package/src/commands/expose.ts +8 -0
  53. package/src/commands/init.ts +33 -2
  54. package/src/commands/lifecycle.ts +386 -17
  55. package/src/commands/status.ts +22 -0
  56. package/src/commands/upgrade.ts +55 -11
  57. package/src/commands/wizard.ts +8 -4
  58. package/src/env-file.ts +10 -0
  59. package/src/help.ts +3 -1
  60. package/src/hub-db.ts +39 -1
  61. package/src/hub-server.ts +52 -0
  62. package/src/hub.ts +82 -14
  63. package/src/oauth-handlers.ts +298 -21
  64. package/src/oauth-ui.ts +10 -0
  65. package/src/operator-token.ts +151 -0
  66. package/src/pending-login.ts +116 -0
  67. package/src/rate-limit.ts +51 -0
  68. package/src/resource-binding.ts +134 -0
  69. package/src/scope-attenuation.ts +85 -0
  70. package/src/scope-explanations.ts +131 -14
  71. package/src/services-manifest.ts +112 -0
  72. package/src/setup-wizard.ts +77 -7
  73. package/src/tailscale/run.ts +28 -11
  74. package/src/totp.ts +201 -0
  75. package/src/two-factor-handlers.ts +287 -0
  76. package/src/two-factor-store.ts +181 -0
  77. package/src/two-factor-ui.ts +462 -0
  78. package/src/users.ts +58 -0
  79. package/src/vault/auth-status.ts +71 -19
  80. package/src/vault-hub-origin-env.ts +163 -0
  81. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  82. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  83. package/web/ui/dist/index.html +2 -2
  84. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  85. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  86. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  87. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Server-rendered HTML for `/account/2fa` — user self-service TOTP 2FA
3
+ * enroll / disenroll (hub#473). Part of the `/account/*` surface family;
4
+ * chrome cloned from `account-home-ui.ts` so the family stays cohesive.
5
+ *
6
+ * Three render states:
7
+ *
8
+ * - `not-enrolled`: a "Set up two-factor authentication" card with a POST
9
+ * button that starts enrollment.
10
+ * - `enrolling`: the QR code (inline SVG, no external fetch) + the manual
11
+ * base32 key + a confirm-code form. Posting a valid code finalizes.
12
+ * - `enrolled`: status (enabled since …, N backup codes left) + a disenroll
13
+ * form (requires the current password).
14
+ *
15
+ * Plus `backup-codes`: a one-time display of the freshly-minted backup codes
16
+ * after a successful enroll-confirm — shown ONCE, never retrievable.
17
+ *
18
+ * Pure renderer — no DB, no fs. The route handlers in `two-factor-handlers.ts`
19
+ * resolve the user + state, then call in here.
20
+ */
21
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
22
+ import { renderCsrfHiddenInput } from "./csrf.ts";
23
+ import { escapeHtml } from "./oauth-ui.ts";
24
+
25
+ const PALETTE = {
26
+ bg: "#faf8f4",
27
+ bgSoft: "#f3f0ea",
28
+ fg: "#2c2a26",
29
+ fgMuted: "#6b6860",
30
+ fgDim: "#9a9690",
31
+ accent: "#4a7c59",
32
+ accentHover: "#3d6849",
33
+ accentSoft: "rgba(74, 124, 89, 0.08)",
34
+ border: "#e4e0d8",
35
+ borderLight: "#ece9e2",
36
+ cardBg: "#ffffff",
37
+ danger: "#a3392b",
38
+ dangerSoft: "rgba(163, 57, 43, 0.08)",
39
+ success: "#3d6849",
40
+ successSoft: "rgba(61, 104, 73, 0.08)",
41
+ } as const;
42
+
43
+ const FONT_SERIF = `Georgia, "Times New Roman", serif`;
44
+ const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
45
+ const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
46
+
47
+ function escapeAttr(s: string): string {
48
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
49
+ }
50
+
51
+ function baseDocument(title: string, body: string): string {
52
+ return `<!doctype html>
53
+ <html lang="en">
54
+ <head>
55
+ <meta charset="utf-8" />
56
+ <title>${escapeHtml(title)}</title>
57
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
58
+ <meta name="referrer" content="no-referrer" />
59
+ <style>${STYLES}</style>
60
+ </head>
61
+ <body>
62
+ <main>
63
+ ${body}
64
+ </main>
65
+ </body>
66
+ </html>`;
67
+ }
68
+
69
+ function header(): string {
70
+ return `
71
+ <div class="brand">
72
+ <span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "account-2fa")}</span>
73
+ <span class="brand-name">${WORDMARK_TEXT}</span>
74
+ <span class="brand-tag">two-factor</span>
75
+ </div>`;
76
+ }
77
+
78
+ function errorBanner(msg: string | undefined): string {
79
+ return msg ? `<p class="error-banner">${escapeHtml(msg)}</p>` : "";
80
+ }
81
+
82
+ function noticeBanner(msg: string | undefined): string {
83
+ return msg ? `<p class="notice-banner">${escapeHtml(msg)}</p>` : "";
84
+ }
85
+
86
+ function shell(title: string, inner: string): string {
87
+ const body = `
88
+ <div class="card">
89
+ <div class="card-header">
90
+ ${header()}
91
+ <h1>Two-factor authentication</h1>
92
+ <p class="subtitle">Protect your hub sign-in with a one-time code from an authenticator app.</p>
93
+ </div>
94
+ ${inner}
95
+ <p class="footer-link"><a href="/account/">← Back to your account</a></p>
96
+ </div>`;
97
+ return baseDocument(title, body);
98
+ }
99
+
100
+ // --- not enrolled ---------------------------------------------------------
101
+
102
+ export interface NotEnrolledProps {
103
+ csrfToken: string;
104
+ errorMessage?: string;
105
+ notice?: string;
106
+ }
107
+
108
+ export function renderTwoFactorNotEnrolled(props: NotEnrolledProps): string {
109
+ const inner = `
110
+ ${errorBanner(props.errorMessage)}
111
+ ${noticeBanner(props.notice)}
112
+ <section class="section">
113
+ <p class="status status-off"><span class="dot dot-off"></span>Two-factor authentication is <strong>off</strong>.</p>
114
+ <p>When enabled, signing in will require a 6-digit code from your authenticator
115
+ app (Google Authenticator, 1Password, Authy, …) in addition to your password.</p>
116
+ <form method="POST" action="/account/2fa" class="action-form">
117
+ ${renderCsrfHiddenInput(props.csrfToken)}
118
+ <input type="hidden" name="action" value="start" />
119
+ <button type="submit" class="btn btn-primary">Set up two-factor authentication</button>
120
+ </form>
121
+ </section>`;
122
+ return shell("Two-factor authentication — Parachute", inner);
123
+ }
124
+
125
+ // --- enrolling (show QR + confirm) ----------------------------------------
126
+
127
+ export interface EnrollingProps {
128
+ csrfToken: string;
129
+ /** Inline SVG markup for the otpauth QR code. */
130
+ qrSvg: string;
131
+ /** Base32 secret for manual authenticator entry. */
132
+ secret: string;
133
+ errorMessage?: string;
134
+ }
135
+
136
+ export function renderTwoFactorEnrolling(props: EnrollingProps): string {
137
+ const inner = `
138
+ ${errorBanner(props.errorMessage)}
139
+ <section class="section">
140
+ <p>1. Scan this QR code with your authenticator app:</p>
141
+ <div class="qr" aria-label="TOTP QR code">${props.qrSvg}</div>
142
+ <p>Can't scan? Enter this key manually:</p>
143
+ <div class="copy-row">
144
+ <code data-testid="totp-secret">${escapeHtml(props.secret)}</code>
145
+ </div>
146
+ <p>2. Enter the 6-digit code your app shows to confirm:</p>
147
+ <form method="POST" action="/account/2fa" class="action-form">
148
+ ${renderCsrfHiddenInput(props.csrfToken)}
149
+ <input type="hidden" name="action" value="confirm" />
150
+ <input type="hidden" name="secret" value="${escapeAttr(props.secret)}" />
151
+ <label class="field">
152
+ <span class="field-label">Authentication code</span>
153
+ <input type="text" name="code" inputmode="numeric" autocomplete="one-time-code"
154
+ autofocus required placeholder="123456" />
155
+ </label>
156
+ <button type="submit" class="btn btn-primary">Confirm and enable</button>
157
+ </form>
158
+ </section>`;
159
+ return shell("Set up two-factor authentication — Parachute", inner);
160
+ }
161
+
162
+ // --- backup codes (one-time display) --------------------------------------
163
+
164
+ export interface BackupCodesProps {
165
+ codes: string[];
166
+ }
167
+
168
+ export function renderTwoFactorBackupCodes(props: BackupCodesProps): string {
169
+ const list = props.codes
170
+ .map((c) => `<li><code>${escapeHtml(c)}</code></li>`)
171
+ .join("\n ");
172
+ const inner = `
173
+ <section class="section">
174
+ <p class="status status-on"><span class="dot dot-on"></span>Two-factor authentication is now <strong>on</strong>.</p>
175
+ <p class="warn-text"><strong>Save these backup codes now.</strong> Each can be used once to
176
+ sign in if you lose access to your authenticator app. They are shown only once —
177
+ store them somewhere safe.</p>
178
+ <ul class="backup-codes" data-testid="backup-codes">
179
+ ${list}
180
+ </ul>
181
+ <p class="footer-link"><a class="btn btn-secondary" href="/account/2fa">I've saved my codes</a></p>
182
+ </section>`;
183
+ return shell("Backup codes — Parachute", inner);
184
+ }
185
+
186
+ // --- enrolled (status + disenroll) ----------------------------------------
187
+
188
+ export interface EnrolledProps {
189
+ csrfToken: string;
190
+ /** ISO-8601 enrollment timestamp, or null. */
191
+ enrolledAt: string | null;
192
+ /** Count of unused backup codes. */
193
+ backupCodesRemaining: number;
194
+ errorMessage?: string;
195
+ notice?: string;
196
+ }
197
+
198
+ export function renderTwoFactorEnrolled(props: EnrolledProps): string {
199
+ const since = props.enrolledAt ? ` (since ${escapeHtml(props.enrolledAt.slice(0, 10))})` : "";
200
+ const inner = `
201
+ ${errorBanner(props.errorMessage)}
202
+ ${noticeBanner(props.notice)}
203
+ <section class="section">
204
+ <p class="status status-on"><span class="dot dot-on"></span>Two-factor authentication is <strong>on</strong>${since}.</p>
205
+ <p>You have <strong data-testid="backup-remaining">${props.backupCodesRemaining}</strong>
206
+ backup code${props.backupCodesRemaining === 1 ? "" : "s"} remaining.</p>
207
+ </section>
208
+ <section class="section">
209
+ <h2>Turn off two-factor authentication</h2>
210
+ <p>Disabling 2FA removes the second-factor requirement from your sign-in.
211
+ Enter your current password to confirm.</p>
212
+ <form method="POST" action="/account/2fa" class="action-form">
213
+ ${renderCsrfHiddenInput(props.csrfToken)}
214
+ <input type="hidden" name="action" value="disable" />
215
+ <label class="field">
216
+ <span class="field-label">Current password</span>
217
+ <input type="password" name="password" autocomplete="current-password" required />
218
+ </label>
219
+ <button type="submit" class="btn btn-danger">Turn off two-factor authentication</button>
220
+ </form>
221
+ </section>`;
222
+ return shell("Two-factor authentication — Parachute", inner);
223
+ }
224
+
225
+ // --- styles ---------------------------------------------------------------
226
+
227
+ const STYLES = `
228
+ *, *::before, *::after { box-sizing: border-box; }
229
+ html, body { margin: 0; padding: 0; }
230
+ body {
231
+ font-family: ${FONT_SANS};
232
+ background: ${PALETTE.bg};
233
+ color: ${PALETTE.fg};
234
+ line-height: 1.55;
235
+ min-height: 100vh;
236
+ -webkit-font-smoothing: antialiased;
237
+ -moz-osx-font-smoothing: grayscale;
238
+ }
239
+ main {
240
+ display: flex;
241
+ align-items: flex-start;
242
+ justify-content: center;
243
+ min-height: 100vh;
244
+ padding: 2rem 1.5rem;
245
+ }
246
+ .card {
247
+ width: 100%;
248
+ max-width: 34rem;
249
+ background: ${PALETTE.cardBg};
250
+ border: 1px solid ${PALETTE.border};
251
+ border-radius: 12px;
252
+ padding: 2rem 1.75rem;
253
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
254
+ }
255
+ .card-header { margin-bottom: 1.5rem; }
256
+ .brand {
257
+ display: flex;
258
+ align-items: center;
259
+ gap: 0.5rem;
260
+ color: ${PALETTE.accent};
261
+ font-weight: 500;
262
+ font-size: 0.95rem;
263
+ margin-bottom: 1.25rem;
264
+ }
265
+ .brand-mark { display: inline-flex; line-height: 0; }
266
+ .brand-mark svg { width: 20px; height: 20px; }
267
+ .brand-name { letter-spacing: 0.01em; }
268
+ .brand-tag {
269
+ text-transform: uppercase;
270
+ letter-spacing: 0.06em;
271
+ font-size: 0.7rem;
272
+ color: ${PALETTE.fgMuted};
273
+ border: 1px solid ${PALETTE.borderLight};
274
+ padding: 0.05rem 0.4rem;
275
+ border-radius: 999px;
276
+ }
277
+ h1 {
278
+ font-family: ${FONT_SERIF};
279
+ font-weight: 400;
280
+ font-size: 1.6rem;
281
+ line-height: 1.2;
282
+ margin: 0 0 0.4rem;
283
+ color: ${PALETTE.fg};
284
+ }
285
+ h2 {
286
+ font-family: ${FONT_SERIF};
287
+ font-weight: 400;
288
+ font-size: 1.15rem;
289
+ line-height: 1.25;
290
+ margin: 0 0 0.6rem;
291
+ color: ${PALETTE.fg};
292
+ }
293
+ .subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
294
+
295
+ .section {
296
+ border-top: 1px solid ${PALETTE.borderLight};
297
+ padding-top: 1.25rem;
298
+ margin-top: 1.25rem;
299
+ }
300
+ .section p { margin: 0.5rem 0; }
301
+
302
+ .status { font-weight: 500; display: flex; align-items: center; gap: 0.5rem; }
303
+ .dot { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 999px; }
304
+ .dot-on { background: ${PALETTE.success}; }
305
+ .dot-off { background: ${PALETTE.fgDim}; }
306
+ .status-on strong { color: ${PALETTE.success}; }
307
+
308
+ .qr {
309
+ width: 200px;
310
+ height: 200px;
311
+ margin: 0.75rem 0;
312
+ padding: 0.6rem;
313
+ background: #ffffff;
314
+ border: 1px solid ${PALETTE.borderLight};
315
+ border-radius: 8px;
316
+ }
317
+ .qr svg { width: 100%; height: 100%; display: block; }
318
+
319
+ .copy-row {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 0.5rem;
323
+ background: ${PALETTE.bgSoft};
324
+ border: 1px solid ${PALETTE.borderLight};
325
+ border-radius: 6px;
326
+ padding: 0.5rem 0.6rem;
327
+ margin: 0.4rem 0 0.75rem;
328
+ }
329
+ .copy-row code {
330
+ flex: 1 1 auto;
331
+ overflow-x: auto;
332
+ white-space: nowrap;
333
+ background: transparent;
334
+ padding: 0;
335
+ font-size: 0.95rem;
336
+ letter-spacing: 0.08em;
337
+ }
338
+
339
+ .backup-codes {
340
+ list-style: none;
341
+ margin: 0.75rem 0;
342
+ padding: 0;
343
+ display: grid;
344
+ grid-template-columns: repeat(2, 1fr);
345
+ gap: 0.5rem;
346
+ }
347
+ .backup-codes code {
348
+ font-size: 0.95rem;
349
+ letter-spacing: 0.04em;
350
+ display: block;
351
+ text-align: center;
352
+ padding: 0.4rem;
353
+ }
354
+ .warn-text {
355
+ background: ${PALETTE.dangerSoft};
356
+ border: 1px solid ${PALETTE.danger};
357
+ border-radius: 6px;
358
+ color: ${PALETTE.danger};
359
+ padding: 0.6rem 0.8rem;
360
+ }
361
+
362
+ code {
363
+ font-family: ${FONT_MONO};
364
+ background: ${PALETTE.bgSoft};
365
+ padding: 0.1rem 0.4rem;
366
+ border-radius: 4px;
367
+ font-size: 0.88em;
368
+ }
369
+
370
+ .action-form { display: flex; flex-direction: column; gap: 0.9rem; margin-top: 0.75rem; }
371
+ .field { display: flex; flex-direction: column; gap: 0.35rem; }
372
+ .field-label {
373
+ font-size: 0.85rem;
374
+ font-weight: 500;
375
+ color: ${PALETTE.fgMuted};
376
+ letter-spacing: 0.01em;
377
+ font-family: ${FONT_MONO};
378
+ }
379
+ input[type=text], input[type=password] {
380
+ font: inherit;
381
+ width: 100%;
382
+ padding: 0.6rem 0.75rem;
383
+ border: 1px solid ${PALETTE.border};
384
+ border-radius: 6px;
385
+ background: ${PALETTE.bg};
386
+ color: ${PALETTE.fg};
387
+ transition: border-color 0.15s ease, background 0.15s ease;
388
+ }
389
+ input[type=text]:focus, input[type=password]:focus {
390
+ outline: none;
391
+ border-color: ${PALETTE.accent};
392
+ background: ${PALETTE.cardBg};
393
+ box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
394
+ }
395
+
396
+ .btn {
397
+ font: inherit;
398
+ font-weight: 500;
399
+ padding: 0.6rem 1.1rem;
400
+ border-radius: 6px;
401
+ border: 1px solid transparent;
402
+ cursor: pointer;
403
+ text-decoration: none;
404
+ display: inline-block;
405
+ align-self: flex-start;
406
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
407
+ }
408
+ .btn-primary { background: ${PALETTE.accent}; color: ${PALETTE.cardBg}; }
409
+ .btn-primary:hover { background: ${PALETTE.accentHover}; }
410
+ .btn-secondary { background: transparent; color: ${PALETTE.fg}; border-color: ${PALETTE.border}; }
411
+ .btn-secondary:hover { background: ${PALETTE.bgSoft}; border-color: ${PALETTE.accent}; }
412
+ .btn-danger { background: ${PALETTE.danger}; color: ${PALETTE.cardBg}; }
413
+ .btn-danger:hover { background: #8a3023; }
414
+
415
+ .error-banner {
416
+ background: ${PALETTE.dangerSoft};
417
+ border: 1px solid ${PALETTE.danger};
418
+ border-radius: 6px;
419
+ color: ${PALETTE.danger};
420
+ padding: 0.6rem 0.8rem;
421
+ margin: 0 0 1rem;
422
+ font-size: 0.9rem;
423
+ }
424
+ .notice-banner {
425
+ background: ${PALETTE.successSoft};
426
+ border: 1px solid ${PALETTE.success};
427
+ border-radius: 6px;
428
+ color: ${PALETTE.success};
429
+ padding: 0.6rem 0.8rem;
430
+ margin: 0 0 1rem;
431
+ font-size: 0.9rem;
432
+ }
433
+
434
+ .footer-link { margin-top: 1.25rem; }
435
+ a { color: ${PALETTE.accent}; }
436
+ a:hover { color: ${PALETTE.accentHover}; }
437
+
438
+ @media (max-width: 480px) {
439
+ main { padding: 1rem 0.75rem; }
440
+ .card { padding: 1.5rem 1.25rem; border-radius: 10px; }
441
+ h1 { font-size: 1.4rem; }
442
+ .backup-codes { grid-template-columns: 1fr; }
443
+ }
444
+
445
+ @media (prefers-color-scheme: dark) {
446
+ body { background: #1a1815; color: #e8e4dc; }
447
+ .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
448
+ h1, h2 { color: #f0ece4; }
449
+ .subtitle, .field-label { color: #a8a29a; }
450
+ code { background: #1f1c18; color: #e8e4dc; }
451
+ .copy-row { background: #1f1c18; border-color: #3a362f; }
452
+ .copy-row code { background: transparent; }
453
+ .section { border-top-color: #3a362f; }
454
+ .brand-tag { border-color: #3a362f; color: #a8a29a; }
455
+ input[type=text], input[type=password] {
456
+ background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
457
+ }
458
+ input[type=text]:focus, input[type=password]:focus { background: #25221d; }
459
+ .btn-secondary { color: #e8e4dc; border-color: #3a362f; }
460
+ .btn-secondary:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
461
+ }
462
+ `;
package/src/users.ts CHANGED
@@ -144,6 +144,64 @@ function readVaultsForUser(db: Database, userId: string): string[] {
144
144
  .map((r) => r.vault_name);
145
145
  }
146
146
 
147
+ /**
148
+ * The per-vault verbs a `user_vaults.role` grants. The schema's `role`
149
+ * column is `TEXT NOT NULL DEFAULT 'write'` and is reserved forward-compat
150
+ * for per-vault role granularity (see the v10 migration note in
151
+ * `hub-db.ts`). Today every assignment is created with `role = 'write'`, so
152
+ * the only live mapping is `write → {read, write}`. The function is the
153
+ * single place the verb-cap lives so a future role taxonomy (`read`-only,
154
+ * `admin`, etc.) lands here without the friend-mint path having to change.
155
+ *
156
+ * Mapping:
157
+ * - `write` (today's only value) → `["read", "write"]`
158
+ * - `read` → `["read"]`
159
+ * - anything else (unknown role) → `[]` — fail closed. An unrecognised
160
+ * role grants no minting authority rather than silently defaulting to
161
+ * write. (Defense-in-depth: a hand-edited / future row with a role this
162
+ * code doesn't understand should not be treated as broad write.)
163
+ *
164
+ * `admin` is intentionally NOT mapped to a `vault:<name>:admin` mint here —
165
+ * the friend-facing token mint is capped at read/write by design. A
166
+ * future per-vault-admin friend grant would route through the
167
+ * vault-admin-token path, not this one.
168
+ */
169
+ export type VaultVerb = "read" | "write";
170
+
171
+ export function vaultVerbsForRole(role: string): VaultVerb[] {
172
+ if (role === "write") return ["read", "write"];
173
+ if (role === "read") return ["read"];
174
+ return [];
175
+ }
176
+
177
+ /**
178
+ * Read the verbs a user may mint for one of their assigned vaults.
179
+ *
180
+ * Returns `null` when the user has NO `user_vaults` row for `vaultName` —
181
+ * i.e. the vault is not in their assignment. The caller treats `null` as a
182
+ * hard 403 (no minting for an unassigned vault). When a row exists, returns
183
+ * the verb list `vaultVerbsForRole` maps the stored role to (today always
184
+ * `["read", "write"]` since every assignment is `role = 'write'`).
185
+ *
186
+ * This reads the role column directly rather than going through
187
+ * `getUserById().assignedVaults` because that array is verb-blind — it
188
+ * names the vaults but not the role granted. The friend-mint authorization
189
+ * cap needs the role.
190
+ */
191
+ export function vaultVerbsForUserVault(
192
+ db: Database,
193
+ userId: string,
194
+ vaultName: string,
195
+ ): VaultVerb[] | null {
196
+ const row = db
197
+ .query<{ role: string }, [string, string]>(
198
+ "SELECT role FROM user_vaults WHERE user_id = ? AND vault_name = ?",
199
+ )
200
+ .get(userId, vaultName);
201
+ if (!row) return null;
202
+ return vaultVerbsForRole(row.role);
203
+ }
204
+
147
205
  export interface CreateUserOpts {
148
206
  /** Allow creating an additional user when one already exists. Off by default. */
149
207
  allowMulti?: boolean;
@@ -18,11 +18,12 @@
18
18
  * 3. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table
19
19
  * count, summed across every vault instance.
20
20
  *
21
- * The hub.db schema doesn't yet carry a TOTP column (2FA lands in a later
22
- * phase per the multi-user design doc); we always report `hasTotp: false`
23
- * when the hub.db path is the source of truth. That matches what's
24
- * actually shipped pretending otherwise would whisper "you're covered"
25
- * when no second factor exists.
21
+ * As of hub#473 the hub.db `users` table carries TOTP columns
22
+ * (`totp_secret` et al.) and `/login` enforces a second factor when set, so
23
+ * `hasTotp` now reflects the hub.db state: true when any user has a non-empty
24
+ * `totp_secret`. The legacy vault `config.yaml` `totp_secret` (which never
25
+ * gated hub `/login`) is still read as a fallback for super-old installs whose
26
+ * hub.db is absent, but real hub-login 2FA is the hub.db signal.
26
27
  *
27
28
  * The YAML fallback path uses line-anchored regex parsing that matches
28
29
  * vault's own `readGlobalConfig()` semantics (parachute-vault src/config.ts):
@@ -54,9 +55,12 @@ export interface VaultAuthStatus {
54
55
  hasTotp: boolean;
55
56
  /**
56
57
  * `null` means we couldn't read the SQLite DB — distinct from "0 tokens
57
- * exist." Callers branch the UI on this: `null` "token status unknown,
58
- * run `parachute vault tokens list` to check"; `0` loud "no auth at
59
- * all!" warning; `>0` benign.
58
+ * exist." Post the pvt_* DROP (hub#466) the expose preflight no longer
59
+ * branches on this `classify()` in expose-auth-preflight.ts gates on
60
+ * `hasOwnerPassword`, since access now flows through the owner password +
61
+ * on-demand hub JWT mint, not standing vault tokens. `tokenCount` is kept
62
+ * as a best-effort diagnostic only (these rows are vestigial); `null`
63
+ * still cleanly distinguishes "unreadable DB" from "0 rows."
60
64
  */
61
65
  tokenCount: number | null;
62
66
  /** Vault instance names discovered under data/. Empty when vault has
@@ -96,6 +100,16 @@ export interface AuthStatusOpts {
96
100
  * want true "I can sign in" semantics get the OR of hub.db∪YAML.
97
101
  */
98
102
  probeHubDbHasUserPassword?: (dbPath: string) => boolean | undefined;
103
+ /**
104
+ * Probe hub.db for "does at least one user row have a non-empty
105
+ * `totp_secret`?" — the canonical "real hub-login 2FA is on" signal
106
+ * (hub#473). Same tri-state semantics as {@link probeHubDbHasUserPassword}:
107
+ * - `true` → at least one user has TOTP enrolled.
108
+ * - `false` → hub.db opened cleanly but no user has a secret.
109
+ * - `undefined` → hub.db missing / unreadable / column absent (pre-v11);
110
+ * caller falls back to the legacy YAML probe.
111
+ */
112
+ probeHubDbHasTotp?: (dbPath: string) => boolean | undefined;
99
113
  }
100
114
 
101
115
  interface Resolved {
@@ -105,6 +119,7 @@ interface Resolved {
105
119
  listVaultNames: (dataDir: string) => string[];
106
120
  countTokens: (dbPath: string) => number;
107
121
  probeHubDbHasUserPassword: (dbPath: string) => boolean | undefined;
122
+ probeHubDbHasTotp: (dbPath: string) => boolean | undefined;
108
123
  }
109
124
 
110
125
  function defaultVaultHome(): string {
@@ -183,11 +198,46 @@ function defaultProbeHubDbHasUserPassword(dbPath: string): boolean | undefined {
183
198
  // simpler than a JOIN on earliest-created-at. A future env-seed
184
199
  // flow that creates friend accounts before the operator sets a
185
200
  // password would need to revisit this assumption.
186
- const row = db?.prepare(
187
- "SELECT COUNT(*) AS n FROM users WHERE password_hash IS NOT NULL AND length(password_hash) > 0",
188
- ).get() as { n: number } | null;
201
+ const row = db
202
+ ?.prepare(
203
+ "SELECT COUNT(*) AS n FROM users WHERE password_hash IS NOT NULL AND length(password_hash) > 0",
204
+ )
205
+ .get() as { n: number } | null;
206
+ return (row?.n ?? 0) > 0;
207
+ } catch {
208
+ return undefined;
209
+ } finally {
210
+ try {
211
+ db?.close();
212
+ } catch {
213
+ // ignore
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Open hub.db readonly and ask "does at least one user have a non-empty
220
+ * `totp_secret`?" — the real hub-login 2FA signal (hub#473). Returns
221
+ * `undefined` on any failure (DB missing, locked, or the `totp_secret` column
222
+ * absent because migration v11 hasn't applied) — the caller then falls back to
223
+ * the legacy YAML probe. Readonly open (no migration side effects) mirrors
224
+ * {@link defaultProbeHubDbHasUserPassword}.
225
+ */
226
+ function defaultProbeHubDbHasTotp(dbPath: string): boolean | undefined {
227
+ if (!existsSync(dbPath)) return undefined;
228
+ const { Database } = require("bun:sqlite");
229
+ let db: { prepare: (sql: string) => { get: () => unknown }; close: () => void } | undefined;
230
+ try {
231
+ db = new Database(dbPath, { readonly: true }) as typeof db;
232
+ const row = db
233
+ ?.prepare(
234
+ "SELECT COUNT(*) AS n FROM users WHERE totp_secret IS NOT NULL AND length(totp_secret) > 0",
235
+ )
236
+ .get() as { n: number } | null;
189
237
  return (row?.n ?? 0) > 0;
190
238
  } catch {
239
+ // Column absent (pre-v11) or DB unreadable — indistinguishable from
240
+ // "no hub.db"; let the YAML fallback decide.
191
241
  return undefined;
192
242
  } finally {
193
243
  try {
@@ -205,8 +255,8 @@ function resolve(opts: AuthStatusOpts): Resolved {
205
255
  readText: opts.readText ?? defaultReadText,
206
256
  listVaultNames: opts.listVaultNames ?? defaultListVaultNames,
207
257
  countTokens: opts.countTokens ?? defaultCountTokens,
208
- probeHubDbHasUserPassword:
209
- opts.probeHubDbHasUserPassword ?? defaultProbeHubDbHasUserPassword,
258
+ probeHubDbHasUserPassword: opts.probeHubDbHasUserPassword ?? defaultProbeHubDbHasUserPassword,
259
+ probeHubDbHasTotp: opts.probeHubDbHasTotp ?? defaultProbeHubDbHasTotp,
210
260
  };
211
261
  }
212
262
 
@@ -258,12 +308,14 @@ function readAuthSignals(r: Resolved): AuthSignals {
258
308
  const yaml = readYamlAuth(r);
259
309
  const hubDbHasUser = r.probeHubDbHasUserPassword(r.hubDbPath);
260
310
  const hasOwnerPassword = hubDbHasUser === true ? true : yaml.hasOwnerPassword;
261
- return {
262
- hasOwnerPassword,
263
- // No hub-side TOTP column shipped yet (multi-user Phase 3). Until it
264
- // lands, TOTP is YAML-only matches what's actually true on disk.
265
- hasTotp: yaml.hasTotp,
266
- };
311
+ // Real hub-login 2FA (hub#473): read hub.db's totp_secret. `undefined` (DB
312
+ // missing / pre-v11 column absent) falls back to the legacy YAML totp_secret
313
+ // that one never gated hub `/login`, but suppressing the warning for an
314
+ // operator who set it avoids nagging. A definitive hub.db `false` (column
315
+ // present, no user enrolled) overrides a stale YAML true.
316
+ const hubDbHasTotp = r.probeHubDbHasTotp(r.hubDbPath);
317
+ const hasTotp = hubDbHasTotp === undefined ? yaml.hasTotp : hubDbHasTotp;
318
+ return { hasOwnerPassword, hasTotp };
267
319
  }
268
320
 
269
321
  /**