@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.
- package/CHANGELOG.md +156 -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 +20 -17
- 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
|
@@ -35,13 +35,69 @@ interface MockGroup {
|
|
|
35
35
|
updatedAt: Date;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
interface MockEnvironment {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
metadata?: Record<string, unknown>;
|
|
43
|
+
createdAt: Date;
|
|
44
|
+
updatedAt: Date;
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
function createMockEntityService() {
|
|
39
48
|
const systems: MockSystem[] = [];
|
|
40
49
|
const groups: MockGroup[] = [];
|
|
50
|
+
const environments: MockEnvironment[] = [];
|
|
41
51
|
|
|
42
52
|
return {
|
|
43
53
|
systems,
|
|
44
54
|
groups,
|
|
55
|
+
environments,
|
|
56
|
+
createEnvironment: mock(
|
|
57
|
+
async (data: {
|
|
58
|
+
name: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
metadata?: Record<string, unknown>;
|
|
61
|
+
}) => {
|
|
62
|
+
const environment: MockEnvironment = {
|
|
63
|
+
id: `env-${environments.length + 1}`,
|
|
64
|
+
name: data.name,
|
|
65
|
+
description: data.description,
|
|
66
|
+
metadata: data.metadata,
|
|
67
|
+
createdAt: new Date(),
|
|
68
|
+
updatedAt: new Date(),
|
|
69
|
+
};
|
|
70
|
+
environments.push(environment);
|
|
71
|
+
return environment;
|
|
72
|
+
},
|
|
73
|
+
),
|
|
74
|
+
updateEnvironment: mock(
|
|
75
|
+
async (
|
|
76
|
+
id: string,
|
|
77
|
+
data: Partial<{
|
|
78
|
+
name: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
metadata?: Record<string, unknown>;
|
|
81
|
+
}>,
|
|
82
|
+
) => {
|
|
83
|
+
const environment = environments.find((e) => e.id === id);
|
|
84
|
+
if (environment) Object.assign(environment, data);
|
|
85
|
+
return environment;
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
deleteEnvironment: mock(async (id: string) => {
|
|
89
|
+
const idx = environments.findIndex((e) => e.id === id);
|
|
90
|
+
if (idx >= 0) environments.splice(idx, 1);
|
|
91
|
+
}),
|
|
92
|
+
addSystemToEnvironment: mock(
|
|
93
|
+
async (_props: { environmentId: string; systemId: string }) => {},
|
|
94
|
+
),
|
|
95
|
+
removeSystemFromEnvironment: mock(
|
|
96
|
+
async (_props: { environmentId: string; systemId: string }) => {},
|
|
97
|
+
),
|
|
98
|
+
getEnvironmentsForSystem: mock(async (_systemId: string) => {
|
|
99
|
+
return [] as { environmentId: string; systemId: string }[];
|
|
100
|
+
}),
|
|
45
101
|
createSystem: mock(async (data: { name: string; description?: string }) => {
|
|
46
102
|
const system: MockSystem = {
|
|
47
103
|
id: `sys-${systems.length + 1}`,
|
|
@@ -454,3 +510,235 @@ describe("Catalog GitOps Kind: Group", () => {
|
|
|
454
510
|
expect(mockService.groups).toHaveLength(0);
|
|
455
511
|
});
|
|
456
512
|
});
|
|
513
|
+
|
|
514
|
+
describe("Catalog GitOps Kind: Environment", () => {
|
|
515
|
+
let mockService: ReturnType<typeof createMockEntityService>;
|
|
516
|
+
|
|
517
|
+
const environmentSpecSchema = z.object({
|
|
518
|
+
fields: z.record(z.string(), z.unknown()).optional(),
|
|
519
|
+
});
|
|
520
|
+
type EnvironmentSpec = z.infer<typeof environmentSpecSchema>;
|
|
521
|
+
|
|
522
|
+
function buildEnvironmentKind(
|
|
523
|
+
svc: ReturnType<typeof createMockEntityService>,
|
|
524
|
+
): EntityKindDefinition<EnvironmentSpec> {
|
|
525
|
+
return {
|
|
526
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
527
|
+
kind: "Environment",
|
|
528
|
+
specSchema: environmentSpecSchema,
|
|
529
|
+
reconcile: async ({ entity, existingEntityId, context }) => {
|
|
530
|
+
const displayName = entity.metadata.title ?? entity.metadata.name;
|
|
531
|
+
const description = entity.metadata.description;
|
|
532
|
+
const metadata = entity.spec.fields ?? {};
|
|
533
|
+
|
|
534
|
+
if (existingEntityId) {
|
|
535
|
+
await svc.updateEnvironment(existingEntityId, {
|
|
536
|
+
name: displayName,
|
|
537
|
+
description,
|
|
538
|
+
metadata,
|
|
539
|
+
});
|
|
540
|
+
context.logger.info(`Updated environment (id: ${existingEntityId})`);
|
|
541
|
+
return { entityId: existingEntityId };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const environment = await svc.createEnvironment({
|
|
545
|
+
name: displayName,
|
|
546
|
+
description,
|
|
547
|
+
metadata,
|
|
548
|
+
});
|
|
549
|
+
context.logger.info(`Created environment (id: ${environment.id})`);
|
|
550
|
+
return { entityId: environment.id };
|
|
551
|
+
},
|
|
552
|
+
delete: async ({ entityId }) => {
|
|
553
|
+
if (entityId) await svc.deleteEnvironment(entityId);
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
beforeEach(() => {
|
|
559
|
+
mockService = createMockEntityService();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("creates a new environment with free-form custom fields", async () => {
|
|
563
|
+
const kind = buildEnvironmentKind(mockService);
|
|
564
|
+
|
|
565
|
+
const result = await kind.reconcile({
|
|
566
|
+
entity: {
|
|
567
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
568
|
+
kind: "Environment",
|
|
569
|
+
metadata: {
|
|
570
|
+
name: "production",
|
|
571
|
+
title: "Production",
|
|
572
|
+
description: "Live traffic",
|
|
573
|
+
},
|
|
574
|
+
spec: { fields: { baseUrl: "https://prod.example.com", tier: "1" } },
|
|
575
|
+
},
|
|
576
|
+
context: mockContext,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(result.entityId).toBe("env-1");
|
|
580
|
+
expect(mockService.createEnvironment).toHaveBeenCalledTimes(1);
|
|
581
|
+
expect(mockService.environments).toHaveLength(1);
|
|
582
|
+
expect(mockService.environments[0].name).toBe("Production");
|
|
583
|
+
expect(mockService.environments[0].description).toBe("Live traffic");
|
|
584
|
+
expect(mockService.environments[0].metadata).toEqual({
|
|
585
|
+
baseUrl: "https://prod.example.com",
|
|
586
|
+
tier: "1",
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("defaults metadata to {} when spec.fields is absent", async () => {
|
|
591
|
+
const kind = buildEnvironmentKind(mockService);
|
|
592
|
+
|
|
593
|
+
await kind.reconcile({
|
|
594
|
+
entity: {
|
|
595
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
596
|
+
kind: "Environment",
|
|
597
|
+
metadata: { name: "staging" },
|
|
598
|
+
spec: {},
|
|
599
|
+
},
|
|
600
|
+
context: mockContext,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
expect(mockService.environments[0].name).toBe("staging");
|
|
604
|
+
expect(mockService.environments[0].metadata).toEqual({});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("updates an existing environment using existingEntityId", async () => {
|
|
608
|
+
const kind = buildEnvironmentKind(mockService);
|
|
609
|
+
|
|
610
|
+
mockService.environments.push({
|
|
611
|
+
id: "env-existing",
|
|
612
|
+
name: "Old",
|
|
613
|
+
metadata: { region: "eu" },
|
|
614
|
+
createdAt: new Date(),
|
|
615
|
+
updatedAt: new Date(),
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const result = await kind.reconcile({
|
|
619
|
+
entity: {
|
|
620
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
621
|
+
kind: "Environment",
|
|
622
|
+
metadata: { name: "production", title: "Production v2" },
|
|
623
|
+
spec: { fields: { region: "us" } },
|
|
624
|
+
},
|
|
625
|
+
existingEntityId: "env-existing",
|
|
626
|
+
context: mockContext,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
expect(result.entityId).toBe("env-existing");
|
|
630
|
+
expect(mockService.createEnvironment).not.toHaveBeenCalled();
|
|
631
|
+
expect(mockService.updateEnvironment).toHaveBeenCalledTimes(1);
|
|
632
|
+
expect(mockService.environments[0].name).toBe("Production v2");
|
|
633
|
+
expect(mockService.environments[0].metadata).toEqual({ region: "us" });
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("deletes an environment by entityId", async () => {
|
|
637
|
+
const kind = buildEnvironmentKind(mockService);
|
|
638
|
+
|
|
639
|
+
mockService.environments.push({
|
|
640
|
+
id: "env-del",
|
|
641
|
+
name: "To Delete",
|
|
642
|
+
createdAt: new Date(),
|
|
643
|
+
updatedAt: new Date(),
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
await kind.delete!({
|
|
647
|
+
entityName: "old-env",
|
|
648
|
+
entityId: "env-del",
|
|
649
|
+
context: mockContext,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
expect(mockService.deleteEnvironment).toHaveBeenCalledWith("env-del");
|
|
653
|
+
expect(mockService.environments).toHaveLength(0);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("skips delete when entityId is missing", async () => {
|
|
657
|
+
const kind = buildEnvironmentKind(mockService);
|
|
658
|
+
|
|
659
|
+
await kind.delete!({
|
|
660
|
+
entityName: "unknown-env",
|
|
661
|
+
context: mockContext,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
expect(mockService.deleteEnvironment).not.toHaveBeenCalled();
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe("Catalog GitOps Kind Extension: System -> environments", () => {
|
|
669
|
+
let mockService: ReturnType<typeof createMockEntityService>;
|
|
670
|
+
|
|
671
|
+
beforeEach(() => {
|
|
672
|
+
mockService = createMockEntityService();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("associates system with environments and removes stale ones", async () => {
|
|
676
|
+
mockService.getEnvironmentsForSystem.mockResolvedValueOnce([
|
|
677
|
+
{ environmentId: "env-stale", systemId: "sys-1" },
|
|
678
|
+
]);
|
|
679
|
+
|
|
680
|
+
const mockExtContext: ReconcileContext = {
|
|
681
|
+
...mockContext,
|
|
682
|
+
resolveEntityRef: mock(async ({ entityName }) => {
|
|
683
|
+
if (entityName === "new-env") return "env-new";
|
|
684
|
+
return undefined;
|
|
685
|
+
}),
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// Simulate the inline reconcile logic from index.ts
|
|
689
|
+
const reconcileExt = async ({
|
|
690
|
+
extensionSpec,
|
|
691
|
+
entityId,
|
|
692
|
+
context,
|
|
693
|
+
}: {
|
|
694
|
+
extensionSpec?: { kind: string; name: string }[];
|
|
695
|
+
entityId: string;
|
|
696
|
+
context: ReconcileContext;
|
|
697
|
+
}) => {
|
|
698
|
+
if (!extensionSpec || extensionSpec.length === 0) return;
|
|
699
|
+
|
|
700
|
+
const desiredEnvironmentIds = new Set<string>();
|
|
701
|
+
|
|
702
|
+
for (const entry of extensionSpec) {
|
|
703
|
+
const environmentId = await context.resolveEntityRef({
|
|
704
|
+
kind: entry.kind,
|
|
705
|
+
entityName: entry.name,
|
|
706
|
+
});
|
|
707
|
+
if (environmentId) {
|
|
708
|
+
desiredEnvironmentIds.add(environmentId);
|
|
709
|
+
await mockService.addSystemToEnvironment({
|
|
710
|
+
environmentId,
|
|
711
|
+
systemId: entityId,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const currentAssociations =
|
|
717
|
+
await mockService.getEnvironmentsForSystem(entityId);
|
|
718
|
+
for (const existing of currentAssociations) {
|
|
719
|
+
if (!desiredEnvironmentIds.has(existing.environmentId)) {
|
|
720
|
+
await mockService.removeSystemFromEnvironment({
|
|
721
|
+
environmentId: existing.environmentId,
|
|
722
|
+
systemId: entityId,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
await reconcileExt({
|
|
729
|
+
extensionSpec: [{ kind: "Environment", name: "new-env" }],
|
|
730
|
+
entityId: "sys-1",
|
|
731
|
+
context: mockExtContext,
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
expect(mockService.addSystemToEnvironment).toHaveBeenCalledWith({
|
|
735
|
+
environmentId: "env-new",
|
|
736
|
+
systemId: "sys-1",
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
expect(mockService.removeSystemFromEnvironment).toHaveBeenCalledWith({
|
|
740
|
+
environmentId: "env-stale",
|
|
741
|
+
systemId: "sys-1",
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,12 @@ import {
|
|
|
3
3
|
type SafeDatabase,
|
|
4
4
|
} from "@checkstack/backend-api";
|
|
5
5
|
import { coreServices } from "@checkstack/backend-api";
|
|
6
|
+
import {
|
|
7
|
+
aiToolExtensionPoint,
|
|
8
|
+
aiToolProjectionExtensionPoint,
|
|
9
|
+
deferredProjectionExecute,
|
|
10
|
+
} from "@checkstack/ai-backend";
|
|
11
|
+
import { buildCatalogAiTools } from "./ai/register-ai-tools";
|
|
6
12
|
import {
|
|
7
13
|
automationActionExtensionPoint,
|
|
8
14
|
automationArtifactTypeExtensionPoint,
|
|
@@ -289,6 +295,109 @@ export default createBackendPlugin({
|
|
|
289
295
|
},
|
|
290
296
|
});
|
|
291
297
|
|
|
298
|
+
// Register kind: Environment (mirror "Group")
|
|
299
|
+
kindRegistry.registerKind({
|
|
300
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
301
|
+
kind: "Environment",
|
|
302
|
+
// Free-form custom fields. `z.record` keeps GitOps in step with the
|
|
303
|
+
// free-form metadata decision (v1): fields surface in templating verbatim.
|
|
304
|
+
specSchema: z.object({
|
|
305
|
+
fields: z.record(z.string(), z.unknown()).optional(),
|
|
306
|
+
}),
|
|
307
|
+
reconcile: async ({ entity, existingEntityId, context }) => {
|
|
308
|
+
if (!gitopsDb) throw new Error("Catalog database not initialized");
|
|
309
|
+
const entityService = new EntityService(gitopsDb);
|
|
310
|
+
const displayName = entity.metadata.title ?? entity.metadata.name;
|
|
311
|
+
const description = entity.metadata.description;
|
|
312
|
+
const metadata = entity.spec.fields ?? {};
|
|
313
|
+
|
|
314
|
+
if (existingEntityId) {
|
|
315
|
+
await entityService.updateEnvironment(existingEntityId, {
|
|
316
|
+
name: displayName,
|
|
317
|
+
description,
|
|
318
|
+
metadata,
|
|
319
|
+
});
|
|
320
|
+
context.logger.info(
|
|
321
|
+
`GitOps: updated Environment "${displayName}" (id: ${existingEntityId})`,
|
|
322
|
+
);
|
|
323
|
+
return { entityId: existingEntityId };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const environment = await entityService.createEnvironment({
|
|
327
|
+
name: displayName,
|
|
328
|
+
description,
|
|
329
|
+
metadata,
|
|
330
|
+
});
|
|
331
|
+
context.logger.info(
|
|
332
|
+
`GitOps: created Environment "${displayName}" (id: ${environment.id})`,
|
|
333
|
+
);
|
|
334
|
+
return { entityId: environment.id };
|
|
335
|
+
},
|
|
336
|
+
delete: async ({ entityId, context }) => {
|
|
337
|
+
if (!gitopsDb) throw new Error("Catalog database not initialized");
|
|
338
|
+
if (!entityId) return;
|
|
339
|
+
const entityService = new EntityService(gitopsDb);
|
|
340
|
+
await entityService.deleteEnvironment(entityId);
|
|
341
|
+
context.logger.info(`GitOps: deleted Environment (id: ${entityId})`);
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Register kind extension: System -> environments (mirror System -> groups)
|
|
346
|
+
kindRegistry.registerKindExtension({
|
|
347
|
+
apiVersion: CHECKSTACK_API_VERSION,
|
|
348
|
+
kind: "System",
|
|
349
|
+
namespace: "environments",
|
|
350
|
+
specSchema: z.array(entityRefSchema).optional(),
|
|
351
|
+
reconcile: async ({ entity, extensionSpec, entityId, context }) => {
|
|
352
|
+
if (!gitopsDb) throw new Error("Catalog database not initialized");
|
|
353
|
+
if (!extensionSpec || extensionSpec.length === 0) return;
|
|
354
|
+
|
|
355
|
+
const entityService = new EntityService(gitopsDb);
|
|
356
|
+
const systemEntityId = entityId;
|
|
357
|
+
|
|
358
|
+
const desiredEnvironmentIds = new Set<string>();
|
|
359
|
+
|
|
360
|
+
for (const entry of extensionSpec) {
|
|
361
|
+
const environmentId = await context.resolveEntityRef({
|
|
362
|
+
kind: entry.kind,
|
|
363
|
+
entityName: entry.name,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
if (!environmentId) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`Cannot resolve ${entry.kind} ref "${entry.name}" — ensure the entity exists`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
desiredEnvironmentIds.add(environmentId);
|
|
373
|
+
|
|
374
|
+
await entityService.addSystemToEnvironment({
|
|
375
|
+
environmentId,
|
|
376
|
+
systemId: systemEntityId,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
context.logger.info(
|
|
380
|
+
`GitOps: associated System "${entity.metadata.name}" with Environment "${entry.name}" (${environmentId})`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Remove stale associations not in the spec
|
|
385
|
+
const currentAssociations =
|
|
386
|
+
await entityService.getEnvironmentsForSystem(systemEntityId);
|
|
387
|
+
for (const existing of currentAssociations) {
|
|
388
|
+
if (!desiredEnvironmentIds.has(existing.environmentId)) {
|
|
389
|
+
await entityService.removeSystemFromEnvironment({
|
|
390
|
+
environmentId: existing.environmentId,
|
|
391
|
+
systemId: systemEntityId,
|
|
392
|
+
});
|
|
393
|
+
context.logger.info(
|
|
394
|
+
`GitOps: removed stale association ${existing.environmentId} from System "${entity.metadata.name}"`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
292
401
|
env.registerInit({
|
|
293
402
|
schema,
|
|
294
403
|
deps: {
|
|
@@ -331,6 +440,46 @@ export default createBackendPlugin({
|
|
|
331
440
|
});
|
|
332
441
|
rpc.registerRouter(catalogRouter, catalogContract);
|
|
333
442
|
|
|
443
|
+
// Register this plugin's AI tools (system + group + membership) into
|
|
444
|
+
// the AI registry via the extension point - owned here, not in
|
|
445
|
+
// ai-backend. The tools go through the USER-SCOPED client passed at
|
|
446
|
+
// call time, so handler-side authorization is enforced exactly as a
|
|
447
|
+
// direct UI/RPC call.
|
|
448
|
+
const aiToolExt = env.getExtensionPoint(aiToolExtensionPoint);
|
|
449
|
+
for (const tool of buildCatalogAiTools()) {
|
|
450
|
+
aiToolExt.registerTool(tool, pluginMetadata);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Expose this plugin's OWN read-only AI projections of the existing
|
|
454
|
+
// `getSystems` / `getGroups` queries via aiToolProjectionExtensionPoint
|
|
455
|
+
// - owned here, not in ai-backend. The projected read tools are routed
|
|
456
|
+
// by the transport (MCP / chat) AS the principal, so the procedures'
|
|
457
|
+
// own contract access rules gate them; `deferredProjectionExecute` is
|
|
458
|
+
// the fail-closed net if a transport ever forgot to route.
|
|
459
|
+
const aiProjectionExt = env.getExtensionPoint(
|
|
460
|
+
aiToolProjectionExtensionPoint,
|
|
461
|
+
);
|
|
462
|
+
aiProjectionExt.expose({
|
|
463
|
+
procedure: catalogContract.getSystems,
|
|
464
|
+
sourcePluginMetadata: pluginMetadata,
|
|
465
|
+
procedureKey: "getSystems",
|
|
466
|
+
name: "catalog.listSystems",
|
|
467
|
+
description:
|
|
468
|
+
"List all systems (services/resources) with their ids and names. Read-only. Use this to resolve a system name to its id.",
|
|
469
|
+
effect: "read",
|
|
470
|
+
execute: deferredProjectionExecute,
|
|
471
|
+
});
|
|
472
|
+
aiProjectionExt.expose({
|
|
473
|
+
procedure: catalogContract.getGroups,
|
|
474
|
+
sourcePluginMetadata: pluginMetadata,
|
|
475
|
+
procedureKey: "getGroups",
|
|
476
|
+
name: "catalog.listGroups",
|
|
477
|
+
description:
|
|
478
|
+
"List all system groups with ids and names. Read-only.",
|
|
479
|
+
effect: "read",
|
|
480
|
+
execute: deferredProjectionExecute,
|
|
481
|
+
});
|
|
482
|
+
|
|
334
483
|
// Register catalog systems as searchable in the command palette
|
|
335
484
|
registerSearchProvider({
|
|
336
485
|
pluginMetadata,
|
package/src/router.test.ts
CHANGED
|
@@ -134,6 +134,89 @@ describe("Catalog Router - GitOps Provenance Enforcement", () => {
|
|
|
134
134
|
expect((error as any).code).toBe("FORBIDDEN");
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
it("throws CONFLICT when creating a system whose name already exists", async () => {
|
|
138
|
+
// getSystemByName (the first/only select before the guard throws) finds a
|
|
139
|
+
// system already using this name.
|
|
140
|
+
(mockDb as { select: ReturnType<typeof mock> }).select.mockReturnValueOnce({
|
|
141
|
+
from: () => ({
|
|
142
|
+
where: () =>
|
|
143
|
+
Promise.resolve([
|
|
144
|
+
{
|
|
145
|
+
id: "existing",
|
|
146
|
+
name: "Payments",
|
|
147
|
+
description: null,
|
|
148
|
+
metadata: {},
|
|
149
|
+
createdAt: new Date(),
|
|
150
|
+
updatedAt: new Date(),
|
|
151
|
+
},
|
|
152
|
+
]),
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
156
|
+
|
|
157
|
+
let error;
|
|
158
|
+
try {
|
|
159
|
+
await call(router.createSystem, { name: "Payments" }, { context });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
error = e;
|
|
162
|
+
}
|
|
163
|
+
expect(error).toBeDefined();
|
|
164
|
+
expect((error as any).code).toBe("CONFLICT");
|
|
165
|
+
expect((error as any).message).toContain("already exists");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("throws CONFLICT when renaming a system to a name another system uses", async () => {
|
|
169
|
+
mockGitOpsClient.getProvenance.mockResolvedValueOnce(null);
|
|
170
|
+
// 1st select: getSystem(id) finds the system being renamed.
|
|
171
|
+
// 2nd select: getSystemByName(newName) finds a DIFFERENT system.
|
|
172
|
+
(mockDb as { select: ReturnType<typeof mock> }).select
|
|
173
|
+
.mockReturnValueOnce({
|
|
174
|
+
from: () => ({
|
|
175
|
+
where: () =>
|
|
176
|
+
Promise.resolve([
|
|
177
|
+
{
|
|
178
|
+
id: "sys-1",
|
|
179
|
+
name: "Old",
|
|
180
|
+
description: null,
|
|
181
|
+
metadata: {},
|
|
182
|
+
createdAt: new Date(),
|
|
183
|
+
updatedAt: new Date(),
|
|
184
|
+
},
|
|
185
|
+
]),
|
|
186
|
+
}),
|
|
187
|
+
})
|
|
188
|
+
.mockReturnValueOnce({
|
|
189
|
+
from: () => ({
|
|
190
|
+
where: () =>
|
|
191
|
+
Promise.resolve([
|
|
192
|
+
{
|
|
193
|
+
id: "other",
|
|
194
|
+
name: "Taken",
|
|
195
|
+
description: null,
|
|
196
|
+
metadata: {},
|
|
197
|
+
createdAt: new Date(),
|
|
198
|
+
updatedAt: new Date(),
|
|
199
|
+
},
|
|
200
|
+
]),
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
204
|
+
|
|
205
|
+
let error;
|
|
206
|
+
try {
|
|
207
|
+
await call(
|
|
208
|
+
router.updateSystem,
|
|
209
|
+
{ id: "sys-1", data: { name: "Taken" } },
|
|
210
|
+
{ context },
|
|
211
|
+
);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
error = e;
|
|
214
|
+
}
|
|
215
|
+
expect(error).toBeDefined();
|
|
216
|
+
expect((error as any).code).toBe("CONFLICT");
|
|
217
|
+
expect((error as any).message).toContain("already exists");
|
|
218
|
+
});
|
|
219
|
+
|
|
137
220
|
it("allows adding an unlocked system to a group, even if the group is locked", async () => {
|
|
138
221
|
mockGitOpsClient.getProvenance.mockResolvedValueOnce(null);
|
|
139
222
|
|
|
@@ -145,4 +228,28 @@ describe("Catalog Router - GitOps Provenance Enforcement", () => {
|
|
|
145
228
|
expect(e.code).not.toBe("FORBIDDEN");
|
|
146
229
|
}
|
|
147
230
|
});
|
|
231
|
+
|
|
232
|
+
it("maps a DB unique violation (concurrent create race) to CONFLICT", async () => {
|
|
233
|
+
// The pre-insert name check finds no clash...
|
|
234
|
+
(mockDb as { select: ReturnType<typeof mock> }).select.mockReturnValueOnce({
|
|
235
|
+
from: () => ({ where: () => Promise.resolve([]) }),
|
|
236
|
+
});
|
|
237
|
+
// ...but the INSERT loses a race and the DB's unique index rejects it.
|
|
238
|
+
(mockDb as { insert: ReturnType<typeof mock> }).insert = mock(() => ({
|
|
239
|
+
values: () => ({
|
|
240
|
+
returning: () => Promise.reject({ code: "23505" }),
|
|
241
|
+
}),
|
|
242
|
+
}));
|
|
243
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
244
|
+
|
|
245
|
+
let error;
|
|
246
|
+
try {
|
|
247
|
+
await call(router.createSystem, { name: "Payments" }, { context });
|
|
248
|
+
} catch (e) {
|
|
249
|
+
error = e;
|
|
250
|
+
}
|
|
251
|
+
expect(error).toBeDefined();
|
|
252
|
+
expect((error as any).code).toBe("CONFLICT");
|
|
253
|
+
expect((error as any).message).toContain("already exists");
|
|
254
|
+
});
|
|
148
255
|
});
|