@openparachute/hub 0.5.14-rc.1 → 0.5.14-rc.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.1",
3
+ "version": "0.5.14-rc.3",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -22,7 +22,7 @@ describe("renderAccountHome", () => {
22
22
  test("assigned-vault branch — Notes CTA carries the encoded hub+vault URL", () => {
23
23
  const html = renderAccountHome({
24
24
  username: "alice",
25
- assignedVault: "alice",
25
+ assignedVaults: ["alice"],
26
26
  passwordChanged: true,
27
27
  hubOrigin: HUB_ORIGIN,
28
28
  isFirstAdmin: false,
@@ -49,7 +49,7 @@ describe("renderAccountHome", () => {
49
49
  // renderer must produce a clean `/vault/<name>` join either way.
50
50
  const html = renderAccountHome({
51
51
  username: "alice",
52
- assignedVault: "alice",
52
+ assignedVaults: ["alice"],
53
53
  passwordChanged: true,
54
54
  hubOrigin: `${HUB_ORIGIN}/`,
55
55
  isFirstAdmin: false,
@@ -65,7 +65,7 @@ describe("renderAccountHome", () => {
65
65
  test("admin branch — null assignedVault + isFirstAdmin renders an /admin/ link", () => {
66
66
  const html = renderAccountHome({
67
67
  username: "admin",
68
- assignedVault: null,
68
+ assignedVaults: [],
69
69
  passwordChanged: true,
70
70
  hubOrigin: HUB_ORIGIN,
71
71
  isFirstAdmin: true,
@@ -85,7 +85,7 @@ describe("renderAccountHome", () => {
85
85
  // state via hand-edit or migration race.
86
86
  const html = renderAccountHome({
87
87
  username: "ghost",
88
- assignedVault: null,
88
+ assignedVaults: [],
89
89
  passwordChanged: true,
90
90
  hubOrigin: HUB_ORIGIN,
91
91
  isFirstAdmin: false,
@@ -102,7 +102,7 @@ describe("renderAccountHome", () => {
102
102
  test("account card — change-password link and sign-out form are present", () => {
103
103
  const html = renderAccountHome({
104
104
  username: "alice",
105
- assignedVault: "alice",
105
+ assignedVaults: ["alice"],
106
106
  passwordChanged: true,
107
107
  hubOrigin: HUB_ORIGIN,
108
108
  isFirstAdmin: false,
@@ -120,6 +120,29 @@ describe("renderAccountHome", () => {
120
120
  expect(html).toContain("<code>alice</code>");
121
121
  });
122
122
 
123
+ test("multi-vault branch — renders one tile per assigned vault (Phase 2 PR 2)", () => {
124
+ const html = renderAccountHome({
125
+ username: "alice",
126
+ assignedVaults: ["personal", "family"],
127
+ passwordChanged: true,
128
+ hubOrigin: HUB_ORIGIN,
129
+ isFirstAdmin: false,
130
+ csrfToken: CSRF,
131
+ });
132
+ // Plural heading.
133
+ expect(html).toContain("Your vaults");
134
+ // Each vault name appears.
135
+ expect(html).toContain("<strong>personal</strong>");
136
+ expect(html).toContain("<strong>family</strong>");
137
+ // One CTA per vault with the right encoded URL.
138
+ const personalEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/personal`);
139
+ const familyEncoded = encodeURIComponent(`${HUB_ORIGIN}/vault/family`);
140
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${personalEncoded}`);
141
+ expect(html).toContain(`https://notes.parachute.computer/add?url=${familyEncoded}`);
142
+ // Hub origin block appears once at the section level, not per tile.
143
+ expect(html.split(`<code>${HUB_ORIGIN}</code>`).length - 1).toBe(1);
144
+ });
145
+
123
146
  test("escapes hostile content in username and vault name", () => {
124
147
  // Defense-in-depth: usernames pass validateUsername (lowercase alnum
125
148
  // + `_-`), so HTML metacharacters won't normally make it through. But
@@ -127,7 +150,7 @@ describe("renderAccountHome", () => {
127
150
  // escape is load-bearing if the validator ever loosens.
128
151
  const html = renderAccountHome({
129
152
  username: "<script>",
130
- assignedVault: "<vault>",
153
+ assignedVaults: ["<vault>"],
131
154
  passwordChanged: true,
132
155
  hubOrigin: HUB_ORIGIN,
133
156
  isFirstAdmin: false,
@@ -160,7 +160,7 @@ describe("handleVaultAdminToken", () => {
160
160
  // whole reason this gate exists.
161
161
  await createUser(harness.db, "operator", "hunter2-admin");
162
162
  const friend = await createUser(harness.db, "friend", "hunter2-friend", {
163
- assignedVault: "work",
163
+ assignedVaults: ["work"],
164
164
  allowMulti: true,
165
165
  });
166
166
  const session = createSession(harness.db, { userId: friend.id });
@@ -182,7 +182,7 @@ describe("handleVaultAdminToken", () => {
182
182
  // happy path when there are friends in the DB.
183
183
  const admin = await createUser(harness.db, "operator", "hunter2-admin");
184
184
  await createUser(harness.db, "friend", "hunter2-friend", {
185
- assignedVault: "work",
185
+ assignedVaults: ["work"],
186
186
  allowMulti: true,
187
187
  });
188
188
  const session = createSession(harness.db, { userId: admin.id });
@@ -757,7 +757,7 @@ describe("handleAccountHomeGet", () => {
757
757
  const friend = await createUser(harness.db, "alice", "alice-passphrase", {
758
758
  allowMulti: true,
759
759
  passwordChanged: true,
760
- assignedVault: "alice",
760
+ assignedVaults: ["alice"],
761
761
  });
762
762
  const session = createSession(harness.db, { userId: friend.id });
763
763
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
@@ -774,7 +774,7 @@ describe("handleAccountHomeGet", () => {
774
774
  expect(html).toContain(`https://notes.parachute.computer/add?url=${encoded}`);
775
775
  });
776
776
 
777
- test("200 + admin branch when the first-admin signs in (assignedVault=null)", async () => {
777
+ test("200 + admin branch when the first-admin signs in (no vault assignments)", async () => {
778
778
  // The first-created user with no vault pin is the admin posture.
779
779
  const admin = await createUser(harness.db, "admin", "admin-passphrase", {
780
780
  passwordChanged: true,
@@ -23,6 +23,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
23
23
  import { tmpdir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { decodeJwt } from "jose";
26
+ import type { CuratedModuleShort } from "../api-modules.ts";
26
27
  import {
27
28
  API_MODULES_CONFIG_REQUIRED_SCOPE,
28
29
  MODULE_CONFIG_PROXY_CLIENT_ID,
@@ -152,14 +153,19 @@ describe("parseModulesConfigPath", () => {
152
153
  });
153
154
  });
154
155
 
155
- test("matches vault and notes (curated modules)", () => {
156
+ test("matches vault and scribe (curated modules)", () => {
156
157
  expect(parseModulesConfigPath("/api/modules/vault/config")?.short).toBe("vault");
157
- expect(parseModulesConfigPath("/api/modules/notes/config/schema")?.short).toBe("notes");
158
+ expect(parseModulesConfigPath("/api/modules/scribe/config/schema")?.short).toBe("scribe");
158
159
  });
159
160
 
160
161
  test("rejects unknown short (non-curated)", () => {
161
162
  expect(parseModulesConfigPath("/api/modules/unknown/config")).toBeUndefined();
162
163
  expect(parseModulesConfigPath("/api/modules/channel/config")).toBeUndefined();
164
+ // Curated list trimmed 2026-05-27: notes / runner / surface are no
165
+ // longer curated and reject at the parse boundary.
166
+ expect(parseModulesConfigPath("/api/modules/notes/config")).toBeUndefined();
167
+ expect(parseModulesConfigPath("/api/modules/runner/config")).toBeUndefined();
168
+ expect(parseModulesConfigPath("/api/modules/surface/config")).toBeUndefined();
163
169
  });
164
170
 
165
171
  test("rejects non-config suffix shapes", () => {
@@ -302,7 +308,7 @@ describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
302
308
  makeReq("/api/modules/runner/config/schema", {
303
309
  headers: { authorization: `Bearer ${bearer}` },
304
310
  }),
305
- { short: "runner", suffix: "schema" },
311
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
306
312
  { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath },
307
313
  );
308
314
  expect(res.status).toBe(404);
@@ -365,7 +371,7 @@ describe("handleApiModulesConfig — FALLBACK retirement (hub#310)", () => {
365
371
  makeReq("/api/modules/runner/config/schema", {
366
372
  headers: { authorization: `Bearer ${bearer}` },
367
373
  }),
368
- { short: "runner", suffix: "schema" },
374
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
369
375
  {
370
376
  db: h.db,
371
377
  issuer: ISSUER,
@@ -614,7 +620,7 @@ describe("handleApiModulesConfig — stripPrefix=false (notes-shape)", () => {
614
620
  makeReq("/api/modules/notes/config/schema", {
615
621
  headers: { authorization: `Bearer ${bearer}` },
616
622
  }),
617
- { short: "notes", suffix: "schema" },
623
+ { short: "notes" as CuratedModuleShort, suffix: "schema" },
618
624
  {
619
625
  db: h.db,
620
626
  issuer: ISSUER,
@@ -668,7 +674,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
668
674
  makeReq("/api/modules/runner/config/schema", {
669
675
  headers: { authorization: `Bearer ${bearer}` },
670
676
  }),
671
- { short: "runner", suffix: "schema" },
677
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
672
678
  {
673
679
  db: h.db,
674
680
  issuer: ISSUER,
@@ -700,7 +706,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
700
706
  makeReq("/api/modules/runner/config", {
701
707
  headers: { authorization: `Bearer ${bearer}` },
702
708
  }),
703
- { short: "runner", suffix: "" },
709
+ { short: "runner" as CuratedModuleShort, suffix: "" },
704
710
  {
705
711
  db: h.db,
706
712
  issuer: ISSUER,
@@ -733,7 +739,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
733
739
  },
734
740
  body: JSON.stringify({ intervalSeconds: 120 }),
735
741
  }),
736
- { short: "runner", suffix: "" },
742
+ { short: "runner" as CuratedModuleShort, suffix: "" },
737
743
  {
738
744
  db: h.db,
739
745
  issuer: ISSUER,
@@ -760,7 +766,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
760
766
  makeReq("/api/modules/runner/config", {
761
767
  headers: { authorization: `Bearer ${bearer}` },
762
768
  }),
763
- { short: "runner", suffix: "" },
769
+ { short: "runner" as CuratedModuleShort, suffix: "" },
764
770
  {
765
771
  db: h.db,
766
772
  issuer: ISSUER,
@@ -863,7 +869,7 @@ describe("handleApiModulesConfig — hostsBareParachute (hub#307)", () => {
863
869
  makeReq("/api/modules/runner/config/schema", {
864
870
  headers: { authorization: `Bearer ${bearer}` },
865
871
  }),
866
- { short: "runner", suffix: "schema" },
872
+ { short: "runner" as CuratedModuleShort, suffix: "schema" },
867
873
  {
868
874
  db: h.db,
869
875
  issuer: ISSUER,
@@ -126,6 +126,19 @@ function alwaysOkRun(): {
126
126
  };
127
127
  }
128
128
 
129
+ /**
130
+ * Test default for `isLinked`: assume the package is NOT bun-linked so
131
+ * `runInstall` exercises the `bun add -g` path (which the stubbed runner
132
+ * captures into `calls`). The production default at
133
+ * `src/bun-link.ts` reads the contributor's real `~/.bun/install/global/`
134
+ * symlinks; on Aaron's machine vault/scribe/notes/hub are all linked
135
+ * (the canonical local-dev shape — smoke 2026-05-27 finding 1 caps the
136
+ * fix). Tests asserting "bun add WAS called" must opt out of that leakage
137
+ * by passing this stub. Tests specifically exercising the skip path use
138
+ * an inline `isLinked: () => true` or a per-pkg discriminator.
139
+ */
140
+ const TEST_DEFAULT_NOT_LINKED = (_pkg: string): boolean => false;
141
+
129
142
  describe("parseModulesPath", () => {
130
143
  test("recognizes curated short + action", () => {
131
144
  expect(parseModulesPath("/api/modules/vault/install")).toEqual({
@@ -231,6 +244,7 @@ describe("POST /api/modules/:short/install", () => {
231
244
  configDir: h.dir,
232
245
  supervisor,
233
246
  run,
247
+ isLinked: TEST_DEFAULT_NOT_LINKED,
234
248
  },
235
249
  );
236
250
  expect(res.status).toBe(202);
@@ -280,6 +294,7 @@ describe("POST /api/modules/:short/install", () => {
280
294
  configDir: h.dir,
281
295
  supervisor,
282
296
  run,
297
+ isLinked: TEST_DEFAULT_NOT_LINKED,
283
298
  },
284
299
  );
285
300
  expect(res.status).toBe(202);
@@ -301,6 +316,7 @@ describe("POST /api/modules/:short/install", () => {
301
316
  configDir: h.dir,
302
317
  supervisor,
303
318
  run,
319
+ isLinked: TEST_DEFAULT_NOT_LINKED,
304
320
  },
305
321
  );
306
322
  const op = (await opRes.json()) as { status: string };
@@ -324,6 +340,7 @@ describe("POST /api/modules/:short/install", () => {
324
340
  configDir: h.dir,
325
341
  supervisor,
326
342
  run,
343
+ isLinked: TEST_DEFAULT_NOT_LINKED,
327
344
  },
328
345
  );
329
346
  expect(res.status).toBe(202);
@@ -348,6 +365,7 @@ describe("POST /api/modules/:short/install", () => {
348
365
  configDir: h.dir,
349
366
  supervisor,
350
367
  run,
368
+ isLinked: TEST_DEFAULT_NOT_LINKED,
351
369
  },
352
370
  );
353
371
  await new Promise((r) => setTimeout(r, 10));
@@ -379,6 +397,7 @@ describe("POST /api/modules/:short/install", () => {
379
397
  configDir: h.dir,
380
398
  supervisor,
381
399
  run,
400
+ isLinked: TEST_DEFAULT_NOT_LINKED,
382
401
  },
383
402
  );
384
403
  expect(res.status).toBe(202);
@@ -406,6 +425,7 @@ describe("POST /api/modules/:short/install", () => {
406
425
  configDir: h.dir,
407
426
  supervisor,
408
427
  run,
428
+ isLinked: TEST_DEFAULT_NOT_LINKED,
409
429
  },
410
430
  );
411
431
  await new Promise((r) => setTimeout(r, 10));
@@ -433,6 +453,7 @@ describe("POST /api/modules/:short/install", () => {
433
453
  configDir: h.dir,
434
454
  supervisor,
435
455
  run,
456
+ isLinked: TEST_DEFAULT_NOT_LINKED,
436
457
  },
437
458
  );
438
459
  expect(res.status).toBe(400);
@@ -458,6 +479,7 @@ describe("POST /api/modules/:short/install", () => {
458
479
  configDir: h.dir,
459
480
  supervisor,
460
481
  run,
482
+ isLinked: TEST_DEFAULT_NOT_LINKED,
461
483
  },
462
484
  );
463
485
  await new Promise((r) => setTimeout(r, 10));
@@ -487,6 +509,7 @@ describe("POST /api/modules/:short/install", () => {
487
509
  configDir: h.dir,
488
510
  supervisor,
489
511
  run,
512
+ isLinked: TEST_DEFAULT_NOT_LINKED,
490
513
  },
491
514
  );
492
515
  await new Promise((r) => setTimeout(r, 10));
@@ -524,6 +547,7 @@ describe("POST /api/modules/:short/install", () => {
524
547
  configDir: h.dir,
525
548
  supervisor,
526
549
  run,
550
+ isLinked: TEST_DEFAULT_NOT_LINKED,
527
551
  },
528
552
  );
529
553
  await new Promise((r) => setTimeout(r, 10));
@@ -557,6 +581,7 @@ describe("POST /api/modules/:short/install", () => {
557
581
  configDir: h.dir,
558
582
  supervisor,
559
583
  run,
584
+ isLinked: TEST_DEFAULT_NOT_LINKED,
560
585
  },
561
586
  );
562
587
  expect(res.status).toBe(202);
@@ -583,6 +608,7 @@ describe("POST /api/modules/:short/install", () => {
583
608
  supervisor,
584
609
  run: async () => 1,
585
610
  findGlobalInstall: () => null,
611
+ isLinked: TEST_DEFAULT_NOT_LINKED,
586
612
  };
587
613
  const res = await handleInstall(
588
614
  postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
@@ -602,6 +628,71 @@ describe("POST /api/modules/:short/install", () => {
602
628
  expect(op.status).toBe("failed");
603
629
  expect(op.error).toMatch(/bun add -g exited 1/);
604
630
  });
631
+
632
+ test("skips bun add -g when package is already bun-linked (smoke 2026-05-27 finding 1)", async () => {
633
+ // Smoke finding 1: the wizard's parallel install path was unconditionally
634
+ // invoking `bun add -g <pkg>` even when the package was already linked
635
+ // via `bun link <abspath>` (the standard local-dev shape). At best a
636
+ // wasted ~3s npm round-trip per install; at worst the global bun.lock
637
+ // had unrelated noise and the install failed outright, taking the
638
+ // wizard's vault step with it. Fix: mirror the CLI install path's
639
+ // `isLinked` short-circuit. Regression guard.
640
+ const { supervisor, spawns } = makeIdleSupervisor();
641
+ const { run, calls } = alwaysOkRun();
642
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
643
+ const res = await handleInstall(
644
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
645
+ "vault",
646
+ {
647
+ db: h.db,
648
+ issuer: ISSUER,
649
+ manifestPath: h.manifestPath,
650
+ configDir: h.dir,
651
+ supervisor,
652
+ run,
653
+ // The bug shape: package IS linked locally. Without the
654
+ // short-circuit, runInstall would still call bun add -g.
655
+ isLinked: (pkg) => pkg === "@openparachute/vault",
656
+ },
657
+ );
658
+ expect(res.status).toBe(202);
659
+ await new Promise((r) => setTimeout(r, 10));
660
+
661
+ // The fix: bun add -g was NOT invoked for the linked package.
662
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
663
+ // Downstream of the skip, the seed + spawn still happen — the install
664
+ // op completes successfully against the locally-linked checkout.
665
+ const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
666
+ services: Array<{ name: string }>;
667
+ };
668
+ expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
669
+ expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
670
+ });
671
+
672
+ test("still runs bun add -g when package is NOT bun-linked", async () => {
673
+ // Companion to the above — confirms the short-circuit doesn't
674
+ // unconditionally skip. On a friend's fresh machine (no bun link),
675
+ // bun add -g IS what installs the package from npm. `isLinked: () => false`
676
+ // is the production default behavior for a non-linked package.
677
+ const { supervisor } = makeIdleSupervisor();
678
+ const { run, calls } = alwaysOkRun();
679
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
680
+ await handleInstall(
681
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
682
+ "vault",
683
+ {
684
+ db: h.db,
685
+ issuer: ISSUER,
686
+ manifestPath: h.manifestPath,
687
+ configDir: h.dir,
688
+ supervisor,
689
+ run,
690
+ isLinked: () => false,
691
+ },
692
+ );
693
+ await new Promise((r) => setTimeout(r, 10));
694
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
695
+ });
605
696
  });
606
697
 
607
698
  describe("POST /api/modules/:short/restart", () => {
@@ -682,6 +773,7 @@ describe("POST /api/modules/:short/upgrade", () => {
682
773
  configDir: h.dir,
683
774
  supervisor,
684
775
  run,
776
+ isLinked: TEST_DEFAULT_NOT_LINKED,
685
777
  },
686
778
  );
687
779
  expect(res.status).toBe(202);
@@ -706,6 +798,7 @@ describe("POST /api/modules/:short/upgrade", () => {
706
798
  configDir: h.dir,
707
799
  supervisor,
708
800
  run,
801
+ isLinked: TEST_DEFAULT_NOT_LINKED,
709
802
  },
710
803
  );
711
804
  expect(res.status).toBe(202);
@@ -736,6 +829,7 @@ describe("POST /api/modules/:short/upgrade", () => {
736
829
  configDir: h.dir,
737
830
  supervisor,
738
831
  run,
832
+ isLinked: TEST_DEFAULT_NOT_LINKED,
739
833
  },
740
834
  );
741
835
  await new Promise((r) => setTimeout(r, 10));
@@ -846,6 +940,7 @@ describe("POST /api/modules/:short/uninstall", () => {
846
940
  configDir: h.dir,
847
941
  supervisor,
848
942
  run,
943
+ isLinked: TEST_DEFAULT_NOT_LINKED,
849
944
  },
850
945
  );
851
946
  expect(res.status).toBe(200);
@@ -878,6 +973,7 @@ describe("POST /api/modules/:short/uninstall", () => {
878
973
  configDir: h.dir,
879
974
  supervisor,
880
975
  run,
976
+ isLinked: TEST_DEFAULT_NOT_LINKED,
881
977
  },
882
978
  );
883
979
  expect(res.status).toBe(200);
@@ -1099,6 +1195,7 @@ describe("well-known regen after module ops", () => {
1099
1195
  supervisor,
1100
1196
  run: async () => 1,
1101
1197
  findGlobalInstall: () => null,
1198
+ isLinked: TEST_DEFAULT_NOT_LINKED,
1102
1199
  wellKnownPath: wkPath,
1103
1200
  };
1104
1201
  const res = await handleInstall(