@openparachute/hub 0.7.0 → 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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -4,7 +4,7 @@
4
4
  * architecture, P5). Generalizes the channel-specific `/admin/channels`
5
5
  * endpoint (hub#624 era; retired in boundary D1): "add a vault-backed channel"
6
6
  * is just the first connection, `vault.note.created (filter
7
- * #channel-message/inbound) → channel.message.deliver`.
7
+ * #agent-message/inbound) → agent.message.deliver`.
8
8
  *
9
9
  * THE CONCEPT. A connection wires "when [EVENT] in [source module] (filter) →
10
10
  * do [ACTION] in [sink module]". The sink is ALWAYS an action. Modules declare
@@ -17,7 +17,7 @@
17
17
  * - the SINK action's `endpoint` → the hub-proxied webhook the vault calls
18
18
  * (`<hub-origin>/<sink-mount><endpoint>`). NOT a hardcoded channel path.
19
19
  * - the SINK action's `scope` → the OAuth scope minted into the webhook's
20
- * `Authorization: Bearer`. NOT a hardcoded `channel:send`.
20
+ * `Authorization: Bearer`. NOT a hardcoded `agent:send`.
21
21
  * - the SOURCE event key → the vault trigger's `events` (`note.created` →
22
22
  * `["created"]`, `note.updated` → `["updated"]`).
23
23
  * - the SOURCE filter → the vault trigger's `when` predicate (`tags` /
@@ -27,13 +27,49 @@
27
27
  *
28
28
  * THE ONE SINK-SPECIFIC PREREQUISITE. A vault-backed channel additionally needs
29
29
  * its reply path wired: a `vault:<v>:write` token + a `channels.json` entry so
30
- * the session can reply. That's a property of the channel SINK, not of the
31
- * engine — it runs only for `sink.module === "channel"` and is clearly fenced
32
- * (`prepareChannelSink`). Everything else is declaration-driven.
30
+ * the session can reply. That's a property of the agent SINK, not of the
31
+ * engine — it runs only for `sink.module === "agent"` and is clearly fenced
32
+ * (`prepareAgentSink`). Everything else is declaration-driven.
33
33
  *
34
34
  * AUTH. Same gate as the admin-token mints: a cookie-gated operator session
35
35
  * pinned to the first admin. The catalog (`/api/connections/catalog`) is
