@openparachute/hub 0.5.2 → 0.5.9-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- 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 +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- 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/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- 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/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -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,16 +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
|
-
*
|
|
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
|
|
22
68
|
*
|
|
23
69
|
* Invoked as:
|
|
24
70
|
* bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
|
|
@@ -39,10 +85,9 @@ import type { Database } from "bun:sqlite";
|
|
|
39
85
|
import { existsSync } from "node:fs";
|
|
40
86
|
import { dirname, join, resolve } from "node:path";
|
|
41
87
|
import { fileURLToPath } from "node:url";
|
|
88
|
+
import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
42
89
|
import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
|
|
43
90
|
import {
|
|
44
|
-
handleAdminConfigGet,
|
|
45
|
-
handleAdminConfigPost,
|
|
46
91
|
handleAdminLoginGet,
|
|
47
92
|
handleAdminLoginPost,
|
|
48
93
|
handleAdminLogoutPost,
|
|
@@ -50,9 +95,17 @@ import {
|
|
|
50
95
|
import { handleHostAdminToken } from "./admin-host-admin-token.ts";
|
|
51
96
|
import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
|
|
52
97
|
import { handleCreateVault } from "./admin-vaults.ts";
|
|
98
|
+
import { handleApiMe } from "./api-me.ts";
|
|
99
|
+
import { handleApiMintToken } from "./api-mint-token.ts";
|
|
100
|
+
import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
|
|
101
|
+
import { handleApiRevokeToken } from "./api-revoke-token.ts";
|
|
102
|
+
import { handleApiTokens } from "./api-tokens.ts";
|
|
53
103
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
104
|
+
import { ensureCsrfToken } from "./csrf.ts";
|
|
105
|
+
import { readExposeState } from "./expose-state.ts";
|
|
54
106
|
import { HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
|
|
55
107
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
108
|
+
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
56
109
|
import { pemToJwk } from "./jwks.ts";
|
|
57
110
|
import {
|
|
58
111
|
type ModuleManifest,
|
|
@@ -60,15 +113,24 @@ import {
|
|
|
60
113
|
} from "./module-manifest.ts";
|
|
61
114
|
import {
|
|
62
115
|
authorizationServerMetadata,
|
|
116
|
+
handleApproveClientPost,
|
|
63
117
|
handleAuthorizeGet,
|
|
64
118
|
handleAuthorizePost,
|
|
65
119
|
handleRegister,
|
|
66
120
|
handleRevoke,
|
|
67
121
|
handleToken,
|
|
68
122
|
} from "./oauth-handlers.ts";
|
|
123
|
+
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
69
124
|
import { clearPid, writePid } from "./process-state.ts";
|
|
125
|
+
import {
|
|
126
|
+
FIRST_PARTY_FALLBACKS,
|
|
127
|
+
effectivePublicExposure,
|
|
128
|
+
shortNameForManifest,
|
|
129
|
+
} from "./service-spec.ts";
|
|
70
130
|
import { type ServiceEntry, readManifest } from "./services-manifest.ts";
|
|
131
|
+
import { findActiveSession } from "./sessions.ts";
|
|
71
132
|
import { getAllPublicKeys } from "./signing-keys.ts";
|
|
133
|
+
import { getUserById } from "./users.ts";
|
|
72
134
|
import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
73
135
|
|
|
74
136
|
interface Args {
|
|
@@ -136,9 +198,16 @@ export function findVaultUpstream(
|
|
|
136
198
|
for (const s of services) {
|
|
137
199
|
if (!isVaultEntry(s)) continue;
|
|
138
200
|
for (const path of s.paths) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
201
|
+
// Normalize trailing slashes before comparison (#197). A services.json
|
|
202
|
+
// entry written with `paths: ["/vault/default/"]` would otherwise only
|
|
203
|
+
// match the exact pathname `/vault/default/` and never any sub-path,
|
|
204
|
+
// because `pathname.startsWith("/vault/default//")` is always false.
|
|
205
|
+
// The "|| '/'" branch keeps a bare-root mount "/" stable rather than
|
|
206
|
+
// collapsing it to an empty string.
|
|
207
|
+
const norm = path.replace(/\/+$/, "") || "/";
|
|
208
|
+
if (pathname === norm || pathname.startsWith(`${norm}/`)) {
|
|
209
|
+
if (!best || norm.length > best.mount.length) {
|
|
210
|
+
best = { port: s.port, mount: norm, entry: s };
|
|
142
211
|
}
|
|
143
212
|
}
|
|
144
213
|
}
|
|
@@ -146,6 +215,65 @@ export function findVaultUpstream(
|
|
|
146
215
|
return best;
|
|
147
216
|
}
|
|
148
217
|
|
|
218
|
+
/**
|
|
219
|
+
* The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
|
|
220
|
+
* every request reaches it via one of three trusted forwarders (or directly
|
|
221
|
+
* over loopback). The forwarder injects characteristic headers that we use to
|
|
222
|
+
* classify; nothing else can reach the listener, so spoofing isn't a concern.
|
|
223
|
+
*
|
|
224
|
+
* "loopback" — direct localhost call (CLI, on-box service, dev shell).
|
|
225
|
+
* "tailnet" — `tailscale serve` forwarding an authed tailnet user.
|
|
226
|
+
* "public" — `tailscale funnel` (public-over-tailnet, unauthed) OR a
|
|
227
|
+
* cloudflared tunnel forwarding from the public internet.
|
|
228
|
+
*
|
|
229
|
+
* Used to gate `publicExposure: "loopback"` services on the generic
|
|
230
|
+
* `/<svc>/*` dispatch (the hub's only layer-gate). Hub-owned paths (`/`,
|
|
231
|
+
* `/admin/*`, `/api/*`, `/hub/*`, `/oauth/*`, `/.well-known/*`, `/vault/*`,
|
|
232
|
+
* `/vaults`) reach all layers and rely on app-level auth (admin session
|
|
233
|
+
* cookie + 2FA, OAuth, per-service tokens) — they are NOT layer-blocked.
|
|
234
|
+
*/
|
|
235
|
+
export type RequestLayer = "loopback" | "tailnet" | "public";
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Classify the trust layer for an incoming request by inspecting proxy
|
|
239
|
+
* headers. Order matters: cloudflared headers come first because cloudflared
|
|
240
|
+
* could in principle be deployed alongside tailscale on the same node.
|
|
241
|
+
*
|
|
242
|
+
* Header reference (verified against tailscale serve.go on 2026-05-08):
|
|
243
|
+
* - `Tailscale-User-Login` is set ONLY by `tailscale serve` for an authed
|
|
244
|
+
* tailnet user. Tagged-source nodes don't get it. Funnel never sets it.
|
|
245
|
+
* - `Tailscale-Funnel-Request: ?1` is set ONLY by Tailscale Funnel.
|
|
246
|
+
* Mutually exclusive with `Tailscale-User-Login` (the serve.go path
|
|
247
|
+
* returns early when funneled).
|
|
248
|
+
* - `CF-Ray` and `CF-Connecting-IP` are set by Cloudflare's edge for
|
|
249
|
+
* anything proxied through a cloudflared tunnel.
|
|
250
|
+
*
|
|
251
|
+
* Spoofing isn't a concern: hub binds `127.0.0.1:1939`, so external requests
|
|
252
|
+
* can't reach the listener except via these trusted forwarders. Tailscale
|
|
253
|
+
* specifically strips the same headers from incoming requests before
|
|
254
|
+
* re-injecting them, so even a malicious tailnet peer can't impersonate a
|
|
255
|
+
* different user. We could mirror that strip-on-arrival defense, but it's
|
|
256
|
+
* belt-and-braces given the bind shape.
|
|
257
|
+
*
|
|
258
|
+
* Default to "loopback" when no proxy headers are present — that's the
|
|
259
|
+
* direct-localhost case. Funnel without `Tailscale-Funnel-Request` would
|
|
260
|
+
* also fall here, but Tailscale always sets the header on funneled
|
|
261
|
+
* requests, so this branch only fires for true loopback callers.
|
|
262
|
+
*/
|
|
263
|
+
export function layerOf(req: Request): RequestLayer {
|
|
264
|
+
const h = req.headers;
|
|
265
|
+
if (h.get("cf-ray") !== null || h.get("cf-connecting-ip") !== null) return "public";
|
|
266
|
+
// Match the structured-header value (`?1`) rather than mere presence:
|
|
267
|
+
// serve.go only ever emits `?1`, so insisting on the canonical value keeps
|
|
268
|
+
// the classifier's intent obvious to a future reader (don't loosen this to
|
|
269
|
+
// `!== null` — Tailscale's contract is the value, not the header name).
|
|
270
|
+
// CF-Ray / CF-Connecting-IP are open-string identifiers with no canonical
|
|
271
|
+
// value to compare against, hence the presence-check above.
|
|
272
|
+
if (h.get("tailscale-funnel-request") === "?1") return "public";
|
|
273
|
+
if (h.get("tailscale-user-login") !== null) return "tailnet";
|
|
274
|
+
return "loopback";
|
|
275
|
+
}
|
|
276
|
+
|
|
149
277
|
/**
|
|
150
278
|
* Forward a request to a loopback service on `127.0.0.1:<port>`. By default
|
|
151
279
|
* the incoming pathname + query are preserved verbatim; pass `targetPath` to
|
|
@@ -229,7 +357,20 @@ async function proxyToVault(req: Request, manifestPath: string): Promise<Respons
|
|
|
229
357
|
const url = new URL(req.url);
|
|
230
358
|
const match = findVaultUpstream(services, url.pathname);
|
|
231
359
|
if (!match) return undefined;
|
|
232
|
-
|
|
360
|
+
// Layer-gate on `publicExposure: "loopback"` — hide the entry from non-
|
|
361
|
+
// loopback callers as if it doesn't exist. "allowed" / "auth-required"
|
|
362
|
+
// pass through; the service does its own auth.
|
|
363
|
+
if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
|
|
364
|
+
return new Response("not found", { status: 404 });
|
|
365
|
+
}
|
|
366
|
+
// Symmetry with proxyToService (#196): honor `stripPrefix` with FIRST_-
|
|
367
|
+
// PARTY_FALLBACKS as a fallback source. No first-party vault fallback
|
|
368
|
+
// declares stripPrefix today (vault expects the full `/vault/<name>/*`
|
|
369
|
+
// path), so this is a no-op in practice — but reading the same shape in
|
|
370
|
+
// both proxies keeps the dispatch surface consistent for future readers.
|
|
371
|
+
const stripPrefix = stripPrefixFor(match.entry);
|
|
372
|
+
const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
|
|
373
|
+
return proxyRequest(req, match.port, "vault", targetPath);
|
|
233
374
|
}
|
|
234
375
|
|
|
235
376
|
/**
|
|
@@ -248,9 +389,18 @@ export function findServiceUpstream(
|
|
|
248
389
|
for (const s of services) {
|
|
249
390
|
if (isVaultEntry(s)) continue;
|
|
250
391
|
for (const path of s.paths) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
392
|
+
// Normalize trailing slashes before comparison (#197). A services.json
|
|
393
|
+
// entry written with `paths: ["/notes/"]` would otherwise only match
|
|
394
|
+
// the exact pathname `/notes/` and never `/notes/assets/index.js` —
|
|
395
|
+
// `pathname.startsWith("/notes//")` is always false because URLs
|
|
396
|
+
// don't have double slashes. Result: SPA shell loads but every asset
|
|
397
|
+
// 404s (notes blank-screen on Aaron's box, 2026-05-08).
|
|
398
|
+
// The "|| '/'" branch keeps a bare-root mount "/" stable rather than
|
|
399
|
+
// collapsing it to an empty string.
|
|
400
|
+
const norm = path.replace(/\/+$/, "") || "/";
|
|
401
|
+
if (pathname === norm || pathname.startsWith(`${norm}/`)) {
|
|
402
|
+
if (!best || norm.length > best.mount.length) {
|
|
403
|
+
best = { port: s.port, mount: norm, entry: s };
|
|
254
404
|
}
|
|
255
405
|
}
|
|
256
406
|
}
|
|
@@ -290,12 +440,42 @@ async function proxyToService(req: Request, manifestPath: string): Promise<Respo
|
|
|
290
440
|
const url = new URL(req.url);
|
|
291
441
|
const match = findServiceUpstream(services, url.pathname);
|
|
292
442
|
if (!match) return undefined;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
443
|
+
// Layer-gate on `publicExposure: "loopback"`. From the perspective of a
|
|
444
|
+
// tailnet/public caller, a loopback-only service must be indistinguishable
|
|
445
|
+
// from "not installed" — 404, not 403, so we don't leak the existence of
|
|
446
|
+
// the route. "allowed" / "auth-required" pass through; the service does
|
|
447
|
+
// its own auth.
|
|
448
|
+
if (effectivePublicExposure(match.entry) === "loopback" && layerOf(req) !== "loopback") {
|
|
449
|
+
return new Response("not found", { status: 404 });
|
|
450
|
+
}
|
|
451
|
+
// Consult FIRST_PARTY_FALLBACKS as a fallback for `stripPrefix` (#196).
|
|
452
|
+
// Scribe v0.4.0 doesn't write `stripPrefix: true` to its services.json
|
|
453
|
+
// entry — the declaration only lives in hub's SCRIBE_FALLBACK manifest.
|
|
454
|
+
// Pre-#187 this didn't matter because the per-service tailscale serve
|
|
455
|
+
// plan baked the path into the target URL; post-#187 routing went through
|
|
456
|
+
// hub which wasn't consulting the fallback registry. Same shape as how
|
|
457
|
+
// `effectivePublicExposure` already handles fallback derivation in
|
|
458
|
+
// service-spec.ts. Explicit-on-entry still wins; absent → fallback →
|
|
459
|
+
// false (preserving existing keep-prefix default for unknown services).
|
|
460
|
+
const stripPrefix = stripPrefixFor(match.entry);
|
|
461
|
+
const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
|
|
296
462
|
return proxyRequest(req, match.port, match.entry.name, targetPath);
|
|
297
463
|
}
|
|
298
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Resolve effective `stripPrefix` for a service entry. Explicit on-entry
|
|
467
|
+
* wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (so
|
|
468
|
+
* scribe's vendored fallback supplies `stripPrefix: true` even when scribe's
|
|
469
|
+
* own boot doesn't write it). Defaults to `false` — keep the prefix —
|
|
470
|
+
* matching the pre-#196 dispatch behavior for unknown / third-party services.
|
|
471
|
+
*/
|
|
472
|
+
function stripPrefixFor(entry: ServiceEntry): boolean {
|
|
473
|
+
if (entry.stripPrefix !== undefined) return entry.stripPrefix;
|
|
474
|
+
const short = shortNameForManifest(entry.name);
|
|
475
|
+
const fb = short !== undefined ? FIRST_PARTY_FALLBACKS[short] : undefined;
|
|
476
|
+
return fb?.manifest.stripPrefix ?? false;
|
|
477
|
+
}
|
|
478
|
+
|
|
299
479
|
export interface HubFetchDeps {
|
|
300
480
|
/**
|
|
301
481
|
* Lazily opens (or returns a cached handle to) the hub DB. Optional so
|
|
@@ -333,6 +513,23 @@ export interface HubFetchDeps {
|
|
|
333
513
|
* fixture installDirs.
|
|
334
514
|
*/
|
|
335
515
|
readModuleManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
516
|
+
/**
|
|
517
|
+
* Hub's listening port. Threaded into the OAuth `hubBoundOrigins` set so
|
|
518
|
+
* the same-origin defense accepts loopback access (`http://localhost:<port>`,
|
|
519
|
+
* `http://127.0.0.1:<port>`) alongside the configured issuer. Closes #245
|
|
520
|
+
* Case A (operator on `localhost:1939` getting "Cross-origin request
|
|
521
|
+
* rejected" because Origin ≠ tailnet issuer).
|
|
522
|
+
*/
|
|
523
|
+
loopbackPort?: number;
|
|
524
|
+
/**
|
|
525
|
+
* Test seam for reading `expose-state.json`. Production reads the operator's
|
|
526
|
+
* `~/.parachute/expose-state.json` via `readExposeState`; tests inject a
|
|
527
|
+
* fake to drive tailnet/funnel origins into the bound set without standing
|
|
528
|
+
* up real exposes. Returns `undefined` when no state file is present
|
|
529
|
+
* (pre-`parachute expose` state — fine, the issuer + loopback still cover
|
|
530
|
+
* legitimate access).
|
|
531
|
+
*/
|
|
532
|
+
loadExposeHubOrigin?: () => string | undefined;
|
|
336
533
|
}
|
|
337
534
|
|
|
338
535
|
/**
|
|
@@ -365,6 +562,50 @@ async function loadManagementUrls(
|
|
|
365
562
|
return out;
|
|
366
563
|
}
|
|
367
564
|
|
|
565
|
+
/**
|
|
566
|
+
* For each NON-vault `ServiceEntry` with a known `installDir`, read its
|
|
567
|
+
* `.parachute/module.json` and surface the optional `uiUrl` and
|
|
568
|
+
* `displayName`. Returns two `name → value` maps keyed by services.json
|
|
569
|
+
* entry name. Mirrors `loadManagementUrls` (vault is the analog there;
|
|
570
|
+
* non-vault services are the analog here — vaults are user-facing via
|
|
571
|
+
* Notes, not their own UI).
|
|
572
|
+
*
|
|
573
|
+
* Why read at request time and not from services.json: services own the
|
|
574
|
+
* write side of services.json (`upsertService` replaces the whole entry
|
|
575
|
+
* on every boot), so any install-time copy of `uiUrl` / `displayName`
|
|
576
|
+
* would be clobbered the first time the service writes its own entry.
|
|
577
|
+
* Reading from `installDir/module.json` at request time avoids the gap
|
|
578
|
+
* and matches the established `managementUrl` precedent.
|
|
579
|
+
*
|
|
580
|
+
* Quiet on per-entry errors: a malformed module.json on one service
|
|
581
|
+
* shouldn't 500 the entire well-known doc — its row just renders without
|
|
582
|
+
* a Services tile. The validator already throws structured errors from
|
|
583
|
+
* `readModuleManifest`; logging them once here is the right floor.
|
|
584
|
+
*/
|
|
585
|
+
async function loadServiceUiMetadata(
|
|
586
|
+
services: readonly ServiceEntry[],
|
|
587
|
+
read: (installDir: string) => Promise<ModuleManifest | null>,
|
|
588
|
+
): Promise<{ uiUrls: Map<string, string>; displayNames: Map<string, string> }> {
|
|
589
|
+
const uiUrls = new Map<string, string>();
|
|
590
|
+
const displayNames = new Map<string, string>();
|
|
591
|
+
await Promise.all(
|
|
592
|
+
services.map(async (s) => {
|
|
593
|
+
// Skip vaults — they have their own loadManagementUrls path and no
|
|
594
|
+
// operator-facing user UI of their own (content browses via Notes).
|
|
595
|
+
if (isVaultEntry(s) || !s.installDir) return;
|
|
596
|
+
try {
|
|
597
|
+
const m = await read(s.installDir);
|
|
598
|
+
if (m?.uiUrl) uiUrls.set(s.name, m.uiUrl);
|
|
599
|
+
if (m?.displayName) displayNames.set(s.name, m.displayName);
|
|
600
|
+
} catch (err) {
|
|
601
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
602
|
+
console.warn(`well-known: skipping uiUrl/displayName for ${s.name}: ${msg}`);
|
|
603
|
+
}
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
return { uiUrls, displayNames };
|
|
607
|
+
}
|
|
608
|
+
|
|
368
609
|
/**
|
|
369
610
|
* Resolve the SPA bundle dir. We anchor to this file's location so a
|
|
370
611
|
* `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
|
|
@@ -378,22 +619,23 @@ function defaultSpaDistDir(): string {
|
|
|
378
619
|
}
|
|
379
620
|
|
|
380
621
|
/**
|
|
381
|
-
* The SPA serves at
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* `/
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* (`/vault
|
|
393
|
-
*
|
|
394
|
-
*
|
|
622
|
+
* The admin SPA serves at a single mount: `/admin/*` (since hub#231).
|
|
623
|
+
*
|
|
624
|
+
* Routes:
|
|
625
|
+
* - `/admin/vaults` → vault list (the SPA's home)
|
|
626
|
+
* - `/admin/vaults/new` → vault create form
|
|
627
|
+
* - `/admin/permissions` → OAuth consent grant management
|
|
628
|
+
* - `/admin/tokens` → token registry: mint / list / revoke
|
|
629
|
+
*
|
|
630
|
+
* Asset URLs are origin-absolute (`/admin/assets/...`) per the Vite build
|
|
631
|
+
* base. main.tsx pins react-router's basename to `/admin`.
|
|
632
|
+
*
|
|
633
|
+
* Pre-rename mounts (the old `/vault` for the vault SPA, `/hub/*` for
|
|
634
|
+
* permissions+tokens) are 301-redirected further up the dispatch so cached
|
|
635
|
+
* operator URLs keep working. `/vault/<name>/*` (per-vault content proxy)
|
|
636
|
+
* stays — that's user-facing vault data, not part of this admin SPA.
|
|
395
637
|
*/
|
|
396
|
-
type SpaMount = "/
|
|
638
|
+
type SpaMount = "/admin";
|
|
397
639
|
|
|
398
640
|
/**
|
|
399
641
|
* Pick a content type for static assets the SPA build produces. Vite's
|
|
@@ -440,8 +682,8 @@ function spaContentType(pathname: string): string {
|
|
|
440
682
|
* filter rejects sub-paths containing "..", and the resolved absolute
|
|
441
683
|
* path is checked to start with `dist/` before any read.
|
|
442
684
|
*
|
|
443
|
-
* `mount` is the prefix being served (`/
|
|
444
|
-
*
|
|
685
|
+
* `mount` is the prefix being served (`/admin`); we strip it from
|
|
686
|
+
* `pathname` to land on the file path inside `dist/`.
|
|
445
687
|
*/
|
|
446
688
|
async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
|
|
447
689
|
if (!existsSync(spaDistDir)) {
|
|
@@ -450,7 +692,7 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
|
|
|
450
692
|
{ status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
|
|
451
693
|
);
|
|
452
694
|
}
|
|
453
|
-
// Strip the mount prefix; "/
|
|
695
|
+
// Strip the mount prefix; "/admin" → "", "/admin/" → "/", "/admin/x" → "/x".
|
|
454
696
|
const sub = pathname === mount ? "" : pathname.slice(mount.length);
|
|
455
697
|
const indexPath = join(spaDistDir, "index.html");
|
|
456
698
|
|
|
@@ -492,31 +734,137 @@ export function hubFetch(
|
|
|
492
734
|
const configuredIssuer = deps?.issuer;
|
|
493
735
|
const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
494
736
|
const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
|
|
737
|
+
const loopbackPort = deps?.loopbackPort;
|
|
738
|
+
const loadExposeHubOrigin =
|
|
739
|
+
deps?.loadExposeHubOrigin ??
|
|
740
|
+
(() => {
|
|
741
|
+
try {
|
|
742
|
+
return readExposeState()?.hubOrigin;
|
|
743
|
+
} catch {
|
|
744
|
+
// Malformed expose-state.json shouldn't 500 hub on every same-origin
|
|
745
|
+
// check — the issuer + loopback already cover legitimate access.
|
|
746
|
+
return undefined;
|
|
747
|
+
}
|
|
748
|
+
});
|
|
495
749
|
|
|
496
|
-
const oauthDeps = (req: Request) =>
|
|
497
|
-
issuer
|
|
498
|
-
|
|
750
|
+
const oauthDeps = (req: Request) => {
|
|
751
|
+
const issuer = configuredIssuer ?? new URL(req.url).origin;
|
|
752
|
+
return {
|
|
753
|
+
issuer,
|
|
754
|
+
// Per-request resolution (closes #245): expose-state.json can change
|
|
755
|
+
// mid-session (operator runs `parachute expose tailnet` while hub is
|
|
756
|
+
// up), so we re-read the bound origins on each call rather than
|
|
757
|
+
// capturing at hub start. Cheap — a single small JSON parse per OAuth
|
|
758
|
+
// request, only on the cookie-POST paths that consult it.
|
|
759
|
+
hubBoundOrigins: () =>
|
|
760
|
+
buildHubBoundOrigins({
|
|
761
|
+
issuer,
|
|
762
|
+
loopbackPort,
|
|
763
|
+
exposeHubOrigin: loadExposeHubOrigin(),
|
|
764
|
+
}),
|
|
765
|
+
};
|
|
766
|
+
};
|
|
499
767
|
|
|
500
768
|
return async (req) => {
|
|
501
769
|
const url = new URL(req.url);
|
|
502
770
|
const pathname = url.pathname;
|
|
503
771
|
|
|
504
|
-
// 301 back-compat
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
772
|
+
// 301 back-compat for the pre-hub#231 admin-SPA mounts:
|
|
773
|
+
//
|
|
774
|
+
// `/vault` → `/admin/vaults`
|
|
775
|
+
// `/vault/new` → `/admin/vaults/new`
|
|
776
|
+
// `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
|
|
777
|
+
// it now retargets at the new admin mount instead
|
|
778
|
+
// of the interim `/vault` mount)
|
|
779
|
+
// `/hub/permissions` → `/admin/permissions`
|
|
780
|
+
// `/hub/tokens` → `/admin/tokens`
|
|
781
|
+
// `/hub` (bare) → `/admin/vaults`
|
|
782
|
+
//
|
|
783
|
+
// Permanent redirect so cached operator URLs keep working without
|
|
784
|
+
// leaving dangling SPA routes. Query string preserved; fragment is
|
|
785
|
+
// client-side and survives the redirect at the browser. Method-agnostic
|
|
786
|
+
// — even a misrouted POST gets the redirect; none of these paths host a
|
|
787
|
+
// POST endpoint to protect.
|
|
788
|
+
//
|
|
789
|
+
// `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
|
|
790
|
+
// content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
|
|
791
|
+
if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
|
|
792
|
+
const sub = pathname === "/vault/new" ? "/new" : "";
|
|
793
|
+
return new Response("", {
|
|
794
|
+
status: 301,
|
|
795
|
+
headers: { location: `/admin/vaults${sub}${url.search}` },
|
|
796
|
+
});
|
|
797
|
+
}
|
|
511
798
|
if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
|
|
512
|
-
const newPath = `/
|
|
799
|
+
const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
|
|
513
800
|
return new Response("", {
|
|
514
801
|
status: 301,
|
|
515
802
|
headers: { location: `${newPath}${url.search}` },
|
|
516
803
|
});
|
|
517
804
|
}
|
|
805
|
+
if (pathname === "/hub/permissions") {
|
|
806
|
+
return new Response("", {
|
|
807
|
+
status: 301,
|
|
808
|
+
headers: { location: `/admin/permissions${url.search}` },
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (pathname === "/hub/tokens") {
|
|
812
|
+
return new Response("", {
|
|
813
|
+
status: 301,
|
|
814
|
+
headers: { location: `/admin/tokens${url.search}` },
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
if (pathname === "/hub" || pathname === "/hub/") {
|
|
818
|
+
return new Response("", {
|
|
819
|
+
status: 301,
|
|
820
|
+
headers: { location: `/admin/vaults${url.search}` },
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Login surface rename: `/admin/login` and `/admin/logout` 301 to the
|
|
825
|
+
// canonical `/login` and `/logout`. The names were "admin" only by
|
|
826
|
+
// historical accident — the handlers serve every parachute auth flow
|
|
827
|
+
// (operator, OAuth user-redirect, future SPA sign-in). Renaming makes
|
|
828
|
+
// the surface name match its actual scope.
|
|
829
|
+
if (pathname === "/admin/login") {
|
|
830
|
+
return new Response("", {
|
|
831
|
+
status: 301,
|
|
832
|
+
headers: { location: `/login${url.search}` },
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
if (pathname === "/admin/logout") {
|
|
836
|
+
return new Response("", {
|
|
837
|
+
status: 301,
|
|
838
|
+
headers: { location: `/logout${url.search}` },
|
|
839
|
+
});
|
|
840
|
+
}
|
|
518
841
|
|
|
519
842
|
if (pathname === "/" || pathname === "/hub.html") {
|
|
843
|
+
// When a DB is configured, render the discovery page dynamically so
|
|
844
|
+
// the header carries a "Signed in as <name>" affordance for the
|
|
845
|
+
// active session. Without a DB, fall back to the static disk file
|
|
846
|
+
// (signed-out shape) — the disk file is what `parachute expose`
|
|
847
|
+
// wrote out, used when the hub-server is running without state.
|
|
848
|
+
if (getDb) {
|
|
849
|
+
const db = getDb();
|
|
850
|
+
const session = findActiveSession(db, req);
|
|
851
|
+
let renderOpts: RenderHubOpts = {};
|
|
852
|
+
const headers: Record<string, string> = {
|
|
853
|
+
"content-type": "text/html; charset=utf-8",
|
|
854
|
+
};
|
|
855
|
+
if (session) {
|
|
856
|
+
const user = getUserById(db, session.userId);
|
|
857
|
+
if (user) {
|
|
858
|
+
const csrf = ensureCsrfToken(req);
|
|
859
|
+
renderOpts = {
|
|
860
|
+
session: { displayName: user.username, csrfToken: csrf.token },
|
|
861
|
+
};
|
|
862
|
+
if (csrf.setCookie) headers["set-cookie"] = csrf.setCookie;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return new Response(renderHub(renderOpts), { headers });
|
|
866
|
+
}
|
|
867
|
+
// No DB configured → fall back to static file (signed-out only).
|
|
520
868
|
if (!existsSync(hubHtmlPath)) {
|
|
521
869
|
return new Response("hub.html not found", { status: 404 });
|
|
522
870
|
}
|
|
@@ -546,14 +894,17 @@ export function hubFetch(
|
|
|
546
894
|
try {
|
|
547
895
|
const manifest = readManifest(manifestPath);
|
|
548
896
|
const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
897
|
+
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
898
|
+
const [managementUrlByName, serviceUiMeta] = await Promise.all([
|
|
899
|
+
loadManagementUrls(manifest.services, readManifestFn),
|
|
900
|
+
loadServiceUiMetadata(manifest.services, readManifestFn),
|
|
901
|
+
]);
|
|
553
902
|
const doc = buildWellKnown({
|
|
554
903
|
services: manifest.services,
|
|
555
904
|
canonicalOrigin,
|
|
556
905
|
managementUrlFor: (entry) => managementUrlByName.get(entry.name),
|
|
906
|
+
uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
|
|
907
|
+
displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
|
|
557
908
|
});
|
|
558
909
|
return new Response(JSON.stringify(doc), {
|
|
559
910
|
headers: { "content-type": "application/json", ...corsHeaders },
|
|
@@ -569,6 +920,30 @@ export function hubFetch(
|
|
|
569
920
|
}
|
|
570
921
|
}
|
|
571
922
|
|
|
923
|
+
if (pathname === REVOCATION_LIST_MOUNT) {
|
|
924
|
+
// Revocation list (hub#212 Phase 1). Public — same CORS posture as
|
|
925
|
+
// jwks.json since resource servers (vault/scribe/agent) fetch it
|
|
926
|
+
// cross-origin on the 60s polling cadence wired in Phase 4.
|
|
927
|
+
const corsHeaders = {
|
|
928
|
+
"access-control-allow-origin": "*",
|
|
929
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
930
|
+
};
|
|
931
|
+
if (req.method === "OPTIONS") {
|
|
932
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
933
|
+
}
|
|
934
|
+
if (!getDb) {
|
|
935
|
+
return new Response('{"error":"revocation list unavailable: db not configured"}', {
|
|
936
|
+
status: 503,
|
|
937
|
+
headers: { "content-type": "application/json", ...corsHeaders },
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
const resp = handleRevocationList(req, { db: getDb() });
|
|
941
|
+
// Layer the wildcard CORS over whatever cache-control the handler set.
|
|
942
|
+
const merged = new Headers(resp.headers);
|
|
943
|
+
for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
|
|
944
|
+
return new Response(resp.body, { status: resp.status, headers: merged });
|
|
945
|
+
}
|
|
946
|
+
|
|
572
947
|
if (pathname === "/.well-known/jwks.json") {
|
|
573
948
|
// JWKS is also a cross-origin fetch target (browser-side OAuth
|
|
574
949
|
// libraries pull this to verify access tokens). Same wildcard CORS
|
|
@@ -629,6 +1004,18 @@ export function hubFetch(
|
|
|
629
1004
|
return new Response("method not allowed", { status: 405 });
|
|
630
1005
|
}
|
|
631
1006
|
|
|
1007
|
+
// Inline approve form for the operator-driven pending-client flow (#208).
|
|
1008
|
+
// Receives `client_id` + `csrf_token` + `return_to` from the form rendered
|
|
1009
|
+
// by handleAuthorizeGet when the operator hits a pending client. Three
|
|
1010
|
+
// gates inside the handler: CSRF, active session, same-origin Origin.
|
|
1011
|
+
if (pathname === "/oauth/authorize/approve") {
|
|
1012
|
+
if (!getDb) {
|
|
1013
|
+
return new Response("hub db not configured", { status: 503 });
|
|
1014
|
+
}
|
|
1015
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
1016
|
+
return handleApproveClientPost(getDb(), req, oauthDeps(req));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
632
1019
|
if (pathname === "/oauth/token") {
|
|
633
1020
|
if (!getDb) {
|
|
634
1021
|
return new Response("hub db not configured", { status: 503 });
|
|
@@ -663,15 +1050,10 @@ export function hubFetch(
|
|
|
663
1050
|
});
|
|
664
1051
|
}
|
|
665
1052
|
|
|
666
|
-
//
|
|
667
|
-
// hub
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
// POSTs for vault create go to /vaults, not the SPA mount.
|
|
671
|
-
if (pathname === "/hub" || pathname.startsWith("/hub/")) {
|
|
672
|
-
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
673
|
-
return serveSpa(spaDistDir, pathname, "/hub");
|
|
674
|
-
}
|
|
1053
|
+
// Note: the old `/hub/*` SPA mount has been retired. Known prefixes
|
|
1054
|
+
// (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
|
|
1055
|
+
// 301-redirected at the top of dispatch. Any other `/hub/*` path falls
|
|
1056
|
+
// through to the catch-all 404 — there's no admin surface left there.
|
|
675
1057
|
|
|
676
1058
|
if (pathname === "/admin/host-admin-token") {
|
|
677
1059
|
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
@@ -701,6 +1083,55 @@ export function hubFetch(
|
|
|
701
1083
|
});
|
|
702
1084
|
}
|
|
703
1085
|
|
|
1086
|
+
if (pathname === "/api/me") {
|
|
1087
|
+
if (!getDb) {
|
|
1088
|
+
return Response.json(
|
|
1089
|
+
{ error: "service_unavailable", error_description: "hub db not configured" },
|
|
1090
|
+
{ status: 503 },
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
return handleApiMe(req, { db: getDb() });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (pathname === "/api/auth/mint-token") {
|
|
1097
|
+
if (!getDb) {
|
|
1098
|
+
return Response.json(
|
|
1099
|
+
{ error: "service_unavailable", error_description: "hub db not configured" },
|
|
1100
|
+
{ status: 503 },
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
return handleApiMintToken(req, {
|
|
1104
|
+
db: getDb(),
|
|
1105
|
+
issuer: oauthDeps(req).issuer,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (pathname === "/api/auth/revoke-token") {
|
|
1110
|
+
if (!getDb) {
|
|
1111
|
+
return Response.json(
|
|
1112
|
+
{ error: "service_unavailable", error_description: "hub db not configured" },
|
|
1113
|
+
{ status: 503 },
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
return handleApiRevokeToken(req, {
|
|
1117
|
+
db: getDb(),
|
|
1118
|
+
issuer: oauthDeps(req).issuer,
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (pathname === "/api/auth/tokens") {
|
|
1123
|
+
if (!getDb) {
|
|
1124
|
+
return Response.json(
|
|
1125
|
+
{ error: "service_unavailable", error_description: "hub db not configured" },
|
|
1126
|
+
{ status: 503 },
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
return handleApiTokens(req, {
|
|
1130
|
+
db: getDb(),
|
|
1131
|
+
issuer: oauthDeps(req).issuer,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
704
1135
|
if (pathname === "/api/grants") {
|
|
705
1136
|
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
706
1137
|
return handleListGrants(req, {
|
|
@@ -721,63 +1152,98 @@ export function hubFetch(
|
|
|
721
1152
|
});
|
|
722
1153
|
}
|
|
723
1154
|
|
|
724
|
-
|
|
1155
|
+
// OAuth client lookup + approval. Both bearer-gated under host:admin.
|
|
1156
|
+
// Two paths: `/api/oauth/clients/<id>` (GET, details) and
|
|
1157
|
+
// `/api/oauth/clients/<id>/approve` (POST, flip to approved). The
|
|
1158
|
+
// SPA approve-client deep link reads details from the first and
|
|
1159
|
+
// submits approval to the second — keeps the surface easy to test
|
|
1160
|
+
// and audit without overloading a single verb.
|
|
1161
|
+
if (pathname.startsWith("/api/oauth/clients/")) {
|
|
1162
|
+
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
1163
|
+
const tail = pathname.slice("/api/oauth/clients/".length);
|
|
1164
|
+
if (!tail) return new Response("not found", { status: 404 });
|
|
1165
|
+
const approveSuffix = "/approve";
|
|
1166
|
+
if (tail.endsWith(approveSuffix)) {
|
|
1167
|
+
const clientId = decodeURIComponent(tail.slice(0, -approveSuffix.length));
|
|
1168
|
+
if (!clientId || clientId.includes("/")) {
|
|
1169
|
+
return new Response("not found", { status: 404 });
|
|
1170
|
+
}
|
|
1171
|
+
return handleApproveClient(req, clientId, {
|
|
1172
|
+
db: getDb(),
|
|
1173
|
+
issuer: oauthDeps(req).issuer,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
const clientId = decodeURIComponent(tail);
|
|
1177
|
+
if (!clientId || clientId.includes("/")) {
|
|
1178
|
+
return new Response("not found", { status: 404 });
|
|
1179
|
+
}
|
|
1180
|
+
return handleGetClient(req, clientId, {
|
|
1181
|
+
db: getDb(),
|
|
1182
|
+
issuer: oauthDeps(req).issuer,
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Canonical login/logout. The handlers themselves are unchanged from
|
|
1187
|
+
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
1188
|
+
// via #231-followup so the URL reflects the surface's actual scope
|
|
1189
|
+
// (entry point for ALL parachute auth — not admin-only). The
|
|
1190
|
+
// /admin/login and /admin/logout paths 301 to here, dispatched at the
|
|
1191
|
+
// top of this fn alongside the other back-compat redirects.
|
|
1192
|
+
if (pathname === "/login") {
|
|
725
1193
|
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
726
1194
|
if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
|
|
727
1195
|
if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
|
|
728
1196
|
return new Response("method not allowed", { status: 405 });
|
|
729
1197
|
}
|
|
730
1198
|
|
|
731
|
-
if (pathname === "/
|
|
1199
|
+
if (pathname === "/logout") {
|
|
732
1200
|
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
733
1201
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
734
1202
|
return handleAdminLogoutPost(getDb(), req);
|
|
735
1203
|
}
|
|
736
1204
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1205
|
+
// Legacy `/admin/config` (server-rendered module-config portal, #46)
|
|
1206
|
+
// retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
|
|
1207
|
+
// post-login redirect lands somewhere useful. The route stays here in
|
|
1208
|
+
// dispatch order (above the /admin/* SPA catch-all) so the redirect
|
|
1209
|
+
// wins over a SPA shell render.
|
|
1210
|
+
if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
|
|
1211
|
+
return new Response(null, {
|
|
1212
|
+
status: 301,
|
|
1213
|
+
headers: { location: "/admin/vaults" },
|
|
1214
|
+
});
|
|
741
1215
|
}
|
|
742
1216
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// /vault — primary SPA mount + dynamic per-vault proxy share this
|
|
754
|
-
// namespace. Order matters:
|
|
755
|
-
// 1. `/vault` exact → SPA shell (vault list).
|
|
756
|
-
// 2. `/vault/<known-vault>/...` → proxy to the vault backend, picked
|
|
757
|
-
// from services.json by longest-mount-prefix. Read per request so a
|
|
758
|
-
// `parachute vault create` performed after `parachute expose` is
|
|
759
|
-
// immediately reachable (#144).
|
|
760
|
-
// 3. `/vault/<spa-route>` → SPA shell. Only single-segment paths
|
|
761
|
-
// (`/vault/new`, `/vault/<name>`) and `/vault/assets/*` count as
|
|
762
|
-
// SPA routes. Multi-segment requests like `/vault/<unknown>/health`
|
|
763
|
-
// are vault-API shapes targeting a non-existent vault and 404 —
|
|
764
|
-
// otherwise the SPA shell would mask backend 404s with HTML.
|
|
765
|
-
// `new` and `assets` are reserved vault names (see
|
|
766
|
-
// `RESERVED_VAULT_NAMES` in admin-vaults.ts) so an operator
|
|
767
|
-
// can't register a vault that shadows the SPA's create route or
|
|
768
|
-
// its static asset bundle.
|
|
769
|
-
if (pathname === "/vault") {
|
|
770
|
-
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
771
|
-
return serveSpa(spaDistDir, pathname, "/vault");
|
|
772
|
-
}
|
|
1217
|
+
// /vault/<name>/* — per-vault content proxy. Stays as user-facing
|
|
1218
|
+
// surface (the Notes PWA loads through here, etc.). The bare `/vault`
|
|
1219
|
+
// and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
|
|
1220
|
+
// the top of dispatch now. Multi-segment requests like
|
|
1221
|
+
// `/vault/<unknown>/health` are vault-API shapes targeting a
|
|
1222
|
+
// non-existent vault and 404 directly — there's no SPA-shell fallback
|
|
1223
|
+
// here anymore (the SPA moved to /admin), so we can't accidentally
|
|
1224
|
+
// mask a backend 404 with HTML.
|
|
773
1225
|
if (pathname.startsWith("/vault/")) {
|
|
774
1226
|
const proxied = await proxyToVault(req, manifestPath);
|
|
775
1227
|
if (proxied) return proxied;
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1228
|
+
return new Response("not found", { status: 404 });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// /admin/* SPA mount. All non-SPA admin handlers (host-admin-token,
|
|
1232
|
+
// vault-admin-token, login, logout, config, api/auth/*, api/grants,
|
|
1233
|
+
// grants/*) ran above and either matched or returned. Anything that
|
|
1234
|
+
// makes it here under /admin/* is a SPA route or asset request; the
|
|
1235
|
+
// SPA's own router renders the page and handles 404 client-side for
|
|
1236
|
+
// unknown sub-paths.
|
|
1237
|
+
if (pathname === "/admin" || pathname === "/admin/") {
|
|
1238
|
+
// Unprefixed /admin → SPA shell pointed at the vault list (its home).
|
|
1239
|
+
// The SPA's basename is /admin, so the router will land on / and
|
|
1240
|
+
// render VaultsList.
|
|
1241
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
1242
|
+
return serveSpa(spaDistDir, pathname, "/admin");
|
|
1243
|
+
}
|
|
1244
|
+
if (pathname.startsWith("/admin/")) {
|
|
1245
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
1246
|
+
return serveSpa(spaDistDir, pathname, "/admin");
|
|
781
1247
|
}
|
|
782
1248
|
|
|
783
1249
|
// Generic services.json-driven dispatch for non-vault modules. Reaches
|
|
@@ -801,7 +1267,7 @@ if (import.meta.main) {
|
|
|
801
1267
|
Bun.serve({
|
|
802
1268
|
port,
|
|
803
1269
|
hostname: "127.0.0.1",
|
|
804
|
-
fetch: hubFetch(wellKnownDir, { getDb, issuer }),
|
|
1270
|
+
fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
|
|
805
1271
|
});
|
|
806
1272
|
// Register PID + port from the running hub itself so any startup path
|
|
807
1273
|
// (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from
|