@openparachute/hub 0.5.13 → 0.5.14-rc.10

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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -0,0 +1,547 @@
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
+ /**
152
+ * Build `<hub-origin>/vault/<name>/mcp` — the MCP endpoint a client connects
153
+ * to. Mirrors the SPA's `mcpEndpointFor` (McpConnectCard.tsx) and the
154
+ * wizard's `renderMcpTile`. The friend's `/account/` tile is server-rendered
155
+ * (no SPA), so we compute it here from the hub origin + vault name rather
156
+ * than the vault's well-known `url`.
157
+ *
158
+ * Exported for direct unit testing.
159
+ */
160
+ export function accountMcpEndpoint(trimmedOrigin: string, vaultName: string): string {
161
+ return `${trimmedOrigin}/vault/${vaultName}/mcp`;
162
+ }
163
+
164
+ /**
165
+ * The OAuth-path `claude mcp add` command — no token, triggers browser OAuth
166
+ * on first use. Same `parachute-<name>` server name as the SPA card and the
167
+ * wizard tile, so a friend and the operator end up with identically-named
168
+ * MCP servers. Exported for direct unit testing.
169
+ */
170
+ export function accountClaudeMcpAddCommand(trimmedOrigin: string, vaultName: string): string {
171
+ return `claude mcp add --transport http parachute-${vaultName} ${accountMcpEndpoint(
172
+ trimmedOrigin,
173
+ vaultName,
174
+ )}`;
175
+ }
176
+
177
+ function renderVaultCard(opts: VaultCardOpts): string {
178
+ const { assignedVaults, trimmedOrigin, isFirstAdmin } = opts;
179
+
180
+ if (assignedVaults.length > 0) {
181
+ // One vault tile per assignment (multi-user Phase 2 PR 2). Each tile
182
+ // carries the Notes "Open" CTA AND a server-rendered MCP connect block
183
+ // (endpoint + `claude mcp add` command, each with a copy button). The
184
+ // connect command is the OAuth path — no token, so a non-admin friend
185
+ // who can't run the SPA's host-admin mint still gets a working
186
+ // connect affordance (the first `claude mcp add` use opens a browser,
187
+ // signs them in, and approves the scope). This closes the multi-user
188
+ // gap where the friend tile only offered the external Notes link + a
189
+ // bare hub-origin string.
190
+ const heading = assignedVaults.length === 1 ? "<h2>Your vault</h2>" : "<h2>Your vaults</h2>";
191
+ const tiles = assignedVaults
192
+ .map((vaultName) => {
193
+ const safeVault = escapeHtml(vaultName);
194
+ const vaultUrlForAdd = encodeURIComponent(`${trimmedOrigin}/vault/${vaultName}`);
195
+ const endpoint = accountMcpEndpoint(trimmedOrigin, vaultName);
196
+ const addCmd = accountClaudeMcpAddCommand(trimmedOrigin, vaultName);
197
+ const safeEndpoint = escapeHtml(endpoint);
198
+ const safeAddCmd = escapeHtml(addCmd);
199
+ return `
200
+ <div class="vault-tile" data-testid="vault-tile" data-vault-name="${safeVault}">
201
+ <p class="vault-name"><strong>${safeVault}</strong></p>
202
+ <p>
203
+ <a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
204
+ target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
205
+ </p>
206
+ <div class="mcp-connect" data-testid="mcp-connect">
207
+ <p class="mcp-connect-label">Connect an MCP client (Claude Code, Claude.ai)</p>
208
+ <div class="mcp-field">
209
+ <span class="mcp-field-label">Endpoint</span>
210
+ <div class="copy-row">
211
+ <code data-testid="mcp-endpoint">${safeEndpoint}</code>
212
+ <button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
213
+ data-testid="copy-mcp-endpoint">Copy</button>
214
+ </div>
215
+ </div>
216
+ <div class="mcp-field">
217
+ <span class="mcp-field-label">Claude Code</span>
218
+ <div class="copy-row">
219
+ <code data-testid="mcp-add-command">${safeAddCmd}</code>
220
+ <button type="button" class="btn btn-copy" data-copy="${safeAddCmd}"
221
+ data-testid="copy-mcp-add-command">Copy</button>
222
+ </div>
223
+ </div>
224
+ <p class="mcp-connect-hint">No token needed — the command opens a browser to
225
+ sign you in to this hub and approve access on first use.</p>
226
+ </div>
227
+ </div>`;
228
+ })
229
+ .join("");
230
+ return `
231
+ <section class="section" data-testid="vault-card">
232
+ ${heading}
233
+ <p>Open Notes — the canonical browser UI for your vault${
234
+ assignedVaults.length === 1 ? "" : "s"
235
+ } — or connect an MCP client
236
+ (Claude Code, Claude.ai) with the command below. Either way you sign in to your
237
+ hub over HTTPS and approve access on the first connection.</p>
238
+ <div class="vault-tiles">${tiles}
239
+ </div>
240
+ </section>${COPY_SCRIPT}`;
241
+ }
242
+ if (isFirstAdmin) {
243
+ return `
244
+ <section class="section" data-testid="admin-card">
245
+ <h2>Your vault</h2>
246
+ <p>You're the hub administrator. Visit
247
+ <a href="/admin/">the admin surface</a> to manage vaults, users, and clients.</p>
248
+ </section>`;
249
+ }
250
+ // Defensive third branch — non-admin with no assigned vault. The
251
+ // /api/users path doesn't require a vault on create (admins can leave
252
+ // a new friend's vault list empty and fill it in later), so this
253
+ // state is reachable through normal flows — surface a clear "ask
254
+ // your admin" message.
255
+ return `
256
+ <section class="section" data-testid="no-vault-card">
257
+ <h2>Your vault</h2>
258
+ <p>Your account isn't assigned to a vault yet. Ask the hub operator
259
+ to assign one.</p>
260
+ </section>`;
261
+ }
262
+
263
+ interface AccountCardOpts {
264
+ username: string;
265
+ csrfToken: string;
266
+ }
267
+
268
+ function renderAccountCard(opts: AccountCardOpts): string {
269
+ const { username, csrfToken } = opts;
270
+ return `
271
+ <section class="section" data-testid="account-card">
272
+ <h2>Account</h2>
273
+ <dl class="kv">
274
+ <dt>Username</dt>
275
+ <dd><code>${username}</code></dd>
276
+ </dl>
277
+ <p>
278
+ <a class="account-action" href="/account/change-password" data-testid="change-password-link">Change password →</a>
279
+ </p>
280
+ <form method="POST" action="/logout" class="signout-form" data-testid="signout-form">
281
+ ${renderCsrfHiddenInput(csrfToken)}
282
+ <button type="submit" class="btn btn-secondary">Sign out</button>
283
+ </form>
284
+ </section>`;
285
+ }
286
+
287
+ // --- copy-button script ---------------------------------------------------
288
+ //
289
+ // Tiny inline progressive-enhancement script for the per-tile copy buttons.
290
+ // Delegated click handler reads the command/endpoint from the button's
291
+ // `data-copy` attribute and writes it to the clipboard, flashing "Copied ✓"
292
+ // for 2s. No-ops gracefully when the Clipboard API is unavailable (insecure
293
+ // context, older browser) — the command text stays selectable in the
294
+ // codebox. Mirrors the SPA `CopyButton` posture (McpConnectCard.tsx) for a
295
+ // surface that has no React. Only emitted on the assigned-vault branch
296
+ // (where copy buttons exist).
297
+ const COPY_SCRIPT = `
298
+ <script>
299
+ (function () {
300
+ document.addEventListener('click', function (e) {
301
+ var btn = e.target && e.target.closest ? e.target.closest('[data-copy]') : null;
302
+ if (!btn) return;
303
+ var value = btn.getAttribute('data-copy') || '';
304
+ if (typeof navigator === 'undefined' || !navigator.clipboard) return;
305
+ navigator.clipboard.writeText(value).then(function () {
306
+ var original = btn.textContent;
307
+ btn.textContent = 'Copied \\u2713';
308
+ setTimeout(function () { btn.textContent = original; }, 2000);
309
+ }).catch(function () { /* insecure context — leave selectable */ });
310
+ });
311
+ })();
312
+ </script>`;
313
+
314
+ // --- styles ---------------------------------------------------------------
315
+ //
316
+ // Same brand palette + font stack as account-change-password-ui.ts so the
317
+ // `/account/*` family is visually cohesive. Extra rules (.section, .kv,
318
+ // .vault-name, .mcp-connect, .copy-row) describe the new card + MCP
319
+ // connect-block shapes this page introduces.
320
+
321
+ const STYLES = `
322
+ *, *::before, *::after { box-sizing: border-box; }
323
+ html, body { margin: 0; padding: 0; }
324
+ body {
325
+ font-family: ${FONT_SANS};
326
+ background: ${PALETTE.bg};
327
+ color: ${PALETTE.fg};
328
+ line-height: 1.55;
329
+ min-height: 100vh;
330
+ -webkit-font-smoothing: antialiased;
331
+ -moz-osx-font-smoothing: grayscale;
332
+ }
333
+ main {
334
+ display: flex;
335
+ align-items: flex-start;
336
+ justify-content: center;
337
+ min-height: 100vh;
338
+ padding: 2rem 1.5rem;
339
+ }
340
+ .card {
341
+ width: 100%;
342
+ max-width: 34rem;
343
+ background: ${PALETTE.cardBg};
344
+ border: 1px solid ${PALETTE.border};
345
+ border-radius: 12px;
346
+ padding: 2rem 1.75rem;
347
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
348
+ }
349
+ .card-header { margin-bottom: 1.5rem; }
350
+ .brand {
351
+ display: flex;
352
+ align-items: center;
353
+ gap: 0.5rem;
354
+ color: ${PALETTE.accent};
355
+ font-weight: 500;
356
+ font-size: 0.95rem;
357
+ margin-bottom: 1.25rem;
358
+ }
359
+ .brand-mark { display: inline-flex; line-height: 0; }
360
+ .brand-mark svg { width: 20px; height: 20px; }
361
+ .brand-name { letter-spacing: 0.01em; }
362
+ .brand-tag {
363
+ text-transform: uppercase;
364
+ letter-spacing: 0.06em;
365
+ font-size: 0.7rem;
366
+ color: ${PALETTE.fgMuted};
367
+ border: 1px solid ${PALETTE.borderLight};
368
+ padding: 0.05rem 0.4rem;
369
+ border-radius: 999px;
370
+ }
371
+ h1 {
372
+ font-family: ${FONT_SERIF};
373
+ font-weight: 400;
374
+ font-size: 1.6rem;
375
+ line-height: 1.2;
376
+ margin: 0 0 0.4rem;
377
+ color: ${PALETTE.fg};
378
+ }
379
+ h2 {
380
+ font-family: ${FONT_SERIF};
381
+ font-weight: 400;
382
+ font-size: 1.15rem;
383
+ line-height: 1.25;
384
+ margin: 0 0 0.6rem;
385
+ color: ${PALETTE.fg};
386
+ }
387
+ .subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
388
+
389
+ .section {
390
+ border-top: 1px solid ${PALETTE.borderLight};
391
+ padding-top: 1.25rem;
392
+ margin-top: 1.25rem;
393
+ }
394
+ .section p { margin: 0.4rem 0; }
395
+ .vault-name {
396
+ font-family: ${FONT_MONO};
397
+ font-size: 1rem;
398
+ margin: 0 0 0.6rem;
399
+ }
400
+ .vault-name strong { color: ${PALETTE.fg}; font-weight: 600; }
401
+ .vault-tiles {
402
+ display: flex;
403
+ flex-direction: column;
404
+ gap: 0.75rem;
405
+ margin: 0.75rem 0 0.4rem;
406
+ }
407
+ .vault-tile {
408
+ border: 1px solid ${PALETTE.borderLight};
409
+ border-radius: 8px;
410
+ padding: 0.75rem 1rem;
411
+ background: ${PALETTE.bgSoft};
412
+ }
413
+ .vault-tile p { margin: 0.2rem 0; }
414
+ .vault-tile p:last-child { margin-top: 0.5rem; }
415
+
416
+ .mcp-connect {
417
+ margin-top: 0.75rem;
418
+ padding-top: 0.6rem;
419
+ border-top: 1px solid ${PALETTE.borderLight};
420
+ }
421
+ .mcp-connect-label {
422
+ font-size: 0.85rem;
423
+ font-weight: 500;
424
+ color: ${PALETTE.fg};
425
+ margin: 0 0 0.5rem;
426
+ }
427
+ .mcp-field { margin: 0.5rem 0; }
428
+ .mcp-field-label {
429
+ display: block;
430
+ font-size: 0.7rem;
431
+ text-transform: uppercase;
432
+ letter-spacing: 0.06em;
433
+ color: ${PALETTE.fgMuted};
434
+ font-family: ${FONT_MONO};
435
+ margin-bottom: 0.2rem;
436
+ }
437
+ .copy-row {
438
+ display: flex;
439
+ align-items: center;
440
+ gap: 0.5rem;
441
+ background: ${PALETTE.cardBg};
442
+ border: 1px solid ${PALETTE.borderLight};
443
+ border-radius: 6px;
444
+ padding: 0.4rem 0.5rem;
445
+ }
446
+ .copy-row code {
447
+ flex: 1 1 auto;
448
+ overflow-x: auto;
449
+ white-space: nowrap;
450
+ background: transparent;
451
+ padding: 0;
452
+ font-size: 0.82rem;
453
+ }
454
+ .btn-copy {
455
+ flex: 0 0 auto;
456
+ font-size: 0.8rem;
457
+ padding: 0.3rem 0.7rem;
458
+ background: transparent;
459
+ color: ${PALETTE.fg};
460
+ border-color: ${PALETTE.border};
461
+ }
462
+ .btn-copy:hover { background: ${PALETTE.bgSoft}; border-color: ${PALETTE.accent}; }
463
+ .mcp-connect-hint {
464
+ font-size: 0.82rem;
465
+ color: ${PALETTE.fgMuted};
466
+ margin: 0.4rem 0 0;
467
+ }
468
+ code {
469
+ font-family: ${FONT_MONO};
470
+ background: ${PALETTE.bgSoft};
471
+ padding: 0.1rem 0.4rem;
472
+ border-radius: 4px;
473
+ font-size: 0.88em;
474
+ }
475
+
476
+ .kv { margin: 0 0 0.6rem; padding: 0; }
477
+ .kv dt {
478
+ font-size: 0.78rem;
479
+ text-transform: uppercase;
480
+ letter-spacing: 0.06em;
481
+ color: ${PALETTE.fgMuted};
482
+ font-family: ${FONT_MONO};
483
+ margin-top: 0.4rem;
484
+ }
485
+ .kv dd { margin: 0.15rem 0 0.4rem; }
486
+
487
+ .btn {
488
+ font: inherit;
489
+ font-weight: 500;
490
+ padding: 0.55rem 1rem;
491
+ border-radius: 6px;
492
+ border: 1px solid transparent;
493
+ cursor: pointer;
494
+ text-decoration: none;
495
+ display: inline-block;
496
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
497
+ }
498
+ .btn-primary {
499
+ background: ${PALETTE.accent};
500
+ color: ${PALETTE.cardBg};
501
+ }
502
+ .btn-primary:hover { background: ${PALETTE.accentHover}; }
503
+ .btn-secondary {
504
+ background: transparent;
505
+ color: ${PALETTE.fg};
506
+ border-color: ${PALETTE.border};
507
+ }
508
+ .btn-secondary:hover {
509
+ background: ${PALETTE.bgSoft};
510
+ border-color: ${PALETTE.accent};
511
+ }
512
+
513
+ .signout-form { margin: 0.8rem 0 0; }
514
+
515
+ .account-action {
516
+ color: ${PALETTE.accent};
517
+ text-decoration: none;
518
+ font-weight: 500;
519
+ font-size: 0.95rem;
520
+ }
521
+ .account-action:hover { color: ${PALETTE.accentHover}; text-decoration: underline; }
522
+
523
+ a { color: ${PALETTE.accent}; }
524
+ a:hover { color: ${PALETTE.accentHover}; }
525
+
526
+ @media (max-width: 480px) {
527
+ main { padding: 1rem 0.75rem; }
528
+ .card { padding: 1.5rem 1.25rem; border-radius: 10px; }
529
+ h1 { font-size: 1.4rem; }
530
+ h2 { font-size: 1.05rem; }
531
+ }
532
+
533
+ @media (prefers-color-scheme: dark) {
534
+ body { background: #1a1815; color: #e8e4dc; }
535
+ .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
536
+ h1, h2 { color: #f0ece4; }
537
+ .subtitle, .kv dt, .mcp-field-label, .mcp-connect-hint { color: #a8a29a; }
538
+ .vault-name strong, .mcp-connect-label { color: #f0ece4; }
539
+ code { background: #1f1c18; color: #e8e4dc; }
540
+ .copy-row code { background: transparent; }
541
+ .section, .mcp-connect { border-top-color: #3a362f; }
542
+ .brand-tag { border-color: #3a362f; color: #a8a29a; }
543
+ .copy-row { background: #1f1c18; border-color: #3a362f; }
544
+ .btn-secondary, .btn-copy { color: #e8e4dc; border-color: #3a362f; }
545
+ .btn-secondary:hover, .btn-copy:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
546
+ }
547
+ `;
@@ -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],