@openparachute/hub 0.3.0-rc.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) 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 +1063 -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 +616 -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 +851 -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 +269 -38
  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-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
package/src/hub-server.ts CHANGED
@@ -10,30 +10,79 @@
10
10
  * that localhost backing.
11
11
  *
12
12
  * Routes (all bound to 127.0.0.1):
13
- * / → hub.html (text/html)
14
- * /hub.html → hub.html (text/html)
15
- * /.well-known/parachute.json parachute.json (application/json)
16
- * anything else 404
13
+ * / → hub.html
14
+ * /hub.html → hub.html
15
+ * /.well-known/parachute.json built dynamically from services.json
16
+ * /.well-known/jwks.json JWKS from hub.db
17
+ * /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
18
+ * /oauth/authorize (GET + POST) → login → consent → auth code
19
+ * /oauth/token (POST) → authorization_code + refresh_token grants
20
+ * /oauth/register (POST) → RFC 7591 dynamic client registration
21
+ * anything else → 404
17
22
  *
18
23
  * Invoked as:
19
- * bun <this-file> --port <n> --well-known-dir <path>
24
+ * bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
20
25
  *
21
- * `--well-known-dir` is the directory containing both `hub.html` and
22
- * `parachute.json` (both written by `parachute expose`). Kept as one flag so
23
- * the lifecycle side doesn't have to care how the hub server lays out files.
26
+ * `--well-known-dir` is the directory containing `hub.html` (written by
27
+ * `parachute expose`). The well-known doc is no longer served from this
28
+ * directory it's built on every GET from `services.json` so changes to
29
+ * the installed-services list (e.g. `parachute vault create`) are visible
30
+ * immediately without a re-expose.
31
+ *
32
+ * `--db` is the path to `hub.db`. JWKS is served live from the DB so key
33
+ * rotation takes effect on the next request without re-running
34
+ * `parachute expose`. Defaults to `~/.parachute/hub.db` (overridable via
35
+ * `$PARACHUTE_HOME`).
24
36
  */
25
37
 
38
+ import type { Database } from "bun:sqlite";
26
39
  import { existsSync } from "node:fs";
