@openparachute/hub 0.5.13 → 0.5.14-rc.1

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 (37) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +140 -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.test.ts +32 -32
  8. package/src/__tests__/api-users.test.ts +192 -2
  9. package/src/__tests__/chrome-strip.test.ts +15 -15
  10. package/src/__tests__/hub-server.test.ts +23 -23
  11. package/src/__tests__/notes-redirect.test.ts +20 -20
  12. package/src/__tests__/services-manifest.test.ts +40 -40
  13. package/src/__tests__/setup-wizard.test.ts +157 -19
  14. package/src/__tests__/setup.test.ts +1 -1
  15. package/src/__tests__/status.test.ts +39 -0
  16. package/src/__tests__/users.test.ts +261 -0
  17. package/src/__tests__/well-known.test.ts +9 -9
  18. package/src/account-home-ui.ts +404 -0
  19. package/src/admin-handlers.ts +49 -17
  20. package/src/admin-host-admin-token.ts +25 -0
  21. package/src/admin-vault-admin-token.ts +17 -0
  22. package/src/api-account.ts +72 -6
  23. package/src/api-modules.ts +3 -3
  24. package/src/api-users.ts +173 -12
  25. package/src/chrome-strip.ts +6 -6
  26. package/src/commands/status.ts +10 -1
  27. package/src/help.ts +2 -2
  28. package/src/hub-server.ts +50 -10
  29. package/src/hub-settings.ts +2 -2
  30. package/src/hub.ts +6 -6
  31. package/src/notes-redirect.ts +5 -5
  32. package/src/service-spec.ts +39 -18
  33. package/src/setup-wizard.ts +335 -28
  34. package/src/users.ts +112 -0
  35. package/web/ui/dist/assets/index-Qf56GsGm.js +61 -0
  36. package/web/ui/dist/index.html +1 -1
  37. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -0,0 +1,404 @@
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
+ * Assigned vault instance name, or `null` for users with no per-vault
82
+ * restriction. In Phase 1 a `null` assigned vault means "admin posture"
83
+ * (the OAuth issuer mints tokens for any requested vault) — combined
84
+ * with `isFirstAdmin: true` this is the hub administrator's account.
85
+ */
86
+ assignedVault: string | null;
87
+ /** Force-change-password completion flag. Currently informational only. */
88
+ passwordChanged: boolean;
89
+ /**
90
+ * Hub origin (no trailing slash) — the canonical URL operators connect
91
+ * their MCP / Surface clients to. Used both in the Notes "Open" CTA
92
+ * (encoded as `?url=...` on `notes.parachute.computer/add`) and as
93
+ * inline-code text for the "custom client" disclosure.
94
+ */
95
+ hubOrigin: string;
96
+ /**
97
+ * Whether the signed-in user is the hub administrator (the
98
+ * earliest-created users row). Drives the `assignedVault: null`
99
+ * branch — admins see an "open admin" affordance; non-admins (a
100
+ * Phase 1 shape that shouldn't normally occur — admins land with
101
+ * `assignedVault: null`, friends always have one set) see a
102
+ * defensive "ask the operator" message.
103
+ */
104
+ isFirstAdmin: boolean;
105
+ /**
106
+ * CSRF token for the sign-out form. Same pattern as
107
+ * `account-change-password-ui.ts` — POSTs to `/logout` are CSRF-gated
108
+ * to keep a cross-origin form from logging the user out.
109
+ */
110
+ csrfToken: string;
111
+ }
112
+
113
+ export function renderAccountHome(opts: RenderAccountHomeOpts): string {
114
+ const { username, assignedVault, hubOrigin, isFirstAdmin, csrfToken } = opts;
115
+ const safeUsername = escapeHtml(username);
116
+ // Origin is already canonicalized by the handler (trailing slash stripped),
117
+ // but defensively re-trim so a stray slash here doesn't break the CTA href.
118
+ const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
119
+
120
+ const vaultCard = renderVaultCard({
121
+ assignedVault,
122
+ trimmedOrigin,
123
+ isFirstAdmin,
124
+ });
125
+
126
+ const accountCard = renderAccountCard({
127
+ username: safeUsername,
128
+ csrfToken,
129
+ });
130
+
131
+ const body = `
132
+ <div class="card">
133
+ <div class="card-header">
134
+ ${header()}
135
+ <h1>Welcome, ${safeUsername}</h1>
136
+ <p class="subtitle">Your Parachute account home.</p>
137
+ </div>
138
+ ${vaultCard}
139
+ ${accountCard}
140
+ </div>`;
141
+ return baseDocument(`${username} — Parachute`, body);
142
+ }
143
+
144
+ interface VaultCardOpts {
145
+ assignedVault: string | null;
146
+ trimmedOrigin: string;
147
+ isFirstAdmin: boolean;
148
+ }
149
+
150
+ function renderVaultCard(opts: VaultCardOpts): string {
151
+ const { assignedVault, trimmedOrigin, isFirstAdmin } = opts;
152
+
153
+ if (assignedVault !== null) {
154
+ const safeVault = escapeHtml(assignedVault);
155
+ // Mirror setup-wizard.ts:renderStartUsingTile — vault URLs are
156
+ // `${hubOrigin}/vault/<name>`. URL-encode defensively even though
157
+ // current vault-name validation rules mean it's a no-op.
158
+ const vaultUrlForAdd = encodeURIComponent(`${trimmedOrigin}/vault/${assignedVault}`);
159
+ const hubOriginDisplay = escapeHtml(trimmedOrigin);
160
+ return `
161
+ <section class="section" data-testid="vault-card">
162
+ <h2>Your vault</h2>
163
+ <p class="vault-name"><strong>${safeVault}</strong></p>
164
+ <p>Open Notes — the canonical browser UI for your vault. It connects to your
165
+ hub over HTTPS and remembers your URL after the first OAuth.</p>
166
+ <p>
167
+ <a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
168
+ target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
169
+ </p>
170
+ <details class="custom-client">
171
+ <summary>Use a custom client</summary>
172
+ <p>To connect a custom Surface or MCP client running on your own machine,
173
+ point it at the hub origin below and run through OAuth — the hub will
174
+ ask you to consent.</p>
175
+ <p class="hub-origin-line">Hub origin: <code>${hubOriginDisplay}</code></p>
176
+ </details>
177
+ </section>`;
178
+ }
179
+ if (isFirstAdmin) {
180
+ return `
181
+ <section class="section" data-testid="admin-card">
182
+ <h2>Your vault</h2>
183
+ <p>You're the hub administrator. Visit
184
+ <a href="/admin/">the admin surface</a> to manage vaults, users, and clients.</p>
185
+ </section>`;
186
+ }
187
+ // Defensive third branch — non-admin with no assigned vault. PR 2's
188
+ // /api/users path always assigns a vault on create, so a friend reaching
189
+ // this state would have to come from a hand-edited row or a migration
190
+ // race. Surface a clear message instead of a blank card.
191
+ return `
192
+ <section class="section" data-testid="no-vault-card">
193
+ <h2>Your vault</h2>
194
+ <p>Your account isn't assigned to a vault yet. Ask the hub operator
195
+ to assign one.</p>
196
+ </section>`;
197
+ }
198
+
199
+ interface AccountCardOpts {
200
+ username: string;
201
+ csrfToken: string;
202
+ }
203
+
204
+ function renderAccountCard(opts: AccountCardOpts): string {
205
+ const { username, csrfToken } = opts;
206
+ return `
207
+ <section class="section" data-testid="account-card">
208
+ <h2>Account</h2>
209
+ <dl class="kv">
210
+ <dt>Username</dt>
211
+ <dd><code>${username}</code></dd>
212
+ </dl>
213
+ <p>
214
+ <a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
215
+ </p>
216
+ <form method="POST" action="/logout" class="signout-form" data-testid="signout-form">
217
+ ${renderCsrfHiddenInput(csrfToken)}
218
+ <button type="submit" class="btn btn-secondary">Sign out</button>
219
+ </form>
220
+ </section>`;
221
+ }
222
+
223
+ // --- styles ---------------------------------------------------------------
224
+ //
225
+ // Same brand palette + font stack as account-change-password-ui.ts so the
226
+ // `/account/*` family is visually cohesive. Extra rules (.section, .kv,
227
+ // .vault-name, .custom-client, .hub-origin-line) describe the new card +
228
+ // disclosure shapes this page introduces.
229
+
230
+ const STYLES = `
231
+ *, *::before, *::after { box-sizing: border-box; }
232
+ html, body { margin: 0; padding: 0; }
233
+ body {
234
+ font-family: ${FONT_SANS};
235
+ background: ${PALETTE.bg};
236
+ color: ${PALETTE.fg};
237
+ line-height: 1.55;
238
+ min-height: 100vh;
239
+ -webkit-font-smoothing: antialiased;
240
+ -moz-osx-font-smoothing: grayscale;
241
+ }
242
+ main {
243
+ display: flex;
244
+ align-items: flex-start;
245
+ justify-content: center;
246
+ min-height: 100vh;
247
+ padding: 2rem 1.5rem;
248
+ }
249
+ .card {
250
+ width: 100%;
251
+ max-width: 34rem;
252
+ background: ${PALETTE.cardBg};
253
+ border: 1px solid ${PALETTE.border};
254
+ border-radius: 12px;
255
+ padding: 2rem 1.75rem;
256
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
257
+ }
258
+ .card-header { margin-bottom: 1.5rem; }
259
+ .brand {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: 0.5rem;
263
+ color: ${PALETTE.accent};
264
+ font-weight: 500;
265
+ font-size: 0.95rem;
266
+ margin-bottom: 1.25rem;
267
+ }
268
+ .brand-mark { display: inline-flex; line-height: 0; }
269
+ .brand-mark svg { width: 20px; height: 20px; }
270
+ .brand-name { letter-spacing: 0.01em; }
271
+ .brand-tag {
272
+ text-transform: uppercase;
273
+ letter-spacing: 0.06em;
274
+ font-size: 0.7rem;
275
+ color: ${PALETTE.fgMuted};
276
+ border: 1px solid ${PALETTE.borderLight};
277
+ padding: 0.05rem 0.4rem;
278
+ border-radius: 999px;
279
+ }
280
+ h1 {
281
+ font-family: ${FONT_SERIF};
282
+ font-weight: 400;
283
+ font-size: 1.6rem;
284
+ line-height: 1.2;
285
+ margin: 0 0 0.4rem;
286
+ color: ${PALETTE.fg};
287
+ }
288
+ h2 {
289
+ font-family: ${FONT_SERIF};
290
+ font-weight: 400;
291
+ font-size: 1.15rem;
292
+ line-height: 1.25;
293
+ margin: 0 0 0.6rem;
294
+ color: ${PALETTE.fg};
295
+ }
296
+ .subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
297
+
298
+ .section {
299
+ border-top: 1px solid ${PALETTE.borderLight};
300
+ padding-top: 1.25rem;
301
+ margin-top: 1.25rem;
302
+ }
303
+ .section p { margin: 0.4rem 0; }
304
+ .vault-name {
305
+ font-family: ${FONT_MONO};
306
+ font-size: 1rem;
307
+ margin: 0 0 0.6rem;
308
+ }
309
+ .vault-name strong { color: ${PALETTE.fg}; font-weight: 600; }
310
+
311
+ .custom-client { margin-top: 0.8rem; }
312
+ .custom-client summary {
313
+ cursor: pointer;
314
+ font-size: 0.85rem;
315
+ color: ${PALETTE.fgMuted};
316
+ font-family: ${FONT_MONO};
317
+ padding: 0.25rem 0;
318
+ user-select: none;
319
+ }
320
+ .custom-client[open] summary { color: ${PALETTE.fg}; }
321
+ .custom-client p {
322
+ font-size: 0.9rem;
323
+ color: ${PALETTE.fgMuted};
324
+ margin: 0.4rem 0;
325
+ }
326
+ .hub-origin-line { font-family: ${FONT_MONO}; }
327
+ code {
328
+ font-family: ${FONT_MONO};
329
+ background: ${PALETTE.bgSoft};
330
+ padding: 0.1rem 0.4rem;
331
+ border-radius: 4px;
332
+ font-size: 0.88em;
333
+ }
334
+
335
+ .kv { margin: 0 0 0.6rem; padding: 0; }
336
+ .kv dt {
337
+ font-size: 0.78rem;
338
+ text-transform: uppercase;
339
+ letter-spacing: 0.06em;
340
+ color: ${PALETTE.fgMuted};
341
+ font-family: ${FONT_MONO};
342
+ margin-top: 0.4rem;
343
+ }
344
+ .kv dd { margin: 0.15rem 0 0.4rem; }
345
+
346
+ .btn {
347
+ font: inherit;
348
+ font-weight: 500;
349
+ padding: 0.55rem 1rem;
350
+ border-radius: 6px;
351
+ border: 1px solid transparent;
352
+ cursor: pointer;
353
+ text-decoration: none;
354
+ display: inline-block;
355
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
356
+ }
357
+ .btn-primary {
358
+ background: ${PALETTE.accent};
359
+ color: ${PALETTE.cardBg};
360
+ }
361
+ .btn-primary:hover { background: ${PALETTE.accentHover}; }
362
+ .btn-secondary {
363
+ background: transparent;
364
+ color: ${PALETTE.fg};
365
+ border-color: ${PALETTE.border};
366
+ }
367
+ .btn-secondary:hover {
368
+ background: ${PALETTE.bgSoft};
369
+ border-color: ${PALETTE.accent};
370
+ }
371
+
372
+ .signout-form { margin: 0.8rem 0 0; }
373
+
374
+ .account-action {
375
+ color: ${PALETTE.accent};
376
+ text-decoration: none;
377
+ font-weight: 500;
378
+ font-size: 0.95rem;
379
+ }
380
+ .account-action:hover { color: ${PALETTE.accentHover}; text-decoration: underline; }
381
+
382
+ a { color: ${PALETTE.accent}; }
383
+ a:hover { color: ${PALETTE.accentHover}; }
384
+
385
+ @media (max-width: 480px) {
386
+ main { padding: 1rem 0.75rem; }
387
+ .card { padding: 1.5rem 1.25rem; border-radius: 10px; }
388
+ h1 { font-size: 1.4rem; }
389
+ h2 { font-size: 1.05rem; }
390
+ }
391
+
392
+ @media (prefers-color-scheme: dark) {
393
+ body { background: #1a1815; color: #e8e4dc; }
394
+ .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
395
+ h1, h2 { color: #f0ece4; }
396
+ .subtitle, .kv dt, .custom-client summary { color: #a8a29a; }
397
+ .vault-name strong { color: #f0ece4; }
398
+ code { background: #1f1c18; color: #e8e4dc; }
399
+ .section { border-top-color: #3a362f; }
400
+ .brand-tag { border-color: #3a362f; color: #a8a29a; }
401
+ .btn-secondary { color: #e8e4dc; border-color: #3a362f; }
402
+ .btn-secondary:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
403
+ }
404
+ `;
@@ -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