@openparachute/hub 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,7 +33,43 @@
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";
@@ -111,6 +159,16 @@ 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;
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;
114
172
  /** Loopback origin for the channel daemon, or `null` when not installed. */
115
173
  channelOrigin: string | null;
116
174
  /** Absolute path to `connections.json` in the hub state dir. */
@@ -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,6 +440,13 @@ 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
  /**
@@ -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);
@@ -599,6 +747,707 @@ async function prepareChannelSink(
599
747
  }
600
748
  }
601
749
 
750
+ // ===========================================================================
751
+ // kind: "credential" — provision / renew / deliver (H4)
752
+ // ===========================================================================
753
+
754
+ /** TTL of the standing credential (matches the engine's webhook bearer). */
755
+ const CREDENTIAL_TTL_SECONDS = WEBHOOK_BEARER_TTL_SECONDS;
756
+
757
+ /**
758
+ * The payload POSTed to the module's declared endpoint over loopback. One
759
+ * shape for all three lifecycle moments, discriminated by `op`:
760
+ * `"provisioned"` and `"renewed"` carry the token; `"removed"` carries only
761
+ * the identity fields (the module drops its stored credential).
762
+ */
763
+ interface CredentialPayload {
764
+ kind: "credential";
765
+ op: "provisioned" | "renewed" | "removed";
766
+ connection_id: string;
767
+ key: string;
768
+ vault: string;
769
+ scope: string;
770
+ /** Tag allowlist. Empty = vault-wide (read scopes only). */
771
+ scoped_tags: string[];
772
+ token?: string;
773
+ jti?: string;
774
+ expires_at?: string;
775
+ /** Hub path the module POSTs (Bearer = this token) to renew before expiry. */
776
+ renew_path?: string;
777
+ }
778
+
779
+ /**
780
+ * Mint the standing credential for a credential connection: a REGISTERED
781
+ * (created_via "connection_credential") 90-day JWT at `vault:<v>:<verb>`,
782
+ * audience-bound + vault_scope-pinned to the vault, carrying
783
+ * `permissions.scoped_tags` when tags were chosen (the claim path vault's
784
+ * tag-scope enforcement reads — vault/src/auth.ts `scoped_tags`).
785
+ */
786
+ async function mintCredential(
787
+ deps: ConnectionsDeps,
788
+ userId: string,
789
+ vault: string,
790
+ scope: string,
791
+ scopedTags: readonly string[],
792
+ ): Promise<{ token: string; jti: string; expiresAt: string }> {
793
+ const signed = await mint(deps, userId, {
794
+ scopes: [scope],
795
+ audience: `vault.${vault}`,
796
+ vaultScope: [vault],
797
+ ttlSeconds: CREDENTIAL_TTL_SECONDS,
798
+ createdVia: "connection_credential",
799
+ ...(scopedTags.length > 0 ? { permissions: { scoped_tags: [...scopedTags] } } : {}),
800
+ });
801
+ return { token: signed.token, jti: signed.jti, expiresAt: signed.expiresAt };
802
+ }
803
+
804
+ /**
805
+ * POST a credential payload to the module's declared endpoint over loopback,
806
+ * authenticated with a short-lived `<module>:admin` bearer (the engine's
807
+ * channel-config delivery shape — the module's endpoint gates on its own
808
+ * admin scope, so a random on-box process can't plant a forged credential).
809
+ */
810
+ async function deliverCredentialPayload(
811
+ deps: ConnectionsDeps,
812
+ userId: string,
813
+ moduleShort: string,
814
+ endpoint: string,
815
+ payload: CredentialPayload,
816
+ ): Promise<{ ok: true } | { ok: false; detail: string }> {
817
+ const moduleOrigin = deps.resolveModuleOrigin?.(moduleShort) ?? null;
818
+ if (moduleOrigin === null) {
819
+ return { ok: false, detail: `module "${moduleShort}" has no resolvable loopback origin` };
820
+ }
821
+ const fetchImpl = deps.fetchImpl ?? fetch;
822
+ try {
823
+ const adminBearer = (
824
+ await mint(deps, userId, {
825
+ scopes: [`${moduleShort}:admin`],
826
+ audience: moduleShort,
827
+ vaultScope: [],
828
+ ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
829
+ })
830
+ ).token;
831
+ const res = await fetchImpl(`${moduleOrigin}${endpoint}`, {
832
+ method: "POST",
833
+ headers: {
834
+ authorization: `Bearer ${adminBearer}`,
835
+ "content-type": "application/json",
836
+ },
837
+ body: JSON.stringify(payload),
838
+ });
839
+ if (!res.ok) return { ok: false, detail: await remoteDetail(res) };
840
+ return { ok: true };
841
+ } catch (err) {
842
+ return { ok: false, detail: errMsg(err) };
843
+ }
844
+ }
845
+
846
+ /**
847
+ * POST /admin/connections with `kind: "credential"` — the operator approves
848
+ * granting <module> a standing tag-scoped credential on <vault>.
849
+ *
850
+ * Validation (the privilege-escalation guard's runtime half):
851
+ * - the module must be installed AND must DECLARE the credential key;
852
+ * - the declared scope template must (still) be `vault:{vault}:read|write`
853
+ * — re-checked here even though the manifest validator enforces it, so a
854
+ * manifest read through a non-validating path can't widen the grant;
855
+ * - the vault must exist; tags must be non-empty strings;
856
+ * - WRITE scopes require non-empty tags (tags are the sharing scope —
857
+ * an untagged write credential would be a vault-wide write). Read may be
858
+ * vault-wide per operator choice (the UI defaults to tag-scoped).
859
+ *
860
+ * Provision order: mint (registered) → deliver to the module's endpoint →
861
+ * persist. A failed delivery revokes the fresh mint — an undelivered live
862
+ * credential must not outlive the request.
863
+ *
864
+ * Re-approval: POSTing the same module/key/vault again (the expired-renewal
865
+ * path) upserts by the derived id; the prior record's jtis are revoked first
866
+ * so exactly one live credential exists per connection.
867
+ */
868
+ async function createCredentialConnection(
869
+ body: CreateBody,
870
+ userId: string,
871
+ deps: ConnectionsDeps,
872
+ ): Promise<Response> {
873
+ const moduleShort = str(body.credential?.module);
874
+ const key = str(body.credential?.key);
875
+ const vault = str(body.credential?.vault);
876
+ if (!moduleShort || !key || !vault) {
877
+ return jsonError(
878
+ 400,
879
+ "invalid_request",
880
+ "credential.module, credential.key, credential.vault are all required",
881
+ );
882
+ }
883
+
884
+ const requestedByRaw = str(body.requestedBy);
885
+ const requestedBy = requestedByRaw === "" ? DEFAULT_REQUESTED_BY : requestedByRaw.toLowerCase();
886
+ if (!REQUESTED_BY_RE.test(requestedBy)) {
887
+ return jsonError(
888
+ 400,
889
+ "invalid_request",
890
+ `requestedBy "${requestedByRaw}" is not a valid label (letters, numbers, dash, underscore)`,
891
+ );
892
+ }
893
+
894
+ // --- Declaration check: installed module, declared key, sane template. ---
895
+ const mod = deps.modules.find((m) => m.short === moduleShort);
896
+ if (!mod) return jsonError(400, "unknown_module", `no installed module "${moduleShort}"`);
897
+ const decl = findCredentialDecl(mod.manifest, key);
898
+ if (!decl) {
899
+ return jsonError(
900
+ 400,
901
+ "unknown_credential",
902
+ `module "${moduleShort}" declares no credential "${key}"`,
903
+ );
904
+ }
905
+ // Escalation guard, runtime half: ONLY vault:{vault}:read|write. A module
906
+ // requesting vault:{vault}:admin, scribe:{vault}:read, or a literal vault
907
+ // name is refused regardless of what its manifest says.
908
+ if (!CREDENTIAL_SCOPE_TEMPLATE_RE.test(decl.scope)) {
909
+ return jsonError(
910
+ 400,
911
+ "invalid_scope",
912
+ `credential "${moduleShort}.${key}" declares scope "${decl.scope}" — only "vault:{vault}:read" or "vault:{vault}:write" are grantable (never admin, never another namespace)`,
913
+ );
914
+ }
915
+ if (!decl.endpoint || !decl.endpoint.startsWith("/")) {
916
+ return jsonError(
917
+ 400,
918
+ "credential_underdeclared",
919
+ `credential "${moduleShort}.${key}" declares no delivery endpoint`,
920
+ );
921
+ }
922
+
923
+ // --- Vault + tags. ---------------------------------------------------------
924
+ if (!VAULT_NAME_CHARSET_RE.test(vault)) {
925
+ return jsonError(
926
+ 400,
927
+ "invalid_request",
928
+ `credential.vault "${vault}" is not a valid identifier`,
929
+ );
930
+ }
931
+ if (deps.resolveVaultOrigin(vault) === null) {
932
+ return jsonError(400, "unknown_vault", `no vault named "${vault}" in this hub`);
933
+ }
934
+ const rawTags = body.credential?.tags;
935
+ if (rawTags !== undefined && !Array.isArray(rawTags)) {
936
+ return jsonError(400, "invalid_request", "credential.tags must be an array of tag names");
937
+ }
938
+ const tags: string[] = [];
939
+ for (const t of (rawTags as unknown[] | undefined) ?? []) {
940
+ if (typeof t !== "string" || t.trim().length === 0) {
941
+ return jsonError(400, "invalid_request", "credential.tags entries must be non-empty strings");
942
+ }
943
+ tags.push(t.trim());
944
+ }
945
+ const verb = decl.scope.endsWith(":write") ? "write" : "read";
946
+ if (verb === "write" && tags.length === 0) {
947
+ return jsonError(
948
+ 400,
949
+ "invalid_request",
950
+ "a write credential requires a non-empty tag scope — tags are the sharing scope; vault-wide write is not grantable here",
951
+ );
952
+ }
953
+ const scope = `vault:${vault}:${verb}`;
954
+
955
+ // --- Id (stable per module/key/vault → re-approve upserts). ----------------
956
+ const suppliedId = str(body.id);
957
+ const id = (suppliedId || `cred-${moduleShort}-${key}-${vault}`).toLowerCase();
958
+ if (!CONNECTION_ID_RE.test(id)) {
959
+ return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
960
+ }
961
+
962
+ // Re-approval path: revoke the prior record's still-registered jtis BEFORE
963
+ // minting the replacement, so exactly one live credential exists per
964
+ // connection (idempotent for already-revoked/expired rows).
965
+ const prior = readConnections(deps.storePath).find((r) => r.id === id);
966
+ if (prior) {
967
+ const now = deps.now?.() ?? new Date();
968
+ for (const jti of prior.provisioned?.mintedJtis ?? []) {
969
+ try {
970
+ revokeTokenByJti(deps.db, jti, now);
971
+ } catch {
972
+ // Best-effort — a missing registry row leaves nothing to revoke.
973
+ }
974
+ }
975
+ }
976
+
977
+ // --- Mint (registered) → deliver → persist. --------------------------------
978
+ let minted: { token: string; jti: string; expiresAt: string };
979
+ try {
980
+ minted = await mintCredential(deps, userId, vault, scope, tags);
981
+ } catch (err) {
982
+ return stepError("mint_credential", err);
983
+ }
984
+
985
+ const payload: CredentialPayload = {
986
+ kind: "credential",
987
+ op: "provisioned",
988
+ connection_id: id,
989
+ key,
990
+ vault,
991
+ scope,
992
+ scoped_tags: tags,
993
+ token: minted.token,
994
+ jti: minted.jti,
995
+ expires_at: minted.expiresAt,
996
+ renew_path: `/admin/connections/${id}/renew`,
997
+ };
998
+ const delivered = await deliverCredentialPayload(
999
+ deps,
1000
+ userId,
1001
+ moduleShort,
1002
+ decl.endpoint,
1003
+ payload,
1004
+ );
1005
+ if (!delivered.ok) {
1006
+ // An undelivered live credential must not outlive the request.
1007
+ try {
1008
+ revokeTokenByJti(deps.db, minted.jti, deps.now?.() ?? new Date());
1009
+ } catch {
1010
+ // Registry row just written by mint() — failure here is exotic; the
1011
+ // step error below still surfaces the delivery fault.
1012
+ }
1013
+ return stepError("credential_delivery", delivered.detail);
1014
+ }
1015
+
1016
+ const record: ConnectionRecord = {
1017
+ id,
1018
+ kind: "credential",
1019
+ // The source of authority is the granting vault; the sink is the module
1020
+ // holding the credential. Populating both keeps the store filter, the
1021
+ // list projection, and the vault-delete cascade (`source.vault === name
1022
+ // || provisioned.vault === name`) uniform across both kinds.
1023
+ source: { module: "vault", vault, event: "credential" },
1024
+ sink: {
1025
+ module: moduleShort,
1026
+ action: `credential.${key}`,
1027
+ ...(tags.length > 0 ? { params: { tags } } : {}),
1028
+ },
1029
+ provisioned: {
1030
+ type: "credential",
1031
+ vault,
1032
+ mintedJtis: [minted.jti],
1033
+ scope,
1034
+ scopedTags: tags,
1035
+ credentialKey: key,
1036
+ endpoint: decl.endpoint,
1037
+ },
1038
+ createdAt: (deps.now?.() ?? new Date()).toISOString(),
1039
+ requestedBy,
1040
+ };
1041
+ putConnection(deps.storePath, record);
1042
+
1043
+ return json(200, { ok: true, connection: record, expires_at: minted.expiresAt });
1044
+ }
1045
+
1046
+ /**
1047
+ * POST /admin/connections/:id/renew — credential renewal by PROOF OF
1048
+ * POSSESSION: the caller presents the connection's CURRENT still-valid
1049
+ * credential as Bearer. No operator click — a headless module daemon renews
1050
+ * before expiry.
1051
+ *
1052
+ * The possession check is the load-bearing gate: the presented token must
1053
+ * (a) verify against the hub's JWKS (signature, expiry, revocation — via
1054
+ * `validateAccessToken` WITHOUT an issuer pin; the signature proves the hub
1055
+ * minted it, and the jti binding below makes a foreign-issuer replay
1056
+ * structurally impossible), and (b) carry the EXACT jti recorded on this
1057
+ * connection. An expired or revoked credential fails (a) → 401 → the
1058
+ * operator re-approves in the UI (the upsert path in create).
1059
+ *
1060
+ * Renewal re-mints the SAME scope + tags from the record (never request
1061
+ * input), delivers the fresh credential in the RESPONSE BODY (the caller is
1062
+ * the proven credential holder — that's the delivery; no second loopback
1063
+ * POST), revokes the old jti, and updates the record.
1064
+ */
1065
+ async function renewCredentialConnection(
1066
+ req: Request,
1067
+ id: string,
1068
+ deps: ConnectionsDeps,
1069
+ ): Promise<Response> {
1070
+ if (!CONNECTION_ID_RE.test(id)) {
1071
+ return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
1072
+ }
1073
+ const record = readConnections(deps.storePath).find((r) => r.id === id);
1074
+ if (!record || record.kind !== "credential") {
1075
+ return jsonError(404, "not_found", `no credential connection "${id}"`);
1076
+ }
1077
+
1078
+ const auth = req.headers.get("authorization");
1079
+ if (!auth || !auth.startsWith("Bearer ")) {
1080
+ return jsonError(
1081
+ 401,
1082
+ "unauthenticated",
1083
+ "renewal requires the connection's current credential as Authorization: Bearer",
1084
+ );
1085
+ }
1086
+ const bearer = auth.slice("Bearer ".length).trim();
1087
+ let presentedJti: string;
1088
+ try {
1089
+ // Deliberately NO expectedIssuer pin here — unlike the audience gate's
1090
+ // Bearer branch (audience-gate.ts → validateHostAdminToken, iss ∈ the
1091
+ // hub's bound-origin set). See the fn docstring: the JWKS signature
1092
+ // proves local issuance, and the jti binding below makes a foreign
1093
+ // replay structurally impossible — an iss check would add nothing but
1094
+ // the #516 loopback-vs-public false-reject class.
1095
+ const validated = await validateAccessToken(deps.db, bearer);
1096
+ presentedJti = typeof validated.payload.jti === "string" ? validated.payload.jti : "";
1097
+ } catch (err) {
1098
+ // Signature/expiry/revocation failure — including the EXPIRED case the
1099
+ // design calls out: an expired credential cannot renew itself; the
1100
+ // operator re-approves in the UI.
1101
+ return jsonError(
1102
+ 401,
1103
+ "invalid_credential",
1104
+ `credential is not valid (expired credentials require operator re-approval in the hub UI): ${errMsg(err)}`,
1105
+ );
1106
+ }
1107
+ const currentJtis = record.provisioned?.mintedJtis ?? [];
1108
+ if (!presentedJti || !currentJtis.includes(presentedJti)) {
1109
+ return jsonError(
1110
+ 403,
1111
+ "not_credential_holder",
1112
+ "the presented token is not this connection's current credential",
1113
+ );
1114
+ }
1115
+
1116
+ // surface#113 — a CLAIMED record grants nothing until the operator
1117
+ // approves. Checked AFTER the possession proof so the pending state is
1118
+ // revealed only to the actual credential holder (the claimant itself).
1119
+ if (record.status === "pending") {
1120
+ return jsonError(
1121
+ 403,
1122
+ "pending_approval",
1123
+ "this connection's claim awaits operator approval in the hub admin Connections view — renewal is enabled after approval",
1124
+ );
1125
+ }
1126
+
1127
+ const vault = record.provisioned?.vault ?? "";
1128
+ const scope = record.provisioned?.scope ?? "";
1129
+ const scopedTags = record.provisioned?.scopedTags ?? [];
1130
+ const key = record.provisioned?.credentialKey ?? "";
1131
+ if (!vault || !scope) {
1132
+ return jsonError(500, "record_corrupt", `credential connection "${id}" has no minted shape`);
1133
+ }
1134
+
1135
+ // Renewal authority is the connection itself (operator approved the
1136
+ // standing grant; renewal extends it without escalation — same scope, same
1137
+ // tags). No operator user is in the loop, so the registry row carries the
1138
+ // provenance subject only (empty userId → mint() omits user_id).
1139
+ let minted: { token: string; jti: string; expiresAt: string };
1140
+ try {
1141
+ minted = await mintCredential(deps, "", vault, scope, scopedTags);
1142
+ } catch (err) {
1143
+ return stepError("mint_credential", err);
1144
+ }
1145
+
1146
+ // Revoke the old credential, persist the new jti. The ORDERING (mint new →
1147
+ // revoke old → write record → respond) is a deliberate trade-off: a
1148
+ // connection drop after the record write but before the response leaves
1149
+ // the module holding NEITHER credential (old revoked, new never received)
1150
+ // → operator re-approval required. We fail toward lockout, never toward
1151
+ // two live credentials. If that window ever bites in practice, the future
1152
+ // option is a retrieve-current-by-jti endpoint (present the revoked-but-
1153
+ // recorded predecessor, fetch its successor) — not reordering the steps.
1154
+ const now = deps.now?.() ?? new Date();
1155
+ for (const jti of currentJtis) {
1156
+ try {
1157
+ revokeTokenByJti(deps.db, jti, now);
1158
+ } catch {
1159
+ // Best-effort; the new mint is already the only one the record names.
1160
+ }
1161
+ }
1162
+ const updated: ConnectionRecord = {
1163
+ ...record,
1164
+ provisioned: {
1165
+ ...record.provisioned,
1166
+ mintedJtis: [minted.jti],
1167
+ },
1168
+ };
1169
+ putConnection(deps.storePath, updated);
1170
+
1171
+ const payload: CredentialPayload = {
1172
+ kind: "credential",
1173
+ op: "renewed",
1174
+ connection_id: id,
1175
+ key,
1176
+ vault,
1177
+ scope,
1178
+ scoped_tags: [...scopedTags],
1179
+ token: minted.token,
1180
+ jti: minted.jti,
1181
+ expires_at: minted.expiresAt,
1182
+ renew_path: `/admin/connections/${id}/renew`,
1183
+ };
1184
+ return json(200, { ok: true, credential: payload });
1185
+ }
1186
+
1187
+ /**
1188
+ * POST /admin/connections/:id/claim — backfill the hub-side record for a
1189
+ * credential that was delivered to a module OUTSIDE this engine (surface#113:
1190
+ * CLI-minted + POSTed straight to the module's delivery endpoint), so the
1191
+ * existing jti-bound renewal flow can find a record instead of 404ing.
1192
+ *
1193
+ * AUTH mirrors renew: proof of possession — the module presents the
1194
+ * credential it ALREADY holds as Bearer; the jti is derived from the
1195
+ * validated token, never from request input. The claim grants NOTHING:
1196
+ *
1197
+ * - the presented token must verify (signature / expiry / revocation —
1198
+ * an expired or revoked credential cannot be claimed; re-link via the
1199
+ * operator flow is the path);
1200
+ * - its jti must be REGISTERED in the tokens table (the registered-mint
1201
+ * rule is the precondition for renewal anyway), with the registry row
1202
+ * recording the same scope;
1203
+ * - the token's scope / aud / vault_scope must carry EXACTLY the grant the
1204
+ * claimed connection id implies (`cred-<module>-<key>-<vault>` +
1205
+ * the module's DECLARED credential template — same declaration checks
1206
+ * as create);
1207
+ * - the record is written `status: "pending"`: renewal refuses it until
1208
+ * the operator's one-click approve in the Connections view.
1209
+ *
1210
+ * So the only thing a claim can ever enable — and only after explicit
1211
+ * operator approval — is renewal of a token the module already holds, at the
1212
+ * scope/tags already baked into that token. NOTE the deliberate asymmetry
1213
+ * with create: a claim ACCEPTS the existing token's shape verbatim,
1214
+ * including an untagged write (create refuses those for NEW grants) — the
1215
+ * operator already granted that shape when they minted + delivered it, and
1216
+ * the approve click is the explicit sanction of carrying it forward.
1217
+ *
1218
+ * All post-authentication mismatches refuse with ONE generic error so the
1219
+ * endpoint is not an oracle on registry contents; the specific reason is
1220
+ * logged server-side (no token material).
1221
+ *
1222
+ * Idempotency: re-claiming with the same credential returns the same pending
1223
+ * record (no dupes). A pending record's claim may be superseded by another
1224
+ * fully-valid claim for the same id (pending grants nothing — last writer
1225
+ * wins until approval). An ACTIVE record is never touched: claiming it with
1226
+ * its own current credential reports "active" (renewal already works);
1227
+ * anything else is refused.
1228
+ */
1229
+ async function claimCredentialConnection(
1230
+ req: Request,
1231
+ id: string,
1232
+ deps: ConnectionsDeps,
1233
+ ): Promise<Response> {
1234
+ if (!CONNECTION_ID_RE.test(id)) {
1235
+ return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
1236
+ }
1237
+ let body: { module?: unknown; key?: unknown; vault?: unknown };
1238
+ try {
1239
+ body = (await req.json()) as { module?: unknown; key?: unknown; vault?: unknown };
1240
+ } catch {
1241
+ return jsonError(400, "invalid_request", "request body must be JSON");
1242
+ }
1243
+ const moduleShort = str(body.module);
1244
+ const key = str(body.key);
1245
+ const vault = str(body.vault);
1246
+ if (!moduleShort || !key || !vault) {
1247
+ return jsonError(400, "invalid_request", "module, key, vault are all required");
1248
+ }
1249
+ if (!VAULT_NAME_CHARSET_RE.test(vault)) {
1250
+ return jsonError(400, "invalid_request", `vault "${vault}" is not a valid identifier`);
1251
+ }
1252
+
1253
+ const auth = req.headers.get("authorization");
1254
+ if (!auth || !auth.startsWith("Bearer ")) {
1255
+ return jsonError(
1256
+ 401,
1257
+ "unauthenticated",
1258
+ "a claim requires the delivered credential as Authorization: Bearer",
1259
+ );
1260
+ }
1261
+ const bearer = auth.slice("Bearer ".length).trim();
1262
+ let payload: Record<string, unknown>;
1263
+ try {
1264
+ // Same validation posture as renew (and the same deliberate absence of
1265
+ // an issuer pin — see renewCredentialConnection): signature proves local
1266
+ // issuance; the registry + claim-shape binding below does the rest.
1267
+ payload = (await validateAccessToken(deps.db, bearer)).payload as Record<string, unknown>;
1268
+ } catch (err) {
1269
+ // Signature/expiry/revocation failure — an expired or revoked credential
1270
+ // cannot be claimed; the operator re-links through the module's flow.
1271
+ return jsonError(
1272
+ 401,
1273
+ "invalid_credential",
1274
+ `credential is not valid (expired or revoked credentials cannot be claimed — re-link via the operator flow): ${errMsg(err)}`,
1275
+ );
1276
+ }
1277
+
1278
+ // Every post-authentication mismatch refuses identically (no oracle on
1279
+ // registry contents); the reason is logged server-side, token-free.
1280
+ const reject = (reason: string): Response => {
1281
+ console.warn(`[connections] claim for "${id}" rejected: ${reason}`);
1282
+ return jsonError(
1283
+ 403,
1284
+ "claim_rejected",
1285
+ "the presented credential does not match a claimable connection",
1286
+ );
1287
+ };
1288
+
1289
+ const jti = typeof payload.jti === "string" ? payload.jti : "";
1290
+ if (!jti) return reject("token carries no jti");
1291
+
1292
+ // Registry half: the jti must be a REGISTERED mint (renewal's revocation
1293
+ // lifecycle depends on the row; an unregistered long-lived token is
1294
+ // unrevocable by construction and not reconcilable here).
1295
+ const registryRow = findTokenRowByJti(deps.db, jti);
1296
+ if (!registryRow) return reject("jti is not in the token registry");
1297
+ // NOTE: created_via is deliberately NOT filtered here. A claim grandfathers
1298
+ // a token that already exists and is already registered — provenance
1299
+ // (cli_mint, connection_credential, …) adds no authority either way, and
1300
+ // a connection_credential jti with an active record is already refused by
1301
+ // the existing-record check below.
1302
+
1303
+ // Declaration half — the same checks create performs, so a claim can't
1304
+ // smuggle past anything the operator-initiated path would refuse.
1305
+ const mod = deps.modules.find((m) => m.short === moduleShort);
1306
+ if (!mod) return reject(`no installed module "${moduleShort}"`);
1307
+ const decl = findCredentialDecl(mod.manifest, key);
1308
+ if (!decl) return reject(`module "${moduleShort}" declares no credential "${key}"`);
1309
+ if (!CREDENTIAL_SCOPE_TEMPLATE_RE.test(decl.scope)) {
1310
+ return reject(`declared scope template "${decl.scope}" is not grantable`);
1311
+ }
1312
+ if (!decl.endpoint || !decl.endpoint.startsWith("/")) {
1313
+ return reject(`credential "${moduleShort}.${key}" declares no delivery endpoint`);
1314
+ }
1315
+ if (deps.resolveVaultOrigin(vault) === null) return reject(`no vault named "${vault}"`);
1316
+
1317
+ // Identity half: the claimed id must be EXACTLY the id the hub derives for
1318
+ // this module/key/vault (the same derivation create uses by default) — the
1319
+ // id alone is ambiguous to parse (keys may contain dashes), so the body
1320
+ // names the parts and the derivation closes the loop.
1321
+ const impliedId = `cred-${moduleShort}-${key}-${vault}`.toLowerCase();
1322
+ if (id !== impliedId)
1323
+ return reject(`id does not match module/key/vault (implies "${impliedId}")`);
1324
+
1325
+ // Token-shape half: the presented credential must carry EXACTLY the grant
1326
+ // the connection implies — scope at the declared verb, audience-bound and
1327
+ // vault_scope-pinned to the vault (what makes it usable there at all).
1328
+ const verb = decl.scope.endsWith(":write") ? "write" : "read";
1329
+ const scope = `vault:${vault}:${verb}`;
1330
+ const tokenScopes =
1331
+ typeof payload.scope === "string" ? payload.scope.split(" ").filter((s) => s.length > 0) : [];
1332
+ if (!tokenScopes.includes(scope)) return reject(`token scope does not include "${scope}"`);
1333
+ const aud = payload.aud;
1334
+ const audOk = aud === `vault.${vault}` || (Array.isArray(aud) && aud.includes(`vault.${vault}`));
1335
+ if (!audOk) return reject(`token aud is not "vault.${vault}"`);
1336
+ // vault_scope: connection-minted tokens pin the vault here; CLI-minted
1337
+ // tokens (the very population claims reconcile — surface#113's live case)
1338
+ // carry vault_scope: [] and pin the vault via scope + aud instead, both
1339
+ // already exact-matched above. An EMPTY vault_scope is therefore accepted;
1340
+ // a NON-empty one that omits this vault is a genuine mismatch (the token
1341
+ // was pinned elsewhere) and is refused.
1342
+ const vaultScopePin = Array.isArray(payload.vault_scope) ? payload.vault_scope : [];
1343
+ if (vaultScopePin.length > 0 && !vaultScopePin.includes(vault)) {
1344
+ return reject(`token vault_scope does not pin "${vault}"`);
1345
+ }
1346
+ if (!registryRow.scopes.includes(scope)) return reject("registry row scope mismatch");
1347
+
1348
+ // Tags ride along verbatim from the SIGNED token (never request input) —
1349
+ // renewal will re-mint exactly this shape.
1350
+ const scopedTags = readScopedTagsClaim(payload);
1351
+
1352
+ const nowIso = (deps.now?.() ?? new Date()).toISOString();
1353
+ const pendingRecord: ConnectionRecord = {
1354
+ id,
1355
+ kind: "credential",
1356
+ status: "pending",
1357
+ source: { module: "vault", vault, event: "credential" },
1358
+ sink: {
1359
+ module: moduleShort,
1360
+ action: `credential.${key}`,
1361
+ ...(scopedTags.length > 0 ? { params: { tags: scopedTags } } : {}),
1362
+ },
1363
+ provisioned: {
1364
+ type: "credential",
1365
+ vault,
1366
+ mintedJtis: [jti],
1367
+ scope,
1368
+ scopedTags,
1369
+ credentialKey: key,
1370
+ endpoint: decl.endpoint,
1371
+ },
1372
+ createdAt: nowIso,
1373
+ requestedBy: moduleShort,
1374
+ };
1375
+
1376
+ const existing = readConnections(deps.storePath).find((r) => r.id === id);
1377
+ if (existing) {
1378
+ if (existing.kind !== "credential") {
1379
+ return reject("id names an existing non-credential connection");
1380
+ }
1381
+ const holdsCurrent = (existing.provisioned?.mintedJtis ?? []).includes(jti);
1382
+ if (existing.status !== "pending") {
1383
+ // ACTIVE record: never mutated by a claim. The current holder learns
1384
+ // renewal already works; anything else is refused generically.
1385
+ if (holdsCurrent) {
1386
+ return json(200, { ok: true, connection_id: id, status: "active" });
1387
+ }
1388
+ return reject("an active connection already exists for this id");
1389
+ }
1390
+ if (holdsCurrent) {
1391
+ // Idempotent re-claim — same pending record, no dupes, no rewrite.
1392
+ return json(202, claimPendingBody(id));
1393
+ }
1394
+ // A different fully-validated credential supersedes the unapproved claim
1395
+ // (pending grants nothing; last writer wins until the operator approves).
1396
+ }
1397
+
1398
+ putConnection(deps.storePath, pendingRecord);
1399
+ return json(202, claimPendingBody(id));
1400
+ }
1401
+
1402
+ function claimPendingBody(id: string): Record<string, unknown> {
1403
+ return {
1404
+ ok: true,
1405
+ connection_id: id,
1406
+ status: "pending",
1407
+ detail:
1408
+ "claim recorded — awaiting operator approval in the hub admin Connections view; renewal is enabled after approval",
1409
+ };
1410
+ }
1411
+
1412
+ /** `permissions.scoped_tags` from a validated token payload (strings only). */
1413
+ function readScopedTagsClaim(payload: Record<string, unknown>): string[] {
1414
+ const permissions = payload.permissions;
1415
+ if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return [];
1416
+ const tags = (permissions as Record<string, unknown>).scoped_tags;
1417
+ if (!Array.isArray(tags)) return [];
1418
+ return tags.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
1419
+ }
1420
+
1421
+ /**
1422
+ * POST /admin/connections/:id/approve — the operator's one-click activation
1423
+ * of a pending claim (surface#113). Operator-gated by the caller (same
1424
+ * session gate as create/teardown, CSRF-belted in hub-server.ts). Flips
1425
+ * `status: "pending"` → active by dropping the field; mints NOTHING and
1426
+ * delivers NOTHING — the module already holds the credential, approval only
1427
+ * lets the existing renewal flow find the record. Idempotent: approving an
1428
+ * already-active credential record reports active without rewriting it.
1429
+ */
1430
+ function approveCredentialConnection(id: string, deps: ConnectionsDeps): Response {
1431
+ if (!CONNECTION_ID_RE.test(id)) {
1432
+ return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
1433
+ }
1434
+ const record = readConnections(deps.storePath).find((r) => r.id === id);
1435
+ if (!record) return jsonError(404, "not_found", `no connection "${id}"`);
1436
+ if (record.kind !== "credential") {
1437
+ return jsonError(400, "not_claimable", `connection "${id}" is not a credential connection`);
1438
+ }
1439
+ if (record.status !== "pending") {
1440
+ return json(200, { ok: true, id, status: "active" });
1441
+ }
1442
+ const { status: _pending, ...approved } = record;
1443
+ putConnection(deps.storePath, approved);
1444
+ return json(200, { ok: true, id, status: "active" });
1445
+ }
1446
+
1447
+ function findCredentialDecl(m: ModuleManifest, key: string): ModuleCredential | undefined {
1448
+ return (m.credentials ?? []).find((c) => c.key === key);
1449
+ }
1450
+
602
1451
  // ---------------------------------------------------------------------------
