@openparachute/hub 0.3.0-rc.1 → 0.5.0

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 (90) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +712 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +519 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +652 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +242 -37
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,365 @@
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 `/`.
6
+ *
7
+ * 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.
13
+ */
14
+ 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";
32
+ 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";
38
+ import {
39
+ SESSION_TTL_MS,
40
+ buildSessionClearCookie,
41
+ buildSessionCookie,
42
+ createSession,
43
+ deleteSession,
44
+ findSession,
45
+ parseSessionCookie,
46
+ } from "./sessions.ts";
47
+ import { getUserByUsername, verifyPassword } from "./users.ts";
48
+
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
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
65
+ return new Response(body, {
66
+ status,
67
+ headers: { "content-type": "text/html; charset=utf-8", ...extra },
68
+ });
69
+ }
70
+
71
+ function redirect(location: string, extra: Record<string, string> = {}): Response {
72
+ return new Response(null, { status: 302, headers: { location, ...extra } });
73
+ }
74
+
75
+ // --- session gate ----------------------------------------------------------
76
+
77
+ /**
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>`.
80
+ */
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
+ }
91
+
92
+ function safeNext(raw: string | null): string {
93
+ if (!raw) return "/admin/config";
94
+ // Only allow same-origin paths — never honor an absolute URL or scheme.
95
+ if (!raw.startsWith("/") || raw.startsWith("//")) return "/admin/config";
96
+ return raw;
97
+ }
98
+
99
+ // --- /admin/login ----------------------------------------------------------
100
+
101
+ export function handleAdminLoginGet(_db: Database, req: Request): Response {
102
+ const url = new URL(req.url);
103
+ const next = safeNext(url.searchParams.get("next"));
104
+ const csrf = ensureCsrfToken(req);
105
+ const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
106
+ return htmlResponse(renderAdminLogin({ next, csrfToken: csrf.token }), 200, extra);
107
+ }
108
+
109
+ export async function handleAdminLoginPost(db: Database, req: Request): Promise<Response> {
110
+ const form = await req.formData();
111
+ const formCsrf = form.get(CSRF_FIELD_NAME);
112
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
113
+ return htmlResponse(
114
+ renderAdminError({
115
+ title: "Invalid form submission",
116
+ message: "The form's CSRF token did not match. Reload the page and try again.",
117
+ }),
118
+ 400,
119
+ );
120
+ }
121
+ const username = String(form.get("username") ?? "");
122
+ const password = String(form.get("password") ?? "");
123
+ const next = safeNext(String(form.get("next") ?? ""));
124
+ const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
125
+ if (!username || !password) {
126
+ return htmlResponse(
127
+ renderAdminLogin({ next, csrfToken, errorMessage: "Username and password are required." }),
128
+ 400,
129
+ );
130
+ }
131
+ const user = getUserByUsername(db, username);
132
+ if (!user) {
133
+ return htmlResponse(
134
+ renderAdminLogin({ next, csrfToken, errorMessage: "Invalid credentials." }),
135
+ 401,
136
+ );
137
+ }
138
+ const ok = await verifyPassword(user, password);
139
+ if (!ok) {
140
+ return htmlResponse(
141
+ renderAdminLogin({ next, csrfToken, errorMessage: "Invalid credentials." }),
142
+ 401,
143
+ );
144
+ }
145
+ const session = createSession(db, { userId: user.id });
146
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
147
+ return redirect(next, { "set-cookie": cookie });
148
+ }
149
+
150
+ // --- /admin/logout ---------------------------------------------------------
151
+
152
+ /**
153
+ * 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).
157
+ *
158
+ * 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.
161
+ */
162
+ export async function handleAdminLogoutPost(db: Database, req: Request): Promise<Response> {
163
+ const form = await req.formData();
164
+ const formCsrf = form.get(CSRF_FIELD_NAME);
165
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
166
+ return htmlResponse(
167
+ renderAdminError({
168
+ title: "Invalid form submission",
169
+ message: "The form's CSRF token did not match. Reload the page and try again.",
170
+ }),
171
+ 400,
172
+ );
173
+ }
174
+ const sid = parseSessionCookie(req.headers.get("cookie"));
175
+ 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);
365
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `GET /admin/host-admin-token` — exchange a valid admin session cookie for a
3
+ * short-lived JWT carrying `parachute:host:admin`.
4
+ *
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.
10
+ *
11
+ * The local mint path now has two surfaces:
12
+ * 1. `parachute auth ...` — operator-token file (~1y, on-disk, mode 0600).
13
+ * 2. THIS endpoint — short-lived JWT (10 min) handed to the SPA in memory.
14
+ *
15
+ * Both paths require already-proved local-operator identity:
16
+ * - operator-token mint runs as the operator's unix user.
17
+ * - this endpoint requires a valid `parachute_hub_session` cookie, which
18
+ * was set by `/admin/login` after a password check.
19
+ *
20
+ * Tokens minted here are deliberately NOT persisted in the `tokens` table
21
+ * (no refresh, no revocation tracking). They expire on their own; the SPA
22
+ * re-fetches when the JWT is about to lapse.
23
+ */
24
+ import type { Database } from "bun:sqlite";
25
+ import { signAccessToken } from "./jwt-sign.ts";
26
+ import { findSession, parseSessionCookie } from "./sessions.ts";
27
+
28
+ /** Short TTL — page-snapshot threats can't carry the token forever. */
29
+ export const HOST_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
30
+ const HOST_ADMIN_AUDIENCE = "hub";
31
+ const HOST_ADMIN_CLIENT_ID = "parachute-hub-spa";
32
+ export const HOST_ADMIN_SCOPES = ["parachute:host:admin"] as const;
33
+
34
+ export interface MintHostAdminTokenDeps {
35
+ db: Database;
36
+ /** Hub origin — written into JWT `iss`. */
37
+ issuer: string;
38
+ }
39
+
40
+ export async function handleHostAdminToken(
41
+ req: Request,
42
+ deps: MintHostAdminTokenDeps,
43
+ ): Promise<Response> {
44
+ if (req.method !== "GET") {
45
+ return jsonError(405, "method_not_allowed", "use GET");
46
+ }
47
+ const sid = parseSessionCookie(req.headers.get("cookie"));
48
+ const session = sid ? findSession(deps.db, sid) : null;
49
+ if (!session) {
50
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /admin/login first");
51
+ }
52
+ const minted = await signAccessToken(deps.db, {
53
+ sub: session.userId,
54
+ scopes: [...HOST_ADMIN_SCOPES],
55
+ audience: HOST_ADMIN_AUDIENCE,
56
+ clientId: HOST_ADMIN_CLIENT_ID,
57
+ issuer: deps.issuer,
58
+ ttlSeconds: HOST_ADMIN_TOKEN_TTL_SECONDS,
59
+ });
60
+ return new Response(
61
+ JSON.stringify({
62
+ token: minted.token,
63
+ expires_at: minted.expiresAt,
64
+ scopes: HOST_ADMIN_SCOPES,
65
+ }),
66
+ {
67
+ status: 200,
68
+ headers: {
69
+ "content-type": "application/json",
70
+ // No browser cache — token rotates per-fetch, and a stale 200 from a
71
+ // back/forward navigation could hand the SPA a long-expired JWT.
72
+ "cache-control": "no-store",
73
+ },
74
+ },
75
+ );
76
+ }
77
+
78
+ function jsonError(status: number, error: string, description: string): Response {
79
+ return new Response(JSON.stringify({ error, error_description: description }), {
80
+ status,
81
+ headers: { "content-type": "application/json" },
82
+ });
83
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `GET /admin/vault-admin-token/<name>` — exchange a valid admin session
3
+ * cookie for a short-lived JWT carrying `vault:<name>:admin`.
4
+ *
5
+ * Why this exists: the per-vault admin SPA (vault#216 / vault PR #219) needs
6
+ * a Bearer to call vault-internal admin endpoints (token mint/revoke,
7
+ * config edits). `vault:<name>:admin` is non-requestable from the public
8
+ * `/oauth/authorize` flow (`scope-explanations.ts:isNonRequestableScope`)
9
+ * — only the local session-cookie path can mint it.
10
+ *
11
+ * The minted JWT is handed to the vault SPA via a URL fragment on redirect
12
+ * (`<vault-url><managementUrl>#token=<jwt>`). Vault's admin SPA bootstraps
13
+ * by reading `location.hash`, stashing the token in module-scoped state,
14
+ * and replaceState-ing the fragment off the URL.
15
+ *
16
+ * Validation: `<name>` must match a vault instance currently in services.json.
17
+ * That keeps a forged URL from minting `vault:does-not-exist:admin` and
18
+ * masking a typo as a real (but unusable) credential. Resolved via the
19
+ * already-built well-known doc — same source of truth the SPA's vault list
20
+ * reads.
21
+ */
22
+ import type { Database } from "bun:sqlite";
23
+ import { signAccessToken } from "./jwt-sign.ts";
24
+ import { findSession, parseSessionCookie } from "./sessions.ts";
25
+
26
+ /** Short TTL — matches host-admin-token. SPA re-fetches on near-expiry. */
27
+ export const VAULT_ADMIN_TOKEN_TTL_SECONDS = 10 * 60;
28
+ const VAULT_ADMIN_CLIENT_ID = "parachute-hub-spa";
29
+
30
+ /** Same shape as the manifest name validator — keeps URL-injection out. */
31
+ const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
32
+
33
+ export interface MintVaultAdminTokenDeps {
34
+ db: Database;
35
+ /** Hub origin — written into JWT `iss`. */
36
+ issuer: string;
37
+ /** Names of currently-installed vault instances; the request name must be in this set. */
38
+ knownVaultNames: ReadonlySet<string>;
39
+ }
40
+
41
+ export async function handleVaultAdminToken(
42
+ req: Request,
43
+ vaultName: string,
44
+ deps: MintVaultAdminTokenDeps,
45
+ ): Promise<Response> {
46
+ if (req.method !== "GET") {
47
+ return jsonError(405, "method_not_allowed", "use GET");
48
+ }
49
+ if (!VAULT_NAME_RE.test(vaultName)) {
50
+ return jsonError(400, "invalid_request", `vault name "${vaultName}" is not a valid identifier`);
51
+ }
52
+ if (!deps.knownVaultNames.has(vaultName)) {
53
+ return jsonError(404, "not_found", `no vault named "${vaultName}" in this hub`);
54
+ }
55
+ const sid = parseSessionCookie(req.headers.get("cookie"));
56
+ const session = sid ? findSession(deps.db, sid) : null;
57
+ if (!session) {
58
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /admin/login first");
59
+ }
60
+ const scope = `vault:${vaultName}:admin`;
61
+ // Per-vault audience: vault validates the JWT's `aud` claim against
62
+ // `vault.<name>` derived from its own URL-bound config (vault src/auth.ts
63
+ // line ~167 — `expectedAudience: vault.${vaultConfig.name}`). Same shape
64
+ // as `inferAudience` in oauth-handlers.ts for the public OAuth flow, so
65
+ // hub-minted and OAuth-minted tokens are indistinguishable to vault. A
66
+ // single `audience: "hub"` constant here was wrong end-to-end and broke
67
+ // every Manage-button click against the vault SPA (PR #173 follow-up).
68
+ const audience = `vault.${vaultName}`;
69
+ const minted = await signAccessToken(deps.db, {
70
+ sub: session.userId,
71
+ scopes: [scope],
72
+ audience,
73
+ clientId: VAULT_ADMIN_CLIENT_ID,
74
+ issuer: deps.issuer,
75
+ ttlSeconds: VAULT_ADMIN_TOKEN_TTL_SECONDS,
76
+ });
77
+ return new Response(
78
+ JSON.stringify({
79
+ token: minted.token,
80
+ expires_at: minted.expiresAt,
81
+ scopes: [scope],
82
+ }),
83
+ {
84
+ status: 200,
85
+ headers: {
86
+ "content-type": "application/json",
87
+ "cache-control": "no-store",
88
+ },
89
+ },
90
+ );
91
+ }
92
+
93
+ function jsonError(status: number, error: string, description: string): Response {
94
+ return new Response(JSON.stringify({ error, error_description: description }), {
95
+ status,
96
+ headers: { "content-type": "application/json" },
97
+ });
98
+ }