@openparachute/hub 0.6.5-rc.8 → 0.7.1
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 +310 -6
- 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-credentials.test.ts +1320 -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-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +585 -21
- package/src/__tests__/invites.test.ts +91 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- 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/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +1882 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +399 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +191 -0
- package/src/grants.ts +50 -0
- package/src/help.ts +13 -6
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +849 -70
- package/src/invites.ts +91 -2
- package/src/jwt-sign.ts +47 -1
- package/src/module-manifest.ts +536 -23
- package/src/origin-check.ts +109 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/services-manifest.ts +97 -0
- 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/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-Cxtod68O.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
|
+
});
|