@openparachute/hub 0.5.13 → 0.5.14-rc.2

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 (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Server-rendered HTML for `/account/` — the friend-facing user home.
3
+ *
4
+ * Multi-user Phase 1 follow-up. Companion to the first-admin gate on
5
+ * `/admin/host-admin-token` (see `admin-host-admin-token.ts`): without
6
+ * this landing surface, a non-admin friend signing in would either
7
+ * (a) hit a 403 wall when the SPA tries to mint a host-admin bearer,
8
+ * or (b) — pre-gate — silently escalate to full admin. The gate plus
9
+ * this page give the friend a coherent home: their assigned vault,
10
+ * password rotation link, sign-out.
11
+ *
12
+ * Pure renderer — no DB, no Bun.serve, no fs. The `/account/` route
13
+ * handler in `hub-server.ts` resolves the user + their vault + the
14
+ * is-first-admin flag, then calls in here. Same posture as
15
+ * `account-change-password-ui.ts` and `oauth-ui.ts`.
16
+ *
17
+ * Visual chrome: cloned from `account-change-password-ui.ts` so the
18
+ * `/account/*` surface family stays cohesive. If/when an
19
+ * `auth-ui-chrome.ts` lands, both should consolidate.
20
+ */
21
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
22
+ import { renderCsrfHiddenInput } from "./csrf.ts";
23
+ import { escapeHtml } from "./oauth-ui.ts";
24
+
25
+ // --- shared chrome (mirrors account-change-password-ui.ts) ----------------
26
+
27
+ const PALETTE = {
28
+ bg: "#faf8f4",
29
+ bgSoft: "#f3f0ea",
30
+ fg: "#2c2a26",
31
+ fgMuted: "#6b6860",
32
+ fgDim: "#9a9690",
33
+ accent: "#4a7c59",
34
+ accentHover: "#3d6849",
35
+ accentSoft: "rgba(74, 124, 89, 0.08)",
36
+ border: "#e4e0d8",
37
+ borderLight: "#ece9e2",
38
+ cardBg: "#ffffff",
39
+ danger: "#a3392b",
40
+ dangerSoft: "rgba(163, 57, 43, 0.08)",
41
+ success: "#3d6849",
42
+ successSoft: "rgba(61, 104, 73, 0.08)",
43
+ } as const;
44
+
45
+ const FONT_SERIF = `Georgia, "Times New Roman", serif`;
46
+ const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
47
+ const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
48
+
49
+ function baseDocument(title: string, body: string): string {
50
+ return `<!doctype html>
51
+ <html lang="en">
52
+ <head>
53
+ <meta charset="utf-8" />
54
+ <title>${escapeHtml(title)}</title>
55
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
56
+ <meta name="referrer" content="no-referrer" />
57
+ <style>${STYLES}</style>
58
+ </head>
59
+ <body>
60
+ <main>
61
+ ${body}
62
+ </main>
63
+ </body>
64
+ </html>`;
65
+ }
66
+
67
+ function header(): string {
68
+ return `
69
+ <div class="brand">
70
+ <span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "account-home")}</span>
71
+ <span class="brand-name">${WORDMARK_TEXT}</span>
72
+ <span class="brand-tag">account</span>
73
+ </div>`;
74
+ }
75
+
76
+ // --- /account/ ------------------------------------------------------------
77
+
78
+ export interface RenderAccountHomeOpts {
79
+ username: string;
80
+ /**
81
+ * Vault instance names this user has access to (multi-user Phase 2
82
+ * PR 2). Empty `[]` for admin posture (combined with `isFirstAdmin:
83
+ * true` this is the hub administrator's account, whose vault access
84
+ * is unrestricted). One or more entries for non-admin users — the
85
+ * page renders one Notes-CTA tile per vault.
86
+ */
87
+ assignedVaults: string[];
88
+ /** Force-change-password completion flag. Currently informational only. */
89
+ passwordChanged: boolean;
90
+ /**
91
+ * Hub origin (no trailing slash) — the canonical URL operators connect
92
+ * their MCP / Surface clients to. Used both in the Notes "Open" CTA
93
+ * (encoded as `?url=...` on `notes.parachute.computer/add`) and as
94
+ * inline-code text for the "custom client" disclosure.
95
+ */
96
+ hubOrigin: string;
97
+ /**
98
+ * Whether the signed-in user is the hub administrator (the
99
+ * earliest-created users row). Drives the `assignedVault: null`
100
+ * branch — admins see an "open admin" affordance; non-admins (a
101
+ * Phase 1 shape that shouldn't normally occur — admins land with
102
+ * `assignedVault: null`, friends always have one set) see a
103
+ * defensive "ask the operator" message.
104
+ */
105
+ isFirstAdmin: boolean;
106
+ /**
107
+ * CSRF token for the sign-out form. Same pattern as
108
+ * `account-change-password-ui.ts` — POSTs to `/logout` are CSRF-gated
109
+ * to keep a cross-origin form from logging the user out.
110
+ */
111
+ csrfToken: string;
112
+ }
113
+
114
+ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
115
+ const { username, assignedVaults, hubOrigin, isFirstAdmin, csrfToken } = opts;
116
+ const safeUsername = escapeHtml(username);
117
+ // Origin is already canonicalized by the handler (trailing slash stripped),
118
+ // but defensively re-trim so a stray slash here doesn't break the CTA href.
119
+ const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
120
+
121
+ const vaultCard = renderVaultCard({
122
+ assignedVaults,
123
+ trimmedOrigin,
124
+ isFirstAdmin,
125
+ });
126
+
127
+ const accountCard = renderAccountCard({
128
+ username: safeUsername,
129
+ csrfToken,
130
+ });
131
+
132
+ const body = `
133
+ <div class="card">
134
+ <div class="card-header">
135
+ ${header()}
136
+ <h1>Welcome, ${safeUsername}</h1>
137
+ <p class="subtitle">Your Parachute account home.</p>
138
+ </div>
139
+ ${vaultCard}
140
+ ${accountCard}
141
+ </div>`;
142
+ return baseDocument(`${username} — Parachute`, body);
143
+ }
144
+
145
+ interface VaultCardOpts {
146
+ assignedVaults: string[];
147
+ trimmedOrigin: string;
148
+ isFirstAdmin: boolean;
149
+ }
150
+
151
+ function renderVaultCard(opts: VaultCardOpts): string {
152
+ const { assignedVaults, trimmedOrigin, isFirstAdmin } = opts;
153
+
154
+ if (assignedVaults.length > 0) {
155
+ // One vault tile per assignment (multi-user Phase 2 PR 2). Each
156
+ // tile carries its own Notes "Open" CTA and the hub-origin code
157
+ // block. The disclosure ("Use a custom client") lives at the
158
+ // section level, not per-tile, because the hub origin is the same
159
+ // regardless of which vault the user picks.
160
+ const hubOriginDisplay = escapeHtml(trimmedOrigin);
161
+ const heading = assignedVaults.length === 1 ? "<h2>Your vault</h2>" : "<h2>Your vaults</h2>";
162
+ const tiles = assignedVaults
163
+ .map((vaultName) => {
164
+ const safeVault = escapeHtml(vaultName);
165
+ const vaultUrlForAdd = encodeURIComponent(`${trimmedOrigin}/vault/${vaultName}`);
166
+ return `
167
+ <div class="vault-tile" data-testid="vault-tile" data-vault-name="${safeVault}">
168
+ <p class="vault-name"><strong>${safeVault}</strong></p>
169
+ <p>
170
+ <a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
171
+ target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
172
+ </p>
173
+ </div>`;
174
+ })
175
+ .join("");
176
+ return `
177
+ <section class="section" data-testid="vault-card">
178
+ ${heading}
179
+ <p>Open Notes — the canonical browser UI for your vault${
180
+ assignedVaults.length === 1 ? "" : "s"
181
+ }. It connects to your hub
182
+ over HTTPS and remembers your URL after the first OAuth.</p>
183
+ <div class="vault-tiles">${tiles}
184
+ </div>
185
+ <details class="custom-client">
186
+ <summary>Use a custom client</summary>
187
+ <p>To connect a custom Surface or MCP client running on your own machine,
188
+ point it at the hub origin below and run through OAuth — the hub will
189
+ ask you to consent.</p>
190
+ <p class="hub-origin-line">Hub origin: <code>${hubOriginDisplay}</code></p>
191
+ </details>
192
+ </section>`;
193
+ }
194
+ if (isFirstAdmin) {
195
+ return `
196
+ <section class="section" data-testid="admin-card">
197
+ <h2>Your vault</h2>
198
+ <p>You're the hub administrator. Visit
199
+ <a href="/admin/">the admin surface</a> to manage vaults, users, and clients.</p>
200
+ </section>`;
201
+ }
202
+ // Defensive third branch — non-admin with no assigned vault. The
203
+ // /api/users path doesn't require a vault on create (admins can leave
204
+ // a new friend's vault list empty and fill it in later), so this
205
+ // state is reachable through normal flows — surface a clear "ask
206
+ // your admin" message.
207
+ return `
208
+ <section class="section" data-testid="no-vault-card">
209
+ <h2>Your vault</h2>
210
+ <p>Your account isn't assigned to a vault yet. Ask the hub operator
211
+ to assign one.</p>
212
+ </section>`;
213
+ }
214
+
215
+ interface AccountCardOpts {
216
+ username: string;
217
+ csrfToken: string;
218
+ }
219
+
220
+ function renderAccountCard(opts: AccountCardOpts): string {
221
+ const { username, csrfToken } = opts;
222
+ return `
223
+ <section class="section" data-testid="account-card">
224
+ <h2>Account</h2>
225
+ <dl class="kv">
226
+ <dt>Username</dt>
227
+ <dd><code>${username}</code></dd>
228
+ </dl>
229
+ <p>
230
+ <a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
231
+ </p>
232
+ <form method="POST" action="/logout" class="signout-form" data-testid="signout-form">
233
+ ${renderCsrfHiddenInput(csrfToken)}
234
+ <button type="submit" class="btn btn-secondary">Sign out</button>
235
+ </form>
236
+ </section>`;
237
+ }
238
+
239
+ // --- styles ---------------------------------------------------------------
240
+ //
241
+ // Same brand palette + font stack as account-change-password-ui.ts so the
242
+ // `/account/*` family is visually cohesive. Extra rules (.section, .kv,
243
+ // .vault-name, .custom-client, .hub-origin-line) describe the new card +
244
+ // disclosure shapes this page introduces.
245
+
246
+ const STYLES = `
247
+ *, *::before, *::after { box-sizing: border-box; }
248
+ html, body { margin: 0; padding: 0; }
249
+ body {
250
+ font-family: ${FONT_SANS};
251
+ background: ${PALETTE.bg};
252
+ color: ${PALETTE.fg};
253
+ line-height: 1.55;
254
+ min-height: 100vh;
255
+ -webkit-font-smoothing: antialiased;
256
+ -moz-osx-font-smoothing: grayscale;
257
+ }
258
+ main {
259
+ display: flex;
260
+ align-items: flex-start;
261
+ justify-content: center;
262
+ min-height: 100vh;
263
+ padding: 2rem 1.5rem;
264
+ }
265
+ .card {
266
+ width: 100%;
267
+ max-width: 34rem;
268
+ background: ${PALETTE.cardBg};
269
+ border: 1px solid ${PALETTE.border};
270
+ border-radius: 12px;
271
+ padding: 2rem 1.75rem;
272
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
273
+ }
274
+ .card-header { margin-bottom: 1.5rem; }
275
+ .brand {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: 0.5rem;
279
+ color: ${PALETTE.accent};
280
+ font-weight: 500;
281
+ font-size: 0.95rem;
282
+ margin-bottom: 1.25rem;
283
+ }
284
+ .brand-mark { display: inline-flex; line-height: 0; }
285
+ .brand-mark svg { width: 20px; height: 20px; }
286
+ .brand-name { letter-spacing: 0.01em; }
287
+ .brand-tag {
288
+ text-transform: uppercase;
289
+ letter-spacing: 0.06em;
290
+ font-size: 0.7rem;
291
+ color: ${PALETTE.fgMuted};
292
+ border: 1px solid ${PALETTE.borderLight};
293
+ padding: 0.05rem 0.4rem;
294
+ border-radius: 999px;
295
+ }
296
+ h1 {
297
+ font-family: ${FONT_SERIF};
298
+ font-weight: 400;
299
+ font-size: 1.6rem;
300
+ line-height: 1.2;
301
+ margin: 0 0 0.4rem;
302
+ color: ${PALETTE.fg};
303
+ }
304
+ h2 {
305
+ font-family: ${FONT_SERIF};
306
+ font-weight: 400;
307
+ font-size: 1.15rem;
308
+ line-height: 1.25;
309
+ margin: 0 0 0.6rem;
310
+ color: ${PALETTE.fg};
311
+ }
312
+ .subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
313
+
314
+ .section {
315
+ border-top: 1px solid ${PALETTE.borderLight};
316
+ padding-top: 1.25rem;
317
+ margin-top: 1.25rem;
318
+ }
319
+ .section p { margin: 0.4rem 0; }
320
+ .vault-name {
321
+ font-family: ${FONT_MONO};
322
+ font-size: 1rem;
323
+ margin: 0 0 0.6rem;
324
+ }
325
+ .vault-name strong { color: ${PALETTE.fg}; font-weight: 600; }
326
+ .vault-tiles {
327
+ display: flex;
328
+ flex-direction: column;
329
+ gap: 0.75rem;
330
+ margin: 0.75rem 0 0.4rem;
331
+ }
332
+ .vault-tile {
333
+ border: 1px solid ${PALETTE.borderLight};
334
+ border-radius: 8px;
335
+ padding: 0.75rem 1rem;
336
+ background: ${PALETTE.bgSoft};
337
+ }
338
+ .vault-tile p { margin: 0.2rem 0; }
339
+ .vault-tile p:last-child { margin-top: 0.5rem; }
340
+
341
+ .custom-client { margin-top: 0.8rem; }
342
+ .custom-client summary {
343
+ cursor: pointer;
344
+ font-size: 0.85rem;
345
+ color: ${PALETTE.fgMuted};
346
+ font-family: ${FONT_MONO};
347
+ padding: 0.25rem 0;
348
+ user-select: none;
349
+ }
350
+ .custom-client[open] summary { color: ${PALETTE.fg}; }
351
+ .custom-client p {
352
+ font-size: 0.9rem;
353
+ color: ${PALETTE.fgMuted};
354
+ margin: 0.4rem 0;
355
+ }
356
+ .hub-origin-line { font-family: ${FONT_MONO}; }
357
+ code {
358
+ font-family: ${FONT_MONO};
359
+ background: ${PALETTE.bgSoft};
360
+ padding: 0.1rem 0.4rem;
361
+ border-radius: 4px;
362
+ font-size: 0.88em;
363
+ }
364
+
365
+ .kv { margin: 0 0 0.6rem; padding: 0; }
366
+ .kv dt {
367
+ font-size: 0.78rem;
368
+ text-transform: uppercase;
369
+ letter-spacing: 0.06em;
370
+ color: ${PALETTE.fgMuted};
371
+ font-family: ${FONT_MONO};
372
+ margin-top: 0.4rem;
373
+ }
374
+ .kv dd { margin: 0.15rem 0 0.4rem; }
375
+
376
+ .btn {
377
+ font: inherit;
378
+ font-weight: 500;
379
+ padding: 0.55rem 1rem;
380
+ border-radius: 6px;
381
+ border: 1px solid transparent;
382
+ cursor: pointer;
383
+ text-decoration: none;
384
+ display: inline-block;
385
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
386
+ }
387
+ .btn-primary {
388
+ background: ${PALETTE.accent};
389
+ color: ${PALETTE.cardBg};
390
+ }
391
+ .btn-primary:hover { background: ${PALETTE.accentHover}; }
392
+ .btn-secondary {
393
+ background: transparent;
394
+ color: ${PALETTE.fg};
395
+ border-color: ${PALETTE.border};
396
+ }
397
+ .btn-secondary:hover {
398
+ background: ${PALETTE.bgSoft};
399
+ border-color: ${PALETTE.accent};
400
+ }
401
+
402
+ .signout-form { margin: 0.8rem 0 0; }
403
+
404
+ .account-action {
405
+ color: ${PALETTE.accent};
406
+ text-decoration: none;
407
+ font-weight: 500;
408
+ font-size: 0.95rem;
409
+ }
410
+ .account-action:hover { color: ${PALETTE.accentHover}; text-decoration: underline; }
411
+
412
+ a { color: ${PALETTE.accent}; }
413
+ a:hover { color: ${PALETTE.accentHover}; }
414
+
415
+ @media (max-width: 480px) {
416
+ main { padding: 1rem 0.75rem; }
417
+ .card { padding: 1.5rem 1.25rem; border-radius: 10px; }
418
+ h1 { font-size: 1.4rem; }
419
+ h2 { font-size: 1.05rem; }
420
+ }
421
+
422
+ @media (prefers-color-scheme: dark) {
423
+ body { background: #1a1815; color: #e8e4dc; }
424
+ .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
425
+ h1, h2 { color: #f0ece4; }
426
+ .subtitle, .kv dt, .custom-client summary { color: #a8a29a; }
427
+ .vault-name strong { color: #f0ece4; }
428
+ code { background: #1f1c18; color: #e8e4dc; }
429
+ .section { border-top-color: #3a362f; }
430
+ .brand-tag { border-color: #3a362f; color: #a8a29a; }
431
+ .btn-secondary { color: #e8e4dc; border-color: #3a362f; }
432
+ .btn-secondary:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
433
+ }
434
+ `;
@@ -23,7 +23,13 @@ import {
23
23
  deleteSession,
24
24
  parseSessionCookie,
25
25
  } from "./sessions.ts";
