@openparachute/hub 0.6.5-rc.8 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/origin-check.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* case. CSRF + session gates upstream are the real auth defense — this is
|
|
30
30
|
* a belt for browser flows where the legitimate Origin/Referer got dropped.
|
|
31
31
|
*/
|
|
32
|
+
import { parseSessionCookie } from "./sessions.ts";
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Build the bound-origin set from the hub's configuration. Returns
|
|
@@ -161,3 +162,111 @@ export function isSameOriginRequest(req: Request, boundOrigins: readonly string[
|
|
|
161
162
|
}
|
|
162
163
|
return false;
|
|
163
164
|
}
|
|
165
|
+
|
|
166
|
+
// ===========================================================================
|
|
167
|
+
// CSRF belt for cookie-gated /admin/* JSON mutation endpoints (hub#632,
|
|
168
|
+
// 2026-06-09 hub-module-boundary Phase C1)
|
|
169
|
+
// ===========================================================================
|
|
170
|
+
|
|
171
|
+
/** Methods the belt gates. GET/HEAD/OPTIONS are read-shaped and pass. */
|
|
172
|
+
const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Strict same-origin belt for cookie-authenticated JSON mutations — the
|
|
176
|
+
* defense-in-depth layer over `SameSite=Lax` on the hub's cookie-gated JSON
|
|
177
|
+
* mutation endpoints (hub#632; hub-module-boundary charter, trust statement).
|
|
178
|
+
*
|
|
179
|
+
* BELTED ENDPOINTS (the explicit enumeration — every cookie-gated JSON
|
|
180
|
+
* mutation under `/admin/*`; keep this list in sync with the dispatch in
|
|
181
|
+
* `hub-server.ts`):
|
|
182
|
+
*
|
|
183
|
+
* - `POST /admin/connections` + `DELETE /admin/connections/<id>` +
|
|
184
|
+
* `POST /admin/connections/<id>/approve`
|
|
185
|
+
* (connection provision/teardown/claim-approval — the seam's canonical
|
|
186
|
+
* consumers are channel's admin page and the hub SPA, both same-origin
|
|
187
|
+
* `fetch()` with `credentials: "include"`. The Bearer-authed
|
|
188
|
+
* `/<id>/renew` + `/<id>/claim` siblings pass the belt via the
|
|
189
|
+
* Authorization carve-out below.)
|
|
190
|
+
*
|
|
191
|
+
* (The legacy `POST/DELETE /admin/channels` pair was belted here until
|
|
192
|
+
* boundary D1 retired the endpoint — superseded by `/admin/connections`.)
|
|
193
|
+
*
|
|
194
|
+
* NOT belted, and why:
|
|
195
|
+
* - GET/HEAD/OPTIONS — read-shaped; the mint GETs
|
|
196
|
+
* (`/admin/host-admin-token`, `/admin/channel-token`,
|
|
197
|
+
* `/admin/module-token/<short>`, `/admin/vault-admin-token/<name>`)
|
|
198
|
+
* enforce GET-only with a 405 and their response bodies are unreadable
|
|
199
|
+
* cross-origin (no CORS on these routes).
|
|
200
|
+
* - Bearer-authed requests — a cross-site page cannot attach an
|
|
201
|
+
* `Authorization` header without a CORS preflight these routes never
|
|
202
|
+
* approve, so a request carrying one is not a browser CSRF. The
|
|
203
|
+
* downstream auth gate still validates the credential; this belt sits
|
|
204
|
+
* only on the cookie path.
|
|
205
|
+
* - Server-rendered form posts (`/login`, `/logout`, `/admin/setup/*`,
|
|
206
|
+
* `/oauth/authorize` approve) — already carry the double-submit CSRF
|
|
207
|
+
* token (`csrf.ts`) and/or `isSameOriginRequest`; not double-gated here.
|
|
208
|
+
* - `/vaults` (POST) + `/vaults/<name>` (DELETE) — Bearer
|
|
209
|
+
* `parachute:host:admin` gated, CSRF-immune.
|
|
210
|
+
* - `/api/*` — Bearer-gated. `/oauth/*` — spec-shaped, own protections.
|
|
211
|
+
*
|
|
212
|
+
* Stricter than `isSameOriginRequest` ON PURPOSE: no Referer fallback and —
|
|
213
|
+
* critically — no Host-header fallback. The Host fallback exists for
|
|
214
|
+
* server-rendered form flows where the double-submit token is the real
|
|
215
|
+
* defense and headers got proxy-stripped (#245). These JSON endpoints carry
|
|
216
|
+
* NO token, so a Host fallback would be a genuine bypass: an attacker form
|
|
217
|
+
* post under `referrer-policy: no-referrer` arrives with `Origin: null` and
|
|
218
|
+
* no Referer, and the Host header always names the target. Browsers send a
|
|
219
|
+
* real `Origin` on every non-GET `fetch()` (same-origin included — default
|
|
220
|
+
* `mode: "cors"` is exempt from referrer-policy Origin masking), so every
|
|
221
|
+
* legitimate consumer of these endpoints passes tier 1. `Origin: null` and
|
|
222
|
+
* malformed values are affirmative mismatches; a missing header on a
|
|
223
|
+
* cookie-authed mutation is rejected with its own error code
|
|
224
|
+
* (`csrf_origin_required`) naming the fix (send Origin, or use a Bearer).
|
|
225
|
+
*
|
|
226
|
+
* Returns `null` when the request may proceed, or a 403 JSON `Response`
|
|
227
|
+
* when the belt rejects. Rejections are logged (method, path, origin — no
|
|
228
|
+
* cookies, no tokens).
|
|
229
|
+
*/
|
|
230
|
+
export function assertSameOriginForCookieMutation(
|
|
231
|
+
req: Request,
|
|
232
|
+
boundOrigins: readonly string[],
|
|
233
|
+
): Response | null {
|
|
234
|
+
if (!MUTATION_METHODS.has(req.method.toUpperCase())) return null;
|
|
235
|
+
// Authorization present → not a browser CSRF (custom headers require a
|
|
236
|
+
// CORS preflight no /admin/* route approves). The endpoint's own gate
|
|
237
|
+
// validates the credential — API clients with Bearers never see the belt.
|
|
238
|
+
if (req.headers.get("authorization")) return null;
|
|
239
|
+
// No session cookie → no ambient credential to ride; the endpoint's own
|
|
240
|
+
// gate returns its usual 401. Presence is enough here — a forged/stale
|
|
241
|
+
// session id fails downstream regardless of what the belt decides.
|
|
242
|
+
if (!parseSessionCookie(req.headers.get("cookie"))) return null;
|
|
243
|
+
|
|
244
|
+
const pathname = new URL(req.url).pathname;
|
|
245
|
+
const origin = req.headers.get("origin");
|
|
246
|
+
if (!origin) {
|
|
247
|
+
console.warn(`csrf belt: rejected cookie-authed ${req.method} ${pathname} — no Origin header`);
|
|
248
|
+
return csrfBeltError(
|
|
249
|
+
"csrf_origin_required",
|
|
250
|
+
"cookie-authenticated mutations require an Origin header matching the hub origin; browser fetch() sends it automatically — non-browser clients should authenticate with a Bearer token instead",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (origin !== "null") {
|
|
254
|
+
try {
|
|
255
|
+
if (new Set(boundOrigins).has(new URL(origin).origin)) return null;
|
|
256
|
+
} catch {
|
|
257
|
+
// Malformed Origin — fall through to the mismatch rejection.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
console.warn(`csrf belt: rejected cookie-authed ${req.method} ${pathname} — origin=${origin}`);
|
|
261
|
+
return csrfBeltError(
|
|
262
|
+
"csrf_origin_mismatch",
|
|
263
|
+
"request Origin does not match this hub's origin — cross-site mutations are not allowed on cookie-authenticated endpoints",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function csrfBeltError(code: string, description: string): Response {
|
|
268
|
+
return new Response(JSON.stringify({ error: code, error_description: description }), {
|
|
269
|
+
status: 403,
|
|
270
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
271
|
+
});
|
|
272
|
+
}
|
package/src/proxy-error-ui.ts
CHANGED
|
@@ -47,7 +47,7 @@ export const TRANSIENT_MAX_ATTEMPTS = 5;
|
|
|
47
47
|
export const ADMIN_MODULES_URL = "/admin/modules";
|
|
48
48
|
|
|
49
49
|
/** JSON error vocabulary. Matches the snake_case shape used elsewhere
|
|
50
|
-
* in the hub's API (`api-modules
|
|
50
|
+
* in the hub's API (`api-modules.ts` etc.) for consistency. */
|
|
51
51
|
export const ERROR_TYPE_TRANSIENT = "upstream_starting";
|
|
52
52
|
export const ERROR_TYPE_PERSISTENT = "upstream_unreachable";
|
|
53
53
|
|
package/src/service-spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
-
import { type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
|
|
2
|
+
import { type ModuleFocus, type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
|
|
3
3
|
import type { ServiceEntry } from "./services-manifest.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -126,8 +126,8 @@ export interface FirstPartyExtras {
|
|
|
126
126
|
* The CLI prefers the installed module's own `.parachute/module.json` when
|
|
127
127
|
* present and falls back to this embedded manifest otherwise. The plan is
|
|
128
128
|
* to delete each fallback as its upstream module starts shipping the real
|
|
129
|
-
* file — see the `// FALLBACK: Delete when ...`
|
|
130
|
-
* specific upstream reference
|
|
129
|
+
* file — see the `// FALLBACK: Delete when ...` marker below for the
|
|
130
|
+
* specific upstream reference.
|
|
131
131
|
*
|
|
132
132
|
* Third-party modules never have a fallback; they ship `module.json` or
|
|
133
133
|
* the install hard-errors.
|
|
@@ -260,7 +260,10 @@ export function composeServiceSpec(opts: {
|
|
|
260
260
|
//
|
|
261
261
|
// As of 2026-05-21 (hub#310), vault / scribe / runner have all retired their
|
|
262
262
|
// FALLBACK entries: each ships `module.json` AND self-registers its
|
|
263
|
-
// services.json row at boot (vault#356, scribe#50, runner#3).
|
|
263
|
+
// services.json row at boot (vault#356, scribe#50, runner#3). Channel
|
|
264
|
+
// followed in the 2026-06-09 hub-module-boundary migration (D3) — it ships
|
|
265
|
+
// `.parachute/module.json` and self-registers (channel#34 era), so its
|
|
266
|
+
// vendored manifest retired to a KNOWN_MODULES row. Hub reads the
|
|
264
267
|
// canonical fields from services.json (operator-authoritative) and falls
|
|
265
268
|
// through to `<installDir>/.parachute/module.json` when a lifecycle command
|
|
266
269
|
// needs a static manifest. The `KNOWN_MODULES` registry below carries just
|
|
@@ -272,11 +275,10 @@ export function composeServiceSpec(opts: {
|
|
|
272
275
|
// What remains in FIRST_PARTY_FALLBACKS:
|
|
273
276
|
// - notes: still a frontend with a hub-side static-serve shim (`notes-serve.ts`)
|
|
274
277
|
// — its startCmd is composed from the services.json entry's port + mount,
|
|
275
|
-
// which is hub-side logic, not something notes itself runs.
|
|
276
|
-
//
|
|
277
|
-
// so the vendored fallback is fine.
|
|
278
|
+
// which is hub-side logic, not something notes itself runs. (The archive
|
|
279
|
+
// isn't done — notes-daemon Phase 3 retirement hasn't landed.)
|
|
278
280
|
//
|
|
279
|
-
//
|
|
281
|
+
// The remaining entry keeps its "FALLBACK: Delete when …" marker so the
|
|
280
282
|
// next cleanup pass is a one-grep operation.
|
|
281
283
|
// ---------------------------------------------------------------------------
|
|
282
284
|
|
|
@@ -314,44 +316,24 @@ const NOTES_FALLBACK: FirstPartyFallback = {
|
|
|
314
316
|
},
|
|
315
317
|
};
|
|
316
318
|
|
|
317
|
-
// FALLBACK: Delete when @openparachute/channel ships .parachute/module.json
|
|
318
|
-
// (parachute-channel repo: file follow-up after parachute-hub#56 lands;
|
|
319
|
-
// channel is exploration tier — may be retired before module.json ships).
|
|
320
|
-
const CHANNEL_FALLBACK: FirstPartyFallback = {
|
|
321
|
-
package: "@openparachute/channel",
|
|
322
|
-
manifest: {
|
|
323
|
-
name: "channel",
|
|
324
|
-
manifestName: "parachute-channel",
|
|
325
|
-
displayName: "Channel",
|
|
326
|
-
tagline: "Notification fan-out across modules.",
|
|
327
|
-
port: 1941,
|
|
328
|
-
paths: ["/channel"],
|
|
329
|
-
health: "/channel/health",
|
|
330
|
-
startCmd: ["parachute-channel", "daemon"],
|
|
331
|
-
},
|
|
332
|
-
extras: {
|
|
333
|
-
hasAuth: true,
|
|
334
|
-
},
|
|
335
|
-
};
|
|
336
|
-
|
|
337
319
|
/**
|
|
338
320
|
* Vendored manifests + extras for first-party modules that still need them.
|
|
339
321
|
* Indexed by short name (the `parachute install <X>` token).
|
|
340
322
|
*
|
|
341
|
-
* Only notes
|
|
342
|
-
* (vault/scribe/runner now self-register and ship their own
|
|
343
|
-
* Other code paths consult both this table AND `KNOWN_MODULES`
|
|
344
|
-
* the post-self-register-retirement entries) via the helpers
|
|
345
|
-
* (`shortNameForManifest`, `knownServices`, …).
|
|
323
|
+
* Only notes remains — see the block comment above for the rationale
|
|
324
|
+
* (vault/scribe/runner/channel now self-register and ship their own
|
|
325
|
+
* module.json). Other code paths consult both this table AND `KNOWN_MODULES`
|
|
326
|
+
* (which carries the post-self-register-retirement entries) via the helpers
|
|
327
|
+
* in this file (`shortNameForManifest`, `knownServices`, …).
|
|
346
328
|
*/
|
|
347
329
|
export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
|
|
348
330
|
notes: NOTES_FALLBACK,
|
|
349
|
-
channel: CHANNEL_FALLBACK,
|
|
350
331
|
};
|
|
351
332
|
|
|
352
333
|
/**
|
|
353
334
|
* Minimal install-time registry for first-party modules whose FALLBACK has
|
|
354
|
-
* retired (vault / scribe / runner as of hub#310
|
|
335
|
+
* retired (vault / scribe / runner as of hub#310; channel as of the
|
|
336
|
+
* 2026-06-09 hub-module-boundary migration, D3). Hub uses this for:
|
|
355
337
|
*
|
|
356
338
|
* 1. **Install bootstrap**: mapping `parachute install <short>` to the npm
|
|
357
339
|
* package to `bun add -g`. Pre-install there's no module.json on disk
|
|
@@ -475,6 +457,31 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
|
|
|
475
457
|
hasAuth: true,
|
|
476
458
|
},
|
|
477
459
|
},
|
|
460
|
+
channel: {
|
|
461
|
+
short: "channel",
|
|
462
|
+
package: "@openparachute/channel",
|
|
463
|
+
manifestName: "parachute-channel",
|
|
464
|
+
canonicalPort: 1941,
|
|
465
|
+
displayName: "Channel",
|
|
466
|
+
// Mirrors channel's own module.json (the canonical fields below do too —
|
|
467
|
+
// keep in sync if channel's declaration changes). The retired FALLBACK's
|
|
468
|
+
// copy ("Notification fan-out across modules.", health "/channel/health",
|
|
469
|
+
// startCmd ["parachute-channel", "daemon"]) predated channel's
|
|
470
|
+
// module.json + self-registration and had drifted on all three.
|
|
471
|
+
tagline: "Chat with your Claude Code sessions — a channel per session.",
|
|
472
|
+
canonicalPaths: ["/channel"],
|
|
473
|
+
canonicalHealth: "/health",
|
|
474
|
+
canonicalStripPrefix: true,
|
|
475
|
+
extras: {
|
|
476
|
+
// Backward-compat startCmd for rows without installDir — same rationale
|
|
477
|
+
// as scribe / vault / runner. The bare binary IS the daemon (channel's
|
|
478
|
+
// package.json bin maps `parachute-channel` → src/daemon.ts); the old
|
|
479
|
+
// vendored `["parachute-channel", "daemon"]` subcommand is stale.
|
|
480
|
+
startCmd: () => ["parachute-channel"],
|
|
481
|
+
// Channel gates its endpoints behind hub-issued JWTs (channel:* scopes).
|
|
482
|
+
hasAuth: true,
|
|
483
|
+
},
|
|
484
|
+
},
|
|
478
485
|
surface: {
|
|
479
486
|
short: "surface",
|
|
480
487
|
package: "@openparachute/surface",
|
|
@@ -625,6 +632,66 @@ export function knownServices(): string[] {
|
|
|
625
632
|
return [...Object.keys(FIRST_PARTY_FALLBACKS), ...Object.keys(KNOWN_MODULES)];
|
|
626
633
|
}
|
|
627
634
|
|
|
635
|
+
/**
|
|
636
|
+
* Default discovery tier per short name (2026-06-09 modular-UI architecture).
|
|
637
|
+
* The hub PREFERS a module's manifest-declared `focus`; this map is the
|
|
638
|
+
* fallback when `module.json` omits it (and the bootstrap value before a
|
|
639
|
+
* module is installed). vault / scribe / hub / surface are `core`; everything
|
|
640
|
+
* else (channel / runner / notes / unknown third-party) defaults to
|
|
641
|
+
* `experimental`. **Show all; never hide** — `focus` only groups + labels.
|
|
642
|
+
*/
|
|
643
|
+
const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
|
|
644
|
+
vault: "core",
|
|
645
|
+
scribe: "core",
|
|
646
|
+
hub: "core",
|
|
647
|
+
surface: "core",
|
|
648
|
+
channel: "experimental",
|
|
649
|
+
runner: "experimental",
|
|
650
|
+
notes: "experimental",
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Resolve a short name's discovery tier. When `declared` (the module's
|
|
655
|
+
* `module.json` `focus`) is present it wins; otherwise fall back to
|
|
656
|
+
* `FOCUS_DEFAULTS`, defaulting any unlisted short to `experimental`. Never
|
|
657
|
+
* returns undefined — the Modules screen always has a tier to group by.
|
|
658
|
+
*/
|
|
659
|
+
export function focusForShort(short: string, declared?: ModuleFocus): ModuleFocus {
|
|
660
|
+
if (declared !== undefined) return declared;
|
|
661
|
+
return FOCUS_DEFAULTS[short] ?? "experimental";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The set of short names the hub knows how to discover, display, and install
|
|
666
|
+
* from its bootstrap registries — `KNOWN_MODULES` ∪ `FIRST_PARTY_FALLBACKS`.
|
|
667
|
+
* This is the SELF-REGISTRATION-driven discovery surface that replaces the old
|
|
668
|
+
* `CURATED_MODULES` whitelist (2026-06-09 modular-UI architecture, P2): every
|
|
669
|
+
* module the hub can resolve a package/manifest for is discoverable + installable,
|
|
670
|
+
* regardless of `focus` tier. Deduped, with FIRST_PARTY_FALLBACKS shorts first
|
|
671
|
+
* (notes) then KNOWN_MODULES (vault / scribe / runner / channel / surface).
|
|
672
|
+
*
|
|
673
|
+
* `notes` is intentionally included — it's still resolvable (vendored fallback)
|
|
674
|
+
* for legacy installs; it surfaces as `experimental` and isn't pushed as a
|
|
675
|
+
* fresh install. Callers that want only the "recommended fresh-install" subset
|
|
676
|
+
* can filter by `focus === "core"`.
|
|
677
|
+
*/
|
|
678
|
+
export function discoverableShorts(): string[] {
|
|
679
|
+
const seen = new Set<string>();
|
|
680
|
+
const out: string[] = [];
|
|
681
|
+
for (const short of [...Object.keys(FIRST_PARTY_FALLBACKS), ...Object.keys(KNOWN_MODULES)]) {
|
|
682
|
+
if (seen.has(short)) continue;
|
|
683
|
+
seen.add(short);
|
|
684
|
+
out.push(short);
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** True iff `short` is a module the hub can resolve a package/manifest for
|
|
690
|
+
* (the install-path gate, replacing the `CURATED_MODULES` whitelist). */
|
|
691
|
+
export function isKnownModuleShort(short: string): boolean {
|
|
692
|
+
return short in FIRST_PARTY_FALLBACKS || short in KNOWN_MODULES;
|
|
693
|
+
}
|
|
694
|
+
|
|
628
695
|
/**
|
|
629
696
|
* Canonical port assignment for a known short name, or `undefined` for
|
|
630
697
|
* third-party services we don't have a fallback for. Drives the
|
|
@@ -656,12 +723,12 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
|
|
|
656
723
|
/**
|
|
657
724
|
* Resolve the runtime spec for a known short name.
|
|
658
725
|
*
|
|
659
|
-
* FIRST_PARTY_FALLBACKS shorts (notes
|
|
726
|
+
* FIRST_PARTY_FALLBACKS shorts (notes) return a fully-composed
|
|
660
727
|
* spec with embedded manifest + extras — the vendored manifest is the
|
|
661
728
|
* source of truth pre-install and the install path preserves it through.
|
|
662
729
|
*
|
|
663
|
-
* KNOWN_MODULES shorts (vault / scribe / runner — post
|
|
664
|
-
* retirement) return a **minimal** spec carrying `package`, `manifestName`,
|
|
730
|
+
* KNOWN_MODULES shorts (vault / scribe / runner / channel / surface — post
|
|
731
|
+
* FALLBACK retirement) return a **minimal** spec carrying `package`, `manifestName`,
|
|
665
732
|
* and the imperative `extras` fields
|
|
666
733
|
* (`init`, `hasAuth`, `urlForEntry`, `postInstallFooter`). They do NOT carry
|
|
667
734
|
* `startCmd` or `seedEntry` — those come from `<installDir>/.parachute/module.json`
|
|
@@ -760,9 +827,9 @@ const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
|
|
|
760
827
|
};
|
|
761
828
|
|
|
762
829
|
/** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
|
|
763
|
-
* Consults both FIRST_PARTY_FALLBACKS (notes
|
|
764
|
-
* (vault / scribe / runner — post-FALLBACK-retirement).
|
|
765
|
-
* for unknown manifests. */
|
|
830
|
+
* Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
|
|
831
|
+
* (vault / scribe / runner / channel / surface — post-FALLBACK-retirement).
|
|
832
|
+
* Returns undefined for unknown manifests. */
|
|
766
833
|
export function shortNameForManifest(manifestName: string): string | undefined {
|
|
767
834
|
for (const [short, fb] of Object.entries(FIRST_PARTY_FALLBACKS)) {
|
|
768
835
|
if (fb.manifest.manifestName === manifestName) return short;
|
|
@@ -773,6 +840,30 @@ export function shortNameForManifest(manifestName: string): string | undefined {
|
|
|
773
840
|
return LEGACY_MANIFEST_ALIASES[manifestName];
|
|
774
841
|
}
|
|
775
842
|
|
|
843
|
+
/**
|
|
844
|
+
* Find a services.json row by its SHORT name (e.g. `"channel"`), resolving each
|
|
845
|
+
* row's manifest name (`parachute-channel`) back through `shortNameForManifest`.
|
|
846
|
+
*
|
|
847
|
+
* services.json rows carry the MANIFEST name, not the bare short — so a direct
|
|
848
|
+
* `s.name === short` comparison silently misses every first-party module. That
|
|
849
|
+
* exact mismatch (`s.name === "channel"` vs a `parachute-channel` row) is what
|
|
850
|
+
* surfaced a spurious "channel module is not installed" when wiring the channel
|
|
851
|
+
* connection sink. Resolve through the short↔manifest map instead. Returns the
|
|
852
|
+
* first match, or undefined when no installed row maps to `short`.
|
|
853
|
+
*
|
|
854
|
+
* NOTE: multi-instance vault rows (`parachute-vault-<name>`) are NOT resolvable
|
|
855
|
+
* here — `shortNameForManifest` only knows the canonical `parachute-vault`, so
|
|
856
|
+
* `findServiceByShort(services, "vault")` returns undefined even when a vault is
|
|
857
|
+
* installed. Vault rows are resolved by mount path via `findVaultUpstream`; this
|
|
858
|
+
* helper is for single-instance modules (channel / scribe / runner / surface).
|
|
859
|
+
*/
|
|
860
|
+
export function findServiceByShort<T extends { name: string }>(
|
|
861
|
+
services: readonly T[],
|
|
862
|
+
short: string,
|
|
863
|
+
): T | undefined {
|
|
864
|
+
return services.find((s) => shortNameForManifest(s.name) === short);
|
|
865
|
+
}
|
|
866
|
+
|
|
776
867
|
/**
|
|
777
868
|
* Compose a `ServiceSpec` from a `KNOWN_MODULES` entry plus the static
|
|
778
869
|
* manifest data the caller has on hand (typically read from
|
package/src/services-manifest.ts
CHANGED
|
@@ -71,6 +71,38 @@ function normalizeUiSubUnitStatus(value: string): UiSubUnitStatus | undefined {
|
|
|
71
71
|
return UI_SUB_UNIT_STATUS_ALIASES[value];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Who may load a UI sub-unit through the hub proxy (H3, surface-runtime
|
|
76
|
+
* design §12 — fixes parachute-surface#88, where `public` was declared but
|
|
77
|
+
* never enforced):
|
|
78
|
+
*
|
|
79
|
+
* "public" — anyone; no hub identity required (chrome strip off — H5).
|
|
80
|
+
* "hub-users" — a valid hub session cookie OR a hub-issued Bearer whose
|
|
81
|
+
* scopes satisfy the surface's `scopes_required` (the OR
|
|
82
|
+
* keeps installed PWAs working). THE DEFAULT when absent.
|
|
83
|
+
* "operator" — the first-admin session only.
|
|
84
|
+
* "surface" — the surface backend owns admission END-TO-END; the hub
|
|
85
|
+
* proxy passes every request through (backed surfaces —
|
|
86
|
+
* kit-authenticated via @openparachute/surface-server: hub
|
|
87
|
+
* JWTs / capability links / anon, deny-by-default). Chrome
|
|
88
|
+
* strip off like `public` — capability-link invitees are
|
|
89
|
+
* NOT hub users.
|
|
90
|
+
*
|
|
91
|
+
* Enforced at the hub proxy BEFORE forwarding (`src/audience-gate.ts`).
|
|
92
|
+
* Legacy boolean `public` on the wire is accepted one alias window:
|
|
93
|
+
* `public: true` → `"public"`, `public: false` → default. Fail-closed: an
|
|
94
|
+
* unrecognized `audience` value rejects the row (the lenient manifest read
|
|
95
|
+
* then drops it — unroutable beats accidentally public). Version-skew
|
|
96
|
+
* corollary: a surface declaring `audience: "surface"` registered against a
|
|
97
|
+
* hub OLDER than this value's introduction gets its row rejected by that
|
|
98
|
+
* same fail-closed rule — the mount 404s until the hub is upgraded. The
|
|
99
|
+
* surface-side meta-schema ships the value with a hub-version requirement
|
|
100
|
+
* note for exactly this reason.
|
|
101
|
+
*/
|
|
102
|
+
export type UiAudience = "public" | "hub-users" | "operator" | "surface";
|
|
103
|
+
|
|
104
|
+
const UI_AUDIENCE_VALUES: readonly UiAudience[] = ["public", "hub-users", "operator", "surface"];
|
|
105
|
+
|
|
74
106
|
/**
|
|
75
107
|
* A sub-unit beneath a module — used today by parachute-app to surface each
|
|
76
108
|
* hosted UI as its own discoverable row under the App module, and the shape
|
|
@@ -129,6 +161,20 @@ export interface UiSubUnit {
|
|
|
129
161
|
oauthClientId?: string;
|
|
130
162
|
/** UI sub-unit lifecycle state. Absent → discovery treats as "active". */
|
|
131
163
|
status?: UiSubUnitStatus;
|
|
164
|
+
/**
|
|
165
|
+
* Audience exposure (H3). Absent → "hub-users". The legacy boolean
|
|
166
|
+
* `public` field is normalized into this on read (true → "public") for
|
|
167
|
+
* one alias window. See {@link UiAudience}.
|
|
168
|
+
*/
|
|
169
|
+
audience?: UiAudience;
|
|
170
|
+
/**
|
|
171
|
+
* OAuth scopes the UI declares as required (surface-host stamps these from
|
|
172
|
+
* the surface's meta.json `scopes_required`, e.g. `["vault:*:read"]` —
|
|
173
|
+
* `*` is a single-segment wildcard). The audience gate's Bearer branch
|
|
174
|
+
* checks a presented token's scopes against these. Snake_case to match
|
|
175
|
+
* the wire shape surface already writes.
|
|
176
|
+
*/
|
|
177
|
+
scopes_required?: string[];
|
|
132
178
|
}
|
|
133
179
|
|
|
134
180
|
export interface ServiceEntry {
|
|
@@ -170,6 +216,16 @@ export interface ServiceEntry {
|
|
|
170
216
|
* bridges the gap. Tracked in parachute-scribe (separate issue).
|
|
171
217
|
*/
|
|
172
218
|
stripPrefix?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* When `true`, the module's daemon accepts WebSocket upgrades and the hub's
|
|
221
|
+
* Bun-native upgrade bridge (H1, surface-runtime design) will forward
|
|
222
|
+
* `Upgrade: websocket` requests on this module's mounts to it. DENY BY
|
|
223
|
+
* DEFAULT: absent/false means an upgrade request to this mount is refused
|
|
224
|
+
* (426) without ever reaching the daemon. Modules declare this on their
|
|
225
|
+
* `.parachute/module.json` (the install-time contract) and carry it onto
|
|
226
|
+
* their self-registered services.json row; the hub honors either source.
|
|
227
|
+
*/
|
|
228
|
+
websocket?: boolean;
|
|
173
229
|
/**
|
|
174
230
|
* Sub-units hosted under this module — parachute-app's bag of UIs, and
|
|
175
231
|
* the shape vault is expected to adopt for per-instance metadata in a
|
|
@@ -282,6 +338,10 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
282
338
|
if (stripPrefix !== undefined && typeof stripPrefix !== "boolean") {
|
|
283
339
|
throw new ServicesManifestError(`${where}: "stripPrefix" must be a boolean if present`);
|
|
284
340
|
}
|
|
341
|
+
const websocket = e.websocket;
|
|
342
|
+
if (websocket !== undefined && typeof websocket !== "boolean") {
|
|
343
|
+
throw new ServicesManifestError(`${where}: "websocket" must be a boolean if present`);
|
|
344
|
+
}
|
|
285
345
|
const uis = e.uis;
|
|
286
346
|
const validatedUis = validateUis(uis, where);
|
|
287
347
|
const validatedStartError = validateStartError(e.lastStartError, where);
|
|
@@ -291,6 +351,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
|
|
|
291
351
|
if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
|
|
292
352
|
if (installDir !== undefined) entry.installDir = installDir;
|
|
293
353
|
if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
|
|
354
|
+
if (websocket !== undefined) entry.websocket = websocket;
|
|
294
355
|
if (validatedUis !== undefined) entry.uis = validatedUis;
|
|
295
356
|
if (validatedStartError !== undefined) entry.lastStartError = validatedStartError;
|
|
296
357
|
return entry;
|
|
@@ -405,12 +466,48 @@ function validateUiSubUnit(raw: unknown, where: string): UiSubUnit {
|
|
|
405
466
|
}
|
|
406
467
|
normalizedStatus = norm;
|
|
407
468
|
}
|
|
469
|
+
// Audience (H3). Explicit `audience` wins; the legacy boolean `public` is
|
|
470
|
+
// accepted as an alias for one release window (true → "public"; false →
|
|
471
|
+
// the default, i.e. no field). FAIL-CLOSED on malformed values: throwing
|
|
472
|
+
// here makes the lenient manifest read drop the whole row — an unroutable
|
|
473
|
+
// surface beats one that silently falls open to the wrong audience.
|
|
474
|
+
let audience: UiAudience | undefined;
|
|
475
|
+
if (u.audience !== undefined) {
|
|
476
|
+
if (
|
|
477
|
+
typeof u.audience !== "string" ||
|
|
478
|
+
!(UI_AUDIENCE_VALUES as readonly string[]).includes(u.audience)
|
|
479
|
+
) {
|
|
480
|
+
throw new ServicesManifestError(
|
|
481
|
+
`${where}: "audience" must be "public" | "hub-users" | "operator" | "surface" if present (got ${JSON.stringify(u.audience)})`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
audience = u.audience as UiAudience;
|
|
485
|
+
} else if (u.public !== undefined) {
|
|
486
|
+
if (typeof u.public !== "boolean") {
|
|
487
|
+
throw new ServicesManifestError(`${where}: legacy "public" must be a boolean if present`);
|
|
488
|
+
}
|
|
489
|
+
if (u.public) audience = "public";
|
|
490
|
+
}
|
|
491
|
+
let scopesRequired: string[] | undefined;
|
|
492
|
+
if (u.scopes_required !== undefined) {
|
|
493
|
+
if (
|
|
494
|
+
!Array.isArray(u.scopes_required) ||
|
|
495
|
+
u.scopes_required.some((s) => typeof s !== "string" || s.length === 0)
|
|
496
|
+
) {
|
|
497
|
+
throw new ServicesManifestError(
|
|
498
|
+
`${where}: "scopes_required" must be an array of non-empty strings if present`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
scopesRequired = u.scopes_required as string[];
|
|
502
|
+
}
|
|
408
503
|
const out: UiSubUnit = { displayName, path };
|
|
409
504
|
if (tagline !== undefined) out.tagline = tagline;
|
|
410
505
|
if (iconUrl !== undefined) out.iconUrl = iconUrl;
|
|
411
506
|
if (version !== undefined) out.version = version;
|
|
412
507
|
if (oauthClientId !== undefined) out.oauthClientId = oauthClientId;
|
|
413
508
|
if (normalizedStatus !== undefined) out.status = normalizedStatus;
|
|
509
|
+
if (audience !== undefined) out.audience = audience;
|
|
510
|
+
if (scopesRequired !== undefined) out.scopes_required = scopesRequired;
|
|
414
511
|
return out;
|
|
415
512
|
}
|
|
416
513
|
|
package/src/setup-wizard.ts
CHANGED
|
@@ -199,6 +199,16 @@ export interface DerivedWizardState {
|
|
|
199
199
|
hasAdmin: boolean;
|
|
200
200
|
/** Whether the first vault (curated) has been provisioned in services.json. */
|
|
201
201
|
hasVault: boolean;
|
|
202
|
+
/**
|
|
203
|
+
* Whether a REAL vault instance exists (a non-placeholder services.json
|
|
204
|
+
* row) — `hasVault` minus the `setup_vault_skipped` marker. The
|
|
205
|
+
* re-enterable vault step (B5, 2026-06-09 hub-module-boundary) keys on
|
|
206
|
+
* this: an operator who skipped the wizard's vault step has
|
|
207
|
+
* `hasVault === true` but `hasRealVault === false`, and the
|
|
208
|
+
* `/admin/setup?step=vault` deep-link (Home's "create your first vault"
|
|
209
|
+
* card) must still reach the create/import form.
|
|
210
|
+
*/
|
|
211
|
+
hasRealVault: boolean;
|
|
202
212
|
/**
|
|
203
213
|
* Whether the operator has answered the "how will this hub be reached?"
|
|
204
214
|
* question (the expose step, hub#268 Item 2). When admin + vault both
|
|
@@ -290,6 +300,14 @@ export function deriveWizardState(deps: {
|
|
|
290
300
|
// phantom `vaults[]` row at SEED_VERSION); both surfaces must agree that a
|
|
291
301
|
// placeholder is not a real vault.
|
|
292
302
|
const vaultIsPlaceholder = vaultEntry !== undefined && vaultEntry.version === SEED_VERSION;
|
|
303
|
+
// INVARIANT (B5 re-enterable vault step): hasRealVault means "a real
|
|
304
|
+
// instance row exists" — placeholder excluded here, skip-marker excluded
|
|
305
|
+
// below (skip flips hasVault, never hasRealVault). THREE sites key on this
|
|
306
|
+
// same placeholder logic and must move together: this derivation,
|
|
307
|
+
// handleSetupGet's `?step=vault` re-entry gate, and handleSetupVaultPost's
|
|
308
|
+
// already-provisioned short-circuit. Changing one without the others
|
|
309
|
+
// either re-opens a provisioning form over a real vault or dead-ends the
|
|
310
|
+
// post-skip re-entry path.
|
|
293
311
|
const hasRealVault = vaultEntry !== undefined && !vaultIsPlaceholder;
|
|
294
312
|
// hub#168 Cut 2: `setup_vault_skipped === "true"` advances the wizard
|
|
295
313
|
// past the vault step even when no vault row exists. The operator
|
|
@@ -339,7 +357,7 @@ export function deriveWizardState(deps: {
|
|
|
339
357
|
else if (!hasVault) step = "vault";
|
|
340
358
|
else if (!hasExposeMode) step = "expose";
|
|
341
359
|
else step = "done";
|
|
342
|
-
return { step, hasAdmin, hasVault, hasExposeMode };
|
|
360
|
+
return { step, hasAdmin, hasVault, hasRealVault, hasExposeMode };
|
|
343
361
|
}
|
|
344
362
|
|
|
345
363
|
// --- handler types -------------------------------------------------------
|
|
@@ -724,7 +742,7 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
|
724
742
|
<h2>What's next</h2>
|
|
725
743
|
<p>You'll land on a success screen with copy-paste MCP install
|
|
726
744
|
instructions for Claude Code and a link to the admin UI, where
|
|
727
|
-
you can
|
|
745
|
+
you can add more vaults.</p>
|
|
728
746
|
</section>
|
|
729
747
|
<section class="preview">
|
|
730
748
|
<p class="preview-label">About to create</p>
|
|
@@ -735,8 +753,8 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
|
|
|
735
753
|
</div>
|
|
736
754
|
<p class="preview-fine">
|
|
737
755
|
The name shows up in the MCP URL (<code>/vault/<name>/mcp</code>)
|
|
738
|
-
and on the admin UI. You can
|
|
739
|
-
<code>/admin
|
|
756
|
+
and on the admin UI. You can add or manage vaults later from the
|
|
757
|
+
vault module's own admin at <code>/vault/admin/</code>.
|
|
740
758
|
</p>
|
|
741
759
|
</section>
|
|
742
760
|
${error}
|
|
@@ -1696,6 +1714,41 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1696
1714
|
};
|
|
1697
1715
|
if (csrf.setCookie) extraHeaders["set-cookie"] = csrf.setCookie;
|
|
1698
1716
|
|
|
1717
|
+
// Re-enterable vault step (B5, 2026-06-09 hub-module-boundary migration).
|
|
1718
|
+
// A wizard-skip leaves the vault MODULE installed with no instances and no
|
|
1719
|
+
// daemon (hub#607's zero-instances state) — and `setup_vault_skipped`
|
|
1720
|
+
// makes `hasVault` true, so a plain GET resumes at expose/done and the
|
|
1721
|
+
// create form is unreachable. The hub-side "create your first vault"
|
|
1722
|
+
// affordance (Home's vault card + the legacy /admin/vaults empty state)
|
|
1723
|
+
// deep-links `?step=vault`, which re-enters the create/import form as long
|
|
1724
|
+
// as no REAL vault instance exists. Session-gated: post-account the box
|
|
1725
|
+
// has an admin, and re-opening a provisioning form to a drive-by GET
|
|
1726
|
+
// would leak setup state (the POST is session+CSRF-gated either way).
|
|
1727
|
+
// With a real vault present the param is ignored and the normal flow
|
|
1728
|
+
// (expose step / 301 → /login) runs.
|
|
1729
|
+
//
|
|
1730
|
+
// INVARIANT: this gate keys on `hasRealVault` (deriveWizardState) — the
|
|
1731
|
+
// same placeholder logic handleSetupVaultPost's short-circuit uses. The
|
|
1732
|
+
// three sites must move together; see the derivation comment in
|
|
1733
|
+
// deriveWizardState.
|
|
1734
|
+
if (url.searchParams.get("step") === "vault" && state.hasAdmin && !state.hasRealVault) {
|
|
1735
|
+
const session = findActiveSession(deps.db, req);
|
|
1736
|
+
if (!session) {
|
|
1737
|
+
// Preserve the CSRF set-cookie across the bounce — same shape as the
|
|
1738
|
+
// `?just_finished=1` session gate below.
|
|
1739
|
+
const redirectHeaders: Record<string, string> = {
|
|
1740
|
+
location: `/login?next=${encodeURIComponent("/admin/setup?step=vault")}`,
|
|
1741
|
+
};
|
|
1742
|
+
if (csrf.setCookie) redirectHeaders["set-cookie"] = csrf.setCookie;
|
|
1743
|
+
return new Response(null, { status: 302, headers: redirectHeaders });
|
|
1744
|
+
}
|
|
1745
|
+
const cloudHost = detectAutoExposeMode(deps.env ?? process.env) === "public";
|
|
1746
|
+
return new Response(renderVaultStep({ csrfToken: csrf.token, cloudHost }), {
|
|
1747
|
+
status: 200,
|
|
1748
|
+
headers: extraHeaders,
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1699
1752
|
// Setup fully complete (including expose-mode choice) — redirect to
|
|
1700
1753
|
// /login unless we're rendering the success page once. The success
|
|
1701
1754
|
// page sets `?just_finished=1` and the session cookie is on the
|
|
@@ -2168,9 +2221,18 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
2168
2221
|
"Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
|
|
2169
2222
|
);
|
|
2170
2223
|
}
|
|
2171
|
-
// Already done — short-circuit to the done step.
|
|
2224
|
+
// Already done — short-circuit to the done step. Keyed on hasRealVault
|
|
2225
|
+
// (NOT hasVault): the `setup_vault_skipped` marker satisfies hasVault, but
|
|
2226
|
+
// a skipped box has no instance and the re-entered vault step (B5,
|
|
2227
|
+
// `?step=vault`) must be able to POST create/import — `mode=create` below
|
|
2228
|
+
// clears the skip marker; `mode=skip` just re-sets it (idempotent).
|
|
2229
|
+
//
|
|
2230
|
+
// INVARIANT: same placeholder logic as deriveWizardState's hasRealVault
|
|
2231
|
+
// derivation and handleSetupGet's `?step=vault` re-entry gate — the three
|
|
2232
|
+
// sites must move together; see the derivation comment in
|
|
2233
|
+
// deriveWizardState.
|
|
2172
2234
|
const state = deriveWizardState(deps);
|
|
2173
|
-
if (state.
|
|
2235
|
+
if (state.hasRealVault) {
|
|
2174
2236
|
if (form.isJson) {
|
|
2175
2237
|
return jsonOkResponse({ step: "expose", message: "vault already provisioned" });
|
|
2176
2238
|
}
|
package/src/users.ts
CHANGED
|
@@ -466,6 +466,17 @@ export function setUserVaults(
|
|
|
466
466
|
return true;
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Vault-delete cascade step (B1, 2026-06-09 hub-module-boundary): drop every
|
|
471
|
+
* `user_vaults` assignment row for the deleted vault, across all users.
|
|
472
|
+
* Exact `=` comparison on `vault_name` — no pattern matching. Returns the
|
|
473
|
+
* number of rows deleted.
|
|
474
|
+
*/
|
|
475
|
+
export function removeVaultAssignments(db: Database, vaultName: string): number {
|
|
476
|
+
const res = db.prepare("DELETE FROM user_vaults WHERE vault_name = ?").run(vaultName);
|
|
477
|
+
return Number(res.changes);
|
|
478
|
+
}
|
|
479
|
+
|
|
469
480
|
/**
|
|
470
481
|
* Updates the password for an existing user. Throws `UserNotFoundError` if
|
|
471
482
|
* the id has no row. Single-user-mode flows look up by username first and
|