@openparachute/hub 0.5.14-rc.18 → 0.5.14-rc.20

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.18",
3
+ "version": "0.5.14-rc.20",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -11,15 +11,8 @@
11
11
  "bin": {
12
12
  "parachute": "src/cli.ts"
13
13
  },
14
- "workspaces": [
15
- "packages/*"
16
- ],
17
- "files": [
18
- "src",
19
- "web/ui/dist",
20
- "README.md",
21
- "LICENSE"
22
- ],
14
+ "workspaces": ["packages/*"],
15
+ "files": ["src", "web/ui/dist", "README.md", "LICENSE"],
23
16
  "repository": {
24
17
  "type": "git",
25
18
  "url": "https://github.com/ParachuteComputer/parachute-hub.git"
@@ -45,6 +38,7 @@
45
38
  },
46
39
  "dependencies": {
47
40
  "@node-rs/argon2": "^2.0.2",
41
+ "@openparachute/depcheck": "0.1.0-rc.1",
48
42
  "jose": "^6.2.2",
49
43
  "otpauth": "^9.5.0",
50
44
  "qrcode": "^1.5.4"
@@ -382,10 +382,14 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
382
382
  }
383
383
  });
384
384
 
385
- // The de-escalation exception is gated on host:admin specifically. A
386
- // bearer that only holds `parachute:host:auth` (the narrow auth scope-set)
387
- // can mint verb scopes but NOT vault-admin that would be an escalation.
388
- test("400 invalid_scope when auth-only bearer mints vault:<name>:admin", async () => {
385
+ // Single-consent change (2026-05-29) INTENTIONAL canGrant widening. Once
386
+ // `vault:<name>:admin` became requestable (`isNonRequestableScope` dropped
387
+ // the per-vault-admin clause), canGrant rule 1 (`!isNonRequestableScope` +
388
+ // bearer holds `parachute:host:auth`) now ADMITS it. A `parachute:host:auth`
389
+ // bearer is an on-box operator credential, so minting a vault-pinned admin
390
+ // from it is a de-escalation, not an escalation. Pinned here so the widening
391
+ // is deliberate, not an accidental regression.
392
+ test("200 when auth-only bearer mints vault:<name>:admin (intentional canGrant widening)", async () => {
389
393
  const h = makeHarness();
390
394
  try {
391
395
  const { db, userId } = await bootstrap(h.dir);
@@ -398,10 +402,12 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
398
402
  jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
399
403
  { db, issuer: ISSUER },
400
404
  );
401
- expect(resp.status).toBe(400);
402
- const body = (await resp.json()) as { error: string; error_description: string };
403
- expect(body.error).toBe("invalid_scope");
404
- expect(body.error_description).toContain("vault:work:admin");
405
+ expect(resp.status).toBe(200);
406
+ const body = (await resp.json()) as { scope: string; token: string };
407
+ expect(body.scope).toBe("vault:work:admin");
408
+ const validated = await validateAccessToken(db, body.token, ISSUER);
409
+ expect(validated.payload.aud).toBe("vault.work");
410
+ expect(validated.payload.scope).toBe("vault:work:admin");
405
411
  } finally {
406
412
  db.close();
407
413
  }
@@ -765,7 +771,10 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
765
771
  }
766
772
  });
767
773
 
768
- test("host:auth-only bearer mints vault:work:admin → 400 (rule 1 doesn't cover admin)", async () => {
774
+ test("host:auth-only bearer mints vault:work:admin → 200 (single-consent: rule 1 now covers admin)", async () => {
775
+ // Single-consent change (2026-05-29): vault:<name>:admin is requestable
776
+ // now, so canGrant rule 1 admits it for a host:auth bearer. De-escalation
777
+ // from an on-box operator credential — intentional widening.
769
778
  const h = makeHarness();
770
779
  try {
771
780
  const { db, userId } = await bootstrap(h.dir);
@@ -775,9 +784,9 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
775
784
  jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
776
785
  { db, issuer: ISSUER },
777
786
  );
778
- expect(resp.status).toBe(400);
779
- const body = (await resp.json()) as { error: string };
780
- expect(body.error).toBe("invalid_scope");
787
+ expect(resp.status).toBe(200);
788
+ const body = (await resp.json()) as { scope: string };
789
+ expect(body.scope).toBe("vault:work:admin");
781
790
  } finally {
782
791
  db.close();
783
792
  }
@@ -993,10 +1002,12 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
993
1002
  }
994
1003
  });
995
1004
 
