@openparachute/hub 0.6.5-rc.8 → 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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +310 -6
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- 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/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
|
@@ -0,0 +1,1882 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/connections/catalog` + `GET/POST/DELETE /admin/connections` — the
|
|
3
|
+
* general module event→action Connections engine (2026-06-09 modular-UI
|
|
4
|
+
* architecture, P5). Generalizes the channel-specific `/admin/channels`
|
|
5
|
+
* endpoint (hub#624 era; retired in boundary D1): "add a vault-backed channel"
|
|
6
|
+
* is just the first connection, `vault.note.created (filter
|
|
7
|
+
* #channel-message/inbound) → channel.message.deliver`.
|
|
8
|
+
*
|
|
9
|
+
* THE CONCEPT. A connection wires "when [EVENT] in [source module] (filter) →
|
|
10
|
+
* do [ACTION] in [sink module]". The sink is ALWAYS an action. Modules declare
|
|
11
|
+
* the `events` they emit + the `actions` they accept in `module.json` (landed
|
|
12
|
+
* P4). The hub is the only thing with cross-module authority (mint tokens,
|
|
13
|
+
* register vault triggers), so this is hub-native.
|
|
14
|
+
*
|
|
15
|
+
* WHY IT'S GENERAL, NOT CHANNEL-HARDCODED. The provisioning engine reads
|
|
16
|
+
* everything it needs from the declarations:
|
|
17
|
+
* - the SINK action's `endpoint` → the hub-proxied webhook the vault calls
|
|
18
|
+
* (`<hub-origin>/<sink-mount><endpoint>`). NOT a hardcoded channel path.
|
|
19
|
+
* - the SINK action's `scope` → the OAuth scope minted into the webhook's
|
|
20
|
+
* `Authorization: Bearer`. NOT a hardcoded `channel:send`.
|
|
21
|
+
* - the SOURCE event key → the vault trigger's `events` (`note.created` →
|
|
22
|
+
* `["created"]`, `note.updated` → `["updated"]`).
|
|
23
|
+
* - the SOURCE filter → the vault trigger's `when` predicate (`tags` /
|
|
24
|
+
* `has_metadata` / `missing_metadata` / `has_content`), 1:1.
|
|
25
|
+
* Any future `vault-trigger` sink (a different module's action) provisions
|
|
26
|
+
* through the same path with zero hub code changes.
|
|
27
|
+
*
|
|
28
|
+
* THE ONE SINK-SPECIFIC PREREQUISITE. A vault-backed channel additionally needs
|
|
29
|
+
* its reply path wired: a `vault:<v>:write` token + a `channels.json` entry so
|
|
30
|
+
* the session can reply. That's a property of the channel SINK, not of the
|
|
31
|
+
* engine — it runs only for `sink.module === "channel"` and is clearly fenced
|
|
32
|
+
* (`prepareChannelSink`). Everything else is declaration-driven.
|
|
33
|
+
*
|
|
34
|
+
* AUTH. Same gate as the admin-token mints: a cookie-gated operator session
|
|
35
|
+
* pinned to the first admin. The catalog (`/api/connections/catalog`) is
|
|
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).
|
|
73
|
+
*/
|
|
74
|
+
import type { Database } from "bun:sqlite";
|
|
75
|
+
import {
|
|
76
|
+
type ConnectionRecord,
|
|
77
|
+
type ConnectionSink,
|
|
78
|
+
type ConnectionSource,
|
|
79
|
+
putConnection,
|
|
80
|
+
readConnections,
|
|
81
|
+
removeConnection,
|
|
82
|
+
} from "./connections-store.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";
|
|
97
|
+
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
98
|
+
import { isFirstAdmin } from "./users.ts";
|
|
99
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
100
|
+
|
|
101
|
+
/** Short TTL — provisioning calls use these immediately. */
|
|
102
|
+
const PROVISION_TOKEN_TTL_SECONDS = 60;
|
|
103
|
+
/**
|
|
104
|
+
* The webhook bearer is persisted as the vault trigger's `action.auth.bearer` —
|
|
105
|
+
* the vault re-presents it on every callback, so it must outlive the request.
|
|
106
|
+
* Long-lived (~90d) to match the daemon's headless-credential posture.
|
|
107
|
+
*/
|
|
108
|
+
const WEBHOOK_BEARER_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
109
|
+
const PROVISION_CLIENT_ID = "parachute-hub-spa";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Connection id charset. The id lands in a URL path segment (DELETE) and is the
|
|
113
|
+
* basis for the derived vault trigger name — keep it a conservative slug.
|
|
114
|
+
*/
|
|
115
|
+
const CONNECTION_ID_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Channel-name charset. A channel name lands in a services.json key, a URL
|
|
119
|
+
* path segment, and an MCP server name — keep it a conservative slug to close
|
|
120
|
+
* injection across all of them.
|
|
121
|
+
*/
|
|
122
|
+
const CHANNEL_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Provenance label charset (modular-UI R2). `requestedBy` is an operator-/module-
|
|
126
|
+
* supplied label that lands in the Connections SPA as a grouping key — keep it a
|
|
127
|
+
* conservative slug so it can't carry markup or odd characters into the view.
|
|
128
|
+
* Defaults to `"custom"` (the hub's own builder) when omitted.
|
|
129
|
+
*/
|
|
130
|
+
const REQUESTED_BY_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
131
|
+
const DEFAULT_REQUESTED_BY = "custom";
|
|
132
|
+
|
|
133
|
+
/** A module installed on this hub, with its manifest + resolved mount path. */
|
|
134
|
+
export interface InstalledModuleInfo {
|
|
135
|
+
/** Module short name (the catalog/wire key). */
|
|
136
|
+
readonly short: string;
|
|
137
|
+
/** Parsed `.parachute/module.json`. */
|
|
138
|
+
readonly manifest: ModuleManifest;
|
|
139
|
+
/**
|
|
140
|
+
* The module's user-facing mount path under the hub origin (e.g. `/channel`),
|
|
141
|
+
* used to build a hub-proxied webhook from a sink action's `endpoint`.
|
|
142
|
+
* `null` when the module declares no user-facing mount.
|
|
143
|
+
*/
|
|
144
|
+
readonly mount: string | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ConnectionsDeps {
|
|
148
|
+
db: Database;
|
|
149
|
+
/** Public hub origin — webhook URL + connect lines + minted-token `iss`. */
|
|
150
|
+
hubOrigin: string;
|
|
151
|
+
/**
|
|
152
|
+
* Snapshot of installed modules + their manifests + mounts, read at request
|
|
153
|
+
* time so a freshly-installed module's events/actions show up without a hub
|
|
154
|
+
* restart. Keyed scan is fine — cardinality is small.
|
|
155
|
+
*/
|
|
156
|
+
modules: InstalledModuleInfo[];
|
|
157
|
+
/**
|
|
158
|
+
* Resolve a vault's loopback origin (e.g. `http://127.0.0.1:1940`) from
|
|
159
|
+
* services.json, or `null` when no vault by that name is installed.
|
|
160
|
+
*/
|
|
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;
|
|
172
|
+
/** Loopback origin for the channel daemon, or `null` when not installed. */
|
|
173
|
+
channelOrigin: string | null;
|
|
174
|
+
/** Absolute path to `connections.json` in the hub state dir. */
|
|
175
|
+
storePath: string;
|
|
176
|
+
/** Test seam — `globalThis.fetch` in production. */
|
|
177
|
+
fetchImpl?: typeof fetch;
|
|
178
|
+
/** Test seam — defaults to the real `signAccessToken`. */
|
|
179
|
+
signToken?: typeof signAccessToken;
|
|
180
|
+
/** Test seam for the clock. */
|
|
181
|
+
now?: () => Date;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ===========================================================================
|
|
185
|
+
// Catalog — GET /api/connections/catalog
|
|
186
|
+
// ===========================================================================
|
|
187
|
+
|
|
188
|
+
interface CatalogEvent {
|
|
189
|
+
module: string;
|
|
190
|
+
key: string;
|
|
191
|
+
title: string;
|
|
192
|
+
filterSchema: unknown;
|
|
193
|
+
}
|
|
194
|
+
interface CatalogAction {
|
|
195
|
+
module: string;
|
|
196
|
+
key: string;
|
|
197
|
+
title: string;
|
|
198
|
+
inputSchema: unknown;
|
|
199
|
+
/** The provision descriptor (e.g. `{ type: "vault-trigger" }`), opaque to the SPA. */
|
|
200
|
+
provision: unknown;
|
|
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
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A connection preset declared in a module's `module.json`
|
|
219
|
+
* `connectionTemplates` (boundary D2). Drives the SPA builder's one-click
|
|
220
|
+
* preset buttons — declaration-driven, replacing the SPA's hardcoded
|
|
221
|
+
* channel preset (the charter's per-module-view test).
|
|
222
|
+
*/
|
|
223
|
+
interface CatalogTemplate {
|
|
224
|
+
/** Short of the DECLARING module (the template can wire other modules). */
|
|
225
|
+
module: string;
|
|
226
|
+
key: string;
|
|
227
|
+
title: string;
|
|
228
|
+
description: string | null;
|
|
229
|
+
requestedBy: string | null;
|
|
230
|
+
source: { module: string; event: string; filter: unknown };
|
|
231
|
+
sink: { module: string; action: string };
|
|
232
|
+
parameters: {
|
|
233
|
+
key: string;
|
|
234
|
+
target: string;
|
|
235
|
+
title: string | null;
|
|
236
|
+
description: string | null;
|
|
237
|
+
example: string | null;
|
|
238
|
+
}[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build the catalog from the installed modules' declared
|
|
243
|
+
* events/actions/templates. Drives the SPA builder's source/sink dropdowns +
|
|
244
|
+
* preset buttons. NO tokens, NO secrets — pure declaration metadata read from
|
|
245
|
+
* each `module.json`.
|
|
246
|
+
*/
|
|
247
|
+
export function buildCatalog(modules: InstalledModuleInfo[]): {
|
|
248
|
+
events: CatalogEvent[];
|
|
249
|
+
actions: CatalogAction[];
|
|
250
|
+
templates: CatalogTemplate[];
|
|
251
|
+
credentials: CatalogCredential[];
|
|
252
|
+
} {
|
|
253
|
+
const events: CatalogEvent[] = [];
|
|
254
|
+
const actions: CatalogAction[] = [];
|
|
255
|
+
const templates: CatalogTemplate[] = [];
|
|
256
|
+
const credentials: CatalogCredential[] = [];
|
|
257
|
+
for (const { short, manifest } of modules) {
|
|
258
|
+
for (const e of manifest.events ?? []) {
|
|
259
|
+
events.push({
|
|
260
|
+
module: short,
|
|
261
|
+
key: e.key,
|
|
262
|
+
title: e.title,
|
|
263
|
+
filterSchema: e.filterSchema ?? null,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
for (const a of manifest.actions ?? []) {
|
|
267
|
+
actions.push({
|
|
268
|
+
module: short,
|
|
269
|
+
key: a.key,
|
|
270
|
+
title: a.title,
|
|
271
|
+
inputSchema: a.inputSchema ?? null,
|
|
272
|
+
provision: a.provision ?? null,
|
|
273
|
+
});
|
|
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
|
+
}
|
|
285
|
+
for (const t of manifest.connectionTemplates ?? []) {
|
|
286
|
+
// Only event→action presets surface here — a template without BOTH
|
|
287
|
+
// source and sink (e.g. scribe's `kind: "config"` link, consumed by
|
|
288
|
+
// scribe's own UI) isn't something the hub builder can pre-fill.
|
|
289
|
+
if (!t.source || !t.sink) continue;
|
|
290
|
+
templates.push({
|
|
291
|
+
module: short,
|
|
292
|
+
key: t.key,
|
|
293
|
+
title: t.title,
|
|
294
|
+
description: t.description ?? null,
|
|
295
|
+
requestedBy: t.requestedBy ?? null,
|
|
296
|
+
source: { module: t.source.module, event: t.source.event, filter: t.source.filter ?? null },
|
|
297
|
+
sink: { module: t.sink.module, action: t.sink.action },
|
|
298
|
+
parameters: (t.parameters ?? []).map((p) => ({
|
|
299
|
+
key: p.key,
|
|
300
|
+
target: p.target,
|
|
301
|
+
title: p.title ?? null,
|
|
302
|
+
description: p.description ?? null,
|
|
303
|
+
example: p.example ?? null,
|
|
304
|
+
})),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { events, actions, templates, credentials };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function handleConnectionsCatalog(
|
|
312
|
+
req: Request,
|
|
313
|
+
deps: ConnectionsDeps,
|
|
314
|
+
): Promise<Response> {
|
|
315
|
+
const gate = operatorGate(req, deps);
|
|
316
|
+
if (gate) return gate;
|
|
317
|
+
return json(200, buildCatalog(deps.modules));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ===========================================================================
|
|
321
|
+
// Collection + item — GET/POST /admin/connections, DELETE /admin/connections/:id
|
|
322
|
+
// ===========================================================================
|
|
323
|
+
|
|
324
|
+
export async function handleConnections(
|
|
325
|
+
req: Request,
|
|
326
|
+
/** Path after `/admin/connections` — `""` for the collection, `/<id>` for
|
|
327
|
+
* an item, `/<id>/renew` for credential renewal (H4). */
|
|
328
|
+
subPath: string,
|
|
329
|
+
deps: ConnectionsDeps,
|
|
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
|
+
|
|
362
|
+
const gate = operatorGate(req, deps);
|
|
363
|
+
if (gate) return gate;
|
|
364
|
+
const { userId } = sessionUser(req, deps);
|
|
365
|
+
|
|
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
|
+
}
|
|
376
|
+
|
|
377
|
+
if (segments.length === 0 && method === "GET") return listConnections(deps);
|
|
378
|
+
if (segments.length === 0 && method === "POST") return createConnection(req, userId, deps);
|
|
379
|
+
if (itemId !== "" && method === "DELETE") return teardownConnection(itemId, userId, deps);
|
|
380
|
+
return jsonError(
|
|
381
|
+
405,
|
|
382
|
+
"method_not_allowed",
|
|
383
|
+
"use GET/POST on /admin/connections, DELETE on /admin/connections/:id, or POST on /admin/connections/:id/renew, /claim, /approve",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// GET — list
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
function listConnections(deps: ConnectionsDeps): Response {
|
|
392
|
+
// The store never holds a token — records carry source/sink/provisioned
|
|
393
|
+
// metadata only — so this is a straight read. Project to the wire shape so
|
|
394
|
+
// the response is stable regardless of the on-disk record shape.
|
|
395
|
+
const connections = readConnections(deps.storePath).map((c) => ({
|
|
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 } : {}),
|
|
405
|
+
source: c.source,
|
|
406
|
+
sink: c.sink,
|
|
407
|
+
provisioned: c.provisioned,
|
|
408
|
+
created_at: c.createdAt,
|
|
409
|
+
// Provenance (modular-UI R2). Records written before R2 carry no
|
|
410
|
+
// `requestedBy`; project them as the default so the SPA grouping is total.
|
|
411
|
+
requested_by: c.requestedBy ?? DEFAULT_REQUESTED_BY,
|
|
412
|
+
// Records provisioned before the registered-mint rule (B0) carry no
|
|
413
|
+
// mintedJtis — their long-lived tokens were never registered, so teardown
|
|
414
|
+
// can't revoke them (they ride to their original ~90d expiry).
|
|
415
|
+
...(isLegacyRecord(c) ? { legacy: true } : {}),
|
|
416
|
+
}));
|
|
417
|
+
return json(200, { ok: true, connections });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A record minted before B0 — no registered jtis, tokens unrevocable. */
|
|
421
|
+
function isLegacyRecord(c: ConnectionRecord): boolean {
|
|
422
|
+
return (c.provisioned?.mintedJtis?.length ?? 0) === 0;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// POST — create + provision
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
interface CreateBody {
|
|
430
|
+
/** `"credential"` routes to the H4 flow; absent/anything-else = event→action. */
|
|
431
|
+
kind?: unknown;
|
|
432
|
+
source?: {
|
|
433
|
+
module?: unknown;
|
|
434
|
+
vault?: unknown;
|
|
435
|
+
event?: unknown;
|
|
436
|
+
filter?: unknown;
|
|
437
|
+
};
|
|
438
|
+
sink?: {
|
|
439
|
+
module?: unknown;
|
|
440
|
+
action?: unknown;
|
|
441
|
+
params?: unknown;
|
|
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
|
+
};
|
|
450
|
+
/** Optional operator-supplied id; otherwise derived from source/sink. */
|
|
451
|
+
id?: unknown;
|
|
452
|
+
/**
|
|
453
|
+
* Provenance — WHO requested this connection (modular-UI R2). A module-owned
|
|
454
|
+
* config UI calling this endpoint on the operator's behalf labels itself (e.g.
|
|
455
|
+
* `"channel"`); the hub's own builder omits it and falls back to `"custom"`.
|
|
456
|
+
*/
|
|
457
|
+
requestedBy?: unknown;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function createConnection(
|
|
461
|
+
req: Request,
|
|
462
|
+
userId: string,
|
|
463
|
+
deps: ConnectionsDeps,
|
|
464
|
+
): Promise<Response> {
|
|
465
|
+
let body: CreateBody;
|
|
466
|
+
try {
|
|
467
|
+
body = (await req.json()) as CreateBody;
|
|
468
|
+
} catch {
|
|
469
|
+
return jsonError(400, "invalid_request", "request body must be JSON");
|
|
470
|
+
}
|
|
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
|
+
|
|
478
|
+
const sourceModule = str(body.source?.module);
|
|
479
|
+
const sourceEvent = str(body.source?.event);
|
|
480
|
+
const sinkModule = str(body.sink?.module);
|
|
481
|
+
const sinkAction = str(body.sink?.action);
|
|
482
|
+
|
|
483
|
+
// Provenance label (modular-UI R2). Default to the hub's own builder; a
|
|
484
|
+
// module-owned config UI that POSTs on the operator's behalf labels itself.
|
|
485
|
+
// Validated to a slug so a bad value can't poison the SPA grouping.
|
|
486
|
+
const requestedByRaw = str(body.requestedBy);
|
|
487
|
+
const requestedBy = requestedByRaw === "" ? DEFAULT_REQUESTED_BY : requestedByRaw.toLowerCase();
|
|
488
|
+
if (!REQUESTED_BY_RE.test(requestedBy)) {
|
|
489
|
+
return jsonError(
|
|
490
|
+
400,
|
|
491
|
+
"invalid_request",
|
|
492
|
+
`requestedBy "${requestedByRaw}" is not a valid label (letters, numbers, dash, underscore)`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (!sourceModule || !sourceEvent || !sinkModule || !sinkAction) {
|
|
496
|
+
return jsonError(
|
|
497
|
+
400,
|
|
498
|
+
"invalid_request",
|
|
499
|
+
"source.module, source.event, sink.module, sink.action are all required",
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// --- Validate event + action existence against the declared catalog. -----
|
|
504
|
+
const src = deps.modules.find((m) => m.short === sourceModule);
|
|
505
|
+
if (!src) return jsonError(400, "unknown_module", `no installed module "${sourceModule}"`);
|
|
506
|
+
const event = findEvent(src.manifest, sourceEvent);
|
|
507
|
+
if (!event) {
|
|
508
|
+
return jsonError(
|
|
509
|
+
400,
|
|
510
|
+
"unknown_event",
|
|
511
|
+
`module "${sourceModule}" declares no event "${sourceEvent}"`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const sink = deps.modules.find((m) => m.short === sinkModule);
|
|
515
|
+
if (!sink) return jsonError(400, "unknown_module", `no installed module "${sinkModule}"`);
|
|
516
|
+
const action = findAction(sink.manifest, sinkAction);
|
|
517
|
+
if (!action) {
|
|
518
|
+
return jsonError(
|
|
519
|
+
400,
|
|
520
|
+
"unknown_action",
|
|
521
|
+
`module "${sinkModule}" declares no action "${sinkAction}"`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const provisionType = readProvisionType(action.provision);
|
|
526
|
+
if (provisionType !== "vault-trigger") {
|
|
527
|
+
return jsonError(
|
|
528
|
+
400,
|
|
529
|
+
"unsupported_provision",
|
|
530
|
+
`action "${sinkModule}.${sinkAction}" provision type ${provisionType ? `"${provisionType}"` : "(none)"} is not supported (only "vault-trigger" today)`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// vault-trigger requires the source to be a vault event on a named vault.
|
|
535
|
+
if (sourceModule !== "vault") {
|
|
536
|
+
return jsonError(
|
|
537
|
+
400,
|
|
538
|
+
"invalid_source",
|
|
539
|
+
`a "vault-trigger" sink requires a vault source event; got "${sourceModule}"`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
// The source event must map to a vault-trigger verb. `note.deleted` is a
|
|
543
|
+
// declared vault event (passes the catalog check above) but has no trigger
|
|
544
|
+
// verb today — reject it cleanly here rather than 500ing downstream.
|
|
545
|
+
try {
|
|
546
|
+
eventsForSourceEvent(sourceEvent);
|
|
547
|
+
} catch {
|
|
548
|
+
return jsonError(
|
|
549
|
+
400,
|
|
550
|
+
"unsupported_event",
|
|
551
|
+
`source event "${sourceEvent}" has no vault-trigger mapping (supported: note.created, note.updated)`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const vault = str(body.source?.vault);
|
|
555
|
+
if (!VAULT_NAME_CHARSET_RE.test(vault)) {
|
|
556
|
+
return jsonError(400, "invalid_request", `source.vault "${vault}" is not a valid identifier`);
|
|
557
|
+
}
|
|
558
|
+
const vaultOrigin = deps.resolveVaultOrigin(vault);
|
|
559
|
+
if (vaultOrigin === null) {
|
|
560
|
+
return jsonError(400, "unknown_vault", `no vault named "${vault}" in this hub`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// The sink action MUST declare its webhook endpoint + scope for the hub to
|
|
564
|
+
// wire it generically. A vault-trigger action without these is mis-declared.
|
|
565
|
+
if (!action.endpoint || !action.scope) {
|
|
566
|
+
return jsonError(
|
|
567
|
+
400,
|
|
568
|
+
"action_underdeclared",
|
|
569
|
+
`action "${sinkModule}.${sinkAction}" is a vault-trigger sink but declares no endpoint/scope`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (sink.mount === null) {
|
|
573
|
+
return jsonError(
|
|
574
|
+
400,
|
|
575
|
+
"sink_unmounted",
|
|
576
|
+
`sink module "${sinkModule}" has no mount path — cannot build a hub-proxied webhook`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const filter = readFilter(body.source?.filter);
|
|
581
|
+
const sourceRec: ConnectionSource = {
|
|
582
|
+
module: sourceModule,
|
|
583
|
+
vault,
|
|
584
|
+
event: sourceEvent,
|
|
585
|
+
...(filter ? { filter } : {}),
|
|
586
|
+
};
|
|
587
|
+
const sinkParams = readParams(body.sink?.params);
|
|
588
|
+
const sinkRec: ConnectionSink = {
|
|
589
|
+
module: sinkModule,
|
|
590
|
+
action: sinkAction,
|
|
591
|
+
...(sinkParams ? { params: sinkParams } : {}),
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// Connection id — operator-supplied or derived. Drives the trigger name.
|
|
595
|
+
const id = deriveId(body.id, sourceRec, sinkRec);
|
|
596
|
+
if (!CONNECTION_ID_RE.test(id)) {
|
|
597
|
+
return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// jtis of the long-lived tokens minted for this connection — persisted on
|
|
601
|
+
// the record so teardown can revoke them (registered-mint rule).
|
|
602
|
+
const mintedJtis: string[] = [];
|
|
603
|
+
|
|
604
|
+
// --- Sink prerequisite (channel reply path), fenced to the channel sink. --
|
|
605
|
+
// Everything below this is general; THIS block is the only sink-specific step.
|
|
606
|
+
// The channel name comes from the action params (`sink.params.channel`) — it
|
|
607
|
+
// becomes a services.json key + an MCP server name, so it must be a slug.
|
|
608
|
+
if (sinkModule === "channel") {
|
|
609
|
+
const channelName = typeof sinkParams?.channel === "string" ? sinkParams.channel : "";
|
|
610
|
+
if (!CHANNEL_NAME_RE.test(channelName)) {
|
|
611
|
+
return jsonError(
|
|
612
|
+
400,
|
|
613
|
+
"invalid_request",
|
|
614
|
+
`channel sink requires sink.params.channel as a valid identifier; got "${channelName}"`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
const prep = await prepareChannelSink(channelName, vault, vaultOrigin, userId, deps);
|
|
618
|
+
if (prep.error) return prep.error;
|
|
619
|
+
mintedJtis.push(prep.replyTokenJti);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// --- Mint the webhook bearer at the action's DECLARED scope. -------------
|
|
623
|
+
let webhookBearer: string;
|
|
624
|
+
try {
|
|
625
|
+
const signed = await mint(deps, userId, {
|
|
626
|
+
scopes: [action.scope],
|
|
627
|
+
audience: audienceForScope(action.scope, sinkModule),
|
|
628
|
+
vaultScope: [],
|
|
629
|
+
ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS,
|
|
630
|
+
});
|
|
631
|
+
webhookBearer = signed.token;
|
|
632
|
+
mintedJtis.push(signed.jti);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
return stepError("mint_webhook_bearer", err);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// --- Build the trigger from the declarations + filter. -------------------
|
|
638
|
+
const triggerName = `conn_${id}`;
|
|
639
|
+
const webhook = buildWebhook(deps.hubOrigin, sink.mount, action.endpoint);
|
|
640
|
+
const trigger = buildVaultTrigger(triggerName, sourceEvent, filter, webhook, webhookBearer);
|
|
641
|
+
|
|
642
|
+
// --- Register the trigger on the vault (upsert: POST replaces by name). ---
|
|
643
|
+
try {
|
|
644
|
+
const vaultAdminToken = (
|
|
645
|
+
await mint(deps, userId, {
|
|
646
|
+
scopes: [`vault:${vault}:admin`],
|
|
647
|
+
audience: `vault.${vault}`,
|
|
648
|
+
vaultScope: [vault],
|
|
649
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
650
|
+
})
|
|
651
|
+
).token;
|
|
652
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
653
|
+
const res = await fetchImpl(`${vaultOrigin}/vault/${vault}/api/triggers`, {
|
|
654
|
+
method: "POST",
|
|
655
|
+
headers: {
|
|
656
|
+
authorization: `Bearer ${vaultAdminToken}`,
|
|
657
|
+
"content-type": "application/json",
|
|
658
|
+
},
|
|
659
|
+
body: JSON.stringify(trigger),
|
|
660
|
+
});
|
|
661
|
+
if (!res.ok) return stepError("vault_trigger", await describeRemote(res));
|
|
662
|
+
} catch (err) {
|
|
663
|
+
return stepError("vault_trigger", err);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// --- Persist the connection record. --------------------------------------
|
|
667
|
+
const record: ConnectionRecord = {
|
|
668
|
+
id,
|
|
669
|
+
source: sourceRec,
|
|
670
|
+
sink: sinkRec,
|
|
671
|
+
provisioned: { type: "vault-trigger", vault, triggerName, mintedJtis },
|
|
672
|
+
createdAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
673
|
+
requestedBy,
|
|
674
|
+
};
|
|
675
|
+
putConnection(deps.storePath, record);
|
|
676
|
+
|
|
677
|
+
// --- Response. For a channel-deliver sink, hand back the connect lines
|
|
678
|
+
// (parity with hub#624) so the operator can join a session.
|
|
679
|
+
const out: {
|
|
680
|
+
ok: true;
|
|
681
|
+
connection: typeof record;
|
|
682
|
+
connect?: { mcpAdd: string; launch: string };
|
|
683
|
+
} = { ok: true, connection: record };
|
|
684
|
+
if (sinkModule === "channel" && typeof sinkParams?.channel === "string") {
|
|
685
|
+
out.connect = channelConnectLines(deps.hubOrigin, sinkParams.channel);
|
|
686
|
+
}
|
|
687
|
+
return json(200, out);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The channel sink's reply-path prerequisite (mirrors hub#624). Mints a
|
|
692
|
+
* `vault:<v>:write` for the channel + writes the `channels.json` entry on the
|
|
693
|
+
* channel daemon so the session can reply. Fenced to `sink.module === "channel"`
|
|
694
|
+
* — this is sink-specific config, not part of the general vault-trigger engine.
|
|
695
|
+
* Returns `{ error }` on failure, or `{ error: null, replyTokenJti }` on
|
|
696
|
+
* success — the jti of the long-lived reply token, so the caller can persist
|
|
697
|
+
* it for teardown revocation.
|
|
698
|
+
*/
|
|
699
|
+
async function prepareChannelSink(
|
|
700
|
+
channelName: string,
|
|
701
|
+
vault: string,
|
|
702
|
+
vaultOrigin: string,
|
|
703
|
+
userId: string,
|
|
704
|
+
deps: ConnectionsDeps,
|
|
705
|
+
): Promise<{ error: Response } | { error: null; replyTokenJti: string }> {
|
|
706
|
+
if (deps.channelOrigin === null) {
|
|
707
|
+
return {
|
|
708
|
+
error: jsonError(
|
|
709
|
+
503,
|
|
710
|
+
"channel_unavailable",
|
|
711
|
+
"the channel module is not installed on this hub",
|
|
712
|
+
),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
716
|
+
try {
|
|
717
|
+
const vaultWriteSigned = await mint(deps, userId, {
|
|
718
|
+
scopes: [`vault:${vault}:write`],
|
|
719
|
+
audience: `vault.${vault}`,
|
|
720
|
+
vaultScope: [vault],
|
|
721
|
+
ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS, // channel keeps it for its lifetime
|
|
722
|
+
});
|
|
723
|
+
const channelAdminToken = (
|
|
724
|
+
await mint(deps, userId, {
|
|
725
|
+
scopes: ["channel:admin"],
|
|
726
|
+
audience: "channel",
|
|
727
|
+
vaultScope: [],
|
|
728
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
729
|
+
})
|
|
730
|
+
).token;
|
|
731
|
+
const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
|
|
732
|
+
method: "POST",
|
|
733
|
+
headers: {
|
|
734
|
+
authorization: `Bearer ${channelAdminToken}`,
|
|
735
|
+
"content-type": "application/json",
|
|
736
|
+
},
|
|
737
|
+
body: JSON.stringify({
|
|
738
|
+
name: channelName,
|
|
739
|
+
transport: "vault",
|
|
740
|
+
config: { vault, vaultUrl: vaultOrigin, token: vaultWriteSigned.token },
|
|
741
|
+
}),
|
|
742
|
+
});
|
|
743
|
+
if (!res.ok) return { error: stepError("channel_config", await describeRemote(res)) };
|
|
744
|
+
return { error: null, replyTokenJti: vaultWriteSigned.jti };
|
|
745
|
+
} catch (err) {
|
|
746
|
+
return { error: stepError("channel_config", err) };
|
|
747
|
+
}
|
|
748
|
+
}
|
|
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
|
+
|
|
1451
|
+
// ---------------------------------------------------------------------------
|
|
1452
|
+
// DELETE — teardown
|
|
1453
|
+
// ---------------------------------------------------------------------------
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Tear down one connection by id: deregister the vault trigger, delete the
|
|
1457
|
+
* channel-config entry (channel sinks), revoke the registered long-lived
|
|
1458
|
+
* mints (B0), remove the record. Exported for the vault-delete cascade (B1)
|
|
1459
|
+
* — `admin-vaults.handleDeleteVault` reuses this per matching record so the
|
|
1460
|
+
* cascade and the operator-facing DELETE behave identically.
|
|
1461
|
+
*/
|
|
1462
|
+
export async function teardownConnection(
|
|
1463
|
+
id: string,
|
|
1464
|
+
userId: string,
|
|
1465
|
+
deps: ConnectionsDeps,
|
|
1466
|
+
): Promise<Response> {
|
|
1467
|
+
if (!CONNECTION_ID_RE.test(id)) {
|
|
1468
|
+
return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
|
|
1469
|
+
}
|
|
1470
|
+
const record = readConnections(deps.storePath).find((r) => r.id === id);
|
|
1471
|
+
if (!record) {
|
|
1472
|
+
return jsonError(404, "not_found", `no connection "${id}"`);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
1476
|
+
const errors: { step: string; detail: string }[] = [];
|
|
1477
|
+
|
|
1478
|
+
// --- Vault trigger teardown. ---------------------------------------------
|
|
1479
|
+
const vault = record.provisioned.vault;
|
|
1480
|
+
const triggerName = record.provisioned.triggerName;
|
|
1481
|
+
if (vault && triggerName) {
|
|
1482
|
+
const vaultOrigin = deps.resolveVaultOrigin(vault);
|
|
1483
|
+
if (vaultOrigin) {
|
|
1484
|
+
try {
|
|
1485
|
+
const vaultAdminToken = (
|
|
1486
|
+
await mint(deps, userId, {
|
|
1487
|
+
scopes: [`vault:${vault}:admin`],
|
|
1488
|
+
audience: `vault.${vault}`,
|
|
1489
|
+
vaultScope: [vault],
|
|
1490
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
1491
|
+
})
|
|
1492
|
+
).token;
|
|
1493
|
+
const res = await fetchImpl(
|
|
1494
|
+
`${vaultOrigin}/vault/${vault}/api/triggers/${encodeURIComponent(triggerName)}`,
|
|
1495
|
+
{ method: "DELETE", headers: { authorization: `Bearer ${vaultAdminToken}` } },
|
|
1496
|
+
);
|
|
1497
|
+
if (!res.ok && res.status !== 404) {
|
|
1498
|
+
errors.push({ step: "vault_trigger", detail: await remoteDetail(res) });
|
|
1499
|
+
}
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
errors.push({ step: "vault_trigger", detail: errMsg(err) });
|
|
1502
|
+
}
|
|
1503
|
+
} else {
|
|
1504
|
+
errors.push({ step: "vault_trigger", detail: `vault "${vault}" no longer installed` });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
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
|
+
|
|
1541
|
+
// --- Channel-sink teardown (remove the channel config entry). ------------
|
|
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) {
|
|
1545
|
+
const channelName =
|
|
1546
|
+
typeof record.sink.params?.channel === "string" ? record.sink.params.channel : record.id;
|
|
1547
|
+
try {
|
|
1548
|
+
const channelAdminToken = (
|
|
1549
|
+
await mint(deps, userId, {
|
|
1550
|
+
scopes: ["channel:admin"],
|
|
1551
|
+
audience: "channel",
|
|
1552
|
+
vaultScope: [],
|
|
1553
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
1554
|
+
})
|
|
1555
|
+
).token;
|
|
1556
|
+
const res = await fetchImpl(
|
|
1557
|
+
`${deps.channelOrigin}/api/channels/${encodeURIComponent(channelName)}`,
|
|
1558
|
+
{ method: "DELETE", headers: { authorization: `Bearer ${channelAdminToken}` } },
|
|
1559
|
+
);
|
|
1560
|
+
if (!res.ok && res.status !== 404) {
|
|
1561
|
+
errors.push({ step: "channel_config", detail: await remoteDetail(res) });
|
|
1562
|
+
}
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
errors.push({ step: "channel_config", detail: errMsg(err) });
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// --- Revoke the registered long-lived mints (B0, registered-mint rule). ---
|
|
1569
|
+
// Marks each tokens-registry row revoked → the revocation list at
|
|
1570
|
+
// `/.well-known/parachute-revocation.json` advertises the jtis, and every
|
|
1571
|
+
// resource server (vault, channel) rejects the credential from its next
|
|
1572
|
+
// poll. Runs regardless of remote-teardown outcome — revocation is the safe
|
|
1573
|
+
// direction. Legacy records (provisioned before B0) carry no jtis: teardown
|
|
1574
|
+
// proceeds, but their tokens were never registered and ride to expiry.
|
|
1575
|
+
const mintedJtis = record.provisioned?.mintedJtis ?? [];
|
|
1576
|
+
if (mintedJtis.length === 0) {
|
|
1577
|
+
console.warn(
|
|
1578
|
+
`[connections] connection "${id}" predates registered mints — its provisioned tokens were never registered and ride to their original expiry`,
|
|
1579
|
+
);
|
|
1580
|
+
} else {
|
|
1581
|
+
const now = deps.now?.() ?? new Date();
|
|
1582
|
+
for (const jti of mintedJtis) {
|
|
1583
|
+
try {
|
|
1584
|
+
revokeTokenByJti(deps.db, jti, now);
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
errors.push({ step: "revoke_mints", detail: `jti ${jti}: ${errMsg(err)}` });
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// Remove the record regardless — leaving a phantom record after a downstream
|
|
1592
|
+
// failure is worse than a possibly-orphaned trigger the operator can re-run.
|
|
1593
|
+
removeConnection(deps.storePath, id);
|
|
1594
|
+
|
|
1595
|
+
if (errors.length > 0) {
|
|
1596
|
+
return json(207, { ok: false, id, partial: true, errors });
|
|
1597
|
+
}
|
|
1598
|
+
return json(200, { ok: true, id });
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// ===========================================================================
|
|
1602
|
+
// Derivation — the GENERAL mapping (filter→predicate, event→events, webhook)
|
|
1603
|
+
// ===========================================================================
|
|
1604
|
+
|
|
1605
|
+
/** Shape of the vault runtime trigger we POST (vault#469 API). */
|
|
1606
|
+
interface VaultTrigger {
|
|
1607
|
+
name: string;
|
|
1608
|
+
events: string[];
|
|
1609
|
+
when: Record<string, unknown>;
|
|
1610
|
+
action: { webhook: string; send: string; auth: { bearer: string } };
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Map a source event key to the vault trigger's `events`. The vault hook system
|
|
1615
|
+
* fires on `"created"` / `"updated"`; the `note.<verb>` key carries the verb.
|
|
1616
|
+
*/
|
|
1617
|
+
export function eventsForSourceEvent(eventKey: string): string[] {
|
|
1618
|
+
if (eventKey === "note.created") return ["created"];
|
|
1619
|
+
if (eventKey === "note.updated") return ["updated"];
|
|
1620
|
+
// The catalog already validated the event exists upstream, so an unknown key
|
|
1621
|
+
// reaching here is a bug (or an event with no vault-trigger verb mapping, e.g.
|
|
1622
|
+
// note.deleted). Fail loud rather than silently registering the wrong trigger.
|
|
1623
|
+
throw new Error(`no vault-trigger event mapping for source event "${eventKey}"`);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* Map the operator-set `source.filter` to a vault trigger `when` predicate. The
|
|
1628
|
+
* filter keys are the trigger predicate keys 1:1 (`tags`, `has_metadata`,
|
|
1629
|
+
* `missing_metadata`, `has_content`) — this is what makes a vault event's
|
|
1630
|
+
* filterSchema drive the predicate with no per-module code.
|
|
1631
|
+
*/
|
|
1632
|
+
export function whenFromFilter(
|
|
1633
|
+
filter: Record<string, unknown> | undefined,
|
|
1634
|
+
): Record<string, unknown> {
|
|
1635
|
+
const when: Record<string, unknown> = {};
|
|
1636
|
+
if (!filter) return when;
|
|
1637
|
+
if (Array.isArray(filter.tags)) {
|
|
1638
|
+
const tags = filter.tags.filter((t): t is string => typeof t === "string");
|
|
1639
|
+
if (tags.length > 0) when.tags = tags;
|
|
1640
|
+
}
|
|
1641
|
+
if (Array.isArray(filter.has_metadata)) {
|
|
1642
|
+
const keys = filter.has_metadata.filter((k): k is string => typeof k === "string");
|
|
1643
|
+
if (keys.length > 0) when.has_metadata = keys;
|
|
1644
|
+
}
|
|
1645
|
+
if (Array.isArray(filter.missing_metadata)) {
|
|
1646
|
+
const keys = filter.missing_metadata.filter((k): k is string => typeof k === "string");
|
|
1647
|
+
if (keys.length > 0) when.missing_metadata = keys;
|
|
1648
|
+
}
|
|
1649
|
+
if (typeof filter.has_content === "boolean") when.has_content = filter.has_content;
|
|
1650
|
+
return when;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/** Build the hub-proxied webhook from the sink module's mount + action endpoint. */
|
|
1654
|
+
export function buildWebhook(hubOrigin: string, mount: string, endpoint: string): string {
|
|
1655
|
+
const origin = hubOrigin.replace(/\/+$/, "");
|
|
1656
|
+
const m = mount.startsWith("/") ? mount.replace(/\/+$/, "") : `/${mount.replace(/\/+$/, "")}`;
|
|
1657
|
+
const ep = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
1658
|
+
return `${origin}${m}${ep}`;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function buildVaultTrigger(
|
|
1662
|
+
name: string,
|
|
1663
|
+
sourceEvent: string,
|
|
1664
|
+
filter: Record<string, unknown> | undefined,
|
|
1665
|
+
webhook: string,
|
|
1666
|
+
bearer: string,
|
|
1667
|
+
): VaultTrigger {
|
|
1668
|
+
return {
|
|
1669
|
+
name,
|
|
1670
|
+
events: eventsForSourceEvent(sourceEvent),
|
|
1671
|
+
when: whenFromFilter(filter),
|
|
1672
|
+
action: { webhook, send: "json", auth: { bearer } },
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// ===========================================================================
|
|
1677
|
+
// Helpers
|
|
1678
|
+
// ===========================================================================
|
|
1679
|
+
|
|
1680
|
+
function findEvent(m: ModuleManifest, key: string): ModuleEvent | undefined {
|
|
1681
|
+
return (m.events ?? []).find((e) => e.key === key);
|
|
1682
|
+
}
|
|
1683
|
+
function findAction(m: ModuleManifest, key: string): ModuleAction | undefined {
|
|
1684
|
+
return (m.actions ?? []).find((a) => a.key === key);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** Extract `provision.type` from the opaque provision descriptor. */
|
|
1688
|
+
function readProvisionType(provision: unknown): string | null {
|
|
1689
|
+
if (provision && typeof provision === "object" && !Array.isArray(provision)) {
|
|
1690
|
+
const t = (provision as Record<string, unknown>).type;
|
|
1691
|
+
if (typeof t === "string") return t;
|
|
1692
|
+
}
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Audience for a minted sink bearer. A `<module>:<verb>` scope (e.g.
|
|
1698
|
+
* `channel:send`) takes the module namespace as its audience — matching how
|
|
1699
|
+
* the channel validates `aud: channel`. Falls back to the sink module name.
|
|
1700
|
+
*/
|
|
1701
|
+
function audienceForScope(scope: string, sinkModule: string): string {
|
|
1702
|
+
const colon = scope.indexOf(":");
|
|
1703
|
+
return colon > 0 ? scope.slice(0, colon) : sinkModule;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function readFilter(v: unknown): Record<string, unknown> | undefined {
|
|
1707
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
|
|
1708
|
+
return undefined;
|
|
1709
|
+
}
|
|
1710
|
+
function readParams(v: unknown): Record<string, unknown> | undefined {
|
|
1711
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
|
|
1712
|
+
return undefined;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function str(v: unknown): string {
|
|
1716
|
+
return typeof v === "string" ? v.trim() : "";
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* Derive the connection id. Operator-supplied wins; else for a channel sink use
|
|
1721
|
+
* the channel name (so the trigger + channel-config share a stable key), else a
|
|
1722
|
+
* `<srcModule>-<event>-<sinkModule>-<action>` slug.
|
|
1723
|
+
*/
|
|
1724
|
+
function deriveId(rawId: unknown, source: ConnectionSource, sink: ConnectionSink): string {
|
|
1725
|
+
const supplied = str(rawId);
|
|
1726
|
+
if (supplied) return supplied.toLowerCase();
|
|
1727
|
+
if (sink.module === "channel" && typeof sink.params?.channel === "string") {
|
|
1728
|
+
return `channel-${sink.params.channel}`.toLowerCase();
|
|
1729
|
+
}
|
|
1730
|
+
const slug = `${source.module}-${source.event}-${sink.module}-${sink.action}`
|
|
1731
|
+
.toLowerCase()
|
|
1732
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1733
|
+
.replace(/^-+|-+$/g, "");
|
|
1734
|
+
return slug;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function channelConnectLines(
|
|
1738
|
+
hubOrigin: string,
|
|
1739
|
+
channelName: string,
|
|
1740
|
+
): { mcpAdd: string; launch: string } {
|
|
1741
|
+
const origin = hubOrigin.replace(/\/+$/, "");
|
|
1742
|
+
return {
|
|
1743
|
+
mcpAdd: `claude mcp add --transport http --scope user channel-${channelName} ${origin}/channel/mcp/${channelName}`,
|
|
1744
|
+
launch: `claude --dangerously-load-development-channels=server:channel-${channelName} --dangerously-skip-permissions`,
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// --- Auth gate (mirrors the admin-token mints) ------------------------------
|
|
1749
|
+
|
|
1750
|
+
/** Returns an error Response when the operator gate fails, else `null`. */
|
|
1751
|
+
function operatorGate(req: Request, deps: ConnectionsDeps): Response | null {
|
|
1752
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
1753
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
1754
|
+
if (!session) {
|
|
1755
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
1756
|
+
}
|
|
1757
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
1758
|
+
return jsonError(
|
|
1759
|
+
403,
|
|
1760
|
+
"not_admin",
|
|
1761
|
+
"connection provisioning is restricted to the hub admin — your account home is at /account/",
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
return null;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function sessionUser(req: Request, deps: ConnectionsDeps): { userId: string } {
|
|
1768
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
1769
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
1770
|
+
return { userId: session?.userId ?? "" };
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// --- Mint -------------------------------------------------------------------
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* TTL boundary between "interactive" mints (used immediately, ride to expiry
|
|
1777
|
+
* by design — the documented ≤10-min unregistered bound) and LONG-LIVED mints,
|
|
1778
|
+
* which MUST be registered in the tokens table so they stay revocable
|
|
1779
|
+
* (hub-module-boundary charter, registered-mint rule). The audit that produced
|
|
1780
|
+
* the rule found this engine minting ~90-day tokens with no registry row — an
|
|
1781
|
+
* unrevocable-by-construction credential (`api-revoke-token` 404s unknown
|
|
1782
|
+
* jtis; the revocation list only carries registered jtis).
|
|
1783
|
+
*/
|
|
1784
|
+
const REGISTERED_MINT_TTL_THRESHOLD_SECONDS = 10 * 60;
|
|
1785
|
+
|
|
1786
|
+
interface MintSpec {
|
|
1787
|
+
scopes: string[];
|
|
1788
|
+
audience: string;
|
|
1789
|
+
vaultScope: string[];
|
|
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>;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
async function mint(deps: ConnectionsDeps, userId: string, spec: MintSpec) {
|
|
1806
|
+
const sign = deps.signToken ?? signAccessToken;
|
|
1807
|
+
const signed = await sign(deps.db, {
|
|
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",
|
|
1811
|
+
scopes: spec.scopes,
|
|
1812
|
+
audience: spec.audience,
|
|
1813
|
+
clientId: PROVISION_CLIENT_ID,
|
|
1814
|
+
issuer: deps.hubOrigin,
|
|
1815
|
+
ttlSeconds: spec.ttlSeconds,
|
|
1816
|
+
vaultScope: spec.vaultScope,
|
|
1817
|
+
...(spec.permissions !== undefined ? { extraClaims: { permissions: spec.permissions } } : {}),
|
|
1818
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
1819
|
+
});
|
|
1820
|
+
// Register long-lived mints so they're revocable on teardown. Short-lived
|
|
1821
|
+
// provisioning tokens (60s, consumed inline) stay unregistered by design.
|
|
1822
|
+
if (spec.ttlSeconds > REGISTERED_MINT_TTL_THRESHOLD_SECONDS) {
|
|
1823
|
+
recordTokenMint(deps.db, {
|
|
1824
|
+
jti: signed.jti,
|
|
1825
|
+
createdVia: spec.createdVia ?? "connection_provision",
|
|
1826
|
+
subject: "connection",
|
|
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 } : {}),
|
|
1830
|
+
clientId: PROVISION_CLIENT_ID,
|
|
1831
|
+
scopes: spec.scopes,
|
|
1832
|
+
expiresAt: signed.expiresAt,
|
|
1833
|
+
...(spec.permissions !== undefined ? { permissions: JSON.stringify(spec.permissions) } : {}),
|
|
1834
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
return signed;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// --- Response helpers -------------------------------------------------------
|
|
1841
|
+
|
|
1842
|
+
function json(status: number, payload: unknown): Response {
|
|
1843
|
+
return new Response(JSON.stringify(payload), {
|
|
1844
|
+
status,
|
|
1845
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
1850
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
1851
|
+
status,
|
|
1852
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function stepError(step: string, cause: unknown): Response {
|
|
1857
|
+
return json(502, {
|
|
1858
|
+
error: "provision_failed",
|
|
1859
|
+
step,
|
|
1860
|
+
error_description: `provisioning failed at step "${step}": ${errMsg(cause)}`,
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function errMsg(cause: unknown): string {
|
|
1865
|
+
if (cause instanceof Error) return cause.message;
|
|
1866
|
+
if (typeof cause === "string") return cause;
|
|
1867
|
+
return String(cause);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
async function describeRemote(res: Response): Promise<Error> {
|
|
1871
|
+
return new Error(await remoteDetail(res));
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
async function remoteDetail(res: Response): Promise<string> {
|
|
1875
|
+
let text = "";
|
|
1876
|
+
try {
|
|
1877
|
+
text = (await res.text()).slice(0, 300);
|
|
1878
|
+
} catch {
|
|
1879
|
+
// status alone is informative enough
|
|
1880
|
+
}
|
|
1881
|
+
return `downstream ${res.status}${text ? `: ${text}` : ""}`;
|
|
1882
|
+
}
|