@openparachute/hub 0.6.5-rc.7 → 0.7.0
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 -1
- package/src/__tests__/account-setup.test.ts +34 -0
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-db-liveness.test.ts +12 -7
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-db-liveness.ts +33 -17
- package/src/hub-server.ts +354 -61
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
|
@@ -301,6 +301,28 @@ describe("POST /vaults — body validation", () => {
|
|
|
301
301
|
h.cleanup();
|
|
302
302
|
}
|
|
303
303
|
});
|
|
304
|
+
|
|
305
|
+
test('400 when name is "admin" (would shadow the /vault/admin daemon-level mount — B2h)', async () => {
|
|
306
|
+
// A vault named "admin" would capture `/vault/admin`, the daemon-level
|
|
307
|
+
// mount for vault's own multi-vault admin surface (B-route). The reserved
|
|
308
|
+
// set is now the consolidated RESERVED_VAULT_NAMES in vault-name.ts, so
|
|
309
|
+
// the wizard + invite redemption reject it too.
|
|
310
|
+
const h = makeHarness();
|
|
311
|
+
try {
|
|
312
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
313
|
+
try {
|
|
314
|
+
rotateSigningKey(db);
|
|
315
|
+
const res = await call({ db, manifestPath: h.manifestPath, body: { name: "admin" } });
|
|
316
|
+
expect(res.status).toBe(400);
|
|
317
|
+
const body = (await res.json()) as { error_description: string };
|
|
318
|
+
expect(body.error_description).toMatch(/reserved/i);
|
|
319
|
+
} finally {
|
|
320
|
+
db.close();
|
|
321
|
+
}
|
|
322
|
+
} finally {
|
|
323
|
+
h.cleanup();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
304
326
|
});
|
|
305
327
|
|
|
306
328
|
describe("POST /vaults — orchestration", () => {
|
|
@@ -699,3 +721,571 @@ describe("POST /vaults — method gating", () => {
|
|
|
699
721
|
}
|
|
700
722
|
});
|
|
701
723
|
});
|
|
724
|
+
|
|
725
|
+
// ===========================================================================
|
|
726
|
+
// DELETE /vaults/<name> — the identity cascade (B1, hub-module-boundary)
|
|
727
|
+
// ===========================================================================
|
|
728
|
+
|
|
729
|
+
import { handleDeleteVault } from "../admin-vaults.ts";
|
|
730
|
+
import { registerClient } from "../clients.ts";
|
|
731
|
+
import { putConnection, readConnections } from "../connections-store.ts";
|
|
732
|
+
import { findGrant, recordGrant } from "../grants.ts";
|
|
733
|
+
import { findInviteByHash, issueInvite } from "../invites.ts";
|
|
734
|
+
import { findTokenRowByJti, listActiveRevocations, recordTokenMint } from "../jwt-sign.ts";
|
|
735
|
+
import { createUser, setUserVaults } from "../users.ts";
|
|
736
|
+
|
|
737
|
+
const VAULT_ORIGIN = "http://127.0.0.1:19400";
|
|
738
|
+
const CHANNEL_ORIGIN = "http://127.0.0.1:19410";
|
|
739
|
+
|
|
740
|
+
/** Successful no-op runner — records the commands it was asked to run. */
|
|
741
|
+
function stubRun(
|
|
742
|
+
exitCode = 0,
|
|
743
|
+
stderr = "",
|
|
744
|
+
): {
|
|
745
|
+
run: (cmd: readonly string[]) => Promise<RunResult>;
|
|
746
|
+
calls: (readonly string[])[];
|
|
747
|
+
} {
|
|
748
|
+
const calls: (readonly string[])[] = [];
|
|
749
|
+
return {
|
|
750
|
+
calls,
|
|
751
|
+
run: async (cmd) => {
|
|
752
|
+
calls.push(cmd);
|
|
753
|
+
return { exitCode, stdout: "", stderr };
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Recording fetch mock keyed by `METHOD <pathname>` (mirrors admin-connections.test). */
|
|
759
|
+
function mockFetch(routes: Record<string, () => Response>): {
|
|
760
|
+
fetchImpl: typeof fetch;
|
|
761
|
+
calls: { method: string; url: string }[];
|
|
762
|
+
} {
|
|
763
|
+
const calls: { method: string; url: string }[] = [];
|
|
764
|
+
const fetchImpl = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
765
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
766
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
767
|
+
calls.push({ method, url });
|
|
768
|
+
const responder = routes[`${method} ${new URL(url).pathname}`];
|
|
769
|
+
if (!responder) return new Response("no route", { status: 599 });
|
|
770
|
+
return responder();
|
|
771
|
+
}) as typeof fetch;
|
|
772
|
+
return { fetchImpl, calls };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function okJson(payload: unknown): Response {
|
|
776
|
+
return new Response(JSON.stringify(payload), {
|
|
777
|
+
status: 200,
|
|
778
|
+
headers: { "content-type": "application/json" },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
interface DeleteCallOpts {
|
|
783
|
+
name: string;
|
|
784
|
+
body?: unknown;
|
|
785
|
+
authHeader?: string | null;
|
|
786
|
+
db: ReturnType<typeof openHubDb>;
|
|
787
|
+
manifestPath: string;
|
|
788
|
+
connectionsStorePath: string;
|
|
789
|
+
runCommand?: (cmd: readonly string[]) => Promise<RunResult>;
|
|
790
|
+
restartVaultModule?: () => Promise<void>;
|
|
791
|
+
channelOrigin?: string | null;
|
|
792
|
+
fetchImpl?: typeof fetch;
|
|
793
|
+
resolveVaultOrigin?: (v: string) => string | null;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function callDelete(opts: DeleteCallOpts): Promise<Response> {
|
|
797
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
798
|
+
if (opts.authHeader === undefined) {
|
|
799
|
+
headers.set("authorization", `Bearer ${await adminToken(opts.db)}`);
|
|
800
|
+
} else if (opts.authHeader !== null) {
|
|
801
|
+
headers.set("authorization", opts.authHeader);
|
|
802
|
+
}
|
|
803
|
+
const req = new Request(`${ISSUER}/vaults/${opts.name}`, {
|
|
804
|
+
method: "DELETE",
|
|
805
|
+
headers,
|
|
806
|
+
body: JSON.stringify(opts.body ?? { confirm: opts.name }),
|
|
807
|
+
});
|
|
808
|
+
return handleDeleteVault(req, opts.name, {
|
|
809
|
+
db: opts.db,
|
|
810
|
+
issuer: ISSUER,
|
|
811
|
+
manifestPath: opts.manifestPath,
|
|
812
|
+
connectionsStorePath: opts.connectionsStorePath,
|
|
813
|
+
channelOrigin: opts.channelOrigin ?? null,
|
|
814
|
+
resolveVaultOrigin: opts.resolveVaultOrigin ?? (() => VAULT_ORIGIN),
|
|
815
|
+
runCommand: opts.runCommand ?? stubRun().run,
|
|
816
|
+
...(opts.restartVaultModule ? { restartVaultModule: opts.restartVaultModule } : {}),
|
|
817
|
+
...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** services.json with one multi-path vault row (the canonical Q5 shape). */
|
|
822
|
+
function writeVaults(manifestPath: string, instanceNames: string[]): void {
|
|
823
|
+
writeManifest(
|
|
824
|
+
{
|
|
825
|
+
services: [
|
|
826
|
+
{
|
|
827
|
+
name: "parachute-vault",
|
|
828
|
+
port: 1940,
|
|
829
|
+
paths: instanceNames.map((n) => `/vault/${n}`),
|
|
830
|
+
health: `/vault/${instanceNames[0]}/health`,
|
|
831
|
+
version: "0.5.0",
|
|
832
|
+
},
|
|
833
|
+
],
|
|
834
|
+
},
|
|
835
|
+
manifestPath,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function registryRow(db: ReturnType<typeof openHubDb>, jti: string, scopes: string[]): void {
|
|
840
|
+
recordTokenMint(db, {
|
|
841
|
+
jti,
|
|
842
|
+
createdVia: "cli_mint",
|
|
843
|
+
subject: "user-admin",
|
|
844
|
+
clientId: "test-client",
|
|
845
|
+
scopes,
|
|
846
|
+
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
describe("DELETE /vaults/<name> — gates", () => {
|
|
851
|
+
test("401 without a bearer; 403 without host:admin", async () => {
|
|
852
|
+
const h = makeHarness();
|
|
853
|
+
try {
|
|
854
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
855
|
+
try {
|
|
856
|
+
rotateSigningKey(db);
|
|
857
|
+
writeVaults(h.manifestPath, ["default", "work"]);
|
|
858
|
+
const store = join(h.dir, "connections.json");
|
|
859
|
+
const noAuth = await callDelete({
|
|
860
|
+
name: "work",
|
|
861
|
+
db,
|
|
862
|
+
manifestPath: h.manifestPath,
|
|
863
|
+
connectionsStorePath: store,
|
|
864
|
+
authHeader: null,
|
|
865
|
+
});
|
|
866
|
+
expect(noAuth.status).toBe(401);
|
|
867
|
+
const readOnly = await callDelete({
|
|
868
|
+
name: "work",
|
|
869
|
+
db,
|
|
870
|
+
manifestPath: h.manifestPath,
|
|
871
|
+
connectionsStorePath: store,
|
|
872
|
+
authHeader: `Bearer ${await readOnlyToken(db)}`,
|
|
873
|
+
});
|
|
874
|
+
expect(readOnly.status).toBe(403);
|
|
875
|
+
} finally {
|
|
876
|
+
db.close();
|
|
877
|
+
}
|
|
878
|
+
} finally {
|
|
879
|
+
h.cleanup();
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("400 confirm_mismatch when the body doesn't retype the name (nothing revoked, CLI never runs)", async () => {
|
|
884
|
+
const h = makeHarness();
|
|
885
|
+
try {
|
|
886
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
887
|
+
try {
|
|
888
|
+
rotateSigningKey(db);
|
|
889
|
+
writeVaults(h.manifestPath, ["default", "work"]);
|
|
890
|
+
registryRow(db, "jti-confirm-guard", ["vault:work:write"]);
|
|
891
|
+
const runner = stubRun();
|
|
892
|
+
for (const body of [{ confirm: "wrong" }, {}, { confirm: "" }]) {
|
|
893
|
+
const res = await callDelete({
|
|
894
|
+
name: "work",
|
|
895
|
+
body,
|
|
896
|
+
db,
|
|
897
|
+
manifestPath: h.manifestPath,
|
|
898
|
+
connectionsStorePath: join(h.dir, "connections.json"),
|
|
899
|
+
runCommand: runner.run,
|
|
900
|
+
});
|
|
901
|
+
expect(res.status).toBe(400);
|
|
902
|
+
expect(((await res.json()) as { error: string }).error).toBe("confirm_mismatch");
|
|
903
|
+
}
|
|
904
|
+
expect(runner.calls.length).toBe(0);
|
|
905
|
+
expect(findTokenRowByJti(db, "jti-confirm-guard")?.revokedAt).toBeNull();
|
|
906
|
+
} finally {
|
|
907
|
+
db.close();
|
|
908
|
+
}
|
|
909
|
+
} finally {
|
|
910
|
+
h.cleanup();
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("404 on an unknown vault", async () => {
|
|
915
|
+
const h = makeHarness();
|
|
916
|
+
try {
|
|
917
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
918
|
+
try {
|
|
919
|
+
rotateSigningKey(db);
|
|
920
|
+
writeVaults(h.manifestPath, ["default", "work"]);
|
|
921
|
+
const res = await callDelete({
|
|
922
|
+
name: "ghost",
|
|
923
|
+
db,
|
|
924
|
+
manifestPath: h.manifestPath,
|
|
925
|
+
connectionsStorePath: join(h.dir, "connections.json"),
|
|
926
|
+
});
|
|
927
|
+
expect(res.status).toBe(404);
|
|
928
|
+
} finally {
|
|
929
|
+
db.close();
|
|
930
|
+
}
|
|
931
|
+
} finally {
|
|
932
|
+
h.cleanup();
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("409 last_vault on the only remaining vault (CLI never runs)", async () => {
|
|
937
|
+
const h = makeHarness();
|
|
938
|
+
try {
|
|
939
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
940
|
+
try {
|
|
941
|
+
rotateSigningKey(db);
|
|
942
|
+
writeVaults(h.manifestPath, ["default"]);
|
|
943
|
+
const runner = stubRun();
|
|
944
|
+
const res = await callDelete({
|
|
945
|
+
name: "default",
|
|
946
|
+
db,
|
|
947
|
+
manifestPath: h.manifestPath,
|
|
948
|
+
connectionsStorePath: join(h.dir, "connections.json"),
|
|
949
|
+
runCommand: runner.run,
|
|
950
|
+
});
|
|
951
|
+
expect(res.status).toBe(409);
|
|
952
|
+
const out = (await res.json()) as { error: string; error_description: string };
|
|
953
|
+
expect(out.error).toBe("last_vault");
|
|
954
|
+
// Guidance names the CLI escape hatch + the resurrection reason.
|
|
955
|
+
expect(out.error_description).toContain("parachute-vault remove");
|
|
956
|
+
expect(runner.calls.length).toBe(0);
|
|
957
|
+
} finally {
|
|
958
|
+
db.close();
|
|
959
|
+
}
|
|
960
|
+
} finally {
|
|
961
|
+
h.cleanup();
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("reserved names are deletable (a squatted `admin` vault can be removed)", async () => {
|
|
966
|
+
const h = makeHarness();
|
|
967
|
+
try {
|
|
968
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
969
|
+
try {
|
|
970
|
+
rotateSigningKey(db);
|
|
971
|
+
// A vault squatted on "admin" BEFORE the B2h reservation existed.
|
|
972
|
+
writeVaults(h.manifestPath, ["default", "admin"]);
|
|
973
|
+
const runner = stubRun();
|
|
974
|
+
const res = await callDelete({
|
|
975
|
+
name: "admin",
|
|
976
|
+
db,
|
|
977
|
+
manifestPath: h.manifestPath,
|
|
978
|
+
connectionsStorePath: join(h.dir, "connections.json"),
|
|
979
|
+
runCommand: runner.run,
|
|
980
|
+
});
|
|
981
|
+
expect(res.status).toBe(200);
|
|
982
|
+
expect(runner.calls).toContainEqual(["parachute-vault", "remove", "admin", "--yes"]);
|
|
983
|
+
} finally {
|
|
984
|
+
db.close();
|
|
985
|
+
}
|
|
986
|
+
} finally {
|
|
987
|
+
h.cleanup();
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
describe("DELETE /vaults/<name> — the identity cascade", () => {
|
|
993
|
+
test("full cascade: tokens, grants, user_vaults, invites, connections, mechanics, restart", async () => {
|
|
994
|
+
const h = makeHarness();
|
|
995
|
+
try {
|
|
996
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
997
|
+
try {
|
|
998
|
+
rotateSigningKey(db);
|
|
999
|
+
writeVaults(h.manifestPath, ["default", "work"]);
|
|
1000
|
+
const store = join(h.dir, "connections.json");
|
|
1001
|
+
|
|
1002
|
+
// 1. Registry rows: two naming "work" (one standalone + the
|
|
1003
|
+
// connection's registered mint below), one naming "default".
|
|
1004
|
+
registryRow(db, "jti-work-1", ["vault:work:write"]);
|
|
1005
|
+
registryRow(db, "jti-conn-1", ["channel:send"]); // connection webhook bearer (non-vault scope)
|
|
1006
|
+
registryRow(db, "jti-default-1", ["vault:default:read"]);
|
|
1007
|
+
|
|
1008
|
+
// 2. Grants: one spanning both vaults (rewrite), one work-only (drop).
|
|
1009
|
+
const alice = await createUser(db, "alice", "alice-passphrase-123");
|
|
1010
|
+
const clientSpan = registerClient(db, { redirectUris: ["https://a.example/cb"] }).client
|
|
1011
|
+
.clientId;
|
|
1012
|
+
const clientWorkOnly = registerClient(db, { redirectUris: ["https://b.example/cb"] }).client
|
|
1013
|
+
.clientId;
|
|
1014
|
+
recordGrant(db, alice.id, clientSpan, [
|
|
1015
|
+
"vault:work:read",
|
|
1016
|
+
"vault:default:read",
|
|
1017
|
+
"offline_access",
|
|
1018
|
+
]);
|
|
1019
|
+
recordGrant(db, alice.id, clientWorkOnly, ["vault:work:admin"]);
|
|
1020
|
+
|
|
1021
|
+
// 3. user_vaults: alice assigned to both.
|
|
1022
|
+
setUserVaults(db, alice.id, ["work", "default"]);
|
|
1023
|
+
|
|
1024
|
+
// 4. Invites: pending pinned to work (invalidate), pending pinned to
|
|
1025
|
+
// default (keep), already-redeemed work invite (terminal, keep).
|
|
1026
|
+
const pendingWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
|
|
1027
|
+
const pendingDefault = issueInvite(db, { createdBy: alice.id, vaultName: "default" });
|
|
1028
|
+
const redeemedWork = issueInvite(db, { createdBy: alice.id, vaultName: "work" });
|
|
1029
|
+
db.prepare("UPDATE invites SET used_at = ?, redeemed_user_id = ? WHERE token = ?").run(
|
|
1030
|
+
new Date().toISOString(),
|
|
1031
|
+
alice.id,
|
|
1032
|
+
redeemedWork.invite.tokenHash,
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
// 5. Connections: one sourced on work (torn down), one on default (kept).
|
|
1036
|
+
putConnection(store, {
|
|
1037
|
+
id: "conn-work",
|
|
1038
|
+
source: { module: "vault", vault: "work", event: "note.created" },
|
|
1039
|
+
sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
|
|
1040
|
+
provisioned: {
|
|
1041
|
+
type: "vault-trigger",
|
|
1042
|
+
vault: "work",
|
|
1043
|
+
triggerName: "conn_conn-work",
|
|
1044
|
+
mintedJtis: ["jti-conn-1"],
|
|
1045
|
+
},
|
|
1046
|
+
createdAt: new Date().toISOString(),
|
|
1047
|
+
});
|
|
1048
|
+
putConnection(store, {
|
|
1049
|
+
id: "conn-default",
|
|
1050
|
+
source: { module: "vault", vault: "default", event: "note.created" },
|
|
1051
|
+
sink: { module: "channel", action: "message.deliver", params: { channel: "ops" } },
|
|
1052
|
+
provisioned: { type: "vault-trigger", vault: "default", triggerName: "conn_d" },
|
|
1053
|
+
createdAt: new Date().toISOString(),
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
const { fetchImpl, calls } = mockFetch({
|
|
1057
|
+
"DELETE /vault/work/api/triggers/conn_conn-work": () => okJson({ ok: true }),
|
|
1058
|
+
"DELETE /api/channels/eng": () => okJson({ ok: true }),
|
|
1059
|
+
// Channel scan: one legacy vault-backed entry still references work.
|
|
1060
|
+
"GET /api/channels": () =>
|
|
1061
|
+
okJson({
|
|
1062
|
+
channels: [
|
|
1063
|
+
{ name: "legacy-chan", transport: "vault", vault: "work" },
|
|
1064
|
+
{ name: "other-chan", transport: "vault", vault: "default" },
|
|
1065
|
+
{ name: "tg", transport: "telegram", vault: null },
|
|
1066
|
+
],
|
|
1067
|
+
}),
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
const runner = stubRun();
|
|
1071
|
+
let restarted = false;
|
|
1072
|
+
const res = await callDelete({
|
|
1073
|
+
name: "work",
|
|
1074
|
+
db,
|
|
1075
|
+
manifestPath: h.manifestPath,
|
|
1076
|
+
connectionsStorePath: store,
|
|
1077
|
+
runCommand: runner.run,
|
|
1078
|
+
restartVaultModule: async () => {
|
|
1079
|
+
restarted = true;
|
|
1080
|
+
},
|
|
1081
|
+
channelOrigin: CHANNEL_ORIGIN,
|
|
1082
|
+
fetchImpl,
|
|
1083
|
+
resolveVaultOrigin: (v) => (v === "work" ? VAULT_ORIGIN : null),
|
|
1084
|
+
});
|
|
1085
|
+
expect(res.status).toBe(200);
|
|
1086
|
+
const out = (await res.json()) as {
|
|
1087
|
+
ok: boolean;
|
|
1088
|
+
name: string;
|
|
1089
|
+
cascade: {
|
|
1090
|
+
tokens_revoked: number;
|
|
1091
|
+
grants_rewritten: number;
|
|
1092
|
+
grants_dropped: number;
|
|
1093
|
+
user_vaults_removed: number;
|
|
1094
|
+
invites_invalidated: number;
|
|
1095
|
+
connections_torn_down: number;
|
|
1096
|
+
orphaned_channels: string[];
|
|
1097
|
+
vault_removed: boolean;
|
|
1098
|
+
module_restarted: boolean;
|
|
1099
|
+
};
|
|
1100
|
+
};
|
|
1101
|
+
expect(out.ok).toBe(true);
|
|
1102
|
+
expect(out.name).toBe("work");
|
|
1103
|
+
|
|
1104
|
+
// 1. Registry sweep: the work-scoped row revoked; default untouched.
|
|
1105
|
+
// The connection's webhook-bearer row (channel:send — names no
|
|
1106
|
+
// vault) is revoked by the CONNECTION teardown, not the sweep.
|
|
1107
|
+
expect(out.cascade.tokens_revoked).toBe(1);
|
|
1108
|
+
expect(findTokenRowByJti(db, "jti-work-1")?.revokedAt).not.toBeNull();
|
|
1109
|
+
expect(findTokenRowByJti(db, "jti-conn-1")?.revokedAt).not.toBeNull();
|
|
1110
|
+
expect(findTokenRowByJti(db, "jti-default-1")?.revokedAt).toBeNull();
|
|
1111
|
+
expect(listActiveRevocations(db, new Date())).toContain("jti-work-1");
|
|
1112
|
+
|
|
1113
|
+
// 2. Grants: span-rewritten (kept, minus work scopes); work-only dropped.
|
|
1114
|
+
expect(out.cascade.grants_rewritten).toBe(1);
|
|
1115
|
+
expect(out.cascade.grants_dropped).toBe(1);
|
|
1116
|
+
const span = findGrant(db, alice.id, clientSpan);
|
|
1117
|
+
expect(span?.scopes.sort()).toEqual(["offline_access", "vault:default:read"]);
|
|
1118
|
+
expect(findGrant(db, alice.id, clientWorkOnly)).toBeNull();
|
|
1119
|
+
|
|
1120
|
+
// 3. user_vaults: work row gone, default row stays.
|
|
1121
|
+
expect(out.cascade.user_vaults_removed).toBe(1);
|
|
1122
|
+
const remaining = db
|
|
1123
|
+
.query<{ vault_name: string }, [string]>(
|
|
1124
|
+
"SELECT vault_name FROM user_vaults WHERE user_id = ?",
|
|
1125
|
+
)
|
|
1126
|
+
.all(alice.id)
|
|
1127
|
+
.map((r) => r.vault_name);
|
|
1128
|
+
expect(remaining).toEqual(["default"]);
|
|
1129
|
+
|
|
1130
|
+
// 4. Invites: pending-work revoked; pending-default + redeemed-work untouched.
|
|
1131
|
+
expect(out.cascade.invites_invalidated).toBe(1);
|
|
1132
|
+
expect(findInviteByHash(db, pendingWork.invite.tokenHash)?.revokedAt).not.toBeNull();
|
|
1133
|
+
expect(findInviteByHash(db, pendingDefault.invite.tokenHash)?.revokedAt).toBeNull();
|
|
1134
|
+
expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.usedAt).not.toBeNull();
|
|
1135
|
+
expect(findInviteByHash(db, redeemedWork.invite.tokenHash)?.revokedAt).toBeNull();
|
|
1136
|
+
|
|
1137
|
+
// 5. Connections: work connection torn down (trigger deregistered +
|
|
1138
|
+
// channel entry deleted + record removed); default connection kept.
|
|
1139
|
+
expect(out.cascade.connections_torn_down).toBe(1);
|
|
1140
|
+
expect(
|
|
1141
|
+
calls.some(
|
|
1142
|
+
(c) =>
|
|
1143
|
+
c.method === "DELETE" && c.url.endsWith("/vault/work/api/triggers/conn_conn-work"),
|
|
1144
|
+
),
|
|
1145
|
+
).toBe(true);
|
|
1146
|
+
expect(
|
|
1147
|
+
calls.some((c) => c.method === "DELETE" && c.url.endsWith("/api/channels/eng")),
|
|
1148
|
+
).toBe(true);
|
|
1149
|
+
const records = readConnections(store);
|
|
1150
|
+
expect(records.map((r) => r.id)).toEqual(["conn-default"]);
|
|
1151
|
+
|
|
1152
|
+
// Channel scan: legacy entry surfaced, NOT deleted (report-only —
|
|
1153
|
+
// no DELETE call for it), and the default-vault entry not flagged.
|
|
1154
|
+
expect(out.cascade.orphaned_channels).toEqual(["legacy-chan"]);
|
|
1155
|
+
expect(calls.some((c) => c.method === "DELETE" && c.url.includes("legacy-chan"))).toBe(
|
|
1156
|
+
false,
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
// 6 + 7. Mechanics + eviction.
|
|
1160
|
+
expect(runner.calls).toContainEqual(["parachute-vault", "remove", "work", "--yes"]);
|
|
1161
|
+
expect(out.cascade.vault_removed).toBe(true);
|
|
1162
|
+
expect(restarted).toBe(true);
|
|
1163
|
+
expect(out.cascade.module_restarted).toBe(true);
|
|
1164
|
+
} finally {
|
|
1165
|
+
db.close();
|
|
1166
|
+
}
|
|
1167
|
+
} finally {
|
|
1168
|
+
h.cleanup();
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
test("registry sweep is EXACT-match — `_` in vault names is not a LIKE wildcard", async () => {
|
|
1173
|
+
// Vault `my_vault` must not revoke `myxvault` tokens: under SQL LIKE,
|
|
1174
|
+
// `_` matches any single character, so a LIKE-based sweep for
|
|
1175
|
+
// `%vault:my_vault:%` would catch `vault:myxvault:write` too. The sweep
|
|
1176
|
+
// splits + exact-matches scope segments instead.
|
|
1177
|
+
const h = makeHarness();
|
|
1178
|
+
try {
|
|
1179
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1180
|
+
try {
|
|
1181
|
+
rotateSigningKey(db);
|
|
1182
|
+
writeVaults(h.manifestPath, ["my_vault", "myxvault"]);
|
|
1183
|
+
registryRow(db, "jti-underscore", ["vault:my_vault:write"]);
|
|
1184
|
+
registryRow(db, "jti-lookalike", ["vault:myxvault:write"]);
|
|
1185
|
+
const res = await callDelete({
|
|
1186
|
+
name: "my_vault",
|
|
1187
|
+
db,
|
|
1188
|
+
manifestPath: h.manifestPath,
|
|
1189
|
+
connectionsStorePath: join(h.dir, "connections.json"),
|
|
1190
|
+
});
|
|
1191
|
+
expect(res.status).toBe(200);
|
|
1192
|
+
const out = (await res.json()) as { cascade: { tokens_revoked: number } };
|
|
1193
|
+
expect(out.cascade.tokens_revoked).toBe(1);
|
|
1194
|
+
expect(findTokenRowByJti(db, "jti-underscore")?.revokedAt).not.toBeNull();
|
|
1195
|
+
// The lookalike vault's token is NOT revoked.
|
|
1196
|
+
expect(findTokenRowByJti(db, "jti-lookalike")?.revokedAt).toBeNull();
|
|
1197
|
+
} finally {
|
|
1198
|
+
db.close();
|
|
1199
|
+
}
|
|
1200
|
+
} finally {
|
|
1201
|
+
h.cleanup();
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test("a multi-vault grant is REWRITTEN, not dropped (dropping over-revokes)", async () => {
|
|
1206
|
+
const h = makeHarness();
|
|
1207
|
+
try {
|
|
1208
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1209
|
+
try {
|
|
1210
|
+
rotateSigningKey(db);
|
|
1211
|
+
writeVaults(h.manifestPath, ["default", "work"]);
|
|
1212
|
+
const bob = await createUser(db, "bob", "bob-passphrase-12345");
|
|
1213
|
+
const claude = registerClient(db, { redirectUris: ["https://c.example/cb"] }).client
|
|
1214
|
+
.clientId;
|
|
1215
|
+
recordGrant(db, bob.id, claude, ["vault:work:write", "vault:default:write"]);
|
|
1216
|
+
const res = await callDelete({
|
|
1217
|
+
name: "work",
|
|
1218
|
+
db,
|
|
1219
|
+
manifestPath: h.manifestPath,
|
|
1220
|
+
connectionsStorePath: join(h.dir, "connections.json"),
|
|
1221
|
+
});
|
|
1222
|
+
expect(res.status).toBe(200);
|
|
1223
|
+
const grant = findGrant(db, bob.id, claude);
|
|
1224
|
+
// Row survives with the other vault's scope intact.
|
|
1225
|
+
expect(grant).not.toBeNull();
|
|
1226
|
+
expect(grant?.scopes).toEqual(["vault:default:write"]);
|
|
1227
|
+
} finally {
|
|
1228
|
+
db.close();
|
|
1229
|
+
}
|
|
1230
|
+
} finally {
|
|
1231
|
+
h.cleanup();
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test("mechanics failure → 500, identity artifacts stay revoked", async () => {
|
|
1236
|
+
const h = makeHarness();
|
|
1237
|
+
try {
|
|
1238
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1239
|
+
try {
|
|
1240
|
+
rotateSigningKey(db);
|
|
1241
|
+
writeVaults(h.manifestPath, ["default", "work"]);
|
|
1242
|
+
registryRow(db, "jti-pre-fail", ["vault:work:write"]);
|
|
1243
|
+
const res = await callDelete({
|
|
1244
|
+
name: "work",
|
|
1245
|
+
db,
|
|
1246
|
+
manifestPath: h.manifestPath,
|
|
1247
|
+
connectionsStorePath: join(h.dir, "connections.json"),
|
|
1248
|
+
runCommand: stubRun(1, "disk on fire").run,
|
|
1249
|
+
});
|
|
1250
|
+
expect(res.status).toBe(500);
|
|
1251
|
+
const out = (await res.json()) as { error: string; error_description: string };
|
|
1252
|
+
expect(out.error).toBe("server_error");
|
|
1253
|
+
expect(out.error_description).toContain("disk on fire");
|
|
1254
|
+
// Revocation is the safe direction — the sweep is NOT rolled back.
|
|
1255
|
+
expect(findTokenRowByJti(db, "jti-pre-fail")?.revokedAt).not.toBeNull();
|
|
1256
|
+
} finally {
|
|
1257
|
+
db.close();
|
|
1258
|
+
}
|
|
1259
|
+
} finally {
|
|
1260
|
+
h.cleanup();
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test("no supervisor → 200 with a module_restart warning (not silent)", async () => {
|
|
1265
|
+
const h = makeHarness();
|
|
1266
|
+
try {
|
|
1267
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1268
|
+
try {
|
|
1269
|
+
rotateSigningKey(db);
|
|
1270
|
+
writeVaults(h.manifestPath, ["default", "work"]);
|
|
1271
|
+
const res = await callDelete({
|
|
1272
|
+
name: "work",
|
|
1273
|
+
db,
|
|
1274
|
+
manifestPath: h.manifestPath,
|
|
1275
|
+
connectionsStorePath: join(h.dir, "connections.json"),
|
|
1276
|
+
});
|
|
1277
|
+
expect(res.status).toBe(200);
|
|
1278
|
+
const out = (await res.json()) as {
|
|
1279
|
+
cascade: { module_restarted: boolean };
|
|
1280
|
+
warnings?: { step: string; detail: string }[];
|
|
1281
|
+
};
|
|
1282
|
+
expect(out.cascade.module_restarted).toBe(false);
|
|
1283
|
+
expect(out.warnings?.some((w) => w.step === "module_restart")).toBe(true);
|
|
1284
|
+
} finally {
|
|
1285
|
+
db.close();
|
|
1286
|
+
}
|
|
1287
|
+
} finally {
|
|
1288
|
+
h.cleanup();
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
});
|
|
@@ -208,11 +208,27 @@ describe("parseModulesPath", () => {
|
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
test("
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
211
|
+
test("accepts any KNOWN module short — channel install now resolves (was the bug)", () => {
|
|
212
|
+
// Post-2026-06-09 (modular-UI architecture, P2) the install-path gate is
|
|
213
|
+
// `isKnownModuleShort` (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old
|
|
214
|
+
// CURATED_MODULES whitelist. channel is in FIRST_PARTY_FALLBACKS, so its
|
|
215
|
+
// install path now resolves — fixing the running-but-uninstallable bug.
|
|
216
|
+
expect(parseModulesPath("/api/modules/channel/install")).toEqual({
|
|
217
|
+
short: "channel",
|
|
218
|
+
rest: "install",
|
|
219
|
+
});
|
|
220
|
+
// Other known modules (runner / surface) resolve too.
|
|
221
|
+
expect(parseModulesPath("/api/modules/runner/install")).toEqual({
|
|
222
|
+
short: "runner",
|
|
223
|
+
rest: "install",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("rejects unknown shorts (the hub itself + genuinely third-party rows)", () => {
|
|
228
|
+
// `hub` isn't a supervised module (no registry entry) and a random short
|
|
229
|
+
// has no install package — both still fall through to undefined so the
|
|
230
|
+
// module-ops switch never acts on them.
|
|
231
|
+
expect(parseModulesPath("/api/modules/hub/install")).toBeUndefined();
|
|
216
232
|
expect(parseModulesPath("/api/modules/random/install")).toBeUndefined();
|
|
217
233
|
});
|
|
218
234
|
|
|
@@ -996,6 +1012,55 @@ describe("POST /api/modules/:short/start", () => {
|
|
|
996
1012
|
expect(spawns).toEqual([]);
|
|
997
1013
|
expect(calls).toEqual([]);
|
|
998
1014
|
});
|
|
1015
|
+
|
|
1016
|
+
test("channel#41: start reconciles a drifted services.json port back to canonical (API path)", async () => {
|
|
1017
|
+
// The live signature: channel's row carried 19415 instead of canonical 1941.
|
|
1018
|
+
// The API start path (admin SPA / `parachute start channel`) must apply the
|
|
1019
|
+
// SAME reconcile the boot path does — otherwise an operator-triggered start
|
|
1020
|
+
// re-strands the module on the dead port.
|
|
1021
|
+
writeManifest(h.manifestPath, [
|
|
1022
|
+
{
|
|
1023
|
+
name: "parachute-channel",
|
|
1024
|
+
port: 19415,
|
|
1025
|
+
paths: ["/channel"],
|
|
1026
|
+
health: "/health",
|
|
1027
|
+
version: "0.0.0-linked",
|
|
1028
|
+
},
|
|
1029
|
+
]);
|
|
1030
|
+
const { supervisor, spawns } = makeIdleSupervisor();
|
|
1031
|
+
const logs: string[] = [];
|
|
1032
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
1033
|
+
|
|
1034
|
+
const res = await handleStart(
|
|
1035
|
+
postReq("/api/modules/channel/start", { authorization: `Bearer ${bearer}` }),
|
|
1036
|
+
"channel",
|
|
1037
|
+
{
|
|
1038
|
+
db: h.db,
|
|
1039
|
+
issuer: ISSUER,
|
|
1040
|
+
manifestPath: h.manifestPath,
|
|
1041
|
+
configDir: h.dir,
|
|
1042
|
+
supervisor,
|
|
1043
|
+
log: (l) => logs.push(l),
|
|
1044
|
+
},
|
|
1045
|
+
);
|
|
1046
|
+
|
|
1047
|
+
expect(res.status).toBe(200);
|
|
1048
|
+
// The supervisor child gets PORT=1941 (canonical), not the drifted 19415 —
|
|
1049
|
+
// so it binds + the readiness probe checks the right port.
|
|
1050
|
+
expect(spawns.length).toBe(1);
|
|
1051
|
+
expect(spawns[0]?.short).toBe("channel");
|
|
1052
|
+
expect(spawns[0]?.env?.PORT).toBe("1941");
|
|
1053
|
+
// services.json row is rewritten to 1941 → the reverse-proxy (which reads
|
|
1054
|
+
// services.json) routes /channel/* to the live port.
|
|
1055
|
+
const onDisk = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
|
|
1056
|
+
services: { name: string; port: number }[];
|
|
1057
|
+
};
|
|
1058
|
+
expect(onDisk.services.find((s) => s.name === "parachute-channel")?.port).toBe(1941);
|
|
1059
|
+
// The reconcile event logged on the API path too (deps.log wired — #41 review).
|
|
1060
|
+
expect(
|
|
1061
|
+
logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1941")),
|
|
1062
|
+
).toBe(true);
|
|
1063
|
+
});
|
|
999
1064
|
});
|
|
1000
1065
|
|
|
1001
1066
|
describe("POST /api/modules/:short/stop", () => {
|