@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.
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
package/src/admin-connections.ts
CHANGED
|
@@ -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
|
-
* #
|
|
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 `
|
|
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
|
|
31
|
-
* engine — it runs only for `sink.module === "
|
|
32
|
-
* (`
|
|
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 {
|
|
48
|
-
|
|
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. `/
|
|
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
|
-
/**
|
|
115
|
-
|
|
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
|
|
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
|
|
250
|
-
|
|
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 (
|
|
253
|
-
if (
|
|
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
|
|
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
|
-
* `"
|
|
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 (
|
|
604
|
+
// --- Sink prerequisite (agent message-delivery reply path). --------------
|
|
457
605
|
// Everything below this is general; THIS block is the only sink-specific step.
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
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
|
-
`
|
|
622
|
+
`agent sink requires sink.params.channel as a valid identifier; got "${channelName}"`,
|
|
467
623
|
);
|
|
468
624
|
}
|
|
469
|
-
const prep = await
|
|
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 === "
|
|
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
|
|
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
|
-
*
|
|
546
|
-
*
|
|
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
|
|
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.
|
|
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, //
|
|
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: ["
|
|
578
|
-
audience: "
|
|
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.
|
|
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("
|
|
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("
|
|
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
|
-
// ---
|
|
660
|
-
|
|
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: ["
|
|
667
|
-
audience: "
|
|
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.
|
|
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: "
|
|
1578
|
+
errors.push({ step: "agent_config", detail: await remoteDetail(res) });
|
|
678
1579
|
}
|
|
679
1580
|
} catch (err) {
|
|
680
|
-
errors.push({ step: "
|
|
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,
|
|
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
|
-
* `
|
|
815
|
-
* the
|
|
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
|
|
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 === "
|
|
844
|
-
return `
|
|
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
|
|
860
|
-
launch: `claude --dangerously-load-development-channels=server:
|
|
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
|
|
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
|
-
|
|
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
|
}
|