26
- import { PASSWORD_MAX_LEN, type User, getUserByUsername, verifyPassword } from "./users.ts";
26
+ import {
27
+ PASSWORD_MAX_LEN,
28
+ type User,
29
+ getUserByUsername,
30
+ isFirstAdmin,
31
+ verifyPassword,
32
+ } from "./users.ts";
27
33
 
28
34
  function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
29
35
  return new Response(body, {
@@ -41,8 +47,12 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
41
47
  * `/admin/vaults` as its home (vault list, the default tab). Anywhere else
42
48
  * would either bounce the operator out of the SPA shell or land on a
43
49
  * legacy server-rendered page — `/admin/vaults` is the canonical entry.
50
+ *
51
+ * Exported because `api-account.ts` consumes the same constant for its
52
+ * change-password landing. Single source of truth so a future "default
53
+ * landing changed to /admin/dashboard" PR doesn't drift across two files.
44
54
  */
45
- const POST_LOGIN_DEFAULT = "/admin/vaults";
55
+ export const POST_LOGIN_DEFAULT = "/admin/vaults";
46
56
 
47
57
  /**
48
58
  * Force-change-password landing. Multi-user Phase 1 PR 3: when a user
@@ -75,22 +85,44 @@ function safeNext(raw: string | null): string {
75
85
  * boundary — once changed, no per-request scope check is needed and
76
86
  * no token claim carries the bit forward.
77
87
  *
78
- * When `password_changed === false`:
79
- * - redirect to `/account/change-password`
80
- * - preserve the user's intended `next` as a query param so the
81
- * change-password POST can finish the trip
88
+ * Three precedence rules, in order:
89
+ *
90
+ * 1. `password_changed === false` `/account/change-password`
91
+ * (preserves `next` as a query param so the change-password POST
92
+ * finishes the trip).
93
+ * 2. Non-admin user (friend) whose `next` targets `/admin/*` → rewrite
94
+ * to `/account/`. Friends have no business on admin SPA URLs —
95
+ * `/admin/host-admin-token` would 403 (first-admin gate) and the
96
+ * SPA would bounce them to `/account/` anyway. Doing the rewrite
97
+ * at the login boundary avoids the bouncing-around UX. Other `next`
98
+ * paths (`/`, `/oauth/authorize/...`, `/vault/...`) are legitimate
99
+ * destinations for non-admin users and pass through unchanged.
100
+ * 3. Otherwise return `next` (the today's-default behavior).
82
101
  *
83
- * When `password_changed === true`: return `next` (today's behavior).
102
+ * `db` is required for the non-admin check (it consults `getFirstAdminId`).
103
+ * Wizard-only test fixtures that pre-existed the `db` plumbing pass the
104
+ * harness DB through; the parameter is non-optional to make the
105
+ * "did you remember the gate?" review check explicit.
84
106
  */
85
- export function loginRedirectTarget(user: User, next: string): string {
86
- if (user.passwordChanged) return next;
87
- // Preserve the operator's intended destination; the change-password
88
- // POST will read this and 302 there after the change lands.
89
- // Only encode `next` if it isn't already the post-change default —
90
- // keeps the URL clean for the common case (no `next` param specified
91
- // at login time → no `?next=` on the change-password URL).
92
- if (next === POST_LOGIN_DEFAULT) return FORCE_CHANGE_PASSWORD_PATH;
93
- return `${FORCE_CHANGE_PASSWORD_PATH}?next=${encodeURIComponent(next)}`;
107
+ export function loginRedirectTarget(db: Database, user: User, next: string): string {
108
+ if (!user.passwordChanged) {
109
+ // Preserve the operator's intended destination; the change-password
110
+ // POST will read this and 302 there after the change lands.
111
+ // Only encode `next` if it isn't already the post-change default —
112
+ // keeps the URL clean for the common case (no `next` param specified
113
+ // at login time → no `?next=` on the change-password URL).
114
+ if (next === POST_LOGIN_DEFAULT) return FORCE_CHANGE_PASSWORD_PATH;
115
+ return `${FORCE_CHANGE_PASSWORD_PATH}?next=${encodeURIComponent(next)}`;
116
+ }
117
+ // Non-admin friend aiming at admin SPA: rewrite to /account/. We check
118
+ // both the literal `/admin` and the `/admin/...` prefix; safeNext has
119
+ // already normalized to a leading-`/` same-origin path, so a plain
120
+ // string prefix check is sufficient (no `//admin.evil.com/` shape can
121
+ // reach here).
122
+ if (!isFirstAdmin(db, user.id) && (next === "/admin" || next.startsWith("/admin/"))) {
123
+ return "/account/";
124
+ }
125
+ return next;
94
126
  }
95
127
 
96
128
  // --- /login ---------------------------------------------------------------
@@ -193,7 +225,7 @@ export async function handleAdminLoginPost(
193
225
  // to change the admin-typed default before continuing. Their original
194
226
  // `next` rides along on the change-password URL so the post-change
195
227
  // POST can land them at their intended destination.
196
- const target = loginRedirectTarget(user, next);
228
+ const target = loginRedirectTarget(db, user, next);
197
229
  return redirect(target, { "set-cookie": cookie });
198
230
  }
199
231
 
@@ -25,6 +25,16 @@
25
25
  * - this endpoint requires a valid `parachute_hub_session` cookie, which
26
26
  * was set by `/login` after a password check.
27
27
  *
28
+ * **First-admin gate (multi-user Phase 1 follow-up).** A valid session is
29
+ * necessary but NOT sufficient. The session must belong to the hub admin
30
+ * (the earliest-created user row, per `getFirstAdminId` in users.ts).
31
+ * Without this gate, any signed-in friend account created via PR 2's
32
+ * `/api/users` could hit this endpoint and walk away with a JWT carrying
33
+ * `parachute:host:admin` + `parachute:host:auth` — a full-admin privesc,
34
+ * since both scopes are the SPA's gate-bypass into vault provisioning,
35
+ * grant management, and the token registry. The SPA-side mirror is in
36
+ * `web/ui/src/lib/auth.ts`: 403 → redirect to `/account/`.
37
+ *
28
38
  * Tokens minted here are deliberately NOT persisted in the `tokens` table
29
39
  * (no refresh, no revocation tracking). They expire on their own; the SPA
30
40
  * re-fetches when the JWT is about to lapse.
@@ -39,6 +49,7 @@
39
49
  import type { Database } from "bun:sqlite";
40
50
  import { signAccessToken } from "./jwt-sign.ts";
41
51
  import { findSession, parseSessionCookie } from "./sessions.ts";
52
+ import { isFirstAdmin } from "./users.ts";
42
53
 
43
54
  /** Short TTL — page-snapshot threats can't carry the token forever. */
44
55
  export const HOST_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
@@ -64,6 +75,20 @@ export async function handleHostAdminToken(
64
75
  if (!session) {
65
76
  return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
66
77
  }
78
+ // First-admin gate. A friend account (non-first-admin user created via
79
+ // `/api/users`) holds a valid session but must not be able to mint
80
+ // host-admin scopes. Without this check, any signed-in friend hitting
81
+ // `GET /admin/host-admin-token` would walk away with a JWT carrying
82
+ // `parachute:host:admin` + `parachute:host:auth` — full admin access.
83
+ // The 403 here is mirrored on the SPA side in `web/ui/src/lib/auth.ts`:
84
+ // 403 → redirect to `/account/` (the friend's home).
85
+ if (!isFirstAdmin(deps.db, session.userId)) {
86
+ return jsonError(
87
+ 403,
88
+ "not_admin",
89
+ "host-admin token mint is restricted to the hub admin — your account home is at /account/",
90
+ );
91
+ }
67
92
  const minted = await signAccessToken(deps.db, {
68
93
  sub: session.userId,
69
94
  scopes: [...HOST_ADMIN_SCOPES],
@@ -18,10 +18,16 @@
18
18
  * masking a typo as a real (but unusable) credential. Resolved via the
19
19
  * already-built well-known doc — same source of truth the SPA's vault list
20
20
  * reads.
21
+ *
22
+ * Multi-user Phase 1 gate: the session must belong to the first admin (the
23
+ * single hub admin under the Phase 1 model — see `users.ts:isFirstAdmin`).
24
+ * Friends pinned to a vault use the OAuth flow to get vault:<name>:read/write
25
+ * for their assigned vault; they don't get vault admin via this endpoint.
21
26
  */
22
27
  import type { Database } from "bun:sqlite";
23
28
  import { signAccessToken } from "./jwt-sign.ts";
24
29
  import { findSession, parseSessionCookie } from "./sessions.ts";
30
+ import { isFirstAdmin } from "./users.ts";
25
31
 
26
32
  /** Short TTL — matches host-admin-token. SPA re-fetches on near-expiry. */
27
33
  export const VAULT_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
@@ -57,6 +63,17 @@ export async function handleVaultAdminToken(
57
63
  if (!session) {
58
64
  return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
59
65
  }
66
+ // Multi-user Phase 1 privesc gate (mirrors host-admin-token). vault:<name>:admin
67
+ // is the per-vault operator scope used by the vault admin SPA — friends pinned
68
+ // to a vault get vault:<name>:read/write via OAuth, never admin. Without this
69
+ // gate, any signed-in friend can mint a vault admin token for any vault.
70
+ if (!isFirstAdmin(deps.db, session.userId)) {
71
+ return jsonError(
72
+ 403,
73
+ "not_admin",
74
+ "vault admin token mint is restricted to the hub admin — your account home is at /account/",
75
+ );
76
+ }
60
77
  const scope = `vault:${vaultName}:admin`;
61
78
  // Per-vault audience: vault validates the JWT's `aud` claim against
62
79
  // `vault.<name>` derived from its own URL-bound config (vault src/auth.ts