@openparachute/hub 0.5.7 → 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 +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 +338 -65
- 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 +266 -5
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/status.test.ts +199 -0
- 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/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/status.ts +74 -10
- package/src/csrf.ts +6 -3
- package/src/help.ts +10 -4
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +426 -97
- 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 +183 -54
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +272 -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/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,9 @@ 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 { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
43
89
|
import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
|
|
44
90
|
import {
|
|
45
|
-
handleAdminConfigGet,
|
|
46
|
-
handleAdminConfigPost,
|
|
47
91
|
handleAdminLoginGet,
|
|
48
92
|
handleAdminLoginPost,
|
|
49
93
|
handleAdminLogoutPost,
|
|
@@ -51,9 +95,17 @@ import {
|
|
|
51
95
|
import { handleHostAdminToken } from "./admin-host-admin-token.ts";
|
|
52
96
|
import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
|
|
53
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";
|
|
54
103
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
104
|
+
import { ensureCsrfToken } from "./csrf.ts";
|
|
105
|
+
import { readExposeState } from "./expose-state.ts";
|
|
55
106
|
import { HUB_SVC, clearHubPort, writeHubPort } from "./hub-control.ts";
|
|
56
107
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
108
|
+
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
57
109
|
import { pemToJwk } from "./jwks.ts";
|
|
58
110
|
import {
|
|
59
111
|
type ModuleManifest,
|
|
@@ -68,6 +120,7 @@ import {
|
|
|
68
120
|
handleRevoke,
|
|
69
121
|
handleToken,
|
|
70
122
|
} from "./oauth-handlers.ts";
|
|
123
|
+
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
71
124
|
import { clearPid, writePid } from "./process-state.ts";
|
|
72
125
|
import {
|
|
73
126
|
FIRST_PARTY_FALLBACKS,
|
|
@@ -75,7 +128,9 @@ import {
|
|
|
75
128
|
shortNameForManifest,
|
|
76
129
|
} from "./service-spec.ts";
|
|
77
130
|
import { type ServiceEntry, readManifest } from "./services-manifest.ts";
|
|
131
|
+
import { findActiveSession } from "./sessions.ts";
|
|
78
132
|
import { getAllPublicKeys } from "./signing-keys.ts";
|
|
133
|
+
import { getUserById } from "./users.ts";
|
|
79
134
|
import { buildWellKnown, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
80
135
|
|
|
81
136
|
interface Args {
|
|
@@ -458,6 +513,23 @@ export interface HubFetchDeps {
|
|
|
458
513
|
* fixture installDirs.
|
|
459
514
|
*/
|
|
460
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;
|
|
461
533
|
}
|
|
462
534
|
|
|
463
535
|
/**
|
|
@@ -490,6 +562,50 @@ async function loadManagementUrls(
|
|
|
490
562
|
return out;
|
|
491
563
|
}
|
|
492
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
|
+
|
|
493
609
|
/**
|
|
494
610
|
* Resolve the SPA bundle dir. We anchor to this file's location so a
|
|
495
611
|
* `bun src/hub-server.ts` from any cwd still finds `<repo>/web/ui/dist/`.
|
|
@@ -503,22 +619,23 @@ function defaultSpaDistDir(): string {
|
|
|
503
619
|
}
|
|
504
620
|
|
|
505
621
|
/**
|
|
506
|
-
* The SPA serves at
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* `/
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* (`/vault
|
|
518
|
-
*
|
|
519
|
-
*
|
|
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.
|
|
520
637
|
*/
|
|
521
|
-
type SpaMount = "/
|
|
638
|
+
type SpaMount = "/admin";
|
|
522
639
|
|
|
523
640
|
/**
|
|
524
641
|
* Pick a content type for static assets the SPA build produces. Vite's
|
|
@@ -565,8 +682,8 @@ function spaContentType(pathname: string): string {
|
|
|
565
682
|
* filter rejects sub-paths containing "..", and the resolved absolute
|
|
566
683
|
* path is checked to start with `dist/` before any read.
|
|
567
684
|
*
|
|
568
|
-
* `mount` is the prefix being served (`/
|
|
569
|
-
*
|
|
685
|
+
* `mount` is the prefix being served (`/admin`); we strip it from
|
|
686
|
+
* `pathname` to land on the file path inside `dist/`.
|
|
570
687
|
*/
|
|
571
688
|
async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount): Promise<Response> {
|
|
572
689
|
if (!existsSync(spaDistDir)) {
|
|
@@ -575,7 +692,7 @@ async function serveSpa(spaDistDir: string, pathname: string, mount: SpaMount):
|
|
|
575
692
|
{ status: 503, headers: { "content-type": "text/plain; charset=utf-8" } },
|
|
576
693
|
);
|
|
577
694
|
}
|
|
578
|
-
// Strip the mount prefix; "/
|
|
695
|
+
// Strip the mount prefix; "/admin" → "", "/admin/" → "/", "/admin/x" → "/x".
|
|
579
696
|
const sub = pathname === mount ? "" : pathname.slice(mount.length);
|
|
580
697
|
const indexPath = join(spaDistDir, "index.html");
|
|
581
698
|
|
|
@@ -617,31 +734,137 @@ export function hubFetch(
|
|
|
617
734
|
const configuredIssuer = deps?.issuer;
|
|
618
735
|
const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
619
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
|
+
});
|
|
620
749
|
|
|
621
|
-
const oauthDeps = (req: Request) =>
|
|
622
|
-
issuer
|
|
623
|
-
|
|
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
|
+
};
|
|
624
767
|
|
|
625
768
|
return async (req) => {
|
|
626
769
|
const url = new URL(req.url);
|
|
627
770
|
const pathname = url.pathname;
|
|
628
771
|
|
|
629
|
-
// 301 back-compat
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
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
|
+
}
|
|
636
798
|
if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
|
|
637
|
-
const newPath = `/
|
|
799
|
+
const newPath = `/admin/vaults${pathname.slice("/hub/vaults".length)}`;
|
|
638
800
|
return new Response("", {
|
|
639
801
|
status: 301,
|
|
640
802
|
headers: { location: `${newPath}${url.search}` },
|
|
641
803
|
});
|
|
642
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
|
+
}
|
|
643
841
|
|
|
644
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).
|
|
645
868
|
if (!existsSync(hubHtmlPath)) {
|
|
646
869
|
return new Response("hub.html not found", { status: 404 });
|
|
647
870
|
}
|
|
@@ -671,14 +894,17 @@ export function hubFetch(
|
|
|
671
894
|
try {
|
|
672
895
|
const manifest = readManifest(manifestPath);
|
|
673
896
|
const canonicalOrigin = configuredIssuer ?? new URL(req.url).origin;
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
897
|
+
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
898
|
+
const [managementUrlByName, serviceUiMeta] = await Promise.all([
|
|
899
|
+
loadManagementUrls(manifest.services, readManifestFn),
|
|
900
|
+
loadServiceUiMetadata(manifest.services, readManifestFn),
|
|
901
|
+
]);
|
|
678
902
|
const doc = buildWellKnown({
|
|
679
903
|
services: manifest.services,
|
|
680
904
|
canonicalOrigin,
|
|
681
905
|
managementUrlFor: (entry) => managementUrlByName.get(entry.name),
|
|
906
|
+
uiUrlFor: (entry) => serviceUiMeta.uiUrls.get(entry.name),
|
|
907
|
+
displayNameFor: (entry) => serviceUiMeta.displayNames.get(entry.name),
|
|
682
908
|
});
|
|
683
909
|
return new Response(JSON.stringify(doc), {
|
|
684
910
|
headers: { "content-type": "application/json", ...corsHeaders },
|
|
@@ -694,6 +920,30 @@ export function hubFetch(
|
|
|
694
920
|
}
|
|
695
921
|
}
|
|
696
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
|
+
|
|
697
947
|
if (pathname === "/.well-known/jwks.json") {
|
|
698
948
|
// JWKS is also a cross-origin fetch target (browser-side OAuth
|
|
699
949
|
// libraries pull this to verify access tokens). Same wildcard CORS
|
|
@@ -800,15 +1050,10 @@ export function hubFetch(
|
|
|
800
1050
|
});
|
|
801
1051
|
}
|
|
802
1052
|
|
|
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
|
-
}
|
|
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.
|
|
812
1057
|
|
|
813
1058
|
if (pathname === "/admin/host-admin-token") {
|
|
814
1059
|
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
@@ -838,6 +1083,55 @@ export function hubFetch(
|
|
|
838
1083
|
});
|
|
839
1084
|
}
|
|
840
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
|
+
|
|
841
1135
|
if (pathname === "/api/grants") {
|
|
842
1136
|
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
843
1137
|
return handleListGrants(req, {
|
|
@@ -858,63 +1152,98 @@ export function hubFetch(
|
|
|
858
1152
|
});
|
|
859
1153
|
}
|
|
860
1154
|
|
|
861
|
-
|
|
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") {
|
|
862
1193
|
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
863
1194
|
if (req.method === "GET") return handleAdminLoginGet(getDb(), req);
|
|
864
1195
|
if (req.method === "POST") return handleAdminLoginPost(getDb(), req);
|
|
865
1196
|
return new Response("method not allowed", { status: 405 });
|
|
866
1197
|
}
|
|
867
1198
|
|
|
868
|
-
if (pathname === "/
|
|
1199
|
+
if (pathname === "/logout") {
|
|
869
1200
|
if (!getDb) return new Response("hub db not configured", { status: 503 });
|
|
870
1201
|
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
871
1202
|
return handleAdminLogoutPost(getDb(), req);
|
|
872
1203
|
}
|
|
873
1204
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
+
});
|
|
878
1215
|
}
|
|
879
1216
|
|
|
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
|
-
}
|
|
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.
|
|
910
1225
|
if (pathname.startsWith("/vault/")) {
|
|
911
1226
|
const proxied = await proxyToVault(req, manifestPath);
|
|
912
1227
|
if (proxied) return proxied;
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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");
|
|
918
1247
|
}
|
|
919
1248
|
|
|
920
1249
|
// Generic services.json-driven dispatch for non-vault modules. Reaches
|
|
@@ -938,7 +1267,7 @@ if (import.meta.main) {
|
|
|
938
1267
|
Bun.serve({
|
|
939
1268
|
port,
|
|
940
1269
|
hostname: "127.0.0.1",
|
|
941
|
-
fetch: hubFetch(wellKnownDir, { getDb, issuer }),
|
|
1270
|
+
fetch: hubFetch(wellKnownDir, { getDb, issuer, loopbackPort: port }),
|
|
942
1271
|
});
|
|
943
1272
|
// Register PID + port from the running hub itself so any startup path
|
|
944
1273
|
// (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from
|