@openparachute/hub 0.5.10-rc.6 → 0.5.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +139 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-server.test.ts +29 -4
  10. package/src/__tests__/hub-settings.test.ts +377 -0
  11. package/src/__tests__/hub.test.ts +17 -0
  12. package/src/__tests__/jwt-sign.test.ts +59 -0
  13. package/src/__tests__/oauth-handlers.test.ts +1059 -10
  14. package/src/__tests__/oauth-ui.test.ts +210 -0
  15. package/src/__tests__/scope-explanations.test.ts +23 -0
  16. package/src/__tests__/serve.test.ts +8 -1
  17. package/src/__tests__/setup-wizard.test.ts +1500 -13
  18. package/src/__tests__/supervisor.test.ts +76 -2
  19. package/src/__tests__/users.test.ts +196 -0
  20. package/src/__tests__/vault-name.test.ts +79 -0
  21. package/src/__tests__/vault-names.test.ts +172 -0
  22. package/src/account-change-password-ui.ts +379 -0
  23. package/src/admin-handlers.ts +68 -2
  24. package/src/admin-host-admin-token.ts +5 -0
  25. package/src/admin-vault-admin-token.ts +7 -0
  26. package/src/api-account.ts +443 -0
  27. package/src/api-mint-token.ts +6 -0
  28. package/src/api-modules-ops.ts +30 -6
  29. package/src/api-modules.ts +101 -0
  30. package/src/api-users.ts +393 -0
  31. package/src/commands/auth.ts +10 -1
  32. package/src/commands/serve.ts +5 -1
  33. package/src/cors.ts +263 -0
  34. package/src/hub-db.ts +54 -0
  35. package/src/hub-server.ts +162 -18
  36. package/src/hub-settings.ts +259 -0
  37. package/src/hub.ts +34 -9
  38. package/src/jwt-sign.ts +17 -1
  39. package/src/oauth-handlers.ts +256 -29
  40. package/src/oauth-ui.ts +451 -38
  41. package/src/operator-token.ts +4 -0
  42. package/src/scope-explanations.ts +26 -1
  43. package/src/setup-wizard.ts +1100 -56
  44. package/src/supervisor.ts +66 -14
  45. package/src/users.ts +210 -3
  46. package/src/vault-name.ts +71 -0
  47. package/src/vault-names.ts +57 -0
  48. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  49. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  50. package/web/ui/dist/index.html +2 -2
  51. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Server-rendered HTML for `/account/change-password` — the user's
