@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -1,66 +1,29 @@
1
1
  /**
2
- * HTTP handlers for the hub admin surface — login (`/admin/login`) and the
3
- * config portal (`/admin/config`, `/admin/config/<name>`). Sessions ride the
4
- * same `parachute_hub_session` cookie that the OAuth login mints, since PR
5
- * #112 widened the cookie path from `/oauth/` to `/`.
2
+ * HTTP handlers for the hub admin surface — login + logout. Sessions ride
3
+ * the same `parachute_hub_session` cookie that the OAuth login mints, since
4
+ * PR #112 widened the cookie path from `/oauth/` to `/`.
5
+ *
6
+ * `/login` (was `/admin/login` pre-#231-followup) is the canonical entry
7
+ * for ALL parachute auth — admin operators, OAuth user flows, etc. The
8
+ * `/admin/login` and `/admin/logout` paths 301-redirect for back-compat.
6
9
  *
7
10
  * Every state-changing POST is double-submit-CSRF protected
8
- * (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare),
9
- * and every authenticated GET issues a 302 to `/admin/login?next=<path>`
10
- * when no session is found rather than rendering an inline login form —
11
- * keeps each route's intent clean and lets the operator bookmark
12
- * `/admin/config` without thinking about state.
11
+ * (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare).
13
12
  */
14
13
  import type { Database } from "bun:sqlite";
15
- import {
16
- type AdminConfigModuleView,
17
- type ModuleStatus,
18
- renderAdminConfigPage,
19
- renderAdminError,
20
- renderAdminLogin,
21
- } from "./admin-config-ui.ts";
22
- import {
23
- type ConfigurableModule,
24
- configPathFor,
25
- discoverConfigurableModules,
26
- readModuleConfig,
27
- validateAndCoerce,
28
- writeModuleConfig,
29
- } from "./admin-config.ts";
30
- import { restart as lifecycleRestart } from "./commands/lifecycle.ts";
31
- import { CONFIG_DIR } from "./config.ts";
14
+ import { renderAdminError, renderAdminLogin } from "./admin-login-ui.ts";
32
15
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
33
- import type { ModuleManifest } from "./module-manifest.ts";
34
- import {
35
- type ServicesManifest,
36
- readManifest as readServicesManifest,
37
- } from "./services-manifest.ts";
16
+ import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
38
17
  import {
39
18
  SESSION_TTL_MS,
40
19
  buildSessionClearCookie,
41
20
  buildSessionCookie,
42
21
  createSession,
43
22
  deleteSession,
44
- findSession,
45
23
  parseSessionCookie,
46
24
  } from "./sessions.ts";
47
25
  import { getUserByUsername, verifyPassword } from "./users.ts";
48
26
 
49
- export interface AdminDeps {
50
- /** Resolves the installed-services manifest (production: `services-manifest.readManifest`). */
51
- loadServicesManifest?: () => ServicesManifest;
52
- /** Per-module `.parachute/module.json` reader (production: `module-manifest.readModuleManifest`). */
53
- readManifest?: (installDir: string) => Promise<ModuleManifest | null>;
54
- /** `~/.parachute` (defaults to `CONFIG_DIR`). Module configs land at `<configDir>/<name>/config.json`. */
55
- configDir?: string;
56
- /** Test seam — defaults to `commands/lifecycle.restart`. */
57
- restartService?: (name: string) => Promise<number>;
58
- /** Test seam — defaults to logging to stderr. */
59
- log?: (line: string) => void;
60
- /** Test seam — defaults to real clock. */
61
- now?: () => Date;
62
- }
63
-
64
27
  function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