996
- // The existing non-requestable behaviour is unchanged: a host:auth-only
997
- // bearer minting the well-formed `vault:work:admin` is still 400 (rule 1
998
- // doesn't cover admin) this path is reached AFTER the shape guard passes.
999
- test("host:auth-only bearer minting vault:work:admin 400 (non-requestable, unchanged)", async () => {
1005
+ // Contrast with the malformed forms above: a WELL-FORMED `vault:work:admin`
1006
+ // clears the shape guard, and (single-consent change, 2026-05-29) now mints
1007
+ // 200 via canGrant rule 1 for a host:auth bearer. The malformed forms are
1008
+ // rejected by the shape guard BEFORE canGrant; this one passes the guard
1009
+ // and is admitted.
1010
+ test("host:auth-only bearer minting well-formed vault:work:admin → 200 (clears shape guard, mints)", async () => {
1000
1011
  const h = makeHarness();
1001
1012
  try {
1002
1013
  const { db, userId } = await bootstrap(h.dir);
@@ -1006,11 +1017,9 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
1006
1017
  jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
1007
1018
  { db, issuer: ISSUER },
1008
1019
  );
1009
- expect(resp.status).toBe(400);
1010
- const body = (await resp.json()) as { error: string; error_description: string };
1011
- expect(body.error).toBe("invalid_scope");
1012
- // Not the malformed-shape message — it cleared the shape guard.
1013
- expect(body.error_description).toContain("not grantable");
1020
+ expect(resp.status).toBe(200);
1021
+ const body = (await resp.json()) as { scope: string };
1022
+ expect(body.scope).toBe("vault:work:admin");
1014
1023
  } finally {
1015
1024
  db.close();
1016
1025
  }
@@ -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
@@ -21,7 +21,7 @@ import {
21
21
  readOperatorTokenFile,
22
22
  } from "../operator-token.ts";
23
23
  import { ensureLogPath, logPath, readPid, writePid } from "../process-state.ts";
24
- import { upsertService } from "../services-manifest.ts";
24
+ import { readManifest, upsertService } from "../services-manifest.ts";
25
25
  import { rotateSigningKey } from "../signing-keys.ts";
26
26
 
27
27
  interface Harness {
@@ -197,6 +197,87 @@ describe("parachute start", () => {
197
197
  }
198
198
  });
199
199
 
200
+ test("missing startCmd binary → friendly missing-dependency message + no spawn", async () => {
201
+ const h = makeHarness();
202
+ try {
203
+ seedVault(h.manifestPath);
204
+ const spawner = makeSpawner([4242]);
205
+ const logs: string[] = [];
206
+ const code = await start("vault", {
207
+ configDir: h.configDir,
208
+ manifestPath: h.manifestPath,
209
+ spawner,
210
+ // Force the preflight's missing-binary branch: parachute-vault not on PATH.
211
+ which: () => null,
212
+ log: (l) => logs.push(l),
213
+ });
214
+ expect(code).toBe(1);
215
+ // Preflight fired before the spawn — the stub spawner is never called.
216
+ expect(spawner.calls).toHaveLength(0);
217
+ const out = logs.join("\n");
218
+ expect(out).toMatch(/vault failed to start/);
219
+ // The friendly install block names the binary + its install path.
220
+ expect(out).toContain("parachute-vault is required to run the Vault module Hub supervises");
221
+ expect(out).toContain("parachute install vault");
222
+ expect(readPid("vault", h.configDir)).toBeUndefined();
223
+ } finally {
224
+ h.cleanup();
225
+ }
226
+ });
227
+
228
+ test("missing startCmd binary persists lastStartError so a later status surfaces it", async () => {
229
+ const h = makeHarness();
230
+ try {
231
+ seedVault(h.manifestPath);
232
+ await start("vault", {
233
+ configDir: h.configDir,
234
+ manifestPath: h.manifestPath,
235
+ spawner: makeSpawner([4242]),
236
+ which: () => null,
237
+ log: () => {},
238
+ });
239
+ const entry = readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault");
240
+ expect(entry?.lastStartError?.error_type).toBe("missing_dependency");
241
+ expect(entry?.lastStartError?.binary).toBe("parachute-vault");
242
+ expect(entry?.lastStartError?.at).toBeDefined();
243
+ } finally {
244
+ h.cleanup();
245
+ }
246
+ });
247
+
248
+ test("a successful start clears a previously-recorded lastStartError", async () => {
249
+ const h = makeHarness();
250
+ try {
251
+ seedVault(h.manifestPath);
252
+ // First start fails (binary missing) → records the error.
253
+ await start("vault", {
254
+ configDir: h.configDir,
255
+ manifestPath: h.manifestPath,
256
+ spawner: makeSpawner([1]),
257
+ which: () => null,
258
+ log: () => {},
259
+ });
260
+ expect(
261
+ readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
262
+ ?.lastStartError,
263
+ ).toBeDefined();
264
+ // Second start succeeds (binary present via the permissive default which
265
+ // — stub spawner path) → clears the recorded error.
266
+ await start("vault", {
267
+ configDir: h.configDir,
268
+ manifestPath: h.manifestPath,
269
+ spawner: makeSpawner([4242]),
270
+ log: () => {},
271
+ });
272
+ expect(
273
+ readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
274
+ ?.lastStartError,
275
+ ).toBeUndefined();
276
+ } finally {
277
+ h.cleanup();
278
+ }
279
+ });
280
+
200
281
  test("notes start command includes configured port and notes-serve shim path", async () => {
201
282
  const h = makeHarness();
202
283
  try {