@openparachute/hub 0.5.7 → 0.5.10-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/hub-server.ts
CHANGED
|
@@ -9,17 +9,69 @@
|
|
|
9
9
|
* `tailscale serve … --set-path=/ http://127.0.0.1:<port>`. This shim is
|
|
10
10
|
* that localhost backing.
|
|
11
11
|
*
|
|
12
|
-
* Routes (all bound to 127.0.0.1)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* /
|
|
19
|
-
* /
|
|
20
|
-
* /
|
|
21
|
-
* /
|
|
22
|
-
*
|
|
12
|
+
* Routes (all bound to 127.0.0.1) — listed in dispatch order. Order is
|
|
13
|
+
* load-bearing: 301 redirects fire before the proxies and SPA mount they
|
|
14
|
+
* preempt; admin API endpoints fire before the /admin/* SPA catch-all.
|
|
15
|
+
*
|
|
16
|
+
* # Pre-rename 301 back-compat (hub#231 — first so they preempt any
|
|
17
|
+
* # remaining handlers under /vault or /hub).
|
|
18
|
+
* /vault, /vault/, /vault/new → 301 → /admin/vaults[/new]
|
|
19
|
+
* /hub/vaults* → 301 → /admin/vaults*
|
|
20
|
+
* /hub/permissions → 301 → /admin/permissions
|
|
21
|
+
* /hub/tokens → 301 → /admin/tokens
|
|
22
|
+
* /hub, /hub/ → 301 → /admin/vaults
|
|
23
|
+
* /admin/login, /admin/logout → 301 → /login, /logout
|
|
24
|
+
*
|
|
25
|
+
* # Discovery + well-known.
|
|
26
|
+
* /, /hub.html → hub.html (the discovery page)
|
|
27
|
+
* /.well-known/parachute.json → built dynamically from services.json
|
|
28
|
+
* /.well-known/parachute-revocation.json → revoked-jti list (hub#212 Phase 1)
|
|
29
|
+
* /.well-known/jwks.json → JWKS from hub.db
|
|
30
|
+
* /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
|
|
31
|
+
*
|
|
32
|
+
* # OAuth issuer.
|
|
33
|
+
* /oauth/authorize (GET + POST) → login → consent → auth code
|
|
34
|
+
* /oauth/authorize/approve (POST) → inline DCR approve form (#208)
|
|
35
|
+
* /oauth/token (POST) → authorization_code + refresh_token grants
|
|
36
|
+
* /oauth/register (POST) → RFC 7591 dynamic client registration
|
|
37
|
+
* /oauth/revoke (POST) → RFC 7009 refresh-token revocation
|
|
38
|
+
*
|
|
39
|
+
* # Admin API + bearer-mint surfaces (must precede /admin/* SPA mount).
|
|
40
|
+
* /vaults (POST) → create vault
|
|
41
|
+
* /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
|
|
42
|
+
* /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
|
|
43
|
+
* /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
|
|
44
|
+
* /api/modules (GET) → curated + installed module catalog (host:auth)
|
|
45
|
+
* /api/modules/channel (PUT) → operator channel toggle (host:admin)
|
|
46
|
+
* /api/modules/:short/install (POST) → bun add + spawn (async op)
|
|
47
|
+
* /api/modules/:short/restart (POST) → supervisor restart (sync)
|
|
48
|
+
* /api/modules/:short/upgrade (POST) → bun add @<channel> + restart (async op)
|
|
49
|
+
* /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
|
|
50
|
+
* /api/modules/operations/:id (GET) → poll async op status
|
|
51
|
+
* /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
|
|
52
|
+
* /api/auth/revoke-token (POST) → revoke registry-row token by jti
|
|
53
|
+
* /api/auth/tokens (GET) → paginated registry list
|
|
54
|
+
* /api/grants (GET) → OAuth consent grants list
|
|
55
|
+
* /api/grants/<client_id> (DELETE) → revoke a single OAuth grant
|
|
56
|
+
* /api/oauth/clients/<id> (GET) → OAuth client details
|
|
57
|
+
* /api/oauth/clients/<id>/approve (POST) → flip a pending client to approved
|
|
58
|
+
* /login (GET + POST) → operator password login
|
|
59
|
+
* /logout (POST) → end admin session
|
|
60
|
+
* /admin/config* → 301 → /admin/vaults (legacy
|
|
61
|
+
* portal retired post-SPA-rework)
|
|
62
|
+
*
|
|
63
|
+
* # Per-vault content proxy (user-facing vault data: Notes PWA, MCP, etc.).
|
|
64
|
+
* /vault/<name>/* → proxy to the vault backend
|
|
65
|
+
*
|
|
66
|
+
* # Admin SPA mount (catch-all under /admin; runs after all admin API
|
|
67
|
+
* # handlers above, so /admin/<known> reaches the right handler and
|
|
68
|
+
* # /admin/<spa-route> serves the SPA shell).
|
|
69
|
+
* /admin, /admin/, /admin/* → SPA shell (vaults / new / permissions / tokens)
|
|
70
|
+
*
|
|
71
|
+
* # Generic services.json-driven proxy (non-vault modules: notes, scribe, agent).
|
|
72
|
+
* /<service-mount>/* → proxy via services.json longest-prefix
|
|
73
|
+
*
|
|
74
|
+
* anything else → 404
|
|
23
75
|
*
|
|
24
76
|
* Invoked as:
|
|
25
77
|
* bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
|
|
@@ -40,10 +92,10 @@ import type { Database } from "bun:sqlite";
|
|
|
40
92
|
import { existsSync } from "node:fs";
|
|
41
93
|
import { dirname, join, resolve } from "node:path";
|
|
42
94
|
import { fileURLToPath } from "node:url";
|
|
95
|
+
import pkg from "../package.json" with { type: "json" };
|
|
96
|
+
import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
43
97
|
import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
|
|
44
98
|
import {
|
|
45
|
-
handleAdminConfigGet,
|
|
46
|
-
handleAdminConfigPost,
|
|
47
99
|
handleAdminLoginGet,
|
|
48
100
|
handleAdminLoginPost,
|
|
49
101
|
handleAdminLogoutPost,
|
|
@@ -51,9 +103,27 @@ import {
|
|
|
51
103
|
import { handleHostAdminToken } from "./admin-host-admin-token.ts";
|
|
52
104
|
import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
|
|
53
105
|
import { handleCreateVault } from "./admin-vaults.ts";
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
106
|
+
import { handleApiMe } from "./api-me.ts";
|
|
107
|
+
import { handleApiMintToken } from "./api-mint-token.ts";
|
|
108
|
+
import {
|
|
109
|
+
getDefaultOperationsRegistry,
|
|
110
|
+
handleInstall,
|
|
111
|
+
handleOperationGet,
|
|
112
|
+
handleRestart,
|
|
113
|
+
handleUninstall,
|
|
114
|
+
handleUpgrade,
|
|
115
|
+
parseModulesPath,
|
|
116
|
+
} from "./api-modules-ops.ts";
|
|
117
|
+
import { handleApiModules, handleApiModulesChannel } from "./api-modules.ts";
|
|
118
|
+
import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
|
|
119
|
+
import { handleApiRevokeToken } from "./api-revoke-token.ts";
|
|
120
|
+
import { handleApiTokens } from "./api-tokens.ts";
|
|
121
|
+
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
122
|
+
import { ensureCsrfToken } from "./csrf.ts";
|
|
123
|
+
import { readExposeState } from "./expose-state.ts";
|
|
124
|
+
import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
|
|
56
125
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
126
|
+
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
57
127
|
import { pemToJwk } from "./jwks.ts";
|
|
58
128
|
import {
|
|
59
129
|
type ModuleManifest,
|
|
@@ -68,6 +138,7 @@ import {
|
|
|
68
138
|
handleRevoke,
|
|
69
139
|
handleToken,
|
|
70
140
|
} from "./oauth-handlers.ts";
|
|
141
|
+
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
71
142
|
import { clearPid, writePid } from "./process-state.ts";
|
|
72
143
|
import {
|
|
73
144
|
FIRST_PARTY_FALLBACKS,
|
|
@@ -75,18 +146,48 @@ import {
|
|
|
75
146
|
shortNameForManifest,
|
|
76
147
|
} from "./service-spec.ts";
|
|
77
148
|
import { type ServiceEntry, readManifest } from "./services-manifest.ts";
|
|
149
|
+
import { findActiveSession } from "./sessions.ts";
|
|
150
|
+
import {
|
|
151
|
+
type SetupWizardDeps,
|
|
152
|
+
handleSetupAccountPost,
|
|
153
|
+
handleSetupExposePost,
|
|
154
|
+
handleSetupGet,
|
|
155
|
+
handleSetupInstallPost,
|
|
156
|
+
handleSetupVaultPost,
|
|
157
|
+
} from "./setup-wizard.ts";
|
|
78
158
|
import { getAllPublicKeys } from "./signing-keys.ts";
|
|
79
|
-
import {
|
|
159
|
+
import type { Supervisor } from "./supervisor.ts";
|
|
160
|
+
import { getUserById, userCount } from "./users.ts";
|
|
161
|
+
import {
|
|
162
|
+
WELL_KNOWN_DIR,
|
|
163
|
+
buildWellKnown,
|
|
164
|
+
isVaultEntry,
|
|
165
|
+
vaultInstanceNameFor,
|
|
166
|
+
} from "./well-known.ts";
|
|
80
167
|
|
|
81
168
|
interface Args {
|
|
82
169
|
port: number;
|
|
170
|
+
hostname: string;
|
|
83
171
|
wellKnownDir: string;
|
|
84
172
|
dbPath: string;
|
|
85
173
|
issuer: string | undefined;
|
|
86
174
|
}
|
|
87
175
|
|
|
88
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Parse hub-server flags. Container hosts (Render, Docker) configure us
|
|
178
|
+
* entirely via env vars — no flags. The `parachute expose` spawn path passes
|
|
179
|
+
* everything as flags. Flags beat env, env beats defaults.
|
|
180
|
+
*
|
|
181
|
+
* PORT — bind port (Render injects this)
|
|
182
|
+
* PARACHUTE_BIND_HOST — bind hostname; default 127.0.0.1 to keep
|
|
183
|
+
* the historical loopback posture safe.
|
|
184
|
+
* Containers should set 0.0.0.0.
|
|
185
|
+
* PARACHUTE_HUB_ORIGIN — canonical https://… origin used as the
|
|
186
|
+
* OAuth issuer claim.
|
|
187
|
+
*/
|
|
188
|
+
function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): Args {
|
|
89
189
|
let port: number | undefined;
|
|
190
|
+
let hostname: string | undefined;
|
|
90
191
|
let wellKnownDir: string | undefined;
|
|
91
192
|
let dbPath: string | undefined;
|
|
92
193
|
let issuer: string | undefined;
|
|
@@ -95,11 +196,11 @@ function parseArgs(argv: string[]): Args {
|
|
|
95
196
|
if (a === "--port") {
|
|
96
197
|
const v = argv[++i];
|
|
97
198
|
if (!v) throw new Error("--port requires a value");
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
199
|
+
port = parsePort(v);
|
|
200
|
+
} else if (a === "--hostname") {
|
|
201
|
+
const v = argv[++i];
|
|
202
|
+
if (!v) throw new Error("--hostname requires a value");
|
|
203
|
+
hostname = v;
|
|
103
204
|
} else if (a === "--well-known-dir") {
|
|
104
205
|
const v = argv[++i];
|
|
105
206
|
if (!v) throw new Error("--well-known-dir requires a value");
|
|
@@ -116,9 +217,22 @@ function parseArgs(argv: string[]): Args {
|
|
|
116
217
|
throw new Error(`unknown argument: ${a}`);
|
|
117
218
|
}
|
|
118
219
|
}
|
|
119
|
-
if (port === undefined
|
|
120
|
-
if (
|
|
121
|
-
|
|
220
|
+
if (port === undefined && env.PORT) port = parsePort(env.PORT);
|
|
221
|
+
if (port === undefined) port = HUB_DEFAULT_PORT;
|
|
222
|
+
if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
|
|
223
|
+
if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
|
|
224
|
+
if (issuer === undefined && env.PARACHUTE_HUB_ORIGIN) {
|
|
225
|
+
issuer = env.PARACHUTE_HUB_ORIGIN.replace(/\/+$/, "");
|
|
226
|
+
}
|
|
227
|
+
return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parsePort(v: string): number {
|
|
231
|
+
const n = Number.parseInt(v, 10);
|
|
232
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
233
|
+
throw new Error(`port must be 1..65535, got "${v}"`);
|
|
234
|
+
}
|
|
235
|
+
return n;
|
|
122
236
|
}
|
|
123
237
|
|
|
124
238
|
/**
|
|
@@ -458,6 +572,32 @@ export interface HubFetchDeps {
|
|
|
458
572
|
* fixture installDirs.
|
|
459
573
|
*/
|
|
460
574
|
readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
575
|
+
/**
|
|
576
|
+
* Hub's listening port. Threaded into the OAuth `hubBoundOrigins` set so
|
|
577
|
+
* the same-origin defense accepts loopback access (`http://localhost:<port>`,
|
|
578
|
+
* `http://127.0.0.1:<port>`) alongside the configured issuer. Closes #245
|
|
579
|
+
* Case A (operator on `localhost:1939` getting "Cross-origin request
|
|
580
|
+
* rejected" because Origin ≠ tailnet issuer).
|
|
581
|
+
*/
|
|
582
|
+
loopbackPort?: number;
|
|
583
|
+
/**
|
|
584
|
+
* Test seam for reading `expose-state.json`. Production reads the operator's
|
|
585
|
+
* `~/.parachute/expose-state.json` via `readExposeState`; tests inject a
|
|
586
|
+
* fake to drive tailnet/funnel origins into the bound set without standing
|
|
587
|
+
* up real exposes. Returns `undefined` when no state file is present
|
|
588
|
+
* (pre-`parachute expose` state — fine, the issuer + loopback still cover
|
|
589
|
+
* legitimate access).
|
|
590
|
+
*/
|
|
591
|
+
loadExposeHubOrigin?: () => string | undefined;
|
|
592
|
+
/**
|
|
593
|
+
* Container-mode child supervisor. When present (under `parachute serve`),
|
|
594
|
+
* `/api/modules/*` handlers drive install/restart/upgrade/uninstall through
|
|
595
|
+
* it. Absent under the on-box CLI path (`parachute expose`) where
|
|
596
|
+
* `commands/lifecycle.ts` owns the detached-pidfile lifecycle instead —
|
|
597
|
+
* the module-mgmt API in that mode returns 503 with a hint to use the
|
|
598
|
+
* CLI commands directly.
|
|
599
|
+
*/
|
|
600
|
+
supervisor?: Supervisor;
|
|
461
601
|
}
|
|
462
602
|
|
|
463
603
|
/**
|
|
@@ -490,6 +630,50 @@ async function loadManagementUrls(
|
|
|
490
630
|
return out;
|
|
491
631
|
}
|
|
492
632
|
|
|
633
|
+
/**
|
|
634
|
+
* For each NON-vault `ServiceEntry` with a known `installDir`, read its
|
|
635
|
+
* `.parachute/module.json` and surface the optional `uiUrl` and
|
|
636
|
+
* `displayName`. Returns two `name → value` maps keyed by services.json
|
|
637
|
+
* entry name. Mirrors `loadManagementUrls` (vault is the analog there;
|
|
638
|
+
* non-vault services are the analog here — vaults are user-facing via
|
|
639
|
+
* Notes, not their own UI).
|
|
640
|
+
*
|
|
641
|
+
* Why read at request time and not from services.json: services own the
|
|
642
|
+
* write side of services.json (`upsertService` replaces the whole entry
|
|
643
|
+
* on every boot), so any install-time copy of `uiUrl` / `displayName`
|
|
644
|
+
* would be clobbered the first time the service writes its own entry.
|
|
645
|
+
* Reading from `installDir/module.json` at request time avoids the gap
|
|
646
|
+
* and matches the established `managementUrl` precedent.
|
|
647
|
+
*
|
|
648
|
+
* Quiet on per-entry errors: a malformed module.json on one service
|
|
649
|
+
* shouldn't 500 the entire well-known doc — its row just renders without
|
|
650
|
+
* a Services tile. The validator already throws structured errors from
|
|
651
|
+
* `readModuleManifest`; logging them once here is the right floor.
|
|
652
|
+
*/
|
|
653
|
+
async function loadServiceUiMetadata(
|
|
654
|
+
services: readonly ServiceEntry[],
|
|
655
|
+
read: (installDir: string) => Promise<ModuleManifest | null>,
|
|
656
|
+
): Promise<{ uiUrls: Map<string, string>; displayNames: Map<string, string> }> {
|
|
657
|
+
const uiUrls = new Map<string, string>();
|
|
658
|
+
const displayNames = new Map<string, string>();
|
|
659
|
+
await Promise.all(
|
|
660
|
+
services.map(async (s) => {
|
|
661
|
+
// Skip vaults — they have their own loadManagementUrls path and no
|
|
662
|
+
// operator-facing user UI of their own (content browses via Notes).
|
|
663
|
+
if (isVaultEntry(s) || !s.installDir) return;
|
|
664
|
+
try {
|
|
665
|
+
const m = await read(s.installDir);
|
|
666
|
+
if (m?.uiUrl) uiUrls.set(s.name, m.uiUrl);
|
|
667
|
+
if (m?.displayName) displayNames.set(s.name, m.displayName);
|
|
668
|
+
} catch (err) {
|
|
669
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
670
|
+
console.warn(`well-known: skipping uiUrl/displayName for ${s.name}: ${msg}`);
|
|
671
|
+
}
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
return { uiUrls, displayNames };
|
|
675
|
+
}
|
|
676
|
+
|
|
493
677
|
/**
|
|
494
678
|
* Resolve the SPA bundle dir. We anchor to this file's location so a
|
|
495
679
|
* `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
|
|
@@ -503,22 +687,23 @@ function defaultSpaDistDir(): string {
|
|
|
503
687
|
}
|
|
504
688
|
|
|
505
689
|
/**
|
|
506
|
-
* The SPA serves at
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* `/
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* (`/vault
|
|
518
|
-
*
|
|
519
|
-
*
|
|
690
|
+
* The admin SPA serves at a single mount: `/admin/*` (since hub#231).
|
|
691
|
+
*
|
|
692
|
+
* Routes:
|
|
693
|
+
* - `/admin/vaults` → vault list (the SPA's home)
|
|
694
|
+
* - `/admin/vaults/new` → vault create form
|
|
695
|
+
* - `/admin/permissions` → OAuth consent grant management
|
|
696
|
+
* - `/admin/tokens` → token registry: mint / list / revoke
|
|
697
|
+
*
|
|
698
|
+
* Asset URLs are origin-absolute (`/admin/assets/...`) per the Vite build
|
|
699
|
+
* base. main.tsx pins react-router's basename to `/admin`.
|
|
700
|
+
*
|
|
701
|
+
* Pre-rename mounts (the old `/vault` for the vault SPA, `/hub/*` for
|
|
702
|
+
* permissions+tokens) are 301-redirected further up the dispatch so cached
|
|
703
|
+
* operator URLs keep working. `/vault/<name>/*` (per-vault content proxy)
|
|
704
|
+
* stays — that's user-facing vault data, not part of this admin SPA.
|
|
520
705
|
*/
|
|
521
|
-
type SpaMount = "/
|
|
706
|
+
type SpaMount = "/admin";
|
|
522
707
|
|
|
523
708
|
/**
|
|
524
709
|
* Pick a content type for static assets the SPA build produces. Vite's
|
|
@@ -565,8 +750,8 @@ function spaContentType(pathname: string): string {
|
|
|
565
750
|
* filter rejects sub-paths containing "..", and the resolved absolute
|
|
566
751
|
* path is checked to start with `dist/` before any read.
|
|
567
752
|
*
|
|
568
|
-
* `mount` is the prefix being served (`/
|
|
569
|
-
*
|
|
753
|
+
* `mount` is the prefix being served (`/admin`); we strip it from
|
|
754
|
+
* `pathname` to land on the file path inside `dist/`.
|
|
570
755
|
*/
|
|
571
756
|
async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
|
|
572
757
|
if (!existsSync(spaDistDir)) {
|
|
@@ -575,7 +760,7 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
|
|
|
575
760
|
{ status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
|
|
576
761
|
);
|
|
577
762
|
}
|
|
578
|
-
// Strip the mount prefix; "/
|
|
763
|
+
// Strip the mount prefix; "/admin" → "", "/admin/" → "/", "/admin/x" → "/x".
|
|
579
764
|
const sub = pathname === mount ? "" : pathname.slice(mount.length);
|
|
580
765
|
const indexPath = join(spaDistDir, "index.html");
|
|
581
766
|
|
|
@@ -608,6 +793,64 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
|
|
|
608
793
|
});
|
|
609
794
|
}
|
|
610
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Routes that fall through the pre-admin lockout (503 → /admin/setup).
|
|
798
|
+
*
|
|
799
|
+
* Gated (operator-facing) when no admin row exists:
|
|
800
|
+
* - `/admin/*` (except `/admin/setup*`) — vault admin, permissions, tokens
|
|
801
|
+
* - `/api/*` — host-admin API surface
|
|
802
|
+
* - `/login`, `/logout` — pointless without an account
|
|
803
|
+
*
|
|
804
|
+
* Open through the gate (so the container is reachable and discoverable
|
|
805
|
+
* the moment it boots, even before an admin is seeded):
|
|
806
|
+
* - `/health` — platform liveness check
|
|
807
|
+
* - `/.well-known/*` — public discovery + JWKS
|
|
808
|
+
* - `/admin/setup`, `/admin/setup/*` — the first-boot wizard (hub#259)
|
|
809
|
+
* and its POST endpoints; the
|
|
810
|
+
* wizard is the *only* browser
|
|
811
|
+
* path to exit the lockout
|
|
812
|
+
* - `/` and `/hub.html` — static discovery page
|
|
813
|
+
* - `/oauth/*` — third-party OAuth surface; clients
|
|
814
|
+
* can register/refresh independent
|
|
815
|
+
* of admin onboarding
|
|
816
|
+
* - `/vault/*`, `/<service>/*` — content proxies; service-level auth
|
|
817
|
+
* handles its own failure modes
|
|
818
|
+
*
|
|
819
|
+
* The function is called only when an admin row is missing; in the
|
|
820
|
+
* normal-running case (any admin row exists) this gate is a no-op and the
|
|
821
|
+
* regular dispatch continues.
|
|
822
|
+
*/
|
|
823
|
+
function shouldGateForSetup(pathname: string): boolean {
|
|
824
|
+
if (pathname === "/login" || pathname === "/logout") return true;
|
|
825
|
+
// The wizard itself + its POST endpoints are the *only* way to exit
|
|
826
|
+
// the pre-admin lockout from a browser — they must pass through. Any
|
|
827
|
+
// path under `/admin/setup` (including `/admin/setup/account` and
|
|
828
|
+
// `/admin/setup/vault`) is fair game for an un-authed operator on a
|
|
829
|
+
// fresh hub.
|
|
830
|
+
if (pathname === "/admin/setup" || pathname.startsWith("/admin/setup/")) return false;
|
|
831
|
+
if (pathname === "/admin" || pathname.startsWith("/admin/")) return true;
|
|
832
|
+
if (pathname.startsWith("/api/")) return true;
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// hub#259 replaced the static placeholder with a real three-step wizard.
|
|
837
|
+
// The handlers (account creation + vault provisioning) live in
|
|
838
|
+
// `src/setup-wizard.ts` so the dispatch in this file stays a one-liner per
|
|
839
|
+
// route. The env-var seed path (PARACHUTE_INITIAL_ADMIN_*) still works on
|
|
840
|
+
// first boot — see `src/commands/serve.ts` — and is surfaced as the
|
|
841
|
+
// "alt-path" disclosure on the wizard's welcome screen.
|
|
842
|
+
|
|
843
|
+
// Canonical 503 body for "getDb is unset, hub is unconfigured." Shape matches
|
|
844
|
+
// the OAuth error vocabulary used by /api/auth/* (`service_unavailable`) so a
|
|
845
|
+
// consumer never has to branch on content-type to extract a message. See
|
|
846
|
+
// hub#227 for the migration from plain-text bodies.
|
|
847
|
+
function dbNotConfigured(): Response {
|
|
848
|
+
return Response.json(
|
|
849
|
+
{ error: "service_unavailable", error_description: "hub db not configured" },
|
|
850
|
+
{ status: 503 },
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
611
854
|
export function hubFetch(
|
|
612
855
|
wellKnownDir: string,
|
|
613
856
|
deps?: HubFetchDeps,
|
|
@@ -617,31 +860,252 @@ export function hubFetch(
|
|
|
617
860
|
const configuredIssuer = deps?.issuer;
|
|
618
861
|
const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
619
862
|
const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
|
|
863
|
+
const loopbackPort = deps?.loopbackPort;
|
|
864
|
+
const loadExposeHubOrigin =
|
|
865
|
+
deps?.loadExposeHubOrigin ??
|
|
866
|
+
(() => {
|
|
867
|
+
try {
|
|
868
|
+
return readExposeState()?.hubOrigin;
|
|
869
|
+
} catch {
|
|
870
|
+
// Malformed expose-state.json shouldn't 500 hub on every same-origin
|
|
871
|
+
// check — the issuer + loopback already cover legitimate access.
|
|
872
|
+
return undefined;
|
|
873
|
+
}
|
|
874
|
+
});
|
|
620
875
|
|
|
621
|
-
const oauthDeps = (req: Request) =>
|
|
622
|
-
issuer
|
|
623
|
-
|
|
876
|
+
const oauthDeps = (req: Request) => {
|
|
877
|
+
const issuer = configuredIssuer ?? new URL(req.url).origin;
|
|
878
|
+
return {
|
|
879
|
+
issuer,
|
|
880
|
+
// Per-request resolution (closes #245): expose-state.json can change
|
|
881
|
+
// mid-session (operator runs `parachute expose tailnet` while hub is
|
|
882
|
+
// up), so we re-read the bound origins on each call rather than
|
|
883
|
+
// capturing at hub start. Cheap — a single small JSON parse per OAuth
|
|
884
|
+
// request, only on the cookie-POST paths that consult it.
|
|
885
|
+
hubBoundOrigins: () =>
|
|
886
|
+
buildHubBoundOrigins({
|
|
887
|
+
issuer,
|
|
888
|
+
loopbackPort,
|
|
889
|
+
exposeHubOrigin: loadExposeHubOrigin(),
|
|
890
|
+
}),
|
|
891
|
+
};
|
|
892
|
+
};
|
|
624
893
|
|
|
625
894
|
return async (req) => {
|
|
626
895
|
const url = new URL(req.url);
|
|
627
896
|
const pathname = url.pathname;
|
|
628
897
|
|
|
629
|
-
// 301 back-compat
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
898
|
+
// 301 back-compat for the pre-hub#231 admin-SPA mounts:
|
|
899
|
+
//
|
|
900
|
+
// `/vault` → `/admin/vaults`
|
|
901
|
+
// `/vault/new` → `/admin/vaults/new`
|
|
902
|
+
// `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
|
|
903
|
+
// it now retargets at the new admin mount instead
|
|
904
|
+
// of the interim `/vault` mount)
|
|
905
|
+
// `/hub/permissions` → `/admin/permissions`
|
|
906
|
+
// `/hub/tokens` → `/admin/tokens`
|
|
907
|
+
// `/hub` (bare) → `/admin/vaults`
|
|
908
|
+
//
|
|
909
|
+
// Permanent redirect so cached operator URLs keep working without
|
|
910
|
+
// leaving dangling SPA routes. Query string preserved; fragment is
|
|
911
|
+
// client-side and survives the redirect at the browser. Method-agnostic
|
|
912
|
+
// — even a misrouted POST gets the redirect; none of these paths host a
|
|
913
|
+
// POST endpoint to protect.
|
|
914
|
+
//
|
|
915
|
+
// `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
|
|
916
|
+
// content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
|
|
917
|
+
if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
|
|
918
|
+
const sub = pathname === "/vault/new" ? "/new" : "";
|
|
919
|
+
return new Response("", {
|
|
920
|
+
status: 301,
|
|
921
|
+
headers: { location: `/admin/vaults${sub}${url.search}` },
|
|
922
|
+
});
|
|
923
|
+
}
|
|
636
924
|
if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
|
|
637
|
-
const newPath = `/
|
|
925
|
+
const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
|
|
638
926
|
return new Response("", {
|
|
639
927
|
status: 301,
|
|
640
928
|
headers: { location: `${newPath}${url.search}` },
|
|
641
929
|
});
|
|
642
930
|
}
|
|
931
|
+
if (pathname === "/hub/permissions") {
|
|
932
|
+
return new Response("", {
|
|
933
|
+
status: 301,
|
|
934
|
+
headers: { location: `/admin/permissions${url.search}` },
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
if (pathname === "/hub/tokens") {
|
|
938
|
+
return new Response("", {
|
|
939
|
+
status: 301,
|
|
940
|
+
headers: { location: `/admin/tokens${url.search}` },
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
if (pathname === "/hub" || pathname === "/hub/") {
|
|
944
|
+
return new Response("", {
|
|
945
|
+
status: 301,
|
|
946
|
+
headers: { location: `/admin/vaults${url.search}` },
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Login surface rename: `/admin/login` and `/admin/logout` 301 to the
|
|
951
|
+
// canonical `/login` and `/logout`. The names were "admin" only by
|
|
952
|
+
// historical accident — the handlers serve every parachute auth flow
|
|
953
|
+
// (operator, OAuth user-redirect, future SPA sign-in). Renaming makes
|
|
954
|
+
// the surface name match its actual scope.
|
|
955
|
+
if (pathname === "/admin/login") {
|
|
956
|
+
return new Response("", {
|
|
957
|
+
status: 301,
|
|
958
|
+
headers: { location: `/login${url.search}` },
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
if (pathname === "/admin/logout") {
|
|
962
|
+
return new Response("", {
|
|
963
|
+
status: 301,
|
|
964
|
+
headers: { location: `/logout${url.search}` },
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Platform health check (Render, Fly, Kubernetes, etc.). Plain JSON,
|
|
969
|
+
// no DB required — the route reports liveness, not readiness. Anything
|
|
970
|
+
// more invasive (DB ping, schema check) would let a transient lock turn
|
|
971
|
+
// into a restart loop on the platform side. 200 always while the
|
|
972
|
+
// process is up.
|
|
973
|
+
if (pathname === "/health") {
|
|
974
|
+
return new Response(
|
|
975
|
+
JSON.stringify({ status: "ok", service: "parachute-hub", version: pkg.version }),
|
|
976
|
+
{
|
|
977
|
+
headers: {
|
|
978
|
+
"content-type": "application/json",
|
|
979
|
+
"cache-control": "no-store",
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// First-boot setup wizard (hub#259). Three steps server-rendered:
|
|
986
|
+
// GET /admin/setup — derive state, render the right step
|
|
987
|
+
// POST /admin/setup/account — create the admin row, set session
|
|
988
|
+
// POST /admin/setup/vault — provision the first vault
|
|
989
|
+
//
|
|
990
|
+
// The wizard owns the "should I 301 to /login now?" decision: setup is
|
|
991
|
+
// complete only when admin AND a vault entry both exist. A re-visit
|
|
992
|
+
// after partial setup picks up at the next step. See
|
|
993
|
+
// src/setup-wizard.ts for the renderer + handler internals.
|
|
994
|
+
if (pathname === "/admin/setup" || pathname.startsWith("/admin/setup/")) {
|
|
995
|
+
if (!getDb) return dbNotConfigured();
|
|
996
|
+
const wizardDeps: SetupWizardDeps = {
|
|
997
|
+
db: getDb(),
|
|
998
|
+
manifestPath,
|
|
999
|
+
configDir: CONFIG_DIR,
|
|
1000
|
+
issuer: oauthDeps(req).issuer,
|
|
1001
|
+
registry: getDefaultOperationsRegistry(),
|
|
1002
|
+
};
|
|
1003
|
+
if (deps?.supervisor !== undefined) wizardDeps.supervisor = deps.supervisor;
|
|
1004
|
+
if (pathname === "/admin/setup") {
|
|
1005
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
1006
|
+
return handleSetupGet(req, wizardDeps);
|
|
1007
|
+
}
|
|
1008
|
+
if (pathname === "/admin/setup/account") {
|
|
1009
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1010
|
+
return handleSetupAccountPost(req, wizardDeps);
|
|
1011
|
+
}
|
|
1012
|
+
if (pathname === "/admin/setup/vault") {
|
|
1013
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1014
|
+
return handleSetupVaultPost(req, wizardDeps);
|
|
1015
|
+
}
|
|
1016
|
+
if (pathname === "/admin/setup/expose") {
|
|
1017
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1018
|
+
return handleSetupExposePost(req, wizardDeps);
|
|
1019
|
+
}
|
|
1020
|
+
// hub#272 Item B: post-wizard direct module-install POSTs from
|
|
1021
|
+
// the done-screen "What's next?" tiles. Path shape is
|
|
1022
|
+
// `/admin/setup/install/<short>`; the handler rejects on
|
|
1023
|
+
// unknown shorts, on `vault` (the wizard's own step owns that),
|
|
1024
|
+
// and on missing session/CSRF — same gates as the vault POST.
|
|
1025
|
+
if (pathname.startsWith("/admin/setup/install/")) {
|
|
1026
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1027
|
+
const short = pathname.slice("/admin/setup/install/".length);
|
|
1028
|
+
return handleSetupInstallPost(req, short, wizardDeps);
|
|
1029
|
+
}
|
|
1030
|
+
return new Response("not found", { status: 404 });
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Fresh-hub redirect: on a hub with no admin row yet, the discovery
|
|
1034
|
+
// page (`/`, `/hub.html`) funnels straight to the wizard. The static
|
|
1035
|
+
// portal isn't useful pre-setup — nothing's installed, the
|
|
1036
|
+
// "Signed in" affordance has no session to surface — and the
|
|
1037
|
+
// operator landing on `/` in a browser otherwise has to manually
|
|
1038
|
+
// type `/admin/setup` to escape. 302 (not 301) so the redirect
|
|
1039
|
+
// disappears the moment the wizard finishes.
|
|
1040
|
+
//
|
|
1041
|
+
// Sits before the JSON-shaped 503 gate below because `/` is an
|
|
1042
|
+
// HTML surface — a JSON 503 there would render as raw text in the
|
|
1043
|
+
// operator's browser tab. The 503 gate handles API + admin SPA +
|
|
1044
|
+
// OAuth callers that branch on the structured body.
|
|
1045
|
+
if (getDb && (pathname === "/" || pathname === "/hub.html") && userCount(getDb()) === 0) {
|
|
1046
|
+
return new Response(null, {
|
|
1047
|
+
status: 302,
|
|
1048
|
+
headers: { location: "/admin/setup" },
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Pre-admin lockout. When the hub has booted with no admin row (the
|
|
1053
|
+
// fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
|
|
1054
|
+
// /admin/setup is walked), every operator-facing surface that requires
|
|
1055
|
+
// identity is meaningless — auth flows can't validate, the SPA can't
|
|
1056
|
+
// mint a host-admin token, OAuth can't issue codes. Route those to a
|
|
1057
|
+
// 503 that points at /admin/setup. Health, well-known, /admin/setup
|
|
1058
|
+
// itself, OAuth third-party endpoints, and content proxies pass
|
|
1059
|
+
// through; the fresh-hub `/` and `/hub.html` redirect above handled
|
|
1060
|
+
// the discovery-page case.
|
|
1061
|
+
//
|
|
1062
|
+
// `shouldGateForSetup` runs first so non-gated paths (well-known, /,
|
|
1063
|
+
// /health, /admin/setup) never touch getDb — keeping the
|
|
1064
|
+
// existing OPTIONS-preflight contract that those routes are db-free.
|
|
1065
|
+
if (getDb && shouldGateForSetup(pathname) && userCount(getDb()) === 0) {
|
|
1066
|
+
return new Response(
|
|
1067
|
+
JSON.stringify({
|
|
1068
|
+
error: "setup_required",
|
|
1069
|
+
error_description:
|
|
1070
|
+
"no admin configured. Visit /admin/setup, or set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD and restart.",
|
|
1071
|
+
setup_url: "/admin/setup",
|
|
1072
|
+
}),
|
|
1073
|
+
{
|
|
1074
|
+
status: 503,
|
|
1075
|
+
headers: {
|
|
1076
|
+
"content-type": "application/json",
|
|
1077
|
+
"cache-control": "no-store",
|
|
1078
|
+
},
|
|
1079
|
+
},
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
643
1082
|
|
|
644
1083
|
if (pathname === "/" || pathname === "/hub.html") {
|
|
1084
|
+
// When a DB is configured, render the discovery page dynamically so
|
|
1085
|
+
// the header carries a "Signed in as <name>" affordance for the
|
|
1086
|
+
// active session. Without a DB, fall back to the static disk file
|
|
1087
|
+
// (signed-out shape) — the disk file is what `parachute expose`
|
|
1088
|
+
// wrote out, used when the hub-server is running without state.
|
|
1089
|
+
if (getDb) {
|
|
1090
|
+
const db = getDb();
|
|
1091
|
+
const session = findActiveSession(db, req);
|
|
1092
|
+
let renderOpts: RenderHubOpts = {};
|
|
1093
|
+
const headers: Record<string, string> = {
|
|
1094
|
+
"content-type": "text/html; charset=utf-8",
|
|
1095
|
+
};
|
|
1096
|
+
if (session) {
|
|
1097
|
+
const user = getUserById(db, session.userId);
|
|
1098
|
+
if (user) {
|
|
1099
|
+
const csrf = ensureCsrfToken(req);
|
|
1100
|
+
renderOpts = {
|
|
1101
|
+
session: { displayName: user.username, csrfToken: csrf.token },
|
|
1102
|
+
};
|
|
1103
|
+
if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return new Response(renderHub(renderOpts), { headers });
|
|
1107
|
+
}
|
|
1108
|
+
// No DB configured → fall back to static file (signed-out only).
|
|
645
1109
|
if (!existsSync(hubHtmlPath)) {
|
|
646
1110
|
return new Response("hub.html not found", { status: 404 });
|
|
647
1111
|
}
|
|
@@ -656,9 +1120,17 @@ export function hubFetch(
|
|
|
656
1120
|
// cross-origin from their own loopback port. Wildcard CORS is the
|
|
657
1121
|
// shape it needs. Browsers send an OPTIONS preflight when the request
|
|
658
1122
|
// adds non-simple headers; answer it with 204 + the same allow-list.
|
|
1123
|
+
//
|
|
1124
|
+
// `cache-control: no-store` matters here: the discovery page (`/`)
|
|
1125
|
+
// fetches this doc and renders Service tiles from it; without
|
|
1126
|
+
// no-store, the browser's HTTP cache returns the stale services list
|
|
1127
|
+
// the next time the operator navigates back to `/` after installing
|
|
1128
|
+
// a module via the admin SPA. The doc is small and built per-request
|
|
1129
|
+
// anyway, so giving up cacheability has no real cost (hub#268 Item 1).
|
|
659
1130
|
const corsHeaders = {
|
|
660
1131
|
"access-control-allow-origin": "*",
|
|
661
1132
|
"access-control-allow-methods": "GET, OPTIONS",
|
|
1133
|
+
"cache-control": "no-store",
|
|
662
1134
|
};
|
|
663
1135
|
if (req.method === "OPTIONS") {
|
|
664
1136
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
@@ -671,14 +1143,17 @@ export function hubFetch(
|
|
|
671
1143
|
try {
|
|
672
1144
|
const manifest = readManifest(manifestPath);
|
|
673
1145
|
const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1146
|
+
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
1147
|
+
const [managementUrlByName, serviceUiMeta] = await Promise.all([
|
|
1148
|
+
loadManagementUrls(manifest.services, readManifestFn),
|
|
1149
|
+
loadServiceUiMetadata(manifest.services, readManifestFn),
|
|
1150
|
+
]);
|
|
678
1151
|
const doc = buildWellKnown({
|
|
679
1152
|
services: manifest.services,
|
|
680
1153
|
canonicalOrigin,
|
|
681
1154
|
managementUrlFor: (entry) => managementUrlByName.get(entry.name),
|
|
1155
|
+
uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
|
|
1156
|
+
displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
|
|
682
1157
|
});
|
|
683
1158
|
return new Response(JSON.stringify(doc), {
|
|
684
1159
|
headers: { "content-type": "application/json", ...corsHeaders },
|
|
@@ -694,6 +1169,30 @@ export function hubFetch(
|
|
|
694
1169
|
}
|
|
695
1170
|
}
|
|
696
1171
|
|
|
1172
|
+
if (pathname === REVOCATION_LIST_MOUNT) {
|
|
1173
|
+
// Revocation list (hub#212 Phase 1). Public — same CORS posture as
|
|
1174
|
+
// jwks.json since resource servers (vault/scribe/agent) fetch it
|
|
1175
|
+
// cross-origin on the 60s polling cadence wired in Phase 4.
|
|
1176
|
+
const corsHeaders = {
|
|
1177
|
+
"access-control-allow-origin": "*",
|
|
1178
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
1179
|
+
};
|
|
1180
|
+
if (req.method === "OPTIONS") {
|
|
1181
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
1182
|
+
}
|
|
1183
|
+
if (!getDb) {
|
|
1184
|
+
return new Response('{"error":"revocation list unavailable: db not configured"}', {
|
|
1185
|
+
status: 503,
|
|
1186
|
+
headers: { "content-type": "application/json", ...corsHeaders },
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
const resp = handleRevocationList(req, { db: getDb() });
|
|
1190
|
+
// Layer the wildcard CORS over whatever cache-control the handler set.
|
|
1191
|
+
const merged = new Headers(resp.headers);
|
|
1192
|
+
for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
|
|
1193
|
+
return new Response(resp.body, { status: resp.status, headers: merged });
|
|
1194
|
+
}
|
|
1195
|
+
|
|
697
1196
|
if (pathname === "/.well-known/jwks.json") {
|
|
698
1197
|
// JWKS is also a cross-origin fetch target (browser-side OAuth
|
|
699
1198
|
// libraries pull this to verify access tokens). Same wildcard CORS
|
|
@@ -746,9 +1245,7 @@ export function hubFetch(
|
|
|
746
1245
|
}
|
|
747
1246
|
|
|
748
1247
|
if (pathname === "/oauth/authorize") {
|
|
749
|
-
if (!getDb)
|
|
750
|
-
return new Response("hub db not configured", { status: 503 });
|
|
751
|
-
}
|
|
1248
|
+
if (!getDb) return dbNotConfigured();
|
|
752
1249
|
if (req.method === "GET") return handleAuthorizeGet(getDb(), req, oauthDeps(req));
|
|
753
1250
|
if (req.method === "POST") return handleAuthorizePost(getDb(), req, oauthDeps(req));
|
|
754
1251
|
return new Response("method not allowed", { status: 405 });
|
|
@@ -759,59 +1256,44 @@ export function hubFetch(
|
|
|
759
1256
|
// by handleAuthorizeGet when the operator hits a pending client. Three
|
|
760
1257
|
// gates inside the handler: CSRF, active session, same-origin Origin.
|
|
761
1258
|
if (pathname === "/oauth/authorize/approve") {
|
|
762
|
-
if (!getDb)
|
|
763
|
-
return new Response("hub db not configured", { status: 503 });
|
|
764
|
-
}
|
|
1259
|
+
if (!getDb) return dbNotConfigured();
|
|
765
1260
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
766
1261
|
return handleApproveClientPost(getDb(), req, oauthDeps(req));
|
|
767
1262
|
}
|
|
768
1263
|
|
|
769
1264
|
if (pathname === "/oauth/token") {
|
|
770
|
-
if (!getDb)
|
|
771
|
-
return new Response("hub db not configured", { status: 503 });
|
|
772
|
-
}
|
|
1265
|
+
if (!getDb) return dbNotConfigured();
|
|
773
1266
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
774
1267
|
return handleToken(getDb(), req, oauthDeps(req));
|
|
775
1268
|
}
|
|
776
1269
|
|
|
777
1270
|
if (pathname === "/oauth/register") {
|
|
778
|
-
if (!getDb)
|
|
779
|
-
return new Response("hub db not configured", { status: 503 });
|
|
780
|
-
}
|
|
1271
|
+
if (!getDb) return dbNotConfigured();
|
|
781
1272
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
782
1273
|
return handleRegister(getDb(), req, oauthDeps(req));
|
|
783
1274
|
}
|
|
784
1275
|
|
|
785
1276
|
if (pathname === "/oauth/revoke") {
|
|
786
|
-
if (!getDb)
|
|
787
|
-
return new Response("hub db not configured", { status: 503 });
|
|
788
|
-
}
|
|
1277
|
+
if (!getDb) return dbNotConfigured();
|
|
789
1278
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
790
1279
|
return handleRevoke(getDb(), req, oauthDeps(req));
|
|
791
1280
|
}
|
|
792
1281
|
|
|
793
1282
|
if (pathname === "/vaults") {
|
|
794
|
-
if (!getDb)
|
|
795
|
-
return new Response("hub db not configured", { status: 503 });
|
|
796
|
-
}
|
|
1283
|
+
if (!getDb) return dbNotConfigured();
|
|
797
1284
|
return handleCreateVault(req, {
|
|
798
1285
|
db: getDb(),
|
|
799
1286
|
issuer: oauthDeps(req).issuer,
|
|
800
1287
|
});
|
|
801
1288
|
}
|
|
802
1289
|
|
|
803
|
-
//
|
|
804
|
-
// hub
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
// POSTs for vault create go to /vaults, not the SPA mount.
|
|
808
|
-
if (pathname === "/hub" || pathname.startsWith("/hub/")) {
|
|
809
|
-
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
810
|
-
return serveSpa(spaDistDir, pathname, "/hub");
|
|
811
|
-
}
|
|
1290
|
+
// Note: the old `/hub/*` SPA mount has been retired. Known prefixes
|
|
1291
|
+
// (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
|
|
1292
|
+
// 301-redirected at the top of dispatch. Any other `/hub/*` path falls
|
|
1293
|
+
// through to the catch-all 404 — there's no admin surface left there.
|
|
812
1294
|
|
|
813
1295
|
if (pathname === "/admin/host-admin-token") {
|
|
814
|
-
if (!getDb) return
|
|
1296
|
+
if (!getDb) return dbNotConfigured();
|
|
815
1297
|
return handleHostAdminToken(req, {
|
|
816
1298
|
db: getDb(),
|
|
817
1299
|
issuer: oauthDeps(req).issuer,
|
|
@@ -819,7 +1301,7 @@ export function hubFetch(
|
|
|
819
1301
|
}
|
|
820
1302
|
|
|
821
1303
|
if (pathname.startsWith("/admin/vault-admin-token/")) {
|
|
822
|
-
if (!getDb) return
|
|
1304
|
+
if (!getDb) return dbNotConfigured();
|
|
823
1305
|
const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
|
|
824
1306
|
// The vault name must correspond to an actual vault instance — same
|
|
825
1307
|
// shape the well-known doc derives. Source from services.json so a
|
|
@@ -838,8 +1320,122 @@ export function hubFetch(
|
|
|
838
1320
|
});
|
|
839
1321
|
}
|
|
840
1322
|
|
|
1323
|
+
if (pathname === "/api/me") {
|
|
1324
|
+
if (!getDb) return dbNotConfigured();
|
|
1325
|
+
return handleApiMe(req, { db: getDb() });
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (pathname === "/api/modules") {
|
|
1329
|
+
if (!getDb) return dbNotConfigured();
|
|
1330
|
+
const modulesDeps: Parameters<typeof handleApiModules>[1] = {
|
|
1331
|
+
db: getDb(),
|
|
1332
|
+
issuer: oauthDeps(req).issuer,
|
|
1333
|
+
manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
1334
|
+
};
|
|
1335
|
+
if (deps?.supervisor !== undefined) modulesDeps.supervisor = deps.supervisor;
|
|
1336
|
+
return handleApiModules(req, modulesDeps);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Channel toggle (hub#275) — pre-empts the /api/modules/:short/*
|
|
1340
|
+
// routes below so `/api/modules/channel` doesn't accidentally match
|
|
1341
|
+
// `parseModulesPath` (which would reject it as a non-curated short
|
|
1342
|
+
// anyway, but precedence makes the intent explicit).
|
|
1343
|
+
if (pathname === "/api/modules/channel") {
|
|
1344
|
+
if (!getDb) return dbNotConfigured();
|
|
1345
|
+
return handleApiModulesChannel(req, {
|
|
1346
|
+
db: getDb(),
|
|
1347
|
+
issuer: oauthDeps(req).issuer,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Module operation poll surface — pre-empts the /api/modules/:short/*
|
|
1352
|
+
// routes below so `/api/modules/operations/<uuid>` doesn't accidentally
|
|
1353
|
+
// match a parseModulesPath("/operations") and fall through.
|
|
1354
|
+
if (pathname.startsWith("/api/modules/operations/")) {
|
|
1355
|
+
if (!getDb) return dbNotConfigured();
|
|
1356
|
+
if (!deps?.supervisor) {
|
|
1357
|
+
return new Response(
|
|
1358
|
+
JSON.stringify({
|
|
1359
|
+
error: "supervisor_unavailable",
|
|
1360
|
+
error_description:
|
|
1361
|
+
"module operations require `parachute serve` (supervisor mode); on-box CLI uses `parachute install/upgrade/restart`",
|
|
1362
|
+
}),
|
|
1363
|
+
{ status: 503, headers: { "content-type": "application/json" } },
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
const opId = decodeURIComponent(pathname.slice("/api/modules/operations/".length));
|
|
1367
|
+
if (!opId || opId.includes("/")) return new Response("not found", { status: 404 });
|
|
1368
|
+
return handleOperationGet(req, opId, {
|
|
1369
|
+
db: getDb(),
|
|
1370
|
+
issuer: oauthDeps(req).issuer,
|
|
1371
|
+
manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
1372
|
+
configDir: CONFIG_DIR,
|
|
1373
|
+
supervisor: deps.supervisor,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
|
|
1378
|
+
if (pathname.startsWith("/api/modules/")) {
|
|
1379
|
+
if (!getDb) return dbNotConfigured();
|
|
1380
|
+
if (!deps?.supervisor) {
|
|
1381
|
+
return new Response(
|
|
1382
|
+
JSON.stringify({
|
|
1383
|
+
error: "supervisor_unavailable",
|
|
1384
|
+
error_description:
|
|
1385
|
+
"module operations require `parachute serve` (supervisor mode); on-box CLI uses `parachute install/upgrade/restart`",
|
|
1386
|
+
}),
|
|
1387
|
+
{ status: 503, headers: { "content-type": "application/json" } },
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
const match = parseModulesPath(pathname);
|
|
1391
|
+
if (!match) return new Response("not found", { status: 404 });
|
|
1392
|
+
const opsDeps = {
|
|
1393
|
+
db: getDb(),
|
|
1394
|
+
issuer: oauthDeps(req).issuer,
|
|
1395
|
+
manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
1396
|
+
configDir: CONFIG_DIR,
|
|
1397
|
+
supervisor: deps.supervisor,
|
|
1398
|
+
};
|
|
1399
|
+
switch (match.rest) {
|
|
1400
|
+
case "install":
|
|
1401
|
+
return handleInstall(req, match.short, opsDeps);
|
|
1402
|
+
case "restart":
|
|
1403
|
+
return handleRestart(req, match.short, opsDeps);
|
|
1404
|
+
case "upgrade":
|
|
1405
|
+
return handleUpgrade(req, match.short, opsDeps);
|
|
1406
|
+
case "uninstall":
|
|
1407
|
+
return handleUninstall(req, match.short, opsDeps);
|
|
1408
|
+
default:
|
|
1409
|
+
return new Response("not found", { status: 404 });
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (pathname === "/api/auth/mint-token") {
|
|
1414
|
+
if (!getDb) return dbNotConfigured();
|
|
1415
|
+
return handleApiMintToken(req, {
|
|
1416
|
+
db: getDb(),
|
|
1417
|
+
issuer: oauthDeps(req).issuer,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
if (pathname === "/api/auth/revoke-token") {
|
|
1422
|
+
if (!getDb) return dbNotConfigured();
|
|
1423
|
+
return handleApiRevokeToken(req, {
|
|
1424
|
+
db: getDb(),
|
|
1425
|
+
issuer: oauthDeps(req).issuer,
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (pathname === "/api/auth/tokens") {
|
|
1430
|
+
if (!getDb) return dbNotConfigured();
|
|
1431
|
+
return handleApiTokens(req, {
|
|
1432
|
+
db: getDb(),
|
|
1433
|
+
issuer: oauthDeps(req).issuer,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
841
1437
|
if (pathname === "/api/grants") {
|
|
842
|
-
if (!getDb) return
|
|
1438
|
+
if (!getDb) return dbNotConfigured();
|
|
843
1439
|
return handleListGrants(req, {
|
|
844
1440
|
db: getDb(),
|
|
845
1441
|
issuer: oauthDeps(req).issuer,
|
|
@@ -847,7 +1443,7 @@ export function hubFetch(
|
|
|
847
1443
|
}
|
|
848
1444
|
|
|
849
1445
|
if (pathname.startsWith("/api/grants/")) {
|
|
850
|
-
if (!getDb) return
|
|
1446
|
+
if (!getDb) return dbNotConfigured();
|
|
851
1447
|
const clientId = decodeURIComponent(pathname.slice("/api/grants/".length));
|
|
852
1448
|
if (!clientId || clientId.includes("/")) {
|
|
853
1449
|
return new Response("not found", { status: 404 });
|
|
@@ -858,63 +1454,98 @@ export function hubFetch(
|
|
|
858
1454
|
});
|
|
859
1455
|
}
|
|
860
1456
|
|
|
861
|
-
|
|
862
|
-
|
|
1457
|
+
// OAuth client lookup + approval. Both bearer-gated under host:admin.
|
|
1458
|
+
// Two paths: `/api/oauth/clients/<id>` (GET, details) and
|
|
1459
|
+
// `/api/oauth/clients/<id>/approve` (POST, flip to approved). The
|
|
1460
|
+
// SPA approve-client deep link reads details from the first and
|
|
1461
|
+
// submits approval to the second — keeps the surface easy to test
|
|
1462
|
+
// and audit without overloading a single verb.
|
|
1463
|
+
if (pathname.startsWith("/api/oauth/clients/")) {
|
|
1464
|
+
if (!getDb) return dbNotConfigured();
|
|
1465
|
+
const tail = pathname.slice("/api/oauth/clients/".length);
|
|
1466
|
+
if (!tail) return new Response("not found", { status: 404 });
|
|
1467
|
+
const approveSuffix = "/approve";
|
|
1468
|
+
if (tail.endsWith(approveSuffix)) {
|
|
1469
|
+
const clientId = decodeURIComponent(tail.slice(0, -approveSuffix.length));
|
|
1470
|
+
if (!clientId || clientId.includes("/")) {
|
|
1471
|
+
return new Response("not found", { status: 404 });
|
|
1472
|
+
}
|
|
1473
|
+
return handleApproveClient(req, clientId, {
|
|
1474
|
+
db: getDb(),
|
|
1475
|
+
issuer: oauthDeps(req).issuer,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
const clientId = decodeURIComponent(tail);
|
|
1479
|
+
if (!clientId || clientId.includes("/")) {
|
|
1480
|
+
return new Response("not found", { status: 404 });
|
|
1481
|
+
}
|
|
1482
|
+
return handleGetClient(req, clientId, {
|
|
1483
|
+
db: getDb(),
|
|
1484
|
+
issuer: oauthDeps(req).issuer,
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Canonical login/logout. The handlers themselves are unchanged from
|
|
1489
|
+
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
1490
|
+
// via #231-followup so the URL reflects the surface's actual scope
|
|
1491
|
+
// (entry point for ALL parachute auth — not admin-only). The
|
|
1492
|
+
// /admin/login and /admin/logout paths 301 to here, dispatched at the
|
|
1493
|
+
// top of this fn alongside the other back-compat redirects.
|
|
1494
|
+
if (pathname === "/login") {
|
|
1495
|
+
if (!getDb) return dbNotConfigured();
|
|
863
1496
|
if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
|
|
864
1497
|
if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
|
|
865
1498
|
return new Response("method not allowed", { status: 405 });
|
|
866
1499
|
}
|
|
867
1500
|
|
|
868
|
-
if (pathname === "/
|
|
869
|
-
if (!getDb) return
|
|
1501
|
+
if (pathname === "/logout") {
|
|
1502
|
+
if (!getDb) return dbNotConfigured();
|
|
870
1503
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
871
1504
|
return handleAdminLogoutPost(getDb(), req);
|
|
872
1505
|
}
|
|
873
1506
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1507
|
+
// Legacy `/admin/config` (server-rendered module-config portal, #46)
|
|
1508
|
+
// retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
|
|
1509
|
+
// post-login redirect lands somewhere useful. The route stays here in
|
|
1510
|
+
// dispatch order (above the /admin/* SPA catch-all) so the redirect
|
|
1511
|
+
// wins over a SPA shell render.
|
|
1512
|
+
if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
|
|
1513
|
+
return new Response(null, {
|
|
1514
|
+
status: 301,
|
|
1515
|
+
headers: { location: "/admin/vaults" },
|
|
1516
|
+
});
|
|
878
1517
|
}
|
|
879
1518
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// /vault — primary SPA mount + dynamic per-vault proxy share this
|
|
891
|
-
// namespace. Order matters:
|
|
892
|
-
// 1. `/vault` exact → SPA shell (vault list).
|
|
893
|
-
// 2. `/vault/<known-vault>/...` → proxy to the vault backend, picked
|
|
894
|
-
// from services.json by longest-mount-prefix. Read per request so a
|
|
895
|
-
// `parachute vault create` performed after `parachute expose` is
|
|
896
|
-
// immediately reachable (#144).
|
|
897
|
-
// 3. `/vault/<spa-route>` → SPA shell. Only single-segment paths
|
|
898
|
-
// (`/vault/new`, `/vault/<name>`) and `/vault/assets/*` count as
|
|
899
|
-
// SPA routes. Multi-segment requests like `/vault/<unknown>/health`
|
|
900
|
-
// are vault-API shapes targeting a non-existent vault and 404 —
|
|
901
|
-
// otherwise the SPA shell would mask backend 404s with HTML.
|
|
902
|
-
// `new` and `assets` are reserved vault names (see
|
|
903
|
-
// `RESERVED_VAULT_NAMES` in admin-vaults.ts) so an operator
|
|
904
|
-
// can't register a vault that shadows the SPA's create route or
|
|
905
|
-
// its static asset bundle.
|
|
906
|
-
if (pathname === "/vault") {
|
|
907
|
-
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
908
|
-
return serveSpa(spaDistDir, pathname, "/vault");
|
|
909
|
-
}
|
|
1519
|
+
// /vault/<name>/* — per-vault content proxy. Stays as user-facing
|
|
1520
|
+
// surface (the Notes PWA loads through here, etc.). The bare `/vault`
|
|
1521
|
+
// and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
|
|
1522
|
+
// the top of dispatch now. Multi-segment requests like
|
|
1523
|
+
// `/vault/<unknown>/health` are vault-API shapes targeting a
|
|
1524
|
+
// non-existent vault and 404 directly — there's no SPA-shell fallback
|
|
1525
|
+
// here anymore (the SPA moved to /admin), so we can't accidentally
|
|
1526
|
+
// mask a backend 404 with HTML.
|
|
910
1527
|
if (pathname.startsWith("/vault/")) {
|
|
911
1528
|
const proxied = await proxyToVault(req, manifestPath);
|
|
912
1529
|
if (proxied) return proxied;
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1530
|
+
return new Response("not found", { status: 404 });
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
|
|
1534
|
+
// vault-admin-token, login, logout, config, api/auth/*, api/grants,
|
|
1535
|
+
// grants/*) ran above and either matched or returned. Anything that
|
|
1536
|
+
// makes it here under /admin/* is a SPA route or asset request; the
|
|
1537
|
+
// SPA's own router renders the page and handles 404 client-side for
|
|
1538
|
+
// unknown sub-paths.
|
|
1539
|
+
if (pathname === "/admin" || pathname === "/admin/") {
|
|
1540
|
+
// Unprefixed /admin → SPA shell pointed at the vault list (its home).
|
|
1541
|
+
// The SPA's basename is /admin, so the router will land on / and
|
|
1542
|
+
// render VaultsList.
|
|
1543
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
1544
|
+
return serveSpa(spaDistDir, pathname, "/admin");
|
|
1545
|
+
}
|
|
1546
|
+
if (pathname.startsWith("/admin/")) {
|
|
1547
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
1548
|
+
return serveSpa(spaDistDir, pathname, "/admin");
|
|
918
1549
|
}
|
|
919
1550
|
|
|
920
1551
|
// Generic services.json-driven dispatch for non-vault modules. Reaches
|
|
@@ -929,7 +1560,7 @@ export function hubFetch(
|
|
|
929
1560
|
}
|
|
930
1561
|
|
|
931
1562
|
if (import.meta.main) {
|
|
932
|
-
const { port, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
|
|
1563
|
+
const { port, hostname, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
|
|
933
1564
|
let cachedDb: Database | undefined;
|
|
934
1565
|
const getDb = () => {
|
|
935
1566
|
if (!cachedDb) cachedDb = openHubDb(dbPath);
|
|
@@ -937,8 +1568,8 @@ if (import.meta.main) {
|
|
|
937
1568
|
};
|
|
938
1569
|
Bun.serve({
|
|
939
1570
|
port,
|
|
940
|
-
hostname
|
|
941
|
-
fetch: hubFetch(wellKnownDir, { getDb, issuer }),
|
|
1571
|
+
hostname,
|
|
1572
|
+
fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
|
|
942
1573
|
});
|
|
943
1574
|
// Register PID + port from the running hub itself so any startup path
|
|
944
1575
|
// (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from
|
|
@@ -961,7 +1592,7 @@ if (import.meta.main) {
|
|
|
961
1592
|
});
|
|
962
1593
|
process.on("exit", cleanup);
|
|
963
1594
|
console.log(
|
|
964
|
-
`parachute-hub listening on http
|
|
1595
|
+
`parachute-hub listening on http://${hostname}:${port} (dir=${wellKnownDir}, db=${dbPath}${
|
|
965
1596
|
issuer ? `, issuer=${issuer}` : ""
|
|
966
1597
|
})`,
|
|
967
1598
|
);
|