@openparachute/hub 0.7.6 → 0.7.7-rc.12

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 (78) 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 +12 -0
  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-server.test.ts +37 -0
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +279 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/scope-explanations.test.ts +22 -2
  33. package/src/__tests__/serve-boot.test.ts +25 -36
  34. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  35. package/src/__tests__/services-manifest.test.ts +372 -132
  36. package/src/__tests__/sessions.test.ts +75 -28
  37. package/src/__tests__/setup-wizard.test.ts +7 -10
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/account-api.ts +677 -0
  42. package/src/account-session.ts +132 -0
  43. package/src/account-token.ts +200 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-invites.ts +19 -0
  50. package/src/api-mint-token.ts +2 -1
  51. package/src/api-modules-ops.ts +2 -1
  52. package/src/api-modules.ts +10 -8
  53. package/src/api-revoke-token.ts +2 -1
  54. package/src/api-settings-hub-origin.ts +2 -1
  55. package/src/api-settings-root-redirect.ts +2 -1
  56. package/src/api-tokens.ts +2 -1
  57. package/src/audience-gate.ts +2 -1
  58. package/src/chrome-strip.ts +16 -4
  59. package/src/commands/install.ts +86 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/help.ts +6 -6
  63. package/src/hub-server.ts +247 -52
  64. package/src/hub-settings.ts +25 -1
  65. package/src/hub.ts +64 -31
  66. package/src/invites.ts +42 -0
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/notes-serve.ts +73 -31
  69. package/src/oauth-handlers.ts +1 -11
  70. package/src/operator-token.ts +0 -1
  71. package/src/origin-check.ts +2 -2
  72. package/src/scope-explanations.ts +35 -5
  73. package/src/service-spec.ts +128 -74
  74. package/src/services-manifest.ts +112 -52
  75. package/src/sessions.ts +66 -30
  76. package/src/surface-notes-alias.ts +126 -0
  77. package/src/__tests__/admin-agent-token.test.ts +0 -173
  78. package/src/admin-agent-token.ts +0 -147
package/src/hub-server.ts CHANGED
@@ -24,15 +24,25 @@
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
  * /, /hub.html → hub.html (the discovery page)
33
42
  * /.well-known/parachute.json → built dynamically from services.json
34
43
  * /.well-known/parachute-revocation.json → revoked-jti list (hub#212 Phase 1)
35
44
  * /.well-known/jwks.json → JWKS from hub.db
45
+ * /.well-known/parachute-account → account-door capabilities descriptor (public, H2)
36
46
  * /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints)
37
47
  *
38
48
  * # OAuth issuer.
@@ -49,10 +59,15 @@
49
59
  * tokens/grants/user_vaults/invites/
50
60
  * connections sweep, CLI remove,
51
61
  * supervisor restart)
62
+ * # /account/* — the Bearer-gated account-door REST facade (Phase 2, H2).
63
+ * # account:self:admin OR parachute:host:admin (mutations); +:read (reads).
64
+ * /account (GET) → account bootstrap {id,email,door} (Bearer only)
65
+ * /account/vaults (GET/POST) → list / create vault (create returns vault_token)
66
+ * /account/vaults/<name> (DELETE) → teardown (wraps /vaults/<name> cascade)
67
+ * /account/vaults/<name>/token (POST) → per-vault scoped token mint
68
+ * /account/vaults/<name>/caps (GET/PUT) → read / set the storage cap
52
69
  * /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
53
70
  * /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
71
  * /admin/module-token/<short> (GET) → generic module config-UI bearer mint <short>:admin (cookie-gated)
57
72
  * /api/connections/catalog (GET) → events/actions across installed modules (cookie-gated)
58
73
  * /admin/connections (POST/GET) → connection provision/list (cookie-gated; POST CSRF-belted)
@@ -106,6 +121,13 @@
106
121
  * (hub#473; reached after a correct
107
122
  * password for a 2FA-enrolled user)
108
123
  * /logout (POST) → end admin session
124
+ * /account/session (GET) → same-origin boot oracle {signed_in,
125
+ * csrf,...} (hub-parity P1); cookie-gated,
126
+ * NOT Bearer — mounted ABOVE the
127
+ * force-change-password gate below (a pure
128
+ * state oracle, so a pre-rotation user can
129
+ * still read it to drive the rotation
130
+ * ceremony itself)
109
131
  * /account/change-password (GET + POST) → user self-service change-password