603
1452
  // DELETE — teardown
604
1453
  // ---------------------------------------------------------------------------
@@ -656,8 +1505,43 @@ export async function teardownConnection(
656
1505
  }
657
1506
  }
658
1507
 
1508
+ // --- Credential removal notification (H4, best-effort). -------------------
1509
+ // The module holding the credential gets a removal payload at its declared
1510
+ // endpoint so it can drop the stored token. Best-effort by design: the jti
1511
+ // revocation below is the authoritative kill (the revocation list reaches
1512
+ // every resource server); a missed notification only leaves the module
1513
+ // holding a dead credential it will discover on first use.
1514
+ if (record.kind === "credential") {
1515
+ const endpoint = record.provisioned?.endpoint;
1516
+ const key = record.provisioned?.credentialKey ?? "";
1517
+ const credVault = record.provisioned?.vault ?? "";
1518
+ if (endpoint) {
1519
+ const removal: CredentialPayload = {
1520
+ kind: "credential",
1521
+ op: "removed",
1522
+ connection_id: record.id,
1523
+ key,
1524
+ vault: credVault,
1525
+ scope: record.provisioned?.scope ?? "",
1526
+ scoped_tags: [...(record.provisioned?.scopedTags ?? [])],
1527
+ };
1528
+ const notified = await deliverCredentialPayload(
1529
+ deps,
1530
+ userId,
1531
+ record.sink.module,
1532
+ endpoint,
1533
+ removal,
1534
+ );
1535
+ if (!notified.ok) {
1536
+ errors.push({ step: "credential_notify", detail: notified.detail });
1537
+ }
1538
+ }
1539
+ }
1540
+
659
1541
  // --- Channel-sink teardown (remove the channel config entry). ------------
