@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.
Files changed (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. 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/channel-token (GET) → channel UI bearer mint (cookie-gated)
54
+ * /admin/agent-token (GET) → agent UI bearer mint (cookie-gated)
55
+ * /admin/channel-token (GET) → 301 → /admin/agent-token (legacy; channel→agent rename 2026-06-17)
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 { handleChannelToken } from "./admin-channel-token.ts";
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. `/channel`), which the engine joins with a sink
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 channelEntry
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 channel)
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 with the canonical `stripPrefix`
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
- * channel services.json rows). Defaults to `false` — keep the prefix —
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
- // Channel's row carries its MANIFEST name — resolve via
2766
+ // Agent's row carries its MANIFEST name — resolve via
2653
2767
  // findServiceByShort (see the /admin/connections note below).
2654
- const channelEntry = findServiceByShort(services, "channel");
2655
- const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
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
- channelOrigin,
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 handleChannelToken(req, {
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/channel-token + /admin/vault-admin-token. Gated on
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. Channel's own admin page
2731
- // drives `/admin/connections` + `/admin/channel-token`.
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 channel-hardcoded.
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
- // Channel's services.json row carries its MANIFEST name
2749
- // (`parachute-channel`), not the bare short `channel` — resolve via
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 === "channel"` never matched, leaving channelOrigin null →
2752
- // "channel not installed".)
2753
- const channelEntry = findServiceByShort(services, "channel");
2754
- const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
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
- channelOrigin,
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 — channel's
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/channel-token`).
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/")) {
@@ -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: auto-minted operator token surfaced once on the wizard's
32
- // done screen. Single-use the done-step renderer reads + deletes the
33
- // row so a subsequent GET (page refresh, back button) doesn't re-show
34
- // the secret. Lives in hub_settings rather than tokens because it's a
35
- // wizard-flow ephemeral, not a persistent issued credential — the
36
- // mintOperatorToken call still records the jti in the `tokens`
37
- // registry, so revocation works as usual.
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. */
@@ -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. Channel ships `"channel:send"`.
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 (channel.message.deliver → channel:send).
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.