3
+ * self-service password rotation surface, and the landing page for the
4
+ * force-change-password redirect from `/login` (multi-user Phase 1 PR 3,
5
+ * design 2026-05-20-multi-user-phase-1.md §sign-in flow change).
6
+ *
7
+ * Two modes share the page:
8
+ *
9
+ * - **first-time login** (`mode: "first-time"`): the just-logged-in user
10
+ * has `password_changed: false`. Heading reads "First-time login: please
11
+ * choose a new password" and the page explains why the redirect fired
12
+ * (the admin typed the default; you should pick your own).
13
+ * - **rotate** (`mode: "rotate"`): a signed-in user navigated here on
14
+ * their own to change their password. Heading reads "Change your
15
+ * password" — no force-redirect framing.
16
+ *
17
+ * The handler decides which mode to render at request time by reading
18
+ * the user's `passwordChanged` flag; this file is the pure renderer.
19
+ *
20
+ * Same chrome family as `admin-login-ui.ts` (`/login`) — inline CSS,
21
+ * no third-party fonts, no SPA bundle. The page works without JS;
22
+ * client-side validation is a fast-feedback layer on top of the server-
23
+ * side `validatePassword` + match-confirm + current-≠-new checks.
24
+ */
25
+ import { renderCsrfHiddenInput } from "./csrf.ts";
26
+ import { escapeHtml } from "./oauth-ui.ts";
27
+ import { PASSWORD_MIN_LEN } from "./users.ts";
28
+
29
+ // --- shared chrome --------------------------------------------------------
30
+ //
31
+ // Palette + font stack mirror `admin-login-ui.ts` so the change-password
32
+ // surface visually belongs to the same "Parachute pre-auth + thin-auth"
33
+ // surface family as /login. A small Phase-2 polish opportunity is to
34
+ // extract this into a shared `auth-ui-chrome.ts`; for now duplication is
35
+ // cheap and keeps the surfaces independently editable.
36
+
37
+ const PALETTE = {
38
+ bg: "#faf8f4",
39
+ bgSoft: "#f3f0ea",
40
+ fg: "#2c2a26",
41
+ fgMuted: "#6b6860",
42
+ fgDim: "#9a9690",
43
+ accent: "#4a7c59",
44
+ accentHover: "#3d6849",
45
+ accentSoft: "rgba(74, 124, 89, 0.08)",
46
+ border: "#e4e0d8",
47
+ borderLight: "#ece9e2",
48
+ cardBg: "#ffffff",
49
+ danger: "#a3392b",
50
+ dangerSoft: "rgba(163, 57, 43, 0.08)",
51
+ success: "#3d6849",
52
+ successSoft: "rgba(61, 104, 73, 0.08)",
53
+ } as const;
54
+
55
+ const FONT_SERIF = `Georgia, "Times New Roman", serif`;
56
+ const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
57
+ const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
58
+
59
+ function escapeAttr(s: string): string {
60
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
61
+ }
62
+
63
+ function baseDocument(title: string, body: string): string {
64
+ return `<!doctype html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="utf-8" />
68
+ <title>${escapeHtml(title)}</title>
69
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
70
+ <meta name="referrer" content="no-referrer" />
71
+ <style>${STYLES}</style>
72
+ </head>
73
+ <body>
74
+ <main>
75
+ ${body}
76
+ </main>
77
+ </body>
78
+ </html>`;
79
+ }
80
+
81
+ function header(): string {
82
+ return `
83
+ <div class="brand">
84
+ <span class="brand-mark">⌬</span>
85
+ <span class="brand-name">Parachute</span>
86
+ <span class="brand-tag">account</span>
87
+ </div>`;
88
+ }
89
+
90
+ // --- /account/change-password ---------------------------------------------
91
+
92
+ export type ChangePasswordMode = "first-time" | "rotate";
93
+
94
+ export interface RenderChangePasswordProps {
95
+ mode: ChangePasswordMode;
96
+ csrfToken: string;
97
+ /** The signed-in user's display name (for the "signed in as <X>" line). */
98
+ username: string;
99
+ /** Where the POST handler should redirect on success (same-origin path). */
100
+ next: string;
101
+ /** Inline error to surface above the form after a failed POST. */
102
+ errorMessage?: string;
103
+ // NOTE: unused in Phase 1 (POST always redirects on success). Reserved
104
+ // for Phase 2 self-service profile flow that may rotate-in-place and
105
+ // re-render with a success banner.
106
+ /** Render a success banner — used by the rotate flow when the user
107
+ * re-renders the form after a successful change (Phase 2; for now
108
+ * the POST success path always redirects so this never fires). */
109
+ successMessage?: string;
110
+ }
111
+
112
+ export function renderChangePassword(props: RenderChangePasswordProps): string {
113
+ const { mode, csrfToken, username, next, errorMessage, successMessage } = props;
114
+ const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
115
+ const success = successMessage
116
+ ? `<p class="success-banner">${escapeHtml(successMessage)}</p>`
117
+ : "";
118
+
119
+ const title =
120
+ mode === "first-time" ? "First-time login: choose a new password" : "Change your password";
121
+ const subtitle =
122
+ mode === "first-time"
123
+ ? "An admin set a default password for your account. Please pick your own before continuing — only you should know it."
124
+ : "Pick a new password for your account.";
125
+
126
+ const body = `
127
+ <div class="card">
128
+ <div class="card-header">
129
+ ${header()}
130
+ <h1>${escapeHtml(title)}</h1>
131
+ <p class="subtitle">${escapeHtml(subtitle)}</p>
132
+ <p class="signed-in">Signed in as <strong>${escapeHtml(username)}</strong>.</p>
133
+ </div>
134
+ ${success}
135
+ ${error}
136
+ <form method="POST" action="/account/change-password" class="auth-form" id="change-form">
137
+ ${renderCsrfHiddenInput(csrfToken)}
138
+ <input type="hidden" name="next" value="${escapeAttr(next)}" />
139
+ <label class="field">
140
+ <span class="field-label">Current password</span>
141
+ <input type="password" name="current_password" autocomplete="current-password"
142
+ autofocus required />
143
+ </label>
144
+ <label class="field">
145
+ <span class="field-label">New password</span>
146
+ <input type="password" name="new_password" autocomplete="new-password"
147
+ required minlength="${PASSWORD_MIN_LEN}" id="new-password" />
148
+ <span class="field-hint">at least ${PASSWORD_MIN_LEN} characters — a passphrase is fine</span>
149
+ </label>
150
+ <label class="field">
151
+ <span class="field-label">Confirm new password</span>
152
+ <input type="password" name="new_password_confirm" autocomplete="new-password"
153
+ required minlength="${PASSWORD_MIN_LEN}" id="new-password-confirm" />
154
+ <span class="field-hint" id="confirm-hint"></span>
155
+ </label>
156
+ <button type="submit" class="btn btn-primary">Change password</button>
157
+ </form>
158
+ </div>
159
+ <script>${CLIENT_VALIDATION_JS}</script>`;
160
+ const pageTitle =
161
+ mode === "first-time" ? "First-time login — Parachute" : "Change password — Parachute";
162
+ return baseDocument(pageTitle, body);
163
+ }
164
+
165
+ // --- client-side validation -----------------------------------------------
166
+ //
167
+ // Fast-feedback only — the server-side handler is the authority. Mirrors
168
+ // the three server checks: minimum length, new === confirm, current ≠ new.
169
+ // Without JS the form posts normally and the server re-renders with an
170
+ // inline error message; with JS the user sees "passwords don't match" or
171
+ // "new password must differ from current" inline before submitting.
172
+
173
+ const CLIENT_VALIDATION_JS = `
174
+ (function () {
175
+ var form = document.getElementById("change-form");
176
+ if (!form) return;
177
+ var newPw = document.getElementById("new-password");
178
+ var confirm = document.getElementById("new-password-confirm");
179
+ var current = form.querySelector('input[name="current_password"]');
180
+ var hint = document.getElementById("confirm-hint");
181
+ function check() {
182
+ if (!hint) return;
183
+ if (!confirm || !newPw || !current) return;
184
+ if (confirm.value.length === 0) { hint.textContent = ""; return; }
185
+ if (confirm.value !== newPw.value) {
186
+ hint.textContent = "Passwords do not match";
187
+ hint.style.color = "${PALETTE.danger}";
188
+ } else if (current.value.length > 0 && current.value === newPw.value) {
189
+ hint.textContent = "New password must differ from current";
190
+ hint.style.color = "${PALETTE.danger}";
191
+ } else {
192
+ hint.textContent = "Passwords match";
193
+ hint.style.color = "${PALETTE.success}";
194
+ }
195
+ }
196
+ if (newPw) newPw.addEventListener("input", check);
197
+ if (confirm) confirm.addEventListener("input", check);
198
+ if (current) current.addEventListener("input", check);
199
+ form.addEventListener("submit", function (e) {
200
+ if (!newPw || !confirm || !current) return;
201
+ if (newPw.value !== confirm.value) {
202
+ e.preventDefault();
203
+ if (hint) {
204
+ hint.textContent = "Passwords do not match";
205
+ hint.style.color = "${PALETTE.danger}";
206
+ }
207
+ return;
208
+ }
209
+ if (current.value === newPw.value) {
210
+ e.preventDefault();
211
+ if (hint) {
212
+ hint.textContent = "New password must differ from current";
213
+ hint.style.color = "${PALETTE.danger}";
214
+ }
215
+ return;
216
+ }
217
+ });
218
+ })();
219
+ `;
220
+
221
+ // --- styles ---------------------------------------------------------------
222
+ //
223
+ // Mirrors `admin-login-ui.ts` STYLES. If/when a shared `auth-ui-chrome.ts`
224
+ // lands these two should merge.
225
+
226
+ const STYLES = `
227
+ *, *::before, *::after { box-sizing: border-box; }
228
+ html, body { margin: 0; padding: 0; }
229
+ body {
230
+ font-family: ${FONT_SANS};
231
+ background: ${PALETTE.bg};
232
+ color: ${PALETTE.fg};
233
+ line-height: 1.55;
234
+ min-height: 100vh;
235
+ -webkit-font-smoothing: antialiased;
236
+ -moz-osx-font-smoothing: grayscale;
237
+ }
238
+ main {
239
+ display: flex;
240
+ align-items: center;
241
+ justify-content: center;
242
+ min-height: 100vh;
243
+ padding: 1.5rem;
244
+ }
245
+ .card {
246
+ width: 100%;
247
+ max-width: 30rem;
248
+ background: ${PALETTE.cardBg};
249
+ border: 1px solid ${PALETTE.border};
250
+ border-radius: 12px;
251
+ padding: 2rem 1.75rem;
252
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
253
+ }
254
+ .card-header { margin-bottom: 1.5rem; }
255
+ .brand {
256
+ display: flex;
257
+ align-items: center;
258
+ gap: 0.5rem;
259
+ color: ${PALETTE.accent};
260
+ font-weight: 500;
261
+ font-size: 0.95rem;
262
+ margin-bottom: 1.25rem;
263
+ }
264
+ .brand-mark { font-size: 1.1rem; line-height: 1; }
265
+ .brand-name { letter-spacing: 0.01em; }
266
+ .brand-tag {
267
+ text-transform: uppercase;
268
+ letter-spacing: 0.06em;
269
+ font-size: 0.7rem;
270
+ color: ${PALETTE.fgMuted};
271
+ border: 1px solid ${PALETTE.borderLight};
272
+ padding: 0.05rem 0.4rem;
273
+ border-radius: 999px;
274
+ }
275
+ h1 {
276
+ font-family: ${FONT_SERIF};
277
+ font-weight: 400;
278
+ font-size: 1.6rem;
279
+ line-height: 1.2;
280
+ margin: 0 0 0.4rem;
281
+ color: ${PALETTE.fg};
282
+ }
283
+ .subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
284
+ .signed-in {
285
+ margin: 0.75rem 0 0;
286
+ color: ${PALETTE.fgMuted};
287
+ font-size: 0.85rem;
288
+ font-family: ${FONT_MONO};
289
+ }
290
+ .signed-in strong { color: ${PALETTE.fg}; font-weight: 500; }
291
+
292
+ .auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
293
+ .field { display: flex; flex-direction: column; gap: 0.35rem; }
294
+ .field-label {
295
+ font-size: 0.85rem;
296
+ font-weight: 500;
297
+ color: ${PALETTE.fgMuted};
298
+ letter-spacing: 0.01em;
299
+ font-family: ${FONT_MONO};
300
+ }
301
+ .field-hint {
302
+ font-size: 0.78rem;
303
+ color: ${PALETTE.fgMuted};
304
+ font-family: ${FONT_MONO};
305
+ }
306
+ input[type=text], input[type=password] {
307
+ font: inherit;
308
+ width: 100%;
309
+ padding: 0.6rem 0.75rem;
310
+ border: 1px solid ${PALETTE.border};
311
+ border-radius: 6px;
312
+ background: ${PALETTE.bg};
313
+ color: ${PALETTE.fg};
314
+ transition: border-color 0.15s ease, background 0.15s ease;
315
+ }
316
+ input[type=text]:focus, input[type=password]:focus {
317
+ outline: none;
318
+ border-color: ${PALETTE.accent};
319
+ background: ${PALETTE.cardBg};
320
+ box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
321
+ }
322
+
323
+ .btn {
324
+ font: inherit;
325
+ font-weight: 500;
326
+ padding: 0.65rem 1.25rem;
327
+ border-radius: 6px;
328
+ border: 1px solid transparent;
329
+ cursor: pointer;
330
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
331
+ min-height: 2.5rem;
332
+ }
333
+ .btn-primary {
334
+ background: ${PALETTE.accent};
335
+ color: ${PALETTE.cardBg};
336
+ margin-top: 0.4rem;
337
+ }
338
+ .btn-primary:hover { background: ${PALETTE.accentHover}; }
339
+
340
+ .error-banner {
341
+ background: ${PALETTE.dangerSoft};
342
+ border: 1px solid ${PALETTE.danger};
343
+ border-radius: 6px;
344
+ color: ${PALETTE.danger};
345
+ padding: 0.6rem 0.8rem;
346
+ margin: 0 0 1rem;
347
+ font-size: 0.9rem;
348
+ }
349
+ .success-banner {
350
+ background: ${PALETTE.successSoft};
351
+ border: 1px solid ${PALETTE.success};
352
+ border-radius: 6px;
353
+ color: ${PALETTE.success};
354
+ padding: 0.6rem 0.8rem;
355
+ margin: 0 0 1rem;
356
+ font-size: 0.9rem;
357
+ }
358
+
359
+ @media (max-width: 480px) {
360
+ main { padding: 0.75rem; }
361
+ .card { padding: 1.5rem 1.25rem; border-radius: 10px; }
362
+ h1 { font-size: 1.4rem; }
363
+ }
364
+
365
+ @media (prefers-color-scheme: dark) {
366
+ body { background: #1a1815; color: #e8e4dc; }
367
+ .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
368
+ h1 { color: #f0ece4; }
369
+ .subtitle, .field-label, .field-hint, .signed-in { color: #a8a29a; }
370
+ .signed-in strong { color: #f0ece4; }
371
+ input[type=text], input[type=password] {
372
+ background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
373
+ }
374
+ input[type=text]:focus, input[type=password]:focus {
375
+ background: #25221d;
376
+ }
377
+ .brand-tag { border-color: #3a362f; color: #a8a29a; }
378
+ }
379
+ `;
@@ -23,7 +23,7 @@ import {
23
23
  deleteSession,
24
24
  parseSessionCookie,
25
25
  } from "./sessions.ts";