660
- if (record.sink.module === "channel" && deps.channelOrigin) {
1542
+ // Fenced to event→action records: a credential connection whose HOLDER is
1543
+ // the channel module must not delete an unrelated channel config entry.
1544
+ if (record.kind !== "credential" && record.sink.module === "channel" && deps.channelOrigin) {
661
1545
  const channelName =
662
1546
  typeof record.sink.params?.channel === "string" ? record.sink.params.channel : record.id;
663
1547
  try {
@@ -904,18 +1788,33 @@ interface MintSpec {
904
1788
  audience: string;
905
1789
  vaultScope: string[];
906
1790
  ttlSeconds: number;
1791
+ /**
1792
+ * Registry provenance for long-lived mints. Defaults to the engine's
1793
+ * original `connection_provision`; credential connections (H4) pass
1794
+ * `connection_credential` so the registry distinguishes the two grants.
1795
+ */
1796
+ createdVia?: "connection_provision" | "connection_credential";
1797
+ /**
1798
+ * Extra `permissions` claim (H4 — `{ scoped_tags: [...] }`, the claim path
1799
+ * vault's tag-scope enforcement reads). Embedded in the JWT AND persisted
1800
+ * (JSON) on the registry row.
1801
+ */
1802
+ permissions?: Record<string, unknown>;
907
1803
  }
908
1804
 
909
1805
  async function mint(deps: ConnectionsDeps, userId: string, spec: MintSpec) {
910
1806
  const sign = deps.signToken ?? signAccessToken;
911
1807
  const signed = await sign(deps.db, {
912
- sub: userId,
1808
+ // `sub` falls back to the provenance subject when no operator user is in
1809
+ // the loop (H4 renewal is module-initiated — no session, no user row).
1810
+ sub: userId || "connection",
913
1811
  scopes: spec.scopes,
914
1812
  audience: spec.audience,
915
1813
  clientId: PROVISION_CLIENT_ID,
916
1814
  issuer: deps.hubOrigin,
917
1815
  ttlSeconds: spec.ttlSeconds,
918
1816
  vaultScope: spec.vaultScope,
1817
+ ...(spec.permissions !== undefined ? { extraClaims: { permissions: spec.permissions } } : {}),
919
1818
  ...(deps.now !== undefined ? { now: deps.now } : {}),
920
1819
  });
921
1820
  // Register long-lived mints so they're revocable on teardown. Short-lived
@@ -923,12 +1822,15 @@ async function mint(deps: ConnectionsDeps, userId: string, spec: MintSpec) {
923
1822
  if (spec.ttlSeconds > REGISTERED_MINT_TTL_THRESHOLD_SECONDS) {
924
1823
  recordTokenMint(deps.db, {
925
1824
  jti: signed.jti,
926
- createdVia: "connection_provision",
1825
+ createdVia: spec.createdVia ?? "connection_provision",
927
1826
  subject: "connection",
928
- userId,
1827
+ // tokens.user_id carries an FK to users(id) — only write it when a
1828
+ // real operator user is in the loop (empty = renewal, no session).
1829
+ ...(userId ? { userId } : {}),
929
1830
  clientId: PROVISION_CLIENT_ID,
930
1831
  scopes: spec.scopes,
931
1832
  expiresAt: signed.expiresAt,
1833
+ ...(spec.permissions !== undefined ? { permissions: JSON.stringify(spec.permissions) } : {}),
932
1834
  ...(deps.now !== undefined ? { now: deps.now } : {}),
933
1835
  });
934
1836
  }