@openparachute/hub 0.6.3 → 0.6.4-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/package.json +1 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -317,6 +317,32 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
317
317
|
}
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
+
// Item C — case-insensitive non-requestable guard. An uppercase casing of a
|
|
321
|
+
// host-level scope must NOT bypass the non-requestable membership check (which
|
|
322
|
+
// was exact-string before). `PARACHUTE:HOST:AUTH` is now correctly rejected.
|
|
323
|
+
test("400 invalid_scope when minting an uppercase host scope (item C)", async () => {
|
|
324
|
+
const h = makeHarness();
|
|
325
|
+
try {
|
|
326
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
327
|
+
try {
|
|
328
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
329
|
+
for (const variant of ["PARACHUTE:HOST:AUTH", "Parachute:Host:Admin"]) {
|
|
330
|
+
const resp = await handleApiMintToken(
|
|
331
|
+
jsonRequest({ scope: variant }, { authorization: `Bearer ${op.token}` }),
|
|
332
|
+
{ db, issuer: ISSUER },
|
|
333
|
+
);
|
|
334
|
+
expect(resp.status).toBe(400);
|
|
335
|
+
const body = (await resp.json()) as { error: string };
|
|
336
|
+
expect(body.error).toBe("invalid_scope");
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
db.close();
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
h.cleanup();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
320
346
|
test("400 invalid_scope when multi-scope includes a non-requestable", async () => {
|
|
321
347
|
const h = makeHarness();
|
|
322
348
|
try {
|
|
@@ -416,13 +442,13 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
416
442
|
}
|
|
417
443
|
});
|
|
418
444
|
|
|
419
|
-
//
|
|
420
|
-
// the
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
test("bare vault:admin (no name)
|
|
445
|
+
// Item B / hub#451 — bare `vault:admin` (no vault name) is NOT mintable on
|
|
446
|
+
// the headless path. The unnamed broad-admin form has no resource pin; the
|
|
447
|
+
// mint endpoint refuses it with 400 `invalid_scope` (even for a full host:admin
|
|
448
|
+
// operator). The legitimate path for a vault admin token is a resource-narrowed
|
|
449
|
+
// `vault:<name>:admin`. The OAuth flow still accepts bare `vault:admin` and
|
|
450
|
+
// narrows it via the picker — that path is unaffected (see oauth-handlers).
|
|
451
|
+
test("bare vault:admin (no name) → 400 (non-requestable headlessly, item B / #451)", async () => {
|
|
426
452
|
const h = makeHarness();
|
|
427
453
|
try {
|
|
428
454
|
const { db, userId } = await bootstrap(h.dir);
|
|
@@ -432,9 +458,31 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
432
458
|
jsonRequest({ scope: "vault:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
433
459
|
{ db, issuer: ISSUER },
|
|
434
460
|
);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
461
|
+
expect(resp.status).toBe(400);
|
|
462
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
463
|
+
expect(body.error).toBe("invalid_scope");
|
|
464
|
+
expect(body.error_description).toContain("vault:admin");
|
|
465
|
+
} finally {
|
|
466
|
+
db.close();
|
|
467
|
+
}
|
|
468
|
+
} finally {
|
|
469
|
+
h.cleanup();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Item B does NOT touch unnamed vault:read / vault:write — those carry no
|
|
474
|
+
// admin authority and remain mintable (regression guard for the narrow scope
|
|
475
|
+
// of the bare-admin block).
|
|
476
|
+
test("bare vault:read still mints headlessly (item B is admin-only)", async () => {
|
|
477
|
+
const h = makeHarness();
|
|
478
|
+
try {
|
|
479
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
480
|
+
try {
|
|
481
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
482
|
+
const resp = await handleApiMintToken(
|
|
483
|
+
jsonRequest({ scope: "vault:read" }, { authorization: `Bearer ${op.token}` }),
|
|
484
|
+
{ db, issuer: ISSUER },
|
|
485
|
+
);
|
|
438
486
|
expect(resp.status).toBe(200);
|
|
439
487
|
const body = (await resp.json()) as { token: string };
|
|
440
488
|
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
@@ -745,6 +793,122 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
745
793
|
h.cleanup();
|
|
746
794
|
}
|
|
747
795
|
});
|
|
796
|
+
|
|
797
|
+
// Item A (subject-pin) — audit-attribution forgery. A non-operator
|
|
798
|
+
// (vault-admin-only) bearer may NOT override the minted token's `sub`:
|
|
799
|
+
// forging a foreign subject would mis-attribute the registry + revocation
|
|
800
|
+
// rows. It may still mint under its OWN sub (subject omitted / equal).
|
|
801
|
+
test("subject override by non-operator bearer → 403 (forgery blocked)", async () => {
|
|
802
|
+
const h = makeHarness();
|
|
803
|
+
try {
|
|
804
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
805
|
+
try {
|
|
806
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
807
|
+
const resp = await handleApiMintToken(
|
|
808
|
+
jsonRequest(
|
|
809
|
+
{ scope: "vault:work:read", subject: "someone-else" },
|
|
810
|
+
{ authorization: `Bearer ${bearer}` },
|
|
811
|
+
),
|
|
812
|
+
{ db, issuer: ISSUER },
|
|
813
|
+
);
|
|
814
|
+
expect(resp.status).toBe(403);
|
|
815
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
816
|
+
expect(body.error).toBe("insufficient_scope");
|
|
817
|
+
expect(body.error_description).toContain("non-operator");
|
|
818
|
+
} finally {
|
|
819
|
+
db.close();
|
|
820
|
+
}
|
|
821
|
+
} finally {
|
|
822
|
+
h.cleanup();
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("subject equal to own sub by non-operator bearer → 200 (no forgery)", async () => {
|
|
827
|
+
const h = makeHarness();
|
|
828
|
+
try {
|
|
829
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
830
|
+
try {
|
|
831
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
832
|
+
const resp = await handleApiMintToken(
|
|
833
|
+
jsonRequest(
|
|
834
|
+
{ scope: "vault:work:read", subject: userId },
|
|
835
|
+
{ authorization: `Bearer ${bearer}` },
|
|
836
|
+
),
|
|
837
|
+
{ db, issuer: ISSUER },
|
|
838
|
+
);
|
|
839
|
+
expect(resp.status).toBe(200);
|
|
840
|
+
const body = (await resp.json()) as { jti: string };
|
|
841
|
+
const row = db
|
|
842
|
+
.query<{ subject: string }, [string]>("SELECT subject FROM tokens WHERE jti = ?")
|
|
843
|
+
.get(body.jti);
|
|
844
|
+
expect(row?.subject).toBe(userId);
|
|
845
|
+
} finally {
|
|
846
|
+
db.close();
|
|
847
|
+
}
|
|
848
|
+
} finally {
|
|
849
|
+
h.cleanup();
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Item A (subject-pin) — the operator carve-out: a host operator
|
|
855
|
+
// (parachute:host:auth / parachute:host:admin) MAY override `sub` to stamp a
|
|
856
|
+
// service-account subject. This is the documented service-account override
|
|
857
|
+
// that the non-operator pin above must NOT break.
|
|
858
|
+
describe("subject override — operator carve-out (item A)", () => {
|
|
859
|
+
test("host:auth operator overrides subject → 200, registry row carries override", async () => {
|
|
860
|
+
const h = makeHarness();
|
|
861
|
+
try {
|
|
862
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
863
|
+
try {
|
|
864
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
|
|
865
|
+
const resp = await handleApiMintToken(
|
|
866
|
+
jsonRequest(
|
|
867
|
+
{ scope: "vault:work:read", subject: "svc-account" },
|
|
868
|
+
{ authorization: `Bearer ${op.token}` },
|
|
869
|
+
),
|
|
870
|
+
{ db, issuer: ISSUER },
|
|
871
|
+
);
|
|
872
|
+
expect(resp.status).toBe(200);
|
|
873
|
+
const body = (await resp.json()) as { jti: string; token: string };
|
|
874
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
875
|
+
expect(validated.payload.sub).toBe("svc-account");
|
|
876
|
+
const row = db
|
|
877
|
+
.query<{ subject: string }, [string]>("SELECT subject FROM tokens WHERE jti = ?")
|
|
878
|
+
.get(body.jti);
|
|
879
|
+
expect(row?.subject).toBe("svc-account");
|
|
880
|
+
} finally {
|
|
881
|
+
db.close();
|
|
882
|
+
}
|
|
883
|
+
} finally {
|
|
884
|
+
h.cleanup();
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test("host:admin operator overrides subject → 200", async () => {
|
|
889
|
+
const h = makeHarness();
|
|
890
|
+
try {
|
|
891
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
892
|
+
try {
|
|
893
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
894
|
+
const resp = await handleApiMintToken(
|
|
895
|
+
jsonRequest(
|
|
896
|
+
{ scope: "vault:work:admin", subject: "svc-account" },
|
|
897
|
+
{ authorization: `Bearer ${op.token}` },
|
|
898
|
+
),
|
|
899
|
+
{ db, issuer: ISSUER },
|
|
900
|
+
);
|
|
901
|
+
expect(resp.status).toBe(200);
|
|
902
|
+
const body = (await resp.json()) as { token: string };
|
|
903
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
904
|
+
expect(validated.payload.sub).toBe("svc-account");
|
|
905
|
+
} finally {
|
|
906
|
+
db.close();
|
|
907
|
+
}
|
|
908
|
+
} finally {
|
|
909
|
+
h.cleanup();
|
|
910
|
+
}
|
|
911
|
+
});
|
|
748
912
|
});
|
|
749
913
|
|
|
750
914
|
describe("capability attenuation — entry gate + regression", () => {
|
|
@@ -1051,6 +1215,91 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
|
1051
1215
|
});
|
|
1052
1216
|
});
|
|
1053
1217
|
|
|
1218
|
+
// Item D / hub#450 — vault-existence check on vault:<name>:admin mints.
|
|
1219
|
+
describe("vault-existence check on vault:<name>:admin (item D / #450)", () => {
|
|
1220
|
+
test("vault:typo:admin for an unknown vault → 400 when knownVaultNames is wired", async () => {
|
|
1221
|
+
const h = makeHarness();
|
|
1222
|
+
try {
|
|
1223
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1224
|
+
try {
|
|
1225
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
1226
|
+
const resp = await handleApiMintToken(
|
|
1227
|
+
jsonRequest({ scope: "vault:typo:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
1228
|
+
{ db, issuer: ISSUER, knownVaultNames: new Set(["work", "default"]) },
|
|
1229
|
+
);
|
|
1230
|
+
expect(resp.status).toBe(400);
|
|
1231
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
1232
|
+
expect(body.error).toBe("invalid_scope");
|
|
1233
|
+
expect(body.error_description).toContain("typo");
|
|
1234
|
+
} finally {
|
|
1235
|
+
db.close();
|
|
1236
|
+
}
|
|
1237
|
+
} finally {
|
|
1238
|
+
h.cleanup();
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
test("vault:work:admin for a KNOWN vault → 200", async () => {
|
|
1243
|
+
const h = makeHarness();
|
|
1244
|
+
try {
|
|
1245
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1246
|
+
try {
|
|
1247
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
1248
|
+
const resp = await handleApiMintToken(
|
|
1249
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
1250
|
+
{ db, issuer: ISSUER, knownVaultNames: new Set(["work", "default"]) },
|
|
1251
|
+
);
|
|
1252
|
+
expect(resp.status).toBe(200);
|
|
1253
|
+
const body = (await resp.json()) as { scope: string };
|
|
1254
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
1255
|
+
} finally {
|
|
1256
|
+
db.close();
|
|
1257
|
+
}
|
|
1258
|
+
} finally {
|
|
1259
|
+
h.cleanup();
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
test("read/write for an unknown vault are NOT existence-checked (admin-only gate)", async () => {
|
|
1264
|
+
const h = makeHarness();
|
|
1265
|
+
try {
|
|
1266
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1267
|
+
try {
|
|
1268
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
1269
|
+
const resp = await handleApiMintToken(
|
|
1270
|
+
jsonRequest({ scope: "vault:typo:read" }, { authorization: `Bearer ${op.token}` }),
|
|
1271
|
+
{ db, issuer: ISSUER, knownVaultNames: new Set(["work"]) },
|
|
1272
|
+
);
|
|
1273
|
+
expect(resp.status).toBe(200);
|
|
1274
|
+
} finally {
|
|
1275
|
+
db.close();
|
|
1276
|
+
}
|
|
1277
|
+
} finally {
|
|
1278
|
+
h.cleanup();
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test("knownVaultNames omitted → existence check skipped (back-compat)", async () => {
|
|
1283
|
+
const h = makeHarness();
|
|
1284
|
+
try {
|
|
1285
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
1286
|
+
try {
|
|
1287
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
1288
|
+
const resp = await handleApiMintToken(
|
|
1289
|
+
jsonRequest({ scope: "vault:typo:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
1290
|
+
{ db, issuer: ISSUER },
|
|
1291
|
+
);
|
|
1292
|
+
// No knownVaultNames → the documented "caller responsible" fallback.
|
|
1293
|
+
expect(resp.status).toBe(200);
|
|
1294
|
+
} finally {
|
|
1295
|
+
db.close();
|
|
1296
|
+
}
|
|
1297
|
+
} finally {
|
|
1298
|
+
h.cleanup();
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1054
1303
|
test("405 on non-POST", async () => {
|
|
1055
1304
|
const h = makeHarness();
|
|
1056
1305
|
try {
|
|
@@ -323,6 +323,13 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
323
323
|
expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
|
|
324
324
|
// Supervisor was handed the spawn.
|
|
325
325
|
expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
|
|
326
|
+
// The install-time spawn path (spawnSupervised) injects the enriched PATH
|
|
327
|
+
// so a module installed via /admin/modules can find operator tools (scribe's
|
|
328
|
+
// parakeet-mlx / ffmpeg) — same fix as the serve-boot path, second spawn
|
|
329
|
+
// site (hub launchd-PATH regression). It must carry the inherited PATH.
|
|
330
|
+
const vaultEnv = spawns.find((s) => s.short === "vault")?.env;
|
|
331
|
+
expect(vaultEnv?.PATH).toBeDefined();
|
|
332
|
+
expect(vaultEnv?.PATH?.length).toBeGreaterThan(0);
|
|
326
333
|
});
|
|
327
334
|
|
|
328
335
|
test("idempotent: already-installed + running returns succeeded immediately", async () => {
|
|
@@ -842,6 +849,31 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
842
849
|
expect(res.status).toBe(403);
|
|
843
850
|
});
|
|
844
851
|
|
|
852
|
+
test("supervisor.start throw → structured 500 module_op_failed, not a naked 500 (hub#536)", async () => {
|
|
853
|
+
seedVault();
|
|
854
|
+
// Models the hub#536 wedge: Bun.spawn throwing because the module's
|
|
855
|
+
// installDir/cwd no longer exists (NOT a missing binary — the preflight
|
|
856
|
+
// + rethrowIfMissing nets don't catch it, so it propagates out of
|
|
857
|
+
// supervisor.start). Pre-fix this escaped the handler as a bodyless 500
|
|
858
|
+
// and the CLI showed an opaque "request failed".
|
|
859
|
+
const supervisor = new Supervisor({
|
|
860
|
+
spawnFn: () => {
|
|
861
|
+
throw new Error("simulated spawn failure: cwd /gone/parachute-app does not exist");
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
865
|
+
const res = await handleStart(
|
|
866
|
+
postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
|
|
867
|
+
"vault",
|
|
868
|
+
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
|
|
869
|
+
);
|
|
870
|
+
expect(res.status).toBe(500);
|
|
871
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
872
|
+
expect(body.error).toBe("module_op_failed");
|
|
873
|
+
expect(body.error_description).toContain("vault start failed");
|
|
874
|
+
expect(body.error_description).toContain("cwd /gone/parachute-app does not exist");
|
|
875
|
+
});
|
|
876
|
+
|
|
845
877
|
test("pure supervisor.start of an installed module — NOT install (no bun add)", async () => {
|
|
846
878
|
seedVault();
|
|
847
879
|
const { supervisor, spawns } = makeIdleSupervisor();
|
|
@@ -1096,7 +1128,44 @@ describe("POST /api/modules/:short/restart", () => {
|
|
|
1096
1128
|
});
|
|
1097
1129
|
afterEach(() => h.cleanup());
|
|
1098
1130
|
|
|
1099
|
-
|
|
1131
|
+
/** Seed a minimal installed vault row (in services.json). */
|
|
1132
|
+
function seedVault(port = 1940): void {
|
|
1133
|
+
writeManifest(h.manifestPath, [
|
|
1134
|
+
{
|
|
1135
|
+
name: "parachute-vault",
|
|
1136
|
+
port,
|
|
1137
|
+
paths: ["/vault/default"],
|
|
1138
|
+
health: "/vault/default/health",
|
|
1139
|
+
version: "0.0.0-linked",
|
|
1140
|
+
},
|
|
1141
|
+
]);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
test("400 not_installed when the module isn't in services.json", async () => {
|
|
1145
|
+
// restart rebuilds the spawn req from services.json (hub#532), so a
|
|
1146
|
+
// missing row is a `not_installed` 400 (mirrors `start`) — before the
|
|
1147
|
+
// supervisor is even consulted.
|
|
1148
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1149
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1150
|
+
const res = await handleRestart(
|
|
1151
|
+
postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
|
|
1152
|
+
"vault",
|
|
1153
|
+
{
|
|
1154
|
+
db: h.db,
|
|
1155
|
+
issuer: ISSUER,
|
|
1156
|
+
manifestPath: h.manifestPath,
|
|
1157
|
+
configDir: h.dir,
|
|
1158
|
+
supervisor,
|
|
1159
|
+
run: async () => 0,
|
|
1160
|
+
},
|
|
1161
|
+
);
|
|
1162
|
+
expect(res.status).toBe(400);
|
|
1163
|
+
const body = (await res.json()) as { error: string };
|
|
1164
|
+
expect(body.error).toBe("not_installed");
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
test("404 not_supervised when installed but not currently running", async () => {
|
|
1168
|
+
seedVault();
|
|
1100
1169
|
const { supervisor } = makeIdleSupervisor();
|
|
1101
1170
|
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1102
1171
|
const res = await handleRestart(
|
|
@@ -1117,6 +1186,7 @@ describe("POST /api/modules/:short/restart", () => {
|
|
|
1117
1186
|
});
|
|
1118
1187
|
|
|
1119
1188
|
test("returns new state on success", async () => {
|
|
1189
|
+
seedVault();
|
|
1120
1190
|
const { supervisor } = makeIdleSupervisor();
|
|
1121
1191
|
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
1122
1192
|
|
|
@@ -1140,6 +1210,122 @@ describe("POST /api/modules/:short/restart", () => {
|
|
|
1140
1210
|
// on timing — either is acceptable here as long as it's not crashed/stopped.
|
|
1141
1211
|
expect(["restarting", "running", "starting"]).toContain(body.state.status);
|
|
1142
1212
|
});
|
|
1213
|
+
|
|
1214
|
+
test("re-injects the CURRENT hub origin on restart (hub#532)", async () => {
|
|
1215
|
+
// The core hub#532 fix: a module first started under one issuer, then
|
|
1216
|
+
// restarted after the canonical origin changed (e.g. post-expose), must
|
|
1217
|
+
// re-spawn with the NEW PARACHUTE_HUB_ORIGIN — not replay the stale
|
|
1218
|
+
// first-start snapshot. We drive the supervisor directly to control the
|
|
1219
|
+
// first-start env, then restart through the handler with a different
|
|
1220
|
+
// `deps.issuer` and assert the recorded spawn carries the new origin.
|
|
1221
|
+
seedVault(1940);
|
|
1222
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
1223
|
+
// First start with the OLD origin baked in (as boot would have).
|
|
1224
|
+
await supervisor.start({
|
|
1225
|
+
short: "vault",
|
|
1226
|
+
cmd: ["parachute-vault", "serve"],
|
|
1227
|
+
env: { PORT: "1940", PARACHUTE_HUB_ORIGIN: "https://old.example" },
|
|
1228
|
+
});
|
|
1229
|
+
const spawnsBefore = spawns.length;
|
|
1230
|
+
|
|
1231
|
+
const NEW_ORIGIN = "https://new.example";
|
|
1232
|
+
// The bearer was minted at the prior (loopback) ISSUER; the canonical
|
|
1233
|
+
// origin has since moved to NEW_ORIGIN. `knownIssuers` accepts the older
|
|
1234
|
+
// bearer iss while `deps.issuer` is the current canonical origin — exactly
|
|
1235
|
+
// the post-expose scenario hub#532 describes.
|
|
1236
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1237
|
+
const res = await handleRestart(
|
|
1238
|
+
postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
|
|
1239
|
+
"vault",
|
|
1240
|
+
{
|
|
1241
|
+
db: h.db,
|
|
1242
|
+
issuer: NEW_ORIGIN,
|
|
1243
|
+
knownIssuers: [ISSUER, NEW_ORIGIN],
|
|
1244
|
+
manifestPath: h.manifestPath,
|
|
1245
|
+
configDir: h.dir,
|
|
1246
|
+
supervisor,
|
|
1247
|
+
run: async () => 0,
|
|
1248
|
+
},
|
|
1249
|
+
);
|
|
1250
|
+
expect(res.status).toBe(200);
|
|
1251
|
+
// A fresh spawn happened, and it carries the NEW origin (rebuilt from the
|
|
1252
|
+
// live deps.issuer), not the stale one from first start.
|
|
1253
|
+
const reSpawn = spawns[spawnsBefore];
|
|
1254
|
+
expect(reSpawn?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
|
|
1255
|
+
expect(reSpawn?.env?.PORT).toBe("1940");
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
test("crash-restart after a restart reuses the REFRESHED req (hub#532)", async () => {
|
|
1259
|
+
// The refreshed SpawnRequest must become the supervisor entry's `req` so
|
|
1260
|
+
// a SUBSEQUENT crash-restart (handleExit → spawnAndWatch, which replays
|
|
1261
|
+
// `entry.req`) also carries the current env — not the original snapshot.
|
|
1262
|
+
seedVault(1940);
|
|
1263
|
+
// Controllable supervisor: spawns record env; each fake exposes a `crash()`
|
|
1264
|
+
// resolving `exited` with code 1 (a crash, not an operator stop). `kill()`
|
|
1265
|
+
// (driven by the injected group-aware `killFn`) resolves with 0 so the
|
|
1266
|
+
// handler's restart-stop completes promptly. Distinct codes let us crash a
|
|
1267
|
+
// specific child without tripping the stop path.
|
|
1268
|
+
const spawns: SpawnRequest[] = [];
|
|
1269
|
+
const procs: Array<{ pid: number; crash: () => void; kill: () => void }> = [];
|
|
1270
|
+
let nextPid = 8000;
|
|
1271
|
+
const spawnFn = (req: SpawnRequest): SupervisedProc => {
|
|
1272
|
+
spawns.push(req);
|
|
1273
|
+
const pid = nextPid++;
|
|
1274
|
+
let resolveExit!: (c: number | null) => void;
|
|
1275
|
+
const exited = new Promise<number | null>((r) => {
|
|
1276
|
+
resolveExit = r;
|
|
1277
|
+
});
|
|
1278
|
+
const proc = {
|
|
1279
|
+
pid,
|
|
1280
|
+
exited,
|
|
1281
|
+
stdout: null,
|
|
1282
|
+
stderr: null,
|
|
1283
|
+
kill: () => resolveExit(0),
|
|
1284
|
+
};
|
|
1285
|
+
procs.push({ pid, crash: () => resolveExit(1), kill: proc.kill });
|
|
1286
|
+
return proc;
|
|
1287
|
+
};
|
|
1288
|
+
const killFn = (pid: number): void => {
|
|
1289
|
+
procs.find((p) => p.pid === Math.abs(pid))?.kill();
|
|
1290
|
+
};
|
|
1291
|
+
const supervisor = new Supervisor({ spawnFn, killFn, restartDelayMs: 1, startReadyMs: 0 });
|
|
1292
|
+
|
|
1293
|
+
// First start with the OLD origin.
|
|
1294
|
+
await supervisor.start({
|
|
1295
|
+
short: "vault",
|
|
1296
|
+
cmd: ["parachute-vault", "serve"],
|
|
1297
|
+
env: { PORT: "1940", PARACHUTE_HUB_ORIGIN: "https://old.example" },
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
const NEW_ORIGIN = "https://new.example";
|
|
1301
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1302
|
+
await handleRestart(
|
|
1303
|
+
postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
|
|
1304
|
+
"vault",
|
|
1305
|
+
{
|
|
1306
|
+
db: h.db,
|
|
1307
|
+
issuer: NEW_ORIGIN,
|
|
1308
|
+
knownIssuers: [ISSUER, NEW_ORIGIN],
|
|
1309
|
+
manifestPath: h.manifestPath,
|
|
1310
|
+
configDir: h.dir,
|
|
1311
|
+
supervisor,
|
|
1312
|
+
run: async () => 0,
|
|
1313
|
+
},
|
|
1314
|
+
);
|
|
1315
|
+
// The restart re-spawn is the most recent — confirm the NEW origin landed.
|
|
1316
|
+
const restartSpawnIdx = spawns.length - 1;
|
|
1317
|
+
expect(spawns[restartSpawnIdx]?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
|
|
1318
|
+
|
|
1319
|
+
// Crash the restart-spawned child → the supervisor's crash-restart replays
|
|
1320
|
+
// entry.req (which `start` stored from the refreshed req).
|
|
1321
|
+
procs[restartSpawnIdx]?.crash();
|
|
1322
|
+
// Wait for the crash watcher's restartDelay + respawn.
|
|
1323
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
1324
|
+
expect(spawns.length).toBeGreaterThan(restartSpawnIdx + 1);
|
|
1325
|
+
const crashRespawn = spawns[spawns.length - 1];
|
|
1326
|
+
// The crash-restart inherited the REFRESHED req → still the new origin.
|
|
1327
|
+
expect(crashRespawn?.env?.PARACHUTE_HUB_ORIGIN).toBe(NEW_ORIGIN);
|
|
1328
|
+
});
|
|
1143
1329
|
});
|
|
1144
1330
|
|
|
1145
1331
|
describe("POST /api/modules/:short/upgrade", () => {
|
|
@@ -1485,13 +1671,19 @@ describe("well-known regen after module ops", () => {
|
|
|
1485
1671
|
const vaultRow = manifest.services.find((s) => s.name === "parachute-vault");
|
|
1486
1672
|
expect(vaultRow?.installDir).toBe(install.installDir);
|
|
1487
1673
|
|
|
1488
|
-
// The on-disk well-known doc reflects the new module
|
|
1674
|
+
// The on-disk well-known doc reflects the new module in `services`...
|
|
1489
1675
|
const doc = JSON.parse(readFileSync(wkPath, "utf8")) as {
|
|
1490
1676
|
services: Array<{ name: string; version: string }>;
|
|
1491
1677
|
vaults: Array<{ name: string }>;
|
|
1492
1678
|
};
|
|
1493
1679
|
expect(doc.services.some((s) => s.name === "parachute-vault")).toBe(true);
|
|
1494
|
-
|
|
1680
|
+
// ...but does NOT fabricate a phantom `default` vault row (hub#577). The
|
|
1681
|
+
// install seeds the entry at SEED_VERSION ("module installed, no instance
|
|
1682
|
+
// booted"); vault's own boot registers the real instance later. Until then
|
|
1683
|
+
// the vaults[] list is honestly empty so the management page reads "No
|
|
1684
|
+
// vaults yet" rather than showing a `default` that doesn't exist.
|
|
1685
|
+
expect(doc.vaults.some((v) => v.name === "default")).toBe(false);
|
|
1686
|
+
expect(doc.vaults).toEqual([]);
|
|
1495
1687
|
});
|
|
1496
1688
|
|
|
1497
1689
|
test("runInstall sets PORT in child env from services.json entry (hub#356)", async () => {
|
|
@@ -317,6 +317,45 @@ describe("GET /api/modules", () => {
|
|
|
317
317
|
expect(shorts).not.toContain("surface");
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
+
test("non-curated supervised modules appear in `supervised` (not `modules`) — hub#539", async () => {
|
|
321
|
+
// surface (the UI host) is supervised but not curated. Its run-state must
|
|
322
|
+
// surface in `supervised` so `parachute status` reads it `active`, while it
|
|
323
|
+
// stays OUT of the curated `modules` catalog (which drives the install UI).
|
|
324
|
+
writeManifest(h.manifestPath, [
|
|
325
|
+
{
|
|
326
|
+
name: "parachute-vault",
|
|
327
|
+
port: 1940,
|
|
328
|
+
paths: ["/vault/default"],
|
|
329
|
+
health: "/vault/default/health",
|
|
330
|
+
version: "0.4.5",
|
|
331
|
+
},
|
|
332
|
+
]);
|
|
333
|
+
const { supervisor } = makeIdleSupervisor();
|
|
334
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
335
|
+
await supervisor.start({ short: "surface", cmd: ["parachute-surface", "serve"] });
|
|
336
|
+
|
|
337
|
+
const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
|
|
338
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
339
|
+
db: h.db,
|
|
340
|
+
issuer: ISSUER,
|
|
341
|
+
manifestPath: h.manifestPath,
|
|
342
|
+
supervisor,
|
|
343
|
+
fetchLatestVersion: async () => null,
|
|
344
|
+
});
|
|
345
|
+
const body = (await res.json()) as {
|
|
346
|
+
modules: Array<{ short: string }>;
|
|
347
|
+
supervised: Array<{ short: string; supervisor_status: string | null; pid: number | null }>;
|
|
348
|
+
};
|
|
349
|
+
// surface stays out of the curated catalog…
|
|
350
|
+
expect(body.modules.map((m) => m.short)).not.toContain("surface");
|
|
351
|
+
// …but its run-state is in `supervised`, marked running with a pid.
|
|
352
|
+
const surf = body.supervised.find((m) => m.short === "surface");
|
|
353
|
+
expect(surf?.supervisor_status).toBe("running");
|
|
354
|
+
expect(typeof surf?.pid).toBe("number");
|
|
355
|
+
// Curated modules appear in `supervised` too (consumers dedupe by short).
|
|
356
|
+
expect(body.supervised.find((m) => m.short === "vault")?.supervisor_status).toBe("running");
|
|
357
|
+
});
|
|
358
|
+
|
|
320
359
|
test("surfaces installed_version from services.json", async () => {
|
|
321
360
|
writeManifest(h.manifestPath, [
|
|
322
361
|
{
|
|
@@ -716,10 +755,7 @@ describe("GET /api/modules", () => {
|
|
|
716
755
|
// Second back-to-back request must not re-hit the registry. The
|
|
717
756
|
// UI may poll this endpoint; we don't want it to slam npm.
|
|
718
757
|
let calls = 0;
|
|
719
|
-
const probe = async (
|
|
720
|
-
_pkg: string,
|
|
721
|
-
_channel: "latest" | "rc",
|
|
722
|
-
): Promise<string | null> => {
|
|
758
|
+
const probe = async (_pkg: string, _channel: "latest" | "rc"): Promise<string | null> => {
|
|
723
759
|
calls++;
|
|
724
760
|
return "0.5.0";
|
|
725
761
|
};
|
|
@@ -83,6 +83,11 @@ function putReq(body: unknown, headers: Record<string, string> = {}): Request {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Stub "no exposure recorded" so resolveIssuer / resolveIssuerSource don't
|
|
87
|
+
// pick up the host's real ~/.parachute/expose-state.json (the #531 expose
|
|
88
|
+
// tier would otherwise shadow the request-origin source these tests assert).
|
|
89
|
+
const noExpose = (): string | undefined => undefined;
|
|
90
|
+
|
|
86
91
|
function deps(
|
|
87
92
|
h: Harness,
|
|
88
93
|
overrides: Partial<Parameters<typeof handleApiSettingsHubOrigin>[1]> = {},
|
|
@@ -90,8 +95,8 @@ function deps(
|
|
|
90
95
|
return {
|
|
91
96
|
db: h.db,
|
|
92
97
|
issuer: ISSUER,
|
|
93
|
-
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
|
|
94
|
-
resolvedSource: resolveIssuerSource(h.db, undefined),
|
|
98
|
+
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
|
|
99
|
+
resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
|
|
95
100
|
...overrides,
|
|
96
101
|
};
|
|
97
102
|
}
|
|
@@ -414,8 +419,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
|
|
|
414
419
|
const g1 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
415
420
|
db: h.db,
|
|
416
421
|
issuer: ISSUER,
|
|
417
|
-
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
|
|
418
|
-
resolvedSource: resolveIssuerSource(h.db, undefined),
|
|
422
|
+
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
|
|
423
|
+
resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
|
|
419
424
|
});
|
|
420
425
|
const b1 = (await g1.json()) as { source: string; resolved_issuer: string };
|
|
421
426
|
expect(b1.source).toBe("request");
|
|
@@ -426,8 +431,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
|
|
|
426
431
|
{
|
|
427
432
|
db: h.db,
|
|
428
433
|
issuer: ISSUER,
|
|
429
|
-
resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined),
|
|
430
|
-
resolvedSource: resolveIssuerSource(h.db, undefined),
|
|
434
|
+
resolvedIssuer: resolveIssuer(putReq({}), h.db, undefined, noExpose),
|
|
435
|
+
resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
|
|
431
436
|
},
|
|
432
437
|
);
|
|
433
438
|
expect(p.status).toBe(200);
|
|
@@ -437,8 +442,8 @@ describe("change takes effect on the next request (no restart needed)", () => {
|
|
|
437
442
|
const g2 = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
438
443
|
db: h.db,
|
|
439
444
|
issuer: ISSUER,
|
|
440
|
-
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined),
|
|
441
|
-
resolvedSource: resolveIssuerSource(h.db, undefined),
|
|
445
|
+
resolvedIssuer: resolveIssuer(getReq(), h.db, undefined, noExpose),
|
|
446
|
+
resolvedSource: resolveIssuerSource(h.db, undefined, noExpose),
|
|
442
447
|
});
|
|
443
448
|
const b2 = (await g2.json()) as {
|
|
444
449
|
hub_origin: string | null;
|