@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.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. 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 rejects empty / non-string / non-url-or-path", () => {
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 rejects empty / non-string / non-url-or-path (mirrors managementUrl)", () => {
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 { bootSupervisedModules, buildModuleSpawnRequest } from "../commands/serve-boot.ts";
6
- import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
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("/ renders the discovery page when admin + vault both exist", async () => {
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 res = await hubFetch(h.dir, {
278
+ const handler = hubFetch(h.dir, {
279
279
  getDb: () => db,
280
280
  manifestPath: join(h.dir, "services.json"),
281
- })(req("/"));
282
- // 200 (the dynamic discovery page) NOT 302 to /admin/setup. The
283
- // wizard's work is done.
284
- expect(res.status).toBe(200);
285
- expect(res.headers.get("content-type")).toContain("text/html");
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
  }