27
- import { join, resolve } from "node:path";
40
+ import { dirname, join, resolve } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+ import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
43
+ import {
44
+ handleAdminConfigGet,
45
+ handleAdminConfigPost,
46
+ handleAdminLoginGet,
47
+ handleAdminLoginPost,
48
+ handleAdminLogoutPost,
49
+ } from "./admin-handlers.ts";
50
+ import { handleHostAdminToken } from "./admin-host-admin-token.ts";
51
+ import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
52
+ import { handleCreateVault } from "./admin-vaults.ts";
53
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
54
+ import { HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
55
+ import { hubDbPath, openHubDb } from "./hub-db.ts";
56
+ import { pemToJwk } from "./jwks.ts";
57
+ import {
58
+ type ModuleManifest,
59
+ readModuleManifest as defaultReadModuleManifest,
60
+ } from "./module-manifest.ts";
61
+ import {
62
+ authorizationServerMetadata,
63
+ handleAuthorizeGet,
64
+ handleAuthorizePost,
65
+ handleRegister,
66
+ handleRevoke,
67
+ handleToken,
68
+ } from "./oauth-handlers.ts";
69
+ import { clearPid, writePid } from "./process-state.ts";
70
+ import { type ServiceEntry, readManifest } from "./services-manifest.ts";
71
+ import { getAllPublicKeys } from "./signing-keys.ts";
72
+ import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
28
73
 
29
74
  interface Args {
30
75
  port: number;
31
76
  wellKnownDir: string;
77
+ dbPath: string;
78
+ issuer: string | undefined;
32
79
  }
33
80
 
34
81
  function parseArgs(argv: string[]): Args {
35
82
  let port: number | undefined;
36
83
  let wellKnownDir: string | undefined;
84
+ let dbPath: string | undefined;
85
+ let issuer: string | undefined;
37
86
  for (let i = 0; i < argv.length; i++) {
38
87
  const a = argv[i];
39
88
  if (a === "--port") {
@@ -48,23 +97,335 @@ function parseArgs(argv: string[]): Args {
48
97
  const v = argv[++i];
49
98
  if (!v) throw new Error("--well-known-dir requires a value");
50
99
  wellKnownDir = resolve(v);
100
+ } else if (a === "--db") {
101
+ const v = argv[++i];
102
+ if (!v) throw new Error("--db requires a value");
103
+ dbPath = resolve(v);
104
+ } else if (a === "--issuer") {
105
+ const v = argv[++i];
106
+ if (!v) throw new Error("--issuer requires a value");
107
+ issuer = v.replace(/\/+$/, "");
51
108
  } else {
52
109
  throw new Error(`unknown argument: ${a}`);
53
110
  }
54
111
  }
55
112
  if (port === undefined) throw new Error("--port is required");
56
113
  if (wellKnownDir === undefined) throw new Error("--well-known-dir is required");
57
- return { port, wellKnownDir };
114
+ return { port, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
115
+ }
116
+
117
+ /**
118
+ * Resolve which vault ServiceEntry should handle a given request pathname.
119
+ *
120
+ * Vault paths look like `/vault/<name>` or `/vault/<name>/<rest>`. A request
121
+ * matches a vault entry if the pathname equals one of its mount paths exactly
122
+ * or starts with `<mount>/`. When several mounts could match (one vault has
123
+ * `/vault` and another has `/vault/foo` — pathological but representable),
124
+ * the longer mount wins so the more specific install handles it.
125
+ *
126
+ * Returns `undefined` when no vault is mounted at this pathname; the caller
127
+ * 404s. The lookup is per-request because services.json mutates whenever
128
+ * `parachute vault create` runs and we don't want the user to re-expose just
129
+ * to make a freshly-created vault routable on the tailnet (#144).
130
+ */
131
+ export function findVaultUpstream(
132
+ services: readonly ServiceEntry[],
133
+ pathname: string,
134
+ ): { port: number; mount: string; entry: ServiceEntry } | undefined {
135
+ let best: { port: number; mount: string; entry: ServiceEntry } | undefined;
136
+ for (const s of services) {
137
+ if (!isVaultEntry(s)) continue;
138
+ for (const path of s.paths) {
139
+ if (pathname === path || pathname.startsWith(`${path}/`)) {
140
+ if (!best || path.length > best.mount.length) {
141
+ best = { port: s.port, mount: path, entry: s };
142
+ }
143
+ }
144
+ }
145
+ }
146
+ return best;
58
147
  }
59
148
 
60
- export function hubFetch(wellKnownDir: string): (req: Request) => Response {
149
+ /**
150
+ * Reverse-proxy a `/vault/<name>/*` request onto the vault backend's loopback
151
+ * port. The path is preserved end-to-end (vault since paraclaw#18 expects
152
+ * requests at `/vault/<name>/...` not stripped to `/...`), so the upstream URL
153
+ * mirrors the incoming pathname exactly.
154
+ *
155
+ * `manifestPath` is the services.json path from `HubFetchDeps`. Read on every
156
+ * proxied request so a vault created seconds ago is reachable without a
157
+ * re-expose — same dynamism as the well-known doc (#135).
158
+ *
159
+ * Returns `undefined` when no vault is currently mounted at this pathname so
160
+ * the caller falls through to the catch-all 404. Returns a 502 response when
161
+ * the upstream connection fails (vault crashed, port shifted) — the upstream
162
+ * URL was valid; we just couldn't reach it.
163
+ *
164
+ * Hop-by-hop notes: WebSocket upgrades and HTTP/2 trailers don't traverse
165
+ * fetch-based proxies cleanly. Vault uses neither today; if a future service
166
+ * needs them, switch to a Node http.IncomingMessage / http.request pair.
167
+ */
168
+ async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
169
+ let services: readonly ServiceEntry[];
170
+ try {
171
+ services = readManifest(manifestPath).services;
172
+ } catch (err) {
173
+ const msg = err instanceof Error ? err.message : String(err);
174
+ return new Response(JSON.stringify({ error: `vault routing failed: ${msg}` }), {
175
+ status: 500,
176
+ headers: { "content-type": "application/json" },
177
+ });
178
+ }
179
+ const url = new URL(req.url);
180
+ const match = findVaultUpstream(services, url.pathname);
181
+ if (!match) return undefined;
182
+
183
+ const upstream = `http://127.0.0.1:${match.port}${url.pathname}${url.search}`;
184
+ const headers = new Headers(req.headers);
185
+ // Host comes from the requester (tailnet FQDN); the loopback target wants
186
+ // its own. Bun's fetch fills it in when omitted.
187
+ headers.delete("host");
188
+
189
+ const init: RequestInit & { duplex?: "half" } = {
190
+ method: req.method,
191
+ headers,
192
+ redirect: "manual",
193
+ };
194
+ if (req.method !== "GET" && req.method !== "HEAD") {
195
+ init.body = req.body;
196
+ init.duplex = "half";
197
+ }
198
+ try {
199
+ return await fetch(upstream, init);
200
+ } catch (err) {
201
+ const msg = err instanceof Error ? err.message : String(err);
202
+ return new Response(JSON.stringify({ error: `vault upstream unreachable: ${msg}` }), {
203
+ status: 502,
204
+ headers: { "content-type": "application/json" },
205
+ });
206
+ }
207
+ }
208
+
209
+ export interface HubFetchDeps {
210
+ /**
211
+ * Lazily opens (or returns a cached handle to) the hub DB. Optional so
212
+ * tests can exercise routes that don't touch the DB (the well-known doc,
213
+ * static assets) without standing up a fixture; runtime returns 503 for
214
+ * DB-dependent routes when this is absent.
215
+ */
216
+ getDb?: () => Database;
217
+ /**
218
+ * Hub origin used as the OAuth `iss` claim and to build the authorization-
219
+ * server metadata document. When omitted, OAuth endpoints fall back to the
220
+ * request's own origin — fine for local dev, surprising under a reverse
221
+ * proxy where the request origin is the loopback.
222
+ */
223
+ issuer?: string;
224
+ /**
225
+ * Path to the services manifest read on each `/.well-known/parachute.json`
226
+ * GET. Tests point this at a tmpdir; production uses the default ecosystem
227
+ * path. Read-on-each-request (cheap — single ~KB JSON parse) is what makes
228
+ * the doc reflect `parachute vault create` etc. without re-running expose.
229
+ */
230
+ manifestPath?: string;
231
+ /**
232
+ * Directory containing the built SPA bundle (`index.html` + `assets/`). When
233
+ * absent, the hub auto-resolves to `<repo>/web/ui/dist/` — handy for the
234
+ * default bun-linked checkout. Tests point this at a fixture (or omit it +
235
+ * disable the mount). When the dir doesn't exist on disk, `/hub/*` routes
236
+ * 503 with a "run `bun run build` in web/ui" hint.
237
+ */
238
+ spaDistDir?: string;
239
+ /**
240
+ * Override the per-module `.parachute/module.json` reader. Production reads
241
+ * from disk via `module-manifest.readModuleManifest`; tests inject a fake
242
+ * to drive `managementUrl` into the well-known doc without standing up
243
+ * fixture installDirs.
244
+ */
245
+ readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
246
+ }
247
+
248
+ /**
249
+ * For each vault `ServiceEntry` with a known `installDir`, read its
250
+ * `.parachute/module.json` and surface the optional `managementUrl`. Returns
251
+ * a `name → managementUrl` map keyed by services.json entry name.
252
+ *
253
+ * Quiet on per-entry errors: a malformed module.json on one vault shouldn't
254
+ * 500 the entire well-known doc — its row just renders without a "Manage"
255
+ * link. The validator already throws structured errors from
256
+ * `readModuleManifest`; logging them once here is the right floor.
257
+ */
258
+ async function loadManagementUrls(
259
+ services: readonly ServiceEntry[],
260
+ read: (installDir: string) => Promise<ModuleManifest | null>,
261
+ ): Promise<Map<string, string>> {
262
+ const out = new Map<string, string>();
263
+ await Promise.all(
264
+ services.map(async (s) => {
265
+ if (!isVaultEntry(s) || !s.installDir) return;
266
+ try {
267
+ const m = await read(s.installDir);
268
+ if (m?.managementUrl) out.set(s.name, m.managementUrl);
269
+ } catch (err) {
270
+ const msg = err instanceof Error ? err.message : String(err);
271
+ console.warn(`well-known: skipping managementUrl for ${s.name}: ${msg}`);
272
+ }
273
+ }),
274
+ );
275
+ return out;
276
+ }
277
+
278
+ /**
279
+ * Resolve the SPA bundle dir. We anchor to this file's location so a
280
+ * `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
281
+ * Tests / production override via `HubFetchDeps.spaDistDir`.
282
+ */
283
+ function defaultSpaDistDir(): string {
284
+ // import.meta.dir is the dir holding *this* file (`src/`); the SPA bundle
285
+ // sits at `<repo>/web/ui/dist/`, two hops up + over.
286
+ const here = dirname(fileURLToPath(import.meta.url));
287
+ return resolve(here, "..", "web", "ui", "dist");
288
+ }
289
+
290
+ /**
291
+ * The SPA serves at two mounts:
292
+ *
293
+ * - `/vault` — primary, since hub#168-realignment. Matches the operator
294
+ * pattern of `/<module>` as the entry point (alongside `/notes`, `/agent`,
295
+ * `/scribe`). VaultsList, NewVault, and per-vault detail routes hang off
296
+ * here.
297
+ * - `/hub` — back-compat. `/hub/permissions` (cross-vault grants) is a hub
298
+ * concern and stays where bookmarks expect it. `/hub/vaults*` is a 301 to
299
+ * `/vault*` further up the dispatch — keeping it out of this mount.
300
+ *
301
+ * Both mounts serve the same SPA bundle. Asset URLs are origin-absolute
302
+ * (`/vault/assets/...`) per the build base, so the HTML loads correctly
303
+ * regardless of which mount served it. main.tsx detects the active mount
304
+ * at runtime and configures react-router's `basename` accordingly.
305
+ */
306
+ type SpaMount = "/vault" | "/hub";
307
+
308
+ /**
309
+ * Pick a content type for static assets the SPA build produces. Vite's
310
+ * standard fingerprinted output is the realistic surface — js / css / svg /
311
+ * png / woff2 / ico. We don't reach for a full mime db; mismatches show up
312
+ * loud (a `.js` served as `text/html` is unmistakable) and the list is
313
+ * trivially extensible if a future feature adds an asset type.
314
+ */
315
+ function spaContentType(pathname: string): string {
316
+ const ext = pathname.slice(pathname.lastIndexOf(".") + 1).toLowerCase();
317
+ switch (ext) {
318
+ case "html":
319
+ return "text/html; charset=utf-8";
320
+ case "js":
321
+ case "mjs":
322
+ return "application/javascript; charset=utf-8";
323
+ case "css":
324
+ return "text/css; charset=utf-8";
325
+ case "svg":
326
+ return "image/svg+xml";
327
+ case "png":
328
+ return "image/png";
329
+ case "ico":
330
+ return "image/x-icon";
331
+ case "woff2":
332
+ return "font/woff2";
333
+ case "woff":
334
+ return "font/woff";
335
+ case "json":
336
+ return "application/json";
337
+ case "map":
338
+ return "application/json";
339
+ case "txt":
340
+ return "text/plain; charset=utf-8";
341
+ default:
342
+ return "application/octet-stream";
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Serve a single file under the SPA mount, falling back to `index.html`
348
+ * for client-side-routed paths (anything that doesn't resolve to a real
349
+ * file under `dist/`). Path-traversal is blocked twice: the asset-shape
350
+ * filter rejects sub-paths containing "..", and the resolved absolute
351
+ * path is checked to start with `dist/` before any read.
352
+ *
353
+ * `mount` is the prefix being served (`/vault` or `/hub`); we strip it
354
+ * from `pathname` to land on the file path inside `dist/`.
355
+ */
356
+ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
357
+ if (!existsSync(spaDistDir)) {
358
+ return new Response(
359
+ "hub SPA bundle not found — run `bun run build` in web/ui/ to produce dist/",
360
+ { status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
361
+ );
362
+ }
363
+ // Strip the mount prefix; "/vault" → "", "/vault/" → "/", "/vault/x" → "/x".
364
+ const sub = pathname === mount ? "" : pathname.slice(mount.length);
365
+ const indexPath = join(spaDistDir, "index.html");
366
+
367
+ // Empty / mount-root / any non-asset request → SPA shell. The router takes
368
+ // it from there. First defense against traversal: bare paths and anything
369
+ // containing ".." never enter the asset branch — they fall through to the
370
+ // shell below.
371
+ const looksLikeAsset = sub.length > 0 && /\.[a-z0-9]+$/i.test(sub) && !sub.includes("..");
372
+ if (!looksLikeAsset) {
373
+ return new Response(Bun.file(indexPath), {
374
+ headers: { "content-type": "text/html; charset=utf-8" },
375
+ });
376
+ }
377
+
378
+ const filePath = resolve(spaDistDir, `.${sub}`);
379
+ // Second defense: even if a future tweak loosens looksLikeAsset, refuse
380
+ // any resolved path that escapes dist/. Belt-and-braces.
381
+ if (!filePath.startsWith(`${spaDistDir}/`)) {
382
+ return new Response("not found", { status: 404 });
383
+ }
384
+ if (!existsSync(filePath)) {
385
+ // Asset request that doesn't resolve to a real file → SPA shell.
386
+ // (e.g. `/vault/foo` with a typo'd extension shouldn't 404 the page.)
387
+ return new Response(Bun.file(indexPath), {
388
+ headers: { "content-type": "text/html; charset=utf-8" },
389
+ });
390
+ }
391
+ return new Response(Bun.file(filePath), {
392
+ headers: { "content-type": spaContentType(filePath) },
393
+ });
394
+ }
395
+
396
+ export function hubFetch(
397
+ wellKnownDir: string,
398
+ deps?: HubFetchDeps,
399
+ ): (req: Request) => Response | Promise<Response> {
61
400
  const hubHtmlPath = join(wellKnownDir, "hub.html");
62
- const parachuteJsonPath = join(wellKnownDir, "parachute.json");
401
+ const getDb = deps?.getDb;
402
+ const configuredIssuer = deps?.issuer;
403
+ const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
404
+ const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
63
405
 
64
- return (req) => {
406
+ const oauthDeps = (req: Request) => ({
407
+ issuer: configuredIssuer ?? new URL(req.url).origin,
408
+ });
409
+
410
+ return async (req) => {
65
411
  const url = new URL(req.url);
66
412
  const pathname = url.pathname;
67
413
 
414
+ // 301 back-compat: `/hub/vaults*` was the SPA's vault-management entry
415
+ // before hub#168-realignment. Bookmarks and any cached operator-typed
416
+ // URLs land here; permanent redirect keeps them working without leaving
417
+ // a dangling SPA route. Query string preserved; fragment is client-side
418
+ // and survives the redirect at the browser. Method-agnostic — even a
419
+ // misrouted POST gets the redirect, since there's no /hub/vaults POST
420
+ // endpoint to protect.
421
+ if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
422
+ const newPath = `/vault${pathname.slice("/hub/vaults".length)}`;
423
+ return new Response("", {
424
+ status: 301,
425
+ headers: { location: `${newPath}${url.search}` },
426
+ });
427
+ }
428
+
68
429
  if (pathname === "/" || pathname === "/hub.html") {
69
430
  if (!existsSync(hubHtmlPath)) {
70
431
  return new Response("hub.html not found", { status: 404 });
@@ -87,27 +448,287 @@ export function hubFetch(wellKnownDir: string): (req: Request) => Response {
87
448
  if (req.method === "OPTIONS") {
88
449
  return new Response(null, { status: 204, headers: corsHeaders });
89
450
  }
90
- if (!existsSync(parachuteJsonPath)) {
91
- return new Response("parachute.json not found", {
92
- status: 404,
93
- headers: corsHeaders,
451
+ // Built dynamically from services.json on every request — that's what
452
+ // makes `parachute vault create` show up here without re-running
453
+ // expose. canonicalOrigin reuses the OAuth issuer fallback: prefer the
454
+ // configured public origin (set by `--issuer https://<fqdn>`), else
455
+ // the request's own origin (fine for direct loopback hits).
456
+ try {
457
+ const manifest = readManifest(manifestPath);
458
+ const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
459
+ const managementUrlByName = await loadManagementUrls(
460
+ manifest.services,
461
+ deps?.readModuleManifest ?? defaultReadModuleManifest,
462
+ );
463
+ const doc = buildWellKnown({
464
+ services: manifest.services,
465
+ canonicalOrigin,
466
+ managementUrlFor: (entry) => managementUrlByName.get(entry.name),
467
+ });
468
+ return new Response(JSON.stringify(doc), {
469
+ headers: { "content-type": "application/json", ...corsHeaders },
470
+ });
471
+ } catch (err) {
472
+ // ServicesManifestError lands here too — corrupt JSON or schema
473
+ // violation in services.json shouldn't crash the hub for everyone.
474
+ const msg = err instanceof Error ? err.message : String(err);
475
+ return new Response(JSON.stringify({ error: `well-known build failed: ${msg}` }), {
476
+ status: 500,
477
+ headers: { "content-type": "application/json", ...corsHeaders },
94
478
  });
95
479
  }
96
- return new Response(Bun.file(parachuteJsonPath), {
97
- headers: { "content-type": "application/json", ...corsHeaders },
480
+ }
481
+
482
+ if (pathname === "/.well-known/jwks.json") {
483
+ // JWKS is also a cross-origin fetch target (browser-side OAuth
484
+ // libraries pull this to verify access tokens). Same wildcard CORS
485
+ // shape as parachute.json — JWKS is public-by-design (only public
486
+ // keys leave the server).
487
+ const corsHeaders = {
488
+ "access-control-allow-origin": "*",
489
+ "access-control-allow-methods": "GET, OPTIONS",
490
+ };
491
+ if (req.method === "OPTIONS") {
492
+ return new Response(null, { status: 204, headers: corsHeaders });
493
+ }
494
+ if (!getDb) {
495
+ return new Response('{"error":"jwks unavailable: db not configured"}', {
496
+ status: 503,
497
+ headers: { "content-type": "application/json", ...corsHeaders },
498
+ });
499
+ }
500
+ try {
501
+ const db = getDb();
502
+ const keys = getAllPublicKeys(db).map((k) => pemToJwk(k.publicKeyPem, k.kid));
503
+ return new Response(JSON.stringify({ keys }), {
504
+ headers: { "content-type": "application/json", ...corsHeaders },
505
+ });
506
+ } catch (err) {
507
+ const msg = err instanceof Error ? err.message : String(err);
508
+ return new Response(JSON.stringify({ error: `jwks failed: ${msg}` }), {
509
+ status: 500,
510
+ headers: { "content-type": "application/json", ...corsHeaders },
511
+ });
512
+ }
513
+ }
514
+
515
+ if (pathname === "/.well-known/oauth-authorization-server") {
516
+ // Public discovery doc — clients pull this cross-origin to find the
517
+ // authorize/token endpoints. Same wildcard CORS shape as the JWKS
518
+ // and the parachute manifest.
519
+ const corsHeaders = {
520
+ "access-control-allow-origin": "*",
521
+ "access-control-allow-methods": "GET, OPTIONS",
522
+ };
523
+ if (req.method === "OPTIONS") {
524
+ return new Response(null, { status: 204, headers: corsHeaders });
525
+ }
526
+ const res = authorizationServerMetadata(oauthDeps(req));
527
+ // Fold CORS into the existing JSON response.
528
+ const merged = new Headers(res.headers);
529
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
530
+ return new Response(res.body, { status: res.status, headers: merged });
531
+ }
532
+
533
+ if (pathname === "/oauth/authorize") {
534
+ if (!getDb) {
535
+ return new Response("hub db not configured", { status: 503 });
536
+ }
537
+ if (req.method === "GET") return handleAuthorizeGet(getDb(), req, oauthDeps(req));
538
+ if (req.method === "POST") return handleAuthorizePost(getDb(), req, oauthDeps(req));
539
+ return new Response("method not allowed", { status: 405 });
540
+ }
541
+
542
+ if (pathname === "/oauth/token") {
543
+ if (!getDb) {
544
+ return new Response("hub db not configured", { status: 503 });
545
+ }
546
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
547
+ return handleToken(getDb(), req, oauthDeps(req));
548
+ }
549
+
550
+ if (pathname === "/oauth/register") {
551
+ if (!getDb) {
552
+ return new Response("hub db not configured", { status: 503 });
553
+ }
554
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
555
+ return handleRegister(getDb(), req, oauthDeps(req));
556
+ }
557
+
558
+ if (pathname === "/oauth/revoke") {
559
+ if (!getDb) {
560
+ return new Response("hub db not configured", { status: 503 });
561
+ }
562
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
563
+ return handleRevoke(getDb(), req, oauthDeps(req));
564
+ }
565
+
566
+ if (pathname === "/vaults") {
567
+ if (!getDb) {
568
+ return new Response("hub db not configured", { status: 503 });
569
+ }
570
+ return handleCreateVault(req, {
571
+ db: getDb(),
572
+ issuer: oauthDeps(req).issuer,
573
+ });
574
+ }
575
+
576
+ // /hub SPA mount (back-compat). Kept for `/hub/permissions` and any other
577
+ // hub-level admin surface that lived under /hub/ before the realignment.
578
+ // /hub/vaults* is a separate concern handled by the 301 redirect lower
579
+ // down — the redirect runs first so it never reaches here. Only GET —
580
+ // POSTs for vault create go to /vaults, not the SPA mount.
581
+ if (pathname === "/hub" || pathname.startsWith("/hub/")) {
582
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
583
+ return serveSpa(spaDistDir, pathname, "/hub");
584
+ }
585
+
586
+ if (pathname === "/admin/host-admin-token") {
587
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
588
+ return handleHostAdminToken(req, {
589
+ db: getDb(),
590
+ issuer: oauthDeps(req).issuer,
591
+ });
592
+ }
593
+
594
+ if (pathname.startsWith("/admin/vault-admin-token/")) {
595
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
596
+ const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
597
+ // The vault name must correspond to an actual vault instance — same
598
+ // shape the well-known doc derives. Source from services.json so a
599
+ // freshly-created vault is mintable on the next request without a
600
+ // restart.
601
+ const manifest = readManifest(manifestPath);
602
+ const knownVaultNames = new Set<string>();
603
+ for (const s of manifest.services) {
604
+ if (!isVaultEntry(s)) continue;
605
+ for (const path of s.paths) knownVaultNames.add(vaultInstanceNameFor(s.name, path));
606
+ }
607
+ return handleVaultAdminToken(req, vaultName, {
608
+ db: getDb(),
609
+ issuer: oauthDeps(req).issuer,
610
+ knownVaultNames,
611
+ });
612
+ }
613
+
614
+ if (pathname === "/api/grants") {
615
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
616
+ return handleListGrants(req, {
617
+ db: getDb(),
618
+ issuer: oauthDeps(req).issuer,
619
+ });
620
+ }
621
+
622
+ if (pathname.startsWith("/api/grants/")) {
623
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
624
+ const clientId = decodeURIComponent(pathname.slice("/api/grants/".length));
625
+ if (!clientId || clientId.includes("/")) {
626
+ return new Response("not found", { status: 404 });
627
+ }
628
+ return handleRevokeGrant(req, clientId, {
629
+ db: getDb(),
630
+ issuer: oauthDeps(req).issuer,
98
631
  });
99
632
  }
100
633
 
634
+ if (pathname === "/admin/login") {
635
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
636
+ if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
637
+ if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
638
+ return new Response("method not allowed", { status: 405 });
639
+ }
640
+
641
+ if (pathname === "/admin/logout") {
642
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
643
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
644
+ return handleAdminLogoutPost(getDb(), req);
645
+ }
646
+
647
+ if (pathname === "/admin/config") {
648
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
649
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
650
+ return handleAdminConfigGet(getDb(), req);
651
+ }
652
+
653
+ if (pathname.startsWith("/admin/config/")) {
654
+ if (!getDb) return new Response("hub db not configured", { status: 503 });
655
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
656
+ const name = decodeURIComponent(pathname.slice("/admin/config/".length));
657
+ if (!name || name.includes("/")) {
658
+ return new Response("not found", { status: 404 });
659
+ }
660
+ return handleAdminConfigPost(getDb(), req, name);
661
+ }
662
+
663
+ // /vault — primary SPA mount + dynamic per-vault proxy share this
664
+ // namespace. Order matters:
665
+ // 1. `/vault` exact → SPA shell (vault list).
666
+ // 2. `/vault/<known-vault>/...` → proxy to the vault backend, picked
667
+ // from services.json by longest-mount-prefix. Read per request so a
668
+ // `parachute vault create` performed after `parachute expose` is
669
+ // immediately reachable (#144).
670
+ // 3. `/vault/<spa-route>` → SPA shell. Only single-segment paths
671
+ // (`/vault/new`, `/vault/<name>`) and `/vault/assets/*` count as
672
+ // SPA routes. Multi-segment requests like `/vault/<unknown>/health`
673
+ // are vault-API shapes targeting a non-existent vault and 404 —
674
+ // otherwise the SPA shell would mask backend 404s with HTML.
675
+ // `new` and `assets` are reserved vault names (see
676
+ // `RESERVED_VAULT_NAMES` in admin-vaults.ts) so an operator
677
+ // can't register a vault that shadows the SPA's create route or
678
+ // its static asset bundle.
679
+ if (pathname === "/vault") {
680
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
681
+ return serveSpa(spaDistDir, pathname, "/vault");
682
+ }
683
+ if (pathname.startsWith("/vault/")) {
684
+ const proxied = await proxyToVault(req, manifestPath);
685
+ if (proxied) return proxied;
686
+ const sub = pathname.slice("/vault/".length);
687
+ const isSpaRoute = !sub.includes("/") || sub.startsWith("assets/");
688
+ if (!isSpaRoute) return new Response("not found", { status: 404 });
689
+ if (req.method !== "GET") return new Response("not found", { status: 404 });
690
+ return serveSpa(spaDistDir, pathname, "/vault");
691
+ }
692
+
101
693
  return new Response("not found", { status: 404 });
102
694
  };
103
695
  }
104
696
 
105
697
  if (import.meta.main) {
106
- const { port, wellKnownDir } = parseArgs(process.argv.slice(2));
698
+ const { port, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
699
+ let cachedDb: Database | undefined;
700
+ const getDb = () => {
701
+ if (!cachedDb) cachedDb = openHubDb(dbPath);
702
+ return cachedDb;
703
+ };
107
704
  Bun.serve({
108
705
  port,
109
706
  hostname: "127.0.0.1",
110
- fetch: hubFetch(wellKnownDir),
707
+ fetch: hubFetch(wellKnownDir, { getDb, issuer }),
708
+ });
709
+ // Register PID + port from the running hub itself so any startup path
710
+ // (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from
711
+ // a developer or supervisor) lands the same lifecycle files at
712
+ // ~/.parachute/hub/run/. Manual starts used to be invisible — `parachute
713
+ // expose` then spawned another hub that collided on 1939 (#148).
714
+ writePid(HUB_SVC, process.pid);
715
+ writeHubPort(port);
716
+ const cleanup = () => {
717
+ clearPid(HUB_SVC);
718
+ clearHubPort();
719
+ };
720
+ process.on("SIGINT", () => {
721
+ cleanup();
722
+ process.exit(0);
723
+ });
724
+ process.on("SIGTERM", () => {
725
+ cleanup();
726
+ process.exit(0);
111
727
  });
112
- console.log(`parachute-hub listening on http://127.0.0.1:${port} (dir=${wellKnownDir})`);
728
+ process.on("exit", cleanup);
729
+ console.log(
730
+ `parachute-hub listening on http://127.0.0.1:${port} (dir=${wellKnownDir}, db=${dbPath}${
731
+ issuer ? `, issuer=${issuer}` : ""
732
+ })`,
733
+ );
113
734
  }