@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,1154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the general Connections engine (2026-06-09 modular-UI architecture,
|
|
3
|
+
* P5) — `GET /api/connections/catalog`, `GET/POST /admin/connections`,
|
|
4
|
+
* `DELETE /admin/connections/:id`.
|
|
5
|
+
*
|
|
6
|
+
* The catalog is built from injected module manifests. The vault + channel HTTP
|
|
7
|
+
* calls are mocked via an injectable `fetchImpl` that records every request
|
|
8
|
+
* (method, URL, decoded bearer, parsed body) and returns scripted responses.
|
|
9
|
+
* Tokens are real (minted by the actual `signAccessToken`), so we can decode the
|
|
10
|
+
* JWT claims (scope/aud) the way channel/vault would.
|
|
11
|
+
*
|
|
12
|
+
* The whole point of the engine is GENERALITY: the webhook + scope come from the
|
|
13
|
+
* SINK ACTION's declaration, not hardcoded per module. The tests assert that —
|
|
14
|
+
* e.g. a synthetic sink module ("widget") with a different endpoint/scope
|
|
15
|
+
* provisions a trigger with THAT endpoint + THAT scope.
|
|
16
|
+
*/
|
|
17
|
+
import type { Database } from "bun:sqlite";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { decodeJwt } from "jose";
|
|
23
|
+
import {
|
|
24
|
+
type ConnectionsDeps,
|
|
25
|
+
type InstalledModuleInfo,
|
|
26
|
+
buildCatalog,
|
|
27
|
+
buildWebhook,
|
|
28
|
+
eventsForSourceEvent,
|
|
29
|
+
handleConnections,
|
|
30
|
+
handleConnectionsCatalog,
|
|
31
|
+
whenFromFilter,
|
|
32
|
+
} from "../admin-connections.ts";
|
|
33
|
+
import { putConnection, readConnections } from "../connections-store.ts";
|
|
34
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
35
|
+
import { findTokenRowByJti, listActiveRevocations } from "../jwt-sign.ts";
|
|
36
|
+
import { type ModuleManifest, validateModuleManifest } from "../module-manifest.ts";
|
|
37
|
+
import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
|
|
38
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
39
|
+
import { createUser } from "../users.ts";
|
|
40
|
+
|
|
41
|
+
const HUB_ORIGIN = "https://hub.test";
|
|
42
|
+
const CHANNEL_ORIGIN = "http://127.0.0.1:1941";
|
|
43
|
+
const VAULT_ORIGIN = "http://127.0.0.1:1940";
|
|
44
|
+
|
|
45
|
+
interface Harness {
|
|
46
|
+
db: Database;
|
|
47
|
+
storePath: string;
|
|
48
|
+
cleanup: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeHarness(): Harness {
|
|
52
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-connections-"));
|
|
53
|
+
const db = openHubDb(hubDbPath(dir));
|
|
54
|
+
rotateSigningKey(db);
|
|
55
|
+
return {
|
|
56
|
+
db,
|
|
57
|
+
storePath: join(dir, "connections.json"),
|
|
58
|
+
cleanup: () => {
|
|
59
|
+
db.close();
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let harness: Harness;
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
harness = makeHarness();
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
harness.cleanup();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function adminCookie(): Promise<{ cookie: string; userId: string }> {
|
|
74
|
+
const user = await createUser(harness.db, "operator", "hunter2");
|
|
75
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
76
|
+
return {
|
|
77
|
+
cookie: buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)),
|
|
78
|
+
userId: user.id,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function friendCookie(): Promise<string> {
|
|
83
|
+
await createUser(harness.db, "admin", "admin-passphrase");
|
|
84
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", { allowMulti: true });
|
|
85
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
86
|
+
return buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Mock fetch -------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
interface RecordedReq {
|
|
92
|
+
method: string;
|
|
93
|
+
url: string;
|
|
94
|
+
bearer: string | null;
|
|
95
|
+
body: unknown;
|
|
96
|
+
}
|
|
97
|
+
type Responder = (req: RecordedReq) => Response;
|
|
98
|
+
|
|
99
|
+
function mockFetch(routes: Record<string, Responder>): {
|
|
100
|
+
fetchImpl: typeof fetch;
|
|
101
|
+
calls: RecordedReq[];
|
|
102
|
+
} {
|
|
103
|
+
const calls: RecordedReq[] = [];
|
|
104
|
+
const fetchImpl = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
105
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
106
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
107
|
+
const auth =
|
|
108
|
+
(init?.headers as Record<string, string> | undefined)?.authorization ??
|
|
109
|
+
(init?.headers as Record<string, string> | undefined)?.Authorization ??
|
|
110
|
+
null;
|
|
111
|
+
const bearer = auth ? auth.replace(/^Bearer\s+/i, "") : null;
|
|
112
|
+
let body: unknown;
|
|
113
|
+
if (typeof init?.body === "string") {
|
|
114
|
+
try {
|
|
115
|
+
body = JSON.parse(init.body);
|
|
116
|
+
} catch {
|
|
117
|
+
body = init.body;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const path = new URL(url).pathname;
|
|
121
|
+
const rec: RecordedReq = { method, url, bearer, body };
|
|
122
|
+
calls.push(rec);
|
|
123
|
+
const responder = routes[`${method} ${path}`];
|
|
124
|
+
if (!responder) return new Response("no route", { status: 599 });
|
|
125
|
+
return responder(rec);
|
|
126
|
+
}) as typeof fetch;
|
|
127
|
+
return { fetchImpl, calls };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function ok(payload: unknown): Response {
|
|
131
|
+
return new Response(JSON.stringify(payload), {
|
|
132
|
+
status: 200,
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function scopeOf(jwt: string): string[] {
|
|
138
|
+
const claims = decodeJwt(jwt) as { scope?: string };
|
|
139
|
+
return (claims.scope ?? "").split(/\s+/).filter(Boolean);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Module fixtures --------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const VAULT_MANIFEST: ModuleManifest = {
|
|
145
|
+
name: "vault",
|
|
146
|
+
manifestName: "@openparachute/vault",
|
|
147
|
+
port: 1940,
|
|
148
|
+
paths: ["/vault"],
|
|
149
|
+
health: "/health",
|
|
150
|
+
events: [
|
|
151
|
+
{
|
|
152
|
+
key: "note.created",
|
|
153
|
+
title: "A note was created",
|
|
154
|
+
filterSchema: { type: "object", properties: { tags: { type: "array" } } },
|
|
155
|
+
},
|
|
156
|
+
{ key: "note.updated", title: "A note was updated" },
|
|
157
|
+
// A declared event with NO vault-trigger verb mapping — used to exercise
|
|
158
|
+
// the clean `unsupported_event` 400 (vs a downstream 500).
|
|
159
|
+
{ key: "note.deleted", title: "A note was deleted" },
|
|
160
|
+
],
|
|
161
|
+
actions: [{ key: "note.create", title: "Create a note", inputSchema: {} }],
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const CHANNEL_MANIFEST: ModuleManifest = {
|
|
165
|
+
name: "channel",
|
|
166
|
+
manifestName: "parachute-channel",
|
|
167
|
+
port: 1941,
|
|
168
|
+
paths: ["/channel"],
|
|
169
|
+
health: "/health",
|
|
170
|
+
events: [{ key: "message.received", title: "A message arrived" }],
|
|
171
|
+
actions: [
|
|
172
|
+
{
|
|
173
|
+
key: "message.deliver",
|
|
174
|
+
title: "Deliver an inbound message",
|
|
175
|
+
endpoint: "/api/vault/inbound",
|
|
176
|
+
scope: "channel:send",
|
|
177
|
+
provision: { type: "vault-trigger" },
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
// Mirrors the declaration channel ships in its real module.json (boundary
|
|
181
|
+
// D2) — drives the catalog `templates` round-trip pin below.
|
|
182
|
+
connectionTemplates: [
|
|
183
|
+
{
|
|
184
|
+
key: "link-to-vault",
|
|
185
|
+
title: "Link a channel to a vault",
|
|
186
|
+
description: "Back a channel with a Parachute vault.",
|
|
187
|
+
requestedBy: "channel",
|
|
188
|
+
source: {
|
|
189
|
+
module: "vault",
|
|
190
|
+
event: "note.created",
|
|
191
|
+
filter: { tags: ["#channel-message/inbound"] },
|
|
192
|
+
},
|
|
193
|
+
sink: { module: "channel", action: "message.deliver" },
|
|
194
|
+
parameters: [
|
|
195
|
+
{ key: "vault", target: "source.vault", title: "Vault" },
|
|
196
|
+
{ key: "channel", target: "sink.params.channel", title: "Channel name" },
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** A synthetic sink module that proves the engine is NOT channel-hardcoded. */
|
|
203
|
+
const WIDGET_MANIFEST: ModuleManifest = {
|
|
204
|
+
name: "widget",
|
|
205
|
+
manifestName: "widget",
|
|
206
|
+
port: 1955,
|
|
207
|
+
paths: ["/widget"],
|
|
208
|
+
health: "/health",
|
|
209
|
+
actions: [
|
|
210
|
+
{
|
|
211
|
+
key: "thing.do",
|
|
212
|
+
title: "Do a thing",
|
|
213
|
+
endpoint: "/hooks/incoming",
|
|
214
|
+
scope: "widget:trigger",
|
|
215
|
+
provision: { type: "vault-trigger" },
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
function modulesOf(...manifests: ModuleManifest[]): InstalledModuleInfo[] {
|
|
221
|
+
return manifests.map((manifest) => ({
|
|
222
|
+
short: manifest.name,
|
|
223
|
+
manifest,
|
|
224
|
+
mount: manifest.paths[0] ?? null,
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function baseDeps(fetchImpl: typeof fetch, modules: InstalledModuleInfo[]): ConnectionsDeps {
|
|
229
|
+
return {
|
|
230
|
+
db: harness.db,
|
|
231
|
+
hubOrigin: HUB_ORIGIN,
|
|
232
|
+
modules,
|
|
233
|
+
resolveVaultOrigin: (v) => (v === "default" ? VAULT_ORIGIN : null),
|
|
234
|
+
channelOrigin: CHANNEL_ORIGIN,
|
|
235
|
+
storePath: harness.storePath,
|
|
236
|
+
fetchImpl,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ===========================================================================
|
|
241
|
+
// Pure derivation units
|
|
242
|
+
// ===========================================================================
|
|
243
|
+
|
|
244
|
+
describe("derivation units", () => {
|
|
245
|
+
test("eventsForSourceEvent maps note.created/updated to vault verbs", () => {
|
|
246
|
+
expect(eventsForSourceEvent("note.created")).toEqual(["created"]);
|
|
247
|
+
expect(eventsForSourceEvent("note.updated")).toEqual(["updated"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("eventsForSourceEvent throws (not a silent fallback) on an unmappable event", () => {
|
|
251
|
+
// note.deleted is a real vault event but has no trigger verb — fail loud.
|
|
252
|
+
expect(() => eventsForSourceEvent("note.deleted")).toThrow(/no vault-trigger event mapping/);
|
|
253
|
+
expect(() => eventsForSourceEvent("bogus")).toThrow();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("whenFromFilter maps filter keys 1:1 to the trigger predicate", () => {
|
|
257
|
+
expect(
|
|
258
|
+
whenFromFilter({
|
|
259
|
+
tags: ["#channel-message/inbound"],
|
|
260
|
+
has_metadata: ["channel"],
|
|
261
|
+
missing_metadata: ["channel_inbound_rendered_at"],
|
|
262
|
+
has_content: true,
|
|
263
|
+
ignored: "x",
|
|
264
|
+
}),
|
|
265
|
+
).toEqual({
|
|
266
|
+
tags: ["#channel-message/inbound"],
|
|
267
|
+
has_metadata: ["channel"],
|
|
268
|
+
missing_metadata: ["channel_inbound_rendered_at"],
|
|
269
|
+
has_content: true,
|
|
270
|
+
});
|
|
271
|
+
expect(whenFromFilter(undefined)).toEqual({});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("buildWebhook joins origin + mount + endpoint, trimming slashes", () => {
|
|
275
|
+
expect(buildWebhook(`${HUB_ORIGIN}/`, "/channel", "/api/vault/inbound")).toBe(
|
|
276
|
+
`${HUB_ORIGIN}/channel/api/vault/inbound`,
|
|
277
|
+
);
|
|
278
|
+
expect(buildWebhook(HUB_ORIGIN, "widget", "hooks/incoming")).toBe(
|
|
279
|
+
`${HUB_ORIGIN}/widget/hooks/incoming`,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ===========================================================================
|
|
285
|
+
// Catalog
|
|
286
|
+
// ===========================================================================
|
|
287
|
+
|
|
288
|
+
describe("GET /api/connections/catalog", () => {
|
|
289
|
+
test("returns events + actions read from installed module manifests", () => {
|
|
290
|
+
const cat = buildCatalog(modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
|
|
291
|
+
expect(cat.events).toContainEqual({
|
|
292
|
+
module: "vault",
|
|
293
|
+
key: "note.created",
|
|
294
|
+
title: "A note was created",
|
|
295
|
+
filterSchema: { type: "object", properties: { tags: { type: "array" } } },
|
|
296
|
+
});
|
|
297
|
+
expect(cat.actions).toContainEqual({
|
|
298
|
+
module: "channel",
|
|
299
|
+
key: "message.deliver",
|
|
300
|
+
title: "Deliver an inbound message",
|
|
301
|
+
inputSchema: null,
|
|
302
|
+
provision: { type: "vault-trigger" },
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("round-trips declared connectionTemplates as `templates` (boundary D2)", () => {
|
|
307
|
+
const cat = buildCatalog(modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
|
|
308
|
+
expect(cat.templates).toEqual([
|
|
309
|
+
{
|
|
310
|
+
module: "channel",
|
|
311
|
+
key: "link-to-vault",
|
|
312
|
+
title: "Link a channel to a vault",
|
|
313
|
+
description: "Back a channel with a Parachute vault.",
|
|
314
|
+
requestedBy: "channel",
|
|
315
|
+
source: {
|
|
316
|
+
module: "vault",
|
|
317
|
+
event: "note.created",
|
|
318
|
+
filter: { tags: ["#channel-message/inbound"] },
|
|
319
|
+
},
|
|
320
|
+
sink: { module: "channel", action: "message.deliver" },
|
|
321
|
+
parameters: [
|
|
322
|
+
{
|
|
323
|
+
key: "vault",
|
|
324
|
+
target: "source.vault",
|
|
325
|
+
title: "Vault",
|
|
326
|
+
description: null,
|
|
327
|
+
example: null,
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
key: "channel",
|
|
331
|
+
target: "sink.params.channel",
|
|
332
|
+
title: "Channel name",
|
|
333
|
+
description: null,
|
|
334
|
+
example: null,
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
]);
|
|
339
|
+
// Modules that declare none contribute none.
|
|
340
|
+
expect(buildCatalog(modulesOf(VAULT_MANIFEST)).templates).toEqual([]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("config-kind templates (no source/sink — scribe's shape) are not builder presets", () => {
|
|
344
|
+
const scribeish: ModuleManifest = {
|
|
345
|
+
name: "demo",
|
|
346
|
+
manifestName: "demo",
|
|
347
|
+
port: 1956,
|
|
348
|
+
paths: ["/demo"],
|
|
349
|
+
health: "/health",
|
|
350
|
+
connectionTemplates: [
|
|
351
|
+
{ key: "link-to-vault", kind: "config", title: "Auto-transcribe a vault's audio" },
|
|
352
|
+
],
|
|
353
|
+
};
|
|
354
|
+
expect(buildCatalog(modulesOf(scribeish)).templates).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("catalog endpoint is operator-gated (401 with no session)", async () => {
|
|
358
|
+
const { fetchImpl } = mockFetch({});
|
|
359
|
+
const req = new Request(`${HUB_ORIGIN}/api/connections/catalog`);
|
|
360
|
+
const res = await handleConnectionsCatalog(req, baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST)));
|
|
361
|
+
expect(res.status).toBe(401);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("catalog endpoint returns the catalog for an admin", async () => {
|
|
365
|
+
const { cookie } = await adminCookie();
|
|
366
|
+
const { fetchImpl } = mockFetch({});
|
|
367
|
+
const req = new Request(`${HUB_ORIGIN}/api/connections/catalog`, { headers: { cookie } });
|
|
368
|
+
const res = await handleConnectionsCatalog(
|
|
369
|
+
req,
|
|
370
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST)),
|
|
371
|
+
);
|
|
372
|
+
expect(res.status).toBe(200);
|
|
373
|
+
const out = (await res.json()) as { events: unknown[]; actions: unknown[] };
|
|
374
|
+
// vault: 3 events + 1 action; channel: 1 event + 1 action.
|
|
375
|
+
expect(out.events.length).toBe(4);
|
|
376
|
+
expect(out.actions.length).toBe(2);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// ===========================================================================
|
|
381
|
+
// Operator gate
|
|
382
|
+
// ===========================================================================
|
|
383
|
+
|
|
384
|
+
describe("operator gate", () => {
|
|
385
|
+
test("401 with no session cookie", async () => {
|
|
386
|
+
const { fetchImpl } = mockFetch({});
|
|
387
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
388
|
+
method: "POST",
|
|
389
|
+
body: JSON.stringify({}),
|
|
390
|
+
});
|
|
391
|
+
const res = await handleConnections(req, "", baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST)));
|
|
392
|
+
expect(res.status).toBe(401);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("403 for a non-first-admin (friend)", async () => {
|
|
396
|
+
const cookie = await friendCookie();
|
|
397
|
+
const { fetchImpl } = mockFetch({});
|
|
398
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
399
|
+
method: "POST",
|
|
400
|
+
headers: { cookie },
|
|
401
|
+
body: JSON.stringify({}),
|
|
402
|
+
});
|
|
403
|
+
const res = await handleConnections(req, "", baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST)));
|
|
404
|
+
expect(res.status).toBe(403);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ===========================================================================
|
|
409
|
+
// POST — validation
|
|
410
|
+
// ===========================================================================
|
|
411
|
+
|
|
412
|
+
describe("POST /admin/connections — validation", () => {
|
|
413
|
+
test("400 on unknown event", async () => {
|
|
414
|
+
const { cookie } = await adminCookie();
|
|
415
|
+
const { fetchImpl, calls } = mockFetch({});
|
|
416
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
417
|
+
method: "POST",
|
|
418
|
+
headers: { cookie },
|
|
419
|
+
body: JSON.stringify({
|
|
420
|
+
source: { module: "vault", vault: "default", event: "note.imaginary" },
|
|
421
|
+
sink: { module: "channel", action: "message.deliver" },
|
|
422
|
+
}),
|
|
423
|
+
});
|
|
424
|
+
const res = await handleConnections(
|
|
425
|
+
req,
|
|
426
|
+
"",
|
|
427
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST)),
|
|
428
|
+
);
|
|
429
|
+
expect(res.status).toBe(400);
|
|
430
|
+
expect(((await res.json()) as { error: string }).error).toBe("unknown_event");
|
|
431
|
+
expect(calls.length).toBe(0);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("400 unsupported_event on a declared-but-unmappable event (note.deleted)", async () => {
|
|
435
|
+
const { cookie } = await adminCookie();
|
|
436
|
+
const { fetchImpl, calls } = mockFetch({});
|
|
437
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: { cookie },
|
|
440
|
+
body: JSON.stringify({
|
|
441
|
+
// note.deleted IS a declared vault event (passes the catalog check) but
|
|
442
|
+
// has no vault-trigger verb → clean 400, no downstream provisioning.
|
|
443
|
+
source: { module: "vault", vault: "default", event: "note.deleted" },
|
|
444
|
+
sink: { module: "widget", action: "thing.do" },
|
|
445
|
+
}),
|
|
446
|
+
});
|
|
447
|
+
const res = await handleConnections(
|
|
448
|
+
req,
|
|
449
|
+
"",
|
|
450
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST)),
|
|
451
|
+
);
|
|
452
|
+
expect(res.status).toBe(400);
|
|
453
|
+
expect(((await res.json()) as { error: string }).error).toBe("unsupported_event");
|
|
454
|
+
expect(calls.length).toBe(0);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("400 on unknown action", async () => {
|
|
458
|
+
const { cookie } = await adminCookie();
|
|
459
|
+
const { fetchImpl } = mockFetch({});
|
|
460
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { cookie },
|
|
463
|
+
body: JSON.stringify({
|
|
464
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
465
|
+
sink: { module: "channel", action: "message.imaginary" },
|
|
466
|
+
}),
|
|
467
|
+
});
|
|
468
|
+
const res = await handleConnections(
|
|
469
|
+
req,
|
|
470
|
+
"",
|
|
471
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST)),
|
|
472
|
+
);
|
|
473
|
+
expect(res.status).toBe(400);
|
|
474
|
+
expect(((await res.json()) as { error: string }).error).toBe("unknown_action");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("400 on unknown vault", async () => {
|
|
478
|
+
const { cookie } = await adminCookie();
|
|
479
|
+
const { fetchImpl } = mockFetch({});
|
|
480
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
481
|
+
method: "POST",
|
|
482
|
+
headers: { cookie },
|
|
483
|
+
body: JSON.stringify({
|
|
484
|
+
source: { module: "vault", vault: "ghost", event: "note.created" },
|
|
485
|
+
sink: { module: "widget", action: "thing.do" },
|
|
486
|
+
}),
|
|
487
|
+
});
|
|
488
|
+
const res = await handleConnections(
|
|
489
|
+
req,
|
|
490
|
+
"",
|
|
491
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST)),
|
|
492
|
+
);
|
|
493
|
+
expect(res.status).toBe(400);
|
|
494
|
+
expect(((await res.json()) as { error: string }).error).toBe("unknown_vault");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ===========================================================================
|
|
499
|
+
// POST — GENERAL provision (synthetic widget sink — proves no channel-hardcoding)
|
|
500
|
+
// ===========================================================================
|
|
501
|
+
|
|
502
|
+
describe("POST /admin/connections — general vault-trigger provision", () => {
|
|
503
|
+
test("derives webhook + scope from the SINK ACTION declaration (not hardcoded)", async () => {
|
|
504
|
+
const { cookie } = await adminCookie();
|
|
505
|
+
const { fetchImpl, calls } = mockFetch({
|
|
506
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
507
|
+
});
|
|
508
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
509
|
+
method: "POST",
|
|
510
|
+
headers: { cookie },
|
|
511
|
+
body: JSON.stringify({
|
|
512
|
+
id: "w1",
|
|
513
|
+
source: {
|
|
514
|
+
module: "vault",
|
|
515
|
+
vault: "default",
|
|
516
|
+
event: "note.updated",
|
|
517
|
+
filter: { tags: ["#urgent"], has_metadata: ["owner"] },
|
|
518
|
+
},
|
|
519
|
+
sink: { module: "widget", action: "thing.do" },
|
|
520
|
+
}),
|
|
521
|
+
});
|
|
522
|
+
const res = await handleConnections(
|
|
523
|
+
req,
|
|
524
|
+
"",
|
|
525
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST)),
|
|
526
|
+
);
|
|
527
|
+
expect(res.status).toBe(200);
|
|
528
|
+
|
|
529
|
+
const trigCall = calls.find((c) => c.url.endsWith("/vault/default/api/triggers"));
|
|
530
|
+
expect(trigCall).toBeDefined();
|
|
531
|
+
const trig = trigCall!.body as {
|
|
532
|
+
name: string;
|
|
533
|
+
events: string[];
|
|
534
|
+
when: Record<string, unknown>;
|
|
535
|
+
action: { webhook: string; auth: { bearer: string } };
|
|
536
|
+
};
|
|
537
|
+
// Webhook comes from widget's mount + the action's declared endpoint.
|
|
538
|
+
expect(trig.action.webhook).toBe(`${HUB_ORIGIN}/widget/hooks/incoming`);
|
|
539
|
+
// The persisted bearer carries the action's DECLARED scope (widget:trigger),
|
|
540
|
+
// with the audience taken from the scope namespace.
|
|
541
|
+
expect(scopeOf(trig.action.auth.bearer)).toEqual(["widget:trigger"]);
|
|
542
|
+
expect((decodeJwt(trig.action.auth.bearer) as { aud?: string }).aud).toBe("widget");
|
|
543
|
+
// events from the source event verb; when from the filter, 1:1.
|
|
544
|
+
expect(trig.events).toEqual(["updated"]);
|
|
545
|
+
expect(trig.when).toEqual({ tags: ["#urgent"], has_metadata: ["owner"] });
|
|
546
|
+
expect(trig.name).toBe("conn_w1");
|
|
547
|
+
// The trigger-register Authorization bearer is vault:default:admin.
|
|
548
|
+
expect(scopeOf(trigCall!.bearer!)).toEqual(["vault:default:admin"]);
|
|
549
|
+
|
|
550
|
+
// Persisted record carries the provisioned trigger name + vault for teardown.
|
|
551
|
+
const stored = readConnections(harness.storePath);
|
|
552
|
+
expect(stored).toHaveLength(1);
|
|
553
|
+
expect(stored[0]!.id).toBe("w1");
|
|
554
|
+
expect(stored[0]!.provisioned.triggerName).toBe("conn_w1");
|
|
555
|
+
expect(stored[0]!.provisioned.vault).toBe("default");
|
|
556
|
+
// Bearer tokens are NEVER persisted in the record.
|
|
557
|
+
expect(JSON.stringify(stored[0])).not.toContain(trig.action.auth.bearer);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("a failing vault-trigger step → 502 provision_failed naming the step", async () => {
|
|
561
|
+
const { cookie } = await adminCookie();
|
|
562
|
+
const { fetchImpl } = mockFetch({
|
|
563
|
+
"POST /vault/default/api/triggers": () => new Response("boom", { status: 500 }),
|
|
564
|
+
});
|
|
565
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: { cookie },
|
|
568
|
+
body: JSON.stringify({
|
|
569
|
+
id: "w1",
|
|
570
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
571
|
+
sink: { module: "widget", action: "thing.do" },
|
|
572
|
+
}),
|
|
573
|
+
});
|
|
574
|
+
const res = await handleConnections(
|
|
575
|
+
req,
|
|
576
|
+
"",
|
|
577
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST)),
|
|
578
|
+
);
|
|
579
|
+
expect(res.status).toBe(502);
|
|
580
|
+
const out = (await res.json()) as { error: string; step: string };
|
|
581
|
+
expect(out.error).toBe("provision_failed");
|
|
582
|
+
expect(out.step).toBe("vault_trigger");
|
|
583
|
+
// Nothing persisted on failure.
|
|
584
|
+
expect(readConnections(harness.storePath)).toHaveLength(0);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("a module CANNOT declare a cross-namespace action.scope → no escalating bearer (M1)", () => {
|
|
588
|
+
// The webhook bearer is minted at the sink action's DECLARED scope. The only
|
|
589
|
+
// defense against a malicious module declaring `vault:default:admin` and
|
|
590
|
+
// tricking the hub into minting a 90-day cross-module token is the manifest
|
|
591
|
+
// validator's namespace rule — so a manifest carrying such a scope can never
|
|
592
|
+
// reach the engine in the first place. Prove it's rejected at the boundary.
|
|
593
|
+
expect(() =>
|
|
594
|
+
validateModuleManifest(
|
|
595
|
+
{
|
|
596
|
+
name: "widget",
|
|
597
|
+
manifestName: "widget",
|
|
598
|
+
port: 1955,
|
|
599
|
+
paths: ["/widget"],
|
|
600
|
+
health: "/health",
|
|
601
|
+
actions: [
|
|
602
|
+
{
|
|
603
|
+
key: "thing.do",
|
|
604
|
+
title: "Do",
|
|
605
|
+
endpoint: "/hooks/incoming",
|
|
606
|
+
scope: "vault:default:admin", // ← escalation attempt
|
|
607
|
+
provision: { type: "vault-trigger" },
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
},
|
|
611
|
+
"widget/.parachute/module.json",
|
|
612
|
+
),
|
|
613
|
+
).toThrow(/namespace "vault" does not match module name "widget"/);
|
|
614
|
+
// The legitimate widget (own-namespace scope) parses fine, and THAT is what
|
|
615
|
+
// the engine mints from — verified end-to-end in the provision test above
|
|
616
|
+
// (bearer carries `widget:trigger`, never a vault scope).
|
|
617
|
+
const okm = validateModuleManifest(
|
|
618
|
+
{
|
|
619
|
+
name: "widget",
|
|
620
|
+
manifestName: "widget",
|
|
621
|
+
port: 1955,
|
|
622
|
+
paths: ["/widget"],
|
|
623
|
+
health: "/health",
|
|
624
|
+
actions: [
|
|
625
|
+
{ key: "thing.do", title: "Do", endpoint: "/hooks/incoming", scope: "widget:trigger" },
|
|
626
|
+
],
|
|
627
|
+
},
|
|
628
|
+
"widget/.parachute/module.json",
|
|
629
|
+
);
|
|
630
|
+
expect(okm.actions?.[0]?.scope).toBe("widget:trigger");
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ===========================================================================
|
|
635
|
+
// POST — provenance (modular-UI R2, module-initiated connections)
|
|
636
|
+
// ===========================================================================
|
|
637
|
+
|
|
638
|
+
describe("POST /admin/connections — provenance (R2)", () => {
|
|
639
|
+
test("records the requestedBy label a module-owned UI supplies, returns it on GET", async () => {
|
|
640
|
+
const { cookie } = await adminCookie();
|
|
641
|
+
const { fetchImpl } = mockFetch({
|
|
642
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
643
|
+
});
|
|
644
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST));
|
|
645
|
+
const res = await handleConnections(
|
|
646
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
647
|
+
method: "POST",
|
|
648
|
+
headers: { cookie },
|
|
649
|
+
body: JSON.stringify({
|
|
650
|
+
id: "w1",
|
|
651
|
+
requestedBy: "channel",
|
|
652
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
653
|
+
sink: { module: "widget", action: "thing.do" },
|
|
654
|
+
}),
|
|
655
|
+
}),
|
|
656
|
+
"",
|
|
657
|
+
deps,
|
|
658
|
+
);
|
|
659
|
+
expect(res.status).toBe(200);
|
|
660
|
+
// Persisted on the record.
|
|
661
|
+
const stored = readConnections(harness.storePath);
|
|
662
|
+
expect(stored[0]!.requestedBy).toBe("channel");
|
|
663
|
+
// Returned (snake_case) on the GET wire shape.
|
|
664
|
+
const list = await handleConnections(
|
|
665
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, { method: "GET", headers: { cookie } }),
|
|
666
|
+
"",
|
|
667
|
+
deps,
|
|
668
|
+
);
|
|
669
|
+
const out = (await list.json()) as { connections: Array<{ requested_by?: string }> };
|
|
670
|
+
expect(out.connections[0]!.requested_by).toBe("channel");
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("defaults requestedBy to custom when the body omits it", async () => {
|
|
674
|
+
const { cookie } = await adminCookie();
|
|
675
|
+
const { fetchImpl } = mockFetch({
|
|
676
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
677
|
+
});
|
|
678
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST));
|
|
679
|
+
await handleConnections(
|
|
680
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: { cookie },
|
|
683
|
+
body: JSON.stringify({
|
|
684
|
+
id: "w2",
|
|
685
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
686
|
+
sink: { module: "widget", action: "thing.do" },
|
|
687
|
+
}),
|
|
688
|
+
}),
|
|
689
|
+
"",
|
|
690
|
+
deps,
|
|
691
|
+
);
|
|
692
|
+
expect(readConnections(harness.storePath)[0]!.requestedBy).toBe("custom");
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("rejects a non-slug requestedBy with 400 before provisioning", async () => {
|
|
696
|
+
const { cookie } = await adminCookie();
|
|
697
|
+
const { fetchImpl, calls } = mockFetch({
|
|
698
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
699
|
+
});
|
|
700
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST));
|
|
701
|
+
const res = await handleConnections(
|
|
702
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
703
|
+
method: "POST",
|
|
704
|
+
headers: { cookie },
|
|
705
|
+
body: JSON.stringify({
|
|
706
|
+
id: "w3",
|
|
707
|
+
requestedBy: "<script>",
|
|
708
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
709
|
+
sink: { module: "widget", action: "thing.do" },
|
|
710
|
+
}),
|
|
711
|
+
}),
|
|
712
|
+
"",
|
|
713
|
+
deps,
|
|
714
|
+
);
|
|
715
|
+
expect(res.status).toBe(400);
|
|
716
|
+
expect(((await res.json()) as { error: string }).error).toBe("invalid_request");
|
|
717
|
+
// Nothing provisioned, nothing persisted.
|
|
718
|
+
expect(calls.length).toBe(0);
|
|
719
|
+
expect(readConnections(harness.storePath)).toHaveLength(0);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// ===========================================================================
|
|
724
|
+
// POST — channel-backed connection (parity with hub#624)
|
|
725
|
+
// ===========================================================================
|
|
726
|
+
|
|
727
|
+
describe("POST /admin/connections — channel-backed (the #624 flow as a connection)", () => {
|
|
728
|
+
test("provisions channel config + trigger, returns connect lines", async () => {
|
|
729
|
+
const { cookie, userId } = await adminCookie();
|
|
730
|
+
const { fetchImpl, calls } = mockFetch({
|
|
731
|
+
"POST /api/channels": () => ok({ ok: true, name: "eng", transport: "vault" }),
|
|
732
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
733
|
+
});
|
|
734
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
735
|
+
method: "POST",
|
|
736
|
+
headers: { cookie },
|
|
737
|
+
body: JSON.stringify({
|
|
738
|
+
source: {
|
|
739
|
+
module: "vault",
|
|
740
|
+
vault: "default",
|
|
741
|
+
event: "note.created",
|
|
742
|
+
filter: {
|
|
743
|
+
tags: ["#channel-message/inbound"],
|
|
744
|
+
has_metadata: ["channel"],
|
|
745
|
+
missing_metadata: ["channel_inbound_rendered_at"],
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
|
|
749
|
+
}),
|
|
750
|
+
});
|
|
751
|
+
const res = await handleConnections(
|
|
752
|
+
req,
|
|
753
|
+
"",
|
|
754
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST)),
|
|
755
|
+
);
|
|
756
|
+
expect(res.status).toBe(200);
|
|
757
|
+
const out = (await res.json()) as {
|
|
758
|
+
ok: boolean;
|
|
759
|
+
connection: { id: string };
|
|
760
|
+
connect?: { mcpAdd: string; launch: string };
|
|
761
|
+
};
|
|
762
|
+
expect(out.ok).toBe(true);
|
|
763
|
+
// Derived channel id.
|
|
764
|
+
expect(out.connection.id).toBe("channel-eng");
|
|
765
|
+
// Connect lines (parity with #624).
|
|
766
|
+
expect(out.connect?.mcpAdd).toBe(
|
|
767
|
+
`claude mcp add --transport http --scope user channel-eng ${HUB_ORIGIN}/channel/mcp/eng`,
|
|
768
|
+
);
|
|
769
|
+
expect(out.connect?.launch).toContain("server:channel-eng");
|
|
770
|
+
|
|
771
|
+
// Channel config POST: vault transport, loopback vaultUrl, real
|
|
772
|
+
// vault:default:write token, NO webhookSecret.
|
|
773
|
+
const cfgCall = calls.find((c) => c.url.endsWith("/api/channels"));
|
|
774
|
+
expect(cfgCall).toBeDefined();
|
|
775
|
+
const cfgBody = cfgCall!.body as {
|
|
776
|
+
name: string;
|
|
777
|
+
transport: string;
|
|
778
|
+
config: { vault: string; vaultUrl: string; token: string; webhookSecret?: string };
|
|
779
|
+
};
|
|
780
|
+
expect(cfgBody.name).toBe("eng");
|
|
781
|
+
expect(cfgBody.config.vaultUrl).toBe(VAULT_ORIGIN);
|
|
782
|
+
expect(cfgBody.config).not.toHaveProperty("webhookSecret");
|
|
783
|
+
expect(scopeOf(cfgCall!.bearer!)).toEqual(["channel:admin"]);
|
|
784
|
+
expect(scopeOf(cfgBody.config.token)).toEqual(["vault:default:write"]);
|
|
785
|
+
|
|
786
|
+
// Trigger: webhook from channel mount + message.deliver endpoint; bearer
|
|
787
|
+
// carries channel:send; predicate from the filter.
|
|
788
|
+
const trigCall = calls.find((c) => c.url.endsWith("/vault/default/api/triggers"));
|
|
789
|
+
const trig = trigCall!.body as {
|
|
790
|
+
name: string;
|
|
791
|
+
when: Record<string, unknown>;
|
|
792
|
+
action: { webhook: string; auth: { bearer: string } };
|
|
793
|
+
};
|
|
794
|
+
expect(trig.action.webhook).toBe(`${HUB_ORIGIN}/channel/api/vault/inbound`);
|
|
795
|
+
expect(scopeOf(trig.action.auth.bearer)).toEqual(["channel:send"]);
|
|
796
|
+
expect((decodeJwt(trig.action.auth.bearer) as { aud?: string }).aud).toBe("channel");
|
|
797
|
+
expect(trig.when).toEqual({
|
|
798
|
+
tags: ["#channel-message/inbound"],
|
|
799
|
+
has_metadata: ["channel"],
|
|
800
|
+
missing_metadata: ["channel_inbound_rendered_at"],
|
|
801
|
+
});
|
|
802
|
+
expect(trig.name).toBe("conn_channel-eng");
|
|
803
|
+
|
|
804
|
+
// sub = the operator on every minted token.
|
|
805
|
+
expect((decodeJwt(cfgBody.config.token) as { sub?: string }).sub).toBe(userId);
|
|
806
|
+
// No tokens echoed in the response.
|
|
807
|
+
const serialized = JSON.stringify(out);
|
|
808
|
+
expect(serialized).not.toContain(cfgBody.config.token);
|
|
809
|
+
expect(serialized).not.toContain(trig.action.auth.bearer);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("503 when the channel module is not installed (channelOrigin null)", async () => {
|
|
813
|
+
const { cookie } = await adminCookie();
|
|
814
|
+
const { fetchImpl } = mockFetch({});
|
|
815
|
+
const deps = {
|
|
816
|
+
...baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST)),
|
|
817
|
+
channelOrigin: null,
|
|
818
|
+
};
|
|
819
|
+
const req = new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
820
|
+
method: "POST",
|
|
821
|
+
headers: { cookie },
|
|
822
|
+
body: JSON.stringify({
|
|
823
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
824
|
+
sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
|
|
825
|
+
}),
|
|
826
|
+
});
|
|
827
|
+
const res = await handleConnections(req, "", deps);
|
|
828
|
+
expect(res.status).toBe(503);
|
|
829
|
+
expect(((await res.json()) as { error: string }).error).toBe("channel_unavailable");
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// ===========================================================================
|
|
834
|
+
// GET — list
|
|
835
|
+
// ===========================================================================
|
|
836
|
+
|
|
837
|
+
describe("GET /admin/connections — list", () => {
|
|
838
|
+
test("lists persisted connections; never a token", async () => {
|
|
839
|
+
const { cookie } = await adminCookie();
|
|
840
|
+
const { fetchImpl } = mockFetch({
|
|
841
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
842
|
+
});
|
|
843
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST));
|
|
844
|
+
// Create one.
|
|
845
|
+
await handleConnections(
|
|
846
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
847
|
+
method: "POST",
|
|
848
|
+
headers: { cookie },
|
|
849
|
+
body: JSON.stringify({
|
|
850
|
+
id: "w1",
|
|
851
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
852
|
+
sink: { module: "widget", action: "thing.do" },
|
|
853
|
+
}),
|
|
854
|
+
}),
|
|
855
|
+
"",
|
|
856
|
+
deps,
|
|
857
|
+
);
|
|
858
|
+
const res = await handleConnections(
|
|
859
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, { method: "GET", headers: { cookie } }),
|
|
860
|
+
"",
|
|
861
|
+
deps,
|
|
862
|
+
);
|
|
863
|
+
expect(res.status).toBe(200);
|
|
864
|
+
const out = (await res.json()) as {
|
|
865
|
+
ok: boolean;
|
|
866
|
+
connections: Array<{ id: string; source: unknown; sink: unknown }>;
|
|
867
|
+
};
|
|
868
|
+
expect(out.ok).toBe(true);
|
|
869
|
+
expect(out.connections).toHaveLength(1);
|
|
870
|
+
expect(out.connections[0]!.id).toBe("w1");
|
|
871
|
+
expect(JSON.stringify(out)).not.toContain("eyJ"); // no JWT
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// ===========================================================================
|
|
876
|
+
// DELETE — teardown
|
|
877
|
+
// ===========================================================================
|
|
878
|
+
|
|
879
|
+
describe("DELETE /admin/connections/:id — teardown", () => {
|
|
880
|
+
test("tears down the vault trigger + removes the record", async () => {
|
|
881
|
+
const { cookie } = await adminCookie();
|
|
882
|
+
const { fetchImpl, calls } = mockFetch({
|
|
883
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
884
|
+
"DELETE /vault/default/api/triggers/conn_w1": () => ok({ ok: true }),
|
|
885
|
+
});
|
|
886
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST));
|
|
887
|
+
await handleConnections(
|
|
888
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
889
|
+
method: "POST",
|
|
890
|
+
headers: { cookie },
|
|
891
|
+
body: JSON.stringify({
|
|
892
|
+
id: "w1",
|
|
893
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
894
|
+
sink: { module: "widget", action: "thing.do" },
|
|
895
|
+
}),
|
|
896
|
+
}),
|
|
897
|
+
"",
|
|
898
|
+
deps,
|
|
899
|
+
);
|
|
900
|
+
expect(readConnections(harness.storePath)).toHaveLength(1);
|
|
901
|
+
|
|
902
|
+
const res = await handleConnections(
|
|
903
|
+
new Request(`${HUB_ORIGIN}/admin/connections/w1`, { method: "DELETE", headers: { cookie } }),
|
|
904
|
+
"/w1",
|
|
905
|
+
deps,
|
|
906
|
+
);
|
|
907
|
+
expect(res.status).toBe(200);
|
|
908
|
+
expect(((await res.json()) as { ok: boolean }).ok).toBe(true);
|
|
909
|
+
|
|
910
|
+
const trigDel = calls.find(
|
|
911
|
+
(c) => c.method === "DELETE" && c.url.endsWith("/vault/default/api/triggers/conn_w1"),
|
|
912
|
+
);
|
|
913
|
+
expect(trigDel).toBeDefined();
|
|
914
|
+
expect(scopeOf(trigDel!.bearer!)).toEqual(["vault:default:admin"]);
|
|
915
|
+
// Record gone.
|
|
916
|
+
expect(readConnections(harness.storePath)).toHaveLength(0);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test("channel-sink teardown also removes the channel config entry", async () => {
|
|
920
|
+
const { cookie } = await adminCookie();
|
|
921
|
+
const { fetchImpl, calls } = mockFetch({
|
|
922
|
+
"POST /api/channels": () => ok({ ok: true }),
|
|
923
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
924
|
+
"DELETE /api/channels/eng": () => ok({ ok: true }),
|
|
925
|
+
"DELETE /vault/default/api/triggers/conn_channel-eng": () => ok({ ok: true }),
|
|
926
|
+
});
|
|
927
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
|
|
928
|
+
await handleConnections(
|
|
929
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
930
|
+
method: "POST",
|
|
931
|
+
headers: { cookie },
|
|
932
|
+
body: JSON.stringify({
|
|
933
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
934
|
+
sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
|
|
935
|
+
}),
|
|
936
|
+
}),
|
|
937
|
+
"",
|
|
938
|
+
deps,
|
|
939
|
+
);
|
|
940
|
+
const res = await handleConnections(
|
|
941
|
+
new Request(`${HUB_ORIGIN}/admin/connections/channel-eng`, {
|
|
942
|
+
method: "DELETE",
|
|
943
|
+
headers: { cookie },
|
|
944
|
+
}),
|
|
945
|
+
"/channel-eng",
|
|
946
|
+
deps,
|
|
947
|
+
);
|
|
948
|
+
expect(res.status).toBe(200);
|
|
949
|
+
expect(calls.some((c) => c.method === "DELETE" && c.url.endsWith("/api/channels/eng"))).toBe(
|
|
950
|
+
true,
|
|
951
|
+
);
|
|
952
|
+
expect(
|
|
953
|
+
calls.some(
|
|
954
|
+
(c) =>
|
|
955
|
+
c.method === "DELETE" && c.url.endsWith("/vault/default/api/triggers/conn_channel-eng"),
|
|
956
|
+
),
|
|
957
|
+
).toBe(true);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("404 deleting an unknown connection", async () => {
|
|
961
|
+
const { cookie } = await adminCookie();
|
|
962
|
+
const { fetchImpl } = mockFetch({});
|
|
963
|
+
const res = await handleConnections(
|
|
964
|
+
new Request(`${HUB_ORIGIN}/admin/connections/ghost`, {
|
|
965
|
+
method: "DELETE",
|
|
966
|
+
headers: { cookie },
|
|
967
|
+
}),
|
|
968
|
+
"/ghost",
|
|
969
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST)),
|
|
970
|
+
);
|
|
971
|
+
expect(res.status).toBe(404);
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// ===========================================================================
|
|
976
|
+
// B0 — registered connection mints (hub-module-boundary, registered-mint rule)
|
|
977
|
+
// ===========================================================================
|
|
978
|
+
|
|
979
|
+
describe("B0 — registered connection mints", () => {
|
|
980
|
+
/** Create the canonical channel-backed connection; return the long-lived jtis. */
|
|
981
|
+
async function createChannelConnection(
|
|
982
|
+
cookie: string,
|
|
983
|
+
deps: ConnectionsDeps,
|
|
984
|
+
calls: Array<{ method: string; url: string; bearer: string | null; body: unknown }>,
|
|
985
|
+
): Promise<{ replyJti: string; webhookJti: string }> {
|
|
986
|
+
const res = await handleConnections(
|
|
987
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
988
|
+
method: "POST",
|
|
989
|
+
headers: { cookie },
|
|
990
|
+
body: JSON.stringify({
|
|
991
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
992
|
+
sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
|
|
993
|
+
}),
|
|
994
|
+
}),
|
|
995
|
+
"",
|
|
996
|
+
deps,
|
|
997
|
+
);
|
|
998
|
+
expect(res.status).toBe(200);
|
|
999
|
+
const cfgCall = calls.find((c) => c.url.endsWith("/api/channels"));
|
|
1000
|
+
const replyToken = (cfgCall!.body as { config: { token: string } }).config.token;
|
|
1001
|
+
const trigCall = calls.find((c) => c.url.endsWith("/vault/default/api/triggers"));
|
|
1002
|
+
const webhookBearer = (trigCall!.body as { action: { auth: { bearer: string } } }).action.auth
|
|
1003
|
+
.bearer;
|
|
1004
|
+
return {
|
|
1005
|
+
replyJti: (decodeJwt(replyToken) as { jti?: string }).jti!,
|
|
1006
|
+
webhookJti: (decodeJwt(webhookBearer) as { jti?: string }).jti!,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
test("long-lived mints get a connection_provision registry row; jtis persist on the record; short-lived provisioning mints stay unregistered", async () => {
|
|
1011
|
+
const { cookie } = await adminCookie();
|
|
1012
|
+
const { fetchImpl, calls } = mockFetch({
|
|
1013
|
+
"POST /api/channels": () => ok({ ok: true }),
|
|
1014
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
1015
|
+
});
|
|
1016
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
|
|
1017
|
+
const { replyJti, webhookJti } = await createChannelConnection(cookie, deps, calls);
|
|
1018
|
+
|
|
1019
|
+
// Both ~90d tokens are registered with the connection provenance + exact scopes.
|
|
1020
|
+
const replyRow = findTokenRowByJti(harness.db, replyJti);
|
|
1021
|
+
expect(replyRow).not.toBeNull();
|
|
1022
|
+
expect(replyRow!.createdVia).toBe("connection_provision");
|
|
1023
|
+
expect(replyRow!.scopes).toEqual(["vault:default:write"]);
|
|
1024
|
+
expect(replyRow!.revokedAt).toBeNull();
|
|
1025
|
+
const webhookRow = findTokenRowByJti(harness.db, webhookJti);
|
|
1026
|
+
expect(webhookRow).not.toBeNull();
|
|
1027
|
+
expect(webhookRow!.createdVia).toBe("connection_provision");
|
|
1028
|
+
expect(webhookRow!.scopes).toEqual(["channel:send"]);
|
|
1029
|
+
|
|
1030
|
+
// The short-lived (60s) provisioning bearers — vault:<v>:admin on the
|
|
1031
|
+
// trigger POST, channel:admin on the channel-config POST — ride to expiry
|
|
1032
|
+
// by design (the documented ≤10-min unregistered bound). NOT registered.
|
|
1033
|
+
const trigCall = calls.find((c) => c.url.endsWith("/vault/default/api/triggers"));
|
|
1034
|
+
const cfgCall = calls.find((c) => c.url.endsWith("/api/channels"));
|
|
1035
|
+
const trigAuthJti = (decodeJwt(trigCall!.bearer!) as { jti?: string }).jti!;
|
|
1036
|
+
const cfgAuthJti = (decodeJwt(cfgCall!.bearer!) as { jti?: string }).jti!;
|
|
1037
|
+
expect(findTokenRowByJti(harness.db, trigAuthJti)).toBeNull();
|
|
1038
|
+
expect(findTokenRowByJti(harness.db, cfgAuthJti)).toBeNull();
|
|
1039
|
+
|
|
1040
|
+
// The jtis are persisted on the record's provisioned block for teardown.
|
|
1041
|
+
const stored = readConnections(harness.storePath);
|
|
1042
|
+
expect(stored).toHaveLength(1);
|
|
1043
|
+
expect([...(stored[0]!.provisioned.mintedJtis ?? [])].sort()).toEqual(
|
|
1044
|
+
[replyJti, webhookJti].sort(),
|
|
1045
|
+
);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test("teardown revokes the registered jtis → they appear on the revocation list", async () => {
|
|
1049
|
+
const { cookie } = await adminCookie();
|
|
1050
|
+
const { fetchImpl, calls } = mockFetch({
|
|
1051
|
+
"POST /api/channels": () => ok({ ok: true }),
|
|
1052
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
1053
|
+
"DELETE /api/channels/eng": () => ok({ ok: true }),
|
|
1054
|
+
"DELETE /vault/default/api/triggers/conn_channel-eng": () => ok({ ok: true }),
|
|
1055
|
+
});
|
|
1056
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, CHANNEL_MANIFEST));
|
|
1057
|
+
const { replyJti, webhookJti } = await createChannelConnection(cookie, deps, calls);
|
|
1058
|
+
|
|
1059
|
+
const res = await handleConnections(
|
|
1060
|
+
new Request(`${HUB_ORIGIN}/admin/connections/channel-eng`, {
|
|
1061
|
+
method: "DELETE",
|
|
1062
|
+
headers: { cookie },
|
|
1063
|
+
}),
|
|
1064
|
+
"/channel-eng",
|
|
1065
|
+
deps,
|
|
1066
|
+
);
|
|
1067
|
+
expect(res.status).toBe(200);
|
|
1068
|
+
|
|
1069
|
+
// Registry rows flipped to revoked…
|
|
1070
|
+
expect(findTokenRowByJti(harness.db, replyJti)!.revokedAt).not.toBeNull();
|
|
1071
|
+
expect(findTokenRowByJti(harness.db, webhookJti)!.revokedAt).not.toBeNull();
|
|
1072
|
+
// …and the revocation list (what resource servers poll) advertises them.
|
|
1073
|
+
const revoked = listActiveRevocations(harness.db, new Date());
|
|
1074
|
+
expect(revoked).toContain(replyJti);
|
|
1075
|
+
expect(revoked).toContain(webhookJti);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("legacy records (pre-B0, no mintedJtis): teardown proceeds; list surfaces legacy: true", async () => {
|
|
1079
|
+
const { cookie } = await adminCookie();
|
|
1080
|
+
const { fetchImpl, calls } = mockFetch({
|
|
1081
|
+
"POST /vault/default/api/triggers": () => ok({ ok: true }),
|
|
1082
|
+
"DELETE /vault/default/api/triggers/conn_old1": () => ok({ ok: true }),
|
|
1083
|
+
});
|
|
1084
|
+
const deps = baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST, WIDGET_MANIFEST));
|
|
1085
|
+
|
|
1086
|
+
// A record written before B0 — provisioned block has no mintedJtis.
|
|
1087
|
+
putConnection(harness.storePath, {
|
|
1088
|
+
id: "old1",
|
|
1089
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
1090
|
+
sink: { module: "widget", action: "thing.do" },
|
|
1091
|
+
provisioned: { type: "vault-trigger", vault: "default", triggerName: "conn_old1" },
|
|
1092
|
+
createdAt: "2026-06-01T00:00:00.000Z",
|
|
1093
|
+
});
|
|
1094
|
+
// And a new-style one created through the engine.
|
|
1095
|
+
await handleConnections(
|
|
1096
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, {
|
|
1097
|
+
method: "POST",
|
|
1098
|
+
headers: { cookie },
|
|
1099
|
+
body: JSON.stringify({
|
|
1100
|
+
id: "w-new",
|
|
1101
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
1102
|
+
sink: { module: "widget", action: "thing.do" },
|
|
1103
|
+
}),
|
|
1104
|
+
}),
|
|
1105
|
+
"",
|
|
1106
|
+
deps,
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
// List: the legacy record is flagged, the new one is not.
|
|
1110
|
+
const list = await handleConnections(
|
|
1111
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, { method: "GET", headers: { cookie } }),
|
|
1112
|
+
"",
|
|
1113
|
+
deps,
|
|
1114
|
+
);
|
|
1115
|
+
const out = (await list.json()) as { connections: Array<{ id: string; legacy?: boolean }> };
|
|
1116
|
+
expect(out.connections.find((c) => c.id === "old1")!.legacy).toBe(true);
|
|
1117
|
+
expect(out.connections.find((c) => c.id === "w-new")!.legacy).toBeUndefined();
|
|
1118
|
+
|
|
1119
|
+
// Teardown of the legacy record proceeds cleanly (no crash, no revocation
|
|
1120
|
+
// step — its tokens were never registered and ride to expiry).
|
|
1121
|
+
const res = await handleConnections(
|
|
1122
|
+
new Request(`${HUB_ORIGIN}/admin/connections/old1`, {
|
|
1123
|
+
method: "DELETE",
|
|
1124
|
+
headers: { cookie },
|
|
1125
|
+
}),
|
|
1126
|
+
"/old1",
|
|
1127
|
+
deps,
|
|
1128
|
+
);
|
|
1129
|
+
expect(res.status).toBe(200);
|
|
1130
|
+
expect(
|
|
1131
|
+
calls.some(
|
|
1132
|
+
(c) => c.method === "DELETE" && c.url.endsWith("/vault/default/api/triggers/conn_old1"),
|
|
1133
|
+
),
|
|
1134
|
+
).toBe(true);
|
|
1135
|
+
expect(readConnections(harness.storePath).find((r) => r.id === "old1")).toBeUndefined();
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// ===========================================================================
|
|
1140
|
+
// Method guard
|
|
1141
|
+
// ===========================================================================
|
|
1142
|
+
|
|
1143
|
+
describe("method guard", () => {
|
|
1144
|
+
test("405 on PUT /admin/connections", async () => {
|
|
1145
|
+
const { cookie } = await adminCookie();
|
|
1146
|
+
const { fetchImpl } = mockFetch({});
|
|
1147
|
+
const res = await handleConnections(
|
|
1148
|
+
new Request(`${HUB_ORIGIN}/admin/connections`, { method: "PUT", headers: { cookie } }),
|
|
1149
|
+
"",
|
|
1150
|
+
baseDeps(fetchImpl, modulesOf(VAULT_MANIFEST)),
|
|
1151
|
+
);
|
|
1152
|
+
expect(res.status).toBe(405);
|
|
1153
|
+
});
|
|
1154
|
+
});
|