@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.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +71 -19
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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;
|
package/src/vault/auth-status.ts
CHANGED
|
@@ -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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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."
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
/**
|