@checkstack/catalog-backend 1.3.0 → 1.4.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/CHANGELOG.md +149 -0
- package/drizzle/0003_tan_spot.sql +17 -0
- package/drizzle/0004_heavy_sharon_carter.sql +13 -0
- package/drizzle/0005_normal_shaman.sql +60 -0
- package/drizzle/0006_optimal_gamora.sql +43 -0
- package/drizzle/meta/0003_snapshot.json +479 -0
- package/drizzle/meta/0004_snapshot.json +495 -0
- package/drizzle/meta/0005_snapshot.json +592 -0
- package/drizzle/meta/0006_snapshot.json +592 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +15 -12
- package/src/ai/catalog-add-system-to-group.test.ts +51 -0
- package/src/ai/catalog-add-system-to-group.ts +68 -0
- package/src/ai/catalog-create-group.test.ts +62 -0
- package/src/ai/catalog-create-group.ts +71 -0
- package/src/ai/catalog-create-system.test.ts +62 -0
- package/src/ai/catalog-create-system.ts +78 -0
- package/src/ai/catalog-delete-group.test.ts +83 -0
- package/src/ai/catalog-delete-group.ts +77 -0
- package/src/ai/catalog-delete-system.test.ts +84 -0
- package/src/ai/catalog-delete-system.ts +77 -0
- package/src/ai/catalog-remove-system-from-group.test.ts +55 -0
- package/src/ai/catalog-remove-system-from-group.ts +74 -0
- package/src/ai/catalog-update-group.test.ts +85 -0
- package/src/ai/catalog-update-group.ts +88 -0
- package/src/ai/catalog-update-system.test.ts +87 -0
- package/src/ai/catalog-update-system.ts +93 -0
- package/src/ai/catalog.projection.test.ts +37 -0
- package/src/ai/register-ai-tools.ts +35 -0
- package/src/automations.test.ts +2 -1
- package/src/catalog-gitops-kinds.test.ts +288 -0
- package/src/index.ts +149 -0
- package/src/router.test.ts +107 -0
- package/src/router.ts +200 -26
- package/src/schema.ts +124 -38
- package/src/services/entity-service.test.ts +28 -0
- package/src/services/entity-service.ts +154 -1
- package/src/services/environment-membership.test.ts +66 -0
- package/src/services/environment-membership.ts +40 -0
- package/src/services/pg-errors.test.ts +24 -0
- package/src/services/pg-errors.ts +21 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createCatalogAddSystemToGroupTool } from "./catalog-add-system-to-group";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["catalog.system.manage", "catalog.group.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function fakeRpcClient({
|
|
12
|
+
addSystemToGroup,
|
|
13
|
+
}: {
|
|
14
|
+
addSystemToGroup: ReturnType<typeof mock>;
|
|
15
|
+
}): RpcClient {
|
|
16
|
+
return {
|
|
17
|
+
forPlugin: () => ({ addSystemToGroup }),
|
|
18
|
+
} as unknown as RpcClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("catalog.addSystemToGroup tool", () => {
|
|
22
|
+
test("declares mutate effect + the system manage rule", () => {
|
|
23
|
+
const tool = createCatalogAddSystemToGroupTool();
|
|
24
|
+
expect(tool.name).toBe("catalog.addSystemToGroup");
|
|
25
|
+
expect(tool.effect).toBe("mutate");
|
|
26
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.system.manage"]);
|
|
27
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("dryRun summarizes the membership and NEVER mutates", async () => {
|
|
31
|
+
const addSystemToGroup = mock(() => Promise.resolve({ success: true }));
|
|
32
|
+
const rpcClient = fakeRpcClient({ addSystemToGroup });
|
|
33
|
+
const tool = createCatalogAddSystemToGroupTool();
|
|
34
|
+
const input = { groupId: "grp1", systemId: "sys1" };
|
|
35
|
+
const preview = await tool.dryRun!({ input, principal, rpcClient });
|
|
36
|
+
expect(addSystemToGroup).not.toHaveBeenCalled();
|
|
37
|
+
expect(preview.summary).toContain("sys1");
|
|
38
|
+
expect(preview.summary).toContain("grp1");
|
|
39
|
+
expect(preview.payload).toEqual(input);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("execute (apply) calls addSystemToGroup with { groupId, systemId }", async () => {
|
|
43
|
+
const addSystemToGroup = mock(() => Promise.resolve({ success: true }));
|
|
44
|
+
const rpcClient = fakeRpcClient({ addSystemToGroup });
|
|
45
|
+
const tool = createCatalogAddSystemToGroupTool();
|
|
46
|
+
const input = { groupId: "grp1", systemId: "sys1" };
|
|
47
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
48
|
+
expect(addSystemToGroup).toHaveBeenCalledWith(input);
|
|
49
|
+
expect(result).toEqual({ groupId: "grp1", systemId: "sys1", added: true });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
CatalogApi,
|
|
6
|
+
catalogAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
} from "@checkstack/catalog-common";
|
|
9
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
10
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
11
|
+
|
|
12
|
+
/** Input for `catalog.addSystemToGroup`: the group + system to associate. */
|
|
13
|
+
export const CatalogAddSystemToGroupInputSchema = z.object({
|
|
14
|
+
groupId: z.string(),
|
|
15
|
+
systemId: z.string(),
|
|
16
|
+
});
|
|
17
|
+
export type CatalogAddSystemToGroupInput = z.infer<
|
|
18
|
+
typeof CatalogAddSystemToGroupInputSchema
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
/** Output returned once a human applies the membership change. */
|
|
22
|
+
export interface CatalogAddSystemToGroupApplyResult {
|
|
23
|
+
groupId: string;
|
|
24
|
+
systemId: string;
|
|
25
|
+
added: true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `catalog.addSystemToGroup` - add a system to a group.
|
|
30
|
+
*
|
|
31
|
+
* `effect: "mutate"` - a reversible membership change, so it auto-applies in AUTO
|
|
32
|
+
* mode and is confirm-gated in APPROVE mode. Membership is part of the system
|
|
33
|
+
* surface, so authorization is the `system.manage` rule (matching the contract).
|
|
34
|
+
*/
|
|
35
|
+
export function createCatalogAddSystemToGroupTool(): RegisteredAiTool<
|
|
36
|
+
CatalogAddSystemToGroupInput,
|
|
37
|
+
CatalogAddSystemToGroupApplyResult
|
|
38
|
+
> {
|
|
39
|
+
const dryRun = async ({
|
|
40
|
+
input,
|
|
41
|
+
}: {
|
|
42
|
+
input: CatalogAddSystemToGroupInput;
|
|
43
|
+
principal: AuthUser;
|
|
44
|
+
rpcClient: RpcClient;
|
|
45
|
+
}): Promise<AiProposalPreview<CatalogAddSystemToGroupInput>> => {
|
|
46
|
+
return {
|
|
47
|
+
summary: `Add system "${input.systemId}" to group "${input.groupId}".`,
|
|
48
|
+
payload: input,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
name: "catalog.addSystemToGroup",
|
|
54
|
+
description:
|
|
55
|
+
"Add a system to a group by their ids. Never changes membership directly; a person must approve unless the conversation is in auto mode. Find the ids with the catalog read tools first.",
|
|
56
|
+
effect: "mutate",
|
|
57
|
+
input: CatalogAddSystemToGroupInputSchema,
|
|
58
|
+
requiredAccessRules: [
|
|
59
|
+
qualifyAccessRuleId(pluginMetadata, catalogAccess.system.manage),
|
|
60
|
+
],
|
|
61
|
+
dryRun,
|
|
62
|
+
async execute({ input, rpcClient }) {
|
|
63
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
64
|
+
await catalogClient.addSystemToGroup(input);
|
|
65
|
+
return { groupId: input.groupId, systemId: input.systemId, added: true };
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createCatalogCreateGroupTool } from "./catalog-create-group";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["catalog.system.manage", "catalog.group.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const group = {
|
|
12
|
+
id: "grp1",
|
|
13
|
+
name: "Payments",
|
|
14
|
+
systemIds: [],
|
|
15
|
+
metadata: null,
|
|
16
|
+
createdAt: new Date(),
|
|
17
|
+
updatedAt: new Date(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fakeRpcClient({
|
|
21
|
+
createGroup,
|
|
22
|
+
}: {
|
|
23
|
+
createGroup: ReturnType<typeof mock>;
|
|
24
|
+
}): RpcClient {
|
|
25
|
+
return {
|
|
26
|
+
forPlugin: () => ({ createGroup }),
|
|
27
|
+
} as unknown as RpcClient;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("catalog.createGroup tool", () => {
|
|
31
|
+
test("declares mutate effect + the group manage rule", () => {
|
|
32
|
+
const tool = createCatalogCreateGroupTool();
|
|
33
|
+
expect(tool.name).toBe("catalog.createGroup");
|
|
34
|
+
expect(tool.effect).toBe("mutate");
|
|
35
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.group.manage"]);
|
|
36
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("dryRun summarizes the create and NEVER creates", async () => {
|
|
40
|
+
const createGroup = mock(() => Promise.resolve(group));
|
|
41
|
+
const rpcClient = fakeRpcClient({ createGroup });
|
|
42
|
+
const tool = createCatalogCreateGroupTool();
|
|
43
|
+
const preview = await tool.dryRun!({
|
|
44
|
+
input: { name: "Payments" },
|
|
45
|
+
principal,
|
|
46
|
+
rpcClient,
|
|
47
|
+
});
|
|
48
|
+
expect(createGroup).not.toHaveBeenCalled();
|
|
49
|
+
expect(preview.summary).toContain("Payments");
|
|
50
|
+
expect(preview.payload).toEqual({ name: "Payments" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("execute (apply) creates via createGroup", async () => {
|
|
54
|
+
const createGroup = mock(() => Promise.resolve(group));
|
|
55
|
+
const rpcClient = fakeRpcClient({ createGroup });
|
|
56
|
+
const tool = createCatalogCreateGroupTool();
|
|
57
|
+
const input = { name: "Payments" };
|
|
58
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
59
|
+
expect(createGroup).toHaveBeenCalledWith(input);
|
|
60
|
+
expect(result).toEqual({ group });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
CatalogApi,
|
|
6
|
+
catalogAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
type Group,
|
|
9
|
+
} from "@checkstack/catalog-common";
|
|
10
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
11
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Input for `catalog.createGroup`. Mirrors the contract's (module-private)
|
|
15
|
+
* `CreateGroupInputSchema`: a group has a required name plus optional free-form
|
|
16
|
+
* metadata.
|
|
17
|
+
*/
|
|
18
|
+
export const CatalogCreateGroupInputSchema = z.object({
|
|
19
|
+
name: z.string().min(1),
|
|
20
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
21
|
+
});
|
|
22
|
+
export type CatalogCreateGroupInput = z.infer<
|
|
23
|
+
typeof CatalogCreateGroupInputSchema
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
/** Output returned once a human applies the create (the created group). */
|
|
27
|
+
export interface CatalogCreateGroupApplyResult {
|
|
28
|
+
group: Group;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `catalog.createGroup` - create a new system group in the catalog.
|
|
33
|
+
*
|
|
34
|
+
* `effect: "mutate"` - a non-destructive create, so it auto-applies in AUTO mode
|
|
35
|
+
* and is confirm-gated in APPROVE mode. Authorization is the `group.manage` rule
|
|
36
|
+
* the UI create form requires.
|
|
37
|
+
*/
|
|
38
|
+
export function createCatalogCreateGroupTool(): RegisteredAiTool<
|
|
39
|
+
CatalogCreateGroupInput,
|
|
40
|
+
CatalogCreateGroupApplyResult
|
|
41
|
+
> {
|
|
42
|
+
const dryRun = async ({
|
|
43
|
+
input,
|
|
44
|
+
}: {
|
|
45
|
+
input: CatalogCreateGroupInput;
|
|
46
|
+
principal: AuthUser;
|
|
47
|
+
rpcClient: RpcClient;
|
|
48
|
+
}): Promise<AiProposalPreview<CatalogCreateGroupInput>> => {
|
|
49
|
+
return {
|
|
50
|
+
summary: `Create group "${input.name}".`,
|
|
51
|
+
payload: input,
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
name: "catalog.createGroup",
|
|
57
|
+
description:
|
|
58
|
+
"Create a new system group in the catalog with a name and optional metadata. Never creates directly; a person must approve unless the conversation is in auto mode.",
|
|
59
|
+
effect: "mutate",
|
|
60
|
+
input: CatalogCreateGroupInputSchema,
|
|
61
|
+
requiredAccessRules: [
|
|
62
|
+
qualifyAccessRuleId(pluginMetadata, catalogAccess.group.manage),
|
|
63
|
+
],
|
|
64
|
+
dryRun,
|
|
65
|
+
async execute({ input, rpcClient }) {
|
|
66
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
67
|
+
const group = await catalogClient.createGroup(input);
|
|
68
|
+
return { group };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createCatalogCreateSystemTool } from "./catalog-create-system";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["catalog.system.manage", "catalog.group.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const system = {
|
|
12
|
+
id: "sys1",
|
|
13
|
+
name: "Checkout API",
|
|
14
|
+
description: "Handles checkout",
|
|
15
|
+
metadata: null,
|
|
16
|
+
createdAt: new Date(),
|
|
17
|
+
updatedAt: new Date(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fakeRpcClient({
|
|
21
|
+
createSystem,
|
|
22
|
+
}: {
|
|
23
|
+
createSystem: ReturnType<typeof mock>;
|
|
24
|
+
}): RpcClient {
|
|
25
|
+
return {
|
|
26
|
+
forPlugin: () => ({ createSystem }),
|
|
27
|
+
} as unknown as RpcClient;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("catalog.createSystem tool", () => {
|
|
31
|
+
test("declares mutate effect + the system manage rule", () => {
|
|
32
|
+
const tool = createCatalogCreateSystemTool();
|
|
33
|
+
expect(tool.name).toBe("catalog.createSystem");
|
|
34
|
+
expect(tool.effect).toBe("mutate");
|
|
35
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.system.manage"]);
|
|
36
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("dryRun summarizes the create and NEVER creates", async () => {
|
|
40
|
+
const createSystem = mock(() => Promise.resolve(system));
|
|
41
|
+
const rpcClient = fakeRpcClient({ createSystem });
|
|
42
|
+
const tool = createCatalogCreateSystemTool();
|
|
43
|
+
const preview = await tool.dryRun!({
|
|
44
|
+
input: { name: "Checkout API" },
|
|
45
|
+
principal,
|
|
46
|
+
rpcClient,
|
|
47
|
+
});
|
|
48
|
+
expect(createSystem).not.toHaveBeenCalled();
|
|
49
|
+
expect(preview.summary).toContain("Checkout API");
|
|
50
|
+
expect(preview.payload).toEqual({ name: "Checkout API" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("execute (apply) creates via createSystem", async () => {
|
|
54
|
+
const createSystem = mock(() => Promise.resolve(system));
|
|
55
|
+
const rpcClient = fakeRpcClient({ createSystem });
|
|
56
|
+
const tool = createCatalogCreateSystemTool();
|
|
57
|
+
const input = { name: "Checkout API", description: "Handles checkout" };
|
|
58
|
+
const result = await tool.execute({ input, principal, rpcClient });
|
|
59
|
+
expect(createSystem).toHaveBeenCalledWith(input);
|
|
60
|
+
expect(result).toEqual({ system });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
CatalogApi,
|
|
6
|
+
catalogAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
type System,
|
|
9
|
+
} from "@checkstack/catalog-common";
|
|
10
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
11
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Input for `catalog.createSystem`. Mirrors the contract's (module-private)
|
|
15
|
+
* `CreateSystemInputSchema`: a system has a required name plus optional
|
|
16
|
+
* description and free-form metadata.
|
|
17
|
+
*/
|
|
18
|
+
export const CatalogCreateSystemInputSchema = z.object({
|
|
19
|
+
name: z.string().min(1),
|
|
20
|
+
description: z.string().optional(),
|
|
21
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
22
|
+
});
|
|
23
|
+
export type CatalogCreateSystemInput = z.infer<
|
|
24
|
+
typeof CatalogCreateSystemInputSchema
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
/** Output returned once a human applies the create (the created system). */
|
|
28
|
+
export interface CatalogCreateSystemApplyResult {
|
|
29
|
+
system: System;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `catalog.createSystem` - create a new system (a service or resource) in the
|
|
34
|
+
* catalog.
|
|
35
|
+
*
|
|
36
|
+
* `effect: "mutate"` - a non-destructive create, so it auto-applies in AUTO mode
|
|
37
|
+
* and is confirm-gated in APPROVE mode via the propose/apply machinery. The
|
|
38
|
+
* underlying RPC uses the USER-SCOPED client passed at call time, so handler-side
|
|
39
|
+
* authorization is enforced exactly as a direct UI/RPC call. Authorization is the
|
|
40
|
+
* `system.manage` rule the UI create form requires, re-checked at propose AND
|
|
41
|
+
* apply time by the propose/apply service.
|
|
42
|
+
*/
|
|
43
|
+
export function createCatalogCreateSystemTool(): RegisteredAiTool<
|
|
44
|
+
CatalogCreateSystemInput,
|
|
45
|
+
CatalogCreateSystemApplyResult
|
|
46
|
+
> {
|
|
47
|
+
const dryRun = async ({
|
|
48
|
+
input,
|
|
49
|
+
}: {
|
|
50
|
+
input: CatalogCreateSystemInput;
|
|
51
|
+
principal: AuthUser;
|
|
52
|
+
rpcClient: RpcClient;
|
|
53
|
+
}): Promise<AiProposalPreview<CatalogCreateSystemInput>> => {
|
|
54
|
+
return {
|
|
55
|
+
summary: `Create system "${input.name}"${
|
|
56
|
+
input.description ? ` (${input.description})` : ""
|
|
57
|
+
}.`,
|
|
58
|
+
payload: input,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: "catalog.createSystem",
|
|
64
|
+
description:
|
|
65
|
+
"Create a new system (a service or resource) in the catalog with a name and optional description and metadata. Never creates directly; a person must approve unless the conversation is in auto mode.",
|
|
66
|
+
effect: "mutate",
|
|
67
|
+
input: CatalogCreateSystemInputSchema,
|
|
68
|
+
requiredAccessRules: [
|
|
69
|
+
qualifyAccessRuleId(pluginMetadata, catalogAccess.system.manage),
|
|
70
|
+
],
|
|
71
|
+
dryRun,
|
|
72
|
+
async execute({ input, rpcClient }) {
|
|
73
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
74
|
+
const system = await catalogClient.createSystem(input);
|
|
75
|
+
return { system };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createCatalogDeleteGroupTool } from "./catalog-delete-group";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["catalog.system.manage", "catalog.group.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const group = {
|
|
12
|
+
id: "grp1",
|
|
13
|
+
name: "Payments",
|
|
14
|
+
systemIds: [],
|
|
15
|
+
metadata: null,
|
|
16
|
+
createdAt: new Date(),
|
|
17
|
+
updatedAt: new Date(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fakeRpcClient({
|
|
21
|
+
getGroups,
|
|
22
|
+
deleteGroup,
|
|
23
|
+
}: {
|
|
24
|
+
getGroups: ReturnType<typeof mock>;
|
|
25
|
+
deleteGroup: ReturnType<typeof mock>;
|
|
26
|
+
}): RpcClient {
|
|
27
|
+
return {
|
|
28
|
+
forPlugin: () => ({ getGroups, deleteGroup }),
|
|
29
|
+
} as unknown as RpcClient;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("catalog.deleteGroup tool", () => {
|
|
33
|
+
test("declares destructive effect + the group manage rule", () => {
|
|
34
|
+
const tool = createCatalogDeleteGroupTool();
|
|
35
|
+
expect(tool.name).toBe("catalog.deleteGroup");
|
|
36
|
+
expect(tool.effect).toBe("destructive");
|
|
37
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.group.manage"]);
|
|
38
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dryRun resolves the target and NEVER deletes", async () => {
|
|
42
|
+
const getGroups = mock(() => Promise.resolve([group]));
|
|
43
|
+
const deleteGroup = mock(() => Promise.resolve({ success: true }));
|
|
44
|
+
const rpcClient = fakeRpcClient({ getGroups, deleteGroup });
|
|
45
|
+
const tool = createCatalogDeleteGroupTool();
|
|
46
|
+
const preview = await tool.dryRun!({
|
|
47
|
+
input: { id: "grp1" },
|
|
48
|
+
principal,
|
|
49
|
+
rpcClient,
|
|
50
|
+
});
|
|
51
|
+
expect(deleteGroup).not.toHaveBeenCalled();
|
|
52
|
+
expect(preview.summary).toContain("Payments");
|
|
53
|
+
expect(preview.summary).toContain("permanent");
|
|
54
|
+
expect(preview.payload).toEqual({ id: "grp1" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("dryRun throws a clear error when the id is unknown", async () => {
|
|
58
|
+
const rpcClient = fakeRpcClient({
|
|
59
|
+
getGroups: mock(() => Promise.resolve([group])),
|
|
60
|
+
deleteGroup: mock(),
|
|
61
|
+
});
|
|
62
|
+
const tool = createCatalogDeleteGroupTool();
|
|
63
|
+
await expect(
|
|
64
|
+
tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
|
|
65
|
+
).rejects.toThrow(/No group found/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("execute (apply) deletes via deleteGroup with the POSITIONAL id", async () => {
|
|
69
|
+
const deleteGroup = mock(() => Promise.resolve({ success: true }));
|
|
70
|
+
const rpcClient = fakeRpcClient({
|
|
71
|
+
getGroups: mock(() => Promise.resolve([group])),
|
|
72
|
+
deleteGroup,
|
|
73
|
+
});
|
|
74
|
+
const tool = createCatalogDeleteGroupTool();
|
|
75
|
+
const result = await tool.execute({
|
|
76
|
+
input: { id: "grp1" },
|
|
77
|
+
principal,
|
|
78
|
+
rpcClient,
|
|
79
|
+
});
|
|
80
|
+
expect(deleteGroup).toHaveBeenCalledWith("grp1");
|
|
81
|
+
expect(result).toEqual({ id: "grp1", deleted: true });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
CatalogApi,
|
|
6
|
+
catalogAccess,
|
|
7
|
+
pluginMetadata,
|
|
8
|
+
} from "@checkstack/catalog-common";
|
|
9
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
10
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
11
|
+
|
|
12
|
+
/** Input for `catalog.deleteGroup`: the group id to remove. */
|
|
13
|
+
export const CatalogDeleteGroupInputSchema = z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
});
|
|
16
|
+
export type CatalogDeleteGroupInput = z.infer<
|
|
17
|
+
typeof CatalogDeleteGroupInputSchema
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/** Output returned once a human applies the deletion. */
|
|
21
|
+
export interface CatalogDeleteGroupApplyResult {
|
|
22
|
+
id: string;
|
|
23
|
+
deleted: true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `catalog.deleteGroup` - delete a group by id.
|
|
28
|
+
*
|
|
29
|
+
* `effect: "destructive"` - deletion is irreversible, so it ALWAYS routes through
|
|
30
|
+
* the propose/apply confirm card in BOTH permission modes. `dryRun` resolves the
|
|
31
|
+
* target group (via `getGroups()`, since there is no single-group fetch) so the
|
|
32
|
+
* confirm card names exactly what will be removed; `execute` (reached only via
|
|
33
|
+
* `apply`) performs the delete via the contract's POSITIONAL string id.
|
|
34
|
+
*/
|
|
35
|
+
export function createCatalogDeleteGroupTool(): RegisteredAiTool<
|
|
36
|
+
CatalogDeleteGroupInput,
|
|
37
|
+
CatalogDeleteGroupApplyResult
|
|
38
|
+
> {
|
|
39
|
+
const dryRun = async ({
|
|
40
|
+
input,
|
|
41
|
+
rpcClient,
|
|
42
|
+
}: {
|
|
43
|
+
input: CatalogDeleteGroupInput;
|
|
44
|
+
principal: AuthUser;
|
|
45
|
+
rpcClient: RpcClient;
|
|
46
|
+
}): Promise<AiProposalPreview<CatalogDeleteGroupInput>> => {
|
|
47
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
48
|
+
const groups = await catalogClient.getGroups();
|
|
49
|
+
const group = groups.find((g) => g.id === input.id);
|
|
50
|
+
if (!group) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`No group found with id "${input.id}". List groups first to get a valid id.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
summary: `Delete group "${group.name}". This is permanent and also removes its system memberships.`,
|
|
57
|
+
payload: { id: input.id },
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: "catalog.deleteGroup",
|
|
63
|
+
description:
|
|
64
|
+
"Delete a group by id. DESTRUCTIVE and irreversible - it also removes the group's system memberships. Never deletes directly; a person must approve the confirmation. Find the id with the catalog read tools first.",
|
|
65
|
+
effect: "destructive",
|
|
66
|
+
input: CatalogDeleteGroupInputSchema,
|
|
67
|
+
requiredAccessRules: [
|
|
68
|
+
qualifyAccessRuleId(pluginMetadata, catalogAccess.group.manage),
|
|
69
|
+
],
|
|
70
|
+
dryRun,
|
|
71
|
+
async execute({ input, rpcClient }) {
|
|
72
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
73
|
+
await catalogClient.deleteGroup(input.id);
|
|
74
|
+
return { id: input.id, deleted: true };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createCatalogDeleteSystemTool } from "./catalog-delete-system";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["catalog.system.manage", "catalog.group.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const system = {
|
|
12
|
+
id: "sys1",
|
|
13
|
+
name: "Checkout API",
|
|
14
|
+
description: "Handles checkout",
|
|
15
|
+
metadata: null,
|
|
16
|
+
createdAt: new Date(),
|
|
17
|
+
updatedAt: new Date(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function fakeRpcClient({
|
|
21
|
+
getSystem,
|
|
22
|
+
deleteSystem,
|
|
23
|
+
}: {
|
|
24
|
+
getSystem: ReturnType<typeof mock>;
|
|
25
|
+
deleteSystem: ReturnType<typeof mock>;
|
|
26
|
+
}): RpcClient {
|
|
27
|
+
return {
|
|
28
|
+
forPlugin: () => ({ getSystem, deleteSystem }),
|
|
29
|
+
} as unknown as RpcClient;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("catalog.deleteSystem tool", () => {
|
|
33
|
+
test("declares destructive effect + the system manage rule", () => {
|
|
34
|
+
const tool = createCatalogDeleteSystemTool();
|
|
35
|
+
expect(tool.name).toBe("catalog.deleteSystem");
|
|
36
|
+
expect(tool.effect).toBe("destructive");
|
|
37
|
+
expect(tool.requiredAccessRules).toEqual(["catalog.system.manage"]);
|
|
38
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dryRun resolves the target and NEVER deletes", async () => {
|
|
42
|
+
const getSystem = mock(() => Promise.resolve(system));
|
|
43
|
+
const deleteSystem = mock(() => Promise.resolve({ success: true }));
|
|
44
|
+
const rpcClient = fakeRpcClient({ getSystem, deleteSystem });
|
|
45
|
+
const tool = createCatalogDeleteSystemTool();
|
|
46
|
+
const preview = await tool.dryRun!({
|
|
47
|
+
input: { id: "sys1" },
|
|
48
|
+
principal,
|
|
49
|
+
rpcClient,
|
|
50
|
+
});
|
|
51
|
+
expect(getSystem).toHaveBeenCalledWith({ systemId: "sys1" });
|
|
52
|
+
expect(deleteSystem).not.toHaveBeenCalled();
|
|
53
|
+
expect(preview.summary).toContain("Checkout API");
|
|
54
|
+
expect(preview.summary).toContain("permanent");
|
|
55
|
+
expect(preview.payload).toEqual({ id: "sys1" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("dryRun throws a clear error when the id is unknown", async () => {
|
|
59
|
+
const rpcClient = fakeRpcClient({
|
|
60
|
+
getSystem: mock(() => Promise.resolve(null)),
|
|
61
|
+
deleteSystem: mock(),
|
|
62
|
+
});
|
|
63
|
+
const tool = createCatalogDeleteSystemTool();
|
|
64
|
+
await expect(
|
|
65
|
+
tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
|
|
66
|
+
).rejects.toThrow(/No system found/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("execute (apply) deletes via deleteSystem with the POSITIONAL id", async () => {
|
|
70
|
+
const deleteSystem = mock(() => Promise.resolve({ success: true }));
|
|
71
|
+
const rpcClient = fakeRpcClient({
|
|
72
|
+
getSystem: mock(() => Promise.resolve(system)),
|
|
73
|
+
deleteSystem,
|
|
74
|
+
});
|
|
75
|
+
const tool = createCatalogDeleteSystemTool();
|
|
76
|
+
const result = await tool.execute({
|
|
77
|
+
input: { id: "sys1" },
|
|
78
|
+
principal,
|
|
79
|
+
rpcClient,
|
|
80
|
+
});
|
|
81
|
+
expect(deleteSystem).toHaveBeenCalledWith("sys1");
|
|
82
|
+
expect(result).toEqual({ id: "sys1", deleted: true });
|
|
83
|
+
});
|
|
84
|
+
});
|