@openparachute/hub 0.6.5-rc.7 → 0.7.0
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 +34 -0
- 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.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-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-db-liveness.test.ts +12 -7
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- 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/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-db-liveness.ts +33 -17
- package/src/hub-server.ts +354 -61
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- 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/web/ui/dist/assets/index-C-XzMVqN.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/hub-server.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* # Pre-rename 301 back-compat (hub#231 — first so they preempt any
|
|
17
17
|
* # remaining handlers under /vault or /hub).
|
|
18
|
-
* /vault, /vault/, /vault/new → 301 → /admin/
|
|
18
|
+
* /vault, /vault/, /vault/new → 301 → /vault/admin/ (B5:
|
|
19
|
+
* vault's daemon-level admin)
|
|
19
20
|
* /hub/vaults* → 301 → /admin/vaults*
|
|
20
21
|
* /hub/permissions → 301 → /admin/permissions
|
|
21
22
|
* /hub/tokens → 301 → /admin/tokens
|
|
@@ -43,8 +44,22 @@
|
|
|
43
44
|
*
|
|
44
45
|
* # Admin API + bearer-mint surfaces (must precede /admin/* SPA mount).
|
|
45
46
|
* /vaults (POST) → create vault
|
|
47
|
+
* /vaults/<name> (DELETE) → destroy vault + identity cascade
|
|
48
|
+
* (B1: confirm body, host:admin,
|
|
49
|
+
* tokens/grants/user_vaults/invites/
|
|
50
|
+
* connections sweep, CLI remove,
|
|
51
|
+
* supervisor restart)
|
|
46
52
|
* /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
|
|
47
53
|
* /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
|
|
54
|
+
* /admin/channel-token (GET) → channel UI bearer mint (cookie-gated)
|
|
55
|
+
* /admin/module-token/<short> (GET) → generic module config-UI bearer mint <short>:admin (cookie-gated)
|
|
56
|
+
* /api/connections/catalog (GET) → events/actions across installed modules (cookie-gated)
|
|
57
|
+
* /admin/connections (POST/GET) → connection provision/list (cookie-gated; POST CSRF-belted)
|
|
58
|
+
* /admin/connections/<id> (DELETE) → connection teardown (cookie-gated; CSRF-belted)
|
|
59
|
+
*
|
|
60
|
+
* # "CSRF-belted" = strict same-origin Origin check on cookie-authed
|
|
61
|
+
* # mutations (hub#632, boundary C1) — origin-check.ts
|
|
62
|
+
* # `assertSameOriginForCookieMutation` carries the canonical enumeration.
|
|
48
63
|
* /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
|
|
49
64
|
* /api/hub (GET) → hub version + uptime + install-source (host:admin)
|
|
50
65
|
* /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
|
|
@@ -94,6 +109,11 @@
|
|
|
94
109
|
* /admin/config* → 301 → /admin/vaults (legacy
|
|
95
110
|
* portal retired post-SPA-rework)
|
|
96
111
|
*
|
|
112
|
+
* # Vault MODULE daemon-level admin surface (B-route, 2026-06-09
|
|
113
|
+
* # hub-module-boundary). Runs BEFORE the per-vault proxy; "admin" is a
|
|
114
|
+
* # reserved vault name so no instance can claim the mount.
|
|
115
|
+
* /vault/admin, /vault/admin/* → proxy to the vault module's daemon
|
|
116
|
+
*
|
|
97
117
|
* # Per-vault content proxy (user-facing vault data: Notes PWA, MCP, etc.).
|
|
98
118
|
* /vault/<name>/* → proxy to the vault backend
|
|
99
119
|
*
|
|
@@ -130,7 +150,13 @@ import pkg from "../package.json" with { type: "json" };
|
|
|
130
150
|
import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
|
|
131
151
|
import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
|
|
132
152
|
import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
|
|
153
|
+
import { handleChannelToken } from "./admin-channel-token.ts";
|
|
133
154
|
import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
155
|
+
import {
|
|
156
|
+
type ConnectionsDeps,
|
|
157
|
+
handleConnections,
|
|
158
|
+
handleConnectionsCatalog,
|
|
159
|
+
} from "./admin-connections.ts";
|
|
134
160
|
import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
|
|
135
161
|
import {
|
|
136
162
|
handleAdminLoginGet,
|
|
@@ -139,8 +165,9 @@ import {
|
|
|
139
165
|
handleAdminLogoutPost,
|
|
140
166
|
} from "./admin-handlers.ts";
|
|
141
167
|
import { handleHostAdminToken } from "./admin-host-admin-token.ts";
|
|
168
|
+
import { handleModuleToken } from "./admin-module-token.ts";
|
|
142
169
|
import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
|
|
143
|
-
import { handleCreateVault } from "./admin-vaults.ts";
|
|
170
|
+
import { handleCreateVault, handleDeleteVault } from "./admin-vaults.ts";
|
|
144
171
|
import {
|
|
145
172
|
handleAccountChangePasswordGet,
|
|
146
173
|
handleAccountChangePasswordPost,
|
|
@@ -151,7 +178,6 @@ import { handleApiHub } from "./api-hub.ts";
|
|
|
151
178
|
import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "./api-invites.ts";
|
|
152
179
|
import { handleApiMe } from "./api-me.ts";
|
|
153
180
|
import { handleApiMintToken } from "./api-mint-token.ts";
|
|
154
|
-
import { handleApiModulesConfig, parseModulesConfigPath } from "./api-modules-config.ts";
|
|
155
181
|
import {
|
|
156
182
|
getDefaultOperationsRegistry,
|
|
157
183
|
handleInstall,
|
|
@@ -211,7 +237,7 @@ import {
|
|
|
211
237
|
protectedResourceMetadata,
|
|
212
238
|
} from "./oauth-handlers.ts";
|
|
213
239
|
import { renderNotFoundPage } from "./oauth-ui.ts";
|
|
214
|
-
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
240
|
+
import { assertSameOriginForCookieMutation, buildHubBoundOrigins } from "./origin-check.ts";
|
|
215
241
|
import { clearPid, writePid } from "./process-state.ts";
|
|
216
242
|
import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
|
|
217
243
|
import { classifyUpstream } from "./proxy-state.ts";
|
|
@@ -220,6 +246,7 @@ import {
|
|
|
220
246
|
FIRST_PARTY_FALLBACKS,
|
|
221
247
|
KNOWN_MODULES,
|
|
222
248
|
effectivePublicExposure,
|
|
249
|
+
findServiceByShort,
|
|
223
250
|
shortNameForManifest,
|
|
224
251
|
} from "./service-spec.ts";
|
|
225
252
|
import { type ServiceEntry, readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
@@ -412,6 +439,45 @@ function hasVaultInstalled(manifestPath: string): boolean {
|
|
|
412
439
|
}
|
|
413
440
|
}
|
|
414
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Snapshot of every installed module's `.parachute/module.json` + its
|
|
444
|
+
* user-facing mount path, for the Connections engine (2026-06-09 modular-UI
|
|
445
|
+
* architecture, P5). Read at request time so a freshly-installed module's
|
|
446
|
+
* declared events/actions surface without a hub restart. A malformed manifest
|
|
447
|
+
* on one module is skipped (logged), never 500s the whole catalog — same
|
|
448
|
+
* posture as `/api/modules`.
|
|
449
|
+
*
|
|
450
|
+
* `mount` is the first non-`.parachute` services.json path (the proxied
|
|
451
|
+
* user-facing prefix, e.g. `/channel`), which the engine joins with a sink
|
|
452
|
+
* action's `endpoint` to build the hub-proxied webhook.
|
|
453
|
+
*/
|
|
454
|
+
async function collectInstalledModules(
|
|
455
|
+
manifestPath: string,
|
|
456
|
+
readManifestFn: (installDir: string) => Promise<ModuleManifest | null>,
|
|
457
|
+
): Promise<import("./admin-connections.ts").InstalledModuleInfo[]> {
|
|
458
|
+
// Lenient — see hub#406.
|
|
459
|
+
const services = readManifestLenient(manifestPath).services;
|
|
460
|
+
const out: import("./admin-connections.ts").InstalledModuleInfo[] = [];
|
|
461
|
+
await Promise.all(
|
|
462
|
+
services.map(async (entry) => {
|
|
463
|
+
if (!entry.installDir) return;
|
|
464
|
+
const short = shortNameForManifest(entry.name) ?? entry.name;
|
|
465
|
+
const userPath = (entry.paths ?? []).find(
|
|
466
|
+
(p) => p !== "/.parachute" && !p.startsWith("/.parachute/"),
|
|
467
|
+
);
|
|
468
|
+
try {
|
|
469
|
+
const manifest = await readManifestFn(entry.installDir);
|
|
470
|
+
if (!manifest) return;
|
|
471
|
+
out.push({ short, manifest, mount: userPath ?? null });
|
|
472
|
+
} catch (err) {
|
|
473
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
474
|
+
console.warn(`connections: skipping module ${short}: ${msg}`);
|
|
475
|
+
}
|
|
476
|
+
}),
|
|
477
|
+
);
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
|
|
415
481
|
/**
|
|
416
482
|
* The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
|
|
417
483
|
* every request reaches it via one of three trusted forwarders (or directly
|
|
@@ -698,6 +764,50 @@ async function proxyToVault(
|
|
|
698
764
|
return proxyRequest(req, match.port, "vault", "vault", supervisor, targetPath);
|
|
699
765
|
}
|
|
700
766
|
|
|
767
|
+
/**
|
|
768
|
+
* Reverse-proxy `/vault/admin` + `/vault/admin/*` to the vault MODULE's
|
|
769
|
+
* daemon — the daemon-level multi-vault admin surface (B-route, 2026-06-09
|
|
770
|
+
* hub-module-boundary migration), NOT a per-instance path.
|
|
771
|
+
*
|
|
772
|
+
* Resolution is via `findServiceByShort(services, "vault")` (the canonical
|
|
773
|
+
* self-registered `parachute-vault` row — same shape as the channelEntry
|
|
774
|
+
* lookup in the Connections deps), deliberately NOT `findVaultUpstream`:
|
|
775
|
+
* vault must NOT self-register `/vault/admin` in `paths[]`, because every
|
|
776
|
+
* consumer that derives instance names from paths (`vaultInstanceNameFor`,
|
|
777
|
+
* the well-known vaults[] fan-out, `findExistingVault`, the mint
|
|
778
|
+
* allowlists, the users vault-picker) would fabricate a phantom vault named
|
|
779
|
+
* "admin". The mount is hub-owned and gated on the B2h `admin` name
|
|
780
|
+
* reservation, so no real instance can ever claim it.
|
|
781
|
+
*
|
|
782
|
+
* Applies the SAME `publicExposure: "loopback"` 404-cloak as `proxyToVault`
|
|
783
|
+
* (the per-vault proxy's only layer-gate; "allowed"/"auth-required" pass
|
|
784
|
+
* through and the daemon self-gates). Vault's row declares no `stripPrefix`
|
|
785
|
+
* — the FULL path forwards, so the daemon's own `/vault/admin` routing
|
|
786
|
+
* branch (vault wave, B3) serves it.
|
|
787
|
+
*
|
|
788
|
+
* Returns `undefined` when no vault module is installed (caller 404s).
|
|
789
|
+
* NOTE: legacy per-instance rows named `parachute-vault-<name>` don't
|
|
790
|
+
* resolve through `findServiceByShort` (it only knows the canonical
|
|
791
|
+
* manifest name) — on such an install this surface 404s until vault's boot
|
|
792
|
+
* selfRegister rewrites the canonical row, which is the documented
|
|
793
|
+
* old-install degradation, not a routing hole.
|
|
794
|
+
*/
|
|
795
|
+
async function proxyToVaultAdmin(
|
|
796
|
+
req: Request,
|
|
797
|
+
manifestPath: string,
|
|
798
|
+
supervisor: Supervisor | undefined,
|
|
799
|
+
peerAddr: string | null,
|
|
800
|
+
): Promise<Response | undefined> {
|
|
801
|
+
// Lenient — see hub#406 (same posture as proxyToVault).
|
|
802
|
+
const services = readManifestLenient(manifestPath).services;
|
|
803
|
+
const entry = findServiceByShort(services, "vault");
|
|
804
|
+
if (!entry) return undefined;
|
|
805
|
+
if (effectivePublicExposure(entry) === "loopback" && layerOf(req, peerAddr) !== "loopback") {
|
|
806
|
+
return new Response("not found", { status: 404 });
|
|
807
|
+
}
|
|
808
|
+
return proxyRequest(req, entry.port, "vault", "vault", supervisor);
|
|
809
|
+
}
|
|
810
|
+
|
|
701
811
|
/**
|
|
702
812
|
* Resolve which (non-vault) ServiceEntry should handle a given request.
|
|
703
813
|
* Generic longest-prefix match across every service's `paths[]`. Vault
|
|
@@ -784,15 +894,15 @@ async function proxyToService(
|
|
|
784
894
|
) {
|
|
785
895
|
return new Response("not found", { status: 404 });
|
|
786
896
|
}
|
|
787
|
-
// Consult FIRST_PARTY_FALLBACKS as a fallback for
|
|
788
|
-
// Pre-hub#310, scribe's `stripPrefix: true` lived
|
|
789
|
-
// fallback; post-#310 scribe
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
// (
|
|
793
|
-
// the
|
|
794
|
-
// fallback → false (preserving the
|
|
795
|
-
// services).
|
|
897
|
+
// Consult FIRST_PARTY_FALLBACKS / KNOWN_MODULES as a fallback for
|
|
898
|
+
// `stripPrefix` (#196). Pre-hub#310, scribe's `stripPrefix: true` lived
|
|
899
|
+
// only in hub's vendored fallback; post-#310 scribe (and post-D3 channel)
|
|
900
|
+
// self-register with `stripPrefix: true` on their rows, so the entry-based
|
|
901
|
+
// path is authoritative. The registry consultation now matters only for
|
|
902
|
+
// notes (the remaining FALLBACK short) and legacy rows written before the
|
|
903
|
+
// module emitted the field (KNOWN_MODULES canonicalStripPrefix).
|
|
904
|
+
// Explicit-on-entry still wins; absent → fallback → false (preserving the
|
|
905
|
+
// keep-prefix default for unknown services).
|
|
796
906
|
const stripPrefix = stripPrefixFor(match.entry);
|
|
797
907
|
const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
|
|
798
908
|
// Resolve canonical short for classification — falls back to the
|
|
@@ -807,14 +917,15 @@ async function proxyToService(
|
|
|
807
917
|
/**
|
|
808
918
|
* Resolve effective `stripPrefix` for a service entry. Explicit on-entry
|
|
809
919
|
* wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (for
|
|
810
|
-
* notes
|
|
811
|
-
*
|
|
812
|
-
* their services.json row).
|
|
813
|
-
* is the next fallback — covers
|
|
814
|
-
* module wrote its row before the
|
|
815
|
-
* (e.g. pre-scribe#50
|
|
816
|
-
*
|
|
817
|
-
* party
|
|
920
|
+
* notes — vault/scribe/runner retired their FALLBACK entries in hub#310,
|
|
921
|
+
* channel in boundary D3; all self-register with the canonical `stripPrefix`
|
|
922
|
+
* declaration on their services.json row).
|
|
923
|
+
* `KNOWN_MODULES[short]?.canonicalStripPrefix` is the next fallback — covers
|
|
924
|
+
* the edge case where a self-registering module wrote its row before the
|
|
925
|
+
* `stripPrefix` field was being emitted (e.g. pre-scribe#50 or pre-D3
|
|
926
|
+
* channel services.json rows). Defaults to `false` — keep the prefix —
|
|
927
|
+
* matching the pre-#196 dispatch behavior for unknown / third-party
|
|
928
|
+
* services.
|
|
818
929
|
*
|
|
819
930
|
* For a self-registering KNOWN_MODULES short whose row is missing entirely
|
|
820
931
|
* (uninstalled, never booted), the request never reaches this code path —
|
|
@@ -873,6 +984,11 @@ export interface HubFetchDeps {
|
|
|
873
984
|
* the doc reflect `parachute vault create` etc. without re-running expose.
|
|
874
985
|
*/
|
|
875
986
|
manifestPath?: string;
|
|
987
|
+
/**
|
|
988
|
+
* Path to `connections.json` (the Connections store, P5). Tests point this
|
|
989
|
+
* at a tmpdir; production defaults to `<CONFIG_DIR>/connections.json`.
|
|
990
|
+
*/
|
|
991
|
+
connectionsStorePath?: string;
|
|
876
992
|
/**
|
|
877
993
|
* Directory containing the built SPA bundle (`index.html` + `assets/`). When
|
|
878
994
|
* absent, the hub auto-resolves to `<repo>/web/ui/dist/` — handy for the
|
|
@@ -1024,8 +1140,9 @@ function defaultSpaDistDir(): string {
|
|
|
1024
1140
|
* The admin SPA serves at a single mount: `/admin/*` (since hub#231).
|
|
1025
1141
|
*
|
|
1026
1142
|
* Routes:
|
|
1027
|
-
* - `/admin
|
|
1028
|
-
* - `/admin/vaults
|
|
1143
|
+
* - `/admin/` → Home (the admin-shell overview)
|
|
1144
|
+
* - `/admin/vaults` → legacy vault list; feature-detects a new-manifest
|
|
1145
|
+
* vault and forwards to `/vault/admin/` (B5)
|
|
1029
1146
|
* - `/admin/permissions` → OAuth consent grant management
|
|
1030
1147
|
* - `/admin/tokens` → token registry: mint / list / revoke
|
|
1031
1148
|
*
|
|
@@ -1497,11 +1614,17 @@ export function hubFetch(
|
|
|
1497
1614
|
async function dispatch(): Promise<Response> {
|
|
1498
1615
|
// 301 back-compat for the pre-hub#231 admin-SPA mounts:
|
|
1499
1616
|
//
|
|
1500
|
-
// `/vault`
|
|
1501
|
-
//
|
|
1617
|
+
// `/vault`, `/vault/new` → `/vault/admin/` (vault's own daemon-level
|
|
1618
|
+
// admin surface — B5, 2026-06-09 hub-module-
|
|
1619
|
+
// boundary migration. These pointed at
|
|
1620
|
+
// `/admin/vaults[/new]` until B5; with the
|
|
1621
|
+
// list+create UX module-owned, pointing DIRECTLY
|
|
1622
|
+
// at the target avoids a redirect → SPA-load →
|
|
1623
|
+
// client-side-forward chain)
|
|
1502
1624
|
// `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1625
|
+
// /admin/vaults survives as the feature-detected
|
|
1626
|
+
// legacy list, so the target stays valid on both
|
|
1627
|
+
// old-vault and new-vault boxes)
|
|
1505
1628
|
// `/hub/permissions` → `/admin/permissions`
|
|
1506
1629
|
// `/hub/tokens` → `/admin/tokens`
|
|
1507
1630
|
// `/hub` (bare) → `/admin/vaults`
|
|
@@ -1513,12 +1636,13 @@ export function hubFetch(
|
|
|
1513
1636
|
// POST endpoint to protect.
|
|
1514
1637
|
//
|
|
1515
1638
|
// `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
|
|
1516
|
-
// content proxy (Notes PWA, etc.), not the admin SPA.
|
|
1639
|
+
// content proxy (Notes PWA, etc.), not the admin SPA. The exact-match
|
|
1640
|
+
// condition here also never touches `/vault/admin*` — the daemon-level
|
|
1641
|
+
// mount dispatched further down.
|
|
1517
1642
|
if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
|
|
1518
|
-
const sub = pathname === "/vault/new" ? "/new" : "";
|
|
1519
1643
|
return new Response("", {
|
|
1520
1644
|
status: 301,
|
|
1521
|
-
headers: { location: `/admin
|
|
1645
|
+
headers: { location: `/vault/admin/${url.search}` },
|
|
1522
1646
|
});
|
|
1523
1647
|
}
|
|
1524
1648
|
if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
|
|
@@ -1627,8 +1751,11 @@ export function hubFetch(
|
|
|
1627
1751
|
// succeeding, so `probeDbLiveness` alone would report `db:"ok"` on a
|
|
1628
1752
|
// database that's gone from disk (the /health lie the issue calls
|
|
1629
1753
|
// out). `probeDbPath` stat()s the path + compares inodes; on a
|
|
1630
|
-
//
|
|
1631
|
-
//
|
|
1754
|
+
// "replaced" verdict it self-heals in-place (reopen-or-exit, adopt
|
|
1755
|
+
// the new inode); on a "gone" verdict it exits the process directly
|
|
1756
|
+
// (#621 — a full wipe needs a clean platform-manager restart, not an
|
|
1757
|
+
// empty-db reopen). Either way we surface the fault so the #591
|
|
1758
|
+
// adoption probe + monitoring see it.
|
|
1632
1759
|
const pathVerdict = deps?.probeDbPath?.();
|
|
1633
1760
|
if (pathVerdict === "gone" || pathVerdict === "replaced") {
|
|
1634
1761
|
// One-request anomaly on "replaced": probeDbPath already healed the
|
|
@@ -1792,7 +1919,35 @@ export function hubFetch(
|
|
|
1792
1919
|
);
|
|
1793
1920
|
}
|
|
1794
1921
|
|
|
1795
|
-
|
|
1922
|
+
// Bare `/` → `/admin` (admin-shell IA, R1). The home page and the admin
|
|
1923
|
+
// SPA used to be two disconnected surfaces; `/` now funnels straight into
|
|
1924
|
+
// the single coherent admin shell, whose Home/Overview carries the
|
|
1925
|
+
// discovery content (hub-native sections, modules, user surfaces) that
|
|
1926
|
+
// used to live here.
|
|
1927
|
+
//
|
|
1928
|
+
// Ordering matters: this sits AFTER the fresh-hub wizard funnel above
|
|
1929
|
+
// (so a brand-new operator still lands on `/admin/setup`, not a 404 inside
|
|
1930
|
+
// the shell) and AFTER the pre-admin lockout (so an admin-less hub still
|
|
1931
|
+
// 503s API callers correctly). 302 (not 301) — `/` is reclaimed for
|
|
1932
|
+
// future use, but a permanent redirect would get cached and we may want
|
|
1933
|
+
// `/` back later.
|
|
1934
|
+
//
|
|
1935
|
+
// The signed-out path is preserved: a signed-out visitor lands on
|
|
1936
|
+
// `/admin`, where the SPA's AuthIndicator shows a "Sign in" link that
|
|
1937
|
+
// round-trips through `/login?next=/admin/...` and back. We don't pin the
|
|
1938
|
+
// redirect on session state — the shell handles both auth states itself.
|
|
1939
|
+
//
|
|
1940
|
+
// `/hub.html` is INTENTIONALLY excluded: it still renders the discovery
|
|
1941
|
+
// page (used by the static `parachute expose --set-path=/` disk file and
|
|
1942
|
+
// any bookmark to the explicit `.html`). Only the bare `/` redirects.
|
|
1943
|
+
if (pathname === "/") {
|
|
1944
|
+
return new Response(null, {
|
|
1945
|
+
status: 302,
|
|
1946
|
+
headers: { location: "/admin" },
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if (pathname === "/hub.html") {
|
|
1796
1951
|
// When a DB is configured, render the discovery page dynamically so
|
|
1797
1952
|
// the header carries a "Signed in as <name>" affordance for the
|
|
1798
1953
|
// active session. Without a DB, fall back to the static disk file
|
|
@@ -2060,6 +2215,46 @@ export function hubFetch(
|
|
|
2060
2215
|
});
|
|
2061
2216
|
}
|
|
2062
2217
|
|
|
2218
|
+
// DELETE /vaults/<name> — destroy a vault with the full identity
|
|
2219
|
+
// cascade (B1, 2026-06-09 hub-module-boundary: lifecycle symmetry).
|
|
2220
|
+
// Bearer parachute:host:admin + {"confirm": "<name>"} body. See
|
|
2221
|
+
// admin-vaults.handleDeleteVault for the enumerated cascade.
|
|
2222
|
+
if (pathname.startsWith("/vaults/")) {
|
|
2223
|
+
if (!getDb) return dbNotConfigured();
|
|
2224
|
+
const name = decodeURIComponent(pathname.slice("/vaults/".length));
|
|
2225
|
+
const services = readManifestLenient(manifestPath).services;
|
|
2226
|
+
// Channel's row carries its MANIFEST name — resolve via
|
|
2227
|
+
// findServiceByShort (see the /admin/connections note below).
|
|
2228
|
+
const channelEntry = findServiceByShort(services, "channel");
|
|
2229
|
+
const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
|
|
2230
|
+
const resolveVaultOrigin = (vaultName: string): string | null => {
|
|
2231
|
+
const match = findVaultUpstream(
|
|
2232
|
+
readManifestLenient(manifestPath).services,
|
|
2233
|
+
`/vault/${vaultName}`,
|
|
2234
|
+
);
|
|
2235
|
+
return match ? `http://127.0.0.1:${match.port}` : null;
|
|
2236
|
+
};
|
|
2237
|
+
const supervisor = deps?.supervisor;
|
|
2238
|
+
return handleDeleteVault(req, name, {
|
|
2239
|
+
db: getDb(),
|
|
2240
|
+
issuer: oauthDeps(req).issuer,
|
|
2241
|
+
manifestPath,
|
|
2242
|
+
connectionsStorePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
|
|
2243
|
+
channelOrigin,
|
|
2244
|
+
resolveVaultOrigin,
|
|
2245
|
+
// Daemon eviction — the same in-process supervisor the lifecycle
|
|
2246
|
+
// verbs drive (module-ops API); restarting vault evicts the open
|
|
2247
|
+
// store handle + re-runs selfRegister (services.json path rebuild).
|
|
2248
|
+
...(supervisor
|
|
2249
|
+
? {
|
|
2250
|
+
restartVaultModule: async () => {
|
|
2251
|
+
await supervisor.restart("vault");
|
|
2252
|
+
},
|
|
2253
|
+
}
|
|
2254
|
+
: {}),
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2063
2258
|
// Note: the old `/hub/*` SPA mount has been retired. Known prefixes
|
|
2064
2259
|
// (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
|
|
2065
2260
|
// 301-redirected at the top of dispatch. Any other `/hub/*` path falls
|
|
@@ -2073,6 +2268,97 @@ export function hubFetch(
|
|
|
2073
2268
|
});
|
|
2074
2269
|
}
|
|
2075
2270
|
|
|
2271
|
+
if (pathname === "/admin/channel-token") {
|
|
2272
|
+
if (!getDb) return dbNotConfigured();
|
|
2273
|
+
return handleChannelToken(req, {
|
|
2274
|
+
db: getDb(),
|
|
2275
|
+
issuer: oauthDeps(req).issuer,
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// Generic per-module config-UI bearer mint (2026-06-09 modular-UI
|
|
2280
|
+
// architecture, P3). `<short>:admin` for any single-audience module —
|
|
2281
|
+
// the admin scope each module-owned config UI needs to call its own
|
|
2282
|
+
// endpoints. Cookie-gated to the first-admin operator, exactly like
|
|
2283
|
+
// /admin/channel-token + /admin/vault-admin-token. Gated on
|
|
2284
|
+
// self-registration (services.json row + readable module.json) with the
|
|
2285
|
+
// bootstrap registries as a fallback (boundary C5) — a genuinely
|
|
2286
|
+
// third-party module mints here with zero hub code changes. Vault is
|
|
2287
|
+
// per-instance and routed to /admin/vault-admin-token/<name> instead.
|
|
2288
|
+
if (pathname.startsWith("/admin/module-token/")) {
|
|
2289
|
+
if (!getDb) return dbNotConfigured();
|
|
2290
|
+
const short = decodeURIComponent(pathname.slice("/admin/module-token/".length));
|
|
2291
|
+
return handleModuleToken(req, short, {
|
|
2292
|
+
db: getDb(),
|
|
2293
|
+
issuer: oauthDeps(req).issuer,
|
|
2294
|
+
// Lenient + per-request — a module that self-registered since hub
|
|
2295
|
+
// boot is mintable without a restart (see hub#406 for lenient).
|
|
2296
|
+
readServices: () => readManifestLenient(manifestPath).services,
|
|
2297
|
+
...(deps?.readModuleManifest ? { readModuleManifest: deps.readModuleManifest } : {}),
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// Note: the legacy `/admin/channels` bespoke vault-channel orchestration
|
|
2302
|
+
// endpoint (pre-Connections, hub#624 era) was retired in boundary D1 —
|
|
2303
|
+
// superseded by the general engine below. Channel's own admin page
|
|
2304
|
+
// drives `/admin/connections` + `/admin/channel-token`.
|
|
2305
|
+
|
|
2306
|
+
// Connections — the GENERAL module event→action engine (2026-06-09
|
|
2307
|
+
// modular-UI architecture, P5). `/api/connections/catalog` (GET) returns
|
|
2308
|
+
// the available events/actions read from each installed module's
|
|
2309
|
+
// `module.json`; `/admin/connections` (GET/POST) lists + provisions;
|
|
2310
|
+
// `/admin/connections/:id` (DELETE) tears down. Cookie-gated to the
|
|
2311
|
+
// first-admin operator. The provisioning engine derives the vault
|
|
2312
|
+
// trigger's webhook + scope from the SINK action's declaration —
|
|
2313
|
+
// nothing is channel-hardcoded.
|
|
2314
|
+
if (
|
|
2315
|
+
pathname === "/api/connections/catalog" ||
|
|
2316
|
+
pathname === "/admin/connections" ||
|
|
2317
|
+
pathname.startsWith("/admin/connections/")
|
|
2318
|
+
) {
|
|
2319
|
+
if (!getDb) return dbNotConfigured();
|
|
2320
|
+
const services = readManifestLenient(manifestPath).services;
|
|
2321
|
+
// Channel's services.json row carries its MANIFEST name
|
|
2322
|
+
// (`parachute-channel`), not the bare short `channel` — resolve via
|
|
2323
|
+
// findServiceByShort so the lookup matches the on-disk row. (A bare
|
|
2324
|
+
// `s.name === "channel"` never matched, leaving channelOrigin null →
|
|
2325
|
+
// "channel not installed".)
|
|
2326
|
+
const channelEntry = findServiceByShort(services, "channel");
|
|
2327
|
+
const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
|
|
2328
|
+
const resolveVaultOrigin = (vaultName: string): string | null => {
|
|
2329
|
+
const match = findVaultUpstream(
|
|
2330
|
+
readManifestLenient(manifestPath).services,
|
|
2331
|
+
`/vault/${vaultName}`,
|
|
2332
|
+
);
|
|
2333
|
+
return match ? `http://127.0.0.1:${match.port}` : null;
|
|
2334
|
+
};
|
|
2335
|
+
const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
|
|
2336
|
+
const modules = await collectInstalledModules(manifestPath, readManifestFn);
|
|
2337
|
+
const connectionsDeps: ConnectionsDeps = {
|
|
2338
|
+
db: getDb(),
|
|
2339
|
+
hubOrigin: oauthDeps(req).issuer,
|
|
2340
|
+
modules,
|
|
2341
|
+
resolveVaultOrigin,
|
|
2342
|
+
channelOrigin,
|
|
2343
|
+
storePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
|
|
2344
|
+
};
|
|
2345
|
+
if (pathname === "/api/connections/catalog") {
|
|
2346
|
+
return handleConnectionsCatalog(req, connectionsDeps);
|
|
2347
|
+
}
|
|
2348
|
+
// CSRF belt (hub#632, boundary C1): cookie-authed POST/DELETE must
|
|
2349
|
+
// carry a matching Origin. The seam's canonical consumer — channel's
|
|
2350
|
+
// admin page POSTing link-vault with `credentials: "include"` — is a
|
|
2351
|
+
// same-origin fetch() and passes; see origin-check.ts
|
|
2352
|
+
// `assertSameOriginForCookieMutation` for the belted-endpoint
|
|
2353
|
+
// enumeration.
|
|
2354
|
+
{
|
|
2355
|
+
const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
|
|
2356
|
+
if (rejected) return rejected;
|
|
2357
|
+
}
|
|
2358
|
+
const subPath = pathname.slice("/admin/connections".length);
|
|
2359
|
+
return handleConnections(req, subPath, connectionsDeps);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2076
2362
|
if (pathname.startsWith("/admin/vault-admin-token/")) {
|
|
2077
2363
|
if (!getDb) return dbNotConfigured();
|
|
2078
2364
|
const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
|
|
@@ -2215,28 +2501,12 @@ export function hubFetch(
|
|
|
2215
2501
|
});
|
|
2216
2502
|
}
|
|
2217
2503
|
|
|
2218
|
-
//
|
|
2219
|
-
//
|
|
2220
|
-
//
|
|
2221
|
-
//
|
|
2222
|
-
//
|
|
2223
|
-
//
|
|
2224
|
-
// Diverges from the action endpoints in two ways: doesn't require a
|
|
2225
|
-
// supervisor (we just proxy to the running module's HTTP surface, not
|
|
2226
|
-
// spawn a child), and mints a `<short>:admin` token at proxy time so
|
|
2227
|
-
// the upstream auth gate is satisfied without forcing the SPA bearer
|
|
2228
|
-
// to carry per-module scopes.
|
|
2229
|
-
{
|
|
2230
|
-
const configMatch = parseModulesConfigPath(pathname);
|
|
2231
|
-
if (configMatch) {
|
|
2232
|
-
if (!getDb) return dbNotConfigured();
|
|
2233
|
-
return handleApiModulesConfig(req, configMatch, {
|
|
2234
|
-
db: getDb(),
|
|
2235
|
-
issuer: oauthDeps(req).issuer,
|
|
2236
|
-
manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
2237
|
-
});
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2504
|
+
// NOTE: the hub-hosted generic per-module config proxy
|
|
2505
|
+
// (`/api/modules/<short>/config[/schema]`) + its SPA form were RETIRED in
|
|
2506
|
+
// the 2026-06-09 modular-UI architecture P3. Config is module-owned +
|
|
2507
|
+
// hub-framed now: the Modules page "Configure" action opens the module's
|
|
2508
|
+
// OWN config UI (`configUiUrl`), which mints its admin Bearer from the
|
|
2509
|
+
// cookie-gated `/admin/module-token/<short>` (or `/admin/channel-token`).
|
|
2240
2510
|
|
|
2241
2511
|
// Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
|
|
2242
2512
|
if (pathname.startsWith("/api/modules/")) {
|
|
@@ -2666,8 +2936,10 @@ export function hubFetch(
|
|
|
2666
2936
|
}
|
|
2667
2937
|
|
|
2668
2938
|
// Legacy `/admin/config` (server-rendered module-config portal, #46)
|
|
2669
|
-
// retired post-SPA-rework. 301 →
|
|
2670
|
-
// post-login redirect lands somewhere useful
|
|
2939
|
+
// retired post-SPA-rework. 301 → /admin/vaults so any bookmark or stale
|
|
2940
|
+
// post-login redirect lands somewhere useful (post-B5 that's the
|
|
2941
|
+
// feature-detected vaults surface — legacy list on an old-vault box,
|
|
2942
|
+
// forward to /vault/admin/ on a new one). The route stays here in
|
|
2671
2943
|
// dispatch order (above the /admin/* SPA catch-all) so the redirect
|
|
2672
2944
|
// wins over a SPA shell render.
|
|
2673
2945
|
if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
|
|
@@ -2677,10 +2949,31 @@ export function hubFetch(
|
|
|
2677
2949
|
});
|
|
2678
2950
|
}
|
|
2679
2951
|
|
|
2952
|
+
// /vault/admin + /vault/admin/* — the vault MODULE's daemon-level
|
|
2953
|
+
// admin surface (B-route, 2026-06-09 hub-module-boundary). MUST run
|
|
2954
|
+
// BEFORE the per-vault proxy dispatch below: this is a module-level
|
|
2955
|
+
// mount, not an instance path. "admin" is a reserved vault name (B2h)
|
|
2956
|
+
// so no instance can claim it, and `findVaultUpstream` never sees it —
|
|
2957
|
+
// no consumer fabricates a phantom vault named "admin". Exact-segment
|
|
2958
|
+
// match only: a vault instance named e.g. "adminx" still routes
|
|
2959
|
+
// per-instance through the branch below.
|
|
2960
|
+
if (pathname === "/vault/admin" || pathname.startsWith("/vault/admin/")) {
|
|
2961
|
+
// Same per-request force-change-password gate as the per-vault proxy
|
|
2962
|
+
// (P0-1 / hub#469) — a pre-rotation signed-in user can't reach the
|
|
2963
|
+
// multi-vault admin surface on an un-rotated temp password either.
|
|
2964
|
+
if (getDb) {
|
|
2965
|
+
const gate = forceChangePasswordGate(getDb(), req);
|
|
2966
|
+
if (gate) return gate;
|
|
2967
|
+
}
|
|
2968
|
+
const proxied = await proxyToVaultAdmin(req, manifestPath, deps?.supervisor, peerAddr);
|
|
2969
|
+
if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
|
|
2970
|
+
return new Response("not found", { status: 404 });
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2680
2973
|
// /vault/<name>/* — per-vault content proxy. Stays as user-facing
|
|
2681
2974
|
// surface (the Notes PWA loads through here, etc.). The bare `/vault`
|
|
2682
2975
|
// and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
|
|
2683
|
-
// the top of dispatch now. Multi-segment requests like
|
|
2976
|
+
// the top of dispatch now (to `/vault/admin/` since B5). Multi-segment requests like
|
|
2684
2977
|
// `/vault/<unknown>/health` are vault-API shapes targeting a
|
|
2685
2978
|
// non-existent vault and 404 directly — there's no SPA-shell fallback
|
|
2686
2979
|
// here anymore (the SPA moved to /admin), so we can't accidentally
|
|
@@ -2710,9 +3003,9 @@ export function hubFetch(
|
|
|
2710
3003
|
// SPA's own router renders the page and handles 404 client-side for
|
|
2711
3004
|
// unknown sub-paths.
|
|
2712
3005
|
if (pathname === "/admin" || pathname === "/admin/") {
|
|
2713
|
-
// Unprefixed /admin → SPA shell
|
|
2714
|
-
//
|
|
2715
|
-
//
|
|
3006
|
+
// Unprefixed /admin → SPA shell at its index route. The SPA's
|
|
3007
|
+
// basename is /admin, so the router lands on / and renders Home
|
|
3008
|
+
// (the admin-shell overview).
|
|
2716
3009
|
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
2717
3010
|
return serveSpa(spaDistDir, pathname, "/admin");
|
|
2718
3011
|
}
|
package/src/invites.ts
CHANGED
|
@@ -289,3 +289,25 @@ export function revokeInvite(db: Database, tokenHash: string, now: Date = new Da
|
|
|
289
289
|
.run(now.toISOString(), tokenHash);
|
|
290
290
|
return res.changes > 0;
|
|
291
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Vault-delete cascade step (B1, 2026-06-09 hub-module-boundary): invalidate
|
|
295
|
+
* every UNREDEEMED invite pinned to the deleted vault. An un-revoked pending
|
|
296
|
+
* invite carrying `vault_name = <deleted>` would re-provision (resurrect)
|
|
297
|
+
* the name on redemption — the cascade must close that door. Used/already-
|
|
298
|
+
* revoked invites are untouched (terminal states). `vault_name` is an exact
|
|
299
|
+
* `=` comparison — no pattern matching. Returns the number of invites
|
|
300
|
+
* newly revoked.
|
|
301
|
+
*/
|
|
302
|
+
export function revokeInvitesForVault(
|
|
303
|
+
db: Database,
|
|
304
|
+
vaultName: string,
|
|
305
|
+
now: Date = new Date(),
|
|
306
|
+
): number {
|
|
307
|
+
const res = db
|
|
308
|
+
.prepare(
|
|
309
|
+
"UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND used_at IS NULL AND revoked_at IS NULL",
|
|
310
|
+
)
|
|
311
|
+
.run(now.toISOString(), vaultName);
|
|
312
|
+
return Number(res.changes);
|
|
313
|
+
}
|