@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
@@ -3158,6 +3158,54 @@ describe("typed vault name (hub#267)", () => {
3158
3158
  }
3159
3159
  });
3160
3160
 
3161
+ test("vault POST rejects every consolidated reserved name (B2h: list, new, assets, admin)", async () => {
3162
+ const db = openHubDb(hubDbPath(h.dir));
3163
+ try {
3164
+ const user = await createUser(db, "owner", "pw");
3165
+ const { createSession } = await import("../sessions.ts");
3166
+ const session = createSession(db, { userId: user.id });
3167
+ const get = handleSetupGet(req("/admin/setup"), {
3168
+ db,
3169
+ manifestPath: h.manifestPath,
3170
+ configDir: h.dir,
3171
+ readExposeStateFn: h.readExposeStateFn,
3172
+ issuer: "https://hub.example",
3173
+ registry: getDefaultOperationsRegistry(),
3174
+ });
3175
+ const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
3176
+ for (const name of ["list", "new", "assets", "admin"]) {
3177
+ const post = await handleSetupVaultPost(
3178
+ req("/admin/setup/vault", {
3179
+ method: "POST",
3180
+ body: new URLSearchParams({
3181
+ [CSRF_FIELD_NAME]: csrf,
3182
+ vault_name: name,
3183
+ }).toString(),
3184
+ headers: {
3185
+ "content-type": "application/x-www-form-urlencoded",
3186
+ cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SESSION_COOKIE_NAME}=${session.id}`,
3187
+ },
3188
+ }),
3189
+ {
3190
+ db,
3191
+ manifestPath: h.manifestPath,
3192
+ configDir: h.dir,
3193
+ readExposeStateFn: h.readExposeStateFn,
3194
+ issuer: "https://hub.example",
3195
+ supervisor: makeSupervisor(),
3196
+ registry: getDefaultOperationsRegistry(),
3197
+ },
3198
+ );
3199
+ expect(post.status).toBe(400);
3200
+ const html = await post.text();
3201
+ expect(html).toContain("reserved");
3202
+ expect(getSetting(db, "setup_vault_name")).toBeUndefined();
3203
+ }
3204
+ } finally {
3205
+ db.close();
3206
+ }
3207
+ });
3208
+
3161
3209
  test("vault POST with empty name falls back to 'default' + omits the env override", async () => {
3162
3210
  const db = openHubDb(hubDbPath(h.dir));
3163
3211
  try {
@@ -4350,19 +4398,198 @@ describe("setup-wizard JSON surface (hub#168 Cuts 2/3)", () => {
4350
4398
  expect(body.step).toBe("expose");
4351
4399
  // The skip flag is persisted.
4352
4400
  expect(getSetting(db, "setup_vault_skipped")).toBe("true");
4353
- // deriveWizardState advances past the vault step.
4401
+ // deriveWizardState advances past the vault step — but hasRealVault
4402
+ // stays false (no instance exists; only the skip marker is set). The
4403
+ // distinction is what the re-enterable vault step (B5) keys on.
4354
4404
  const s = deriveWizardState({
4355
4405
  db,
4356
4406
  manifestPath: h.manifestPath,
4357
4407
  readExposeStateFn: h.readExposeStateFn,
4358
4408
  });
4359
4409
  expect(s.hasVault).toBe(true);
4410
+ expect(s.hasRealVault).toBe(false);
4360
4411
  expect(s.step).toBe("expose");
4361
4412
  } finally {
4362
4413
  db.close();
4363
4414
  }
4364
4415
  });
4365
4416
 
4417
+ // --- B5 (2026-06-09 hub-module-boundary): re-enterable vault step --------
4418
+ //
4419
+ // A wizard-skip leaves the vault module installed with zero instances and
4420
+ // `setup_vault_skipped` satisfying hasVault — pre-B5 the create form was
4421
+ // unreachable forever after. The hub-side "create your first vault"
4422
+ // affordances (Home's vault card, the legacy /admin/vaults empty state)
4423
+ // deep-link `/admin/setup?step=vault`, which must re-enter the form.
4424
+
4425
+ test("GET ?step=vault re-enters the create form after a wizard-skip (session-gated)", async () => {
4426
+ const db = openHubDb(hubDbPath(h.dir));
4427
+ try {
4428
+ await createUser(db, "owner", "pw");
4429
+ setSetting(db, "setup_vault_skipped", "true");
4430
+ const { createSession } = await import("../sessions.ts");
4431
+ const user = getUserByUsername(db, "owner");
4432
+ if (!user) throw new Error("user missing");
4433
+ const session = createSession(db, { userId: user.id });
4434
+ const res = handleSetupGet(
4435
+ req("/admin/setup?step=vault", {
4436
+ headers: { cookie: `${SESSION_COOKIE_NAME}=${session.id}` },
4437
+ }),
4438
+ {
4439
+ db,
4440
+ manifestPath: h.manifestPath,
4441
+ configDir: h.dir,
4442
+ readExposeStateFn: h.readExposeStateFn,
4443
+ issuer: "http://127.0.0.1:1939",
4444
+ registry: getDefaultOperationsRegistry(),
4445
+ },
4446
+ );
4447
+ expect(res.status).toBe(200);
4448
+ const html = await res.text();
4449
+ // The vault create/import/skip form — not the expose step the plain
4450
+ // GET would resume at post-skip.
4451
+ expect(html).toContain('action="/admin/setup/vault"');
4452
+ } finally {
4453
+ db.close();
4454
+ }
4455
+ });
4456
+
4457
+ test("GET ?step=vault without a session 302s to /login (post-skip)", async () => {
4458
+ const db = openHubDb(hubDbPath(h.dir));
4459
+ try {
4460
+ await createUser(db, "owner", "pw");
4461
+ setSetting(db, "setup_vault_skipped", "true");
4462
+ const res = handleSetupGet(req("/admin/setup?step=vault"), {
4463
+ db,
4464
+ manifestPath: h.manifestPath,
4465
+ configDir: h.dir,
4466
+ readExposeStateFn: h.readExposeStateFn,
4467
+ issuer: "http://127.0.0.1:1939",
4468
+ registry: getDefaultOperationsRegistry(),
4469
+ });
4470
+ expect(res.status).toBe(302);
4471
+ expect(res.headers.get("location")).toBe(
4472
+ `/login?next=${encodeURIComponent("/admin/setup?step=vault")}`,
4473
+ );
4474
+ } finally {
4475
+ db.close();
4476
+ }
4477
+ });
4478
+
4479
+ test("GET ?step=vault with NO admin falls through to the welcome step (hasAdmin guard)", async () => {
4480
+ // Fresh box, no user rows: the re-entry branch is gated on
4481
+ // `state.hasAdmin` — the param must not jump a brand-new operator past
4482
+ // account creation into a provisioning form (the POST would reject the
4483
+ // session-less submit anyway, but the GET shouldn't render out of order
4484
+ // either).
4485
+ const db = openHubDb(hubDbPath(h.dir));
4486
+ try {
4487
+ const res = handleSetupGet(req("/admin/setup?step=vault"), {
4488
+ db,
4489
+ manifestPath: h.manifestPath,
4490
+ configDir: h.dir,
4491
+ readExposeStateFn: h.readExposeStateFn,
4492
+ issuer: "http://127.0.0.1:1939",
4493
+ registry: getDefaultOperationsRegistry(),
4494
+ });
4495
+ expect(res.status).toBe(200);
4496
+ // The welcome/account form — not the vault create form, not a redirect.
4497
+ const html = await res.text();
4498
+ expect(html).toContain('action="/admin/setup/account"');
4499
+ expect(html).not.toContain('action="/admin/setup/vault"');
4500
+ } finally {
4501
+ db.close();
4502
+ }
4503
+ });
4504
+
4505
+ test("GET ?step=vault is ignored when a real vault instance exists", async () => {
4506
+ const db = openHubDb(hubDbPath(h.dir));
4507
+ try {
4508
+ await createUser(db, "owner", "pw");
4509
+ setSetting(db, "setup_expose_mode", "localhost");
4510
+ writeManifest(
4511
+ {
4512
+ services: [
4513
+ {
4514
+ name: "parachute-vault",
4515
+ version: "0.1.0",
4516
+ port: 1940,
4517
+ paths: ["/vault/default"],
4518
+ health: "/health",
4519
+ },
4520
+ ],
4521
+ },
4522
+ h.manifestPath,
4523
+ );
4524
+ const res = handleSetupGet(req("/admin/setup?step=vault"), {
4525
+ db,
4526
+ manifestPath: h.manifestPath,
4527
+ configDir: h.dir,
4528
+ readExposeStateFn: h.readExposeStateFn,
4529
+ issuer: "http://127.0.0.1:1939",
4530
+ registry: getDefaultOperationsRegistry(),
4531
+ });
4532
+ // Setup is fully complete — the param must not reopen a provisioning
4533
+ // form; the normal completed flow (301 → /login) runs instead.
4534
+ expect(res.status).toBe(301);
4535
+ expect(res.headers.get("location")).toBe("/login");
4536
+ } finally {
4537
+ db.close();
4538
+ }
4539
+ });
4540
+
4541
+ test("vault POST mode=create proceeds after a skip (short-circuit keys on hasRealVault)", async () => {
4542
+ const db = openHubDb(hubDbPath(h.dir));
4543
+ try {
4544
+ await createUser(db, "owner", "pw");
4545
+ setSetting(db, "setup_vault_skipped", "true");
4546
+ // No supervisor in deps: a create that gets PAST the short-circuit hits
4547
+ // the supervisor gate and 503s. Pre-B5 this returned 200
4548
+ // `{ step: "expose", message: "vault already provisioned" }` because the
4549
+ // skip marker satisfied hasVault — the form was a dead end.
4550
+ const baseDeps = {
4551
+ db,
4552
+ manifestPath: h.manifestPath,
4553
+ configDir: h.dir,
4554
+ readExposeStateFn: h.readExposeStateFn,
4555
+ issuer: "http://127.0.0.1:1939",
4556
+ registry: getDefaultOperationsRegistry(),
4557
+ };
4558
+ const getRes = handleSetupGet(
4559
+ req("/admin/setup", { headers: { accept: "application/json" } }),
4560
+ baseDeps,
4561
+ );
4562
+ const csrf = setCookie(getRes, CSRF_COOKIE_NAME) ?? "";
4563
+ const envelope = (await getRes.json()) as { csrfToken: string };
4564
+ const { createSession } = await import("../sessions.ts");
4565
+ const user = getUserByUsername(db, "owner");
4566
+ if (!user) throw new Error("user missing");
4567
+ const session = createSession(db, { userId: user.id });
4568
+ const cookieHeader = `${SESSION_COOKIE_NAME}=${session.id}; ${CSRF_COOKIE_NAME}=${csrf}`;
4569
+ const postRes = await handleSetupVaultPost(
4570
+ req("/admin/setup/vault", {
4571
+ method: "POST",
4572
+ headers: {
4573
+ accept: "application/json",
4574
+ "content-type": "application/json",
4575
+ cookie: cookieHeader,
4576
+ },
4577
+ body: JSON.stringify({
4578
+ [CSRF_FIELD_NAME]: envelope.csrfToken,
4579
+ mode: "create",
4580
+ vault_name: "second-chance",
4581
+ }),
4582
+ }),
4583
+ baseDeps,
4584
+ );
4585
+ expect(postRes.status).toBe(503);
4586
+ const body = (await postRes.json()) as { error: string };
4587
+ expect(body.error).toContain("supervisor");
4588
+ } finally {
4589
+ db.close();
4590
+ }
4591
+ });
4592
+
4366
4593
  test("vault step import mode requires remote_url (400 on empty)", async () => {
4367
4594
  const db = openHubDb(hubDbPath(h.dir));
4368
4595
  try {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import { DEFAULT_VAULT_NAME, validateVaultName } from "../vault-name.ts";
9
+ import { DEFAULT_VAULT_NAME, RESERVED_VAULT_NAMES, validateVaultName } from "../vault-name.ts";
10
10
 
11
11
  describe("validateVaultName", () => {
12
12
  test("accepts lowercase alphanumeric + hyphens/underscores", () => {
@@ -64,10 +64,25 @@ describe("validateVaultName", () => {
64
64
  expect(validateVaultName(" ").ok).toBe(false);
65
65
  });
66
66
 
67
- test("rejects the reserved name 'list' (matches vault's reservation)", () => {
68
- const result = validateVaultName("list");
69
- expect(result.ok).toBe(false);
70
- if (!result.ok) expect(result.error).toContain("reserved");
67
+ test("rejects every consolidated reserved name (B2h: list, new, assets, admin)", () => {
68
+ // One set for every hub edge — wizard, invite redemption, POST /vaults.
69
+ // `list` mirrors vault's CLI reservation; `new`/`assets` shadow SPA
70
+ // routes; `admin` shadows the daemon-level /vault/admin mount (B-route).
71
+ for (const name of ["list", "new", "assets", "admin"]) {
72
+ const result = validateVaultName(name);
73
+ expect(result.ok).toBe(false);
74
+ if (!result.ok) expect(result.error).toContain("reserved");
75
+ }
76
+ });
77
+
78
+ test("near-miss names of the reserved set still pass (admins, listing, my-admin)", () => {
79
+ for (const name of ["admins", "listing", "my-admin", "assets2", "newer"]) {
80
+ expect(validateVaultName(name).ok).toBe(true);
81
+ }
82
+ });
83
+
84
+ test("RESERVED_VAULT_NAMES is the consolidated four-name set", () => {
85
+ expect([...RESERVED_VAULT_NAMES].sort()).toEqual(["admin", "assets", "list", "new"]);
71
86
  });
72
87
 
73
88
  test("DEFAULT_VAULT_NAME is 'default'", () => {
@@ -361,26 +361,27 @@ describe("buildWellKnown", () => {
361
361
  expect(notesSvc?.uiUrl).toBe("https://x.example/notes");
362
362
  });
363
363
 
364
- // Workstream C (patterns#96): vault declares `uiUrl: "/admin/"` as a
365
- // per-instance path. buildWellKnown applies the per-instance mount-path
366
- // prefix on emission, yielding one tile per vault instance pointing at
367
- // `<origin>/vault/<name>/admin/`. Non-vault uiUrl behavior is unchanged.
368
- test("vault uiUrl is prefixed with the per-instance mount path (single instance)", () => {
364
+ // B4 unified semantics (2026-06-09 hub-module-boundary): relative
365
+ // (no leading slash) = the per-instance form, mount-joined per vault
366
+ // instance; leading-"/" = origin-absolute pass-through. The literal legacy
367
+ // `"/admin/"` on a vault entry rides the one-release COMPAT SHIM
368
+ // (mount-join + deprecation log) because deployed vaults still declare it.
369
+ test("vault RELATIVE uiUrl mount-joins per instance (B4 per-instance form)", () => {
369
370
  const doc = buildWellKnown({
370
371
  services: [vault],
371
372
  canonicalOrigin: "https://x.example",
372
- uiUrlFor: () => "/admin/",
373
+ uiUrlFor: () => "admin/",
373
374
  });
374
375
  const svc = doc.services.find((s) => s.name === "parachute-vault");
375
376
  expect(svc?.uiUrl).toBe("https://x.example/vault/default/admin/");
376
377
  });
377
378
 
378
- test("vault uiUrl is prefixed per-instance for multi-path vault entries", () => {
379
+ test("vault RELATIVE uiUrl mount-joins per instance for multi-path vault entries", () => {
379
380
  const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
380
381
  const doc = buildWellKnown({
381
382
  services: [multi],
382
383
  canonicalOrigin: "https://x.example",
383
- uiUrlFor: () => "/admin/",
384
+ uiUrlFor: () => "admin/",
384
385
  });
385
386
  const rows = doc.services.filter((s) => s.name === "parachute-vault");
386
387
  expect(rows.length).toBe(2);
@@ -389,6 +390,41 @@ describe("buildWellKnown", () => {
389
390
  expect(uiUrls[1]).toBe("https://x.example/vault/techne/admin/");
390
391
  });
391
392
 
393
+ test('COMPAT SHIM: vault legacy "/admin/" uiUrl still mount-joins per instance (one release)', () => {
394
+ // Deployed vaults declare `uiUrl: "/admin/"` (the OLD per-instance form).
395
+ // Origin-absolute resolution would point every tile at the daemon-level
396
+ // /vault/admin mount — so the literal "/admin"/"/admin/" keeps the old
397
+ // mount-join for one release, with a deprecation log. Remove the shim
398
+ // once vault's new manifest ("admin/") reaches @latest.
399
+ const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
400
+ const doc = buildWellKnown({
401
+ services: [multi],
402
+ canonicalOrigin: "https://x.example",
403
+ uiUrlFor: () => "/admin/",
404
+ });
405
+ const rows = doc.services.filter((s) => s.name === "parachute-vault");
406
+ const uiUrls = rows.map((r) => r.uiUrl).sort();
407
+ expect(uiUrls[0]).toBe("https://x.example/vault/default/admin/");
408
+ expect(uiUrls[1]).toBe("https://x.example/vault/techne/admin/");
409
+ });
410
+
411
+ test("vault LEADING-SLASH uiUrl (non-shim) is origin-absolute pass-through (B4)", () => {
412
+ // The daemon-level surface form: `/vault/admin/` resolves against the
413
+ // origin — NOT per-instance — so a multi-path vault emits the same
414
+ // daemon-level URL on each row.
415
+ const multi: ServiceEntry = { ...vault, paths: ["/vault/default", "/vault/techne"] };
416
+ const doc = buildWellKnown({
417
+ services: [multi],
418
+ canonicalOrigin: "https://x.example",
419
+ uiUrlFor: () => "/vault/admin/",
420
+ });
421
+ const rows = doc.services.filter((s) => s.name === "parachute-vault");
422
+ expect(rows.length).toBe(2);
423
+ for (const r of rows) {
424
+ expect(r.uiUrl).toBe("https://x.example/vault/admin/");
425
+ }
426
+ });
427
+
392
428
  test("vault uiUrl absolute URL still passes through verbatim (no prefix)", () => {
393
429
  const doc = buildWellKnown({
394
430
  services: [vault],