@openparachute/hub 0.6.5-rc.7 → 0.7.0
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 +34 -0
- 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.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-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-db-liveness.test.ts +12 -7
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- 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/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-db-liveness.ts +33 -17
- package/src/hub-server.ts +354 -61
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- 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/web/ui/dist/assets/index-C-XzMVqN.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,980 @@
|
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
import type { Database } from "bun:sqlite";
|
|
39
|
+
import {
|
|
40
|
+
type ConnectionRecord,
|
|
41
|
+
type ConnectionSink,
|
|
42
|
+
type ConnectionSource,
|
|
43
|
+
putConnection,
|
|
44
|
+
readConnections,
|
|
45
|
+
removeConnection,
|
|
46
|
+
} from "./connections-store.ts";
|
|
47
|
+
import { recordTokenMint, revokeTokenByJti, signAccessToken } from "./jwt-sign.ts";
|
|
48
|
+
import type { ModuleAction, ModuleEvent, ModuleManifest } from "./module-manifest.ts";
|
|
49
|
+
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
50
|
+
import { isFirstAdmin } from "./users.ts";
|
|
51
|
+
import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
|
|
52
|
+
|
|
53
|
+
/** Short TTL — provisioning calls use these immediately. */
|
|
54
|
+
const PROVISION_TOKEN_TTL_SECONDS = 60;
|
|
55
|
+
/**
|
|
56
|
+
* The webhook bearer is persisted as the vault trigger's `action.auth.bearer` —
|
|
57
|
+
* the vault re-presents it on every callback, so it must outlive the request.
|
|
58
|
+
* Long-lived (~90d) to match the daemon's headless-credential posture.
|
|
59
|
+
*/
|
|
60
|
+
const WEBHOOK_BEARER_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
61
|
+
const PROVISION_CLIENT_ID = "parachute-hub-spa";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Connection id charset. The id lands in a URL path segment (DELETE) and is the
|
|
65
|
+
* basis for the derived vault trigger name — keep it a conservative slug.
|
|
66
|
+
*/
|
|
67
|
+
const CONNECTION_ID_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Channel-name charset. A channel name lands in a services.json key, a URL
|
|
71
|
+
* path segment, and an MCP server name — keep it a conservative slug to close
|
|
72
|
+
* injection across all of them.
|
|
73
|
+
*/
|
|
74
|
+
const CHANNEL_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Provenance label charset (modular-UI R2). `requestedBy` is an operator-/module-
|
|
78
|
+
* supplied label that lands in the Connections SPA as a grouping key — keep it a
|
|
79
|
+
* conservative slug so it can't carry markup or odd characters into the view.
|
|
80
|
+
* Defaults to `"custom"` (the hub's own builder) when omitted.
|
|
81
|
+
*/
|
|
82
|
+
const REQUESTED_BY_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
83
|
+
const DEFAULT_REQUESTED_BY = "custom";
|
|
84
|
+
|
|
85
|
+
/** A module installed on this hub, with its manifest + resolved mount path. */
|
|
86
|
+
export interface InstalledModuleInfo {
|
|
87
|
+
/** Module short name (the catalog/wire key). */
|
|
88
|
+
readonly short: string;
|
|
89
|
+
/** Parsed `.parachute/module.json`. */
|
|
90
|
+
readonly manifest: ModuleManifest;
|
|
91
|
+
/**
|
|
92
|
+
* The module's user-facing mount path under the hub origin (e.g. `/channel`),
|
|
93
|
+
* used to build a hub-proxied webhook from a sink action's `endpoint`.
|
|
94
|
+
* `null` when the module declares no user-facing mount.
|
|
95
|
+
*/
|
|
96
|
+
readonly mount: string | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ConnectionsDeps {
|
|
100
|
+
db: Database;
|
|
101
|
+
/** Public hub origin — webhook URL + connect lines + minted-token `iss`. */
|
|
102
|
+
hubOrigin: string;
|
|
103
|
+
/**
|
|
104
|
+
* Snapshot of installed modules + their manifests + mounts, read at request
|
|
105
|
+
* time so a freshly-installed module's events/actions show up without a hub
|
|
106
|
+
* restart. Keyed scan is fine — cardinality is small.
|
|
107
|
+
*/
|
|
108
|
+
modules: InstalledModuleInfo[];
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a vault's loopback origin (e.g. `http://127.0.0.1:1940`) from
|
|
111
|
+
* services.json, or `null` when no vault by that name is installed.
|
|
112
|
+
*/
|
|
113
|
+
resolveVaultOrigin: (vaultName: string) => string | null;
|
|
114
|
+
/** Loopback origin for the channel daemon, or `null` when not installed. */
|
|
115
|
+
channelOrigin: string | null;
|
|
116
|
+
/** Absolute path to `connections.json` in the hub state dir. */
|
|
117
|
+
storePath: string;
|
|
118
|
+
/** Test seam — `globalThis.fetch` in production. */
|
|
119
|
+
fetchImpl?: typeof fetch;
|
|
120
|
+
/** Test seam — defaults to the real `signAccessToken`. */
|
|
121
|
+
signToken?: typeof signAccessToken;
|
|
122
|
+
/** Test seam for the clock. */
|
|
123
|
+
now?: () => Date;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ===========================================================================
|
|
127
|
+
// Catalog — GET /api/connections/catalog
|
|
128
|
+
// ===========================================================================
|
|
129
|
+
|
|
130
|
+
interface CatalogEvent {
|
|
131
|
+
module: string;
|
|
132
|
+
key: string;
|
|
133
|
+
title: string;
|
|
134
|
+
filterSchema: unknown;
|
|
135
|
+
}
|
|
136
|
+
interface CatalogAction {
|
|
137
|
+
module: string;
|
|
138
|
+
key: string;
|
|
139
|
+
title: string;
|
|
140
|
+
inputSchema: unknown;
|
|
141
|
+
/** The provision descriptor (e.g. `{ type: "vault-trigger" }`), opaque to the SPA. */
|
|
142
|
+
provision: unknown;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A connection preset declared in a module's `module.json`
|
|
146
|
+
* `connectionTemplates` (boundary D2). Drives the SPA builder's one-click
|
|
147
|
+
* preset buttons — declaration-driven, replacing the SPA's hardcoded
|
|
148
|
+
* channel preset (the charter's per-module-view test).
|
|
149
|
+
*/
|
|
150
|
+
interface CatalogTemplate {
|
|
151
|
+
/** Short of the DECLARING module (the template can wire other modules). */
|
|
152
|
+
module: string;
|
|
153
|
+
key: string;
|
|
154
|
+
title: string;
|
|
155
|
+
description: string | null;
|
|
156
|
+
requestedBy: string | null;
|
|
157
|
+
source: { module: string; event: string; filter: unknown };
|
|
158
|
+
sink: { module: string; action: string };
|
|
159
|
+
parameters: {
|
|
160
|
+
key: string;
|
|
161
|
+
target: string;
|
|
162
|
+
title: string | null;
|
|
163
|
+
description: string | null;
|
|
164
|
+
example: string | null;
|
|
165
|
+
}[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the catalog from the installed modules' declared
|
|
170
|
+
* events/actions/templates. Drives the SPA builder's source/sink dropdowns +
|
|
171
|
+
* preset buttons. NO tokens, NO secrets — pure declaration metadata read from
|
|
172
|
+
* each `module.json`.
|
|
173
|
+
*/
|
|
174
|
+
export function buildCatalog(modules: InstalledModuleInfo[]): {
|
|
175
|
+
events: CatalogEvent[];
|
|
176
|
+
actions: CatalogAction[];
|
|
177
|
+
templates: CatalogTemplate[];
|
|
178
|
+
} {
|
|
179
|
+
const events: CatalogEvent[] = [];
|
|
180
|
+
const actions: CatalogAction[] = [];
|
|
181
|
+
const templates: CatalogTemplate[] = [];
|
|
182
|
+
for (const { short, manifest } of modules) {
|
|
183
|
+
for (const e of manifest.events ?? []) {
|
|
184
|
+
events.push({
|
|
185
|
+
module: short,
|
|
186
|
+
key: e.key,
|
|
187
|
+
title: e.title,
|
|
188
|
+
filterSchema: e.filterSchema ?? null,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
for (const a of manifest.actions ?? []) {
|
|
192
|
+
actions.push({
|
|
193
|
+
module: short,
|
|
194
|
+
key: a.key,
|
|
195
|
+
title: a.title,
|
|
196
|
+
inputSchema: a.inputSchema ?? null,
|
|
197
|
+
provision: a.provision ?? null,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
for (const t of manifest.connectionTemplates ?? []) {
|
|
201
|
+
// Only event→action presets surface here — a template without BOTH
|
|
202
|
+
// source and sink (e.g. scribe's `kind: "config"` link, consumed by
|
|
203
|
+
// scribe's own UI) isn't something the hub builder can pre-fill.
|
|
204
|
+
if (!t.source || !t.sink) continue;
|
|
205
|
+
templates.push({
|
|
206
|
+
module: short,
|
|
207
|
+
key: t.key,
|
|
208
|
+
title: t.title,
|
|
209
|
+
description: t.description ?? null,
|
|
210
|
+
requestedBy: t.requestedBy ?? null,
|
|
211
|
+
source: { module: t.source.module, event: t.source.event, filter: t.source.filter ?? null },
|
|
212
|
+
sink: { module: t.sink.module, action: t.sink.action },
|
|
213
|
+
parameters: (t.parameters ?? []).map((p) => ({
|
|
214
|
+
key: p.key,
|
|
215
|
+
target: p.target,
|
|
216
|
+
title: p.title ?? null,
|
|
217
|
+
description: p.description ?? null,
|
|
218
|
+
example: p.example ?? null,
|
|
219
|
+
})),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { events, actions, templates };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function handleConnectionsCatalog(
|
|
227
|
+
req: Request,
|
|
228
|
+
deps: ConnectionsDeps,
|
|
229
|
+
): Promise<Response> {
|
|
230
|
+
const gate = operatorGate(req, deps);
|
|
231
|
+
if (gate) return gate;
|
|
232
|
+
return json(200, buildCatalog(deps.modules));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ===========================================================================
|
|
236
|
+
// Collection + item — GET/POST /admin/connections, DELETE /admin/connections/:id
|
|
237
|
+
// ===========================================================================
|
|
238
|
+
|
|
239
|
+
export async function handleConnections(
|
|
240
|
+
req: Request,
|
|
241
|
+
/** Path after `/admin/connections` — `""` for the collection, `/<id>` for an item. */
|
|
242
|
+
subPath: string,
|
|
243
|
+
deps: ConnectionsDeps,
|
|
244
|
+
): Promise<Response> {
|
|
245
|
+
const gate = operatorGate(req, deps);
|
|
246
|
+
if (gate) return gate;
|
|
247
|
+
const { userId } = sessionUser(req, deps);
|
|
248
|
+
|
|
249
|
+
const method = req.method;
|
|
250
|
+
const itemId = subPath.startsWith("/") ? decodeURIComponent(subPath.slice(1)) : "";
|
|
251
|
+
|
|
252
|
+
if (itemId === "" && method === "GET") return listConnections(deps);
|
|
253
|
+
if (itemId === "" && method === "POST") return createConnection(req, userId, deps);
|
|
254
|
+
if (itemId !== "" && method === "DELETE") return teardownConnection(itemId, userId, deps);
|
|
255
|
+
return jsonError(
|
|
256
|
+
405,
|
|
257
|
+
"method_not_allowed",
|
|
258
|
+
"use GET/POST on /admin/connections or DELETE on /admin/connections/:id",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// GET — list
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
function listConnections(deps: ConnectionsDeps): Response {
|
|
267
|
+
// The store never holds a token — records carry source/sink/provisioned
|
|
268
|
+
// metadata only — so this is a straight read. Project to the wire shape so
|
|
269
|
+
// the response is stable regardless of the on-disk record shape.
|
|
270
|
+
const connections = readConnections(deps.storePath).map((c) => ({
|
|
271
|
+
id: c.id,
|
|
272
|
+
source: c.source,
|
|
273
|
+
sink: c.sink,
|
|
274
|
+
provisioned: c.provisioned,
|
|
275
|
+
created_at: c.createdAt,
|
|
276
|
+
// Provenance (modular-UI R2). Records written before R2 carry no
|
|
277
|
+
// `requestedBy`; project them as the default so the SPA grouping is total.
|
|
278
|
+
requested_by: c.requestedBy ?? DEFAULT_REQUESTED_BY,
|
|
279
|
+
// Records provisioned before the registered-mint rule (B0) carry no
|
|
280
|
+
// mintedJtis — their long-lived tokens were never registered, so teardown
|
|
281
|
+
// can't revoke them (they ride to their original ~90d expiry).
|
|
282
|
+
...(isLegacyRecord(c) ? { legacy: true } : {}),
|
|
283
|
+
}));
|
|
284
|
+
return json(200, { ok: true, connections });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A record minted before B0 — no registered jtis, tokens unrevocable. */
|
|
288
|
+
function isLegacyRecord(c: ConnectionRecord): boolean {
|
|
289
|
+
return (c.provisioned?.mintedJtis?.length ?? 0) === 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// POST — create + provision
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
interface CreateBody {
|
|
297
|
+
source?: {
|
|
298
|
+
module?: unknown;
|
|
299
|
+
vault?: unknown;
|
|
300
|
+
event?: unknown;
|
|
301
|
+
filter?: unknown;
|
|
302
|
+
};
|
|
303
|
+
sink?: {
|
|
304
|
+
module?: unknown;
|
|
305
|
+
action?: unknown;
|
|
306
|
+
params?: unknown;
|
|
307
|
+
};
|
|
308
|
+
/** Optional operator-supplied id; otherwise derived from source/sink. */
|
|
309
|
+
id?: unknown;
|
|
310
|
+
/**
|
|
311
|
+
* Provenance — WHO requested this connection (modular-UI R2). A module-owned
|
|
312
|
+
* config UI calling this endpoint on the operator's behalf labels itself (e.g.
|
|
313
|
+
* `"channel"`); the hub's own builder omits it and falls back to `"custom"`.
|
|
314
|
+
*/
|
|
315
|
+
requestedBy?: unknown;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function createConnection(
|
|
319
|
+
req: Request,
|
|
320
|
+
userId: string,
|
|
321
|
+
deps: ConnectionsDeps,
|
|
322
|
+
): Promise<Response> {
|
|
323
|
+
let body: CreateBody;
|
|
324
|
+
try {
|
|
325
|
+
body = (await req.json()) as CreateBody;
|
|
326
|
+
} catch {
|
|
327
|
+
return jsonError(400, "invalid_request", "request body must be JSON");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const sourceModule = str(body.source?.module);
|
|
331
|
+
const sourceEvent = str(body.source?.event);
|
|
332
|
+
const sinkModule = str(body.sink?.module);
|
|
333
|
+
const sinkAction = str(body.sink?.action);
|
|
334
|
+
|
|
335
|
+
// Provenance label (modular-UI R2). Default to the hub's own builder; a
|
|
336
|
+
// module-owned config UI that POSTs on the operator's behalf labels itself.
|
|
337
|
+
// Validated to a slug so a bad value can't poison the SPA grouping.
|
|
338
|
+
const requestedByRaw = str(body.requestedBy);
|
|
339
|
+
const requestedBy = requestedByRaw === "" ? DEFAULT_REQUESTED_BY : requestedByRaw.toLowerCase();
|
|
340
|
+
if (!REQUESTED_BY_RE.test(requestedBy)) {
|
|
341
|
+
return jsonError(
|
|
342
|
+
400,
|
|
343
|
+
"invalid_request",
|
|
344
|
+
`requestedBy "${requestedByRaw}" is not a valid label (letters, numbers, dash, underscore)`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (!sourceModule || !sourceEvent || !sinkModule || !sinkAction) {
|
|
348
|
+
return jsonError(
|
|
349
|
+
400,
|
|
350
|
+
"invalid_request",
|
|
351
|
+
"source.module, source.event, sink.module, sink.action are all required",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Validate event + action existence against the declared catalog. -----
|
|
356
|
+
const src = deps.modules.find((m) => m.short === sourceModule);
|
|
357
|
+
if (!src) return jsonError(400, "unknown_module", `no installed module "${sourceModule}"`);
|
|
358
|
+
const event = findEvent(src.manifest, sourceEvent);
|
|
359
|
+
if (!event) {
|
|
360
|
+
return jsonError(
|
|
361
|
+
400,
|
|
362
|
+
"unknown_event",
|
|
363
|
+
`module "${sourceModule}" declares no event "${sourceEvent}"`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const sink = deps.modules.find((m) => m.short === sinkModule);
|
|
367
|
+
if (!sink) return jsonError(400, "unknown_module", `no installed module "${sinkModule}"`);
|
|
368
|
+
const action = findAction(sink.manifest, sinkAction);
|
|
369
|
+
if (!action) {
|
|
370
|
+
return jsonError(
|
|
371
|
+
400,
|
|
372
|
+
"unknown_action",
|
|
373
|
+
`module "${sinkModule}" declares no action "${sinkAction}"`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const provisionType = readProvisionType(action.provision);
|
|
378
|
+
if (provisionType !== "vault-trigger") {
|
|
379
|
+
return jsonError(
|
|
380
|
+
400,
|
|
381
|
+
"unsupported_provision",
|
|
382
|
+
`action "${sinkModule}.${sinkAction}" provision type ${provisionType ? `"${provisionType}"` : "(none)"} is not supported (only "vault-trigger" today)`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// vault-trigger requires the source to be a vault event on a named vault.
|
|
387
|
+
if (sourceModule !== "vault") {
|
|
388
|
+
return jsonError(
|
|
389
|
+
400,
|
|
390
|
+
"invalid_source",
|
|
391
|
+
`a "vault-trigger" sink requires a vault source event; got "${sourceModule}"`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
// The source event must map to a vault-trigger verb. `note.deleted` is a
|
|
395
|
+
// declared vault event (passes the catalog check above) but has no trigger
|
|
396
|
+
// verb today — reject it cleanly here rather than 500ing downstream.
|
|
397
|
+
try {
|
|
398
|
+
eventsForSourceEvent(sourceEvent);
|
|
399
|
+
} catch {
|
|
400
|
+
return jsonError(
|
|
401
|
+
400,
|
|
402
|
+
"unsupported_event",
|
|
403
|
+
`source event "${sourceEvent}" has no vault-trigger mapping (supported: note.created, note.updated)`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const vault = str(body.source?.vault);
|
|
407
|
+
if (!VAULT_NAME_CHARSET_RE.test(vault)) {
|
|
408
|
+
return jsonError(400, "invalid_request", `source.vault "${vault}" is not a valid identifier`);
|
|
409
|
+
}
|
|
410
|
+
const vaultOrigin = deps.resolveVaultOrigin(vault);
|
|
411
|
+
if (vaultOrigin === null) {
|
|
412
|
+
return jsonError(400, "unknown_vault", `no vault named "${vault}" in this hub`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// The sink action MUST declare its webhook endpoint + scope for the hub to
|
|
416
|
+
// wire it generically. A vault-trigger action without these is mis-declared.
|
|
417
|
+
if (!action.endpoint || !action.scope) {
|
|
418
|
+
return jsonError(
|
|
419
|
+
400,
|
|
420
|
+
"action_underdeclared",
|
|
421
|
+
`action "${sinkModule}.${sinkAction}" is a vault-trigger sink but declares no endpoint/scope`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (sink.mount === null) {
|
|
425
|
+
return jsonError(
|
|
426
|
+
400,
|
|
427
|
+
"sink_unmounted",
|
|
428
|
+
`sink module "${sinkModule}" has no mount path — cannot build a hub-proxied webhook`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const filter = readFilter(body.source?.filter);
|
|
433
|
+
const sourceRec: ConnectionSource = {
|
|
434
|
+
module: sourceModule,
|
|
435
|
+
vault,
|
|
436
|
+
event: sourceEvent,
|
|
437
|
+
...(filter ? { filter } : {}),
|
|
438
|
+
};
|
|
439
|
+
const sinkParams = readParams(body.sink?.params);
|
|
440
|
+
const sinkRec: ConnectionSink = {
|
|
441
|
+
module: sinkModule,
|
|
442
|
+
action: sinkAction,
|
|
443
|
+
...(sinkParams ? { params: sinkParams } : {}),
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Connection id — operator-supplied or derived. Drives the trigger name.
|
|
447
|
+
const id = deriveId(body.id, sourceRec, sinkRec);
|
|
448
|
+
if (!CONNECTION_ID_RE.test(id)) {
|
|
449
|
+
return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// jtis of the long-lived tokens minted for this connection — persisted on
|
|
453
|
+
// the record so teardown can revoke them (registered-mint rule).
|
|
454
|
+
const mintedJtis: string[] = [];
|
|
455
|
+
|
|
456
|
+
// --- Sink prerequisite (channel reply path), fenced to the channel sink. --
|
|
457
|
+
// Everything below this is general; THIS block is the only sink-specific step.
|
|
458
|
+
// The channel name comes from the action params (`sink.params.channel`) — it
|
|
459
|
+
// becomes a services.json key + an MCP server name, so it must be a slug.
|
|
460
|
+
if (sinkModule === "channel") {
|
|
461
|
+
const channelName = typeof sinkParams?.channel === "string" ? sinkParams.channel : "";
|
|
462
|
+
if (!CHANNEL_NAME_RE.test(channelName)) {
|
|
463
|
+
return jsonError(
|
|
464
|
+
400,
|
|
465
|
+
"invalid_request",
|
|
466
|
+
`channel sink requires sink.params.channel as a valid identifier; got "${channelName}"`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const prep = await prepareChannelSink(channelName, vault, vaultOrigin, userId, deps);
|
|
470
|
+
if (prep.error) return prep.error;
|
|
471
|
+
mintedJtis.push(prep.replyTokenJti);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --- Mint the webhook bearer at the action's DECLARED scope. -------------
|
|
475
|
+
let webhookBearer: string;
|
|
476
|
+
try {
|
|
477
|
+
const signed = await mint(deps, userId, {
|
|
478
|
+
scopes: [action.scope],
|
|
479
|
+
audience: audienceForScope(action.scope, sinkModule),
|
|
480
|
+
vaultScope: [],
|
|
481
|
+
ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS,
|
|
482
|
+
});
|
|
483
|
+
webhookBearer = signed.token;
|
|
484
|
+
mintedJtis.push(signed.jti);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
return stepError("mint_webhook_bearer", err);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// --- Build the trigger from the declarations + filter. -------------------
|
|
490
|
+
const triggerName = `conn_${id}`;
|
|
491
|
+
const webhook = buildWebhook(deps.hubOrigin, sink.mount, action.endpoint);
|
|
492
|
+
const trigger = buildVaultTrigger(triggerName, sourceEvent, filter, webhook, webhookBearer);
|
|
493
|
+
|
|
494
|
+
// --- Register the trigger on the vault (upsert: POST replaces by name). ---
|
|
495
|
+
try {
|
|
496
|
+
const vaultAdminToken = (
|
|
497
|
+
await mint(deps, userId, {
|
|
498
|
+
scopes: [`vault:${vault}:admin`],
|
|
499
|
+
audience: `vault.${vault}`,
|
|
500
|
+
vaultScope: [vault],
|
|
501
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
502
|
+
})
|
|
503
|
+
).token;
|
|
504
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
505
|
+
const res = await fetchImpl(`${vaultOrigin}/vault/${vault}/api/triggers`, {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: {
|
|
508
|
+
authorization: `Bearer ${vaultAdminToken}`,
|
|
509
|
+
"content-type": "application/json",
|
|
510
|
+
},
|
|
511
|
+
body: JSON.stringify(trigger),
|
|
512
|
+
});
|
|
513
|
+
if (!res.ok) return stepError("vault_trigger", await describeRemote(res));
|
|
514
|
+
} catch (err) {
|
|
515
|
+
return stepError("vault_trigger", err);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// --- Persist the connection record. --------------------------------------
|
|
519
|
+
const record: ConnectionRecord = {
|
|
520
|
+
id,
|
|
521
|
+
source: sourceRec,
|
|
522
|
+
sink: sinkRec,
|
|
523
|
+
provisioned: { type: "vault-trigger", vault, triggerName, mintedJtis },
|
|
524
|
+
createdAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
525
|
+
requestedBy,
|
|
526
|
+
};
|
|
527
|
+
putConnection(deps.storePath, record);
|
|
528
|
+
|
|
529
|
+
// --- Response. For a channel-deliver sink, hand back the connect lines
|
|
530
|
+
// (parity with hub#624) so the operator can join a session.
|
|
531
|
+
const out: {
|
|
532
|
+
ok: true;
|
|
533
|
+
connection: typeof record;
|
|
534
|
+
connect?: { mcpAdd: string; launch: string };
|
|
535
|
+
} = { ok: true, connection: record };
|
|
536
|
+
if (sinkModule === "channel" && typeof sinkParams?.channel === "string") {
|
|
537
|
+
out.connect = channelConnectLines(deps.hubOrigin, sinkParams.channel);
|
|
538
|
+
}
|
|
539
|
+
return json(200, out);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The channel sink's reply-path prerequisite (mirrors hub#624). Mints a
|
|
544
|
+
* `vault:<v>:write` for the channel + writes the `channels.json` entry on the
|
|
545
|
+
* channel daemon so the session can reply. Fenced to `sink.module === "channel"`
|
|
546
|
+
* — this is sink-specific config, not part of the general vault-trigger engine.
|
|
547
|
+
* Returns `{ error }` on failure, or `{ error: null, replyTokenJti }` on
|
|
548
|
+
* success — the jti of the long-lived reply token, so the caller can persist
|
|
549
|
+
* it for teardown revocation.
|
|
550
|
+
*/
|
|
551
|
+
async function prepareChannelSink(
|
|
552
|
+
channelName: string,
|
|
553
|
+
vault: string,
|
|
554
|
+
vaultOrigin: string,
|
|
555
|
+
userId: string,
|
|
556
|
+
deps: ConnectionsDeps,
|
|
557
|
+
): Promise<{ error: Response } | { error: null; replyTokenJti: string }> {
|
|
558
|
+
if (deps.channelOrigin === null) {
|
|
559
|
+
return {
|
|
560
|
+
error: jsonError(
|
|
561
|
+
503,
|
|
562
|
+
"channel_unavailable",
|
|
563
|
+
"the channel module is not installed on this hub",
|
|
564
|
+
),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
568
|
+
try {
|
|
569
|
+
const vaultWriteSigned = await mint(deps, userId, {
|
|
570
|
+
scopes: [`vault:${vault}:write`],
|
|
571
|
+
audience: `vault.${vault}`,
|
|
572
|
+
vaultScope: [vault],
|
|
573
|
+
ttlSeconds: WEBHOOK_BEARER_TTL_SECONDS, // channel keeps it for its lifetime
|
|
574
|
+
});
|
|
575
|
+
const channelAdminToken = (
|
|
576
|
+
await mint(deps, userId, {
|
|
577
|
+
scopes: ["channel:admin"],
|
|
578
|
+
audience: "channel",
|
|
579
|
+
vaultScope: [],
|
|
580
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
581
|
+
})
|
|
582
|
+
).token;
|
|
583
|
+
const res = await fetchImpl(`${deps.channelOrigin}/api/channels`, {
|
|
584
|
+
method: "POST",
|
|
585
|
+
headers: {
|
|
586
|
+
authorization: `Bearer ${channelAdminToken}`,
|
|
587
|
+
"content-type": "application/json",
|
|
588
|
+
},
|
|
589
|
+
body: JSON.stringify({
|
|
590
|
+
name: channelName,
|
|
591
|
+
transport: "vault",
|
|
592
|
+
config: { vault, vaultUrl: vaultOrigin, token: vaultWriteSigned.token },
|
|
593
|
+
}),
|
|
594
|
+
});
|
|
595
|
+
if (!res.ok) return { error: stepError("channel_config", await describeRemote(res)) };
|
|
596
|
+
return { error: null, replyTokenJti: vaultWriteSigned.jti };
|
|
597
|
+
} catch (err) {
|
|
598
|
+
return { error: stepError("channel_config", err) };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
// DELETE — teardown
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Tear down one connection by id: deregister the vault trigger, delete the
|
|
608
|
+
* channel-config entry (channel sinks), revoke the registered long-lived
|
|
609
|
+
* mints (B0), remove the record. Exported for the vault-delete cascade (B1)
|
|
610
|
+
* — `admin-vaults.handleDeleteVault` reuses this per matching record so the
|
|
611
|
+
* cascade and the operator-facing DELETE behave identically.
|
|
612
|
+
*/
|
|
613
|
+
export async function teardownConnection(
|
|
614
|
+
id: string,
|
|
615
|
+
userId: string,
|
|
616
|
+
deps: ConnectionsDeps,
|
|
617
|
+
): Promise<Response> {
|
|
618
|
+
if (!CONNECTION_ID_RE.test(id)) {
|
|
619
|
+
return jsonError(400, "invalid_request", `connection id "${id}" is not a valid identifier`);
|
|
620
|
+
}
|
|
621
|
+
const record = readConnections(deps.storePath).find((r) => r.id === id);
|
|
622
|
+
if (!record) {
|
|
623
|
+
return jsonError(404, "not_found", `no connection "${id}"`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
627
|
+
const errors: { step: string; detail: string }[] = [];
|
|
628
|
+
|
|
629
|
+
// --- Vault trigger teardown. ---------------------------------------------
|
|
630
|
+
const vault = record.provisioned.vault;
|
|
631
|
+
const triggerName = record.provisioned.triggerName;
|
|
632
|
+
if (vault && triggerName) {
|
|
633
|
+
const vaultOrigin = deps.resolveVaultOrigin(vault);
|
|
634
|
+
if (vaultOrigin) {
|
|
635
|
+
try {
|
|
636
|
+
const vaultAdminToken = (
|
|
637
|
+
await mint(deps, userId, {
|
|
638
|
+
scopes: [`vault:${vault}:admin`],
|
|
639
|
+
audience: `vault.${vault}`,
|
|
640
|
+
vaultScope: [vault],
|
|
641
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
642
|
+
})
|
|
643
|
+
).token;
|
|
644
|
+
const res = await fetchImpl(
|
|
645
|
+
`${vaultOrigin}/vault/${vault}/api/triggers/${encodeURIComponent(triggerName)}`,
|
|
646
|
+
{ method: "DELETE", headers: { authorization: `Bearer ${vaultAdminToken}` } },
|
|
647
|
+
);
|
|
648
|
+
if (!res.ok && res.status !== 404) {
|
|
649
|
+
errors.push({ step: "vault_trigger", detail: await remoteDetail(res) });
|
|
650
|
+
}
|
|
651
|
+
} catch (err) {
|
|
652
|
+
errors.push({ step: "vault_trigger", detail: errMsg(err) });
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
errors.push({ step: "vault_trigger", detail: `vault "${vault}" no longer installed` });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// --- Channel-sink teardown (remove the channel config entry). ------------
|
|
660
|
+
if (record.sink.module === "channel" && deps.channelOrigin) {
|
|
661
|
+
const channelName =
|
|
662
|
+
typeof record.sink.params?.channel === "string" ? record.sink.params.channel : record.id;
|
|
663
|
+
try {
|
|
664
|
+
const channelAdminToken = (
|
|
665
|
+
await mint(deps, userId, {
|
|
666
|
+
scopes: ["channel:admin"],
|
|
667
|
+
audience: "channel",
|
|
668
|
+
vaultScope: [],
|
|
669
|
+
ttlSeconds: PROVISION_TOKEN_TTL_SECONDS,
|
|
670
|
+
})
|
|
671
|
+
).token;
|
|
672
|
+
const res = await fetchImpl(
|
|
673
|
+
`${deps.channelOrigin}/api/channels/${encodeURIComponent(channelName)}`,
|
|
674
|
+
{ method: "DELETE", headers: { authorization: `Bearer ${channelAdminToken}` } },
|
|
675
|
+
);
|
|
676
|
+
if (!res.ok && res.status !== 404) {
|
|
677
|
+
errors.push({ step: "channel_config", detail: await remoteDetail(res) });
|
|
678
|
+
}
|
|
679
|
+
} catch (err) {
|
|
680
|
+
errors.push({ step: "channel_config", detail: errMsg(err) });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// --- Revoke the registered long-lived mints (B0, registered-mint rule). ---
|
|
685
|
+
// Marks each tokens-registry row revoked → the revocation list at
|
|
686
|
+
// `/.well-known/parachute-revocation.json` advertises the jtis, and every
|
|
687
|
+
// resource server (vault, channel) rejects the credential from its next
|
|
688
|
+
// poll. Runs regardless of remote-teardown outcome — revocation is the safe
|
|
689
|
+
// direction. Legacy records (provisioned before B0) carry no jtis: teardown
|
|
690
|
+
// proceeds, but their tokens were never registered and ride to expiry.
|
|
691
|
+
const mintedJtis = record.provisioned?.mintedJtis ?? [];
|
|
692
|
+
if (mintedJtis.length === 0) {
|
|
693
|
+
console.warn(
|
|
694
|
+
`[connections] connection "${id}" predates registered mints — its provisioned tokens were never registered and ride to their original expiry`,
|
|
695
|
+
);
|
|
696
|
+
} else {
|
|
697
|
+
const now = deps.now?.() ?? new Date();
|
|
698
|
+
for (const jti of mintedJtis) {
|
|
699
|
+
try {
|
|
700
|
+
revokeTokenByJti(deps.db, jti, now);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
errors.push({ step: "revoke_mints", detail: `jti ${jti}: ${errMsg(err)}` });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Remove the record regardless — leaving a phantom record after a downstream
|
|
708
|
+
// failure is worse than a possibly-orphaned trigger the operator can re-run.
|
|
709
|
+
removeConnection(deps.storePath, id);
|
|
710
|
+
|
|
711
|
+
if (errors.length > 0) {
|
|
712
|
+
return json(207, { ok: false, id, partial: true, errors });
|
|
713
|
+
}
|
|
714
|
+
return json(200, { ok: true, id });
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ===========================================================================
|
|
718
|
+
// Derivation — the GENERAL mapping (filter→predicate, event→events, webhook)
|
|
719
|
+
// ===========================================================================
|
|
720
|
+
|
|
721
|
+
/** Shape of the vault runtime trigger we POST (vault#469 API). */
|
|
722
|
+
interface VaultTrigger {
|
|
723
|
+
name: string;
|
|
724
|
+
events: string[];
|
|
725
|
+
when: Record<string, unknown>;
|
|
726
|
+
action: { webhook: string; send: string; auth: { bearer: string } };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Map a source event key to the vault trigger's `events`. The vault hook system
|
|
731
|
+
* fires on `"created"` / `"updated"`; the `note.<verb>` key carries the verb.
|
|
732
|
+
*/
|
|
733
|
+
export function eventsForSourceEvent(eventKey: string): string[] {
|
|
734
|
+
if (eventKey === "note.created") return ["created"];
|
|
735
|
+
if (eventKey === "note.updated") return ["updated"];
|
|
736
|
+
// The catalog already validated the event exists upstream, so an unknown key
|
|
737
|
+
// reaching here is a bug (or an event with no vault-trigger verb mapping, e.g.
|
|
738
|
+
// note.deleted). Fail loud rather than silently registering the wrong trigger.
|
|
739
|
+
throw new Error(`no vault-trigger event mapping for source event "${eventKey}"`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Map the operator-set `source.filter` to a vault trigger `when` predicate. The
|
|
744
|
+
* filter keys are the trigger predicate keys 1:1 (`tags`, `has_metadata`,
|
|
745
|
+
* `missing_metadata`, `has_content`) — this is what makes a vault event's
|
|
746
|
+
* filterSchema drive the predicate with no per-module code.
|
|
747
|
+
*/
|
|
748
|
+
export function whenFromFilter(
|
|
749
|
+
filter: Record<string, unknown> | undefined,
|
|
750
|
+
): Record<string, unknown> {
|
|
751
|
+
const when: Record<string, unknown> = {};
|
|
752
|
+
if (!filter) return when;
|
|
753
|
+
if (Array.isArray(filter.tags)) {
|
|
754
|
+
const tags = filter.tags.filter((t): t is string => typeof t === "string");
|
|
755
|
+
if (tags.length > 0) when.tags = tags;
|
|
756
|
+
}
|
|
757
|
+
if (Array.isArray(filter.has_metadata)) {
|
|
758
|
+
const keys = filter.has_metadata.filter((k): k is string => typeof k === "string");
|
|
759
|
+
if (keys.length > 0) when.has_metadata = keys;
|
|
760
|
+
}
|
|
761
|
+
if (Array.isArray(filter.missing_metadata)) {
|
|
762
|
+
const keys = filter.missing_metadata.filter((k): k is string => typeof k === "string");
|
|
763
|
+
if (keys.length > 0) when.missing_metadata = keys;
|
|
764
|
+
}
|
|
765
|
+
if (typeof filter.has_content === "boolean") when.has_content = filter.has_content;
|
|
766
|
+
return when;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Build the hub-proxied webhook from the sink module's mount + action endpoint. */
|
|
770
|
+
export function buildWebhook(hubOrigin: string, mount: string, endpoint: string): string {
|
|
771
|
+
const origin = hubOrigin.replace(/\/+$/, "");
|
|
772
|
+
const m = mount.startsWith("/") ? mount.replace(/\/+$/, "") : `/${mount.replace(/\/+$/, "")}`;
|
|
773
|
+
const ep = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
774
|
+
return `${origin}${m}${ep}`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function buildVaultTrigger(
|
|
778
|
+
name: string,
|
|
779
|
+
sourceEvent: string,
|
|
780
|
+
filter: Record<string, unknown> | undefined,
|
|
781
|
+
webhook: string,
|
|
782
|
+
bearer: string,
|
|
783
|
+
): VaultTrigger {
|
|
784
|
+
return {
|
|
785
|
+
name,
|
|
786
|
+
events: eventsForSourceEvent(sourceEvent),
|
|
787
|
+
when: whenFromFilter(filter),
|
|
788
|
+
action: { webhook, send: "json", auth: { bearer } },
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ===========================================================================
|
|
793
|
+
// Helpers
|
|
794
|
+
// ===========================================================================
|
|
795
|
+
|
|
796
|
+
function findEvent(m: ModuleManifest, key: string): ModuleEvent | undefined {
|
|
797
|
+
return (m.events ?? []).find((e) => e.key === key);
|
|
798
|
+
}
|
|
799
|
+
function findAction(m: ModuleManifest, key: string): ModuleAction | undefined {
|
|
800
|
+
return (m.actions ?? []).find((a) => a.key === key);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Extract `provision.type` from the opaque provision descriptor. */
|
|
804
|
+
function readProvisionType(provision: unknown): string | null {
|
|
805
|
+
if (provision && typeof provision === "object" && !Array.isArray(provision)) {
|
|
806
|
+
const t = (provision as Record<string, unknown>).type;
|
|
807
|
+
if (typeof t === "string") return t;
|
|
808
|
+
}
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Audience for a minted sink bearer. A `<module>:<verb>` scope (e.g.
|
|
814
|
+
* `channel:send`) takes the module namespace as its audience — matching how
|
|
815
|
+
* the channel validates `aud: channel`. Falls back to the sink module name.
|
|
816
|
+
*/
|
|
817
|
+
function audienceForScope(scope: string, sinkModule: string): string {
|
|
818
|
+
const colon = scope.indexOf(":");
|
|
819
|
+
return colon > 0 ? scope.slice(0, colon) : sinkModule;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function readFilter(v: unknown): Record<string, unknown> | undefined {
|
|
823
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
826
|
+
function readParams(v: unknown): Record<string, unknown> | undefined {
|
|
827
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
|
|
828
|
+
return undefined;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function str(v: unknown): string {
|
|
832
|
+
return typeof v === "string" ? v.trim() : "";
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Derive the connection id. Operator-supplied wins; else for a channel sink use
|
|
837
|
+
* the channel name (so the trigger + channel-config share a stable key), else a
|
|
838
|
+
* `<srcModule>-<event>-<sinkModule>-<action>` slug.
|
|
839
|
+
*/
|
|
840
|
+
function deriveId(rawId: unknown, source: ConnectionSource, sink: ConnectionSink): string {
|
|
841
|
+
const supplied = str(rawId);
|
|
842
|
+
if (supplied) return supplied.toLowerCase();
|
|
843
|
+
if (sink.module === "channel" && typeof sink.params?.channel === "string") {
|
|
844
|
+
return `channel-${sink.params.channel}`.toLowerCase();
|
|
845
|
+
}
|
|
846
|
+
const slug = `${source.module}-${source.event}-${sink.module}-${sink.action}`
|
|
847
|
+
.toLowerCase()
|
|
848
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
849
|
+
.replace(/^-+|-+$/g, "");
|
|
850
|
+
return slug;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function channelConnectLines(
|
|
854
|
+
hubOrigin: string,
|
|
855
|
+
channelName: string,
|
|
856
|
+
): { mcpAdd: string; launch: string } {
|
|
857
|
+
const origin = hubOrigin.replace(/\/+$/, "");
|
|
858
|
+
return {
|
|
859
|
+
mcpAdd: `claude mcp add --transport http --scope user channel-${channelName} ${origin}/channel/mcp/${channelName}`,
|
|
860
|
+
launch: `claude --dangerously-load-development-channels=server:channel-${channelName} --dangerously-skip-permissions`,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// --- Auth gate (mirrors the admin-token mints) ------------------------------
|
|
865
|
+
|
|
866
|
+
/** Returns an error Response when the operator gate fails, else `null`. */
|
|
867
|
+
function operatorGate(req: Request, deps: ConnectionsDeps): Response | null {
|
|
868
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
869
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
870
|
+
if (!session) {
|
|
871
|
+
return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
|
|
872
|
+
}
|
|
873
|
+
if (!isFirstAdmin(deps.db, session.userId)) {
|
|
874
|
+
return jsonError(
|
|
875
|
+
403,
|
|
876
|
+
"not_admin",
|
|
877
|
+
"connection provisioning is restricted to the hub admin — your account home is at /account/",
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function sessionUser(req: Request, deps: ConnectionsDeps): { userId: string } {
|
|
884
|
+
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
885
|
+
const session = sid ? findSession(deps.db, sid) : null;
|
|
886
|
+
return { userId: session?.userId ?? "" };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// --- Mint -------------------------------------------------------------------
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* TTL boundary between "interactive" mints (used immediately, ride to expiry
|
|
893
|
+
* by design — the documented ≤10-min unregistered bound) and LONG-LIVED mints,
|
|
894
|
+
* which MUST be registered in the tokens table so they stay revocable
|
|
895
|
+
* (hub-module-boundary charter, registered-mint rule). The audit that produced
|
|
896
|
+
* the rule found this engine minting ~90-day tokens with no registry row — an
|
|
897
|
+
* unrevocable-by-construction credential (`api-revoke-token` 404s unknown
|
|
898
|
+
* jtis; the revocation list only carries registered jtis).
|
|
899
|
+
*/
|
|
900
|
+
const REGISTERED_MINT_TTL_THRESHOLD_SECONDS = 10 * 60;
|
|
901
|
+
|
|
902
|
+
interface MintSpec {
|
|
903
|
+
scopes: string[];
|
|
904
|
+
audience: string;
|
|
905
|
+
vaultScope: string[];
|
|
906
|
+
ttlSeconds: number;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function mint(deps: ConnectionsDeps, userId: string, spec: MintSpec) {
|
|
910
|
+
const sign = deps.signToken ?? signAccessToken;
|
|
911
|
+
const signed = await sign(deps.db, {
|
|
912
|
+
sub: userId,
|
|
913
|
+
scopes: spec.scopes,
|
|
914
|
+
audience: spec.audience,
|
|
915
|
+
clientId: PROVISION_CLIENT_ID,
|
|
916
|
+
issuer: deps.hubOrigin,
|
|
917
|
+
ttlSeconds: spec.ttlSeconds,
|
|
918
|
+
vaultScope: spec.vaultScope,
|
|
919
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
920
|
+
});
|
|
921
|
+
// Register long-lived mints so they're revocable on teardown. Short-lived
|
|
922
|
+
// provisioning tokens (60s, consumed inline) stay unregistered by design.
|
|
923
|
+
if (spec.ttlSeconds > REGISTERED_MINT_TTL_THRESHOLD_SECONDS) {
|
|
924
|
+
recordTokenMint(deps.db, {
|
|
925
|
+
jti: signed.jti,
|
|
926
|
+
createdVia: "connection_provision",
|
|
927
|
+
subject: "connection",
|
|
928
|
+
userId,
|
|
929
|
+
clientId: PROVISION_CLIENT_ID,
|
|
930
|
+
scopes: spec.scopes,
|
|
931
|
+
expiresAt: signed.expiresAt,
|
|
932
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
return signed;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// --- Response helpers -------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
function json(status: number, payload: unknown): Response {
|
|
941
|
+
return new Response(JSON.stringify(payload), {
|
|
942
|
+
status,
|
|
943
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
948
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
949
|
+
status,
|
|
950
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function stepError(step: string, cause: unknown): Response {
|
|
955
|
+
return json(502, {
|
|
956
|
+
error: "provision_failed",
|
|
957
|
+
step,
|
|
958
|
+
error_description: `provisioning failed at step "${step}": ${errMsg(cause)}`,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function errMsg(cause: unknown): string {
|
|
963
|
+
if (cause instanceof Error) return cause.message;
|
|
964
|
+
if (typeof cause === "string") return cause;
|
|
965
|
+
return String(cause);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function describeRemote(res: Response): Promise<Error> {
|
|
969
|
+
return new Error(await remoteDetail(res));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function remoteDetail(res: Response): Promise<string> {
|
|
973
|
+
let text = "";
|
|
974
|
+
try {
|
|
975
|
+
text = (await res.text()).slice(0, 300);
|
|
976
|
+
} catch {
|
|
977
|
+
// status alone is informative enough
|
|
978
|
+
}
|
|
979
|
+
return `downstream ${res.status}${text ? `: ${text}` : ""}`;
|
|
980
|
+
}
|