26
- import { getUserByUsername, verifyPassword } from "./users.ts";
26
+ import { PASSWORD_MAX_LEN, type User, getUserByUsername, verifyPassword } from "./users.ts";
27
27
 
28
28
  function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
29
29
  return new Response(body, {
@@ -44,6 +44,22 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
44
44
  */
45
45
  const POST_LOGIN_DEFAULT = "/admin/vaults";
46
46
 
47
+ /**
48
+ * Force-change-password landing. Multi-user Phase 1 PR 3: when a user
49
+ * signs in with `password_changed === false` (admin-created account
50
+ * with the admin's default password), the login POST 302s here instead
51
+ * of `next`. The original `next` rides along as a query param so the
52
+ * change-password POST can land them at their intended destination
53
+ * after they pick a new password.
54
+ *
55
+ * Session-level redirect, not token-level — the cookie is minted as
56
+ * normal; only the redirect target changes. The user IS signed in,
57
+ * they're just expected to change their password before doing anything
58
+ * else (the `/account/change-password` page is reachable, the SPA
59
+ * isn't gated). Design §sign-in flow change.
60
+ */
61
+ const FORCE_CHANGE_PASSWORD_PATH = "/account/change-password";
62
+
47
63
  function safeNext(raw: string | null): string {
48
64
  if (!raw) return POST_LOGIN_DEFAULT;
49
65
  // Only allow same-origin paths — never honor an absolute URL or scheme.
@@ -51,6 +67,32 @@ function safeNext(raw: string | null): string {
51
67
  return raw;
52
68
  }
53
69
 
70
+ /**
71
+ * Pick the post-login redirect target for a freshly-authenticated user.
72
+ *
73
+ * **The only place `password_changed` gates a flow.** Per design §sign-
74
+ * in flow change, force-change is a session-level redirect at the login
75
+ * boundary — once changed, no per-request scope check is needed and
76
+ * no token claim carries the bit forward.
77
+ *
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
82
+ *
83
+ * When `password_changed === true`: return `next` (today's behavior).
84
+ */
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)}`;
94
+ }
95
+
54
96
  // --- /login ---------------------------------------------------------------
55
97
  //
56
98
  // Renamed from `/admin/login` so the surface name reflects what it is — the
@@ -110,6 +152,22 @@ export async function handleAdminLoginPost(
110
152
  400,
111
153
  );
112
154
  }
155
+ // Cap incoming password length BEFORE getUserByUsername / argon2id
156
+ // verify touches it. An unauthenticated POST submitting a megabyte
157
+ // password would otherwise force a full argon2id hash on arbitrary
158
+ // input — CPU-DoS shape. 413 fires before any DB or hash work; same
159
+ // `PASSWORD_MAX_LEN` constant `/api/users` and `/account/change-
160
+ // password` use (PR-3 fold N1: applied uniformly across the auth
161
+ // surface family).
162
+ if (password.length > PASSWORD_MAX_LEN) {
163
+ return htmlResponse(
164
+ renderAdminError({
165
+ title: "Password too long",
166
+ message: `Password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
167
+ }),
168
+ 413,
169
+ );
170
+ }
113
171
  const user = getUserByUsername(db, username);
114
172
  if (!user) {
115
173
  return htmlResponse(
@@ -128,7 +186,15 @@ export async function handleAdminLoginPost(
128
186
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
129
187
  secure: isHttpsRequest(req),
130
188
  });
131
- return redirect(next, { "set-cookie": cookie });
189
+ // Multi-user Phase 1 PR 3 — `password_changed === false` (admin-created
190
+ // user, hasn't picked their own password yet) lands at
191
+ // `/account/change-password` instead of `next`. The session cookie is
192
+ // minted as normal — the user IS authenticated, they're just expected
193
+ // to change the admin-typed default before continuing. Their original
194
+ // `next` rides along on the change-password URL so the post-change
195
+ // POST can land them at their intended destination.
196
+ const target = loginRedirectTarget(user, next);
197
+ return redirect(target, { "set-cookie": cookie });
132
198
  }
133
199
 
134
200
  /**
@@ -71,6 +71,11 @@ export async function handleHostAdminToken(
71
71
  clientId: HOST_ADMIN_CLIENT_ID,
72
72
  issuer: deps.issuer,
73
73
  ttlSeconds: HOST_ADMIN_TOKEN_TTL_SECONDS,
74
+ // Host-admin tokens carry no per-user vault pin — the SPA Bearer talks
75
+ // to hub-scoped admin endpoints (vaults, grants, users, tokens), not to
76
+ // a single vault. Empty `vault_scope` is the "no per-user restriction"
77
+ // sentinel matching admin OAuth tokens.
78
+ vaultScope: [],
74
79
  });
75
80
  return new Response(
76
81
  JSON.stringify({
@@ -73,6 +73,13 @@ export async function handleVaultAdminToken(
73
73
  clientId: VAULT_ADMIN_CLIENT_ID,
74
74
  issuer: deps.issuer,
75
75
  ttlSeconds: VAULT_ADMIN_TOKEN_TTL_SECONDS,
76
+ // The session-cookie path mints a per-vault admin Bearer for an operator
77
+ // who is already authenticated as an admin (post-#199 SPA path). The
78
+ // token names exactly one vault in its `scope`; the `vault_scope` claim
79
+ // mirrors that so PR 5's scope-guard side sees the same explicit pin
80
+ // (rather than the empty-admin sentinel that would otherwise undermine
81
+ // the per-vault narrowing).
82
+ vaultScope: [vaultName],
76
83
  });
77
84
  return new Response(
78
85
  JSON.stringify({