@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.
- 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 +12 -0
- 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-server.test.ts +37 -0
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +279 -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__/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 +2 -1
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/install.ts +86 -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 +247 -52
- package/src/hub-settings.ts +25 -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/scope-explanations.ts +35 -5
- package/src/service-spec.ts +128 -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,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
|
|
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
|
-
|
|
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,
|
|
3026
|
-
// /admin
|
|
3027
|
-
//
|
|
3028
|
-
//
|
|
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.
|
|
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
|
|
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(
|
|
4179
|
+
const uiMatch = resolveUiMount(manifestServices, pathname);
|
|
3985
4180
|
if (uiMatch) {
|
|
3986
4181
|
const cloaked =
|
|
3987
4182
|
effectivePublicExposure(uiMatch.entry) === "loopback" &&
|
package/src/hub-settings.ts
CHANGED
|
@@ -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
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
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
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* the
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
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
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
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
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
+
}
|
package/src/module-ops-client.ts
CHANGED
|
@@ -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
|
|
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
|