@openparachute/hub 0.7.1 → 0.7.2-rc.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/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +8 -4
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
package/src/hub-server.ts
CHANGED
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
* supervisor restart)
|
|
52
52
|
* /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
|
|
53
53
|
* /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
|
|
54
|
-
* /admin/
|
|
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)
|
|
55
56
|
* /admin/module-token/<short> (GET) → generic module config-UI bearer mint <short>:admin (cookie-gated)
|
|
56
57
|
* /api/connections/catalog (GET) → events/actions across installed modules (cookie-gated)
|
|
57
58
|
* /admin/connections (POST/GET) → connection provision/list (cookie-gated; POST CSRF-belted)
|
|
@@ -59,11 +60,18 @@
|
|
|
59
60
|
* /admin/connections/<id>/renew (POST) → credential renewal (H4; Bearer = the credential itself, proof of possession)
|
|
60
61
|
* /admin/connections/<id>/claim (POST) → claim/reconcile a directly-delivered credential → pending record (surface#113; Bearer = the credential itself)
|
|
61
62
|
* /admin/connections/<id>/approve (POST) → operator approval of a pending claim (cookie-gated; CSRF-belted)
|
|
63
|
+
* /admin/grants (PUT/GET) → agent-connector grant upsert/list (4b-1; host-admin Bearer)
|
|
64
|
+
* /admin/grants/<id>/material (GET) → injectable secret for an APPROVED grant (4b-1; host-admin Bearer)
|
|
65
|
+
* /admin/grants/<id>/approve (POST) → operator approves a grant — mint (vault) / store (service) / static-bearer-or-start-OAuth (mcp, 4b-2) (cookie-gated; CSRF-belted)
|
|
66
|
+
* /admin/grants/<id>/revoke (POST) → operator revokes a grant — drop the stored secret + best-effort issuer revoke (mcp, 4b-2) (cookie-gated; CSRF-belted)
|
|
67
|
+
* /oauth/agent-grant/callback (GET) → OAuth-client redirect target for an mcp grant consent (4b-2; single-use state, no Bearer, NOT same-origin-belted — cross-site redirect in)
|
|
62
68
|
*
|
|
63
69
|
* # "CSRF-belted" = strict same-origin Origin check on cookie-authed
|
|
64
70
|
* # mutations (hub#632, boundary C1) — origin-check.ts
|
|
65
71
|
* # `assertSameOriginForCookieMutation` carries the canonical enumeration.
|
|
66
72
|
* /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
|
|
73
|
+
* /api/admin-lock (GET) → screen-lock status (cookie-gated; first-admin)
|
|
74
|
+
* /api/admin-lock/{set,change,remove,unlock,lock,heartbeat} (POST) → manage the optional admin idle PIN lock (cookie-gated; CSRF)
|
|
67
75
|
* /api/hub (GET) → hub version + uptime + install-source (host:admin)
|
|
68
76
|
* /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
|
|
69
77
|
* /api/hub/upgrade/status (GET) → poll the on-disk hub-upgrade status (host:admin)
|
|
@@ -153,7 +161,12 @@ import pkg from "../package.json" with { type: "json" };
|
|
|
153
161
|
import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
|
|
154
162
|
import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
|
|
155
163
|
import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
|
|
156
|
-
import {
|
|
164
|
+
import {
|
|
165
|
+
type AgentGrantsDeps,
|
|
166
|
+
handleAgentGrants,
|
|
167
|
+
handleOAuthGrantCallback,
|
|
168
|
+
} from "./admin-agent-grants.ts";
|
|
169
|
+
import { handleAgentToken } from "./admin-agent-token.ts";
|
|
157
170
|
import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
158
171
|
import {
|
|
159
172
|
type ConnectionsDeps,
|
|
@@ -176,6 +189,7 @@ import {
|
|
|
176
189
|
handleAccountChangePasswordPost,
|
|
177
190
|
handleAccountHomeGet,
|
|
178
191
|
} from "./api-account.ts";
|
|
192
|
+
import { handleAdminLock } from "./api-admin-lock.ts";
|
|
179
193
|
import { handleHubUpgrade, handleHubUpgradeStatus } from "./api-hub-upgrade.ts";
|
|
180
194
|
import { handleApiHub } from "./api-hub.ts";
|
|
181
195
|
import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "./api-invites.ts";
|
|
@@ -458,7 +472,7 @@ function hasVaultInstalled(manifestPath: string): boolean {
|
|
|
458
472
|
* posture as `/api/modules`.
|
|
459
473
|
*
|
|
460
474
|
* `mount` is the first non-`.parachute` services.json path (the proxied
|
|
461
|
-
* user-facing prefix, e.g. `/
|
|
475
|
+
* user-facing prefix, e.g. `/agent`), which the engine joins with a sink
|
|
462
476
|
* action's `endpoint` to build the hub-proxied webhook.
|
|
463
477
|
*/
|
|
464
478
|
async function collectInstalledModules(
|
|
@@ -929,7 +943,7 @@ async function proxyToVault(
|
|
|
929
943
|
* hub-module-boundary migration), NOT a per-instance path.
|
|
930
944
|
*
|
|
931
945
|
* Resolution is via `findServiceByShort(services, "vault")` (the canonical
|
|
932
|
-
* self-registered `parachute-vault` row — same shape as the
|
|
946
|
+
* self-registered `parachute-vault` row — same shape as the agentEntry
|
|
933
947
|
* lookup in the Connections deps), deliberately NOT `findVaultUpstream`:
|
|
934
948
|
* vault must NOT self-register `/vault/admin` in `paths[]`, because every
|
|
935
949
|
* consumer that derives instance names from paths (`vaultInstanceNameFor`,
|
|
@@ -1055,7 +1069,8 @@ async function proxyToService(
|
|
|
1055
1069
|
}
|
|
1056
1070
|
// Consult FIRST_PARTY_FALLBACKS / KNOWN_MODULES as a fallback for
|
|
1057
1071
|
// `stripPrefix` (#196). Pre-hub#310, scribe's `stripPrefix: true` lived
|
|
1058
|
-
// only in hub's vendored fallback; post-#310 scribe (and post-D3
|
|
1072
|
+
// only in hub's vendored fallback; post-#310 scribe (and post-D3 agent,
|
|
1073
|
+
// renamed from channel 2026-06-17)
|
|
1059
1074
|
// self-register with `stripPrefix: true` on their rows, so the entry-based
|
|
1060
1075
|
// path is authoritative. The registry consultation now matters only for
|
|
1061
1076
|
// notes (the remaining FALLBACK short) and legacy rows written before the
|
|
@@ -1077,12 +1092,12 @@ async function proxyToService(
|
|
|
1077
1092
|
* Resolve effective `stripPrefix` for a service entry. Explicit on-entry
|
|
1078
1093
|
* wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (for
|
|
1079
1094
|
* notes — vault/scribe/runner retired their FALLBACK entries in hub#310,
|
|
1080
|
-
* channel in boundary D3; all self-register
|
|
1081
|
-
* declaration on their services.json row).
|
|
1095
|
+
* agent (renamed from channel 2026-06-17) in boundary D3; all self-register
|
|
1096
|
+
* with the canonical `stripPrefix` declaration on their services.json row).
|
|
1082
1097
|
* `KNOWN_MODULES[short]?.canonicalStripPrefix` is the next fallback — covers
|
|
1083
1098
|
* the edge case where a self-registering module wrote its row before the
|
|
1084
1099
|
* `stripPrefix` field was being emitted (e.g. pre-scribe#50 or pre-D3
|
|
1085
|
-
*
|
|
1100
|
+
* agent services.json rows). Defaults to `false` — keep the prefix —
|
|
1086
1101
|
* matching the pre-#196 dispatch behavior for unknown / third-party
|
|
1087
1102
|
* services.
|
|
1088
1103
|
*
|
|
@@ -1148,6 +1163,17 @@ export interface HubFetchDeps {
|
|
|
1148
1163
|
* at a tmpdir; production defaults to `<CONFIG_DIR>/connections.json`.
|
|
1149
1164
|
*/
|
|
1150
1165
|
connectionsStorePath?: string;
|
|
1166
|
+
/**
|
|
1167
|
+
* Path to `agent-grants.json` (the agent-connector grant store, 4b-1). Tests
|
|
1168
|
+
* point this at a tmpdir; production defaults to `<CONFIG_DIR>/agent-grants.json`.
|
|
1169
|
+
*/
|
|
1170
|
+
agentGrantsStorePath?: string;
|
|
1171
|
+
/**
|
|
1172
|
+
* Path to `agent-oauth-flows.json` (the in-flight agent-grant OAuth consents,
|
|
1173
|
+
* 4b-2). Tests point this at a tmpdir; production defaults to
|
|
1174
|
+
* `<CONFIG_DIR>/agent-oauth-flows.json`.
|
|
1175
|
+
*/
|
|
1176
|
+
agentOAuthFlowsStorePath?: string;
|
|
1151
1177
|
/**
|
|
1152
1178
|
* Directory containing the built SPA bundle (`index.html` + `assets/`). When
|
|
1153
1179
|
* absent, the hub auto-resolves to `<repo>/web/ui/dist/` — handy for the
|
|
@@ -2143,6 +2169,27 @@ export function hubFetch(
|
|
|
2143
2169
|
}
|
|
2144
2170
|
}
|
|
2145
2171
|
|
|
2172
|
+
// `/channel/*` 301-redirects to `/agent/*` — back-compat for the
|
|
2173
|
+
// 2026-06-17 channel→agent module rename. Operator bookmarks, an
|
|
2174
|
+
// un-upgraded chat/config UI's deep links, and any externally-shared
|
|
2175
|
+
// `/channel/mcp/<name>` URL keep resolving for one release cycle while
|
|
2176
|
+
// the module's canonical mount moves to `/agent`. Method-agnostic, same
|
|
2177
|
+
// shape as the `/notes/*` redirect above (the agent's read-write surface
|
|
2178
|
+
// is its own daemon API, not the hub mount, so a re-issued GET is fine).
|
|
2179
|
+
// Matches `/channel` exactly and any `/channel/...` subpath, but NOT a
|
|
2180
|
+
// longer-prefix module like a hypothetical `/channelthing` — the guard is
|
|
2181
|
+
// exact-or-slash-delimited. Query string is preserved. (The generic
|
|
2182
|
+
// services-proxy fallthrough below would otherwise 404 a `/channel/*`
|
|
2183
|
+
// request once the module self-registers under `/agent`.)
|
|
2184
|
+
if (pathname === "/channel" || pathname.startsWith("/channel/")) {
|
|
2185
|
+
const dest = new URL(req.url);
|
|
2186
|
+
dest.pathname = `/agent${pathname.slice("/channel".length)}`;
|
|
2187
|
+
return new Response("", {
|
|
2188
|
+
status: 301,
|
|
2189
|
+
headers: { location: dest.pathname + dest.search },
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2146
2193
|
// CORS preflight for the public OAuth + discovery surface. Browsers
|
|
2147
2194
|
// issue OPTIONS before any non-simple cross-origin request — third-party
|
|
2148
2195
|
// SPAs hitting `/oauth/register` (RFC 7591 DCR), `/oauth/token`,
|
|
@@ -2314,6 +2361,41 @@ export function hubFetch(
|
|
|
2314
2361
|
}
|
|
2315
2362
|
}
|
|
2316
2363
|
|
|
2364
|
+
// Fresh-hub `/login` funnel (hub#644). `/login` is a browser-facing,
|
|
2365
|
+
// server-rendered HTML surface (the sign-in form) — but on a no-admin
|
|
2366
|
+
// box there is no account to sign in as, so the JSON-503 gate below
|
|
2367
|
+
// would render as raw `{"error":"setup_required",...}` text in the
|
|
2368
|
+
// visitor's tab. This is exactly the path a visitor takes when they
|
|
2369
|
+
// load an open module surface (e.g. /vault/admin/) and click its
|
|
2370
|
+
// "Sign in" banner, which links to `/login?next=<surface>`. Funnel the
|
|
2371
|
+
// GET to the wizard instead, mirroring the `/` + `/hub.html` redirect
|
|
2372
|
+
// above (same shape, same justification: never emit a JSON body on an
|
|
2373
|
+
// HTML surface). 302 (not 301) so it disappears the moment setup
|
|
2374
|
+
// completes and the real sign-in form takes over.
|
|
2375
|
+
//
|
|
2376
|
+
// Scoped to `userCount === 0` (the true no-admin state), NOT the
|
|
2377
|
+
// broader `needsWizard`: once an admin exists, that operator must be
|
|
2378
|
+
// able to reach the sign-in form even if no vault is installed yet
|
|
2379
|
+
// (env-seed deploys). Only GET is funneled — a POST to `/login`
|
|
2380
|
+
// pre-admin has no account to authenticate and falls through to the
|
|
2381
|
+
// JSON-503 gate, the right shape for a stray non-browser caller.
|
|
2382
|
+
//
|
|
2383
|
+
// The `?next=<surface>` param is intentionally dropped: there's no
|
|
2384
|
+
// account yet to return the visitor to, and the open surface they came
|
|
2385
|
+
// from likely can't function until setup completes. They land on the
|
|
2386
|
+
// wizard, not back on that surface.
|
|
2387
|
+
//
|
|
2388
|
+
// `cache-control: no-store` (the `/` + `/hub.html` funnel above omits
|
|
2389
|
+
// it) — this 302 reflects transient pre-setup state that flips the
|
|
2390
|
+
// moment an admin is created, so it must never be cached by a CDN or
|
|
2391
|
+
// bfcache and serve a stale "go finish setup" to a now-set-up hub.
|
|
2392
|
+
if (getDb && pathname === "/login" && req.method === "GET" && userCount(getDb()) === 0) {
|
|
2393
|
+
return new Response(null, {
|
|
2394
|
+
status: 302,
|
|
2395
|
+
headers: { location: "/admin/setup", "cache-control": "no-store" },
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2317
2399
|
// Pre-admin lockout. When the hub has booted with no admin row (the
|
|
2318
2400
|
// fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
|
|
2319
2401
|
// /admin/setup is walked), every operator-facing surface that requires
|
|
@@ -2633,6 +2715,38 @@ export function hubFetch(
|
|
|
2633
2715
|
return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
|
|
2634
2716
|
}
|
|
2635
2717
|
|
|
2718
|
+
// Agent-connector OAuth-client callback (Phase 4b-2). The operator's
|
|
2719
|
+
// browser is redirected here by a REMOTE issuer after consenting to a
|
|
2720
|
+
// `kind:mcp` grant. Standalone server-rendered route — NOT under /admin/*,
|
|
2721
|
+
// so the SPA catch-all never swallows it. GET, no Bearer: the single-use
|
|
2722
|
+
// `state` it carries is the CSRF defense (this is a cross-site redirect IN
|
|
2723
|
+
// from the remote issuer, so the same-origin belt does NOT apply). The
|
|
2724
|
+
// handler looks up the pending flow by `state`, exchanges the code at the
|
|
2725
|
+
// remote token endpoint, stores the grant material, and renders a tiny
|
|
2726
|
+
// HTML "connected" / error page (never a token).
|
|
2727
|
+
if (pathname === "/oauth/agent-grant/callback") {
|
|
2728
|
+
if (!getDb) return dbNotConfigured();
|
|
2729
|
+
if (req.method !== "GET") {
|
|
2730
|
+
return new Response("method not allowed", { status: 405 });
|
|
2731
|
+
}
|
|
2732
|
+
const resolveVaultOrigin = (vaultName: string): string | null => {
|
|
2733
|
+
const match = findVaultUpstream(
|
|
2734
|
+
readManifestLenient(manifestPath).services,
|
|
2735
|
+
`/vault/${vaultName}`,
|
|
2736
|
+
);
|
|
2737
|
+
return match ? `http://127.0.0.1:${match.port}` : null;
|
|
2738
|
+
};
|
|
2739
|
+
const agentGrantsDeps: AgentGrantsDeps = {
|
|
2740
|
+
db: getDb(),
|
|
2741
|
+
hubOrigin: oauthDeps(req).issuer,
|
|
2742
|
+
storePath: deps?.agentGrantsStorePath ?? join(CONFIG_DIR, "agent-grants.json"),
|
|
2743
|
+
flowsStorePath:
|
|
2744
|
+
deps?.agentOAuthFlowsStorePath ?? join(CONFIG_DIR, "agent-oauth-flows.json"),
|
|
2745
|
+
resolveVaultOrigin,
|
|
2746
|
+
};
|
|
2747
|
+
return handleOAuthGrantCallback(req, agentGrantsDeps);
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2636
2750
|
if (pathname === "/vaults") {
|
|
2637
2751
|
if (!getDb) return dbNotConfigured();
|
|
2638
2752
|
return handleCreateVault(req, {
|
|
@@ -2649,10 +2763,10 @@ export function hubFetch(
|
|
|
2649
2763
|
if (!getDb) return dbNotConfigured();
|
|
2650
2764
|
const name = decodeURIComponent(pathname.slice("/vaults/".length));
|
|
2651
2765
|
const services = readManifestLenient(manifestPath).services;
|
|
2652
|
-
//
|
|
2766
|
+
// Agent's row carries its MANIFEST name — resolve via
|
|
2653
2767
|
// findServiceByShort (see the /admin/connections note below).
|
|
2654
|
-
const
|
|
2655
|
-
const
|
|
2768
|
+
const agentEntry = findServiceByShort(services, "agent");
|
|
2769
|
+
const agentOrigin = agentEntry ? `http://127.0.0.1:${agentEntry.port}` : null;
|
|
2656
2770
|
const resolveVaultOrigin = (vaultName: string): string | null => {
|
|
2657
2771
|
const match = findVaultUpstream(
|
|
2658
2772
|
readManifestLenient(manifestPath).services,
|
|
@@ -2666,7 +2780,7 @@ export function hubFetch(
|
|
|
2666
2780
|
issuer: oauthDeps(req).issuer,
|
|
2667
2781
|
manifestPath,
|
|
2668
2782
|
connectionsStorePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
|
|
2669
|
-
|
|
2783
|
+
agentOrigin,
|
|
2670
2784
|
resolveVaultOrigin,
|
|
2671
2785
|
resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
|
|
2672
2786
|
// Daemon eviction — the same in-process supervisor the lifecycle
|
|
@@ -2695,9 +2809,19 @@ export function hubFetch(
|
|
|
2695
2809
|
});
|
|
2696
2810
|
}
|
|
2697
2811
|
|
|
2812
|
+
// Back-compat: the agent module's admin-token mint moved from
|
|
2813
|
+
// `/admin/channel-token` to `/admin/agent-token` in the 2026-06-17
|
|
2814
|
+
// channel→agent rename. 301-redirect the old path so operator bookmarks
|
|
2815
|
+
// + any un-upgraded UI fallback keep working for one release cycle.
|
|
2698
2816
|
if (pathname === "/admin/channel-token") {
|
|
2817
|
+
const dest = new URL(req.url);
|
|
2818
|
+
dest.pathname = "/admin/agent-token";
|
|
2819
|
+
return Response.redirect(dest.toString(), 301);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
if (pathname === "/admin/agent-token") {
|
|
2699
2823
|
if (!getDb) return dbNotConfigured();
|
|
2700
|
-
return
|
|
2824
|
+
return handleAgentToken(req, {
|
|
2701
2825
|
db: getDb(),
|
|
2702
2826
|
issuer: oauthDeps(req).issuer,
|
|
2703
2827
|
});
|
|
@@ -2707,7 +2831,7 @@ export function hubFetch(
|
|
|
2707
2831
|
// architecture, P3). `<short>:admin` for any single-audience module —
|
|
2708
2832
|
// the admin scope each module-owned config UI needs to call its own
|
|
2709
2833
|
// endpoints. Cookie-gated to the first-admin operator, exactly like
|
|
2710
|
-
// /admin/
|
|
2834
|
+
// /admin/agent-token + /admin/vault-admin-token. Gated on
|
|
2711
2835
|
// self-registration (services.json row + readable module.json) with the
|
|
2712
2836
|
// bootstrap registries as a fallback (boundary C5) — a genuinely
|
|
2713
2837
|
// third-party module mints here with zero hub code changes. Vault is
|
|
@@ -2727,8 +2851,9 @@ export function hubFetch(
|
|
|
2727
2851
|
|
|
2728
2852
|
// Note: the legacy `/admin/channels` bespoke vault-channel orchestration
|
|
2729
2853
|
// endpoint (pre-Connections, hub#624 era) was retired in boundary D1 —
|
|
2730
|
-
// superseded by the general engine below.
|
|
2731
|
-
// drives `/admin/connections` +
|
|
2854
|
+
// superseded by the general engine below. The agent module's own admin
|
|
2855
|
+
// page (renamed from channel 2026-06-17) drives `/admin/connections` +
|
|
2856
|
+
// `/admin/agent-token`.
|
|
2732
2857
|
|
|
2733
2858
|
// Connections — the GENERAL module event→action engine (2026-06-09
|
|
2734
2859
|
// modular-UI architecture, P5). `/api/connections/catalog` (GET) returns
|
|
@@ -2737,7 +2862,7 @@ export function hubFetch(
|
|
|
2737
2862
|
// `/admin/connections/:id` (DELETE) tears down. Cookie-gated to the
|
|
2738
2863
|
// first-admin operator. The provisioning engine derives the vault
|
|
2739
2864
|
// trigger's webhook + scope from the SINK action's declaration —
|
|
2740
|
-
// nothing is
|
|
2865
|
+
// nothing is agent-hardcoded.
|
|
2741
2866
|
if (
|
|
2742
2867
|
pathname === "/api/connections/catalog" ||
|
|
2743
2868
|
pathname === "/admin/connections" ||
|
|
@@ -2745,13 +2870,14 @@ export function hubFetch(
|
|
|
2745
2870
|
) {
|
|
2746
2871
|
if (!getDb) return dbNotConfigured();
|
|
2747
2872
|
const services = readManifestLenient(manifestPath).services;
|
|
2748
|
-
//
|
|
2749
|
-
// (`parachute-
|
|
2873
|
+
// Agent's services.json row carries its MANIFEST name
|
|
2874
|
+
// (`parachute-agent`), not the bare short `agent` — resolve via
|
|
2750
2875
|
// findServiceByShort so the lookup matches the on-disk row. (A bare
|
|
2751
|
-
// `s.name === "
|
|
2752
|
-
// "
|
|
2753
|
-
|
|
2754
|
-
const
|
|
2876
|
+
// `s.name === "agent"` never matched, leaving agentOrigin null →
|
|
2877
|
+
// "agent not installed".) A legacy un-upgraded `parachute-channel` row
|
|
2878
|
+
// still resolves here via the LEGACY_MANIFEST_ALIASES fallback.
|
|
2879
|
+
const agentEntry = findServiceByShort(services, "agent");
|
|
2880
|
+
const agentOrigin = agentEntry ? `http://127.0.0.1:${agentEntry.port}` : null;
|
|
2755
2881
|
const resolveVaultOrigin = (vaultName: string): string | null => {
|
|
2756
2882
|
const match = findVaultUpstream(
|
|
2757
2883
|
readManifestLenient(manifestPath).services,
|
|
@@ -2767,15 +2893,15 @@ export function hubFetch(
|
|
|
2767
2893
|
modules,
|
|
2768
2894
|
resolveVaultOrigin,
|
|
2769
2895
|
resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
|
|
2770
|
-
|
|
2896
|
+
agentOrigin,
|
|
2771
2897
|
storePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
|
|
2772
2898
|
};
|
|
2773
2899
|
if (pathname === "/api/connections/catalog") {
|
|
2774
2900
|
return handleConnectionsCatalog(req, connectionsDeps);
|
|
2775
2901
|
}
|
|
2776
2902
|
// CSRF belt (hub#632, boundary C1): cookie-authed POST/DELETE must
|
|
2777
|
-
// carry a matching Origin. The seam's canonical consumer —
|
|
2778
|
-
// admin page POSTing link-vault with `credentials: "include"` — is a
|
|
2903
|
+
// carry a matching Origin. The seam's canonical consumer — the agent
|
|
2904
|
+
// module's admin page POSTing link-vault with `credentials: "include"` — is a
|
|
2779
2905
|
// same-origin fetch() and passes; see origin-check.ts
|
|
2780
2906
|
// `assertSameOriginForCookieMutation` for the belted-endpoint
|
|
2781
2907
|
// enumeration.
|
|
@@ -2787,6 +2913,43 @@ export function hubFetch(
|
|
|
2787
2913
|
return handleConnections(req, subPath, connectionsDeps);
|
|
2788
2914
|
}
|
|
2789
2915
|
|
|
2916
|
+
// Agent-connector GRANTS — the approval-gated resource-grant subsystem
|
|
2917
|
+
// (Phase 4b-1, agent-connectors design 2026-06-17). Generalizes the
|
|
2918
|
+
// Connections engine from "event→action triggers" to "approval-gated
|
|
2919
|
+
// resource grants": an agent declares connections it WANTS beyond its
|
|
2920
|
+
// def-vault; the agent module registers each as a pending grant (PUT,
|
|
2921
|
+
// host-admin Bearer); the operator approves per-connection (POST
|
|
2922
|
+
// /approve, first-admin cookie); the hub mints (vault) / stores (service)
|
|
2923
|
+
// the secret; the agent module fetches it at spawn (GET /material,
|
|
2924
|
+
// host-admin Bearer). Two auth classes split by route inside the handler.
|
|
2925
|
+
if (pathname === "/admin/grants" || pathname.startsWith("/admin/grants/")) {
|
|
2926
|
+
if (!getDb) return dbNotConfigured();
|
|
2927
|
+
const resolveVaultOrigin = (vaultName: string): string | null => {
|
|
2928
|
+
const match = findVaultUpstream(
|
|
2929
|
+
readManifestLenient(manifestPath).services,
|
|
2930
|
+
`/vault/${vaultName}`,
|
|
2931
|
+
);
|
|
2932
|
+
return match ? `http://127.0.0.1:${match.port}` : null;
|
|
2933
|
+
};
|
|
2934
|
+
const agentGrantsDeps: AgentGrantsDeps = {
|
|
2935
|
+
db: getDb(),
|
|
2936
|
+
hubOrigin: oauthDeps(req).issuer,
|
|
2937
|
+
storePath: deps?.agentGrantsStorePath ?? join(CONFIG_DIR, "agent-grants.json"),
|
|
2938
|
+
flowsStorePath:
|
|
2939
|
+
deps?.agentOAuthFlowsStorePath ?? join(CONFIG_DIR, "agent-oauth-flows.json"),
|
|
2940
|
+
resolveVaultOrigin,
|
|
2941
|
+
};
|
|
2942
|
+
// CSRF belt (same posture as /admin/connections, hub#632): a no-op for
|
|
2943
|
+
// the host-admin-Bearer PUT/GET (Bearer → not a browser CSRF), and the
|
|
2944
|
+
// real gate on the cookie-authed POST /approve + /revoke.
|
|
2945
|
+
{
|
|
2946
|
+
const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
|
|
2947
|
+
if (rejected) return rejected;
|
|
2948
|
+
}
|
|
2949
|
+
const subPath = pathname.slice("/admin/grants".length);
|
|
2950
|
+
return handleAgentGrants(req, subPath, agentGrantsDeps);
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2790
2953
|
if (pathname.startsWith("/admin/vault-admin-token/")) {
|
|
2791
2954
|
if (!getDb) return dbNotConfigured();
|
|
2792
2955
|
const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
|
|
@@ -2813,6 +2976,25 @@ export function hubFetch(
|
|
|
2813
2976
|
return handleApiMe(req, { db: getDb() });
|
|
2814
2977
|
}
|
|
2815
2978
|
|
|
2979
|
+
// Admin screen-lock management (optional idle PIN lock for the admin
|
|
2980
|
+
// UI). Session-cookie-gated to the first admin; these manage the lock
|
|
2981
|
+
// itself so they are NOT behind the lock gate. The lock GATE lives in
|
|
2982
|
+
// the four `/admin/*-token` mint handlers (admin-lock.ts:requireUnlocked)
|
|
2983
|
+
// — it does NOT touch `/oauth/*`, so the OAuth issuer is unaffected.
|
|
2984
|
+
if (pathname === "/api/admin-lock" || pathname.startsWith("/api/admin-lock/")) {
|
|
2985
|
+
if (!getDb) return dbNotConfigured();
|
|
2986
|
+
// CSRF belt (same posture as /admin/connections): these are
|
|
2987
|
+
// cookie-authed JSON mutations. The handler ALSO checks a double-
|
|
2988
|
+
// submit `__csrf` token; the Origin belt is defense-in-depth on the
|
|
2989
|
+
// POST paths (GET status is read-shaped and skips it).
|
|
2990
|
+
{
|
|
2991
|
+
const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
|
|
2992
|
+
if (rejected) return rejected;
|
|
2993
|
+
}
|
|
2994
|
+
const subpath = pathname.slice("/api/admin-lock".length);
|
|
2995
|
+
return handleAdminLock(req, subpath, { db: getDb() });
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2816
2998
|
// SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
|
|
2817
2999
|
// endpoint — the hub is NOT a supervised module (no /api/modules/hub/*),
|
|
2818
3000
|
// so it gets its own route. Checked BEFORE the `/api/hub` exact match
|
|
@@ -2934,7 +3116,7 @@ export function hubFetch(
|
|
|
2934
3116
|
// the 2026-06-09 modular-UI architecture P3. Config is module-owned +
|
|
2935
3117
|
// hub-framed now: the Modules page "Configure" action opens the module's
|
|
2936
3118
|
// OWN config UI (`configUiUrl`), which mints its admin Bearer from the
|
|
2937
|
-
// cookie-gated `/admin/module-token/<short>` (or `/admin/
|
|
3119
|
+
// cookie-gated `/admin/module-token/<short>` (or `/admin/agent-token`).
|
|
2938
3120
|
|
|
2939
3121
|
// Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
|
|
2940
3122
|
if (pathname.startsWith("/api/modules/")) {
|
package/src/hub-settings.ts
CHANGED
|
@@ -28,13 +28,14 @@ import type { Database } from "bun:sqlite";
|
|
|
28
28
|
export type HubSettingKey =
|
|
29
29
|
| "setup_expose_mode"
|
|
30
30
|
| "pending_first_client_auto_approve_until"
|
|
31
|
-
// hub#272:
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
31
|
+
// hub#272: DEPRECATED 2026-06-23 (Austen's report). Used to hold an
|
|
32
|
+
// auto-minted operator token surfaced once on the wizard's done screen
|
|
33
|
+
// so the MCP command could pre-fill a `--header "Authorization: Bearer
|
|
34
|
+
// <token>"` flag. The auto-mint was removed when vault went OAuth-
|
|
35
|
+
// default (parachute-vault #491) — nothing writes this row anymore.
|
|
36
|
+
// The key is retained so the done-step GET can defensively clear any
|
|
37
|
+
// stale row a pre-upgrade hub left behind (it never renders the value).
|
|
38
|
+
// Drop the member once no live hub_settings tables carry the row.
|
|
38
39
|
| "setup_minted_token"
|
|
39
40
|
// hub#267: the typed vault name. Persisted at vault POST time so the
|
|
40
41
|
// done step can render the operator's choice in the MCP URL +
|
|
@@ -91,7 +92,21 @@ export type HubSettingKey =
|
|
|
91
92
|
// the deprecation window. Stored as the literal string "true" /
|
|
92
93
|
// "false"; any other value parses as "redirect on" (the migration
|
|
93
94
|
// default — operators must opt out, not opt in).
|
|
94
|
-
| "notes_redirect_disabled"
|
|
95
|
+
| "notes_redirect_disabled"
|
|
96
|
+
// Admin-UI screen-lock PIN (hub admin-lock feature). The argon2id hash of
|
|
97
|
+
// the operator's lock PIN. Absent row = lock feature OFF (today's behavior
|
|
98
|
+
// exactly — the admin UI is gated only by the password-login session). When
|
|
99
|
+
// set, the admin token-mint chokepoints refuse to mint while the operator's
|
|
100
|
+
// session is "locked" (no fresh unlock, or idle-expired). NEVER plaintext;
|
|
101
|
+
// the hash sits at the same operator-local trust boundary as the password
|
|
102
|
+
// hashes + signing keys already in hub.db (see migration v11's note on
|
|
103
|
+
// at-rest encryption). The "unlocked-until" state is per-session + in-memory
|
|
104
|
+
// (admin-lock.ts) — never persisted, never in the cookie.
|
|
105
|
+
| "admin_lock_pin_hash"
|
|
106
|
+
// Idle timeout for the admin screen-lock, in seconds. Optional override of
|
|
107
|
+
// the built-in default (DEFAULT_ADMIN_LOCK_IDLE_SECONDS). Stored as a
|
|
108
|
+
// stringified integer; absent / unparseable falls back to the default.
|
|
109
|
+
| "admin_lock_idle_seconds";
|
|
95
110
|
|
|
96
111
|
export type SetupExposeMode = "localhost" | "tailnet" | "public";
|
|
97
112
|
|
package/src/jwt-sign.ts
CHANGED
|
@@ -152,7 +152,11 @@ export type TokenCreatedVia =
|
|
|
152
152
|
| "cli_mint"
|
|
153
153
|
| "operator_mint"
|
|
154
154
|
| "connection_provision"
|
|
155
|
-
| "connection_credential"
|
|
155
|
+
| "connection_credential"
|
|
156
|
+
// Agent-connector grants (Phase 4b-1) — a vault token the hub mints when the
|
|
157
|
+
// operator approves an agent's `vault:<name>:<verb>` connection grant. Stored
|
|
158
|
+
// in the agent-grants store; registered here so revoke can drop it.
|
|
159
|
+
| "agent_grant";
|
|
156
160
|
|
|
157
161
|
export interface SignedRefreshToken {
|
|
158
162
|
/** Opaque token to return to the client. NOT recoverable from the DB. */
|
package/src/module-manifest.ts
CHANGED
|
@@ -124,7 +124,7 @@ export interface ModuleAction {
|
|
|
124
124
|
* Bearer` (P5). For a `vault-trigger`, this is persisted as the trigger's
|
|
125
125
|
* long-lived `action.auth.bearer` scope — the credential the sink module
|
|
126
126
|
* validates on every callback. Sourced from the action declaration so the
|
|
127
|
-
* hub never hardcodes a per-module scope.
|
|
127
|
+
* hub never hardcodes a per-module scope. Agent ships `"agent:send"`.
|
|
128
128
|
*/
|
|
129
129
|
readonly scope?: string;
|
|
130
130
|
/** Opaque (P1) descriptor of how the hub provisions this action. */
|
|
@@ -777,7 +777,7 @@ function asActions(
|
|
|
777
777
|
// `scope` is minted by the hub into a 90-day webhook bearer presented to
|
|
778
778
|
// THIS module's own endpoint, which validates `aud:<name>` + a scope in
|
|
779
779
|
// its own namespace. A legitimate `action.scope` is therefore always in
|
|
780
|
-
// the declaring module's namespace (
|
|
780
|
+
// the declaring module's namespace (agent.message.deliver → agent:send).
|
|
781
781
|
// Enforcing `<ns> === name` blocks a malicious module declaring e.g.
|
|
782
782
|
// `vault:default:admin` and tricking the hub into minting a cross-module
|
|
783
783
|
// privilege-escalating token when an operator wires a Connection to it.
|