36
- * operator-only metadata; it uses the same session gate.
36
+ * operator-only metadata; it uses the same session gate. TWO exceptions:
37
+ * `POST /admin/connections/:id/renew` (H4 credential renewal) and
38
+ * `POST /admin/connections/:id/claim` (surface#113 claim/reconcile) both
39
+ * authenticate by PROOF OF POSSESSION of the connection's current
40
+ * still-valid credential as Bearer — no operator click; an expired
41
+ * credential can neither renew nor claim (the operator re-links in the UI).
42
+ *
43
+ * THE SECOND KIND — `kind: "credential"` (H4, surface-runtime design). A
44
+ * module declares `credentials` in module.json (scope TEMPLATE
45
+ * `vault:{vault}:read|write` — never admin, never another namespace; both
46
+ * the manifest validator and this engine enforce it). The operator approves
47
+ * granting <module> a standing tag-scoped credential on <vault>: the hub
48
+ * mints a REGISTERED 90-day JWT carrying `permissions.scoped_tags`, delivers
49
+ * it to the module's declared endpoint over loopback (authenticated with a
50
+ * short-lived `<module>:admin` bearer — the channel-config delivery shape),
51
+ * and persists the jti + scope + tags on the ConnectionRecord. Teardown
52
+ * revokes the jtis + best-effort notifies the endpoint with a removal
53
+ * payload. Tags are REQUIRED for write scopes (tags are the sharing scope);
54
+ * read may be tag-scoped or vault-wide per the operator's choice (the
55
+ * approval UI defaults to tag-scoped).
56
+ *
57
+ * CLAIM / RECONCILE (surface#113). A credential delivered to a module
58
+ * OUTSIDE this engine (e.g. minted via the CLI and POSTed straight to the
59
+ * module's delivery endpoint) leaves no ConnectionRecord, so jti-bound
60
+ * renewal 404s at the pre-expiry window. `POST /admin/connections/:id/claim`
61
+ * lets the module backfill the record: it presents the credential it ALREADY
62
+ * holds as Bearer (the renew endpoint's proof-of-possession posture), and
63
+ * the hub — after verifying the jti is REGISTERED in the tokens table and
64
+ * that the token's scope/aud/vault_scope match what the claimed connection
65
+ * id implies — writes the record in `status: "pending"`. A claim grants
66
+ * NOTHING: renewal refuses pending records; only the operator-gated
67
+ * `POST /admin/connections/:id/approve` flips it active, after which the
68
+ * existing renewal flow proceeds unchanged. Expired/revoked/unregistered/
69
+ * mismatched claims are refused with ONE generic error (no oracle on
70
+ * registry contents); re-linking through the operator flow is the recovery
71
+ * path. Rejecting a claim = DELETE on the pending record (which revokes the
72
+ * claimed jti — the safe direction).
37
73
  */
38
74
  import type { Database } from "bun:sqlite";
39
75
  import {
@@ -44,8 +80,20 @@ import {
44
80
  readConnections,
45
81
  removeConnection,
46
82
  } from "./connections-store.ts";
47
- import { recordTokenMint, revokeTokenByJti, signAccessToken } from "./jwt-sign.ts";
48
- import type { ModuleAction, ModuleEvent, ModuleManifest } from "./module-manifest.ts";
83
+ import {
84
+ findTokenRowByJti,
85
+ recordTokenMint,
86
+ revokeTokenByJti,
87
+ signAccessToken,
88
+ validateAccessToken,
89
+ } from "./jwt-sign.ts";
90
+ import {
91
+ CREDENTIAL_SCOPE_TEMPLATE_RE,
92
+ type ModuleAction,
93
+ type ModuleCredential,
94
+ type ModuleEvent,
95
+ type ModuleManifest,
96
+ } from "./module-manifest.ts";
49
97
  import { findSession, parseSessionCookie } from "./sessions.ts";
50
98
  import { isFirstAdmin } from "./users.ts";
51
99
  import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
@@ -89,7 +137,7 @@ export interface InstalledModuleInfo {
89
137
  /** Parsed `.parachute/module.json`. */
90
138
  readonly manifest: ModuleManifest;
91
139
  /**
92
- * The module's user-facing mount path under the hub origin (e.g. `/channel`),
140
+ * The module's user-facing mount path under the hub origin (e.g. `/agent`),
93
141
  * used to build a hub-proxied webhook from a sink action's `endpoint`.
94
142
  * `null` when the module declares no user-facing mount.
95
143
  */
@@ -111,8 +159,18 @@ export interface ConnectionsDeps {
111
159
  * services.json, or `null` when no vault by that name is installed.
112
160
  */
113
161
  resolveVaultOrigin: (vaultName: string) => string | null;
114
- /** Loopback origin for the channel daemon, or `null` when not installed. */
115
- channelOrigin: string | null;
162
+ /**
163
+ * Resolve a module's loopback origin (e.g. `http://127.0.0.1:1946`) by
164
+ * short name, or `null` when not installed (H4 — credential delivery +
165
+ * removal notification go direct to the daemon, not through the hub
166
+ * proxy). Optional: callers that never touch credential connections (and
167
+ * the vault-delete cascade on a hub without H4 consumers) may omit it;
168
+ * delivery then fails with a clear `module_unreachable` step error and
169
+ * teardown logs the skipped notification.
170
+ */
171
+ resolveModuleOrigin?: (short: string) => string | null;
172
+ /** Loopback origin for the agent daemon, or `null` when not installed. */
173
+ agentOrigin: string | null;
116
174
  /** Absolute path to `connections.json` in the hub state dir. */
117
175
  storePath: string;
118
176
  /** Test seam — `globalThis.fetch` in production. */
@@ -141,6 +199,21 @@ interface CatalogAction {
141
199
  /** The provision descriptor (e.g. `{ type: "vault-trigger" }`), opaque to the SPA. */
142
200
  provision: unknown;
143
201
  }
202
+ /**
203
+ * A credential declaration (H4) surfaced through the catalog so module UIs
204
+ * can render the link flow (which vaults to offer, which tags to suggest).
205
+ * NO tokens, NO secrets — declaration metadata only, like everything else
206
+ * in the catalog.
207
+ */
208
+ interface CatalogCredential {
209
+ module: string;
210
+ key: string;
211
+ title: string;
212
+ description: string | null;
213
+ /** The scope TEMPLATE, e.g. `vault:{vault}:read`. */
214
+ scope: string;
215
+ endpoint: string;
216
+ }
144
217
  /**
145
218
  * A connection preset declared in a module's `module.json`
146
219
  * `connectionTemplates` (boundary D2). Drives the SPA builder's one-click
@@ -175,10 +248,12 @@ export function buildCatalog(modules: InstalledModuleInfo[]): {
175
248
  events: CatalogEvent[];
176
249
  actions: CatalogAction[];
177
250
  templates: CatalogTemplate[];
251
+ credentials: CatalogCredential[];
178
252
  } {
179
253
  const events: CatalogEvent[] = [];
180
254
  const actions: CatalogAction[] = [];
181
255
  const templates: CatalogTemplate[] = [];
256
+ const credentials: CatalogCredential[] = [];
182
257
  for (const { short, manifest } of modules) {
183
258
  for (const e of manifest.events ?? []) {
184
259
  events.push({
@@ -197,6 +272,16 @@ export function buildCatalog(modules: InstalledModuleInfo[]): {
197
272
  provision: a.provision ?? null,
198
273
  });
199
274
  }
275
+ for (const c of manifest.credentials ?? []) {
276
+ credentials.push({
277
+ module: short,
278
+ key: c.key,
279
+ title: c.title,
280
+ description: c.description ?? null,
281
+ scope: c.scope,
282
+ endpoint: c.endpoint,
283
+ });
284
+ }
200
285
  for (const t of manifest.connectionTemplates ?? []) {
201
286
  // Only event→action presets surface here — a template without BOTH
202
287
  // source and sink (e.g. scribe's `kind: "config"` link, consumed by
@@ -220,7 +305,7 @@ export function buildCatalog(modules: InstalledModuleInfo[]): {
220
305
  });
221
306
  }
222
307
  }
223
- return { events, actions, templates };
308
+ return { events, actions, templates, credentials };
224
309
  }
225
310
 
226
311
  export async function handleConnectionsCatalog(
@@ -238,24 +323,64 @@ export async function handleConnectionsCatalog(
238
323
 
239
324
  export async function handleConnections(
240
325
  req: Request,
241
- /** Path after `/admin/connections` — `""` for the collection, `/<id>` for an item. */
326
+ /** Path after `/admin/connections` — `""` for the collection, `/<id>` for
327
+ * an item, `/<id>/renew` for credential renewal (H4). */
242
328
  subPath: string,
243
329
  deps: ConnectionsDeps,
244
330
  ): Promise<Response> {
331
+ const method = req.method;
332
+ const segments = subPath.startsWith("/")
333
+ ? subPath
334
+ .slice(1)
335
+ .split("/")
336
+ .map((s) => decodeURIComponent(s))
337
+ : [];
338
+
339
+ // H4 — credential renewal. Routed BEFORE the operator gate: the renew
340
+ // endpoint authenticates by proof of possession of the connection's
341
+ // current still-valid credential (Bearer), not by an operator session —
342
+ // a headless module daemon renews without a click. Everything else below
343
+ // stays operator-gated.
344
+ if (segments.length === 2 && segments[1] === "renew") {
345
+ if (method !== "POST") {
346
+ return jsonError(405, "method_not_allowed", "use POST on /admin/connections/:id/renew");
347
+ }
348
+ return renewCredentialConnection(req, segments[0] ?? "", deps);
349
+ }
350
+
351
+ // surface#113 — claim/reconcile. Same auth class as renew (proof of
352
+ // possession of the credential as Bearer, no operator session), so it's
353
+ // routed before the gate too. A successful claim only writes a PENDING
354
+ // record — the operator-gated approve below is what activates it.
355
+ if (segments.length === 2 && segments[1] === "claim") {
356
+ if (method !== "POST") {
357
+ return jsonError(405, "method_not_allowed", "use POST on /admin/connections/:id/claim");
358
+ }
359
+ return claimCredentialConnection(req, segments[0] ?? "", deps);
360
+ }
361
+
245
362
  const gate = operatorGate(req, deps);
246
363
  if (gate) return gate;
247
364
  const { userId } = sessionUser(req, deps);
248
365
 
249
- const method = req.method;
250
- const itemId = subPath.startsWith("/") ? decodeURIComponent(subPath.slice(1)) : "";
366
+ const itemId = segments.length === 1 ? (segments[0] ?? "") : "";
367
+
368
+ // surface#113 — operator approval of a pending claim. Cookie-gated like
369
+ // create/teardown (and CSRF-belted by the dispatch in hub-server.ts).
370
+ if (segments.length === 2 && segments[1] === "approve") {
371
+ if (method !== "POST") {
372
+ return jsonError(405, "method_not_allowed", "use POST on /admin/connections/:id/approve");
373
+ }
374
+ return approveCredentialConnection(segments[0] ?? "", deps);
375
+ }
251
376
 
252
- if (itemId === "" && method === "GET") return listConnections(deps);
253
- if (itemId === "" && method === "POST") return createConnection(req, userId, deps);
377
+ if (segments.length === 0 && method === "GET") return listConnections(deps);
378
+ if (segments.length === 0 && method === "POST") return createConnection(req, userId, deps);
254
379
  if (itemId !== "" && method === "DELETE") return teardownConnection(itemId, userId, deps);
255
380
  return jsonError(
256
381
  405,
257
382
  "method_not_allowed",
258
- "use GET/POST on /admin/connections or DELETE on /admin/connections/:id",
383
+ "use GET/POST on /admin/connections, DELETE on /admin/connections/:id, or POST on /admin/connections/:id/renew, /claim, /approve",
259
384
  );
260
385
  }
261
386
 
@@ -269,6 +394,14 @@ function listConnections(deps: ConnectionsDeps): Response {
269
394
  // the response is stable regardless of the on-disk record shape.
270
395
  const connections = readConnections(deps.storePath).map((c) => ({
271
396
  id: c.id,
397
+ // Kind discriminator (H4): absent = event→action; "credential" = a
398
+ // standing module credential. Projected so the SPA can render the two
399
+ // shapes distinctly.
400
+ ...(c.kind !== undefined ? { kind: c.kind } : {}),
401
+ // Approval state (surface#113): "pending" = a module-initiated claim
402
+ // awaiting the operator's one-click approve in the Connections view.
403
+ // Absent = active.
404
+ ...(c.status !== undefined ? { status: c.status } : {}),
272
405
  source: c.source,
273
406
  sink: c.sink,
274
407
  provisioned: c.provisioned,
@@ -294,6 +427,8 @@ function isLegacyRecord(c: ConnectionRecord): boolean {
294
427
  // ---------------------------------------------------------------------------
295
428
 
296
429
  interface CreateBody {
430
+ /** `"credential"` routes to the H4 flow; absent/anything-else = event→action. */
431
+ kind?: unknown;
297
432
  source?: {
298
433
  module?: unknown;
299
434
  vault?: unknown;
@@ -305,12 +440,19 @@ interface CreateBody {
305
440
  action?: unknown;
306
441
  params?: unknown;
307
442
  };
443
+ /** H4 — the credential request: which module/key, which vault, which tags. */
444
+ credential?: {
445
+ module?: unknown;
446
+ key?: unknown;
447
+ vault?: unknown;
448
+ tags?: unknown;
449
+ };
308
450
  /** Optional operator-supplied id; otherwise derived from source/sink. */
309
451
  id?: unknown;
310
452
  /**
311
453
  * Provenance — WHO requested this connection (modular-UI R2). A module-owned
312
454
  * config UI calling this endpoint on the operator's behalf labels itself (e.g.
313
- * `"channel"`); the hub's own builder omits it and falls back to `"custom"`.
455
+ * `"agent"`); the hub's own builder omits it and falls back to `"custom"`.
314
456
  */
315
457
  requestedBy?: unknown;
316
458
  }
@@ -327,6 +469,12 @@ async function createConnection(
327
469
  return jsonError(400, "invalid_request", "request body must be JSON");
328
470
  }
329
471
 
472
+ // H4 — the second kind. Routed by an explicit discriminator so the two
473
+ // body shapes never ambiguously overlap.
474
+ if (str(body.kind) === "credential") {
475
+ return createCredentialConnection(body, userId, deps);
476
+ }
477
+
330
478
  const sourceModule = str(body.source?.module);
331
479
  const sourceEvent = str(body.source?.event);
332
480
  const sinkModule = str(body.sink?.module);
@@ -453,20 +601,28 @@ async function createConnection(
453
601
  // the record so teardown can revoke them (registered-mint rule).
454
602
  const mintedJtis: string[] = [];
455
603
 
456
- // --- Sink prerequisite (channel reply path), fenced to the channel sink. --
604
+ // --- Sink prerequisite (agent message-delivery reply path). --------------
457
605
  // Everything below this is general; THIS block is the only sink-specific step.
458
- // The channel name comes from the action params (`sink.params.channel`) — it
459
- // becomes a services.json key + an MCP server name, so it must be a slug.
460
- if (sinkModule === "channel") {
606
+ // It is gated on the ACTION, not just the module: ONLY `message.deliver` needs
607
+ // a session reply path (a vault-backed channel's connected session replies via
608
+ // a `vault:<v>:write` token + a `channels.json` entry). Other agent actions —
609
+ // e.g. `definition.reload`, a pure inbound webhook with no session and no
610
+ // reply — need none of it. A module-level gate here 400'd every
611
+ // non-`message.deliver` agent sink for want of a `channel` param (agent#117:
612
+ // the def-reload connectors could never provision). The channel name comes
613
+ // from `sink.params.channel` — it becomes a services.json key + an MCP server
614
+ // name, so it must be a slug. (The session-channel concept is kept; only the
615
+ // MODULE renamed channel → agent in the 2026-06-17 rename.)
616
+ if (sinkModule === "agent" && sinkAction === "message.deliver") {
461
617
  const channelName = typeof sinkParams?.channel === "string" ? sinkParams.channel : "";
462
618
  if (!CHANNEL_NAME_RE.test(channelName)) {
463
619
  return jsonError(
464
620
  400,
465
621
  "invalid_request",
466
- `channel sink requires sink.params.channel as a valid identifier; got "${channelName}"`,
622
+ `agent sink requires sink.params.channel as a valid identifier; got "${channelName}"`,
467
623
  );
468
624
  }
469
- const prep = await prepareChannelSink(channelName, vault, vaultOrigin, userId, deps);
625
+ const prep = await prepareAgentSink(channelName, vault, vaultOrigin, userId, deps);
470
626
  if (prep.error) return prep.error;
471
627
  mintedJtis.push(prep.replyTokenJti);
472
628
  }
@@ -533,35 +689,33 @@ async function createConnection(
533
689
  connection: typeof record;
534
690
  connect?: { mcpAdd: string; launch: string };
535
691
  } = { ok: true, connection: record };
536
- if (sinkModule === "channel" && typeof sinkParams?.channel === "string") {
692
+ if (sinkModule === "agent" && typeof sinkParams?.channel === "string") {
537
693
  out.connect = channelConnectLines(deps.hubOrigin, sinkParams.channel);
538
694
  }
539
695
  return json(200, out);
540
696
  }
541
697
 
542
698
  /**
543
- * The channel sink's reply-path prerequisite (mirrors hub#624). Mints a
699
+ * The agent sink's reply-path prerequisite (mirrors hub#624). Mints a
544
700
  * `vault:<v>:write` for the channel + writes the `channels.json` entry on the
545
- * channel daemon so the session can reply. Fenced to `sink.module === "channel"`
546
- * this is sink-specific config, not part of the general vault-trigger engine.
701
+ * agent daemon so the session can reply. Fenced to the agent `message.deliver`
702
+ * action (the only one with a reply path) — sink-specific config, not part of
703
+ * the general vault-trigger engine.
547
704
  * Returns `{ error }` on failure, or `{ error: null, replyTokenJti }` on
548
705
  * success — the jti of the long-lived reply token, so the caller can persist
549
- * it for teardown revocation.
706
+ * it for teardown revocation. (Renamed from `prepareChannelSink` 2026-06-17;
707
+ * the agent daemon's session-channel CRUD is still `/api/channels`.)
550
708
  */
551
- async function prepareChannelSink(
709
+ async function prepareAgentSink(
552
710
  channelName: string,
553
711
  vault: string,
554
712
  vaultOrigin: string,
555
713
  userId: string,
556
714
  deps: ConnectionsDeps,
557
715
  ): Promise<{ error: Response } | { error: null; replyTokenJti: string }> {
558
- if (deps.channelOrigin === null) {
716
+ if (deps.agentOrigin === null) {
559
717
  return {
560
- error: jsonError(
561
- 503,
562
- "channel_unavailable",
563
- "the channel module is not installed on this hub",
564
- ),
718
+ error: jsonError(503, "agent_unavailable", "the agent module is not installed on this hub"),
565
719
  };
566
720
  }
567
721
  const fetchImpl = deps.fetchImpl ?? fetch;
@@ -570,17 +724,17 @@ async function prepareChannelSink(
570
724
  scopes: [`vault:${vault}:write`],
571
725
  audience: `vault.${vault}`,
572
726
  vaultScope: [vault],
573
- ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS, // channel keeps it for its lifetime
727
+ ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS, // agent keeps it for its lifetime
574
728
  });
575
729
  const channelAdminToken = (
576
730
  await mint(deps, userId, {
577
- scopes: ["channel:admin"],
578
- audience: "channel",
731
+ scopes: ["agent:admin"],
732
+ audience: "agent",
579
733
  vaultScope: [],
580
734
  ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
581
735
  })
582
736
  ).token;
583
- const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
737
+ const res = await fetchImpl(`${deps.agentOrigin}/api/channels`, {
584
738
  method: "POST",
585
739
  headers: {
586
740
  authorization: `Bearer ${channelAdminToken}`,
@@ -592,13 +746,714 @@ async function prepareChannelSink(
592
746
  config: { vault, vaultUrl: vaultOrigin, token: vaultWriteSigned.token },
593
747
  }),
594
748
  });
595
- if (!res.ok) return { error: stepError("channel_config", await describeRemote(res)) };
749
+ if (!res.ok) return { error: stepError("agent_config", await describeRemote(res)) };
596
750
  return { error: null, replyTokenJti: vaultWriteSigned.jti };
597
751
  } catch (err) {
598
- return { error: stepError("channel_config", err) };
752
+ return { error: stepError("agent_config", err) };
599
753
  }
600
754
  }
601
755
 
756
+ // ===========================================================================
757
+ // kind: "credential" — provision / renew / deliver (H4)
758
+ // ===========================================================================
759
+
760
+ /** TTL of the standing credential (matches the engine's webhook bearer). */
761
+ const CREDENTIAL_TTL_SECONDS = WEBHOOK_BEARER_TTL_SECONDS;
762
+
763
+ /**
764
+ * The payload POSTed to the module's declared endpoint over loopback. One
765
+ * shape for all three lifecycle moments, discriminated by `op`:
766
+ * `"provisioned"` and `"renewed"` carry the token; `"removed"` carries only
767
+ * the identity fields (the module drops its stored credential).
768
+ */
769
+ interface CredentialPayload {
770
+ kind: "credential";
771
+ op: "provisioned" | "renewed" | "removed";
772
+ connection_id: string;
773
+ key: string;
774
+ vault: string;
775
+ scope: string;
776
+ /** Tag allowlist. Empty = vault-wide (read scopes only). */
777
+ scoped_tags: string[];
778
+ token?: string;
779
+ jti?: string;
780
+ expires_at?: string;
781
+ /** Hub path the module POSTs (Bearer = this token) to renew before expiry. */
782
+ renew_path?: string;
783
+ }
784
+
785
+ /**
786
+ * Mint the standing credential for a credential connection: a REGISTERED
787
+ * (created_via "connection_credential") 90-day JWT at `vault:<v>:<verb>`,
788
+ * audience-bound + vault_scope-pinned to the vault, carrying
789
+ * `permissions.scoped_tags` when tags were chosen (the claim path vault's
790
+ * tag-scope enforcement reads — vault/src/auth.ts `scoped_tags`).
791
+ */
792
+ async function mintCredential(
793
+ deps: ConnectionsDeps,
794
+ userId: string,
795
+ vault: string,
796
+ scope: string,
797
+ scopedTags: readonly string[],
798
+ ): Promise<{ token: string; jti: string; expiresAt: string }> {
799
+ const signed = await mint(deps, userId, {
800
+ scopes: [scope],
801
+ audience: `vault.${vault}`,
802
+ vaultScope: [vault],
803
+ ttlSeconds: CREDENTIAL_TTL_SECONDS,
804
+ createdVia: "connection_credential",
805
+ ...(scopedTags.length > 0 ? { permissions: { scoped_tags: [...scopedTags] } } : {}),
806
+ });
807
+ return { token: signed.token, jti: signed.jti, expiresAt: signed.expiresAt };
808
+ }
809
+
810
+ /**
811
+ * POST a credential payload to the module's declared endpoint over loopback,
812
+ * authenticated with a short-lived `<module>:admin` bearer (the engine's
813
+ * channel-config delivery shape — the module's endpoint gates on its own
814
+ * admin scope, so a random on-box process can't plant a forged credential).
815
+ */
816
+ async function deliverCredentialPayload(
817
+ deps: ConnectionsDeps,
818
+ userId: string,
819
+ moduleShort: string,
820
+ endpoint: string,
821
+ payload: CredentialPayload,
822
+ ): Promise<{ ok: true } | { ok: false; detail: string }> {
823
+ const moduleOrigin = deps.resolveModuleOrigin?.(moduleShort) ?? null;
824
+ if (moduleOrigin === null) {
825
+ return { ok: false, detail: `module "${moduleShort}" has no resolvable loopback origin` };
826
+ }
827
+ const fetchImpl = deps.fetchImpl ?? fetch;
828
+ try {
829
+ const adminBearer = (
830
+ await mint(deps, userId, {
831
+ scopes: [`${moduleShort}:admin`],
832
+ audience: moduleShort,
833
+ vaultScope: [],
834
+ ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
835
+ })
836
+ ).token;
837
+ const res = await fetchImpl(`${moduleOrigin}${endpoint}`, {
838
+ method: "POST",
839
+ headers: {
840
+ authorization: `Bearer ${adminBearer}`,
841
+ "content-type": "application/json",
842
+ },
843
+ body: JSON.stringify(payload),
844
+ });
845
+ if (!res.ok) return { ok: false, detail: await remoteDetail(res) };
846
+ return { ok: true };
847
+ } catch (err) {
848
+ return { ok: false, detail: errMsg(err) };
849
+ }
850
+ }
851
+
852
+ /**
853
+ * POST /admin/connections with `kind: "credential"` — the operator approves
854
+ * granting <module> a standing tag-scoped credential on <vault>.
855
+ *
856
+ * Validation (the privilege-escalation guard's runtime half):
857
+ * - the module must be installed AND must DECLARE the credential key;
858
+ * - the declared scope template must (still) be `vault:{vault}:read|write`
859
+ * — re-checked here even though the manifest validator enforces it, so a
860
+ * manifest read through a non-validating path can't widen the grant;
861
+ * - the vault must exist; tags must be non-empty strings;
862
+ * - WRITE scopes require non-empty tags (tags are the sharing scope —
863
+ * an untagged write credential would be a vault-wide write). Read may be
864
+ * vault-wide per operator choice (the UI defaults to tag-scoped).
865
+ *
866
+ * Provision order: mint (registered) → deliver to the module's endpoint →
867
+ * persist. A failed delivery revokes the fresh mint — an undelivered live
868
+ * credential must not outlive the request.
869
+ *
870
+ * Re-approval: POSTing the same module/key/vault again (the expired-renewal
871
+ * path) upserts by the derived id; the prior record's jtis are revoked first
872
+ * so exactly one live credential exists per connection.
873
+ */
874
+ async function createCredentialConnection(
875
+ body: CreateBody,
876
+ userId: string,
877
+ deps: ConnectionsDeps,
878
+ ): Promise<Response> {
879
+ const moduleShort = str(body.credential?.module);
880
+ const key = str(body.credential?.key);
881
+ const vault = str(body.credential?.vault);
882
+ if (!moduleShort || !key || !vault) {
883
+ return jsonError(
884
+ 400,
885
+ "invalid_request",
886
+ "credential.module, credential.key, credential.vault are all required",
887
+ );
888
+ }
889
+
890
+ const requestedByRaw = str(body.requestedBy);
891
+ const requestedBy = requestedByRaw === "" ? DEFAULT_REQUESTED_BY : requestedByRaw.toLowerCase();
892
+ if (!REQUESTED_BY_RE.test(requestedBy)) {
893
+ return jsonError(
894
+ 400,
895
+ "invalid_request",
896
+ `requestedBy "${requestedByRaw}" is not a valid label (letters, numbers, dash, underscore)`,
897
+ );
898
+ }
899
+
900
+ // --- Declaration check: installed module, declared key, sane template. ---
901
+ const mod = deps.modules.find((m) => m.short === moduleShort);
902
+ if (!mod) return jsonError(400, "unknown_module", `no installed module "${moduleShort}"`);
903
+ const decl = findCredentialDecl(mod.manifest, key);
904
+ if (!decl) {
905
+ return jsonError(
906
+ 400,
907
+ "unknown_credential",
908
+ `module "${moduleShort}" declares no credential "${key}"`,
909
+ );
910
+ }
911
+ // Escalation guard, runtime half: ONLY vault:{vault}:read|write. A module
912
+ // requesting vault:{vault}:admin, scribe:{vault}:read, or a literal vault
913
+ // name is refused regardless of what its manifest says.
914
+ if (!CREDENTIAL_SCOPE_TEMPLATE_RE.test(decl.scope)) {
915
+ return jsonError(
916
+ 400,
917
+ "invalid_scope",
918
+ `credential "${moduleShort}.${key}" declares scope "${decl.scope}" — only "vault:{vault}:read" or "vault:{vault}:write" are grantable (never admin, never another namespace)`,
919
+ );
920
+ }
921
+ if (!decl.endpoint || !decl.endpoint.startsWith("/")) {
922
+ return jsonError(
923
+ 400,
924
+ "credential_underdeclared",
925
+ `credential "${moduleShort}.${key}" declares no delivery endpoint`,
926
+ );
927
+ }
928
+
929
+ // --- Vault + tags. ---------------------------------------------------------
930
+ if (!VAULT_NAME_CHARSET_RE.test(vault)) {
931
+ return jsonError(
932
+ 400,
933
+ "invalid_request",
934
+ `credential.vault "${vault}" is not a valid identifier`,
935
+ );
936
+ }
937
+ if (deps.resolveVaultOrigin(vault) === null) {
938
+ return jsonError(400, "unknown_vault", `no vault named "${vault}" in this hub`);
939
+ }
940
+ const rawTags = body.credential?.tags;
941
+ if (rawTags !== undefined && !Array.isArray(rawTags)) {
942
+ return jsonError(400, "invalid_request", "credential.tags must be an array of tag names");
943
+ }
944
+ const tags: string[] = [];
945
+ for (const t of (rawTags as unknown[] | undefined) ?? []) {
946
+ if (typeof t !== "string" || t.trim().length === 0) {
947
+ return jsonError(400, "invalid_request", "credential.tags entries must be non-empty strings");
948
+ }
949
+ tags.push(t.trim());
950
+ }
951
+ const verb = decl.scope.endsWith(":write") ? "write" : "read";
952
+ if (verb === "write" && tags.length === 0) {
953
+ return jsonError(
954
+ 400,
955
+ "invalid_request",
956
+ "a write credential requires a non-empty tag scope — tags are the sharing scope; vault-wide write is not grantable here",
957
+ );
958
+ }
959
+ const scope = `vault:${vault}:${verb}`;
960
+
961
+ // --- Id (stable per module/key/vault → re-approve upserts). ----------------
962
+ const suppliedId = str(body.id);
963
+ const id = (suppliedId || `cred-${moduleShort}-${key}-${vault}`).toLowerCase();
964
+ if (!CONNECTION_ID_RE.test(id)) {
965
+ return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
966
+ }
967
+
968
+ // Re-approval path: revoke the prior record's still-registered jtis BEFORE
969
+ // minting the replacement, so exactly one live credential exists per
970
+ // connection (idempotent for already-revoked/expired rows).
971
+ const prior = readConnections(deps.storePath).find((r) => r.id === id);
972
+ if (prior) {
973
+ const now = deps.now?.() ?? new Date();
974
+ for (const jti of prior.provisioned?.mintedJtis ?? []) {
975
+ try {
976
+ revokeTokenByJti(deps.db, jti, now);
977
+ } catch {
978
+ // Best-effort — a missing registry row leaves nothing to revoke.
979
+ }
980
+ }
981
+ }
982
+
983
+ // --- Mint (registered) → deliver → persist. --------------------------------
984
+ let minted: { token: string; jti: string; expiresAt: string };
985
+ try {
986
+ minted = await mintCredential(deps, userId, vault, scope, tags);
987
+ } catch (err) {
988
+ return stepError("mint_credential", err);
989
+ }
990
+
991
+ const payload: CredentialPayload = {
992
+ kind: "credential",
993
+ op: "provisioned",
994
+ connection_id: id,
995
+ key,
996
+ vault,
997
+ scope,
998
+ scoped_tags: tags,
999
+ token: minted.token,
1000
+ jti: minted.jti,
1001
+ expires_at: minted.expiresAt,
1002
+ renew_path: `/admin/connections/${id}/renew`,
1003
+ };
1004
+ const delivered = await deliverCredentialPayload(
1005
+ deps,
1006
+ userId,
1007
+ moduleShort,
1008
+ decl.endpoint,
1009
+ payload,
1010
+ );
1011
+ if (!delivered.ok) {
1012
+ // An undelivered live credential must not outlive the request.
1013
+ try {
1014
+ revokeTokenByJti(deps.db, minted.jti, deps.now?.() ?? new Date());
1015
+ } catch {
1016
+ // Registry row just written by mint() — failure here is exotic; the
1017
+ // step error below still surfaces the delivery fault.
1018
+ }
1019
+ return stepError("credential_delivery", delivered.detail);
1020
+ }
1021
+
1022
+ const record: ConnectionRecord = {
1023
+ id,
1024
+ kind: "credential",
1025
+ // The source of authority is the granting vault; the sink is the module
1026
+ // holding the credential. Populating both keeps the store filter, the
1027
+ // list projection, and the vault-delete cascade (`source.vault === name
1028
+ // || provisioned.vault === name`) uniform across both kinds.
1029
+ source: { module: "vault", vault, event: "credential" },
1030
+ sink: {
1031
+ module: moduleShort,
1032
+ action: `credential.${key}`,
1033
+ ...(tags.length > 0 ? { params: { tags } } : {}),
1034
+ },
1035
+ provisioned: {
1036
+ type: "credential",
1037
+ vault,
1038
+ mintedJtis: [minted.jti],
1039
+ scope,
1040
+ scopedTags: tags,
1041
+ credentialKey: key,
1042
+ endpoint: decl.endpoint,
1043
+ },
1044
+ createdAt: (deps.now?.() ?? new Date()).toISOString(),
1045
+ requestedBy,
1046
+ };
1047
+ putConnection(deps.storePath, record);
1048
+
1049
+ return json(200, { ok: true, connection: record, expires_at: minted.expiresAt });
1050
+ }
1051
+
1052
+ /**
1053
+ * POST /admin/connections/:id/renew — credential renewal by PROOF OF
1054
+ * POSSESSION: the caller presents the connection's CURRENT still-valid
1055
+ * credential as Bearer. No operator click — a headless module daemon renews
1056
+ * before expiry.
1057
+ *
1058
+ * The possession check is the load-bearing gate: the presented token must
1059
+ * (a) verify against the hub's JWKS (signature, expiry, revocation — via
1060
+ * `validateAccessToken` WITHOUT an issuer pin; the signature proves the hub
1061
+ * minted it, and the jti binding below makes a foreign-issuer replay
1062
+ * structurally impossible), and (b) carry the EXACT jti recorded on this
1063
+ * connection. An expired or revoked credential fails (a) → 401 → the
1064
+ * operator re-approves in the UI (the upsert path in create).
1065
+ *
1066
+ * Renewal re-mints the SAME scope + tags from the record (never request
1067
+ * input), delivers the fresh credential in the RESPONSE BODY (the caller is
1068
+ * the proven credential holder — that's the delivery; no second loopback
1069
+ * POST), revokes the old jti, and updates the record.
1070
+ */
1071
+ async function renewCredentialConnection(
1072
+ req: Request,
1073
+ id: string,
1074
+ deps: ConnectionsDeps,
1075
+ ): Promise<Response> {
1076
+ if (!CONNECTION_ID_RE.test(id)) {
1077
+ return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
1078
+ }
1079
+ const record = readConnections(deps.storePath).find((r) => r.id === id);
1080
+ if (!record || record.kind !== "credential") {
1081
+ return jsonError(404, "not_found", `no credential connection "${id}"`);
1082
+ }
1083
+
1084
+ const auth = req.headers.get("authorization");
1085
+ if (!auth || !auth.startsWith("Bearer ")) {
1086
+ return jsonError(
1087
+ 401,
1088
+ "unauthenticated",
1089
+ "renewal requires the connection's current credential as Authorization: Bearer",
1090
+ );
1091
+ }
1092
+ const bearer = auth.slice("Bearer ".length).trim();
1093
+ let presentedJti: string;
1094
+ try {
1095
+ // Deliberately NO expectedIssuer pin here — unlike the audience gate's
1096
+ // Bearer branch (audience-gate.ts → validateHostAdminToken, iss ∈ the
1097
+ // hub's bound-origin set). See the fn docstring: the JWKS signature
1098
+ // proves local issuance, and the jti binding below makes a foreign
1099
+ // replay structurally impossible — an iss check would add nothing but
1100
+ // the #516 loopback-vs-public false-reject class.
1101
+ const validated = await validateAccessToken(deps.db, bearer);
1102
+ presentedJti = typeof validated.payload.jti === "string" ? validated.payload.jti : "";
1103
+ } catch (err) {
1104
+ // Signature/expiry/revocation failure — including the EXPIRED case the
1105
+ // design calls out: an expired credential cannot renew itself; the
1106
+ // operator re-approves in the UI.
1107
+ return jsonError(
1108
+ 401,
1109
+ "invalid_credential",
1110
+ `credential is not valid (expired credentials require operator re-approval in the hub UI): ${errMsg(err)}`,
1111
+ );
1112
+ }
1113
+ const currentJtis = record.provisioned?.mintedJtis ?? [];
1114
+ if (!presentedJti || !currentJtis.includes(presentedJti)) {
1115
+ return jsonError(
1116
+ 403,
1117
+ "not_credential_holder",
1118
+ "the presented token is not this connection's current credential",
1119
+ );
1120
+ }
1121
+
1122
+ // surface#113 — a CLAIMED record grants nothing until the operator
1123
+ // approves. Checked AFTER the possession proof so the pending state is
1124
+ // revealed only to the actual credential holder (the claimant itself).
1125
+ if (record.status === "pending") {
1126
+ return jsonError(
1127
+ 403,
1128
+ "pending_approval",
1129
+ "this connection's claim awaits operator approval in the hub admin Connections view — renewal is enabled after approval",
1130
+ );
1131
+ }
1132
+
1133
+ const vault = record.provisioned?.vault ?? "";
1134
+ const scope = record.provisioned?.scope ?? "";
1135
+ const scopedTags = record.provisioned?.scopedTags ?? [];
1136
+ const key = record.provisioned?.credentialKey ?? "";
1137
+ if (!vault || !scope) {
1138
+ return jsonError(500, "record_corrupt", `credential connection "${id}" has no minted shape`);
1139
+ }
1140
+
1141
+ // Renewal authority is the connection itself (operator approved the
1142
+ // standing grant; renewal extends it without escalation — same scope, same
1143
+ // tags). No operator user is in the loop, so the registry row carries the
1144
+ // provenance subject only (empty userId → mint() omits user_id).
1145
+ let minted: { token: string; jti: string; expiresAt: string };
1146
+ try {
1147
+ minted = await mintCredential(deps, "", vault, scope, scopedTags);
1148
+ } catch (err) {
1149
+ return stepError("mint_credential", err);
1150
+ }
1151
+
1152
+ // Revoke the old credential, persist the new jti. The ORDERING (mint new →
1153
+ // revoke old → write record → respond) is a deliberate trade-off: a
1154
+ // connection drop after the record write but before the response leaves
1155
+ // the module holding NEITHER credential (old revoked, new never received)
1156
+ // → operator re-approval required. We fail toward lockout, never toward
1157
+ // two live credentials. If that window ever bites in practice, the future
1158
+ // option is a retrieve-current-by-jti endpoint (present the revoked-but-
1159
+ // recorded predecessor, fetch its successor) — not reordering the steps.
1160
+ const now = deps.now?.() ?? new Date();
1161
+ for (const jti of currentJtis) {
1162
+ try {
1163
+ revokeTokenByJti(deps.db, jti, now);
1164
+ } catch {
1165
+ // Best-effort; the new mint is already the only one the record names.
1166
+ }
1167
+ }
1168
+ const updated: ConnectionRecord = {
1169
+ ...record,
1170
+ provisioned: {
1171
+ ...record.provisioned,
1172
+ mintedJtis: [minted.jti],
1173
+ },
1174
+ };
1175
+ putConnection(deps.storePath, updated);
1176
+
1177
+ const payload: CredentialPayload = {
1178
+ kind: "credential",
1179
+ op: "renewed",
1180
+ connection_id: id,
1181
+ key,
1182
+ vault,
1183
+ scope,
1184
+ scoped_tags: [...scopedTags],
1185
+ token: minted.token,
1186
+ jti: minted.jti,
1187
+ expires_at: minted.expiresAt,
1188
+ renew_path: `/admin/connections/${id}/renew`,
1189
+ };
1190
+ return json(200, { ok: true, credential: payload });
1191
+ }
1192
+
1193
+ /**
1194
+ * POST /admin/connections/:id/claim — backfill the hub-side record for a
1195
+ * credential that was delivered to a module OUTSIDE this engine (surface#113:
1196
+ * CLI-minted + POSTed straight to the module's delivery endpoint), so the
1197
+ * existing jti-bound renewal flow can find a record instead of 404ing.
1198
+ *
1199
+ * AUTH mirrors renew: proof of possession — the module presents the
1200
+ * credential it ALREADY holds as Bearer; the jti is derived from the
1201
+ * validated token, never from request input. The claim grants NOTHING:
1202
+ *
1203
+ * - the presented token must verify (signature / expiry / revocation —
1204
+ * an expired or revoked credential cannot be claimed; re-link via the
1205
+ * operator flow is the path);
1206
+ * - its jti must be REGISTERED in the tokens table (the registered-mint
1207
+ * rule is the precondition for renewal anyway), with the registry row
1208
+ * recording the same scope;
1209
+ * - the token's scope / aud / vault_scope must carry EXACTLY the grant the
1210
+ * claimed connection id implies (`cred-<module>-<key>-<vault>` +
1211
+ * the module's DECLARED credential template — same declaration checks
1212
+ * as create);
1213
+ * - the record is written `status: "pending"`: renewal refuses it until
1214
+ * the operator's one-click approve in the Connections view.
1215
+ *
1216
+ * So the only thing a claim can ever enable — and only after explicit
1217
+ * operator approval — is renewal of a token the module already holds, at the
1218
+ * scope/tags already baked into that token. NOTE the deliberate asymmetry
1219
+ * with create: a claim ACCEPTS the existing token's shape verbatim,
1220
+ * including an untagged write (create refuses those for NEW grants) — the
1221
+ * operator already granted that shape when they minted + delivered it, and
1222
+ * the approve click is the explicit sanction of carrying it forward.
1223
+ *
1224
+ * All post-authentication mismatches refuse with ONE generic error so the
1225
+ * endpoint is not an oracle on registry contents; the specific reason is
1226
+ * logged server-side (no token material).
1227
+ *
1228
+ * Idempotency: re-claiming with the same credential returns the same pending
1229
+ * record (no dupes). A pending record's claim may be superseded by another
1230
+ * fully-valid claim for the same id (pending grants nothing — last writer
1231
+ * wins until approval). An ACTIVE record is never touched: claiming it with
1232
+ * its own current credential reports "active" (renewal already works);
1233
+ * anything else is refused.
1234
+ */
1235
+ async function claimCredentialConnection(
1236
+ req: Request,
1237
+ id: string,
1238
+ deps: ConnectionsDeps,
1239
+ ): Promise<Response> {
1240
+ if (!CONNECTION_ID_RE.test(id)) {
1241
+ return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
1242
+ }
1243
+ let body: { module?: unknown; key?: unknown; vault?: unknown };
1244
+ try {
1245
+ body = (await req.json()) as { module?: unknown; key?: unknown; vault?: unknown };
1246
+ } catch {
1247
+ return jsonError(400, "invalid_request", "request body must be JSON");
1248
+ }
1249
+ const moduleShort = str(body.module);
1250
+ const key = str(body.key);
1251
+ const vault = str(body.vault);
1252
+ if (!moduleShort || !key || !vault) {
1253
+ return jsonError(400, "invalid_request", "module, key, vault are all required");
1254
+ }
1255
+ if (!VAULT_NAME_CHARSET_RE.test(vault)) {
1256
+ return jsonError(400, "invalid_request", `vault "${vault}" is not a valid identifier`);
1257
+ }
1258
+
1259
+ const auth = req.headers.get("authorization");
1260
+ if (!auth || !auth.startsWith("Bearer ")) {
1261
+ return jsonError(
1262
+ 401,
1263
+ "unauthenticated",
1264
+ "a claim requires the delivered credential as Authorization: Bearer",
1265
+ );
1266
+ }
1267
+ const bearer = auth.slice("Bearer ".length).trim();
1268
+ let payload: Record<string, unknown>;
1269
+ try {
1270
+ // Same validation posture as renew (and the same deliberate absence of
1271
+ // an issuer pin — see renewCredentialConnection): signature proves local
1272
+ // issuance; the registry + claim-shape binding below does the rest.
1273
+ payload = (await validateAccessToken(deps.db, bearer)).payload as Record<string, unknown>;
1274
+ } catch (err) {
1275
+ // Signature/expiry/revocation failure — an expired or revoked credential
1276
+ // cannot be claimed; the operator re-links through the module's flow.
1277
+ return jsonError(
1278
+ 401,
1279
+ "invalid_credential",
1280
+ `credential is not valid (expired or revoked credentials cannot be claimed — re-link via the operator flow): ${errMsg(err)}`,
1281
+ );
1282
+ }
1283
+
1284
+ // Every post-authentication mismatch refuses identically (no oracle on
1285
+ // registry contents); the reason is logged server-side, token-free.
1286
+ const reject = (reason: string): Response => {
1287
+ console.warn(`[connections] claim for "${id}" rejected: ${reason}`);
1288
+ return jsonError(
1289
+ 403,
1290
+ "claim_rejected",
1291
+ "the presented credential does not match a claimable connection",
1292
+ );
1293
+ };
1294
+
1295
+ const jti = typeof payload.jti === "string" ? payload.jti : "";
1296
+ if (!jti) return reject("token carries no jti");
1297
+
1298
+ // Registry half: the jti must be a REGISTERED mint (renewal's revocation
1299
+ // lifecycle depends on the row; an unregistered long-lived token is
1300
+ // unrevocable by construction and not reconcilable here).
1301
+ const registryRow = findTokenRowByJti(deps.db, jti);
1302
+ if (!registryRow) return reject("jti is not in the token registry");
1303
+ // NOTE: created_via is deliberately NOT filtered here. A claim grandfathers
1304
+ // a token that already exists and is already registered — provenance
1305
+ // (cli_mint, connection_credential, …) adds no authority either way, and
1306
+ // a connection_credential jti with an active record is already refused by
1307
+ // the existing-record check below.
1308
+
1309
+ // Declaration half — the same checks create performs, so a claim can't
1310
+ // smuggle past anything the operator-initiated path would refuse.
1311
+ const mod = deps.modules.find((m) => m.short === moduleShort);
1312
+ if (!mod) return reject(`no installed module "${moduleShort}"`);
1313
+ const decl = findCredentialDecl(mod.manifest, key);
1314
+ if (!decl) return reject(`module "${moduleShort}" declares no credential "${key}"`);
1315
+ if (!CREDENTIAL_SCOPE_TEMPLATE_RE.test(decl.scope)) {
1316
+ return reject(`declared scope template "${decl.scope}" is not grantable`);
1317
+ }
1318
+ if (!decl.endpoint || !decl.endpoint.startsWith("/")) {
1319
+ return reject(`credential "${moduleShort}.${key}" declares no delivery endpoint`);
1320
+ }
1321
+ if (deps.resolveVaultOrigin(vault) === null) return reject(`no vault named "${vault}"`);
1322
+
1323
+ // Identity half: the claimed id must be EXACTLY the id the hub derives for
1324
+ // this module/key/vault (the same derivation create uses by default) — the
1325
+ // id alone is ambiguous to parse (keys may contain dashes), so the body
1326
+ // names the parts and the derivation closes the loop.
1327
+ const impliedId = `cred-${moduleShort}-${key}-${vault}`.toLowerCase();
1328
+ if (id !== impliedId)
1329
+ return reject(`id does not match module/key/vault (implies "${impliedId}")`);
1330
+
1331
+ // Token-shape half: the presented credential must carry EXACTLY the grant
1332
+ // the connection implies — scope at the declared verb, audience-bound and
1333
+ // vault_scope-pinned to the vault (what makes it usable there at all).
1334
+ const verb = decl.scope.endsWith(":write") ? "write" : "read";
1335
+ const scope = `vault:${vault}:${verb}`;
1336
+ const tokenScopes =
1337
+ typeof payload.scope === "string" ? payload.scope.split(" ").filter((s) => s.length > 0) : [];
1338
+ if (!tokenScopes.includes(scope)) return reject(`token scope does not include "${scope}"`);
1339
+ const aud = payload.aud;
1340
+ const audOk = aud === `vault.${vault}` || (Array.isArray(aud) && aud.includes(`vault.${vault}`));
1341
+ if (!audOk) return reject(`token aud is not "vault.${vault}"`);
1342
+ // vault_scope: connection-minted tokens pin the vault here; CLI-minted
1343
+ // tokens (the very population claims reconcile — surface#113's live case)
1344
+ // carry vault_scope: [] and pin the vault via scope + aud instead, both
1345
+ // already exact-matched above. An EMPTY vault_scope is therefore accepted;
1346
+ // a NON-empty one that omits this vault is a genuine mismatch (the token
1347
+ // was pinned elsewhere) and is refused.
1348
+ const vaultScopePin = Array.isArray(payload.vault_scope) ? payload.vault_scope : [];
1349
+ if (vaultScopePin.length > 0 && !vaultScopePin.includes(vault)) {
1350
+ return reject(`token vault_scope does not pin "${vault}"`);
1351
+ }
1352
+ if (!registryRow.scopes.includes(scope)) return reject("registry row scope mismatch");
1353
+
1354
+ // Tags ride along verbatim from the SIGNED token (never request input) —
1355
+ // renewal will re-mint exactly this shape.
1356
+ const scopedTags = readScopedTagsClaim(payload);
1357
+
1358
+ const nowIso = (deps.now?.() ?? new Date()).toISOString();
1359
+ const pendingRecord: ConnectionRecord = {
1360
+ id,
1361
+ kind: "credential",
1362
+ status: "pending",
1363
+ source: { module: "vault", vault, event: "credential" },
1364
+ sink: {
1365
+ module: moduleShort,
1366
+ action: `credential.${key}`,
1367
+ ...(scopedTags.length > 0 ? { params: { tags: scopedTags } } : {}),
1368
+ },
1369
+ provisioned: {
1370
+ type: "credential",
1371
+ vault,
1372
+ mintedJtis: [jti],
1373
+ scope,
1374
+ scopedTags,
1375
+ credentialKey: key,
1376
+ endpoint: decl.endpoint,
1377
+ },
1378
+ createdAt: nowIso,
1379
+ requestedBy: moduleShort,
1380
+ };
1381
+
1382
+ const existing = readConnections(deps.storePath).find((r) => r.id === id);
1383
+ if (existing) {
1384
+ if (existing.kind !== "credential") {
1385
+ return reject("id names an existing non-credential connection");
1386
+ }
1387
+ const holdsCurrent = (existing.provisioned?.mintedJtis ?? []).includes(jti);
1388
+ if (existing.status !== "pending") {
1389
+ // ACTIVE record: never mutated by a claim. The current holder learns
1390
+ // renewal already works; anything else is refused generically.
1391
+ if (holdsCurrent) {
1392
+ return json(200, { ok: true, connection_id: id, status: "active" });
1393
+ }
1394
+ return reject("an active connection already exists for this id");
1395
+ }
1396
+ if (holdsCurrent) {
1397
+ // Idempotent re-claim — same pending record, no dupes, no rewrite.
1398
+ return json(202, claimPendingBody(id));
1399
+ }
1400
+ // A different fully-validated credential supersedes the unapproved claim
1401
+ // (pending grants nothing; last writer wins until the operator approves).
1402
+ }
1403
+
1404
+ putConnection(deps.storePath, pendingRecord);
1405
+ return json(202, claimPendingBody(id));
1406
+ }
1407
+
1408
+ function claimPendingBody(id: string): Record<string, unknown> {
1409
+ return {
1410
+ ok: true,
1411
+ connection_id: id,
1412
+ status: "pending",
1413
+ detail:
1414
+ "claim recorded — awaiting operator approval in the hub admin Connections view; renewal is enabled after approval",
1415
+ };
1416
+ }
1417
+
1418
+ /** `permissions.scoped_tags` from a validated token payload (strings only). */
1419
+ function readScopedTagsClaim(payload: Record<string, unknown>): string[] {
1420
+ const permissions = payload.permissions;
1421
+ if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return [];
1422
+ const tags = (permissions as Record<string, unknown>).scoped_tags;
1423
+ if (!Array.isArray(tags)) return [];
1424
+ return tags.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
1425
+ }
1426
+
1427
+ /**
1428
+ * POST /admin/connections/:id/approve — the operator's one-click activation
1429
+ * of a pending claim (surface#113). Operator-gated by the caller (same
1430
+ * session gate as create/teardown, CSRF-belted in hub-server.ts). Flips
1431
+ * `status: "pending"` → active by dropping the field; mints NOTHING and
1432
+ * delivers NOTHING — the module already holds the credential, approval only
1433
+ * lets the existing renewal flow find the record. Idempotent: approving an
1434
+ * already-active credential record reports active without rewriting it.
1435
+ */
1436
+ function approveCredentialConnection(id: string, deps: ConnectionsDeps): Response {
1437
+ if (!CONNECTION_ID_RE.test(id)) {
1438
+ return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
1439
+ }
1440
+ const record = readConnections(deps.storePath).find((r) => r.id === id);
1441
+ if (!record) return jsonError(404, "not_found", `no connection "${id}"`);
1442
+ if (record.kind !== "credential") {
1443
+ return jsonError(400, "not_claimable", `connection "${id}" is not a credential connection`);
1444
+ }
1445
+ if (record.status !== "pending") {
1446
+ return json(200, { ok: true, id, status: "active" });
1447
+ }
1448
+ const { status: _pending, ...approved } = record;
1449
+ putConnection(deps.storePath, approved);
1450
+ return json(200, { ok: true, id, status: "active" });
1451
+ }
1452
+
1453
+ function findCredentialDecl(m: ModuleManifest, key: string): ModuleCredential | undefined {
1454
+ return (m.credentials ?? []).find((c) => c.key === key);
1455
+ }
1456
+
602
1457
  // ---------------------------------------------------------------------------
603
1458
  // DELETE — teardown
604
1459
  // ---------------------------------------------------------------------------
@@ -656,35 +1511,81 @@ export async function teardownConnection(
656
1511
  }
657
1512
  }
658
1513
 
659
- // --- Channel-sink teardown (remove the channel config entry). ------------
660
- if (record.sink.module === "channel" && deps.channelOrigin) {
1514
+ // --- Credential removal notification (H4, best-effort). -------------------
1515
+ // The module holding the credential gets a removal payload at its declared
1516
+ // endpoint so it can drop the stored token. Best-effort by design: the jti
1517
+ // revocation below is the authoritative kill (the revocation list reaches
1518
+ // every resource server); a missed notification only leaves the module
1519
+ // holding a dead credential it will discover on first use.
1520
+ if (record.kind === "credential") {
1521
+ const endpoint = record.provisioned?.endpoint;
1522
+ const key = record.provisioned?.credentialKey ?? "";
1523
+ const credVault = record.provisioned?.vault ?? "";
1524
+ if (endpoint) {
1525
+ const removal: CredentialPayload = {
1526
+ kind: "credential",
1527
+ op: "removed",
1528
+ connection_id: record.id,
1529
+ key,
1530
+ vault: credVault,
1531
+ scope: record.provisioned?.scope ?? "",
1532
+ scoped_tags: [...(record.provisioned?.scopedTags ?? [])],
1533
+ };
1534
+ const notified = await deliverCredentialPayload(
1535
+ deps,
1536
+ userId,
1537
+ record.sink.module,
1538
+ endpoint,
1539
+ removal,
1540
+ );
1541
+ if (!notified.ok) {
1542
+ errors.push({ step: "credential_notify", detail: notified.detail });
1543
+ }
1544
+ }
1545
+ }
1546
+
1547
+ // --- Agent-sink teardown (remove the channel config entry). --------------
1548
+ // Fenced to event→action records (a credential connection whose HOLDER is the
1549
+ // agent module must not delete an unrelated channel config entry) AND — like
1550
+ // the create-side prerequisite — to the `message.deliver` action: only that
1551
+ // action created a channel config entry, so only it has one to remove. A
1552
+ // module-level gate here would issue a spurious DELETE /api/channels/<id> for
1553
+ // a channel-less action (e.g. definition.reload — `record.id` as the channel
1554
+ // fallback), wasting an agent:admin mint on a never-created channel and
1555
+ // risking a real same-named channel. Symmetric to the create-side gate.
1556
+ if (
1557
+ record.kind !== "credential" &&
1558
+ record.sink.module === "agent" &&
1559
+ record.sink.action === "message.deliver" &&
1560
+ deps.agentOrigin
1561
+ ) {
661
1562
  const channelName =
662
1563
  typeof record.sink.params?.channel === "string" ? record.sink.params.channel : record.id;
663
1564
  try {
664
1565
  const channelAdminToken = (
665
1566
  await mint(deps, userId, {
666
- scopes: ["channel:admin"],
667
- audience: "channel",
1567
+ scopes: ["agent:admin"],
1568
+ audience: "agent",
668
1569
  vaultScope: [],
669
1570
  ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
670
1571
  })
671
1572
  ).token;
672
1573
  const res = await fetchImpl(
673
- `${deps.channelOrigin}/api/channels/${encodeURIComponent(channelName)}`,
1574
+ `${deps.agentOrigin}/api/channels/${encodeURIComponent(channelName)}`,
674
1575
  { method: "DELETE", headers: { authorization: `Bearer ${channelAdminToken}` } },
675
1576
  );
676
1577
  if (!res.ok && res.status !== 404) {
677
- errors.push({ step: "channel_config", detail: await remoteDetail(res) });
1578
+ errors.push({ step: "agent_config", detail: await remoteDetail(res) });
678
1579
  }
679
1580
  } catch (err) {
680
- errors.push({ step: "channel_config", detail: errMsg(err) });
1581
+ errors.push({ step: "agent_config", detail: errMsg(err) });
681
1582
  }
682
1583
  }
683
1584
 
684
1585
  // --- Revoke the registered long-lived mints (B0, registered-mint rule). ---
685
1586
  // Marks each tokens-registry row revoked → the revocation list at
686
1587
  // `/.well-known/parachute-revocation.json` advertises the jtis, and every
687
- // resource server (vault, channel) rejects the credential from its next
1588
+ // resource server (vault, agent) rejects the credential from its next
688
1589
  // poll. Runs regardless of remote-teardown outcome — revocation is the safe
689
1590
  // direction. Legacy records (provisioned before B0) carry no jtis: teardown
690
1591
  // proceeds, but their tokens were never registered and ride to expiry.
@@ -811,8 +1712,8 @@ function readProvisionType(provision: unknown): string | null {
811
1712
 
812
1713
  /**
813
1714
  * Audience for a minted sink bearer. A `<module>:<verb>` scope (e.g.
814
- * `channel:send`) takes the module namespace as its audience — matching how
815
- * the channel validates `aud: channel`. Falls back to the sink module name.
1715
+ * `agent:send`) takes the module namespace as its audience — matching how
1716
+ * the agent validates `aud: agent`. Falls back to the sink module name.
816
1717
  */
817
1718
  function audienceForScope(scope: string, sinkModule: string): string {
818
1719
  const colon = scope.indexOf(":");
@@ -833,15 +1734,15 @@ function str(v: unknown): string {
833
1734
  }
834
1735
 
835
1736
  /**
836
- * Derive the connection id. Operator-supplied wins; else for a channel sink use
1737
+ * Derive the connection id. Operator-supplied wins; else for an agent sink use
837
1738
  * the channel name (so the trigger + channel-config share a stable key), else a
838
1739
  * `<srcModule>-<event>-<sinkModule>-<action>` slug.
839
1740
  */
840
1741
  function deriveId(rawId: unknown, source: ConnectionSource, sink: ConnectionSink): string {
841
1742
  const supplied = str(rawId);
842
1743
  if (supplied) return supplied.toLowerCase();
843
- if (sink.module === "channel" && typeof sink.params?.channel === "string") {
844
- return `channel-${sink.params.channel}`.toLowerCase();
1744
+ if (sink.module === "agent" && typeof sink.params?.channel === "string") {
1745
+ return `agent-${sink.params.channel}`.toLowerCase();
845
1746
  }
846
1747
  const slug = `${source.module}-${source.event}-${sink.module}-${sink.action}`
847
1748
  .toLowerCase()
@@ -856,8 +1757,8 @@ function channelConnectLines(
856
1757
  ): { mcpAdd: string; launch: string } {
857
1758
  const origin = hubOrigin.replace(/\/+$/, "");
858
1759
  return {
859
- mcpAdd: `claude mcp add --transport http --scope user channel-${channelName} ${origin}/channel/mcp/${channelName}`,
860
- launch: `claude --dangerously-load-development-channels=server:channel-${channelName} --dangerously-skip-permissions`,
1760
+ mcpAdd: `claude mcp add --transport http --scope user agent-${channelName} ${origin}/agent/mcp/${channelName}`,
1761
+ launch: `claude --dangerously-load-development-channels=server:agent-${channelName} --dangerously-skip-permissions`,
861
1762
  };
862
1763
  }
863
1764
 
@@ -904,18 +1805,33 @@ interface MintSpec {
904
1805
  audience: string;
905
1806
  vaultScope: string[];
906
1807
  ttlSeconds: number;
1808
+ /**
1809
+ * Registry provenance for long-lived mints. Defaults to the engine's
1810
+ * original `connection_provision`; credential connections (H4) pass
1811
+ * `connection_credential` so the registry distinguishes the two grants.
1812
+ */
1813
+ createdVia?: "connection_provision" | "connection_credential";
1814
+ /**
1815
+ * Extra `permissions` claim (H4 — `{ scoped_tags: [...] }`, the claim path
1816
+ * vault's tag-scope enforcement reads). Embedded in the JWT AND persisted
1817
+ * (JSON) on the registry row.
1818
+ */
1819
+ permissions?: Record<string, unknown>;
907
1820
  }
908
1821
 
909
1822
  async function mint(deps: ConnectionsDeps, userId: string, spec: MintSpec) {
910
1823
  const sign = deps.signToken ?? signAccessToken;
911
1824
  const signed = await sign(deps.db, {
912
- sub: userId,
1825
+ // `sub` falls back to the provenance subject when no operator user is in
1826
+ // the loop (H4 renewal is module-initiated — no session, no user row).
1827
+ sub: userId || "connection",
913
1828
  scopes: spec.scopes,
914
1829
  audience: spec.audience,
915
1830
  clientId: PROVISION_CLIENT_ID,
916
1831
  issuer: deps.hubOrigin,
917
1832
  ttlSeconds: spec.ttlSeconds,
918
1833
  vaultScope: spec.vaultScope,
1834
+ ...(spec.permissions !== undefined ? { extraClaims: { permissions: spec.permissions } } : {}),
919
1835
  ...(deps.now !== undefined ? { now: deps.now } : {}),
920
1836
  });
921
1837
  // Register long-lived mints so they're revocable on teardown. Short-lived
@@ -923,12 +1839,15 @@ async function mint(deps: ConnectionsDeps, userId: string, spec: MintSpec) {
923
1839
  if (spec.ttlSeconds > REGISTERED_MINT_TTL_THRESHOLD_SECONDS) {
924
1840
  recordTokenMint(deps.db, {
925
1841
  jti: signed.jti,
926
- createdVia: "connection_provision",
1842
+ createdVia: spec.createdVia ?? "connection_provision",
927
1843
  subject: "connection",
928
- userId,
1844
+ // tokens.user_id carries an FK to users(id) — only write it when a
1845
+ // real operator user is in the loop (empty = renewal, no session).
1846
+ ...(userId ? { userId } : {}),
929
1847
  clientId: PROVISION_CLIENT_ID,
930
1848
  scopes: spec.scopes,
931
1849
  expiresAt: signed.expiresAt,
1850
+ ...(spec.permissions !== undefined ? { permissions: JSON.stringify(spec.permissions) } : {}),
932
1851
  ...(deps.now !== undefined ? { now: deps.now } : {}),
933
1852
  });
934
1853
  }