@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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 +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- 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 +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- 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 +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- 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 +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- 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 +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -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-tRmPbbC7.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,
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { MissingDependencyError, lookupDep } from "@openparachute/depcheck";
|
|
5
6
|
import {
|
|
6
7
|
API_MODULES_OPS_REQUIRED_SCOPE,
|
|
7
8
|
_resetOperationsRegistryForTests,
|
|
@@ -629,6 +630,50 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
629
630
|
expect(op.error).toMatch(/bun add -g exited 1/);
|
|
630
631
|
});
|
|
631
632
|
|
|
633
|
+
test("a MissingDependencyError during install attaches the structured error_detail wire", async () => {
|
|
634
|
+
const { supervisor } = makeIdleSupervisor();
|
|
635
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
636
|
+
const deps = {
|
|
637
|
+
db: h.db,
|
|
638
|
+
issuer: ISSUER,
|
|
639
|
+
manifestPath: h.manifestPath,
|
|
640
|
+
configDir: h.dir,
|
|
641
|
+
supervisor,
|
|
642
|
+
// Simulate `bun` not being on PATH: the install runner's shell-out
|
|
643
|
+
// throws the typed missing-dependency error.
|
|
644
|
+
run: async () => {
|
|
645
|
+
throw new MissingDependencyError("bun", lookupDep("bun"), {
|
|
646
|
+
platform: "linux",
|
|
647
|
+
arch: "x64",
|
|
648
|
+
});
|
|
649
|
+
},
|
|
650
|
+
findGlobalInstall: () => null,
|
|
651
|
+
isLinked: TEST_DEFAULT_NOT_LINKED,
|
|
652
|
+
};
|
|
653
|
+
const res = await handleInstall(
|
|
654
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
655
|
+
"vault",
|
|
656
|
+
deps,
|
|
657
|
+
);
|
|
658
|
+
const body = (await res.json()) as { operation_id: string };
|
|
659
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
660
|
+
const opRes = await handleOperationGet(
|
|
661
|
+
getReq(`/api/modules/operations/${body.operation_id}`, {
|
|
662
|
+
authorization: `Bearer ${bearer}`,
|
|
663
|
+
}),
|
|
664
|
+
body.operation_id,
|
|
665
|
+
deps,
|
|
666
|
+
);
|
|
667
|
+
const op = (await opRes.json()) as {
|
|
668
|
+
status: string;
|
|
669
|
+
error?: string;
|
|
670
|
+
error_detail?: { error_type: string; binary: string };
|
|
671
|
+
};
|
|
672
|
+
expect(op.status).toBe("failed");
|
|
673
|
+
expect(op.error_detail?.error_type).toBe("missing_dependency");
|
|
674
|
+
expect(op.error_detail?.binary).toBe("bun");
|
|
675
|
+
});
|
|
676
|
+
|
|
632
677
|
test("skips bun add -g when package is already bun-linked (smoke 2026-05-27 finding 1)", async () => {
|
|
633
678
|
// Smoke finding 1: the wizard's parallel install path was unconditionally
|
|
634
679
|
// invoking `bun add -g <pkg>` even when the package was already linked
|
|
@@ -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:
|
|
379
|
+
// Audit caught 2026-05-25: surface declared `managementUrl: "/surface/admin/"`
|
|
370
380
|
// (full hub-origin path) and `paths: ["/surface", "/.parachute"]`. The
|
|
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
|
|
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 () => {
|
|
@@ -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 () => {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { handleApiReady } from "../api-ready.ts";
|
|
3
|
+
import type { ModuleState, Supervisor } from "../supervisor.ts";
|
|
4
|
+
|
|
5
|
+
function stubSupervisor(states: ModuleState[]): Supervisor {
|
|
6
|
+
return {
|
|
7
|
+
list: () => states,
|
|
8
|
+
get: (short: string) => states.find((s) => s.short === short),
|
|
9
|
+
start: async () => {
|
|
10
|
+
throw new Error("not implemented");
|
|
11
|
+
},
|
|
12
|
+
stop: async () => undefined,
|
|
13
|
+
restart: async () => undefined,
|
|
14
|
+
} as unknown as Supervisor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function req(): Request {
|
|
18
|
+
return new Request("http://127.0.0.1/api/ready", {
|
|
19
|
+
headers: { accept: "application/json" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
|
|
24
|
+
return {
|
|
25
|
+
status: "running",
|
|
26
|
+
restartsInWindow: 0,
|
|
27
|
+
...partial,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("handleApiReady — no supervisor (CLI mode)", () => {
|
|
32
|
+
test("returns ready=true + empty arrays when supervisor absent", async () => {
|
|
33
|
+
const res = handleApiReady(req());
|
|
34
|
+
expect(res.status).toBe(200);
|
|
35
|
+
const body = (await res.json()) as {
|
|
36
|
+
ready: boolean;
|
|
37
|
+
ready_modules: string[];
|
|
38
|
+
transient_modules: string[];
|
|
39
|
+
persistent_modules: string[];
|
|
40
|
+
};
|
|
41
|
+
expect(body.ready).toBe(true);
|
|
42
|
+
expect(body.ready_modules).toEqual([]);
|
|
43
|
+
expect(body.transient_modules).toEqual([]);
|
|
44
|
+
expect(body.persistent_modules).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("handleApiReady — supervisor mode", () => {
|
|
49
|
+
test("all modules running past boot window → ready=true", async () => {
|
|
50
|
+
const now = 1_700_000_000_000;
|
|
51
|
+
const startedAt = new Date(now - 60_000).toISOString();
|
|
52
|
+
const sup = stubSupervisor([
|
|
53
|
+
moduleState({ short: "vault", status: "running", startedAt }),
|
|
54
|
+
moduleState({ short: "scribe", status: "running", startedAt }),
|
|
55
|
+
]);
|
|
56
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
57
|
+
const body = (await res.json()) as {
|
|
58
|
+
ready: boolean;
|
|
59
|
+
ready_modules: string[];
|
|
60
|
+
transient_modules: string[];
|
|
61
|
+
persistent_modules: string[];
|
|
62
|
+
};
|
|
63
|
+
expect(body.ready).toBe(true);
|
|
64
|
+
expect(body.ready_modules.sort()).toEqual(["scribe", "vault"]);
|
|
65
|
+
expect(body.transient_modules).toEqual([]);
|
|
66
|
+
expect(body.persistent_modules).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("module inside boot window → transient, ready=false", async () => {
|
|
70
|
+
const now = 1_700_000_000_000;
|
|
71
|
+
const sup = stubSupervisor([
|
|
72
|
+
moduleState({
|
|
73
|
+
short: "vault",
|
|
74
|
+
status: "running",
|
|
75
|
+
startedAt: new Date(now - 10_000).toISOString(),
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
79
|
+
const body = (await res.json()) as {
|
|
80
|
+
ready: boolean;
|
|
81
|
+
ready_modules: string[];
|
|
82
|
+
transient_modules: string[];
|
|
83
|
+
};
|
|
84
|
+
expect(body.ready).toBe(false);
|
|
85
|
+
expect(body.transient_modules).toEqual(["vault"]);
|
|
86
|
+
expect(body.ready_modules).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("starting + restarting + crashed all classified correctly", async () => {
|
|
90
|
+
const now = 1_700_000_000_000;
|
|
91
|
+
const sup = stubSupervisor([
|
|
92
|
+
moduleState({ short: "vault", status: "starting" }),
|
|
93
|
+
moduleState({ short: "scribe", status: "restarting" }),
|
|
94
|
+
moduleState({ short: "notes", status: "crashed" }),
|
|
95
|
+
moduleState({ short: "channel", status: "stopped" }),
|
|
96
|
+
moduleState({
|
|
97
|
+
short: "runner",
|
|
98
|
+
status: "running",
|
|
99
|
+
startedAt: new Date(now - 60_000).toISOString(),
|
|
100
|
+
}),
|
|
101
|
+
]);
|
|
102
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
103
|
+
const body = (await res.json()) as {
|
|
104
|
+
ready: boolean;
|
|
105
|
+
ready_modules: string[];
|
|
106
|
+
transient_modules: string[];
|
|
107
|
+
persistent_modules: string[];
|
|
108
|
+
};
|
|
109
|
+
expect(body.ready).toBe(false);
|
|
110
|
+
expect(body.ready_modules).toEqual(["runner"]);
|
|
111
|
+
expect(body.transient_modules.sort()).toEqual(["scribe", "vault"]);
|
|
112
|
+
expect(body.persistent_modules.sort()).toEqual(["channel", "notes"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("only crashed/stopped + nothing transient → ready=false (still failing)", async () => {
|
|
116
|
+
const sup = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
|
|
117
|
+
const res = handleApiReady(req(), { supervisor: sup });
|
|
118
|
+
const body = (await res.json()) as { ready: boolean };
|
|
119
|
+
expect(body.ready).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("handleApiReady — method check", () => {
|
|
124
|
+
test("rejects non-GET", () => {
|
|
125
|
+
const r = new Request("http://127.0.0.1/api/ready", { method: "POST" });
|
|
126
|
+
const res = handleApiReady(r);
|
|
127
|
+
expect(res.status).toBe(405);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("accepts HEAD", () => {
|
|
131
|
+
const r = new Request("http://127.0.0.1/api/ready", { method: "HEAD" });
|
|
132
|
+
const res = handleApiReady(r);
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
});
|
|
135
|
+
});
|