@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 +4 -10
- package/src/__tests__/api-mint-token.test.ts +30 -21
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/lifecycle.test.ts +82 -1
- package/src/__tests__/oauth-handlers.test.ts +697 -87
- package/src/__tests__/scope-explanations.test.ts +41 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/status.test.ts +36 -0
- package/src/api-modules-ops.ts +49 -11
- package/src/cli.ts +9 -0
- package/src/clients.ts +18 -6
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/lifecycle.ts +88 -6
- package/src/commands/status.ts +22 -0
- package/src/oauth-handlers.ts +196 -18
- package/src/scope-explanations.ts +46 -18
- package/src/services-manifest.ts +112 -0
- package/src/tailscale/run.ts +28 -11
- package/web/ui/dist/assets/{index-B28SdMSz.css → index-BiBlvEaj.css} +1 -1
- package/web/ui/dist/assets/{index-2SSK7JbM.js → index-CIN3mnmf.js} +9 -9
- package/web/ui/dist/index.html +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/hub",
|
|
3
|
-
"version": "0.5.14-rc.
|
|
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
|
-
|
|
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
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
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(
|
|
402
|
-
const body = (await resp.json()) as {
|
|
403
|
-
expect(body.
|
|
404
|
-
|
|
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 →
|
|
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(
|
|
779
|
-
const body = (await resp.json()) as {
|
|
780
|
-
expect(body.
|
|
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
|
-
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
|
|
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(
|
|
1010
|
-
const body = (await resp.json()) as {
|
|
1011
|
-
expect(body.
|
|
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 {
|