@openparachute/hub 0.5.7 → 0.5.10-rc.2
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-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__/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 +526 -67
- package/src/__tests__/hub.test.ts +108 -55
- 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 +375 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/setup-gate.test.ts +196 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +408 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +32 -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-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.ts +157 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +6 -3
- package/src/help.ts +54 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +630 -135
- package/src/hub.ts +272 -149
- 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 +238 -54
- 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/scope-explanations.ts +33 -2
- package/src/sessions.ts +1 -1
- package/src/supervisor.ts +359 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -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,62 @@
|
|
|
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/auth/mint-token (POST) → CLI/automation token mint (bearer)
|
|
45
|
+
* /api/auth/revoke-token (POST) → revoke registry-row token by jti
|
|
46
|
+
* /api/auth/tokens (GET) → paginated registry list
|
|
47
|
+
* /api/grants (GET) → OAuth consent grants list
|
|
48
|
+
* /api/grants/<client_id> (DELETE) → revoke a single OAuth grant
|
|
49
|
+
* /api/oauth/clients/<id> (GET) → OAuth client details
|
|
50
|
+
* /api/oauth/clients/<id>/approve (POST) → flip a pending client to approved
|
|
51
|
+
* /login (GET + POST) → operator password login
|
|
52
|
+
* /logout (POST) → end admin session
|
|
53
|
+
* /admin/config* → 301 → /admin/vaults (legacy
|
|
54
|
+
* portal retired post-SPA-rework)
|
|
55
|
+
*
|
|
56
|
+
* # Per-vault content proxy (user-facing vault data: Notes PWA, MCP, etc.).
|
|
57
|
+
* /vault/<name>/* → proxy to the vault backend
|
|
58
|
+
*
|
|
59
|
+
* # Admin SPA mount (catch-all under /admin; runs after all admin API
|
|
60
|
+
* # handlers above, so /admin/<known> reaches the right handler and
|
|
61
|
+
* # /admin/<spa-route> serves the SPA shell).
|
|
62
|
+
* /admin, /admin/, /admin/* → SPA shell (vaults / new / permissions / tokens)
|
|
63
|
+
*
|
|
64
|
+
* # Generic services.json-driven proxy (non-vault modules: notes, scribe, agent).
|
|
65
|
+
* /<service-mount>/* → proxy via services.json longest-prefix
|
|
66
|
+
*
|
|
67
|
+
* anything else → 404
|
|
23
68
|
*
|
|
24
69
|
* Invoked as:
|
|
25
70
|
* bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
|
|
@@ -40,10 +85,10 @@ import type { Database } from "bun:sqlite";
|
|
|
40
85
|
import { existsSync } from "node:fs";
|
|
41
86
|
import { dirname, join, resolve } from "node:path";
|
|
42
87
|
import { fileURLToPath } from "node:url";
|
|
88
|
+
import pkg from "../package.json" with { type: "json" };
|
|
89
|
+
import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
43
90
|
import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
|
|
44
91
|
import {
|
|
45
|
-
handleAdminConfigGet,
|
|
46
|
-
handleAdminConfigPost,
|
|
47
92
|
handleAdminLoginGet,
|
|
48
93
|
handleAdminLoginPost,
|
|
49
94
|
handleAdminLogoutPost,
|
|
@@ -51,9 +96,17 @@ import {
|
|
|
51
96
|
import { handleHostAdminToken } from "./admin-host-admin-token.ts";
|
|
52
97
|
import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
|
|
53
98
|
import { handleCreateVault } from "./admin-vaults.ts";
|
|
99
|
+
import { handleApiMe } from "./api-me.ts";
|
|
100
|
+
import { handleApiMintToken } from "./api-mint-token.ts";
|
|
101
|
+
import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
|
|
102
|
+
import { handleApiRevokeToken } from "./api-revoke-token.ts";
|
|
103
|
+
import { handleApiTokens } from "./api-tokens.ts";
|
|
54
104
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
55
|
-
import {
|
|
105
|
+
import { ensureCsrfToken } from "./csrf.ts";
|
|
106
|
+
import { readExposeState } from "./expose-state.ts";
|
|
107
|
+
import { HUB_DEFAULT_PORT, HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
|
|
56
108
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
109
|
+
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
57
110
|
import { pemToJwk } from "./jwks.ts";
|
|
58
111
|
import {
|
|
59
112
|
type ModuleManifest,
|
|
@@ -68,6 +121,7 @@ import {
|
|
|
68
121
|
handleRevoke,
|
|
69
122
|
handleToken,
|
|
70
123
|
} from "./oauth-handlers.ts";
|
|
124
|
+
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
71
125
|
import { clearPid, writePid } from "./process-state.ts";
|
|
72
126
|
import {
|
|
73
127
|
FIRST_PARTY_FALLBACKS,
|
|
@@ -75,18 +129,39 @@ import {
|
|
|
75
129
|
shortNameForManifest,
|
|
76
130
|
} from "./service-spec.ts";
|
|
77
131
|
import { type ServiceEntry, readManifest } from "./services-manifest.ts";
|
|
132
|
+
import { findActiveSession } from "./sessions.ts";
|
|
78
133
|
import { getAllPublicKeys } from "./signing-keys.ts";
|
|
79
|
-
import {
|
|
134
|
+
import { getUserById, userCount } from "./users.ts";
|
|
135
|
+
import {
|
|
136
|
+
WELL_KNOWN_DIR,
|
|
137
|
+
buildWellKnown,
|
|
138
|
+
isVaultEntry,
|
|
139
|
+
vaultInstanceNameFor,
|
|
140
|
+
} from "./well-known.ts";
|
|
80
141
|
|
|
81
142
|
interface Args {
|
|
82
143
|
port: number;
|
|
144
|
+
hostname: string;
|
|
83
145
|
wellKnownDir: string;
|
|
84
146
|
dbPath: string;
|
|
85
147
|
issuer: string | undefined;
|
|
86
148
|
}
|
|
87
149
|
|
|
88
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Parse hub-server flags. Container hosts (Render, Docker) configure us
|
|
152
|
+
* entirely via env vars — no flags. The `parachute expose` spawn path passes
|
|
153
|
+
* everything as flags. Flags beat env, env beats defaults.
|
|
154
|
+
*
|
|
155
|
+
* PORT — bind port (Render injects this)
|
|
156
|
+
* PARACHUTE_BIND_HOST — bind hostname; default 127.0.0.1 to keep
|
|
157
|
+
* the historical loopback posture safe.
|
|
158
|
+
* Containers should set 0.0.0.0.
|
|
159
|
+
* PARACHUTE_HUB_ORIGIN — canonical https://… origin used as the
|
|
160
|
+
* OAuth issuer claim.
|
|
161
|
+
*/
|
|
162
|
+
function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): Args {
|
|
89
163
|
let port: number | undefined;
|
|
164
|
+
let hostname: string | undefined;
|
|
90
165
|
let wellKnownDir: string | undefined;
|
|
91
166
|
let dbPath: string | undefined;
|
|
92
167
|
let issuer: string | undefined;
|
|
@@ -95,11 +170,11 @@ function parseArgs(argv: string[]): Args {
|
|
|
95
170
|
if (a === "--port") {
|
|
96
171
|
const v = argv[++i];
|
|
97
172
|
if (!v) throw new Error("--port requires a value");
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
173
|
+
port = parsePort(v);
|
|
174
|
+
} else if (a === "--hostname") {
|
|
175
|
+
const v = argv[++i];
|
|
176
|
+
if (!v) throw new Error("--hostname requires a value");
|
|
177
|
+
hostname = v;
|
|
103
178
|
} else if (a === "--well-known-dir") {
|
|
104
179
|
const v = argv[++i];
|
|
105
180
|
if (!v) throw new Error("--well-known-dir requires a value");
|
|
@@ -116,9 +191,22 @@ function parseArgs(argv: string[]): Args {
|
|
|
116
191
|
throw new Error(`unknown argument: ${a}`);
|
|
117
192
|
}
|
|
118
193
|
}
|
|
119
|
-
if (port === undefined
|
|
120
|
-
if (
|
|
121
|
-
|
|
194
|
+
if (port === undefined && env.PORT) port = parsePort(env.PORT);
|
|
195
|
+
if (port === undefined) port = HUB_DEFAULT_PORT;
|
|
196
|
+
if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
|
|
197
|
+
if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
|
|
198
|
+
if (issuer === undefined && env.PARACHUTE_HUB_ORIGIN) {
|
|
199
|
+
issuer = env.PARACHUTE_HUB_ORIGIN.replace(/\/+$/, "");
|
|
200
|
+
}
|
|
201
|
+
return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parsePort(v: string): number {
|
|
205
|
+
const n = Number.parseInt(v, 10);
|
|
206
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
207
|
+
throw new Error(`port must be 1..65535, got "${v}"`);
|
|
208
|
+
}
|
|
209
|
+
return n;
|
|
122
210
|
}
|
|
123
211
|
|
|
124
212
|
/**
|
|
@@ -458,6 +546,23 @@ export interface HubFetchDeps {
|
|
|
458
546
|
* fixture installDirs.
|
|
459
547
|
*/
|
|
460
548
|
readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
549
|
+
/**
|
|
550
|
+
* Hub's listening port. Threaded into the OAuth `hubBoundOrigins` set so
|
|
551
|
+
* the same-origin defense accepts loopback access (`http://localhost:<port>`,
|
|
552
|
+
* `http://127.0.0.1:<port>`) alongside the configured issuer. Closes #245
|
|
553
|
+
* Case A (operator on `localhost:1939` getting "Cross-origin request
|
|
554
|
+
* rejected" because Origin ≠ tailnet issuer).
|
|
555
|
+
*/
|
|
556
|
+
loopbackPort?: number;
|
|
557
|
+
/**
|
|
558
|
+
* Test seam for reading `expose-state.json`. Production reads the operator's
|
|
559
|
+
* `~/.parachute/expose-state.json` via `readExposeState`; tests inject a
|
|
560
|
+
* fake to drive tailnet/funnel origins into the bound set without standing
|
|
561
|
+
* up real exposes. Returns `undefined` when no state file is present
|
|
562
|
+
* (pre-`parachute expose` state — fine, the issuer + loopback still cover
|
|
563
|
+
* legitimate access).
|
|
564
|
+
*/
|
|
565
|
+
loadExposeHubOrigin?: () => string | undefined;
|
|
461
566
|
}
|
|
462
567
|
|
|
463
568
|
/**
|
|
@@ -490,6 +595,50 @@ async function loadManagementUrls(
|
|
|
490
595
|
return out;
|
|
491
596
|
}
|
|
492
597
|
|
|
598
|
+
/**
|
|
599
|
+
* For each NON-vault `ServiceEntry` with a known `installDir`, read its
|
|
600
|
+
* `.parachute/module.json` and surface the optional `uiUrl` and
|
|
601
|
+
* `displayName`. Returns two `name → value` maps keyed by services.json
|
|
602
|
+
* entry name. Mirrors `loadManagementUrls` (vault is the analog there;
|
|
603
|
+
* non-vault services are the analog here — vaults are user-facing via
|
|
604
|
+
* Notes, not their own UI).
|
|
605
|
+
*
|
|
606
|
+
* Why read at request time and not from services.json: services own the
|
|
607
|
+
* write side of services.json (`upsertService` replaces the whole entry
|
|
608
|
+
* on every boot), so any install-time copy of `uiUrl` / `displayName`
|
|
609
|
+
* would be clobbered the first time the service writes its own entry.
|
|
610
|
+
* Reading from `installDir/module.json` at request time avoids the gap
|
|
611
|
+
* and matches the established `managementUrl` precedent.
|
|
612
|
+
*
|
|
613
|
+
* Quiet on per-entry errors: a malformed module.json on one service
|
|
614
|
+
* shouldn't 500 the entire well-known doc — its row just renders without
|
|
615
|
+
* a Services tile. The validator already throws structured errors from
|
|
616
|
+
* `readModuleManifest`; logging them once here is the right floor.
|
|
617
|
+
*/
|
|
618
|
+
async function loadServiceUiMetadata(
|
|
619
|
+
services: readonly ServiceEntry[],
|
|
620
|
+
read: (installDir: string) => Promise<ModuleManifest | null>,
|
|
621
|
+
): Promise<{ uiUrls: Map<string, string>; displayNames: Map<string, string> }> {
|
|
622
|
+
const uiUrls = new Map<string, string>();
|
|
623
|
+
const displayNames = new Map<string, string>();
|
|
624
|
+
await Promise.all(
|
|
625
|
+
services.map(async (s) => {
|
|
626
|
+
// Skip vaults — they have their own loadManagementUrls path and no
|
|
627
|
+
// operator-facing user UI of their own (content browses via Notes).
|
|
628
|
+
if (isVaultEntry(s) || !s.installDir) return;
|
|
629
|
+
try {
|
|
630
|
+
const m = await read(s.installDir);
|
|
631
|
+
if (m?.uiUrl) uiUrls.set(s.name, m.uiUrl);
|
|
632
|
+
if (m?.displayName) displayNames.set(s.name, m.displayName);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
635
|
+
console.warn(`well-known: skipping uiUrl/displayName for ${s.name}: ${msg}`);
|
|
636
|
+
}
|
|
637
|
+
}),
|
|
638
|
+
);
|
|
639
|
+
return { uiUrls, displayNames };
|
|
640
|
+
}
|
|
641
|
+
|
|
493
642
|
/**
|
|
494
643
|
* Resolve the SPA bundle dir. We anchor to this file's location so a
|
|
495
644
|
* `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
|
|
@@ -503,22 +652,23 @@ function defaultSpaDistDir(): string {
|
|
|
503
652
|
}
|
|
504
653
|
|
|
505
654
|
/**
|
|
506
|
-
* The SPA serves at
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* `/
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* (`/vault
|
|
518
|
-
*
|
|
519
|
-
*
|
|
655
|
+
* The admin SPA serves at a single mount: `/admin/*` (since hub#231).
|
|
656
|
+
*
|
|
657
|
+
* Routes:
|
|
658
|
+
* - `/admin/vaults` → vault list (the SPA's home)
|
|
659
|
+
* - `/admin/vaults/new` → vault create form
|
|
660
|
+
* - `/admin/permissions` → OAuth consent grant management
|
|
661
|
+
* - `/admin/tokens` → token registry: mint / list / revoke
|
|
662
|
+
*
|
|
663
|
+
* Asset URLs are origin-absolute (`/admin/assets/...`) per the Vite build
|
|
664
|
+
* base. main.tsx pins react-router's basename to `/admin`.
|
|
665
|
+
*
|
|
666
|
+
* Pre-rename mounts (the old `/vault` for the vault SPA, `/hub/*` for
|
|
667
|
+
* permissions+tokens) are 301-redirected further up the dispatch so cached
|
|
668
|
+
* operator URLs keep working. `/vault/<name>/*` (per-vault content proxy)
|
|
669
|
+
* stays — that's user-facing vault data, not part of this admin SPA.
|
|
520
670
|
*/
|
|
521
|
-
type SpaMount = "/
|
|
671
|
+
type SpaMount = "/admin";
|
|
522
672
|
|
|
523
673
|
/**
|
|
524
674
|
* Pick a content type for static assets the SPA build produces. Vite's
|
|
@@ -565,8 +715,8 @@ function spaContentType(pathname: string): string {
|
|
|
565
715
|
* filter rejects sub-paths containing "..", and the resolved absolute
|
|
566
716
|
* path is checked to start with `dist/` before any read.
|
|
567
717
|
*
|
|
568
|
-
* `mount` is the prefix being served (`/
|
|
569
|
-
*
|
|
718
|
+
* `mount` is the prefix being served (`/admin`); we strip it from
|
|
719
|
+
* `pathname` to land on the file path inside `dist/`.
|
|
570
720
|
*/
|
|
571
721
|
async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
|
|
572
722
|
if (!existsSync(spaDistDir)) {
|
|
@@ -575,7 +725,7 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
|
|
|
575
725
|
{ status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
|
|
576
726
|
);
|
|
577
727
|
}
|
|
578
|
-
// Strip the mount prefix; "/
|
|
728
|
+
// Strip the mount prefix; "/admin" → "", "/admin/" → "/", "/admin/x" → "/x".
|
|
579
729
|
const sub = pathname === mount ? "" : pathname.slice(mount.length);
|
|
580
730
|
const indexPath = join(spaDistDir, "index.html");
|
|
581
731
|
|
|
@@ -608,6 +758,104 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
|
|
|
608
758
|
});
|
|
609
759
|
}
|
|
610
760
|
|
|
761
|
+
/**
|
|
762
|
+
* Routes that fall through the pre-admin lockout (503 → /admin/setup).
|
|
763
|
+
*
|
|
764
|
+
* Gated (operator-facing) when no admin row exists:
|
|
765
|
+
* - `/admin/*` (except `/admin/setup`) — vault admin, permissions, tokens
|
|
766
|
+
* - `/api/*` — host-admin API surface
|
|
767
|
+
* - `/login`, `/logout` — pointless without an account
|
|
768
|
+
*
|
|
769
|
+
* Open through the gate (so the container is reachable and discoverable
|
|
770
|
+
* the moment it boots, even before an admin is seeded):
|
|
771
|
+
* - `/health` — platform liveness check
|
|
772
|
+
* - `/.well-known/*` — public discovery + JWKS
|
|
773
|
+
* - `/admin/setup` — the setup placeholder itself
|
|
774
|
+
* - `/` and `/hub.html` — static discovery page
|
|
775
|
+
* - `/oauth/*` — third-party OAuth surface; clients
|
|
776
|
+
* can register/refresh independent
|
|
777
|
+
* of admin onboarding
|
|
778
|
+
* - `/vault/*`, `/<service>/*` — content proxies; service-level auth
|
|
779
|
+
* handles its own failure modes
|
|
780
|
+
*
|
|
781
|
+
* The function is called only when an admin row is missing; in the
|
|
782
|
+
* normal-running case (any admin row exists) this gate is a no-op and the
|
|
783
|
+
* regular dispatch continues.
|
|
784
|
+
*/
|
|
785
|
+
function shouldGateForSetup(pathname: string): boolean {
|
|
786
|
+
if (pathname === "/login" || pathname === "/logout") return true;
|
|
787
|
+
if (pathname === "/admin/setup") return false;
|
|
788
|
+
if (pathname === "/admin" || pathname.startsWith("/admin/")) return true;
|
|
789
|
+
if (pathname.startsWith("/api/")) return true;
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Minimal first-boot setup page. The real wizard (form + submit + admin
|
|
795
|
+
* row create) ships in hub#259. For now this is a static, single-screen
|
|
796
|
+
* HTML doc that tells the operator how to seed an admin via env vars and
|
|
797
|
+
* restart. No external assets — the body sits inline so a fresh container
|
|
798
|
+
* with no SPA bundle still serves something coherent.
|
|
799
|
+
*/
|
|
800
|
+
function renderSetupPlaceholder(): string {
|
|
801
|
+
return `<!doctype html>
|
|
802
|
+
<html lang="en">
|
|
803
|
+
<head>
|
|
804
|
+
<meta charset="utf-8" />
|
|
805
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
806
|
+
<title>Parachute Hub — first-boot setup</title>
|
|
807
|
+
<style>
|
|
808
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
809
|
+
max-width: 36rem; margin: 4rem auto; padding: 0 1.5rem; line-height: 1.55;
|
|
810
|
+
color: #1a1a1a; background: #fafafa; }
|
|
811
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
812
|
+
p.lede { color: #555; margin-top: 0; }
|
|
813
|
+
code, pre { background: #f0eee7; border-radius: 4px; padding: 0.1rem 0.35rem;
|
|
814
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.95em; }
|
|
815
|
+
pre { padding: 0.75rem 1rem; overflow-x: auto; }
|
|
816
|
+
.note { background: #fff8e1; border-left: 3px solid #d4a017; padding: 0.75rem 1rem;
|
|
817
|
+
margin: 1.5rem 0; font-size: 0.92em; }
|
|
818
|
+
</style>
|
|
819
|
+
</head>
|
|
820
|
+
<body>
|
|
821
|
+
<h1>Parachute Hub — first-boot setup</h1>
|
|
822
|
+
<p class="lede">No admin account exists yet on this hub. Set the seed env
|
|
823
|
+
vars below and restart the container to bootstrap the first operator.</p>
|
|
824
|
+
|
|
825
|
+
<h2>Option 1 — env-var seed (recommended for containers)</h2>
|
|
826
|
+
<p>Set these on the container and restart:</p>
|
|
827
|
+
<pre>PARACHUTE_INITIAL_ADMIN_USERNAME=ops
|
|
828
|
+
PARACHUTE_INITIAL_ADMIN_PASSWORD=<a strong password></pre>
|
|
829
|
+
<p>On boot the hub will create the admin row, then ignore the env vars
|
|
830
|
+
on every subsequent restart — they're a first-boot seed, not a reset
|
|
831
|
+
switch. Rotate the password later via <code>parachute auth set-password</code>
|
|
832
|
+
inside the container, or via the admin UI.</p>
|
|
833
|
+
|
|
834
|
+
<h2>Option 2 — CLI from inside the container</h2>
|
|
835
|
+
<pre>docker exec -it <container> parachute auth set-password \\
|
|
836
|
+
--username ops --password '<…>'</pre>
|
|
837
|
+
|
|
838
|
+
<div class="note">
|
|
839
|
+
The full web-based setup wizard (browser form, no env vars or shell
|
|
840
|
+
needed) is tracked in <code>hub#259</code> and will replace this
|
|
841
|
+
placeholder when it ships.
|
|
842
|
+
</div>
|
|
843
|
+
</body>
|
|
844
|
+
</html>
|
|
845
|
+
`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Canonical 503 body for "getDb is unset, hub is unconfigured." Shape matches
|
|
849
|
+
// the OAuth error vocabulary used by /api/auth/* (`service_unavailable`) so a
|
|
850
|
+
// consumer never has to branch on content-type to extract a message. See
|
|
851
|
+
// hub#227 for the migration from plain-text bodies.
|
|
852
|
+
function dbNotConfigured(): Response {
|
|
853
|
+
return Response.json(
|
|
854
|
+
{ error: "service_unavailable", error_description: "hub db not configured" },
|
|
855
|
+
{ status: 503 },
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
611
859
|
export function hubFetch(
|
|
612
860
|
wellKnownDir: string,
|
|
613
861
|
deps?: HubFetchDeps,
|
|
@@ -617,31 +865,204 @@ export function hubFetch(
|
|
|
617
865
|
const configuredIssuer = deps?.issuer;
|
|
618
866
|
const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
619
867
|
const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
|
|
868
|
+
const loopbackPort = deps?.loopbackPort;
|
|
869
|
+
const loadExposeHubOrigin =
|
|
870
|
+
deps?.loadExposeHubOrigin ??
|
|
871
|
+
(() => {
|
|
872
|
+
try {
|
|
873
|
+
return readExposeState()?.hubOrigin;
|
|
874
|
+
} catch {
|
|
875
|
+
// Malformed expose-state.json shouldn't 500 hub on every same-origin
|
|
876
|
+
// check — the issuer + loopback already cover legitimate access.
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
});
|
|
620
880
|
|
|
621
|
-
const oauthDeps = (req: Request) =>
|
|
622
|
-
issuer
|
|
623
|
-
|
|
881
|
+
const oauthDeps = (req: Request) => {
|
|
882
|
+
const issuer = configuredIssuer ?? new URL(req.url).origin;
|
|
883
|
+
return {
|
|
884
|
+
issuer,
|
|
885
|
+
// Per-request resolution (closes #245): expose-state.json can change
|
|
886
|
+
// mid-session (operator runs `parachute expose tailnet` while hub is
|
|
887
|
+
// up), so we re-read the bound origins on each call rather than
|
|
888
|
+
// capturing at hub start. Cheap — a single small JSON parse per OAuth
|
|
889
|
+
// request, only on the cookie-POST paths that consult it.
|
|
890
|
+
hubBoundOrigins: () =>
|
|
891
|
+
buildHubBoundOrigins({
|
|
892
|
+
issuer,
|
|
893
|
+
loopbackPort,
|
|
894
|
+
exposeHubOrigin: loadExposeHubOrigin(),
|
|
895
|
+
}),
|
|
896
|
+
};
|
|
897
|
+
};
|
|
624
898
|
|
|
625
899
|
return async (req) => {
|
|
626
900
|
const url = new URL(req.url);
|
|
627
901
|
const pathname = url.pathname;
|
|
628
902
|
|
|
629
|
-
// 301 back-compat
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
903
|
+
// 301 back-compat for the pre-hub#231 admin-SPA mounts:
|
|
904
|
+
//
|
|
905
|
+
// `/vault` → `/admin/vaults`
|
|
906
|
+
// `/vault/new` → `/admin/vaults/new`
|
|
907
|
+
// `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
|
|
908
|
+
// it now retargets at the new admin mount instead
|
|
909
|
+
// of the interim `/vault` mount)
|
|
910
|
+
// `/hub/permissions` → `/admin/permissions`
|
|
911
|
+
// `/hub/tokens` → `/admin/tokens`
|
|
912
|
+
// `/hub` (bare) → `/admin/vaults`
|
|
913
|
+
//
|
|
914
|
+
// Permanent redirect so cached operator URLs keep working without
|
|
915
|
+
// leaving dangling SPA routes. Query string preserved; fragment is
|
|
916
|
+
// client-side and survives the redirect at the browser. Method-agnostic
|
|
917
|
+
// — even a misrouted POST gets the redirect; none of these paths host a
|
|
918
|
+
// POST endpoint to protect.
|
|
919
|
+
//
|
|
920
|
+
// `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
|
|
921
|
+
// content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
|
|
922
|
+
if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
|
|
923
|
+
const sub = pathname === "/vault/new" ? "/new" : "";
|
|
924
|
+
return new Response("", {
|
|
925
|
+
status: 301,
|
|
926
|
+
headers: { location: `/admin/vaults${sub}${url.search}` },
|
|
927
|
+
});
|
|
928
|
+
}
|
|
636
929
|
if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
|
|
637
|
-
const newPath = `/
|
|
930
|
+
const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
|
|
638
931
|
return new Response("", {
|
|
639
932
|
status: 301,
|
|
640
933
|
headers: { location: `${newPath}${url.search}` },
|
|
641
934
|
});
|
|
642
935
|
}
|
|
936
|
+
if (pathname === "/hub/permissions") {
|
|
937
|
+
return new Response("", {
|
|
938
|
+
status: 301,
|
|
939
|
+
headers: { location: `/admin/permissions${url.search}` },
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
if (pathname === "/hub/tokens") {
|
|
943
|
+
return new Response("", {
|
|
944
|
+
status: 301,
|
|
945
|
+
headers: { location: `/admin/tokens${url.search}` },
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
if (pathname === "/hub" || pathname === "/hub/") {
|
|
949
|
+
return new Response("", {
|
|
950
|
+
status: 301,
|
|
951
|
+
headers: { location: `/admin/vaults${url.search}` },
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Login surface rename: `/admin/login` and `/admin/logout` 301 to the
|
|
956
|
+
// canonical `/login` and `/logout`. The names were "admin" only by
|
|
957
|
+
// historical accident — the handlers serve every parachute auth flow
|
|
958
|
+
// (operator, OAuth user-redirect, future SPA sign-in). Renaming makes
|
|
959
|
+
// the surface name match its actual scope.
|
|
960
|
+
if (pathname === "/admin/login") {
|
|
961
|
+
return new Response("", {
|
|
962
|
+
status: 301,
|
|
963
|
+
headers: { location: `/login${url.search}` },
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
if (pathname === "/admin/logout") {
|
|
967
|
+
return new Response("", {
|
|
968
|
+
status: 301,
|
|
969
|
+
headers: { location: `/logout${url.search}` },
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Platform health check (Render, Fly, Kubernetes, etc.). Plain JSON,
|
|
974
|
+
// no DB required — the route reports liveness, not readiness. Anything
|
|
975
|
+
// more invasive (DB ping, schema check) would let a transient lock turn
|
|
976
|
+
// into a restart loop on the platform side. 200 always while the
|
|
977
|
+
// process is up.
|
|
978
|
+
if (pathname === "/health") {
|
|
979
|
+
return new Response(
|
|
980
|
+
JSON.stringify({ status: "ok", service: "parachute-hub", version: pkg.version }),
|
|
981
|
+
{
|
|
982
|
+
headers: {
|
|
983
|
+
"content-type": "application/json",
|
|
984
|
+
"cache-control": "no-store",
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// First-boot setup placeholder. Real wizard ships in hub#259. When no
|
|
991
|
+
// admin exists, render a minimal HTML page pointing operators at the
|
|
992
|
+
// env-var seed path. When an admin already exists, 301 to /login —
|
|
993
|
+
// the route doesn't disappear after setup so a stale bookmark still
|
|
994
|
+
// lands somewhere useful.
|
|
995
|
+
if (pathname === "/admin/setup") {
|
|
996
|
+
if (getDb) {
|
|
997
|
+
const db = getDb();
|
|
998
|
+
if (userCount(db) > 0) {
|
|
999
|
+
return new Response("", {
|
|
1000
|
+
status: 301,
|
|
1001
|
+
headers: { location: "/login" },
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return new Response(renderSetupPlaceholder(), {
|
|
1006
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Pre-admin lockout. When the hub has booted with no admin row (the
|
|
1011
|
+
// fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
|
|
1012
|
+
// /admin/setup is walked), every operator-facing surface that requires
|
|
1013
|
+
// identity is meaningless — auth flows can't validate, the SPA can't
|
|
1014
|
+
// mint a host-admin token, OAuth can't issue codes. Route those to a
|
|
1015
|
+
// 503 that points at /admin/setup. Health, well-known, /admin/setup
|
|
1016
|
+
// itself, and the static discovery page (/) ran above and are
|
|
1017
|
+
// unaffected; OAuth + admin + api endpoints fall here.
|
|
1018
|
+
//
|
|
1019
|
+
// `shouldGateForSetup` runs first so non-gated paths (well-known, /,
|
|
1020
|
+
// /health, /admin/setup) never touch getDb — keeping the
|
|
1021
|
+
// existing OPTIONS-preflight contract that those routes are db-free.
|
|
1022
|
+
if (getDb && shouldGateForSetup(pathname) && userCount(getDb()) === 0) {
|
|
1023
|
+
return new Response(
|
|
1024
|
+
JSON.stringify({
|
|
1025
|
+
error: "setup_required",
|
|
1026
|
+
error_description:
|
|
1027
|
+
"no admin configured. Visit /admin/setup, or set PARACHUTE_INITIAL_ADMIN_USERNAME + PARACHUTE_INITIAL_ADMIN_PASSWORD and restart.",
|
|
1028
|
+
setup_url: "/admin/setup",
|
|
1029
|
+
}),
|
|
1030
|
+
{
|
|
1031
|
+
status: 503,
|
|
1032
|
+
headers: {
|
|
1033
|
+
"content-type": "application/json",
|
|
1034
|
+
"cache-control": "no-store",
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
643
1039
|
|
|
644
1040
|
if (pathname === "/" || pathname === "/hub.html") {
|
|
1041
|
+
// When a DB is configured, render the discovery page dynamically so
|
|
1042
|
+
// the header carries a "Signed in as <name>" affordance for the
|
|
1043
|
+
// active session. Without a DB, fall back to the static disk file
|
|
1044
|
+
// (signed-out shape) — the disk file is what `parachute expose`
|
|
1045
|
+
// wrote out, used when the hub-server is running without state.
|
|
1046
|
+
if (getDb) {
|
|
1047
|
+
const db = getDb();
|
|
1048
|
+
const session = findActiveSession(db, req);
|
|
1049
|
+
let renderOpts: RenderHubOpts = {};
|
|
1050
|
+
const headers: Record<string, string> = {
|
|
1051
|
+
"content-type": "text/html; charset=utf-8",
|
|
1052
|
+
};
|
|
1053
|
+
if (session) {
|
|
1054
|
+
const user = getUserById(db, session.userId);
|
|
1055
|
+
if (user) {
|
|
1056
|
+
const csrf = ensureCsrfToken(req);
|
|
1057
|
+
renderOpts = {
|
|
1058
|
+
session: { displayName: user.username, csrfToken: csrf.token },
|
|
1059
|
+
};
|
|
1060
|
+
if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return new Response(renderHub(renderOpts), { headers });
|
|
1064
|
+
}
|
|
1065
|
+
// No DB configured → fall back to static file (signed-out only).
|
|
645
1066
|
if (!existsSync(hubHtmlPath)) {
|
|
646
1067
|
return new Response("hub.html not found", { status: 404 });
|
|
647
1068
|
}
|
|
@@ -671,14 +1092,17 @@ export function hubFetch(
|
|
|
671
1092
|
try {
|
|
672
1093
|
const manifest = readManifest(manifestPath);
|
|
673
1094
|
const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1095
|
+
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
1096
|
+
const [managementUrlByName, serviceUiMeta] = await Promise.all([
|
|
1097
|
+
loadManagementUrls(manifest.services, readManifestFn),
|
|
1098
|
+
loadServiceUiMetadata(manifest.services, readManifestFn),
|
|
1099
|
+
]);
|
|
678
1100
|
const doc = buildWellKnown({
|
|
679
1101
|
services: manifest.services,
|
|
680
1102
|
canonicalOrigin,
|
|
681
1103
|
managementUrlFor: (entry) => managementUrlByName.get(entry.name),
|
|
1104
|
+
uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
|
|
1105
|
+
displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
|
|
682
1106
|
});
|
|
683
1107
|
return new Response(JSON.stringify(doc), {
|
|
684
1108
|
headers: { "content-type": "application/json", ...corsHeaders },
|
|
@@ -694,6 +1118,30 @@ export function hubFetch(
|
|
|
694
1118
|
}
|
|
695
1119
|
}
|
|
696
1120
|
|
|
1121
|
+
if (pathname === REVOCATION_LIST_MOUNT) {
|
|
1122
|
+
// Revocation list (hub#212 Phase 1). Public — same CORS posture as
|
|
1123
|
+
// jwks.json since resource servers (vault/scribe/agent) fetch it
|
|
1124
|
+
// cross-origin on the 60s polling cadence wired in Phase 4.
|
|
1125
|
+
const corsHeaders = {
|
|
1126
|
+
"access-control-allow-origin": "*",
|
|
1127
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
1128
|
+
};
|
|
1129
|
+
if (req.method === "OPTIONS") {
|
|
1130
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
1131
|
+
}
|
|
1132
|
+
if (!getDb) {
|
|
1133
|
+
return new Response('{"error":"revocation list unavailable: db not configured"}', {
|
|
1134
|
+
status: 503,
|
|
1135
|
+
headers: { "content-type": "application/json", ...corsHeaders },
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
const resp = handleRevocationList(req, { db: getDb() });
|
|
1139
|
+
// Layer the wildcard CORS over whatever cache-control the handler set.
|
|
1140
|
+
const merged = new Headers(resp.headers);
|
|
1141
|
+
for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
|
|
1142
|
+
return new Response(resp.body, { status: resp.status, headers: merged });
|
|
1143
|
+
}
|
|
1144
|
+
|
|
697
1145
|
if (pathname === "/.well-known/jwks.json") {
|
|
698
1146
|
// JWKS is also a cross-origin fetch target (browser-side OAuth
|
|
699
1147
|
// libraries pull this to verify access tokens). Same wildcard CORS
|
|
@@ -746,9 +1194,7 @@ export function hubFetch(
|
|
|
746
1194
|
}
|
|
747
1195
|
|
|
748
1196
|
if (pathname === "/oauth/authorize") {
|
|
749
|
-
if (!getDb)
|
|
750
|
-
return new Response("hub db not configured", { status: 503 });
|
|
751
|
-
}
|
|
1197
|
+
if (!getDb) return dbNotConfigured();
|
|
752
1198
|
if (req.method === "GET") return handleAuthorizeGet(getDb(), req, oauthDeps(req));
|
|
753
1199
|
if (req.method === "POST") return handleAuthorizePost(getDb(), req, oauthDeps(req));
|
|
754
1200
|
return new Response("method not allowed", { status: 405 });
|
|
@@ -759,59 +1205,44 @@ export function hubFetch(
|
|
|
759
1205
|
// by handleAuthorizeGet when the operator hits a pending client. Three
|
|
760
1206
|
// gates inside the handler: CSRF, active session, same-origin Origin.
|
|
761
1207
|
if (pathname === "/oauth/authorize/approve") {
|
|
762
|
-
if (!getDb)
|
|
763
|
-
return new Response("hub db not configured", { status: 503 });
|
|
764
|
-
}
|
|
1208
|
+
if (!getDb) return dbNotConfigured();
|
|
765
1209
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
766
1210
|
return handleApproveClientPost(getDb(), req, oauthDeps(req));
|
|
767
1211
|
}
|
|
768
1212
|
|
|
769
1213
|
if (pathname === "/oauth/token") {
|
|
770
|
-
if (!getDb)
|
|
771
|
-
return new Response("hub db not configured", { status: 503 });
|
|
772
|
-
}
|
|
1214
|
+
if (!getDb) return dbNotConfigured();
|
|
773
1215
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
774
1216
|
return handleToken(getDb(), req, oauthDeps(req));
|
|
775
1217
|
}
|
|
776
1218
|
|
|
777
1219
|
if (pathname === "/oauth/register") {
|
|
778
|
-
if (!getDb)
|
|
779
|
-
return new Response("hub db not configured", { status: 503 });
|
|
780
|
-
}
|
|
1220
|
+
if (!getDb) return dbNotConfigured();
|
|
781
1221
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
782
1222
|
return handleRegister(getDb(), req, oauthDeps(req));
|
|
783
1223
|
}
|
|
784
1224
|
|
|
785
1225
|
if (pathname === "/oauth/revoke") {
|
|
786
|
-
if (!getDb)
|
|
787
|
-
return new Response("hub db not configured", { status: 503 });
|
|
788
|
-
}
|
|
1226
|
+
if (!getDb) return dbNotConfigured();
|
|
789
1227
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
790
1228
|
return handleRevoke(getDb(), req, oauthDeps(req));
|
|
791
1229
|
}
|
|
792
1230
|
|
|
793
1231
|
if (pathname === "/vaults") {
|
|
794
|
-
if (!getDb)
|
|
795
|
-
return new Response("hub db not configured", { status: 503 });
|
|
796
|
-
}
|
|
1232
|
+
if (!getDb) return dbNotConfigured();
|
|
797
1233
|
return handleCreateVault(req, {
|
|
798
1234
|
db: getDb(),
|
|
799
1235
|
issuer: oauthDeps(req).issuer,
|
|
800
1236
|
});
|
|
801
1237
|
}
|
|
802
1238
|
|
|
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
|
-
}
|
|
1239
|
+
// Note: the old `/hub/*` SPA mount has been retired. Known prefixes
|
|
1240
|
+
// (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
|
|
1241
|
+
// 301-redirected at the top of dispatch. Any other `/hub/*` path falls
|
|
1242
|
+
// through to the catch-all 404 — there's no admin surface left there.
|
|
812
1243
|
|
|
813
1244
|
if (pathname === "/admin/host-admin-token") {
|
|
814
|
-
if (!getDb) return
|
|
1245
|
+
if (!getDb) return dbNotConfigured();
|
|
815
1246
|
return handleHostAdminToken(req, {
|
|
816
1247
|
db: getDb(),
|
|
817
1248
|
issuer: oauthDeps(req).issuer,
|
|
@@ -819,7 +1250,7 @@ export function hubFetch(
|
|
|
819
1250
|
}
|
|
820
1251
|
|
|
821
1252
|
if (pathname.startsWith("/admin/vault-admin-token/")) {
|
|
822
|
-
if (!getDb) return
|
|
1253
|
+
if (!getDb) return dbNotConfigured();
|
|
823
1254
|
const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
|
|
824
1255
|
// The vault name must correspond to an actual vault instance — same
|
|
825
1256
|
// shape the well-known doc derives. Source from services.json so a
|
|
@@ -838,8 +1269,37 @@ export function hubFetch(
|
|
|
838
1269
|
});
|
|
839
1270
|
}
|
|
840
1271
|
|
|
1272
|
+
if (pathname === "/api/me") {
|
|
1273
|
+
if (!getDb) return dbNotConfigured();
|
|
1274
|
+
return handleApiMe(req, { db: getDb() });
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (pathname === "/api/auth/mint-token") {
|
|
1278
|
+
if (!getDb) return dbNotConfigured();
|
|
1279
|
+
return handleApiMintToken(req, {
|
|
1280
|
+
db: getDb(),
|
|
1281
|
+
issuer: oauthDeps(req).issuer,
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (pathname === "/api/auth/revoke-token") {
|
|
1286
|
+
if (!getDb) return dbNotConfigured();
|
|
1287
|
+
return handleApiRevokeToken(req, {
|
|
1288
|
+
db: getDb(),
|
|
1289
|
+
issuer: oauthDeps(req).issuer,
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (pathname === "/api/auth/tokens") {
|
|
1294
|
+
if (!getDb) return dbNotConfigured();
|
|
1295
|
+
return handleApiTokens(req, {
|
|
1296
|
+
db: getDb(),
|
|
1297
|
+
issuer: oauthDeps(req).issuer,
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
841
1301
|
if (pathname === "/api/grants") {
|
|
842
|
-
if (!getDb) return
|
|
1302
|
+
if (!getDb) return dbNotConfigured();
|
|
843
1303
|
return handleListGrants(req, {
|
|
844
1304
|
db: getDb(),
|
|
845
1305
|
issuer: oauthDeps(req).issuer,
|
|
@@ -847,7 +1307,7 @@ export function hubFetch(
|
|
|
847
1307
|
}
|
|
848
1308
|
|
|
849
1309
|
if (pathname.startsWith("/api/grants/")) {
|
|
850
|
-
if (!getDb) return
|
|
1310
|
+
if (!getDb) return dbNotConfigured();
|
|
851
1311
|
const clientId = decodeURIComponent(pathname.slice("/api/grants/".length));
|
|
852
1312
|
if (!clientId || clientId.includes("/")) {
|
|
853
1313
|
return new Response("not found", { status: 404 });
|
|
@@ -858,63 +1318,98 @@ export function hubFetch(
|
|
|
858
1318
|
});
|
|
859
1319
|
}
|
|
860
1320
|
|
|
861
|
-
|
|
862
|
-
|
|
1321
|
+
// OAuth client lookup + approval. Both bearer-gated under host:admin.
|
|
1322
|
+
// Two paths: `/api/oauth/clients/<id>` (GET, details) and
|
|
1323
|
+
// `/api/oauth/clients/<id>/approve` (POST, flip to approved). The
|
|
1324
|
+
// SPA approve-client deep link reads details from the first and
|
|
1325
|
+
// submits approval to the second — keeps the surface easy to test
|
|
1326
|
+
// and audit without overloading a single verb.
|
|
1327
|
+
if (pathname.startsWith("/api/oauth/clients/")) {
|
|
1328
|
+
if (!getDb) return dbNotConfigured();
|
|
1329
|
+
const tail = pathname.slice("/api/oauth/clients/".length);
|
|
1330
|
+
if (!tail) return new Response("not found", { status: 404 });
|
|
1331
|
+
const approveSuffix = "/approve";
|
|
1332
|
+
if (tail.endsWith(approveSuffix)) {
|
|
1333
|
+
const clientId = decodeURIComponent(tail.slice(0, -approveSuffix.length));
|
|
1334
|
+
if (!clientId || clientId.includes("/")) {
|
|
1335
|
+
return new Response("not found", { status: 404 });
|
|
1336
|
+
}
|
|
1337
|
+
return handleApproveClient(req, clientId, {
|
|
1338
|
+
db: getDb(),
|
|
1339
|
+
issuer: oauthDeps(req).issuer,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
const clientId = decodeURIComponent(tail);
|
|
1343
|
+
if (!clientId || clientId.includes("/")) {
|
|
1344
|
+
return new Response("not found", { status: 404 });
|
|
1345
|
+
}
|
|
1346
|
+
return handleGetClient(req, clientId, {
|
|
1347
|
+
db: getDb(),
|
|
1348
|
+
issuer: oauthDeps(req).issuer,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Canonical login/logout. The handlers themselves are unchanged from
|
|
1353
|
+
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
1354
|
+
// via #231-followup so the URL reflects the surface's actual scope
|
|
1355
|
+
// (entry point for ALL parachute auth — not admin-only). The
|
|
1356
|
+
// /admin/login and /admin/logout paths 301 to here, dispatched at the
|
|
1357
|
+
// top of this fn alongside the other back-compat redirects.
|
|
1358
|
+
if (pathname === "/login") {
|
|
1359
|
+
if (!getDb) return dbNotConfigured();
|
|
863
1360
|
if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
|
|
864
1361
|
if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
|
|
865
1362
|
return new Response("method not allowed", { status: 405 });
|
|
866
1363
|
}
|
|
867
1364
|
|
|
868
|
-
if (pathname === "/
|
|
869
|
-
if (!getDb) return
|
|
1365
|
+
if (pathname === "/logout") {
|
|
1366
|
+
if (!getDb) return dbNotConfigured();
|
|
870
1367
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
871
1368
|
return handleAdminLogoutPost(getDb(), req);
|
|
872
1369
|
}
|
|
873
1370
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1371
|
+
// Legacy `/admin/config` (server-rendered module-config portal, #46)
|
|
1372
|
+
// retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
|
|
1373
|
+
// post-login redirect lands somewhere useful. The route stays here in
|
|
1374
|
+
// dispatch order (above the /admin/* SPA catch-all) so the redirect
|
|
1375
|
+
// wins over a SPA shell render.
|
|
1376
|
+
if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
|
|
1377
|
+
return new Response(null, {
|
|
1378
|
+
status: 301,
|
|
1379
|
+
headers: { location: "/admin/vaults" },
|
|
1380
|
+
});
|
|
878
1381
|
}
|
|
879
1382
|
|
|
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
|
-
}
|
|
1383
|
+
// /vault/<name>/* — per-vault content proxy. Stays as user-facing
|
|
1384
|
+
// surface (the Notes PWA loads through here, etc.). The bare `/vault`
|
|
1385
|
+
// and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
|
|
1386
|
+
// the top of dispatch now. Multi-segment requests like
|
|
1387
|
+
// `/vault/<unknown>/health` are vault-API shapes targeting a
|
|
1388
|
+
// non-existent vault and 404 directly — there's no SPA-shell fallback
|
|
1389
|
+
// here anymore (the SPA moved to /admin), so we can't accidentally
|
|
1390
|
+
// mask a backend 404 with HTML.
|
|
910
1391
|
if (pathname.startsWith("/vault/")) {
|
|
911
1392
|
const proxied = await proxyToVault(req, manifestPath);
|
|
912
1393
|
if (proxied) return proxied;
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1394
|
+
return new Response("not found", { status: 404 });
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
|
|
1398
|
+
// vault-admin-token, login, logout, config, api/auth/*, api/grants,
|
|
1399
|
+
// grants/*) ran above and either matched or returned. Anything that
|
|
1400
|
+
// makes it here under /admin/* is a SPA route or asset request; the
|
|
1401
|
+
// SPA's own router renders the page and handles 404 client-side for
|
|
1402
|
+
// unknown sub-paths.
|
|
1403
|
+
if (pathname === "/admin" || pathname === "/admin/") {
|
|
1404
|
+
// Unprefixed /admin → SPA shell pointed at the vault list (its home).
|
|
1405
|
+
// The SPA's basename is /admin, so the router will land on / and
|
|
1406
|
+
// render VaultsList.
|
|
1407
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
1408
|
+
return serveSpa(spaDistDir, pathname, "/admin");
|
|
1409
|
+
}
|
|
1410
|
+
if (pathname.startsWith("/admin/")) {
|
|
1411
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
1412
|
+
return serveSpa(spaDistDir, pathname, "/admin");
|
|
918
1413
|
}
|
|
919
1414
|
|
|
920
1415
|
// Generic services.json-driven dispatch for non-vault modules. Reaches
|
|
@@ -929,7 +1424,7 @@ export function hubFetch(
|
|
|
929
1424
|
}
|
|
930
1425
|
|
|
931
1426
|
if (import.meta.main) {
|
|
932
|
-
const { port, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
|
|
1427
|
+
const { port, hostname, wellKnownDir, dbPath, issuer } = parseArgs(process.argv.slice(2));
|
|
933
1428
|
let cachedDb: Database | undefined;
|
|
934
1429
|
const getDb = () => {
|
|
935
1430
|
if (!cachedDb) cachedDb = openHubDb(dbPath);
|
|
@@ -937,8 +1432,8 @@ if (import.meta.main) {
|
|
|
937
1432
|
};
|
|
938
1433
|
Bun.serve({
|
|
939
1434
|
port,
|
|
940
|
-
hostname
|
|
941
|
-
fetch: hubFetch(wellKnownDir, { getDb, issuer }),
|
|
1435
|
+
hostname,
|
|
1436
|
+
fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
|
|
942
1437
|
});
|
|
943
1438
|
// Register PID + port from the running hub itself so any startup path
|
|
944
1439
|
// (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from
|
|
@@ -961,7 +1456,7 @@ if (import.meta.main) {
|
|
|
961
1456
|
});
|
|
962
1457
|
process.on("exit", cleanup);
|
|
963
1458
|
console.log(
|
|
964
|
-
`parachute-hub listening on http
|
|
1459
|
+
`parachute-hub listening on http://${hostname}:${port} (dir=${wellKnownDir}, db=${dbPath}${
|
|
965
1460
|
issuer ? `, issuer=${issuer}` : ""
|
|
966
1461
|
})`,
|
|
967
1462
|
);
|