@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
|
@@ -31,6 +31,104 @@ describe("validateModuleManifest", () => {
|
|
|
31
31
|
expect(() => validateModuleManifest([1, 2], "where")).toThrow(/root must be an object/);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
test("parses connectionTemplates (boundary D2 — channel's link-to-vault shape)", () => {
|
|
35
|
+
const m = validateModuleManifest(
|
|
36
|
+
{
|
|
37
|
+
...VALID,
|
|
38
|
+
connectionTemplates: [
|
|
39
|
+
{
|
|
40
|
+
key: "link-to-vault",
|
|
41
|
+
title: "Link a channel to a vault",
|
|
42
|
+
description: "Back a channel with a vault.",
|
|
43
|
+
requestedBy: "channel",
|
|
44
|
+
source: {
|
|
45
|
+
module: "vault",
|
|
46
|
+
event: "note.created",
|
|
47
|
+
filter: { tags: ["#channel-message/inbound"] },
|
|
48
|
+
},
|
|
49
|
+
sink: { module: "channel", action: "message.deliver" },
|
|
50
|
+
parameters: [
|
|
51
|
+
{ key: "vault", target: "source.vault", title: "Vault" },
|
|
52
|
+
{ key: "channel", target: "sink.params.channel", example: "eng" },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
"test",
|
|
58
|
+
);
|
|
59
|
+
const t = m.connectionTemplates?.[0];
|
|
60
|
+
expect(t?.key).toBe("link-to-vault");
|
|
61
|
+
expect(t?.source).toEqual({
|
|
62
|
+
module: "vault",
|
|
63
|
+
event: "note.created",
|
|
64
|
+
filter: { tags: ["#channel-message/inbound"] },
|
|
65
|
+
});
|
|
66
|
+
expect(t?.sink).toEqual({ module: "channel", action: "message.deliver" });
|
|
67
|
+
expect(t?.parameters?.[1]).toEqual({
|
|
68
|
+
key: "channel",
|
|
69
|
+
target: "sink.params.channel",
|
|
70
|
+
example: "eng",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('accepts a source/sink-less config template (scribe\'s kind: "config" shape)', () => {
|
|
75
|
+
// Scribe's real module.json ships a `kind: "config"` template with
|
|
76
|
+
// provider/target instead of source/sink. The validator must NOT reject
|
|
77
|
+
// it (a strict source/sink requirement would make every read of scribe's
|
|
78
|
+
// manifest throw — install, catalog, lifecycle).
|
|
79
|
+
const m = validateModuleManifest(
|
|
80
|
+
{
|
|
81
|
+
...VALID,
|
|
82
|
+
connectionTemplates: [
|
|
83
|
+
{
|
|
84
|
+
key: "link-to-vault",
|
|
85
|
+
kind: "config",
|
|
86
|
+
title: "Auto-transcribe a vault's audio",
|
|
87
|
+
requestedBy: "demo",
|
|
88
|
+
provider: { module: "demo", action: "transcribe" },
|
|
89
|
+
target: { module: "vault", setting: "auto_transcribe.enabled" },
|
|
90
|
+
parameters: [{ key: "vault", target: "target.vault", title: "Vault" }],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
"test",
|
|
95
|
+
);
|
|
96
|
+
const t = m.connectionTemplates?.[0];
|
|
97
|
+
expect(t?.kind).toBe("config");
|
|
98
|
+
expect(t?.source).toBeUndefined();
|
|
99
|
+
expect(t?.sink).toBeUndefined();
|
|
100
|
+
expect(t?.parameters?.[0]?.target).toBe("target.vault");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("rejects malformed connectionTemplates", () => {
|
|
104
|
+
expect(() => validateModuleManifest({ ...VALID, connectionTemplates: "x" }, "t")).toThrow(
|
|
105
|
+
/connectionTemplates/,
|
|
106
|
+
);
|
|
107
|
+
expect(() =>
|
|
108
|
+
validateModuleManifest(
|
|
109
|
+
{ ...VALID, connectionTemplates: [{ key: "k", title: "T", source: {}, sink: {} }] },
|
|
110
|
+
"t",
|
|
111
|
+
),
|
|
112
|
+
).toThrow(/source\.module/);
|
|
113
|
+
expect(() =>
|
|
114
|
+
validateModuleManifest(
|
|
115
|
+
{
|
|
116
|
+
...VALID,
|
|
117
|
+
connectionTemplates: [
|
|
118
|
+
{
|
|
119
|
+
key: "k",
|
|
120
|
+
title: "T",
|
|
121
|
+
source: { module: "vault", event: "note.created" },
|
|
122
|
+
sink: { module: "channel", action: "message.deliver" },
|
|
123
|
+
parameters: [{ key: "p" }],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
"t",
|
|
128
|
+
),
|
|
129
|
+
).toThrow(/parameters\[0\]\.target/);
|
|
130
|
+
});
|
|
131
|
+
|
|
34
132
|
test("rejects missing required fields", () => {
|
|
35
133
|
expect(() => validateModuleManifest({ ...VALID, name: undefined }, "x")).toThrow(/name/);
|
|
36
134
|
expect(() => validateModuleManifest({ ...VALID, port: -1 }, "x")).toThrow(/port/);
|
|
@@ -127,19 +225,216 @@ describe("validateModuleManifest", () => {
|
|
|
127
225
|
expect(m.managementUrl).toBe("https://admin.example.com/");
|
|
128
226
|
});
|
|
129
227
|
|
|
130
|
-
test("managementUrl
|
|
228
|
+
test("managementUrl accepts the relative (per-instance) form — B4", () => {
|
|
229
|
+
// Unified URL semantics (2026-06-09 hub-module-boundary, B4): a relative
|
|
230
|
+
// path (no leading slash) is the per-instance form resolvers join under
|
|
231
|
+
// the module's mount. Vault's new manifest declares `"admin/"`.
|
|
232
|
+
expect(validateModuleManifest({ ...VALID, managementUrl: "admin/" }, "x").managementUrl).toBe(
|
|
233
|
+
"admin/",
|
|
234
|
+
);
|
|
235
|
+
expect(
|
|
236
|
+
validateModuleManifest({ ...VALID, managementUrl: "deep/admin" }, "x").managementUrl,
|
|
237
|
+
).toBe("deep/admin");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("managementUrl rejects empty / non-string / bad-scheme / traversal", () => {
|
|
131
241
|
expect(() => validateModuleManifest({ ...VALID, managementUrl: "" }, "x")).toThrow(
|
|
132
242
|
/managementUrl/,
|
|
133
243
|
);
|
|
134
244
|
expect(() => validateModuleManifest({ ...VALID, managementUrl: 7 }, "x")).toThrow(
|
|
135
245
|
/managementUrl/,
|
|
136
246
|
);
|
|
137
|
-
expect(() =>
|
|
138
|
-
validateModuleManifest({ ...VALID, managementUrl: "no-leading-slash" }, "x"),
|
|
139
|
-
).toThrow(/path starting with "\/" or a full http\(s\) URL/);
|
|
140
247
|
expect(() =>
|
|
141
248
|
validateModuleManifest({ ...VALID, managementUrl: "ftp://example.com" }, "x"),
|
|
142
249
|
).toThrow(/http:.*https:/);
|
|
250
|
+
// Scheme-bearing non-URL strings must not smuggle through as "relative".
|
|
251
|
+
expect(() =>
|
|
252
|
+
validateModuleManifest({ ...VALID, managementUrl: "javascript:alert(1)" }, "x"),
|
|
253
|
+
).toThrow(/managementUrl/);
|
|
254
|
+
// The relative form can only deepen its mount — no `..` traversal.
|
|
255
|
+
expect(() =>
|
|
256
|
+
validateModuleManifest({ ...VALID, managementUrl: "../other/admin" }, "x"),
|
|
257
|
+
).toThrow(/\.\./);
|
|
258
|
+
// Backslash-traversal closure: WHATWG URL parsing treats `\` as `/` in
|
|
259
|
+
// http(s) schemes, so `a\..\b` would normalize to `a/../b` post-join and
|
|
260
|
+
// pop a segment past the `..`-segment check. Any backslash → invalid.
|
|
261
|
+
expect(() => validateModuleManifest({ ...VALID, managementUrl: "a\\..\\b" }, "x")).toThrow(
|
|
262
|
+
/backslash/,
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("percent-encoded ..%2f is inert in the relative form (URL base-join doesn't decode)", () => {
|
|
267
|
+
// The validator ACCEPTS `..%2f...` — it isn't a literal ".." segment and
|
|
268
|
+
// needs no guard, because `new URL()` does not decode percent-escapes
|
|
269
|
+
// during base-join: the segment stays the literal "..%2fadmin" and never
|
|
270
|
+
// traverses. Pin the resolution behavior the safety argument rests on.
|
|
271
|
+
const m = validateModuleManifest({ ...VALID, managementUrl: "..%2fadmin" }, "x");
|
|
272
|
+
expect(m.managementUrl).toBe("..%2fadmin");
|
|
273
|
+
const joined = new URL("/vault/default/..%2fadmin", "https://x.example/");
|
|
274
|
+
expect(joined.pathname).toBe("/vault/default/..%2fadmin"); // no segment popped
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// --- 2026-06-09 modular-UI architecture P1 fields: focus / configUiUrl /
|
|
278
|
+
// adminCapabilities / events / actions. All optional + additive. ---
|
|
279
|
+
|
|
280
|
+
test("focus accepts core / experimental and rejects anything else", () => {
|
|
281
|
+
expect(validateModuleManifest({ ...VALID, focus: "core" }, "x").focus).toBe("core");
|
|
282
|
+
expect(validateModuleManifest({ ...VALID, focus: "experimental" }, "x").focus).toBe(
|
|
283
|
+
"experimental",
|
|
284
|
+
);
|
|
285
|
+
// Absent stays absent (hub falls back to its default map downstream).
|
|
286
|
+
expect(validateModuleManifest(VALID, "x").focus).toBeUndefined();
|
|
287
|
+
expect(() => validateModuleManifest({ ...VALID, focus: "headline" }, "x")).toThrow(/focus/);
|
|
288
|
+
expect(() => validateModuleManifest({ ...VALID, focus: 1 }, "x")).toThrow(/focus/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("configUiUrl follows the path-or-url shape (incl. the B4 relative form)", () => {
|
|
292
|
+
expect(
|
|
293
|
+
validateModuleManifest({ ...VALID, configUiUrl: "/scribe/admin" }, "x").configUiUrl,
|
|
294
|
+
).toBe("/scribe/admin");
|
|
295
|
+
expect(
|
|
296
|
+
validateModuleManifest({ ...VALID, configUiUrl: "https://cfg.example.com/" }, "x")
|
|
297
|
+
.configUiUrl,
|
|
298
|
+
).toBe("https://cfg.example.com/");
|
|
299
|
+
// Relative = the per-instance mount-joined form (B4) — valid.
|
|
300
|
+
expect(validateModuleManifest({ ...VALID, configUiUrl: "admin/" }, "x").configUiUrl).toBe(
|
|
301
|
+
"admin/",
|
|
302
|
+
);
|
|
303
|
+
expect(() => validateModuleManifest({ ...VALID, configUiUrl: "//evil.com" }, "x")).toThrow(
|
|
304
|
+
/configUiUrl/,
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("adminCapabilities accepts a string array, rejects non-arrays", () => {
|
|
309
|
+
expect(
|
|
310
|
+
validateModuleManifest({ ...VALID, adminCapabilities: ["config", "logs"] }, "x")
|
|
311
|
+
.adminCapabilities,
|
|
312
|
+
).toEqual(["config", "logs"]);
|
|
313
|
+
expect(() => validateModuleManifest({ ...VALID, adminCapabilities: "config" }, "x")).toThrow(
|
|
314
|
+
/adminCapabilities/,
|
|
315
|
+
);
|
|
316
|
+
expect(() => validateModuleManifest({ ...VALID, adminCapabilities: [1, 2] }, "x")).toThrow(
|
|
317
|
+
/adminCapabilities/,
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("events parse key + title (+ optional filterSchema), reject malformed entries", () => {
|
|
322
|
+
const m = validateModuleManifest(
|
|
323
|
+
{
|
|
324
|
+
...VALID,
|
|
325
|
+
events: [
|
|
326
|
+
{ key: "note.created", title: "Note created", filterSchema: { type: "object" } },
|
|
327
|
+
{ key: "note.deleted", title: "Note deleted" },
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
"x",
|
|
331
|
+
);
|
|
332
|
+
expect(m.events?.map((e) => e.key)).toEqual(["note.created", "note.deleted"]);
|
|
333
|
+
expect(m.events?.[0]?.filterSchema).toEqual({ type: "object" });
|
|
334
|
+
expect(m.events?.[1]?.filterSchema).toBeUndefined();
|
|
335
|
+
expect(() => validateModuleManifest({ ...VALID, events: "nope" }, "x")).toThrow(/events/);
|
|
336
|
+
expect(() => validateModuleManifest({ ...VALID, events: [{ title: "no key" }] }, "x")).toThrow(
|
|
337
|
+
/events\[0\]\.key/,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("actions parse key + title (+ optional inputSchema / provision), reject malformed entries", () => {
|
|
342
|
+
const m = validateModuleManifest(
|
|
343
|
+
{
|
|
344
|
+
...VALID,
|
|
345
|
+
actions: [
|
|
346
|
+
{
|
|
347
|
+
key: "message.send",
|
|
348
|
+
title: "Send message",
|
|
349
|
+
inputSchema: { type: "object" },
|
|
350
|
+
provision: { kind: "vault-trigger" },
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
"x",
|
|
355
|
+
);
|
|
356
|
+
expect(m.actions?.[0]?.key).toBe("message.send");
|
|
357
|
+
expect(m.actions?.[0]?.inputSchema).toEqual({ type: "object" });
|
|
358
|
+
expect(m.actions?.[0]?.provision).toEqual({ kind: "vault-trigger" });
|
|
359
|
+
expect(() => validateModuleManifest({ ...VALID, actions: [{ key: "x" }] }, "x")).toThrow(
|
|
360
|
+
/actions\[0\]\.title/,
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("actions parse endpoint + scope (the connection-wiring fields, P5)", () => {
|
|
365
|
+
// The scope namespace must match the module name (VALID is "demo"), so use
|
|
366
|
+
// a demo-namespaced scope here; the cross-namespace rejection is exercised
|
|
367
|
+
// in the namespace-guard test below.
|
|
368
|
+
const m = validateModuleManifest(
|
|
369
|
+
{
|
|
370
|
+
...VALID,
|
|
371
|
+
actions: [
|
|
372
|
+
{
|
|
373
|
+
key: "message.deliver",
|
|
374
|
+
title: "Deliver",
|
|
375
|
+
endpoint: "/api/vault/inbound",
|
|
376
|
+
scope: "demo:send",
|
|
377
|
+
provision: { type: "vault-trigger" },
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
},
|
|
381
|
+
"x",
|
|
382
|
+
);
|
|
383
|
+
expect(m.actions?.[0]?.endpoint).toBe("/api/vault/inbound");
|
|
384
|
+
expect(m.actions?.[0]?.scope).toBe("demo:send");
|
|
385
|
+
// endpoint must be a leading-slash path.
|
|
386
|
+
expect(() =>
|
|
387
|
+
validateModuleManifest(
|
|
388
|
+
{ ...VALID, actions: [{ key: "a", title: "A", endpoint: "no-slash" }] },
|
|
389
|
+
"x",
|
|
390
|
+
),
|
|
391
|
+
).toThrow(/actions\[0\]\.endpoint/);
|
|
392
|
+
// scope must be a non-empty string.
|
|
393
|
+
expect(() =>
|
|
394
|
+
validateModuleManifest({ ...VALID, actions: [{ key: "a", title: "A", scope: 7 }] }, "x"),
|
|
395
|
+
).toThrow(/actions\[0\]\.scope/);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("action.scope namespace MUST match the declaring module name (privilege-escalation guard)", () => {
|
|
399
|
+
// A malicious manifest named "widget" declaring an action scoped to ANOTHER
|
|
400
|
+
// module's namespace (vault:default:admin) would trick the hub into minting a
|
|
401
|
+
// 90-day cross-module bearer when an operator wires a Connection to it.
|
|
402
|
+
// Reject it at validation, mirroring the `scopes.defines` namespace rule.
|
|
403
|
+
expect(() =>
|
|
404
|
+
validateModuleManifest(
|
|
405
|
+
{
|
|
406
|
+
...VALID,
|
|
407
|
+
name: "widget",
|
|
408
|
+
actions: [{ key: "thing.do", title: "Do", scope: "vault:default:admin" }],
|
|
409
|
+
},
|
|
410
|
+
"x",
|
|
411
|
+
),
|
|
412
|
+
).toThrow(/namespace "vault" does not match module name "widget"/);
|
|
413
|
+
// An un-namespaced scope is rejected too.
|
|
414
|
+
expect(() =>
|
|
415
|
+
validateModuleManifest(
|
|
416
|
+
{ ...VALID, name: "widget", actions: [{ key: "a", title: "A", scope: "bare" }] },
|
|
417
|
+
"x",
|
|
418
|
+
),
|
|
419
|
+
).toThrow(/must be namespaced as "<name>:<verb>"/);
|
|
420
|
+
// A matching-namespace scope still validates — the real channel case
|
|
421
|
+
// (channel.message.deliver → channel:send).
|
|
422
|
+
const okm = validateModuleManifest(
|
|
423
|
+
{
|
|
424
|
+
...VALID,
|
|
425
|
+
name: "channel",
|
|
426
|
+
actions: [
|
|
427
|
+
{
|
|
428
|
+
key: "message.deliver",
|
|
429
|
+
title: "Deliver",
|
|
430
|
+
endpoint: "/api/vault/inbound",
|
|
431
|
+
scope: "channel:send",
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
},
|
|
435
|
+
"x",
|
|
436
|
+
);
|
|
437
|
+
expect(okm.actions?.[0]?.scope).toBe("channel:send");
|
|
143
438
|
});
|
|
144
439
|
|
|
145
440
|
test("uiUrl accepts a leading-slash path (Phase D)", () => {
|
|
@@ -152,15 +447,17 @@ describe("validateModuleManifest", () => {
|
|
|
152
447
|
expect(m.uiUrl).toBe("https://app.example.com/");
|
|
153
448
|
});
|
|
154
449
|
|
|
155
|
-
test("uiUrl
|
|
450
|
+
test("uiUrl accepts the relative (per-instance) form — B4 (mirrors managementUrl)", () => {
|
|
451
|
+
expect(validateModuleManifest({ ...VALID, uiUrl: "admin/" }, "x").uiUrl).toBe("admin/");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("uiUrl rejects empty / non-string / bad-scheme / traversal (mirrors managementUrl)", () => {
|
|
156
455
|
expect(() => validateModuleManifest({ ...VALID, uiUrl: "" }, "x")).toThrow(/uiUrl/);
|
|
157
456
|
expect(() => validateModuleManifest({ ...VALID, uiUrl: 7 }, "x")).toThrow(/uiUrl/);
|
|
158
|
-
expect(() => validateModuleManifest({ ...VALID, uiUrl: "no-slash" }, "x")).toThrow(
|
|
159
|
-
/path starting with "\/" or a full http\(s\) URL/,
|
|
160
|
-
);
|
|
161
457
|
expect(() => validateModuleManifest({ ...VALID, uiUrl: "ftp://example.com" }, "x")).toThrow(
|
|
162
458
|
/http:.*https:/,
|
|
163
459
|
);
|
|
460
|
+
expect(() => validateModuleManifest({ ...VALID, uiUrl: "../sneaky" }, "x")).toThrow(/\.\./);
|
|
164
461
|
});
|
|
165
462
|
|
|
166
463
|
test("uiUrl absent stays absent", () => {
|
|
@@ -2,8 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
bootSupervisedModules,
|
|
7
|
+
buildModuleSpawnRequest,
|
|
8
|
+
reconcilePortToCanonical,
|
|
9
|
+
} from "../commands/serve-boot.ts";
|
|
10
|
+
import { type ServiceEntry, readManifestLenient, writeManifest } from "../services-manifest.ts";
|
|
7
11
|
import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
|
|
8
12
|
|
|
9
13
|
interface Harness {
|
|
@@ -285,3 +289,130 @@ describe("bootSupervisedModules", () => {
|
|
|
285
289
|
expect(logs.some((l) => l.includes("no startCmd resolvable"))).toBe(true);
|
|
286
290
|
});
|
|
287
291
|
});
|
|
292
|
+
|
|
293
|
+
// channel#41 — a transiently-wrong (drifted) services.json port for a
|
|
294
|
+
// fixed-port first-party module self-perpetuates: the supervisor injects PORT /
|
|
295
|
+
// probes / proxies from that row, so the wrong port strands the module forever.
|
|
296
|
+
// The boot path snaps it back to canonical before spawn AND persists the fix so
|
|
297
|
+
// the reverse-proxy (which reads services.json) routes correctly.
|
|
298
|
+
describe("reconcilePortToCanonical (channel#41)", () => {
|
|
299
|
+
let h: Harness;
|
|
300
|
+
beforeEach(() => {
|
|
301
|
+
h = makeHarness();
|
|
302
|
+
});
|
|
303
|
+
afterEach(() => h.cleanup());
|
|
304
|
+
|
|
305
|
+
// The live-observed signature: channel's row carried 19415 instead of its
|
|
306
|
+
// canonical 1941.
|
|
307
|
+
const DRIFTED_CHANNEL: ServiceEntry = {
|
|
308
|
+
name: "parachute-channel",
|
|
309
|
+
port: 19415,
|
|
310
|
+
paths: ["/channel"],
|
|
311
|
+
health: "/health",
|
|
312
|
+
version: "0.1.0",
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
test("snaps a drifted fixed-port row back to canonical + persists it", () => {
|
|
316
|
+
writeManifest({ services: [DRIFTED_CHANNEL] }, h.manifestPath);
|
|
317
|
+
const logs: string[] = [];
|
|
318
|
+
|
|
319
|
+
const reconciled = reconcilePortToCanonical(DRIFTED_CHANNEL, h.manifestPath, (l) =>
|
|
320
|
+
logs.push(l),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Returned entry carries canonical (channel → 1941).
|
|
324
|
+
expect(reconciled.port).toBe(1941);
|
|
325
|
+
// And it's PERSISTED — the proxy reads services.json, so the row itself must
|
|
326
|
+
// now point at 1941 or `/channel/*` keeps routing to the dead 19415.
|
|
327
|
+
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
328
|
+
(s) => s.name === "parachute-channel",
|
|
329
|
+
);
|
|
330
|
+
expect(onDisk?.port).toBe(1941);
|
|
331
|
+
expect(
|
|
332
|
+
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1941")),
|
|
333
|
+
).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("no-op when the row already sits on its canonical port (vault/scribe/surface common path)", () => {
|
|
337
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
338
|
+
const out = reconcilePortToCanonical(VAULT_ENTRY, h.manifestPath);
|
|
339
|
+
expect(out).toBe(VAULT_ENTRY); // identity — untouched
|
|
340
|
+
expect(out.port).toBe(1940);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("third-party module (no canonical) is never touched", () => {
|
|
344
|
+
const thirdParty: ServiceEntry = {
|
|
345
|
+
name: "third-party-thing",
|
|
346
|
+
port: 7777,
|
|
347
|
+
paths: ["/thing"],
|
|
348
|
+
health: "/thing/health",
|
|
349
|
+
version: "1.0.0",
|
|
350
|
+
};
|
|
351
|
+
writeManifest({ services: [thirdParty] }, h.manifestPath);
|
|
352
|
+
const out = reconcilePortToCanonical(thirdParty, h.manifestPath);
|
|
353
|
+
expect(out).toBe(thirdParty);
|
|
354
|
+
expect(out.port).toBe(7777);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("does NOT steal the canonical port when another row already holds it", () => {
|
|
358
|
+
// Another row legitimately occupies 1941 — reconciling would trip the
|
|
359
|
+
// write-side duplicate-port guard and isn't channel's to take. Leave the
|
|
360
|
+
// drift; the supervisor's squatter detection surfaces it.
|
|
361
|
+
const squatter: ServiceEntry = {
|
|
362
|
+
name: "parachute-vault",
|
|
363
|
+
port: 1941, // unusual, but it owns this slot right now
|
|
364
|
+
paths: ["/vault/default"],
|
|
365
|
+
health: "/vault/default/health",
|
|
366
|
+
version: "0.4.5",
|
|
367
|
+
};
|
|
368
|
+
writeManifest({ services: [squatter, DRIFTED_CHANNEL] }, h.manifestPath);
|
|
369
|
+
const logs: string[] = [];
|
|
370
|
+
|
|
371
|
+
const out = reconcilePortToCanonical(DRIFTED_CHANNEL, h.manifestPath, (l) => logs.push(l));
|
|
372
|
+
|
|
373
|
+
expect(out.port).toBe(19415); // unchanged
|
|
374
|
+
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
375
|
+
(s) => s.name === "parachute-channel",
|
|
376
|
+
);
|
|
377
|
+
expect(onDisk?.port).toBe(19415); // not rewritten
|
|
378
|
+
expect(logs.some((l) => l.includes("held by another row"))).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("boot path injects PORT=canonical + persists the fix for a drifted channel row", async () => {
|
|
382
|
+
writeManifest({ services: [DRIFTED_CHANNEL] }, h.manifestPath);
|
|
383
|
+
const recorder = makeRecorder();
|
|
384
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
385
|
+
|
|
386
|
+
await bootSupervisedModules(sup, {
|
|
387
|
+
manifestPath: h.manifestPath,
|
|
388
|
+
configDir: h.dir,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// The supervisor child gets PORT=1941 (so it binds + the readiness probe
|
|
392
|
+
// checks the right port), not the drifted 19415.
|
|
393
|
+
expect(recorder.calls[0]?.short).toBe("channel");
|
|
394
|
+
expect(recorder.calls[0]?.env?.PORT).toBe("1941");
|
|
395
|
+
// services.json row is reconciled → proxy routes /channel/* to 1941.
|
|
396
|
+
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
397
|
+
(s) => s.name === "parachute-channel",
|
|
398
|
+
);
|
|
399
|
+
expect(onDisk?.port).toBe(1941);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("boot path leaves a non-drifted vault row's port untouched", async () => {
|
|
403
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
404
|
+
const recorder = makeRecorder();
|
|
405
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
406
|
+
|
|
407
|
+
await bootSupervisedModules(sup, {
|
|
408
|
+
manifestPath: h.manifestPath,
|
|
409
|
+
configDir: h.dir,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(recorder.calls[0]?.env?.PORT).toBe("1940");
|
|
413
|
+
const onDisk = readManifestLenient(h.manifestPath).services.find(
|
|
414
|
+
(s) => s.name === "parachute-vault",
|
|
415
|
+
);
|
|
416
|
+
expect(onDisk?.port).toBe(1940);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
FIRST_PARTY_FALLBACKS,
|
|
4
|
+
KNOWN_MODULES,
|
|
5
|
+
discoverableShorts,
|
|
6
|
+
findServiceByShort,
|
|
7
|
+
focusForShort,
|
|
8
|
+
isKnownModuleShort,
|
|
9
|
+
} from "../service-spec.ts";
|
|
10
|
+
|
|
11
|
+
// 2026-06-09 modular-UI architecture (P2): discovery is driven by the union of
|
|
12
|
+
// the bootstrap registries + self-registration, NOT a CURATED_MODULES
|
|
13
|
+
// whitelist. These helpers are the seam.
|
|
14
|
+
|
|
15
|
+
describe("discoverableShorts", () => {
|
|
16
|
+
test("is the deduped union of FIRST_PARTY_FALLBACKS ∪ KNOWN_MODULES", () => {
|
|
17
|
+
const shorts = discoverableShorts();
|
|
18
|
+
const expected = new Set([
|
|
19
|
+
...Object.keys(FIRST_PARTY_FALLBACKS),
|
|
20
|
+
...Object.keys(KNOWN_MODULES),
|
|
21
|
+
]);
|
|
22
|
+
expect(new Set(shorts)).toEqual(expected);
|
|
23
|
+
// No duplicates.
|
|
24
|
+
expect(shorts.length).toBe(new Set(shorts).size);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("includes channel (the module the whitelist used to hide) + the core set", () => {
|
|
28
|
+
const shorts = discoverableShorts();
|
|
29
|
+
for (const s of ["vault", "scribe", "surface", "runner", "channel", "notes"]) {
|
|
30
|
+
expect(shorts).toContain(s);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("FIRST_PARTY_FALLBACKS shorts lead KNOWN_MODULES shorts (registry order)", () => {
|
|
35
|
+
const shorts = discoverableShorts();
|
|
36
|
+
// notes (the remaining FALLBACK — channel moved to KNOWN_MODULES in
|
|
37
|
+
// boundary D3) appears before vault (KNOWN_MODULES) in the union.
|
|
38
|
+
expect(shorts.indexOf("notes")).toBeLessThan(shorts.indexOf("vault"));
|
|
39
|
+
// channel rides in KNOWN_MODULES now but is still discoverable.
|
|
40
|
+
expect(shorts).toContain("channel");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("focusForShort", () => {
|
|
45
|
+
test("declared focus wins over the default map", () => {
|
|
46
|
+
// channel defaults experimental, but a manifest-declared core wins.
|
|
47
|
+
expect(focusForShort("channel", "core")).toBe("core");
|
|
48
|
+
// vault defaults core, but a declared experimental wins.
|
|
49
|
+
expect(focusForShort("vault", "experimental")).toBe("experimental");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("falls back to the default tier map when undeclared", () => {
|
|
53
|
+
expect(focusForShort("vault")).toBe("core");
|
|
54
|
+
expect(focusForShort("scribe")).toBe("core");
|
|
55
|
+
expect(focusForShort("surface")).toBe("core");
|
|
56
|
+
expect(focusForShort("channel")).toBe("experimental");
|
|
57
|
+
expect(focusForShort("runner")).toBe("experimental");
|
|
58
|
+
expect(focusForShort("notes")).toBe("experimental");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("unlisted shorts default to experimental", () => {
|
|
62
|
+
expect(focusForShort("some-third-party-module")).toBe("experimental");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("isKnownModuleShort", () => {
|
|
67
|
+
test("true for every known module (the install/config gate)", () => {
|
|
68
|
+
for (const s of ["vault", "scribe", "surface", "runner", "channel", "notes"]) {
|
|
69
|
+
expect(isKnownModuleShort(s)).toBe(true);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("false for the hub itself + genuinely third-party shorts", () => {
|
|
74
|
+
expect(isKnownModuleShort("hub")).toBe(false);
|
|
75
|
+
expect(isKnownModuleShort("random")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Regression: services.json rows carry the MANIFEST name (`parachute-channel`),
|
|
80
|
+
// not the bare short (`channel`). The connection/channels wiring used to do
|
|
81
|
+
// `services.find((s) => s.name === "channel")`, which never matched the on-disk
|
|
82
|
+
// row → channelOrigin null → a spurious "channel module is not installed" when
|
|
83
|
+
// linking a vault-backed channel. findServiceByShort resolves through the
|
|
84
|
+
// short↔manifest map so the lookup hits the real row.
|
|
85
|
+
describe("findServiceByShort", () => {
|
|
86
|
+
const services = [
|
|
87
|
+
{ name: "parachute-vault-default", port: 1940 },
|
|
88
|
+
{ name: "parachute-channel", port: 1941 },
|
|
89
|
+
{ name: "parachute-scribe", port: 1943 },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
test("matches a row by its manifest name via the short↔manifest map", () => {
|
|
93
|
+
const found = findServiceByShort(services, "channel");
|
|
94
|
+
expect(found?.name).toBe("parachute-channel");
|
|
95
|
+
expect(found?.port).toBe(1941);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("the naive `name === short` comparison would have missed it (the bug)", () => {
|
|
99
|
+
// The exact pre-fix predicate: a bare short never matches a manifest-named row.
|
|
100
|
+
expect(services.find((s) => s.name === "channel")).toBeUndefined();
|
|
101
|
+
// The fix finds it.
|
|
102
|
+
expect(findServiceByShort(services, "channel")).toBeDefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("resolves scribe too, and returns undefined for an absent module", () => {
|
|
106
|
+
expect(findServiceByShort(services, "scribe")?.port).toBe(1943);
|
|
107
|
+
expect(findServiceByShort(services, "runner")).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -257,7 +257,7 @@ describe("setup gate (admin exists)", () => {
|
|
|
257
257
|
}
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
-
test("/
|
|
260
|
+
test("/ redirects to the admin shell (NOT the setup funnel) when admin + vault both exist", async () => {
|
|
261
261
|
const db = openHubDb(hubDbPath(h.dir));
|
|
262
262
|
try {
|
|
263
263
|
await createUser(db, "owner", "pw");
|
|
@@ -275,14 +275,20 @@ describe("setup gate (admin exists)", () => {
|
|
|
275
275
|
},
|
|
276
276
|
join(h.dir, "services.json"),
|
|
277
277
|
);
|
|
278
|
-
const
|
|
278
|
+
const handler = hubFetch(h.dir, {
|
|
279
279
|
getDb: () => db,
|
|
280
280
|
manifestPath: join(h.dir, "services.json"),
|
|
281
|
-
})
|
|
282
|
-
//
|
|
283
|
-
// wizard
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
});
|
|
282
|
+
// Setup complete: bare `/` lands on the admin shell (302 → /admin), NOT
|
|
283
|
+
// the wizard funnel (302 → /admin/setup) and NOT the old 200-discovery.
|
|
284
|
+
// The discovery content moved into the shell's Home overview (R1).
|
|
285
|
+
const res = await handler(req("/"));
|
|
286
|
+
expect(res.status).toBe(302);
|
|
287
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
288
|
+
// The discovery page itself still lives at /hub.html.
|
|
289
|
+
const hubHtmlRes = await handler(req("/hub.html"));
|
|
290
|
+
expect(hubHtmlRes.status).toBe(200);
|
|
291
|
+
expect(hubHtmlRes.headers.get("content-type")).toContain("text/html");
|
|
286
292
|
} finally {
|
|
287
293
|
db.close();
|
|
288
294
|
}
|