65
28
  return new Response(body, {
66
29
  status,
@@ -72,31 +35,27 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
72
35
  return new Response(null, { status: 302, headers: { location, ...extra } });
73
36
  }
74
37
 
75
- // --- session gate ----------------------------------------------------------
76
-
77
38
  /**
78
- * Return the active session for this request, or null. Caller decides what
79
- * to do on null most paths should redirect to `/admin/login?next=<path>`.
39
+ * Post-login default landing. The admin SPA mounts at `/admin/*` and treats
40
+ * `/admin/vaults` as its home (vault list, the default tab). Anywhere else
41
+ * would either bounce the operator out of the SPA shell or land on a
42
+ * legacy server-rendered page — `/admin/vaults` is the canonical entry.
80
43
  */
81
- function activeSession(db: Database, req: Request) {
82
- const sid = parseSessionCookie(req.headers.get("cookie"));
83
- return sid ? findSession(db, sid) : null;
84
- }
85
-
86
- function loginRedirect(req: Request, extra: Record<string, string> = {}): Response {
87
- const url = new URL(req.url);
88
- const next = `${url.pathname}${url.search}`;
89
- return redirect(`/admin/login?next=${encodeURIComponent(next)}`, extra);
90
- }
44
+ const POST_LOGIN_DEFAULT = "/admin/vaults";
91
45
 
92
46
  function safeNext(raw: string | null): string {
93
- if (!raw) return "/admin/config";
47
+ if (!raw) return POST_LOGIN_DEFAULT;
94
48
  // Only allow same-origin paths — never honor an absolute URL or scheme.
95
- if (!raw.startsWith("/") || raw.startsWith("//")) return "/admin/config";
49
+ if (!raw.startsWith("/") || raw.startsWith("//")) return POST_LOGIN_DEFAULT;
96
50
  return raw;
97
51
  }
98
52
 
99
- // --- /admin/login ----------------------------------------------------------
53
+ // --- /login ---------------------------------------------------------------
54
+ //
55
+ // Renamed from `/admin/login` so the surface name reflects what it is — the
56
+ // canonical entry for ALL parachute auth (operators, OAuth user flows,
57
+ // etc.), not an admin-only door. `/admin/login` and `/admin/logout` 301
58
+ // to here from `hub-server.ts` for back-compat.
100
59
 
101
60
  export function handleAdminLoginGet(_db: Database, req: Request): Response {
102
61
  const url = new URL(req.url);
@@ -106,7 +65,11 @@ export function handleAdminLoginGet(_db: Database, req: Request): Response {
106
65
  return htmlResponse(renderAdminLogin({ next, csrfToken: csrf.token }), 200, extra);
107
66
  }
108
67
 
109
- export async function handleAdminLoginPost(db: Database, req: Request): Promise<Response> {
68
+ export async function handleAdminLoginPost(
69
+ db: Database,
70
+ req: Request,
71
+ deps: AdminLoginDeps = {},
72
+ ): Promise<Response> {
110
73
  const form = await req.formData();
111
74
  const formCsrf = form.get(CSRF_FIELD_NAME);
112
75
  if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
@@ -118,6 +81,24 @@ export async function handleAdminLoginPost(db: Database, req: Request): Promise<
118
81
  400,
119
82
  );
120
83
  }
84
+ // Rate-limit gate fires *after* CSRF (so a junk cross-site POST doesn't
85
+ // burn a bucket slot for the victim's IP) but *before* credential check.
86
+ // Every legitimate login attempt — wrong password, missing user, eventually
87
+ // failed-2FA (#186) — counts toward the same bucket so an attacker can't
88
+ // partition the cooldown across stages.
89
+ const clientIp = clientIpFromRequest(req);
90
+ const now = deps.now ? deps.now() : new Date();
91
+ const gate = checkAndRecord(clientIp, now);
92
+ if (!gate.allowed) {
93
+ return htmlResponse(
94
+ renderAdminError({
95
+ title: "Too many login attempts",
96
+ message: `Too many login attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
97
+ }),
98
+ 429,
99
+ { "retry-after": String(gate.retryAfterSeconds ?? 1) },
100
+ );
101
+ }
121
102
  const username = String(form.get("username") ?? "");
122
103
  const password = String(form.get("password") ?? "");
123
104
  const next = safeNext(String(form.get("next") ?? ""));
@@ -147,17 +128,27 @@ export async function handleAdminLoginPost(db: Database, req: Request): Promise<
147
128
  return redirect(next, { "set-cookie": cookie });
148
129
  }
149
130
 
150
- // --- /admin/logout ---------------------------------------------------------
131
+ /**
132
+ * Test-injection seam for `handleAdminLoginPost`. Production callers omit
133
+ * `deps`; tests pass a deterministic clock so the rate-limit assertions
134
+ * don't race wall-clock time.
135
+ */
136
+ export interface AdminLoginDeps {
137
+ /** Test seam — defaults to real clock. */
138
+ now?: () => Date;
139
+ }
140
+
141
+ // --- /logout --------------------------------------------------------------
151
142
 
152
143
  /**
153
144
  * POST-only — logout is state-changing, so it rides the same double-submit
154
- * CSRF discipline as login + config posts. Without CSRF, a malicious
155
- * cross-origin form could log the operator out (annoyance, not catastrophe,
156
- * but the safety belt is already on the bus).
145
+ * CSRF discipline as login. Without CSRF, a malicious cross-origin form
146
+ * could log the operator out (annoyance, not catastrophe, but the safety
147
+ * belt is already on the bus).
157
148
  *
158
149
  * Always idempotent: clearing the cookie succeeds even if there's no
159
- * matching session row. Returns 302 → /admin/login so the operator lands
160
- * back on the form ready to re-authenticate.
150
+ * matching session row. Returns 302 → /login so the operator lands back
151
+ * on the form ready to re-authenticate.
161
152
  */
162
153
  export async function handleAdminLogoutPost(db: Database, req: Request): Promise<Response> {
163
154
  const form = await req.formData();
@@ -173,193 +164,5 @@ export async function handleAdminLogoutPost(db: Database, req: Request): Promise
173
164
  }
174
165
  const sid = parseSessionCookie(req.headers.get("cookie"));
175
166
  if (sid) deleteSession(db, sid);
176
- return redirect("/admin/login", { "set-cookie": buildSessionClearCookie() });
177
- }
178
-
179
- // --- /admin/config ---------------------------------------------------------
180
-
181
- export async function handleAdminConfigGet(
182
- db: Database,
183
- req: Request,
184
- deps: AdminDeps = {},
185
- ): Promise<Response> {
186
- const session = activeSession(db, req);
187
- if (!session) return loginRedirect(req);
188
-
189
- const csrf = ensureCsrfToken(req);
190
- const setCookieExtra: Record<string, string> = csrf.setCookie
191
- ? { "set-cookie": csrf.setCookie }
192
- : {};
193
-
194
- const modules = await loadModuleViews(deps);
195
- const flash = parseFlash(req);
196
- if (flash) applyFlashTo(modules, flash);
197
- return htmlResponse(
198
- renderAdminConfigPage({ modules, csrfToken: csrf.token }),
199
- 200,
200
- setCookieExtra,
201
- );
202
- }
203
-
204
- export async function handleAdminConfigPost(
205
- db: Database,
206
- req: Request,
207
- moduleName: string,
208
- deps: AdminDeps = {},
209
- ): Promise<Response> {
210
- const session = activeSession(db, req);
211
- if (!session) return loginRedirect(req);
212
-
213
- const form = await req.formData();
214
- const formCsrf = form.get(CSRF_FIELD_NAME);
215
- if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
216
- return htmlResponse(
217
- renderAdminError({
218
- title: "Invalid form submission",
219
- message: "The form's CSRF token did not match. Reload the page and try again.",
220
- }),
221
- 400,
222
- );
223
- }
224
-
225
- const modules = await discoverConfigurableModules(discoverDeps(deps));
226
- const target = modules.find((m) => m.name === moduleName);
227
- if (!target) {
228
- return htmlResponse(
229
- renderAdminError({
230
- title: "Unknown module",
231
- message: `No installed module named "${moduleName}" declares a config schema on this hub.`,
232
- }),
233
- 404,
234
- );
235
- }
236
-
237
- const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
238
- const submitted = collectFormValues(form, target);
239
- const result = validateAndCoerce(submitted, target.schema);
240
- if (!result.ok) {
241
- return rerenderWithStatus(deps, target, csrfToken, {
242
- fieldErrors: result.errors,
243
- pending: submitted,
244
- errorMessage: "Some fields need attention before this config can be saved.",
245
- });
246
- }
247
-
248
- try {
249
- writeModuleConfig(target.configPath, result.data ?? {});
250
- } catch (err) {
251
- return rerenderWithStatus(deps, target, csrfToken, {
252
- pending: submitted,
253
- errorMessage: `Failed to write ${target.configPath}: ${err instanceof Error ? err.message : String(err)}`,
254
- });
255
- }
256
-
257
- const restartFn = deps.restartService ?? defaultRestart(deps);
258
- let restartCode = 0;
259
- try {
260
- restartCode = await restartFn(target.name);
261
- } catch (err) {
262
- return successRedirect(target.name, "saved-restart-failed", err);
263
- }
264
- if (restartCode !== 0) return successRedirect(target.name, "saved-restart-failed");
265
- return successRedirect(target.name, "saved");
266
- }
267
-
268
- function defaultRestart(deps: AdminDeps): (name: string) => Promise<number> {
269
- return (name) =>
270
- lifecycleRestart(name, {
271
- configDir: deps.configDir ?? CONFIG_DIR,
272
- log: deps.log,
273
- });
274
- }
275
-
276
- function discoverDeps(deps: AdminDeps) {
277
- const out: Parameters<typeof discoverConfigurableModules>[0] = {
278
- loadServicesManifest: deps.loadServicesManifest ?? readServicesManifest,
279
- configDir: deps.configDir ?? CONFIG_DIR,
280
- };
281
- if (deps.readManifest) out.readManifest = deps.readManifest;
282
- return out;
283
- }
284
-
285
- async function loadModuleViews(deps: AdminDeps): Promise<AdminConfigModuleView[]> {
286
- const modules = await discoverConfigurableModules(discoverDeps(deps));
287
- return modules.map((module) => {
288
- const { data, parseError } = readModuleConfig(module.configPath);
289
- const view: AdminConfigModuleView = { module, current: data };
290
- if (parseError) view.parseError = parseError;
291
- return view;
292
- });
293
- }
294
-
295
- function collectFormValues(
296
- form: Awaited<ReturnType<Request["formData"]>>,
297
- module: ConfigurableModule,
298
- ): Record<string, string | boolean | undefined> {
299
- const out: Record<string, string | boolean | undefined> = {};
300
- for (const [key, prop] of Object.entries(module.schema.properties)) {
301
- if (prop.type === "boolean") {
302
- // Unchecked checkboxes don't appear in form data — absence = false.
303
- out[key] = form.has(key);
304
- continue;
305
- }
306
- const v = form.get(key);
307
- out[key] = typeof v === "string" ? v : undefined;
308
- }
309
- return out;
310
- }
311
-
312
- // --- flash + redirect helpers ---------------------------------------------
313
-
314
- const FLASH_PARAM = "_status";
315
- const FLASH_MODULE_PARAM = "_module";
316
-
317
- function successRedirect(moduleName: string, status: string, err?: unknown): Response {
318
- const target = new URL("/admin/config", "http://placeholder");
319
- target.searchParams.set(FLASH_PARAM, status);
320
- target.searchParams.set(FLASH_MODULE_PARAM, moduleName);
321
- if (err) target.searchParams.set("_err", err instanceof Error ? err.message : String(err));
322
- target.hash = `module-${moduleName}`;
323
- return redirect(`${target.pathname}${target.search}${target.hash}`);
324
- }
325
-
326
- function parseFlash(req: Request): { module: string; status: string; errMessage?: string } | null {
327
- const url = new URL(req.url);
328
- const status = url.searchParams.get(FLASH_PARAM);
329
- const mod = url.searchParams.get(FLASH_MODULE_PARAM);
330
- if (!status || !mod) return null;
331
- const errMessage = url.searchParams.get("_err") ?? undefined;
332
- const out: { module: string; status: string; errMessage?: string } = { module: mod, status };
333
- if (errMessage) out.errMessage = errMessage;
334
- return out;
335
- }
336
-
337
- function applyFlashTo(
338
- views: AdminConfigModuleView[],
339
- flash: { module: string; status: string; errMessage?: string },
340
- ): void {
341
- const view = views.find((v) => v.module.name === flash.module);
342
- if (!view) return;
343
- if (flash.status === "saved") {
344
- view.status = { successMessage: `Saved and restarted ${view.module.displayName}.` };
345
- return;
346
- }
347
- if (flash.status === "saved-restart-failed") {
348
- const tail = flash.errMessage ? ` (${flash.errMessage})` : "";
349
- view.status = {
350
- errorMessage: `Saved ${view.module.displayName} config but the restart did not succeed${tail}. Run \`parachute restart ${view.module.name}\` and check logs.`,
351
- };
352
- }
353
- }
354
-
355
- async function rerenderWithStatus(
356
- deps: AdminDeps,
357
- target: ConfigurableModule,
358
- csrfToken: string,
359
- status: ModuleStatus,
360
- ): Promise<Response> {
361
- const views = await loadModuleViews(deps);
362
- const view = views.find((v) => v.module.name === target.name);
363
- if (view) view.status = status;
364
- return htmlResponse(renderAdminConfigPage({ modules: views, csrfToken }), 422);
167
+ return redirect("/login", { "set-cookie": buildSessionClearCookie() });
365
168
  }
@@ -1,25 +1,40 @@
1
1
  /**
2
2
  * `GET /admin/host-admin-token` — exchange a valid admin session cookie for a
3
- * short-lived JWT carrying `parachute:host:admin`.
3
+ * short-lived JWT carrying both `parachute:host:admin` and
4
+ * `parachute:host:auth` (the same superset an `--scope-set admin` operator
5
+ * token holds).
4
6
  *
5
- * Why this exists: the hub's vault-management SPA (served under `/hub/`)
6
- * needs a Bearer to call admin endpoints like `POST /vaults`, but
7
- * `parachute:host:admin` is in `NON_REQUESTABLE_SCOPES` the public
8
- * `/oauth/authorize` endpoint refuses to mint it so third-party apps can't
9
- * acquire cross-vault provisioning capability via consent.
7
+ * Why this exists: the hub's admin SPA (served under `/hub/`) needs a Bearer
8
+ * to call:
9
+ * - `parachute:host:admin` surfaces `POST /vaults` (vault provisioning),
10
+ * `/api/grants` (OAuth consent grant management).
11
+ * - `parachute:host:auth` surfaces `GET /api/auth/tokens`,
12
+ * `POST /api/auth/mint-token`, `POST /api/auth/revoke-token` (the token
13
+ * registry endpoints from hub#212 Phase 2).
14
+ *
15
+ * Both scopes are in `NON_REQUESTABLE_SCOPES` — the public `/oauth/authorize`
16
+ * endpoint refuses to mint either, so third-party apps can't acquire these
17
+ * capabilities via consent. This local mint path is the SPA's only way in.
10
18
  *
11
19
  * The local mint path now has two surfaces:
12
- * 1. `parachute auth ...` — operator-token file (~1y, on-disk, mode 0600).
20
+ * 1. `parachute auth ...` — operator-token file (90d, on-disk, mode 0600).
13
21
  * 2. THIS endpoint — short-lived JWT (10 min) handed to the SPA in memory.
14
22
  *
15
23
  * Both paths require already-proved local-operator identity:
16
24
  * - operator-token mint runs as the operator's unix user.
17
25
  * - this endpoint requires a valid `parachute_hub_session` cookie, which
18
- * was set by `/admin/login` after a password check.
26
+ * was set by `/login` after a password check.
19
27
  *
20
28
  * Tokens minted here are deliberately NOT persisted in the `tokens` table
21
29
  * (no refresh, no revocation tracking). They expire on their own; the SPA
22
30
  * re-fetches when the JWT is about to lapse.
31
+ *
32
+ * Background on the dual-scope shape: prior to hub#212 Phase 2 this endpoint
33
+ * minted `parachute:host:admin` only. The Phase 2 admin endpoints
34
+ * (`/api/auth/*`) gate on `parachute:host:auth`, which the SPA's bearer
35
+ * lacked — `/hub/tokens` failed with `bearer token lacks parachute:host:auth`
36
+ * on first end-to-end load. Adding `:host:auth` here brings the SPA bearer
37
+ * in line with the admin scope-set semantics from hub#214 / #222.
23
38
  */
24
39
  import type { Database } from "bun:sqlite";
25
40
  import { signAccessToken } from "./jwt-sign.ts";
@@ -29,7 +44,7 @@ import { findSession, parseSessionCookie } from "./sessions.ts";
29
44
  export const HOST_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
30
45
  const HOST_ADMIN_AUDIENCE = "hub";
31
46
  const HOST_ADMIN_CLIENT_ID = "parachute-hub-spa";
32
- export const HOST_ADMIN_SCOPES = ["parachute:host:admin"] as const;
47
+ export const HOST_ADMIN_SCOPES = ["parachute:host:admin", "parachute:host:auth"] as const;
33
48
 
34
49
  export interface MintHostAdminTokenDeps {
35
50
  db: Database;
@@ -47,7 +62,7 @@ export async function handleHostAdminToken(
47
62
  const sid = parseSessionCookie(req.headers.get("cookie"));
48
63
  const session = sid ? findSession(deps.db, sid) : null;
49
64
  if (!session) {
50
- return jsonError(401, "unauthenticated", "no admin session — sign in at /admin/login first");
65
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
51
66
  }
52
67
  const minted = await signAccessToken(deps.db, {
53
68
  sub: session.userId,