@openparachute/hub 0.5.13 → 0.5.14-rc.10
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/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -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
|
|
156
|
+
test("matches vault and scribe (curated modules)", () => {
|
|
156
157
|
expect(parseModulesConfigPath("/api/modules/vault/config")?.short).toBe("vault");
|
|
157
|
-
expect(parseModulesConfigPath("/api/modules/
|
|
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(
|
|
@@ -149,10 +149,12 @@ describe("GET /api/modules", () => {
|
|
|
149
149
|
|
|
150
150
|
test("200 + curated list on fresh container (empty services.json)", async () => {
|
|
151
151
|
// The v0.6 hot path: brand-new Render container, no services.json
|
|
152
|
-
// yet. UI must render "install vault /
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
152
|
+
// yet. UI must render "install vault / scribe" cards even though
|
|
153
|
+
// nothing's installed. Trimmed 2026-05-27 (Aaron-directed launch
|
|
154
|
+
// focus): notes (notes-daemon), surface (host module), and runner
|
|
155
|
+
// (experimental) are no longer curated — notes.parachute.computer
|
|
156
|
+
// is the hosted PWA, surface-client is the library for custom UI
|
|
157
|
+
// builders, and runner isn't in the launch focus set.
|
|
156
158
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
157
159
|
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
158
160
|
db: h.db,
|
|
@@ -170,8 +172,10 @@ describe("GET /api/modules", () => {
|
|
|
170
172
|
}>;
|
|
171
173
|
supervisor_available: boolean;
|
|
172
174
|
};
|
|
173
|
-
// Curated order is preserved: vault →
|
|
174
|
-
|
|
175
|
+
// Curated order is preserved: vault → scribe (vault first per the
|
|
176
|
+
// recommended install order — the wizard's vault step already runs
|
|
177
|
+
// before this catalog surfaces).
|
|
178
|
+
expect(body.modules.map((m) => m.short)).toEqual(["vault", "scribe"]);
|
|
175
179
|
expect(body.modules.every((m) => m.available)).toBe(true);
|
|
176
180
|
expect(body.modules.every((m) => !m.installed)).toBe(true);
|
|
177
181
|
expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
|
|
@@ -179,18 +183,25 @@ describe("GET /api/modules", () => {
|
|
|
179
183
|
expect(body.supervisor_available).toBe(false);
|
|
180
184
|
});
|
|
181
185
|
|
|
182
|
-
test("
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
186
|
+
test("scribe row carries package + display props from KNOWN_MODULES", async () => {
|
|
187
|
+
// Spot-check the wire shape resolves scribe-specific fields
|
|
188
|
+
// (package, displayName, tagline) from KNOWN_MODULES rather than a
|
|
189
|
+
// stale default. Vault is exercised via the install-state test below;
|
|
190
|
+
// this pins the other curated row's KNOWN_MODULES round-trip.
|
|
191
|
+
//
|
|
192
|
+
// Pre-2026-05-27 this test pinned the `surface` row (added by
|
|
193
|
+
// hub#323), and a sibling pinned the `runner` FIRST_PARTY_FALLBACKS
|
|
194
|
+
// row (hub#305). Both modules retired from CURATED_MODULES — the
|
|
195
|
+
// FIRST_PARTY_FALLBACKS / KNOWN_MODULES entries persist for the
|
|
196
|
+
// install-bootstrap path but `/api/modules` doesn't return them.
|
|
197
|
+
// The "uncurated modules don't surface here" test below pins that
|
|
198
|
+
// boundary.
|
|
188
199
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
189
200
|
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
190
201
|
db: h.db,
|
|
191
202
|
issuer: ISSUER,
|
|
192
203
|
manifestPath: h.manifestPath,
|
|
193
|
-
fetchLatestVersion: async () => "0.
|
|
204
|
+
fetchLatestVersion: async () => "0.4.4",
|
|
194
205
|
});
|
|
195
206
|
expect(res.status).toBe(200);
|
|
196
207
|
const body = (await res.json()) as {
|
|
@@ -202,42 +213,41 @@ describe("GET /api/modules", () => {
|
|
|
202
213
|
available: boolean;
|
|
203
214
|
}>;
|
|
204
215
|
};
|
|
205
|
-
const
|
|
206
|
-
expect(
|
|
207
|
-
expect(
|
|
208
|
-
expect(
|
|
209
|
-
expect(
|
|
210
|
-
expect(
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test("runner
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
216
|
+
const scribe = body.modules.find((m) => m.short === "scribe");
|
|
217
|
+
expect(scribe).toBeDefined();
|
|
218
|
+
expect(scribe?.package).toBe("@openparachute/scribe");
|
|
219
|
+
expect(scribe?.display_name).toBe("Scribe");
|
|
220
|
+
expect(scribe?.tagline).toContain("transcription");
|
|
221
|
+
expect(scribe?.available).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("uncurated modules (notes / runner / surface) are NOT returned by GET /api/modules", async () => {
|
|
225
|
+
// CURATED_MODULES was trimmed 2026-05-27 to [vault, scribe]. The
|
|
226
|
+
// KNOWN_MODULES + FIRST_PARTY_FALLBACKS registries still carry
|
|
227
|
+
// entries for notes / runner (install-bootstrap path), but
|
|
228
|
+
// /api/modules only returns CURATED rows. Pins the boundary so a
|
|
229
|
+
// future re-curation has to be intentional, not a stale registry
|
|
230
|
+
// leak.
|
|
218
231
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
219
232
|
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
220
233
|
db: h.db,
|
|
221
234
|
issuer: ISSUER,
|
|
222
235
|
manifestPath: h.manifestPath,
|
|
223
|
-
fetchLatestVersion: async () =>
|
|
236
|
+
fetchLatestVersion: async () => null,
|
|
224
237
|
});
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
expect(
|
|
237
|
-
expect(
|
|
238
|
-
expect(runner?.display_name).toBe("Runner");
|
|
239
|
-
expect(runner?.tagline).toContain("Vault-as-job-substrate");
|
|
240
|
-
expect(runner?.available).toBe(true);
|
|
238
|
+
const body = (await res.json()) as { modules: Array<{ short: string }> };
|
|
239
|
+
const shorts = body.modules.map((m) => m.short);
|
|
240
|
+
// Positive shape assertion — stronger than `not.toContain` because
|
|
241
|
+
// it also catches "we accidentally added a new uncurated entry"
|
|
242
|
+
// and "we accidentally removed an existing curated entry." Update
|
|
243
|
+
// this assertion intentionally when CURATED_MODULES changes.
|
|
244
|
+
expect(shorts).toEqual(["vault", "scribe"]);
|
|
245
|
+
// Belt + suspenders: explicit negatives for the modules dropped
|
|
246
|
+
// 2026-05-27, so a developer regressing the curated list sees both
|
|
247
|
+
// the shape failure AND the named-module failure messages.
|
|
248
|
+
expect(shorts).not.toContain("notes");
|
|
249
|
+
expect(shorts).not.toContain("runner");
|
|
250
|
+
expect(shorts).not.toContain("surface");
|
|
241
251
|
});
|
|
242
252
|
|
|
243
253
|
test("surfaces installed_version from services.json", async () => {
|
|
@@ -272,11 +282,11 @@ describe("GET /api/modules", () => {
|
|
|
272
282
|
expect(vault?.installed_version).toBe("0.4.5");
|
|
273
283
|
expect(vault?.latest_version).toBe("0.5.0");
|
|
274
284
|
expect(vault?.install_dir).toBe("/parachute/modules/node_modules/@openparachute/vault");
|
|
275
|
-
// The other curated
|
|
276
|
-
// only vault, so
|
|
277
|
-
const
|
|
278
|
-
expect(
|
|
279
|
-
expect(
|
|
285
|
+
// The other curated row stays installed:false — the test installed
|
|
286
|
+
// only vault, so scribe still renders as available-but-not-installed.
|
|
287
|
+
const scribe = body.modules.find((m) => m.short === "scribe");
|
|
288
|
+
expect(scribe?.installed).toBe(false);
|
|
289
|
+
expect(scribe?.installed_version).toBeNull();
|
|
280
290
|
});
|
|
281
291
|
|
|
282
292
|
test("includes supervisor status + pid when a supervisor is injected", async () => {
|
|
@@ -309,9 +319,9 @@ describe("GET /api/modules", () => {
|
|
|
309
319
|
expect(vault?.pid).toBe(12345);
|
|
310
320
|
// Modules without a supervisor entry get null status — the UI
|
|
311
321
|
// disables Restart/Stop for those since there's no live process.
|
|
312
|
-
const
|
|
313
|
-
expect(
|
|
314
|
-
expect(
|
|
322
|
+
const scribe = body.modules.find((m) => m.short === "scribe");
|
|
323
|
+
expect(scribe?.supervisor_status).toBeNull();
|
|
324
|
+
expect(scribe?.pid).toBeNull();
|
|
315
325
|
expect(body.supervisor_available).toBe(true);
|
|
316
326
|
});
|
|
317
327
|
|
|
@@ -366,22 +376,28 @@ describe("GET /api/modules", () => {
|
|
|
366
376
|
});
|
|
367
377
|
|
|
368
378
|
test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
|
|
369
|
-
// Audit caught 2026-05-25:
|
|
370
|
-
// (full hub-origin path) and `paths: ["/
|
|
371
|
-
// SPA's Services dropdown was navigating to `/
|
|
372
|
-
// because api-modules unconditionally prepended the mount onto
|
|
373
|
-
// candidate. Fix: detect already-mount-prefixed paths and pass
|
|
379
|
+
// Audit caught 2026-05-25: surface declared `managementUrl: "/surface/admin/"`
|
|
380
|
+
// (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
|
|
381
|
+
// SPA's Services dropdown was navigating to `/surface/surface/admin/`
|
|
382
|
+
// (404) because api-modules unconditionally prepended the mount onto
|
|
383
|
+
// the candidate. Fix: detect already-mount-prefixed paths and pass
|
|
384
|
+
// through.
|
|
374
385
|
//
|
|
375
386
|
// Single-instance modules conventionally declare the full path; only
|
|
376
387
|
// multi-instance modules (vault) use the per-instance relative form.
|
|
388
|
+
// Post 2026-05-27 CURATED trim the canonical single-instance example
|
|
389
|
+
// is scribe (when scribe ships a managementUrl — scribe#53). For now
|
|
390
|
+
// we exercise the same code path with vault declaring an
|
|
391
|
+
// already-mount-prefixed managementUrl: any module whose declared
|
|
392
|
+
// URL starts with its mount must pass through unchanged.
|
|
377
393
|
writeManifest(h.manifestPath, [
|
|
378
394
|
{
|
|
379
|
-
name: "parachute-
|
|
380
|
-
port:
|
|
381
|
-
paths: ["/
|
|
382
|
-
health: "/
|
|
383
|
-
version: "0.
|
|
384
|
-
installDir: "/install/dir/
|
|
395
|
+
name: "parachute-vault",
|
|
396
|
+
port: 1940,
|
|
397
|
+
paths: ["/vault/default"],
|
|
398
|
+
health: "/vault/default/health",
|
|
399
|
+
version: "0.4.5",
|
|
400
|
+
installDir: "/install/dir/vault",
|
|
385
401
|
},
|
|
386
402
|
]);
|
|
387
403
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
@@ -391,17 +407,18 @@ describe("GET /api/modules", () => {
|
|
|
391
407
|
manifestPath: h.manifestPath,
|
|
392
408
|
fetchLatestVersion: async () => null,
|
|
393
409
|
readModuleManifest: async (installDir) => {
|
|
394
|
-
if (installDir === "/install/dir/
|
|
410
|
+
if (installDir === "/install/dir/vault") {
|
|
395
411
|
return {
|
|
396
|
-
name: "
|
|
397
|
-
manifestName: "parachute-
|
|
398
|
-
displayName: "
|
|
412
|
+
name: "parachute-vault",
|
|
413
|
+
manifestName: "parachute-vault",
|
|
414
|
+
displayName: "Vault",
|
|
399
415
|
tagline: "",
|
|
400
|
-
port:
|
|
401
|
-
paths: ["/
|
|
402
|
-
health: "/
|
|
403
|
-
|
|
404
|
-
|
|
416
|
+
port: 1940,
|
|
417
|
+
paths: ["/vault/default"],
|
|
418
|
+
health: "/vault/default/health",
|
|
419
|
+
// Already-mount-prefixed managementUrl — must NOT have the
|
|
420
|
+
// mount prepended again.
|
|
421
|
+
managementUrl: "/vault/default/admin/",
|
|
405
422
|
} as unknown as Awaited<
|
|
406
423
|
ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
|
|
407
424
|
>;
|
|
@@ -413,9 +430,9 @@ describe("GET /api/modules", () => {
|
|
|
413
430
|
const body = (await res.json()) as {
|
|
414
431
|
modules: Array<{ short: string; management_url: string | null }>;
|
|
415
432
|
};
|
|
416
|
-
const
|
|
417
|
-
// Single `/
|
|
418
|
-
expect(
|
|
433
|
+
const vault = body.modules.find((m) => m.short === "vault");
|
|
434
|
+
// Single `/vault/default/`, not `/vault/default/vault/default/`.
|
|
435
|
+
expect(vault?.management_url).toBe("/vault/default/admin/");
|
|
419
436
|
});
|
|
420
437
|
|
|
421
438
|
test("management_url prefix-ish names don't collide (hub#380 — /app vs /app-foo)", async () => {
|
|
@@ -430,8 +447,8 @@ describe("GET /api/modules", () => {
|
|
|
430
447
|
{
|
|
431
448
|
name: "parachute-vault",
|
|
432
449
|
port: 1940,
|
|
433
|
-
paths: ["/
|
|
434
|
-
health: "/
|
|
450
|
+
paths: ["/surface"], // mount is /app (using vault as a stand-in installable)
|
|
451
|
+
health: "/surface/health",
|
|
435
452
|
version: "0.4.5",
|
|
436
453
|
installDir: "/install/dir/contrived",
|
|
437
454
|
},
|
|
@@ -450,8 +467,8 @@ describe("GET /api/modules", () => {
|
|
|
450
467
|
displayName: "Vault",
|
|
451
468
|
tagline: "",
|
|
452
469
|
port: 1940,
|
|
453
|
-
paths: ["/
|
|
454
|
-
health: "/
|
|
470
|
+
paths: ["/surface"],
|
|
471
|
+
health: "/surface/health",
|
|
455
472
|
// candidate looks like a sibling-name prefix but is NOT a
|
|
456
473
|
// mount-prefix of /app — should still get prepended.
|
|
457
474
|
managementUrl: "/app-foo/admin",
|
|
@@ -467,9 +484,9 @@ describe("GET /api/modules", () => {
|
|
|
467
484
|
modules: Array<{ short: string; management_url: string | null }>;
|
|
468
485
|
};
|
|
469
486
|
const vault = body.modules.find((m) => m.short === "vault");
|
|
470
|
-
// /
|
|
471
|
-
// as already-mount-prefixed because /app-foo/ doesn't start with /
|
|
472
|
-
expect(vault?.management_url).toBe("/
|
|
487
|
+
// /surface + /app-foo/admin → /surface/app-foo/admin (prepend fires; not
|
|
488
|
+
// treated as already-mount-prefixed because /app-foo/ doesn't start with /surface/).
|
|
489
|
+
expect(vault?.management_url).toBe("/surface/app-foo/admin");
|
|
473
490
|
});
|
|
474
491
|
|
|
475
492
|
test("management_url equality edge: tail equals mount exactly (hub#380)", async () => {
|
|
@@ -786,8 +803,8 @@ describe("GET /api/modules", () => {
|
|
|
786
803
|
},
|
|
787
804
|
]);
|
|
788
805
|
// Other curated rows stay empty — uis is per-row, not global.
|
|
789
|
-
const
|
|
790
|
-
expect(
|
|
806
|
+
const scribe = body.modules.find((m) => m.short === "scribe");
|
|
807
|
+
expect(scribe?.uis).toEqual([]);
|
|
791
808
|
});
|
|
792
809
|
|
|
793
810
|
test("optional fields ride through verbatim, missing fields become null on the wire", async () => {
|