@openparachute/hub 0.5.13 → 0.5.14-rc.2
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 +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -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__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- 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__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
|
@@ -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(
|
|
@@ -171,7 +171,7 @@ describe("GET /api/modules", () => {
|
|
|
171
171
|
supervisor_available: boolean;
|
|
172
172
|
};
|
|
173
173
|
// Curated order is preserved: vault → app → notes → scribe → runner.
|
|
174
|
-
expect(body.modules.map((m) => m.short)).toEqual(["vault", "
|
|
174
|
+
expect(body.modules.map((m) => m.short)).toEqual(["vault", "surface", "notes", "scribe", "runner"]);
|
|
175
175
|
expect(body.modules.every((m) => m.available)).toBe(true);
|
|
176
176
|
expect(body.modules.every((m) => !m.installed)).toBe(true);
|
|
177
177
|
expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
|
|
@@ -202,12 +202,12 @@ describe("GET /api/modules", () => {
|
|
|
202
202
|
available: boolean;
|
|
203
203
|
}>;
|
|
204
204
|
};
|
|
205
|
-
const
|
|
206
|
-
expect(
|
|
207
|
-
expect(
|
|
208
|
-
expect(
|
|
209
|
-
expect(
|
|
210
|
-
expect(
|
|
205
|
+
const surface = body.modules.find((m) => m.short === "surface");
|
|
206
|
+
expect(surface).toBeDefined();
|
|
207
|
+
expect(surface?.package).toBe("@openparachute/surface");
|
|
208
|
+
expect(surface?.display_name).toBe("Surface");
|
|
209
|
+
expect(surface?.tagline).toContain("auto-installs Notes");
|
|
210
|
+
expect(surface?.available).toBe(true);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
test("runner row carries package + display props from FIRST_PARTY_FALLBACKS (#305)", async () => {
|
|
@@ -366,9 +366,9 @@ describe("GET /api/modules", () => {
|
|
|
366
366
|
});
|
|
367
367
|
|
|
368
368
|
test("management_url does not double-prepend mount when managementUrl is already mount-prefixed (hub#380)", async () => {
|
|
369
|
-
// Audit caught 2026-05-25: app declares `managementUrl: "/
|
|
370
|
-
// (full hub-origin path) and `paths: ["/
|
|
371
|
-
// SPA's Services dropdown was navigating to `/app/
|
|
369
|
+
// Audit caught 2026-05-25: app declares `managementUrl: "/surface/admin/"`
|
|
370
|
+
// (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
|
|
371
|
+
// SPA's Services dropdown was navigating to `/app/surface/admin/` (404)
|
|
372
372
|
// because api-modules unconditionally prepended the mount onto the
|
|
373
373
|
// candidate. Fix: detect already-mount-prefixed paths and pass through.
|
|
374
374
|
//
|
|
@@ -376,12 +376,12 @@ describe("GET /api/modules", () => {
|
|
|
376
376
|
// multi-instance modules (vault) use the per-instance relative form.
|
|
377
377
|
writeManifest(h.manifestPath, [
|
|
378
378
|
{
|
|
379
|
-
name: "parachute-
|
|
379
|
+
name: "parachute-surface",
|
|
380
380
|
port: 1946,
|
|
381
|
-
paths: ["/
|
|
382
|
-
health: "/
|
|
381
|
+
paths: ["/surface", "/.parachute"],
|
|
382
|
+
health: "/surface/healthz",
|
|
383
383
|
version: "0.2.0-rc.13",
|
|
384
|
-
installDir: "/install/dir/
|
|
384
|
+
installDir: "/install/dir/surface",
|
|
385
385
|
},
|
|
386
386
|
]);
|
|
387
387
|
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
@@ -391,17 +391,17 @@ describe("GET /api/modules", () => {
|
|
|
391
391
|
manifestPath: h.manifestPath,
|
|
392
392
|
fetchLatestVersion: async () => null,
|
|
393
393
|
readModuleManifest: async (installDir) => {
|
|
394
|
-
if (installDir === "/install/dir/
|
|
394
|
+
if (installDir === "/install/dir/surface") {
|
|
395
395
|
return {
|
|
396
|
-
name: "
|
|
397
|
-
manifestName: "parachute-
|
|
398
|
-
displayName: "
|
|
396
|
+
name: "surface",
|
|
397
|
+
manifestName: "parachute-surface",
|
|
398
|
+
displayName: "Surface",
|
|
399
399
|
tagline: "",
|
|
400
400
|
port: 1946,
|
|
401
|
-
paths: ["/
|
|
402
|
-
health: "/
|
|
403
|
-
uiUrl: "/
|
|
404
|
-
managementUrl: "/
|
|
401
|
+
paths: ["/surface", "/.parachute"],
|
|
402
|
+
health: "/surface/healthz",
|
|
403
|
+
uiUrl: "/surface/admin/",
|
|
404
|
+
managementUrl: "/surface/admin/",
|
|
405
405
|
} as unknown as Awaited<
|
|
406
406
|
ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
|
|
407
407
|
>;
|
|
@@ -413,9 +413,9 @@ describe("GET /api/modules", () => {
|
|
|
413
413
|
const body = (await res.json()) as {
|
|
414
414
|
modules: Array<{ short: string; management_url: string | null }>;
|
|
415
415
|
};
|
|
416
|
-
const
|
|
417
|
-
// Single `/
|
|
418
|
-
expect(
|
|
416
|
+
const surface = body.modules.find((m) => m.short === "surface");
|
|
417
|
+
// Single `/surface/`, not `/surface/surface/`.
|
|
418
|
+
expect(surface?.management_url).toBe("/surface/admin/");
|
|
419
419
|
});
|
|
420
420
|
|
|
421
421
|
test("management_url prefix-ish names don't collide (hub#380 — /app vs /app-foo)", async () => {
|
|
@@ -430,8 +430,8 @@ describe("GET /api/modules", () => {
|
|
|
430
430
|
{
|
|
431
431
|
name: "parachute-vault",
|
|
432
432
|
port: 1940,
|
|
433
|
-
paths: ["/
|
|
434
|
-
health: "/
|
|
433
|
+
paths: ["/surface"], // mount is /app (using vault as a stand-in installable)
|
|
434
|
+
health: "/surface/health",
|
|
435
435
|
version: "0.4.5",
|
|
436
436
|
installDir: "/install/dir/contrived",
|
|
437
437
|
},
|
|
@@ -450,8 +450,8 @@ describe("GET /api/modules", () => {
|
|
|
450
450
|
displayName: "Vault",
|
|
451
451
|
tagline: "",
|
|
452
452
|
port: 1940,
|
|
453
|
-
paths: ["/
|
|
454
|
-
health: "/
|
|
453
|
+
paths: ["/surface"],
|
|
454
|
+
health: "/surface/health",
|
|
455
455
|
// candidate looks like a sibling-name prefix but is NOT a
|
|
456
456
|
// mount-prefix of /app — should still get prepended.
|
|
457
457
|
managementUrl: "/app-foo/admin",
|
|
@@ -467,9 +467,9 @@ describe("GET /api/modules", () => {
|
|
|
467
467
|
modules: Array<{ short: string; management_url: string | null }>;
|
|
468
468
|
};
|
|
469
469
|
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("/
|
|
470
|
+
// /surface + /app-foo/admin → /surface/app-foo/admin (prepend fires; not
|
|
471
|
+
// treated as already-mount-prefixed because /app-foo/ doesn't start with /surface/).
|
|
472
|
+
expect(vault?.management_url).toBe("/surface/app-foo/admin");
|
|
473
473
|
});
|
|
474
474
|
|
|
475
475
|
test("management_url equality edge: tail equals mount exactly (hub#380)", async () => {
|