@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +149 -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 +15 -12
  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,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.deleteSystem`: the system id to remove. */
13
+ export const CatalogDeleteSystemInputSchema = z.object({
14
+ id: z.string(),
15
+ });
16
+ export type CatalogDeleteSystemInput = z.infer<
17
+ typeof CatalogDeleteSystemInputSchema
18
+ >;
19
+
20
+ /** Output returned once a human applies the deletion. */
21
+ export interface CatalogDeleteSystemApplyResult {
22
+ id: string;
23
+ deleted: true;
24
+ }
25
+
26
+ /**
27
+ * `catalog.deleteSystem` - delete a system by id.
28
+ *
29
+ * `effect: "destructive"` - deletion is irreversible, so it ALWAYS routes through
30
+ * the propose/apply confirm card in BOTH permission modes (it can never
31
+ * auto-apply). `dryRun` resolves the target system so the confirm card names
32
+ * exactly what will be removed; `execute` (reached only via `apply`) performs the
33
+ * delete via the contract's POSITIONAL string id. Authorization is the
34
+ * `system.manage` rule the UI delete requires.
35
+ */
36
+ export function createCatalogDeleteSystemTool(): RegisteredAiTool<
37
+ CatalogDeleteSystemInput,
38
+ CatalogDeleteSystemApplyResult
39
+ > {
40
+ const dryRun = async ({
41
+ input,
42
+ rpcClient,
43
+ }: {
44
+ input: CatalogDeleteSystemInput;
45
+ principal: AuthUser;
46
+ rpcClient: RpcClient;
47
+ }): Promise<AiProposalPreview<CatalogDeleteSystemInput>> => {
48
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
49
+ const system = await catalogClient.getSystem({ systemId: input.id });
50
+ if (!system) {
51
+ throw new Error(
52
+ `No system found with id "${input.id}". List systems first to get a valid id.`,
53
+ );
54
+ }
55
+ return {
56
+ summary: `Delete system "${system.name}". This is permanent and also removes its group memberships and associations.`,
57
+ payload: { id: input.id },
58
+ };
59
+ };
60
+
61
+ return {
62
+ name: "catalog.deleteSystem",
63
+ description:
64
+ "Delete a system by id. DESTRUCTIVE and irreversible - it also removes the system's group memberships and associations. Never deletes directly; a person must approve the confirmation. Find the id with the catalog read tools first.",
65
+ effect: "destructive",
66
+ input: CatalogDeleteSystemInputSchema,
67
+ requiredAccessRules: [
68
+ qualifyAccessRuleId(pluginMetadata, catalogAccess.system.manage),
69
+ ],
70
+ dryRun,
71
+ async execute({ input, rpcClient }) {
72
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
73
+ await catalogClient.deleteSystem(input.id);
74
+ return { id: input.id, deleted: true };
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createCatalogRemoveSystemFromGroupTool } from "./catalog-remove-system-from-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
+ removeSystemFromGroup,
13
+ }: {
14
+ removeSystemFromGroup: ReturnType<typeof mock>;
15
+ }): RpcClient {
16
+ return {
17
+ forPlugin: () => ({ removeSystemFromGroup }),
18
+ } as unknown as RpcClient;
19
+ }
20
+
21
+ describe("catalog.removeSystemFromGroup tool", () => {
22
+ test("declares mutate effect (reversible unassignment) + the system manage rule", () => {
23
+ const tool = createCatalogRemoveSystemFromGroupTool();
24
+ expect(tool.name).toBe("catalog.removeSystemFromGroup");
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 unassignment and NEVER mutates", async () => {
31
+ const removeSystemFromGroup = mock(() => Promise.resolve({ success: true }));
32
+ const rpcClient = fakeRpcClient({ removeSystemFromGroup });
33
+ const tool = createCatalogRemoveSystemFromGroupTool();
34
+ const input = { groupId: "grp1", systemId: "sys1" };
35
+ const preview = await tool.dryRun!({ input, principal, rpcClient });
36
+ expect(removeSystemFromGroup).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 removeSystemFromGroup with { groupId, systemId }", async () => {
43
+ const removeSystemFromGroup = mock(() => Promise.resolve({ success: true }));
44
+ const rpcClient = fakeRpcClient({ removeSystemFromGroup });
45
+ const tool = createCatalogRemoveSystemFromGroupTool();
46
+ const input = { groupId: "grp1", systemId: "sys1" };
47
+ const result = await tool.execute({ input, principal, rpcClient });
48
+ expect(removeSystemFromGroup).toHaveBeenCalledWith(input);
49
+ expect(result).toEqual({
50
+ groupId: "grp1",
51
+ systemId: "sys1",
52
+ removed: true,
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,74 @@
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.removeSystemFromGroup`: the group + system to unassign. */
13
+ export const CatalogRemoveSystemFromGroupInputSchema = z.object({
14
+ groupId: z.string(),
15
+ systemId: z.string(),
16
+ });
17
+ export type CatalogRemoveSystemFromGroupInput = z.infer<
18
+ typeof CatalogRemoveSystemFromGroupInputSchema
19
+ >;
20
+
21
+ /** Output returned once a human applies the membership change. */
22
+ export interface CatalogRemoveSystemFromGroupApplyResult {
23
+ groupId: string;
24
+ systemId: string;
25
+ removed: true;
26
+ }
27
+
28
+ /**
29
+ * `catalog.removeSystemFromGroup` - remove a system from a group.
30
+ *
31
+ * `effect: "mutate"` - this is a reversible UNASSIGNMENT (the system and group
32
+ * both still exist; only the membership edge is dropped), so it is `mutate`, NOT
33
+ * `destructive`. It auto-applies in AUTO mode and is confirm-gated in APPROVE
34
+ * mode. Membership is part of the system surface, so authorization is the
35
+ * `system.manage` rule (matching the contract).
36
+ */
37
+ export function createCatalogRemoveSystemFromGroupTool(): RegisteredAiTool<
38
+ CatalogRemoveSystemFromGroupInput,
39
+ CatalogRemoveSystemFromGroupApplyResult
40
+ > {
41
+ const dryRun = async ({
42
+ input,
43
+ }: {
44
+ input: CatalogRemoveSystemFromGroupInput;
45
+ principal: AuthUser;
46
+ rpcClient: RpcClient;
47
+ }): Promise<AiProposalPreview<CatalogRemoveSystemFromGroupInput>> => {
48
+ return {
49
+ summary: `Remove system "${input.systemId}" from group "${input.groupId}".`,
50
+ payload: input,
51
+ };
52
+ };
53
+
54
+ return {
55
+ name: "catalog.removeSystemFromGroup",
56
+ description:
57
+ "Remove a system from a group by their ids. This only removes the membership; the system and group are kept. Never changes membership directly; a person must approve unless the conversation is in auto mode. Find the ids with the catalog read tools first.",
58
+ effect: "mutate",
59
+ input: CatalogRemoveSystemFromGroupInputSchema,
60
+ requiredAccessRules: [
61
+ qualifyAccessRuleId(pluginMetadata, catalogAccess.system.manage),
62
+ ],
63
+ dryRun,
64
+ async execute({ input, rpcClient }) {
65
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
66
+ await catalogClient.removeSystemFromGroup(input);
67
+ return {
68
+ groupId: input.groupId,
69
+ systemId: input.systemId,
70
+ removed: true,
71
+ };
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createCatalogUpdateGroupTool } from "./catalog-update-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
+ updateGroup,
23
+ }: {
24
+ getGroups: ReturnType<typeof mock>;
25
+ updateGroup: ReturnType<typeof mock>;
26
+ }): RpcClient {
27
+ return {
28
+ forPlugin: () => ({ getGroups, updateGroup }),
29
+ } as unknown as RpcClient;
30
+ }
31
+
32
+ describe("catalog.updateGroup tool", () => {
33
+ test("declares mutate effect + the group manage rule", () => {
34
+ const tool = createCatalogUpdateGroupTool();
35
+ expect(tool.name).toBe("catalog.updateGroup");
36
+ expect(tool.effect).toBe("mutate");
37
+ expect(tool.requiredAccessRules).toEqual(["catalog.group.manage"]);
38
+ expect(typeof tool.dryRun).toBe("function");
39
+ });
40
+
41
+ test("dryRun resolves the target via getGroups + returns a diff", async () => {
42
+ const getGroups = mock(() => Promise.resolve([group]));
43
+ const updateGroup = mock(() => Promise.resolve(group));
44
+ const rpcClient = fakeRpcClient({ getGroups, updateGroup });
45
+ const tool = createCatalogUpdateGroupTool();
46
+ const preview = await tool.dryRun!({
47
+ input: { id: "grp1", data: { name: "Billing" } },
48
+ principal,
49
+ rpcClient,
50
+ });
51
+ expect(updateGroup).not.toHaveBeenCalled();
52
+ expect(preview.summary).toContain("Payments");
53
+ expect(preview.diff).toEqual([
54
+ { path: "name", before: "Payments", after: "Billing" },
55
+ ]);
56
+ });
57
+
58
+ test("dryRun throws a clear error when the id is unknown", async () => {
59
+ const rpcClient = fakeRpcClient({
60
+ getGroups: mock(() => Promise.resolve([group])),
61
+ updateGroup: mock(),
62
+ });
63
+ const tool = createCatalogUpdateGroupTool();
64
+ await expect(
65
+ tool.dryRun!({
66
+ input: { id: "nope", data: { name: "x" } },
67
+ principal,
68
+ rpcClient,
69
+ }),
70
+ ).rejects.toThrow(/No group found/);
71
+ });
72
+
73
+ test("execute (apply) updates via updateGroup", async () => {
74
+ const updateGroup = mock(() => Promise.resolve(group));
75
+ const rpcClient = fakeRpcClient({
76
+ getGroups: mock(() => Promise.resolve([group])),
77
+ updateGroup,
78
+ });
79
+ const tool = createCatalogUpdateGroupTool();
80
+ const input = { id: "grp1", data: { name: "Billing" } };
81
+ const result = await tool.execute({ input, principal, rpcClient });
82
+ expect(updateGroup).toHaveBeenCalledWith(input);
83
+ expect(result).toEqual({ group });
84
+ });
85
+ });
@@ -0,0 +1,88 @@
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 { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
11
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
12
+
13
+ /**
14
+ * Input for `catalog.updateGroup`. Mirrors the contract's (module-private)
15
+ * `UpdateGroupInputSchema`: the group id plus a PARTIAL data body. Only the
16
+ * provided fields change; `metadata` is nullable so it can be explicitly cleared.
17
+ */
18
+ export const CatalogUpdateGroupInputSchema = z.object({
19
+ id: z.string(),
20
+ data: z.object({
21
+ name: z.string().optional(),
22
+ metadata: z.record(z.string(), z.unknown()).nullable().optional(),
23
+ }),
24
+ });
25
+ export type CatalogUpdateGroupInput = z.infer<
26
+ typeof CatalogUpdateGroupInputSchema
27
+ >;
28
+
29
+ /** Output returned once a human applies the update (the updated group). */
30
+ export interface CatalogUpdateGroupApplyResult {
31
+ group: Group;
32
+ }
33
+
34
+ /**
35
+ * `catalog.updateGroup` - update an existing group by id with a partial body.
36
+ *
37
+ * `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
38
+ * and is confirm-gated in APPROVE mode. `dryRun` lists groups, finds the target,
39
+ * and returns a before -> after field diff. There is no single-group fetch on the
40
+ * contract, so the dry-run resolves it from `getGroups()`.
41
+ */
42
+ export function createCatalogUpdateGroupTool(): RegisteredAiTool<
43
+ CatalogUpdateGroupInput,
44
+ CatalogUpdateGroupApplyResult
45
+ > {
46
+ const dryRun = async ({
47
+ input,
48
+ rpcClient,
49
+ }: {
50
+ input: CatalogUpdateGroupInput;
51
+ principal: AuthUser;
52
+ rpcClient: RpcClient;
53
+ }): Promise<AiProposalPreview<CatalogUpdateGroupInput>> => {
54
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
55
+ const groups = await catalogClient.getGroups();
56
+ const group = groups.find((g) => g.id === input.id);
57
+ if (!group) {
58
+ throw new Error(
59
+ `No group found with id "${input.id}". List groups first to get a valid id.`,
60
+ );
61
+ }
62
+ const before = { name: group.name, metadata: group.metadata };
63
+ const after = { ...before, ...input.data };
64
+ const diff = computeFieldDiff({ before, after });
65
+ return {
66
+ summary: `Update group "${group.name}".`,
67
+ payload: input,
68
+ diff,
69
+ };
70
+ };
71
+
72
+ return {
73
+ name: "catalog.updateGroup",
74
+ description:
75
+ "Update an existing group by id with a partial body (only provided fields change). Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the catalog read tools first.",
76
+ effect: "mutate",
77
+ input: CatalogUpdateGroupInputSchema,
78
+ requiredAccessRules: [
79
+ qualifyAccessRuleId(pluginMetadata, catalogAccess.group.manage),
80
+ ],
81
+ dryRun,
82
+ async execute({ input, rpcClient }) {
83
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
84
+ const group = await catalogClient.updateGroup(input);
85
+ return { group };
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createCatalogUpdateSystemTool } from "./catalog-update-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
+ updateSystem,
23
+ }: {
24
+ getSystem: ReturnType<typeof mock>;
25
+ updateSystem: ReturnType<typeof mock>;
26
+ }): RpcClient {
27
+ return {
28
+ forPlugin: () => ({ getSystem, updateSystem }),
29
+ } as unknown as RpcClient;
30
+ }
31
+
32
+ describe("catalog.updateSystem tool", () => {
33
+ test("declares mutate effect + the system manage rule", () => {
34
+ const tool = createCatalogUpdateSystemTool();
35
+ expect(tool.name).toBe("catalog.updateSystem");
36
+ expect(tool.effect).toBe("mutate");
37
+ expect(tool.requiredAccessRules).toEqual(["catalog.system.manage"]);
38
+ expect(typeof tool.dryRun).toBe("function");
39
+ });
40
+
41
+ test("dryRun fetches via getSystem with the systemId key + returns a diff", async () => {
42
+ const getSystem = mock(() => Promise.resolve(system));
43
+ const updateSystem = mock(() => Promise.resolve(system));
44
+ const rpcClient = fakeRpcClient({ getSystem, updateSystem });
45
+ const tool = createCatalogUpdateSystemTool();
46
+ const preview = await tool.dryRun!({
47
+ input: { id: "sys1", data: { name: "Checkout" } },
48
+ principal,
49
+ rpcClient,
50
+ });
51
+ // systemId-key trap: the contract's getSystem input is { systemId }.
52
+ expect(getSystem).toHaveBeenCalledWith({ systemId: "sys1" });
53
+ expect(updateSystem).not.toHaveBeenCalled();
54
+ expect(preview.summary).toContain("Checkout API");
55
+ expect(preview.diff).toEqual([
56
+ { path: "name", before: "Checkout API", after: "Checkout" },
57
+ ]);
58
+ });
59
+
60
+ test("dryRun throws a clear error when the id is unknown", async () => {
61
+ const rpcClient = fakeRpcClient({
62
+ getSystem: mock(() => Promise.resolve(null)),
63
+ updateSystem: mock(),
64
+ });
65
+ const tool = createCatalogUpdateSystemTool();
66
+ await expect(
67
+ tool.dryRun!({
68
+ input: { id: "nope", data: { name: "x" } },
69
+ principal,
70
+ rpcClient,
71
+ }),
72
+ ).rejects.toThrow(/No system found/);
73
+ });
74
+
75
+ test("execute (apply) updates via updateSystem", async () => {
76
+ const updateSystem = mock(() => Promise.resolve(system));
77
+ const rpcClient = fakeRpcClient({
78
+ getSystem: mock(() => Promise.resolve(system)),
79
+ updateSystem,
80
+ });
81
+ const tool = createCatalogUpdateSystemTool();
82
+ const input = { id: "sys1", data: { name: "Checkout" } };
83
+ const result = await tool.execute({ input, principal, rpcClient });
84
+ expect(updateSystem).toHaveBeenCalledWith(input);
85
+ expect(result).toEqual({ system });
86
+ });
87
+ });
@@ -0,0 +1,93 @@
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 { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
11
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
12
+
13
+ /**
14
+ * Input for `catalog.updateSystem`. Mirrors the contract's (module-private)
15
+ * `UpdateSystemInputSchema`: the system id plus a PARTIAL data body. Only the
16
+ * provided fields change; `description` / `metadata` are nullable so they can be
17
+ * explicitly cleared.
18
+ */
19
+ export const CatalogUpdateSystemInputSchema = z.object({
20
+ id: z.string(),
21
+ data: z.object({
22
+ name: z.string().optional(),
23
+ description: z.string().nullable().optional(),
24
+ metadata: z.record(z.string(), z.unknown()).nullable().optional(),
25
+ }),
26
+ });
27
+ export type CatalogUpdateSystemInput = z.infer<
28
+ typeof CatalogUpdateSystemInputSchema
29
+ >;
30
+
31
+ /** Output returned once a human applies the update (the updated system). */
32
+ export interface CatalogUpdateSystemApplyResult {
33
+ system: System;
34
+ }
35
+
36
+ /**
37
+ * `catalog.updateSystem` - update an existing system by id with a partial body.
38
+ *
39
+ * `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
40
+ * and is confirm-gated in APPROVE mode. `dryRun` fetches the live system, merges
41
+ * the partial body, and returns a before -> after field diff so the confirm card
42
+ * shows exactly what changes.
43
+ */
44
+ export function createCatalogUpdateSystemTool(): RegisteredAiTool<
45
+ CatalogUpdateSystemInput,
46
+ CatalogUpdateSystemApplyResult
47
+ > {
48
+ const dryRun = async ({
49
+ input,
50
+ rpcClient,
51
+ }: {
52
+ input: CatalogUpdateSystemInput;
53
+ principal: AuthUser;
54
+ rpcClient: RpcClient;
55
+ }): Promise<AiProposalPreview<CatalogUpdateSystemInput>> => {
56
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
57
+ const system = await catalogClient.getSystem({ systemId: input.id });
58
+ if (!system) {
59
+ throw new Error(
60
+ `No system found with id "${input.id}". List systems first to get a valid id.`,
61
+ );
62
+ }
63
+ const before = {
64
+ name: system.name,
65
+ description: system.description,
66
+ metadata: system.metadata,
67
+ };
68
+ const after = { ...before, ...input.data };
69
+ const diff = computeFieldDiff({ before, after });
70
+ return {
71
+ summary: `Update system "${system.name}".`,
72
+ payload: input,
73
+ diff,
74
+ };
75
+ };
76
+
77
+ return {
78
+ name: "catalog.updateSystem",
79
+ description:
80
+ "Update an existing system by id with a partial body (only provided fields change). Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the catalog read tools first.",
81
+ effect: "mutate",
82
+ input: CatalogUpdateSystemInputSchema,
83
+ requiredAccessRules: [
84
+ qualifyAccessRuleId(pluginMetadata, catalogAccess.system.manage),
85
+ ],
86
+ dryRun,
87
+ async execute({ input, rpcClient }) {
88
+ const catalogClient = rpcClient.forPlugin(CatalogApi);
89
+ const system = await catalogClient.updateSystem(input);
90
+ return { system };
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildProjectedTool } from "@checkstack/ai-backend";
3
+ import { catalogContract, pluginMetadata } from "@checkstack/catalog-common";
4
+
5
+ describe("catalog read projections", () => {
6
+ test("catalog.listSystems projects getSystems as a read tool with the system read rule", () => {
7
+ const tool = buildProjectedTool({
8
+ procedure: catalogContract.getSystems,
9
+ sourcePluginMetadata: pluginMetadata,
10
+ procedureKey: "getSystems",
11
+ name: "catalog.listSystems",
12
+ description:
13
+ "List all systems (services/resources) with their ids and names. Read-only.",
14
+ effect: "read",
15
+ execute: () => Promise.resolve({}),
16
+ });
17
+ expect(tool.name).toBe("catalog.listSystems");
18
+ expect(tool.effect).toBe("read");
19
+ expect(tool.requiredAccessRules).toEqual(["catalog.system.read"]);
20
+ });
21
+
22
+ test("catalog.listGroups projects getGroups as a read tool with the group read rule", () => {
23
+ const tool = buildProjectedTool({
24
+ // unavoidable oRPC typing cast (established projection pattern):
25
+ procedure: catalogContract.getGroups,
26
+ sourcePluginMetadata: pluginMetadata,
27
+ procedureKey: "getGroups",
28
+ name: "catalog.listGroups",
29
+ description: "List all system groups with ids and names. Read-only.",
30
+ effect: "read",
31
+ execute: () => Promise.resolve({}),
32
+ });
33
+ expect(tool.name).toBe("catalog.listGroups");
34
+ expect(tool.effect).toBe("read");
35
+ expect(tool.requiredAccessRules).toEqual(["catalog.group.read"]);
36
+ });
37
+ });
@@ -0,0 +1,35 @@
1
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
2
+ import { createCatalogCreateSystemTool } from "./catalog-create-system";
3
+ import { createCatalogUpdateSystemTool } from "./catalog-update-system";
4
+ import { createCatalogDeleteSystemTool } from "./catalog-delete-system";
5
+ import { createCatalogCreateGroupTool } from "./catalog-create-group";
6
+ import { createCatalogUpdateGroupTool } from "./catalog-update-group";
7
+ import { createCatalogDeleteGroupTool } from "./catalog-delete-group";
8
+ import { createCatalogAddSystemToGroupTool } from "./catalog-add-system-to-group";
9
+ import { createCatalogRemoveSystemFromGroupTool } from "./catalog-remove-system-from-group";
10
+
11
+ /**
12
+ * The catalog plugin's AI tools, registered into the AI registry via
13
+ * `aiToolExtensionPoint` from this plugin's own init - NOT centralized in
14
+ * ai-backend. This is the canonical pattern any plugin uses to contribute AI
15
+ * tools without ai-backend depending on it.
16
+ *
17
+ * Create/update + membership tools are `mutate` (auto-applies in AUTO mode,
18
+ * confirm-gated in APPROVE mode); the two delete tools are `destructive` (always
19
+ * confirm-gated). All go through the USER-SCOPED client passed at call time, so
20
+ * handler-side authorization is enforced exactly as a direct UI/RPC call; the
21
+ * resolver gate + the propose/apply re-check at propose AND apply time are the
22
+ * additional authorization authority.
23
+ */
24
+ export function buildCatalogAiTools(): RegisteredAiTool[] {
25
+ return [
26
+ createCatalogCreateSystemTool(),
27
+ createCatalogUpdateSystemTool(),
28
+ createCatalogDeleteSystemTool(),
29
+ createCatalogCreateGroupTool(),
30
+ createCatalogUpdateGroupTool(),
31
+ createCatalogDeleteGroupTool(),
32
+ createCatalogAddSystemToGroupTool(),
33
+ createCatalogRemoveSystemFromGroupTool(),
34
+ ];
35
+ }
@@ -9,7 +9,7 @@
9
9
  * failure path.
10
10
  */
11
11
  import { describe, expect, it, mock } from "bun:test";
12
- import type { Logger } from "@checkstack/backend-api";
12
+ import type { Logger, RpcClient } from "@checkstack/backend-api";
13
13
  import { createMockLogger } from "@checkstack/test-utils-backend";
14
14
 
15
15
  import {
@@ -33,6 +33,7 @@ const ctxBase = {
33
33
  getService: async <T,>(): Promise<T> => {
34
34
  throw new Error("not used");
35
35
  },
36
+ rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
36
37
  };
37
38
 
38
39
  // ─── Triggers ──────────────────────────────────────────────────────────