@checkstack/catalog-backend 1.3.1 → 1.4.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/drizzle/0003_tan_spot.sql +17 -0
  3. package/drizzle/0004_heavy_sharon_carter.sql +13 -0
  4. package/drizzle/0005_normal_shaman.sql +60 -0
  5. package/drizzle/0006_optimal_gamora.sql +43 -0
  6. package/drizzle/meta/0003_snapshot.json +479 -0
  7. package/drizzle/meta/0004_snapshot.json +495 -0
  8. package/drizzle/meta/0005_snapshot.json +592 -0
  9. package/drizzle/meta/0006_snapshot.json +592 -0
  10. package/drizzle/meta/_journal.json +28 -0
  11. package/package.json +20 -17
  12. package/src/ai/catalog-add-system-to-group.test.ts +51 -0
  13. package/src/ai/catalog-add-system-to-group.ts +68 -0
  14. package/src/ai/catalog-create-group.test.ts +62 -0
  15. package/src/ai/catalog-create-group.ts +71 -0
  16. package/src/ai/catalog-create-system.test.ts +62 -0
  17. package/src/ai/catalog-create-system.ts +78 -0
  18. package/src/ai/catalog-delete-group.test.ts +83 -0
  19. package/src/ai/catalog-delete-group.ts +77 -0
  20. package/src/ai/catalog-delete-system.test.ts +84 -0
  21. package/src/ai/catalog-delete-system.ts +77 -0
  22. package/src/ai/catalog-remove-system-from-group.test.ts +55 -0
  23. package/src/ai/catalog-remove-system-from-group.ts +74 -0
  24. package/src/ai/catalog-update-group.test.ts +85 -0
  25. package/src/ai/catalog-update-group.ts +88 -0
  26. package/src/ai/catalog-update-system.test.ts +87 -0
  27. package/src/ai/catalog-update-system.ts +93 -0
  28. package/src/ai/catalog.projection.test.ts +37 -0
  29. package/src/ai/register-ai-tools.ts +35 -0
  30. package/src/automations.test.ts +2 -1
  31. package/src/catalog-gitops-kinds.test.ts +288 -0
  32. package/src/index.ts +149 -0
  33. package/src/router.test.ts +107 -0
  34. package/src/router.ts +200 -26
  35. package/src/schema.ts +124 -38
  36. package/src/services/entity-service.test.ts +28 -0
  37. package/src/services/entity-service.ts +154 -1
  38. package/src/services/environment-membership.test.ts +66 -0
  39. package/src/services/environment-membership.ts +40 -0
  40. package/src/services/pg-errors.test.ts +24 -0
  41. package/src/services/pg-errors.ts +21 -0
  42. 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
+ });