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