110
132
  * (force-redirect target for users
111
133
  * with password_changed=false; also
@@ -114,8 +136,16 @@
114
136
  * + the per-vault proxy is hard-gated
115
137
  * per-request on password_changed===true
116
138
  * (forceChangePasswordGate); only this
117
- * route and /logout stay reachable
139
+ * route, /logout, and /account/session
140
+ * (P1, also above the gate) stay reachable
118
141
  * pre-rotation.
142
+ * /account/token (POST) → cookie→account-bearer mint (App
143
+ * campaign Phase 2 H1): session cookie →
144
+ * short-lived JWT carrying the account
145
+ * superset {account:self:admin,
146
+ * parachute:host:admin, parachute:host:auth}
147
+ * aud="account"; first-admin-gated,
148
+ * CSRF + same-origin belt; stateless
119
149
  * /account/2fa (GET + POST) → user self-service 2FA enroll/disenroll
120
150
  * (hub#473; QR + backup codes)
121
151
  * /account/vault-token/<name> (POST) → friend mints a scoped
@@ -163,7 +193,21 @@ import { existsSync } from "node:fs";
163
193
  import { dirname, join, resolve } from "node:path";
164
194
  import { fileURLToPath } from "node:url";
165
195
  import pkg from "../package.json" with { type: "json" };
196
+ import {
197
+ ACCOUNT_MUTATION_SCOPES,
198
+ type AccountApiDeps,
199
+ handleAccountCapabilities,
200
+ handleAccountCreateVault,
201
+ handleAccountGetVaultCaps,
202
+ handleAccountListVaults,
203
+ handleAccountMintVaultToken,
204
+ handleAccountRoot,
205
+ handleAccountSetVaultCaps,
206
+ requireAnyScope,
207
+ } from "./account-api.ts";
208
+ import { type AccountSessionDeps, handleAccountSession } from "./account-session.ts";
166
209
  import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
210
+ import { handleAccountToken } from "./account-token.ts";
167
211
  import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
168
212
  import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
169
213
  import {
@@ -171,7 +215,8 @@ import {
171
215
  handleAgentGrants,
172
216
  handleOAuthGrantCallback,
173
217
  } from "./admin-agent-grants.ts";
174
- import { handleAgentToken } from "./admin-agent-token.ts";
218
+
219
+ import { adminAuthErrorResponse } from "./admin-auth.ts";
175
220
  import { handleApproveClient, handleDeleteClient, handleGetClient } from "./admin-clients.ts";
176
221
  import {
177
222
  type ConnectionsDeps,
@@ -295,6 +340,7 @@ import {
295
340
  } from "./setup-wizard.ts";
296
341
  import { getAllPublicKeys } from "./signing-keys.ts";
297
342
  import type { Supervisor } from "./supervisor.ts";
343
+ import { isSurfaceNotesPath, maybeRedirectSurfaceNotes } from "./surface-notes-alias.ts";
298
344
  import { handleTwoFactorGet, handleTwoFactorPost } from "./two-factor-handlers.ts";
299
345
  import { getUserById, userCount } from "./users.ts";
300
346
  import { sanitizePublicOrigin } from "./vault-hub-origin-env.ts";
@@ -2312,27 +2358,6 @@ export function hubFetch(
2312
2358
  }
2313
2359
  }
2314
2360
 
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
2361
  // CORS preflight for the public OAuth + discovery surface. Browsers
2337
2362
  // issue OPTIONS before any non-simple cross-origin request — third-party
2338
2363
  // SPAs hitting `/oauth/register` (RFC 7591 DCR), `/oauth/token`,
@@ -2812,6 +2837,30 @@ export function hubFetch(
2812
2837
  return new Response(res.body, { status: res.status, headers: merged });
2813
2838
  }
2814
2839
 
2840
+ // GET /.well-known/parachute-account — the account-door capabilities
2841
+ // descriptor (Phase 2, H2). Public, no auth, wildcard CORS (the app pulls
2842
+ // it cross-origin to render honestly per door: billing:false + plans:[] on
2843
+ // self-host means no upgrade UI). See account-api.handleAccountCapabilities.
2844
+ if (pathname === "/.well-known/parachute-account") {
2845
+ const corsHeaders = {
2846
+ "access-control-allow-origin": "*",
2847
+ "access-control-allow-methods": "GET, OPTIONS",
2848
+ };
2849
+ if (req.method === "OPTIONS") {
2850
+ return new Response(null, { status: 204, headers: corsHeaders });
2851
+ }
2852
+ if (!getDb) {
2853
+ return new Response('{"error":"account descriptor unavailable: db not configured"}', {
2854
+ status: 503,
2855
+ headers: { "content-type": "application/json", ...corsHeaders },
2856
+ });
2857
+ }
2858
+ const res = handleAccountCapabilities(req, { db: getDb(), issuer: oauthDeps(req).issuer });
2859
+ const merged = new Headers(res.headers);
2860
+ for (const [k, v] of Object.entries(corsHeaders)) merged.set(k, v);
2861
+ return new Response(res.body, { status: res.status, headers: merged });
2862
+ }
2863
+
2815
2864
  // OAuth surface — every handler return is wrapped in `applyCorsHeaders`
2816
2865
  // so third-party SPAs can fetch these endpoints cross-origin (the entire
2817
2866
  // point of OAuth DCR: arbitrary SPAs register → authorize → exchange
@@ -3001,31 +3050,13 @@ export function hubFetch(
3001
3050
  });
3002
3051
  }
3003
3052
 
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
3053
  // Generic per-module config-UI bearer mint (2026-06-09 modular-UI
3023
3054
  // architecture, P3). `<short>:admin` for any single-audience module —
3024
3055
  // 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
3056
+ // endpoints. Cookie-gated to the first-admin operator, matching the
3057
+ // host/vault admin-token endpoints. Gated on self-registration
3058
+ // (services.json row + readable module.json) with the bootstrap
3059
+ // registries as a fallback (boundary C5) — a genuinely
3029
3060
  // third-party module mints here with zero hub code changes. Vault is
3030
3061
  // per-instance and routed to /admin/vault-admin-token/<name> instead.
3031
3062
  if (pathname.startsWith("/admin/module-token/")) {
@@ -3043,9 +3074,7 @@ export function hubFetch(
3043
3074
 
3044
3075
  // Note: the legacy `/admin/channels` bespoke vault-channel orchestration
3045
3076
  // 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`.
3077
+ // superseded by the general engine below.
3049
3078
 
3050
3079
  // Connections — the GENERAL module event→action engine (2026-06-09
3051
3080
  // modular-UI architecture, P5). `/api/connections/catalog` (GET) returns
@@ -3348,7 +3377,7 @@ export function hubFetch(
3348
3377
  // the 2026-06-09 modular-UI architecture P3. Config is module-owned +
3349
3378
  // hub-framed now: the Modules page "Configure" action opens the module's
3350
3379
  // OWN config UI (`configUiUrl`), which mints its admin Bearer from the
3351
- // cookie-gated `/admin/module-token/<short>` (or `/admin/agent-token`).
3380
+ // cookie-gated `/admin/module-token/<short>`.
3352
3381
 
3353
3382
  // Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
3354
3383
  if (pathname.startsWith("/api/modules/")) {
@@ -3699,6 +3728,20 @@ export function hubFetch(
3699
3728
  return new Response("method not allowed", { status: 405 });
3700
3729
  }
3701
3730
 
3731
+ // /account/session — the same-origin boot oracle (hub-parity P1). A
3732
+ // PURE STATE ORACLE, not a mutating surface, so it is mounted ABOVE the
3733
+ // force-change-password gate immediately below: a pre-rotation user
3734
+ // must still be able to read `{signed_in, csrf}` to drive the rotation
3735
+ // ceremony itself, rather than get walled off before it can even ask.
3736
+ // `/account/token` (below the gate) stays gated — a pre-rotation user
3737
+ // hits its 403 `force_change_password` there instead. See
3738
+ // `account-session.ts`.
3739
+ if (pathname === "/account/session") {
3740
+ if (!getDb) return dbNotConfigured();
3741
+ const sessionDeps: AccountSessionDeps = { db: getDb() };
3742
+ return handleAccountSession(req, sessionDeps);
3743
+ }
3744
+
3702
3745
  // Per-request force-change-password gate (P0-1 / hub#469). CHOKE POINT 1:
3703
3746
  // every `/account/*` route BELOW this line is gated. `/logout` and
3704
3747
  // `/account/change-password` (the rotation/exit path) ran above and already
@@ -3721,6 +3764,27 @@ export function hubFetch(
3721
3764
  if (gate) return gate;
3722
3765
  }
3723
3766
 
3767
+ // /account/token — cookie→account-bearer mint (Parachute App campaign,
3768
+ // Phase 2 H1). POST-only. Exchanges the `parachute_hub_session` cookie for
3769
+ // a short-lived JWT carrying the account superset
3770
+ // {account:self:admin, parachute:host:admin, parachute:host:auth} with
3771
+ // aud="account" — the same-origin app's bootstrap into holding the account
3772
+ // credential (it never touches /oauth/authorize, so never hits consent).
3773
+ // First-admin-gated (account ≡ box), CSRF double-submit + the same-origin
3774
+ // Origin belt below. Stateless (no tokens row); revocation = logout.
3775
+ if (pathname === "/account/token") {
3776
+ if (!getDb) return dbNotConfigured();
3777
+ // Origin belt (hub#632 posture): a cookie-authed POST mint gets the
3778
+ // strict same-origin check as defense-in-depth over the body's CSRF
3779
+ // token. A Bearer-authed caller (none expected here) would bypass the
3780
+ // belt but still fail the cookie-only session gate inside the handler.
3781
+ {
3782
+ const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
3783
+ if (rejected) return rejected;
3784
+ }
3785
+ return handleAccountToken(req, { db: getDb(), issuer: oauthDeps(req).issuer });
3786
+ }
3787
+
3724
3788
  // /account/2fa — user self-service TOTP 2FA enroll / disenroll (hub#473).
3725
3789
  // Both GET (render state) and POST (start/confirm/disable) require an
3726
3790
  // active session; the handler does the session check + 302 to /login when
@@ -3777,6 +3841,115 @@ export function hubFetch(
3777
3841
  });
3778
3842
  }
3779
3843
 
3844
+ // ====================================================================
3845
+ // /account/* — the Bearer-gated account-door REST facade (Phase 2, H2).
3846
+ // The normalized account API both doors mount (the hosted cloud door
3847
+ // mounts the twin). Bearer-authed, machine-to-machine — no session
3848
+ // cookie — so these precede the session-cookie `/account/vault-token/`
3849
+ // + `/account/` HTML routes below. Mutations accept account:self:admin
3850
+ // OR parachute:host:admin; reads also accept account:self:read. See
3851
+ // `account-api.ts`.
3852
+ // ====================================================================
3853
+ if (pathname === "/account/vaults") {
3854
+ if (!getDb) return dbNotConfigured();
3855
+ const accountDeps: AccountApiDeps = {
3856
+ db: getDb(),
3857
+ issuer: oauthDeps(req).issuer,
3858
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3859
+ manifestPath,
3860
+ };
3861
+ if (req.method === "GET") return handleAccountListVaults(req, accountDeps);
3862
+ if (req.method === "POST") return handleAccountCreateVault(req, accountDeps);
3863
+ return new Response("method not allowed", { status: 405 });
3864
+ }
3865
+ if (pathname.startsWith("/account/vaults/")) {
3866
+ if (!getDb) return dbNotConfigured();
3867
+ const rest = pathname.slice("/account/vaults/".length);
3868
+ const accountDeps: AccountApiDeps = {
3869
+ db: getDb(),
3870
+ issuer: oauthDeps(req).issuer,
3871
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3872
+ manifestPath,
3873
+ };
3874
+ // POST /account/vaults/<name>/token — per-vault scoped token mint.
3875
+ if (rest.endsWith("/token")) {
3876
+ const name = decodeURIComponent(rest.slice(0, -"/token".length));
3877
+ return handleAccountMintVaultToken(req, name, accountDeps);
3878
+ }
3879
+ // GET/PUT /account/vaults/<name>/caps — read / set the storage cap.
3880
+ if (rest.endsWith("/caps")) {
3881
+ const name = decodeURIComponent(rest.slice(0, -"/caps".length));
3882
+ if (req.method === "GET") return handleAccountGetVaultCaps(req, name, accountDeps);
3883
+ if (req.method === "PUT") return handleAccountSetVaultCaps(req, name, accountDeps);
3884
+ return new Response("method not allowed", { status: 405 });
3885
+ }
3886
+ // DELETE /account/vaults/<name> — teardown via the full identity
3887
+ // cascade. Gate with the account-scope set here (accept account:self:admin
3888
+ // OR parachute:host:admin), then delegate to `handleDeleteVault`, which
3889
+ // re-gates parachute:host:admin — the superset account token carries it,
3890
+ // so this is belt-and-suspenders, not a second authorization.
3891
+ if (req.method === "DELETE") {
3892
+ const name = decodeURIComponent(rest);
3893
+ if (!name || name.includes("/")) return new Response("not found", { status: 404 });
3894
+ try {
3895
+ await requireAnyScope(
3896
+ getDb(),
3897
+ req,
3898
+ ACCOUNT_MUTATION_SCOPES,
3899
+ oauthDeps(req).hubBoundOrigins(),
3900
+ );
3901
+ } catch (err) {
3902
+ return adminAuthErrorResponse(err);
3903
+ }
3904
+ const services = readManifestLenient(manifestPath).services;
3905
+ const agentEntry = findServiceByShort(services, "agent");
3906
+ const agentOrigin = agentEntry ? `http://127.0.0.1:${agentEntry.port}` : null;
3907
+ const resolveVaultOrigin = (vaultName: string): string | null => {
3908
+ const match = findVaultUpstream(
3909
+ readManifestLenient(manifestPath).services,
3910
+ `/vault/${vaultName}`,
3911
+ );
3912
+ return match ? `http://127.0.0.1:${match.port}` : null;
3913
+ };
3914
+ const supervisor = deps?.supervisor;
3915
+ return handleDeleteVault(req, name, {
3916
+ db: getDb(),
3917
+ issuer: oauthDeps(req).issuer,
3918
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3919
+ manifestPath,
3920
+ connectionsStorePath:
3921
+ deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
3922
+ agentOrigin,
3923
+ resolveVaultOrigin,
3924
+ resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
3925
+ ...(supervisor
3926
+ ? {
3927
+ restartVaultModule: async () => {
3928
+ await supervisor.restart("vault");
3929
+ },
3930
+ }
3931
+ : {}),
3932
+ });
3933
+ }
3934
+ return new Response("method not allowed", { status: 405 });
3935
+ }
3936
+ // GET /account (Bearer) — account bootstrap {id,email,door} (the
3937
+ // door-contract's AccountBootstrap). Only intercepts when an
3938
+ // Authorization header is present, so the cookie-authed HTML account
3939
+ // home at `/account`/`/account/` below stays unaffected.
3940
+ if (
3941
+ (pathname === "/account" || pathname === "/account/") &&
3942
+ req.headers.get("authorization")
3943
+ ) {
3944
+ if (!getDb) return dbNotConfigured();
3945
+ return handleAccountRoot(req, {
3946
+ db: getDb(),
3947
+ issuer: oauthDeps(req).issuer,
3948
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3949
+ manifestPath,
3950
+ });
3951
+ }
3952
+
3780
3953
  // /account/vault-token/<name> — friend-facing scoped vault token mint.
3781
3954
  // POST-only, session-gated, assignment-capped: a non-admin friend mints a
3782
3955
  // `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
@@ -3967,6 +4140,28 @@ export function hubFetch(
3967
4140
  // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
3968
4141
  // `/api/*` are excluded by ordering, not by an explicit denylist (#182).
3969
4142
  //
4143
+ // W2-12 — conditional `/surface/notes/*` → `/surface/parachute/*`
4144
+ // alias, the "never 404 an old bookmark after an in-place upgrade"
4145
+ // safety net for the app's notes→parachute surface-identity rename.
4146
+ // Fires ONLY when the manifest's `uis{}` sub-units show the legacy
4147
+ // `/surface/notes` mount gone AND a `/surface/parachute` mount
4148
+ // present (see surface-notes-alias.ts for the full condition +
4149
+ // inert-today proof). On every existing notes-ui install the
4150
+ // condition fails and dispatch falls through to the generic proxy
4151
+ // below unchanged. Reads the same lenient manifest source
4152
+ // `resolveUiMount` consumes — hoisted to one read shared by both.
4153
+ const manifestServices = readManifestLenient(manifestPath).services;
4154
+ if (isSurfaceNotesPath(pathname)) {
4155
+ const aliasTarget = maybeRedirectSurfaceNotes(pathname, url.search, manifestServices);
4156
+ if (aliasTarget !== undefined) {
4157
+ logNotesRedirect(pathname, aliasTarget);
4158
+ return new Response("", {
4159
+ status: 301,
4160
+ headers: { location: aliasTarget },
4161
+ });
4162
+ }
4163
+ }
4164
+
3970
4165
  // H3 — per-UI audience gate. When the path falls under a declared UI
3971
4166
  // sub-unit (a `uis{}` entry on the matched service row — surface-hosted
3972
4167
  // UI mounts like /surface/<name>/*), the sub-unit's audience is
@@ -3981,7 +4176,7 @@ export function hubFetch(
3981
4176
  // which also means a 'surface'/'public' mount on a loopback-only row
3982
4177
  // stays unreachable from tailnet/funnel: exposure is orthogonal to
3983
4178
  // audience.
3984
- const uiMatch = resolveUiMount(readManifestLenient(manifestPath).services, pathname);
4179
+ const uiMatch = resolveUiMount(manifestServices, pathname);
3985
4180
  if (uiMatch) {
3986
4181
  const cloaked =
3987
4182
  effectivePublicExposure(uiMatch.entry) === "loopback" &&
@@ -92,6 +92,16 @@ export type HubSettingKey =
92
92
  // the deprecation window. Stored as the literal string "true" /
93
93
  // "false"; any other value parses as "redirect on" (the migration
94
94
  // default — operators must opt out, not opt in).
95
+ //
96
+ // Mount vocabulary post-W2-12: `/surface/notes` is the LEGACY notes-ui
97
+ // mount (what existing installs serve — this redirect's target);
98
+ // `/surface/parachute` is the app's surface mount under its renamed
99
+ // identity. A separate CONDITIONAL alias (surface-notes-alias.ts, no
100
+ // hub_settings row — it keys off the manifest's uis{} mounts) bridges
101
+ // `/surface/notes/*` → `/surface/parachute/*` only on installs where
102
+ // the legacy mount is gone and the new one exists, so the two
103
+ // redirects compose for post-rename installs: /notes/x →
104
+ // /surface/notes/x → /surface/parachute/x.
95
105
  | "notes_redirect_disabled"
96
106
  // Admin-UI screen-lock PIN (hub admin-lock feature). The argon2id hash of
97
107
  // the operator's lock PIN. Absent row = lock feature OFF (today's behavior
@@ -122,7 +132,21 @@ export type HubSettingKey =
122
132
  // wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
123
133
  // not-yet-set-up hub still lands on setup, not a surface that can't work
124
134
  // yet.
125
- | "root_redirect";
135
+ | "root_redirect"
136
+ // hub-parity P2 (Q2): the RAW token of the newest PUBLIC (multi-use,
137
+ // `max_uses > 1`) invite — persisted so `GET /.well-known/parachute-account`
138
+ // can advertise `signup_path` without being able to reconstruct it from the
139
+ // `invites` table (which stores only `sha256(token)`, see invites.ts's
140
+ // module doc). Written by `api-invites.ts`'s `handleCreateInvite` right
141
+ // after a successful multi-use `issueInvite`; read (and lazily cleared once
142
+ // stale) by `invites.ts`'s `activePublicSignupPath`.
143
+ //
144
+ // This does NOT weaken the hash-only posture of single-use friend invites
145
+ // (`max_uses === 1`, the default) — those NEVER write this row. A
146
+ // multi-use link is, by construction, the operator's deliberately PUBLIC
147
+ // signup page (minted to broadcast), so persisting its raw token is no
148
+ // more sensitive than the link itself already being handed out widely.
149
+ | "public_signup_token";
126
150
 
127
151
  export type SetupExposeMode = "localhost" | "tailnet" | "public";
128
152
 
package/src/hub.ts CHANGED
@@ -541,43 +541,76 @@ const DISCOVERY_SCRIPT = `<script>
541
541
  /**
542
542
  * Render the "Get started" section (hub#342) above the Services grid.
543
543
  *
544
- * One hardcoded target, conditional on its prerequisite being installed:
545
- * - "Open Notes" → /surface/notes/ (requires parachute-surface installed;
546
- * Surface auto-bootstraps Notes-as-UI per parachute-surface §17, so the
547
- * mere presence of Surface means /surface/notes/ is live)
544
+ * Driven by the uis[] sub-unit arrays the well-known doc already carries
545
+ * (W2-12 — retires the hardcoded "Open Notes" → /surface/notes/ tile):
546
+ * one tile per ACTIVE uis[] entry across every services[] row, each
547
+ * carrying its own displayName + tagline + mount path. Under the app's
548
+ * notes→parachute surface-identity rename this stays correct whichever
549
+ * mount an install serves — an existing notes-ui install renders its
550
+ * "notes" entry at /surface/notes, a post-rename install renders its
551
+ * "parachute" entry at /surface/parachute, no hub-side name knowledge
552
+ * required. Entries named "parachute" / "notes" sort first (they're the
553
+ * end-user "browse my content" CTA — the audience that needs the
554
+ * strongest above-the-fold prompt); the rest follow alphabetically.
548
555
  *
549
- * The earlier "Browse Vault" tile retired in workstream C (2026-05-25)
550
- * once vault declared uiUrl in its module.json (per patterns#96). With
551
- * the multi-instance prefix lifted into buildWellKnown, every vault
552
- * instance now renders its own tile in the Services grid below — one
553
- * per instance, with the actual instance name in the discovery doc
554
- * rather than a hub-side guess at "first vault."
555
- *
556
- * Notes stays here because Notes is the end-user CTA (the audience
557
- * that needs the strongest above-the-fold prompt); the Services grid
558
- * positions it equally with admin tiles, which underweights the
559
- * "browse my content" path for a returning operator.
556
+ * Legacy fallback: a surface-host row that predates uis{}
557
+ * self-registration carries no uis[] even while it serves notes-ui at
558
+ * /surface/notes (the hub proxies /surface/* blind mounts are
559
+ * surface-host's on-disk state, not the manifest's). When no uis[]
560
+ * entries exist anywhere but parachute-surface IS installed, keep
561
+ * rendering the pre-W2-12 "Open Notes" tile so those installs' discovery
562
+ * page is untouched. The fallback dies naturally the first time the
563
+ * surface-host re-registers with uis{}.
560
564
  *
561
- * If the prerequisite is not met (fresh install pre-wizard, no app)
562
- * the section stays hidden. The hardcoded shape mirrors the wizard's
563
- * own done-screen "Start using your vault" tile — same architectural
564
- * shape (single obvious entry point) at a different surface.
565
+ * The earlier "Browse Vault" tile retired in workstream C (2026-05-25)
566
+ * once vault declared uiUrl in its module.json (per patterns#96) — vault
567
+ * instances render in the Services grid below.
565
568
  *
566
- * Not driven by /api/modules because discovery is unauth the
567
- * services list from /.well-known/parachute.json is sufficient
568
- * (it carries the same install-detection signal we need, no
569
- * Bearer required).
569
+ * If nothing qualifies (fresh install pre-wizard, no app) the section
570
+ * stays hidden. Not driven by /api/modules because discovery is unauth —
571
+ * the services list from /.well-known/parachute.json is sufficient (it
572
+ * carries the uis[] fan-out, no Bearer required).
570
573
  */
571
574
  function renderGetStarted(services) {
572
575
  if (!getStartedGrid || !getStartedSection) return;
573
- const tiles = [];
574
- const hasSurface = services.some((s) => s && s.name === 'parachute-surface');
575
- if (hasSurface) {
576
- tiles.push({
577
- title: 'Open Notes',
578
- desc: 'Browse + capture in the Notes app reads from your vault.',
579
- href: '/surface/notes/',
580
- });
576
+ const uiEntries = [];
577
+ const seenPaths = new Set();
578
+ for (const svc of services) {
579
+ if (!svc || !Array.isArray(svc.uis)) continue;
580
+ for (const ui of svc.uis) {
581
+ if (!ui || typeof ui.path !== 'string' || ui.path === '') continue;
582
+ // Absent status means active (services-manifest.ts UiSubUnitStatus).
583
+ if (ui.status && ui.status !== 'active') continue;
584
+ // Multi-path parent rows repeat the same uis[] on every path —
585
+ // de-duplicate by mount (well-known.ts's documented consumer duty).
586
+ if (seenPaths.has(ui.path)) continue;
587
+ seenPaths.add(ui.path);
588
+ uiEntries.push(ui);
589
+ }
590
+ }
591
+ const rank = (ui) => (ui.name === 'parachute' ? 0 : ui.name === 'notes' ? 1 : 2);
592
+ uiEntries.sort(
593
+ (a, b) =>
594
+ rank(a) - rank(b) ||
595
+ (a.displayName || a.name || '').localeCompare(b.displayName || b.name || ''),
596
+ );
597
+ const tiles = uiEntries.map((ui) => ({
598
+ title: ui.displayName || ui.name || ui.path,
599
+ desc: ui.tagline || '',
600
+ // Trailing slash keeps relative asset URLs resolving under the mount
601
+ // (same reason the retired hardcode pointed at /surface/notes/).
602
+ href: ui.path.endsWith('/') ? ui.path : ui.path + '/',
603
+ }));
604
+ if (tiles.length === 0) {
605
+ // Legacy fallback (see docstring): pre-uis surface-host installs.
606
+ const hasSurface = services.some((s) => s && s.name === 'parachute-surface');
607
+ if (hasSurface) {
608
+ tiles.push({
609
+ title: 'Open Notes',
610
+ desc: 'Browse + capture in the Notes app — reads from your vault.',
611
+ href: '/surface/notes/',
612
+ });
613
+ }
581
614
  }
582
615
  if (tiles.length === 0) {
583
616
  getStartedSection.setAttribute('hidden', '');
package/src/invites.ts CHANGED
@@ -57,6 +57,7 @@
57
57
  */
58
58
  import type { Database } from "bun:sqlite";
59
59
  import { createHash, randomBytes } from "node:crypto";
60
+ import { deleteSetting, getSetting } from "./hub-settings.ts";
60
61
 
61
62
  /** Default invite lifetime — long enough to deliver out-of-band (no email), short enough to bound a leaked link. */
62
63
  export const DEFAULT_INVITE_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -502,3 +503,44 @@ export function revokeInvitesForVault(
502
503
  .run(now.toISOString(), vaultName);
503
504
  return Number(res.changes);
504
505
  }
506
+
507
+ /**
508
+ * Q2 (hub-parity P2) — the account descriptor's conditional `signup_path`.
509
+ * The hub cannot derive a redemption URL from the `invites` table (only
510
+ * `sha256(token)` is stored, never the raw value — see the module doc), so
511
+ * this reads back the ONE raw token persisted at mint time for a
512
+ * deliberately PUBLIC (multi-use) invite (`api-invites.ts`'s
513
+ * `handleCreateInvite`, which writes `hub_settings.public_signup_token`
514
+ * after `issueInvite` when `maxUses > 1`) and re-validates it's still live.
515
+ *
516
+ * Returns `/account/setup/<token>` when the persisted invite is still
517
+ * `"pending"`, still multi-use (`maxUses > 1`), AND provisioning
518
+ * (`provisionVault` — each redeemer gets their own new vault); `null` on ANY
519
+ * miss (never set, redeemed, revoked, exhausted, expired, or a non-provisioning
520
+ * shared-vault link) — and lazily clears the stale setting in that case. No
521
+ * separate revoke/exhaust/expire hook is needed: `inviteStatus` already derives
522
+ * all four terminal states from the invite's own columns, so every miss flows
523
+ * through this one status check.
524
+ *
525
+ * The `maxUses > 1` AND `provisionVault` re-checks are defense-in-depth mirrors
526
+ * of `handleCreateInvite`'s write condition (which only persists for a
527
+ * multi-use provisioning link): even a hand-edited settings row pointing at a
528
+ * single-use OR a shared-vault (`provision_vault=false`) invite must never be
529
+ * advertised as public signup — a shared-vault link would leak team-vault write
530
+ * on the anonymous, wildcard-CORS descriptor.
531
+ */
532
+ export function activePublicSignupPath(db: Database, now: Date = new Date()): string | null {
533
+ const raw = getSetting(db, "public_signup_token");
534
+ if (!raw) return null;
535
+ const invite = findInviteByRawToken(db, raw);
536
+ if (
537
+ !invite ||
538
+ inviteStatus(invite, now) !== "pending" ||
539
+ invite.maxUses <= 1 ||
540
+ !invite.provisionVault
541
+ ) {
542
+ deleteSetting(db, "public_signup_token");
543
+ return null;
544
+ }
545
+ return `/account/setup/${encodeURIComponent(raw)}`;
546
+ }
@@ -376,7 +376,8 @@ export async function fetchModuleStates(deps: DriveModuleOpDeps): Promise<Module
376
376
  try {
377
377
  res = await doFetch(`${baseUrl}/api/modules`, {
378
378
  method: "GET",
379
- // `/api/modules` parses the scheme-cased `Bearer ` prefix; match it exactly.
379
+ // `/api/modules` parses the Bearer scheme case-insensitively (RFC 7235,
380
+ // V1.4/C1.3 parity); we still emit the canonical `Bearer ` prefix.
380
381
  headers: { authorization: `Bearer ${bearer}` },
381
382
  // Bound the read so a wedged hub handler degrades `status` rather than
382
383
  // hanging it. AbortSignal.timeout fires `AbortError` once the ceiling