@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.
Files changed (83) hide show
  1. package/README.md +7 -7
  2. package/package.json +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-command.test.ts +70 -2
  25. package/src/__tests__/hub-server.test.ts +322 -1
  26. package/src/__tests__/hub-settings.test.ts +110 -6
  27. package/src/__tests__/hub.test.ts +29 -0
  28. package/src/__tests__/install.test.ts +359 -5
  29. package/src/__tests__/migrate.test.ts +3 -1
  30. package/src/__tests__/notes-serve.test.ts +216 -0
  31. package/src/__tests__/oauth-handlers.test.ts +19 -8
  32. package/src/__tests__/operator-token.test.ts +1 -2
  33. package/src/__tests__/port-assign.test.ts +37 -17
  34. package/src/__tests__/root-serve.test.ts +139 -0
  35. package/src/__tests__/scope-explanations.test.ts +22 -2
  36. package/src/__tests__/serve-boot.test.ts +25 -36
  37. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  38. package/src/__tests__/services-manifest.test.ts +372 -132
  39. package/src/__tests__/sessions.test.ts +75 -28
  40. package/src/__tests__/setup-wizard.test.ts +7 -10
  41. package/src/__tests__/setup.test.ts +13 -14
  42. package/src/__tests__/status.test.ts +0 -5
  43. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  44. package/src/account-api.ts +677 -0
  45. package/src/account-session.ts +132 -0
  46. package/src/account-token.ts +200 -0
  47. package/src/admin-connections.ts +4 -2
  48. package/src/admin-lock.ts +1 -2
  49. package/src/admin-module-token.ts +13 -8
  50. package/src/admin-surfaces.ts +2 -1
  51. package/src/api-hub-upgrade.ts +2 -1
  52. package/src/api-invites.ts +19 -0
  53. package/src/api-mint-token.ts +2 -1
  54. package/src/api-modules-ops.ts +2 -1
  55. package/src/api-modules.ts +10 -8
  56. package/src/api-revoke-token.ts +2 -1
  57. package/src/api-settings-hub-origin.ts +2 -1
  58. package/src/api-settings-root-redirect.ts +97 -20
  59. package/src/api-tokens.ts +2 -1
  60. package/src/audience-gate.ts +2 -1
  61. package/src/chrome-strip.ts +16 -4
  62. package/src/commands/hub.ts +104 -0
  63. package/src/commands/install.ts +97 -8
  64. package/src/commands/migrate.ts +5 -1
  65. package/src/commands/setup.ts +9 -6
  66. package/src/help.ts +6 -6
  67. package/src/hub-server.ts +318 -55
  68. package/src/hub-settings.ts +162 -1
  69. package/src/hub.ts +64 -31
  70. package/src/invites.ts +42 -0
  71. package/src/module-ops-client.ts +2 -1
  72. package/src/notes-serve.ts +73 -31
  73. package/src/oauth-handlers.ts +1 -11
  74. package/src/operator-token.ts +0 -1
  75. package/src/origin-check.ts +2 -2
  76. package/src/root-serve.ts +156 -0
  77. package/src/scope-explanations.ts +35 -5
  78. package/src/service-spec.ts +129 -74
  79. package/src/services-manifest.ts +112 -52
  80. package/src/sessions.ts +66 -30
  81. package/src/surface-notes-alias.ts +126 -0
  82. package/src/__tests__/admin-agent-token.test.ts +0 -173
  83. 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
- * /, /hub.html hub.html (the discovery page)
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 and /logout stay reachable
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
- * anything else → 404
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
- import { handleAgentToken } from "./admin-agent-token.ts";
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, exactly like
3026
- // /admin/agent-token + /admin/vault-admin-token. Gated on
3027
- // self-registration (services.json row + readable module.json) with the
3028
- // bootstrap registries as a fallback (boundary C5) — a genuinely
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. The agent module's own admin
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>` (or `/admin/agent-token`).
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(readManifestLenient(manifestPath).services, pathname);
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