@openparachute/hub 0.7.6 → 0.7.7-rc.13
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 +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-command.test.ts +70 -2
- package/src/__tests__/hub-server.test.ts +322 -1
- package/src/__tests__/hub-settings.test.ts +110 -6
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +359 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/root-serve.test.ts +139 -0
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +97 -20
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/hub.ts +104 -0
- package/src/commands/install.ts +97 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +318 -55
- package/src/hub-settings.ts +162 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/root-serve.ts +156 -0
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +129 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
package/src/hub-server.ts
CHANGED
|
@@ -24,15 +24,33 @@
|
|
|
24
24
|
* /admin/login, /admin/logout → 301 → /login, /logout
|
|
25
25
|
*
|
|
26
26
|
* # Notes-as-app migration Phase 2 (parachute-app design doc §16).
|
|
27
|
+
* # `/surface/notes` = the legacy notes-ui mount (existing installs);
|
|
28
|
+
* # `/surface/parachute` = the app's surface mount post-W2-12 rename.
|
|
27
29
|
* /notes, /notes/, /notes/* → 301 → /surface/notes[/...]
|
|
28
30
|
* (opt-out via
|
|
29
31
|
* hub_settings.notes_redirect_disabled)
|
|
32
|
+
* /surface/notes[/...] → 301 → /surface/parachute[/...]
|
|
33
|
+
* CONDITIONAL (W2-12, dispatched just
|
|
34
|
+
* before the generic services proxy):
|
|
35
|
+
* only when uis{} mounts show
|
|
36
|
+
* /surface/notes gone + /surface/parachute
|
|
37
|
+
* present — inert on existing notes-ui
|
|
38
|
+
* installs (surface-notes-alias.ts)
|
|
30
39
|
*
|
|
31
40
|
* # Discovery + well-known.
|
|
32
|
-
*
|
|
41
|
+
* / → root_mode-dependent: `redirect`
|
|
42
|
+
* (default) 302s to root_redirect
|
|
43
|
+
* (default /admin); `serve-app`
|
|
44
|
+
* serves the installed app's dist
|
|
45
|
+
* index.html AT `/` (no chrome).
|
|
46
|
+
* Fresh-hub wizard funnel + pre-
|
|
47
|
+
* admin 503 still preempt it.
|
|
48
|
+
* /hub.html → hub.html (the discovery page;
|
|
49
|
+
* unaffected by root_mode)
|
|
33
50
|
* /.well-known/parachute.json → built dynamically from services.json
|
|
34
51
|
* /.well-known/parachute-revocation.json → revoked-jti list (hub#212 Phase 1)
|
|
35
52
|
* /.well-known/jwks.json → JWKS from hub.db
|
|
53
|
+
* /.well-known/parachute-account → account-door capabilities descriptor (public, H2)
|
|
36
54
|
* /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
|
|
37
55
|
*
|
|
38
56
|
* # OAuth issuer.
|
|
@@ -49,10 +67,15 @@
|
|
|
49
67
|
* tokens/grants/user_vaults/invites/
|
|
50
68
|
* connections sweep, CLI remove,
|
|
51
69
|
* supervisor restart)
|
|
70
|
+
* # /account/* — the Bearer-gated account-door REST facade (Phase 2, H2).
|
|
71
|
+
* # account:self:admin OR parachute:host:admin (mutations); +:read (reads).
|
|
72
|
+
* /account (GET) → account bootstrap {id,email,door} (Bearer only)
|
|
73
|
+
* /account/vaults (GET/POST) → list / create vault (create returns vault_token)
|
|
74
|
+
* /account/vaults/<name> (DELETE) → teardown (wraps /vaults/<name> cascade)
|
|
75
|
+
* /account/vaults/<name>/token (POST) → per-vault scoped token mint
|
|
76
|
+
* /account/vaults/<name>/caps (GET/PUT) → read / set the storage cap
|
|
52
77
|
* /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
|
|
53
78
|
* /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
|
|
54
|
-
* /admin/agent-token (GET) → agent UI bearer mint (cookie-gated)
|
|
55
|
-
* /admin/channel-token (GET) → 301 → /admin/agent-token (legacy; channel→agent rename 2026-06-17)
|
|
56
79
|
* /admin/module-token/<short> (GET) → generic module config-UI bearer mint <short>:admin (cookie-gated)
|
|
57
80
|
* /api/connections/catalog (GET) → events/actions across installed modules (cookie-gated)
|
|
58
81
|
* /admin/connections (POST/GET) → connection provision/list (cookie-gated; POST CSRF-belted)
|
|
@@ -106,6 +129,13 @@
|
|
|
106
129
|
* (hub#473; reached after a correct
|
|
107
130
|
* password for a 2FA-enrolled user)
|
|
108
131
|
* /logout (POST) → end admin session
|
|
132
|
+
* /account/session (GET) → same-origin boot oracle {signed_in,
|
|
133
|
+
* csrf,...} (hub-parity P1); cookie-gated,
|
|
134
|
+
* NOT Bearer — mounted ABOVE the
|
|
135
|
+
* force-change-password gate below (a pure
|
|
136
|
+
* state oracle, so a pre-rotation user can
|
|
137
|
+
* still read it to drive the rotation
|
|
138
|
+
* ceremony itself)
|
|
109
139
|
* /account/change-password (GET + POST) → user self-service change-password
|
|
110
140
|
* (force-redirect target for users
|
|
111
141
|
* with password_changed=false; also
|
|
@@ -114,8 +144,16 @@
|
|
|
114
144
|
* + the per-vault proxy is hard-gated
|
|
115
145
|
* per-request on password_changed===true
|
|
116
146
|
* (forceChangePasswordGate); only this
|
|
117
|
-
* route
|
|
147
|
+
* route, /logout, and /account/session
|
|
148
|
+
* (P1, also above the gate) stay reachable
|
|
118
149
|
* pre-rotation.
|
|
150
|
+
* /account/token (POST) → cookie→account-bearer mint (App
|
|
151
|
+
* campaign Phase 2 H1): session cookie →
|
|
152
|
+
* short-lived JWT carrying the account
|
|
153
|
+
* superset {account:self:admin,
|
|
154
|
+
* parachute:host:admin, parachute:host:auth}
|
|
155
|
+
* aud="account"; first-admin-gated,
|
|
156
|
+
* CSRF + same-origin belt; stateless
|
|
119
157
|
* /account/2fa (GET + POST) → user self-service 2FA enroll/disenroll
|
|
120
158
|
* (hub#473; QR + backup codes)
|
|
121
159
|
* /account/vault-token/<name> (POST) → friend mints a scoped
|
|
@@ -141,7 +179,13 @@
|
|
|
141
179
|
* # Generic services.json-driven proxy (non-vault modules: notes, scribe, agent).
|
|
142
180
|
* /<service-mount>/* → proxy via services.json longest-prefix
|
|
143
181
|
*
|
|
144
|
-
*
|
|
182
|
+
* # serve-app tail (only when root_mode = serve-app): an unclaimed GET is
|
|
183
|
+
* # handed to the app's root-based dist — an existing bundle file (/assets/*,
|
|
184
|
+
* # /icon.svg, /manifest.webmanifest, /sw.js) serves that file; an HTML deep
|
|
185
|
+
* # link gets the SPA shell. Inert in redirect mode (the 404 below is
|
|
186
|
+
* # byte-identical to before). See root-serve.ts.
|
|
187
|
+
* anything else → 404 (or app asset / SPA shell
|
|
188
|
+
* in serve-app mode)
|
|
145
189
|
*
|
|
146
190
|
* Invoked as:
|
|
147
191
|
* bun <this-file> --port <n> --well-known-dir <path> [--db <path>] [--issuer <url>]
|
|
@@ -163,7 +207,21 @@ import { existsSync } from "node:fs";
|
|
|
163
207
|
import { dirname, join, resolve } from "node:path";
|
|
164
208
|
import { fileURLToPath } from "node:url";
|
|
165
209
|
import pkg from "../package.json" with { type: "json" };
|
|
210
|
+
import {
|
|
211
|
+
ACCOUNT_MUTATION_SCOPES,
|
|
212
|
+
type AccountApiDeps,
|
|
213
|
+
handleAccountCapabilities,
|
|
214
|
+
handleAccountCreateVault,
|
|
215
|
+
handleAccountGetVaultCaps,
|
|
216
|
+
handleAccountListVaults,
|
|
217
|
+
handleAccountMintVaultToken,
|
|
218
|
+
handleAccountRoot,
|
|
219
|
+
handleAccountSetVaultCaps,
|
|
220
|
+
requireAnyScope,
|
|
221
|
+
} from "./account-api.ts";
|
|
222
|
+
import { type AccountSessionDeps, handleAccountSession } from "./account-session.ts";
|
|
166
223
|
import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
|
|
224
|
+
import { handleAccountToken } from "./account-token.ts";
|
|
167
225
|
import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
|
|
168
226
|
import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
|
|
169
227
|
import {
|
|
@@ -171,7 +229,8 @@ import {
|
|
|
171
229
|
handleAgentGrants,
|
|
172
230
|
handleOAuthGrantCallback,
|
|
173
231
|
} from "./admin-agent-grants.ts";
|
|
174
|
-
|
|
232
|
+
|
|
233
|
+
import { adminAuthErrorResponse } from "./admin-auth.ts";
|
|
175
234
|
import { handleApproveClient, handleDeleteClient, handleGetClient } from "./admin-clients.ts";
|
|
176
235
|
import {
|
|
177
236
|
type ConnectionsDeps,
|
|
@@ -252,7 +311,7 @@ import {
|
|
|
252
311
|
startDbPathLivenessTimer,
|
|
253
312
|
} from "./hub-db-liveness.ts";
|
|
254
313
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
255
|
-
import { getHubOrigin, resolveRootRedirect } from "./hub-settings.ts";
|
|
314
|
+
import { getHubOrigin, resolveRootMode, resolveRootRedirect } from "./hub-settings.ts";
|
|
256
315
|
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
257
316
|
import { pemToJwk } from "./jwks.ts";
|
|
258
317
|
import {
|
|
@@ -276,6 +335,7 @@ import { clearPid, writePid } from "./process-state.ts";
|
|
|
276
335
|
import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
|
|
277
336
|
import { classifyUpstream } from "./proxy-state.ts";
|
|
278
337
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
338
|
+
import { makeAppDistResolver, serveAppAtRoot } from "./root-serve.ts";
|
|
279
339
|
import {
|
|
280
340
|
FIRST_PARTY_FALLBACKS,
|
|
281
341
|
KNOWN_MODULES,
|
|
@@ -295,6 +355,7 @@ import {
|
|
|
295
355
|
} from "./setup-wizard.ts";
|
|
296
356
|
import { getAllPublicKeys } from "./signing-keys.ts";
|
|
297
357
|
import type { Supervisor } from "./supervisor.ts";
|
|
358
|
+
import { isSurfaceNotesPath, maybeRedirectSurfaceNotes } from "./surface-notes-alias.ts";
|
|
298
359
|
import { handleTwoFactorGet, handleTwoFactorPost } from "./two-factor-handlers.ts";
|
|
299
360
|
import { getUserById, userCount } from "./users.ts";
|
|
300
361
|
import { sanitizePublicOrigin } from "./vault-hub-origin-env.ts";
|
|
@@ -1374,6 +1435,15 @@ export interface HubFetchDeps {
|
|
|
1374
1435
|
* tracker between fetch fn and bridge handlers is impossible.
|
|
1375
1436
|
*/
|
|
1376
1437
|
wsConnectionTracker?: WsConnectionTracker;
|
|
1438
|
+
/**
|
|
1439
|
+
* Resolve the installed app's `dist/` directory for `root_mode = serve-app`.
|
|
1440
|
+
* Returns the dist dir, or `null` when the app isn't installed (→ serve-app
|
|
1441
|
+
* falls back to the redirect). Production defaults to a memoizing resolver
|
|
1442
|
+
* over the real `@openparachute/parachute-app` package (`makeAppDistResolver`);
|
|
1443
|
+
* tests inject a fixture dir or a `() => null` to drive the not-installed
|
|
1444
|
+
* fallback without a real global install.
|
|
1445
|
+
*/
|
|
1446
|
+
resolveAppDist?: () => string | null;
|
|
1377
1447
|
}
|
|
1378
1448
|
|
|
1379
1449
|
/**
|
|
@@ -2073,6 +2143,13 @@ export function hubFetch(
|
|
|
2073
2143
|
const manifestPath = deps?.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
2074
2144
|
const gitRoot = deps?.gitRoot ?? join(CONFIG_DIR, "hub", "git");
|
|
2075
2145
|
const spaDistDir = deps?.spaDistDir ?? defaultSpaDistDir();
|
|
2146
|
+
// `root_mode = serve-app` resolver (memoizing over the real app package by
|
|
2147
|
+
// default; tests inject their own). Instantiated once per hubFetch so the
|
|
2148
|
+
// success-memoization spans the serve process's lifetime.
|
|
2149
|
+
const resolveAppDist = deps?.resolveAppDist ?? makeAppDistResolver();
|
|
2150
|
+
// One-shot log guard: when serve-app is set but the app can't be resolved we
|
|
2151
|
+
// fall back to the redirect and say so ONCE, not on every `/` hit.
|
|
2152
|
+
let warnedAppServeFallback = false;
|
|
2076
2153
|
const loopbackPort = deps?.loopbackPort;
|
|
2077
2154
|
const loadExposeHubOrigin =
|
|
2078
2155
|
deps?.loadExposeHubOrigin ??
|
|
@@ -2312,27 +2389,6 @@ export function hubFetch(
|
|
|
2312
2389
|
}
|
|
2313
2390
|
}
|
|
2314
2391
|
|
|
2315
|
-
// `/channel/*` 301-redirects to `/agent/*` — back-compat for the
|
|
2316
|
-
// 2026-06-17 channel→agent module rename. Operator bookmarks, an
|
|
2317
|
-
// un-upgraded chat/config UI's deep links, and any externally-shared
|
|
2318
|
-
// `/channel/mcp/<name>` URL keep resolving for one release cycle while
|
|
2319
|
-
// the module's canonical mount moves to `/agent`. Method-agnostic, same
|
|
2320
|
-
// shape as the `/notes/*` redirect above (the agent's read-write surface
|
|
2321
|
-
// is its own daemon API, not the hub mount, so a re-issued GET is fine).
|
|
2322
|
-
// Matches `/channel` exactly and any `/channel/...` subpath, but NOT a
|
|
2323
|
-
// longer-prefix module like a hypothetical `/channelthing` — the guard is
|
|
2324
|
-
// exact-or-slash-delimited. Query string is preserved. (The generic
|
|
2325
|
-
// services-proxy fallthrough below would otherwise 404 a `/channel/*`
|
|
2326
|
-
// request once the module self-registers under `/agent`.)
|
|
2327
|
-
if (pathname === "/channel" || pathname.startsWith("/channel/")) {
|
|
2328
|
-
const dest = new URL(req.url);
|
|
2329
|
-
dest.pathname = `/agent${pathname.slice("/channel".length)}`;
|
|
2330
|
-
return new Response("", {
|
|
2331
|
-
status: 301,
|
|
2332
|
-
headers: { location: dest.pathname + dest.search },
|
|
2333
|
-
});
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
2392
|
// CORS preflight for the public OAuth + discovery surface. Browsers
|
|
2337
2393
|
// issue OPTIONS before any non-simple cross-origin request — third-party
|
|
2338
2394
|
// SPAs hitting `/oauth/register` (RFC 7591 DCR), `/oauth/token`,
|
|
@@ -2611,6 +2667,24 @@ export function hubFetch(
|
|
|
2611
2667
|
// page (used by the static `parachute expose --set-path=/` disk file and
|
|
2612
2668
|
// any bookmark to the explicit `.html`). Only the bare `/` redirects.
|
|
2613
2669
|
if (pathname === "/") {
|
|
2670
|
+
// `root_mode = serve-app`: serve the installed app's shell AT the origin
|
|
2671
|
+
// root (no redirect hop), the self-hosted mirror of the hosted door.
|
|
2672
|
+
// GET only — a non-GET `/` keeps its 302 below. Runs AFTER the fresh-hub
|
|
2673
|
+
// wizard funnel + pre-admin 503 above (a not-yet-set-up hub still lands
|
|
2674
|
+
// on setup, never a half-working app shell). Falls back to the redirect
|
|
2675
|
+
// when the app isn't installed (dist unresolvable), with a one-time log.
|
|
2676
|
+
if (req.method === "GET" && resolveRootMode(getDb ? getDb() : null) === "serve-app") {
|
|
2677
|
+
const dist = resolveAppDist();
|
|
2678
|
+
if (dist) {
|
|
2679
|
+
const served = serveAppAtRoot(dist, req, "/");
|
|
2680
|
+
if (served) return served;
|
|
2681
|
+
} else if (!warnedAppServeFallback) {
|
|
2682
|
+
warnedAppServeFallback = true;
|
|
2683
|
+
console.warn(
|
|
2684
|
+
"[hub] root_mode=serve-app but the Parachute app is not installed (its dist could not be resolved) — falling back to the root redirect. Install it with `parachute install app`.",
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2614
2688
|
return new Response(null, {
|
|
2615
2689
|
status: 302,
|
|
2616
2690
|
headers: { location: resolveRootRedirect(getDb ? getDb() : null) },
|
|
@@ -2812,6 +2886,30 @@ export function hubFetch(
|
|
|
2812
2886
|
return new Response(res.body, { status: res.status, headers: merged });
|
|
2813
2887
|
}
|
|
2814
2888
|
|
|
2889
|
+
// GET /.well-known/parachute-account — the account-door capabilities
|
|
2890
|
+
// descriptor (Phase 2, H2). Public, no auth, wildcard CORS (the app pulls
|
|
2891
|
+
// it cross-origin to render honestly per door: billing:false + plans:[] on
|
|
2892
|
+
// self-host means no upgrade UI). See account-api.handleAccountCapabilities.
|
|
2893
|
+
if (pathname === "/.well-known/parachute-account") {
|
|
2894
|
+
const corsHeaders = {
|
|
2895
|
+
"access-control-allow-origin": "*",
|
|
2896
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
2897
|
+
};
|
|
2898
|
+
if (req.method === "OPTIONS") {
|
|
2899
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
2900
|
+
}
|
|
2901
|
+
if (!getDb) {
|
|
2902
|
+
return new Response('{"error":"account descriptor unavailable: db not configured"}', {
|
|
2903
|
+
status: 503,
|
|
2904
|
+
headers: { "content-type": "application/json", ...corsHeaders },
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
const res = handleAccountCapabilities(req, { db: getDb(), issuer: oauthDeps(req).issuer });
|
|
2908
|
+
const merged = new Headers(res.headers);
|
|
2909
|
+
for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
|
|
2910
|
+
return new Response(res.body, { status: res.status, headers: merged });
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2815
2913
|
// OAuth surface — every handler return is wrapped in `applyCorsHeaders`
|
|
2816
2914
|
// so third-party SPAs can fetch these endpoints cross-origin (the entire
|
|
2817
2915
|
// point of OAuth DCR: arbitrary SPAs register → authorize → exchange
|
|
@@ -3001,31 +3099,13 @@ export function hubFetch(
|
|
|
3001
3099
|
});
|
|
3002
3100
|
}
|
|
3003
3101
|
|
|
3004
|
-
// Back-compat: the agent module's admin-token mint moved from
|
|
3005
|
-
// `/admin/channel-token` to `/admin/agent-token` in the 2026-06-17
|
|
3006
|
-
// channel→agent rename. 301-redirect the old path so operator bookmarks
|
|
3007
|
-
// + any un-upgraded UI fallback keep working for one release cycle.
|
|
3008
|
-
if (pathname === "/admin/channel-token") {
|
|
3009
|
-
const dest = new URL(req.url);
|
|
3010
|
-
dest.pathname = "/admin/agent-token";
|
|
3011
|
-
return Response.redirect(dest.toString(), 301);
|
|
3012
|
-
}
|
|
3013
|
-
|
|
3014
|
-
if (pathname === "/admin/agent-token") {
|
|
3015
|
-
if (!getDb) return dbNotConfigured();
|
|
3016
|
-
return handleAgentToken(req, {
|
|
3017
|
-
db: getDb(),
|
|
3018
|
-
issuer: oauthDeps(req).issuer,
|
|
3019
|
-
});
|
|
3020
|
-
}
|
|
3021
|
-
|
|
3022
3102
|
// Generic per-module config-UI bearer mint (2026-06-09 modular-UI
|
|
3023
3103
|
// architecture, P3). `<short>:admin` for any single-audience module —
|
|
3024
3104
|
// the admin scope each module-owned config UI needs to call its own
|
|
3025
|
-
// endpoints. Cookie-gated to the first-admin operator,
|
|
3026
|
-
// /admin
|
|
3027
|
-
//
|
|
3028
|
-
//
|
|
3105
|
+
// endpoints. Cookie-gated to the first-admin operator, matching the
|
|
3106
|
+
// host/vault admin-token endpoints. Gated on self-registration
|
|
3107
|
+
// (services.json row + readable module.json) with the bootstrap
|
|
3108
|
+
// registries as a fallback (boundary C5) — a genuinely
|
|
3029
3109
|
// third-party module mints here with zero hub code changes. Vault is
|
|
3030
3110
|
// per-instance and routed to /admin/vault-admin-token/<name> instead.
|
|
3031
3111
|
if (pathname.startsWith("/admin/module-token/")) {
|
|
@@ -3043,9 +3123,7 @@ export function hubFetch(
|
|
|
3043
3123
|
|
|
3044
3124
|
// Note: the legacy `/admin/channels` bespoke vault-channel orchestration
|
|
3045
3125
|
// endpoint (pre-Connections, hub#624 era) was retired in boundary D1 —
|
|
3046
|
-
// superseded by the general engine below.
|
|
3047
|
-
// page (renamed from channel 2026-06-17) drives `/admin/connections` +
|
|
3048
|
-
// `/admin/agent-token`.
|
|
3126
|
+
// superseded by the general engine below.
|
|
3049
3127
|
|
|
3050
3128
|
// Connections — the GENERAL module event→action engine (2026-06-09
|
|
3051
3129
|
// modular-UI architecture, P5). `/api/connections/catalog` (GET) returns
|
|
@@ -3348,7 +3426,7 @@ export function hubFetch(
|
|
|
3348
3426
|
// the 2026-06-09 modular-UI architecture P3. Config is module-owned +
|
|
3349
3427
|
// hub-framed now: the Modules page "Configure" action opens the module's
|
|
3350
3428
|
// OWN config UI (`configUiUrl`), which mints its admin Bearer from the
|
|
3351
|
-
// cookie-gated `/admin/module-token/<short
|
|
3429
|
+
// cookie-gated `/admin/module-token/<short>`.
|
|
3352
3430
|
|
|
3353
3431
|
// Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
|
|
3354
3432
|
if (pathname.startsWith("/api/modules/")) {
|
|
@@ -3699,6 +3777,20 @@ export function hubFetch(
|
|
|
3699
3777
|
return new Response("method not allowed", { status: 405 });
|
|
3700
3778
|
}
|
|
3701
3779
|
|
|
3780
|
+
// /account/session — the same-origin boot oracle (hub-parity P1). A
|
|
3781
|
+
// PURE STATE ORACLE, not a mutating surface, so it is mounted ABOVE the
|
|
3782
|
+
// force-change-password gate immediately below: a pre-rotation user
|
|
3783
|
+
// must still be able to read `{signed_in, csrf}` to drive the rotation
|
|
3784
|
+
// ceremony itself, rather than get walled off before it can even ask.
|
|
3785
|
+
// `/account/token` (below the gate) stays gated — a pre-rotation user
|
|
3786
|
+
// hits its 403 `force_change_password` there instead. See
|
|
3787
|
+
// `account-session.ts`.
|
|
3788
|
+
if (pathname === "/account/session") {
|
|
3789
|
+
if (!getDb) return dbNotConfigured();
|
|
3790
|
+
const sessionDeps: AccountSessionDeps = { db: getDb() };
|
|
3791
|
+
return handleAccountSession(req, sessionDeps);
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3702
3794
|
// Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
|
|
3703
3795
|
// every `/account/*` route BELOW this line is gated. `/logout` and
|
|
3704
3796
|
// `/account/change-password` (the rotation/exit path) ran above and already
|
|
@@ -3721,6 +3813,27 @@ export function hubFetch(
|
|
|
3721
3813
|
if (gate) return gate;
|
|
3722
3814
|
}
|
|
3723
3815
|
|
|
3816
|
+
// /account/token — cookie→account-bearer mint (Parachute App campaign,
|
|
3817
|
+
// Phase 2 H1). POST-only. Exchanges the `parachute_hub_session` cookie for
|
|
3818
|
+
// a short-lived JWT carrying the account superset
|
|
3819
|
+
// {account:self:admin, parachute:host:admin, parachute:host:auth} with
|
|
3820
|
+
// aud="account" — the same-origin app's bootstrap into holding the account
|
|
3821
|
+
// credential (it never touches /oauth/authorize, so never hits consent).
|
|
3822
|
+
// First-admin-gated (account ≡ box), CSRF double-submit + the same-origin
|
|
3823
|
+
// Origin belt below. Stateless (no tokens row); revocation = logout.
|
|
3824
|
+
if (pathname === "/account/token") {
|
|
3825
|
+
if (!getDb) return dbNotConfigured();
|
|
3826
|
+
// Origin belt (hub#632 posture): a cookie-authed POST mint gets the
|
|
3827
|
+
// strict same-origin check as defense-in-depth over the body's CSRF
|
|
3828
|
+
// token. A Bearer-authed caller (none expected here) would bypass the
|
|
3829
|
+
// belt but still fail the cookie-only session gate inside the handler.
|
|
3830
|
+
{
|
|
3831
|
+
const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
|
|
3832
|
+
if (rejected) return rejected;
|
|
3833
|
+
}
|
|
3834
|
+
return handleAccountToken(req, { db: getDb(), issuer: oauthDeps(req).issuer });
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3724
3837
|
// /account/2fa — user self-service TOTP 2FA enroll / disenroll (hub#473).
|
|
3725
3838
|
// Both GET (render state) and POST (start/confirm/disable) require an
|
|
3726
3839
|
// active session; the handler does the session check + 302 to /login when
|
|
@@ -3777,6 +3890,115 @@ export function hubFetch(
|
|
|
3777
3890
|
});
|
|
3778
3891
|
}
|
|
3779
3892
|
|
|
3893
|
+
// ====================================================================
|
|
3894
|
+
// /account/* — the Bearer-gated account-door REST facade (Phase 2, H2).
|
|
3895
|
+
// The normalized account API both doors mount (the hosted cloud door
|
|
3896
|
+
// mounts the twin). Bearer-authed, machine-to-machine — no session
|
|
3897
|
+
// cookie — so these precede the session-cookie `/account/vault-token/`
|
|
3898
|
+
// + `/account/` HTML routes below. Mutations accept account:self:admin
|
|
3899
|
+
// OR parachute:host:admin; reads also accept account:self:read. See
|
|
3900
|
+
// `account-api.ts`.
|
|
3901
|
+
// ====================================================================
|
|
3902
|
+
if (pathname === "/account/vaults") {
|
|
3903
|
+
if (!getDb) return dbNotConfigured();
|
|
3904
|
+
const accountDeps: AccountApiDeps = {
|
|
3905
|
+
db: getDb(),
|
|
3906
|
+
issuer: oauthDeps(req).issuer,
|
|
3907
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3908
|
+
manifestPath,
|
|
3909
|
+
};
|
|
3910
|
+
if (req.method === "GET") return handleAccountListVaults(req, accountDeps);
|
|
3911
|
+
if (req.method === "POST") return handleAccountCreateVault(req, accountDeps);
|
|
3912
|
+
return new Response("method not allowed", { status: 405 });
|
|
3913
|
+
}
|
|
3914
|
+
if (pathname.startsWith("/account/vaults/")) {
|
|
3915
|
+
if (!getDb) return dbNotConfigured();
|
|
3916
|
+
const rest = pathname.slice("/account/vaults/".length);
|
|
3917
|
+
const accountDeps: AccountApiDeps = {
|
|
3918
|
+
db: getDb(),
|
|
3919
|
+
issuer: oauthDeps(req).issuer,
|
|
3920
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3921
|
+
manifestPath,
|
|
3922
|
+
};
|
|
3923
|
+
// POST /account/vaults/<name>/token — per-vault scoped token mint.
|
|
3924
|
+
if (rest.endsWith("/token")) {
|
|
3925
|
+
const name = decodeURIComponent(rest.slice(0, -"/token".length));
|
|
3926
|
+
return handleAccountMintVaultToken(req, name, accountDeps);
|
|
3927
|
+
}
|
|
3928
|
+
// GET/PUT /account/vaults/<name>/caps — read / set the storage cap.
|
|
3929
|
+
if (rest.endsWith("/caps")) {
|
|
3930
|
+
const name = decodeURIComponent(rest.slice(0, -"/caps".length));
|
|
3931
|
+
if (req.method === "GET") return handleAccountGetVaultCaps(req, name, accountDeps);
|
|
3932
|
+
if (req.method === "PUT") return handleAccountSetVaultCaps(req, name, accountDeps);
|
|
3933
|
+
return new Response("method not allowed", { status: 405 });
|
|
3934
|
+
}
|
|
3935
|
+
// DELETE /account/vaults/<name> — teardown via the full identity
|
|
3936
|
+
// cascade. Gate with the account-scope set here (accept account:self:admin
|
|
3937
|
+
// OR parachute:host:admin), then delegate to `handleDeleteVault`, which
|
|
3938
|
+
// re-gates parachute:host:admin — the superset account token carries it,
|
|
3939
|
+
// so this is belt-and-suspenders, not a second authorization.
|
|
3940
|
+
if (req.method === "DELETE") {
|
|
3941
|
+
const name = decodeURIComponent(rest);
|
|
3942
|
+
if (!name || name.includes("/")) return new Response("not found", { status: 404 });
|
|
3943
|
+
try {
|
|
3944
|
+
await requireAnyScope(
|
|
3945
|
+
getDb(),
|
|
3946
|
+
req,
|
|
3947
|
+
ACCOUNT_MUTATION_SCOPES,
|
|
3948
|
+
oauthDeps(req).hubBoundOrigins(),
|
|
3949
|
+
);
|
|
3950
|
+
} catch (err) {
|
|
3951
|
+
return adminAuthErrorResponse(err);
|
|
3952
|
+
}
|
|
3953
|
+
const services = readManifestLenient(manifestPath).services;
|
|
3954
|
+
const agentEntry = findServiceByShort(services, "agent");
|
|
3955
|
+
const agentOrigin = agentEntry ? `http://127.0.0.1:${agentEntry.port}` : null;
|
|
3956
|
+
const resolveVaultOrigin = (vaultName: string): string | null => {
|
|
3957
|
+
const match = findVaultUpstream(
|
|
3958
|
+
readManifestLenient(manifestPath).services,
|
|
3959
|
+
`/vault/${vaultName}`,
|
|
3960
|
+
);
|
|
3961
|
+
return match ? `http://127.0.0.1:${match.port}` : null;
|
|
3962
|
+
};
|
|
3963
|
+
const supervisor = deps?.supervisor;
|
|
3964
|
+
return handleDeleteVault(req, name, {
|
|
3965
|
+
db: getDb(),
|
|
3966
|
+
issuer: oauthDeps(req).issuer,
|
|
3967
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3968
|
+
manifestPath,
|
|
3969
|
+
connectionsStorePath:
|
|
3970
|
+
deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
|
|
3971
|
+
agentOrigin,
|
|
3972
|
+
resolveVaultOrigin,
|
|
3973
|
+
resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
|
|
3974
|
+
...(supervisor
|
|
3975
|
+
? {
|
|
3976
|
+
restartVaultModule: async () => {
|
|
3977
|
+
await supervisor.restart("vault");
|
|
3978
|
+
},
|
|
3979
|
+
}
|
|
3980
|
+
: {}),
|
|
3981
|
+
});
|
|
3982
|
+
}
|
|
3983
|
+
return new Response("method not allowed", { status: 405 });
|
|
3984
|
+
}
|
|
3985
|
+
// GET /account (Bearer) — account bootstrap {id,email,door} (the
|
|
3986
|
+
// door-contract's AccountBootstrap). Only intercepts when an
|
|
3987
|
+
// Authorization header is present, so the cookie-authed HTML account
|
|
3988
|
+
// home at `/account`/`/account/` below stays unaffected.
|
|
3989
|
+
if (
|
|
3990
|
+
(pathname === "/account" || pathname === "/account/") &&
|
|
3991
|
+
req.headers.get("authorization")
|
|
3992
|
+
) {
|
|
3993
|
+
if (!getDb) return dbNotConfigured();
|
|
3994
|
+
return handleAccountRoot(req, {
|
|
3995
|
+
db: getDb(),
|
|
3996
|
+
issuer: oauthDeps(req).issuer,
|
|
3997
|
+
knownIssuers: oauthDeps(req).hubBoundOrigins(),
|
|
3998
|
+
manifestPath,
|
|
3999
|
+
});
|
|
4000
|
+
}
|
|
4001
|
+
|
|
3780
4002
|
// /account/vault-token/<name> — friend-facing scoped vault token mint.
|
|
3781
4003
|
// POST-only, session-gated, assignment-capped: a non-admin friend mints a
|
|
3782
4004
|
// `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
|
|
@@ -3967,6 +4189,28 @@ export function hubFetch(
|
|
|
3967
4189
|
// `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
|
|
3968
4190
|
// `/api/*` are excluded by ordering, not by an explicit denylist (#182).
|
|
3969
4191
|
//
|
|
4192
|
+
// W2-12 — conditional `/surface/notes/*` → `/surface/parachute/*`
|
|
4193
|
+
// alias, the "never 404 an old bookmark after an in-place upgrade"
|
|
4194
|
+
// safety net for the app's notes→parachute surface-identity rename.
|
|
4195
|
+
// Fires ONLY when the manifest's `uis{}` sub-units show the legacy
|
|
4196
|
+
// `/surface/notes` mount gone AND a `/surface/parachute` mount
|
|
4197
|
+
// present (see surface-notes-alias.ts for the full condition +
|
|
4198
|
+
// inert-today proof). On every existing notes-ui install the
|
|
4199
|
+
// condition fails and dispatch falls through to the generic proxy
|
|
4200
|
+
// below unchanged. Reads the same lenient manifest source
|
|
4201
|
+
// `resolveUiMount` consumes — hoisted to one read shared by both.
|
|
4202
|
+
const manifestServices = readManifestLenient(manifestPath).services;
|
|
4203
|
+
if (isSurfaceNotesPath(pathname)) {
|
|
4204
|
+
const aliasTarget = maybeRedirectSurfaceNotes(pathname, url.search, manifestServices);
|
|
4205
|
+
if (aliasTarget !== undefined) {
|
|
4206
|
+
logNotesRedirect(pathname, aliasTarget);
|
|
4207
|
+
return new Response("", {
|
|
4208
|
+
status: 301,
|
|
4209
|
+
headers: { location: aliasTarget },
|
|
4210
|
+
});
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
|
|
3970
4214
|
// H3 — per-UI audience gate. When the path falls under a declared UI
|
|
3971
4215
|
// sub-unit (a `uis{}` entry on the matched service row — surface-hosted
|
|
3972
4216
|
// UI mounts like /surface/<name>/*), the sub-unit's audience is
|
|
@@ -3981,7 +4225,7 @@ export function hubFetch(
|
|
|
3981
4225
|
// which also means a 'surface'/'public' mount on a loopback-only row
|
|
3982
4226
|
// stays unreachable from tailnet/funnel: exposure is orthogonal to
|
|
3983
4227
|
// audience.
|
|
3984
|
-
const uiMatch = resolveUiMount(
|
|
4228
|
+
const uiMatch = resolveUiMount(manifestServices, pathname);
|
|
3985
4229
|
if (uiMatch) {
|
|
3986
4230
|
const cloaked =
|
|
3987
4231
|
effectivePublicExposure(uiMatch.entry) === "loopback" &&
|
|
@@ -4015,6 +4259,25 @@ export function hubFetch(
|
|
|
4015
4259
|
);
|
|
4016
4260
|
}
|
|
4017
4261
|
|
|
4262
|
+
// serve-app tail (`root_mode = serve-app`). This is exactly the "would
|
|
4263
|
+
// have been the branded 404" point: every hub-owned prefix above (/,
|
|
4264
|
+
// /admin, /oauth, /.well-known, /vault, /git, /api, service mounts) has
|
|
4265
|
+
// already had its turn, so anything here is genuinely unclaimed. Hand an
|
|
4266
|
+
// unclaimed GET to the app's root-based dist — an existing bundle file
|
|
4267
|
+
// (/assets/*, /icon.svg, /manifest.webmanifest, /sw.js) serves that file;
|
|
4268
|
+
// an HTML deep link the app routes client-side gets the SPA shell.
|
|
4269
|
+
// `serveAppAtRoot` returns null (→ branded 404 below) for a non-GET, a
|
|
4270
|
+
// non-HTML unclaimed request, or a reserved /api|/oauth|/.well-known
|
|
4271
|
+
// prefix. NO chrome injection — the app owns its whole page. In redirect
|
|
4272
|
+
// mode this branch is inert and the tail stays byte-identical to before.
|
|
4273
|
+
if (req.method === "GET" && resolveRootMode(getDb ? getDb() : null) === "serve-app") {
|
|
4274
|
+
const dist = resolveAppDist();
|
|
4275
|
+
if (dist) {
|
|
4276
|
+
const served = serveAppAtRoot(dist, req, pathname);
|
|
4277
|
+
if (served) return served;
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
|
|
4018
4281
|
// Branded fall-through 404 (closes hub#392) — the operator who mistyped
|
|
4019
4282
|
// a URL sees a clear "not found" page with a path back home, not the
|
|
4020
4283
|
// browser's default empty-body chrome. Only HTML clients get the
|