@checkstack/backend 0.0.2 → 0.2.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 CHANGED
@@ -1,5 +1,134 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e43507: # Teams and Resource-Level Access Control
8
+
9
+ This release introduces a comprehensive Teams system for organizing users and controlling access to resources at a granular level.
10
+
11
+ ## Features
12
+
13
+ ### Team Management
14
+
15
+ - Create, update, and delete teams with name and description
16
+ - Add/remove users from teams
17
+ - Designate team managers with elevated privileges
18
+ - View team membership and manager status
19
+
20
+ ### Resource-Level Access Control
21
+
22
+ - Grant teams access to specific resources (systems, health checks, incidents, maintenances)
23
+ - Configure read-only or manage permissions per team
24
+ - Resource-level "Team Only" mode that restricts access exclusively to team members
25
+ - Separate `resourceAccessSettings` table for resource-level settings (not per-grant)
26
+ - Automatic cleanup of grants when teams are deleted (database cascade)
27
+
28
+ ### Middleware Integration
29
+
30
+ - Extended `autoAuthMiddleware` to support resource access checks
31
+ - Single-resource pre-handler validation for detail endpoints
32
+ - Automatic list filtering for collection endpoints
33
+ - S2S endpoints for access verification
34
+
35
+ ### Frontend Components
36
+
37
+ - `TeamsTab` component for managing teams in Auth Settings
38
+ - `TeamAccessEditor` component for assigning team access to resources
39
+ - Resource-level "Team Only" toggle in `TeamAccessEditor`
40
+ - Integration into System, Health Check, Incident, and Maintenance editors
41
+
42
+ ## Breaking Changes
43
+
44
+ ### API Response Format Changes
45
+
46
+ List endpoints now return objects with named keys instead of arrays directly:
47
+
48
+ ```typescript
49
+ // Before
50
+ const systems = await catalogApi.getSystems();
51
+
52
+ // After
53
+ const { systems } = await catalogApi.getSystems();
54
+ ```
55
+
56
+ Affected endpoints:
57
+
58
+ - `catalog.getSystems` → `{ systems: [...] }`
59
+ - `healthcheck.getConfigurations` → `{ configurations: [...] }`
60
+ - `incident.listIncidents` → `{ incidents: [...] }`
61
+ - `maintenance.listMaintenances` → `{ maintenances: [...] }`
62
+
63
+ ### User Identity Enrichment
64
+
65
+ `RealUser` and `ApplicationUser` types now include `teamIds: string[]` field with team memberships.
66
+
67
+ ## Documentation
68
+
69
+ See `docs/backend/teams.md` for complete API reference and integration guide.
70
+
71
+ ### Patch Changes
72
+
73
+ - 97c5a6b: Fix collector lookup when health check is assigned to a system
74
+
75
+ Collectors are now stored in the registry with their fully-qualified ID format (ownerPluginId.collectorId) to match how they are referenced in health check configurations. Added `qualifiedId` field to `RegisteredCollector` interface to avoid re-constructing the ID at query time. This fixes the "Collector not found" warning that occurred when executing health checks with assigned systems.
76
+
77
+ - Updated dependencies [97c5a6b]
78
+ - Updated dependencies [8e43507]
79
+ - @checkstack/backend-api@0.2.0
80
+ - @checkstack/auth-common@0.1.0
81
+ - @checkstack/common@0.1.0
82
+ - @checkstack/queue-api@0.0.4
83
+ - @checkstack/signal-backend@0.0.4
84
+ - @checkstack/api-docs-common@0.0.4
85
+ - @checkstack/signal-common@0.0.4
86
+
87
+ ## 0.1.0
88
+
89
+ ### Minor Changes
90
+
91
+ - f5b1f49: Added collector registry lifecycle cleanup during plugin unloading.
92
+
93
+ - Added `unregisterByOwner(pluginId)` to remove collectors owned by unloading plugins
94
+ - Added `unregisterByMissingStrategies(loadedPluginIds)` for dependency-based pruning
95
+ - Integrated registry cleanup into `PluginManager.deregisterPlugin()`
96
+ - Updated `registerCoreServices` to return global registries for lifecycle management
97
+
98
+ ### Patch Changes
99
+
100
+ - f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
101
+
102
+ **JSONPath Assertions:**
103
+
104
+ - Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
105
+ - Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
106
+ - Added `jsonPath` field to `CollectorAssertionSchema` for persistence
107
+ - HTTP Request collector body field now supports JSONPath assertions
108
+
109
+ **Fully Qualified Strategy IDs:**
110
+
111
+ - HealthCheckRegistry now uses scoped factories like CollectorRegistry
112
+ - Strategies are stored with `pluginId.strategyId` format
113
+ - Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
114
+ - Router returns qualified IDs so frontend can correctly fetch collectors
115
+
116
+ **UI Improvements:**
117
+
118
+ - Save button disabled when collector configs have invalid required fields
119
+ - Fixed nested button warning in CollectorList accordion
120
+
121
+ - Updated dependencies [f5b1f49]
122
+ - Updated dependencies [f5b1f49]
123
+ - Updated dependencies [f5b1f49]
124
+ - @checkstack/backend-api@0.1.0
125
+ - @checkstack/common@0.0.3
126
+ - @checkstack/queue-api@0.0.3
127
+ - @checkstack/signal-backend@0.0.3
128
+ - @checkstack/api-docs-common@0.0.3
129
+ - @checkstack/auth-common@0.0.3
130
+ - @checkstack/signal-common@0.0.3
131
+
3
132
  ## 0.0.2
4
133
 
5
134
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.0.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun --env-file=../../.env --watch src/index.ts",
@@ -30,10 +30,13 @@ describe("HealthCheck Plugin Integration", () => {
30
30
  newResponse: mock(),
31
31
  } as never;
32
32
 
33
- // Define a mock execute function for the strategy
34
- const mockExecute = mock(async () => ({ status: "healthy" as const }));
33
+ // Define a mock createClient function for the strategy
34
+ const mockCreateClient = mock(async () => ({
35
+ client: { exec: async () => ({}) },
36
+ close: () => {},
37
+ }));
35
38
 
36
- // 1. Define a mock strategy
39
+ // 1. Define a mock strategy with createClient pattern
37
40
  const mockStrategy: HealthCheckStrategy = {
38
41
  id: "test-strategy",
39
42
  displayName: "Test Strategy",
@@ -42,11 +45,15 @@ describe("HealthCheck Plugin Integration", () => {
42
45
  version: 1,
43
46
  schema: z.object({}),
44
47
  }),
48
+ result: new Versioned({
49
+ version: 1,
50
+ schema: z.record(z.string(), z.unknown()),
51
+ }),
45
52
  aggregatedResult: new Versioned({
46
53
  version: 1,
47
54
  schema: z.record(z.string(), z.unknown()),
48
55
  }),
49
- execute: mockExecute,
56
+ createClient: mockCreateClient,
50
57
  aggregateResult: mock(() => ({})),
51
58
  };
52
59
 
@@ -88,6 +95,6 @@ describe("HealthCheck Plugin Integration", () => {
88
95
  expect(retrieved).toBe(mockStrategy);
89
96
  expect(retrieved?.displayName).toBe("Test Strategy");
90
97
  expect(retrieved?.id).toBe("test-strategy");
91
- expect(retrieved?.execute).toBe(mockExecute);
98
+ expect(retrieved?.createClient).toBe(mockCreateClient);
92
99
  });
93
100
  });
@@ -8,13 +8,11 @@ import {
8
8
  Logger,
9
9
  Fetch,
10
10
  HealthCheckRegistry,
11
+ CollectorRegistry,
11
12
  type EmitHookFn,
12
13
  type Hook,
13
14
  } from "@checkstack/backend-api";
14
- import type {
15
- QueuePluginRegistry,
16
- QueueManager,
17
- } from "@checkstack/queue-api";
15
+ import type { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
18
16
  import type { ServiceRegistry } from "../services/service-registry";
19
17
  import type { EventBus } from "@checkstack/backend-api";
20
18
  import type { PluginMetadata } from "@checkstack/common";
@@ -70,6 +68,7 @@ export function createApiRouteHandler({
70
68
  const healthCheckRegistry = await getService(
71
69
  coreServices.healthCheckRegistry
72
70
  );
71
+ const collectorRegistry = await getService(coreServices.collectorRegistry);
73
72
  const queuePluginRegistry = await getService(
74
73
  coreServices.queuePluginRegistry
75
74
  );
@@ -82,6 +81,7 @@ export function createApiRouteHandler({
82
81
  !db ||
83
82
  !fetch ||
84
83
  !healthCheckRegistry ||
84
+ !collectorRegistry ||
85
85
  !queuePluginRegistry ||
86
86
  !queueManager ||
87
87
  !eventBus
@@ -111,6 +111,7 @@ export function createApiRouteHandler({
111
111
  db: db as NodePgDatabase<Record<string, unknown>>,
112
112
  fetch: fetch as Fetch,
113
113
  healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
114
+ collectorRegistry: collectorRegistry as CollectorRegistry,
114
115
  queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
115
116
  queueManager: queueManager as QueueManager,
116
117
  user,
@@ -16,7 +16,14 @@ import type { ServiceRegistry } from "../services/service-registry";
16
16
  import { rootLogger } from "../logger";
17
17
  import { db } from "../db";
18
18
  import { jwtService } from "../services/jwt";
19
- import { CoreHealthCheckRegistry } from "../services/health-check-registry";
19
+ import {
20
+ CoreHealthCheckRegistry,
21
+ createScopedHealthCheckRegistry,
22
+ } from "../services/health-check-registry";
23
+ import {
24
+ CoreCollectorRegistry,
25
+ createScopedCollectorRegistry,
26
+ } from "../services/collector-registry";
20
27
  import { EventBus } from "../services/event-bus.js";
21
28
  import { getPluginSchemaName } from "@checkstack/drizzle-helper";
22
29
 
@@ -34,6 +41,7 @@ async function schemaExists(pool: Pool, schemaName: string): Promise<boolean> {
34
41
  /**
35
42
  * Registers all core services with the service registry.
36
43
  * Extracted from PluginManager for better organization.
44
+ * Returns the global registries for lifecycle cleanup.
37
45
  */
38
46
  export function registerCoreServices({
39
47
  registry,
@@ -47,7 +55,7 @@ export function registerCoreServices({
47
55
  pluginRpcRouters: Map<string, unknown>;
48
56
  pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
49
57
  pluginContractRegistry: Map<string, unknown>;
50
- }) {
58
+ }): { collectorRegistry: CoreCollectorRegistry } {
51
59
  // 1. Database Factory (Scoped)
52
60
  registry.registerFactory(coreServices.database, async (metadata) => {
53
61
  const { pluginId, previousPluginIds } = metadata;
@@ -169,6 +177,32 @@ export function registerCoreServices({
169
177
  return [];
170
178
  }
171
179
  },
180
+
181
+ checkResourceTeamAccess: async (params) => {
182
+ try {
183
+ const rpcClient = await registry.get(coreServices.rpcClient, {
184
+ pluginId: "core",
185
+ });
186
+ const authClient = rpcClient.forPlugin(AuthApi);
187
+ return await authClient.checkResourceTeamAccess(params);
188
+ } catch {
189
+ // Fall back to global permission on error
190
+ return { hasAccess: params.hasGlobalPermission };
191
+ }
192
+ },
193
+
194
+ getAccessibleResourceIds: async (params) => {
195
+ try {
196
+ const rpcClient = await registry.get(coreServices.rpcClient, {
197
+ pluginId: "core",
198
+ });
199
+ const authClient = rpcClient.forPlugin(AuthApi);
200
+ return await authClient.getAccessibleResourceIds(params);
201
+ } catch {
202
+ // Fall back to global permission on error
203
+ return params.hasGlobalPermission ? params.resourceIds : [];
204
+ }
205
+ },
172
206
  };
173
207
  return authService;
174
208
  });
@@ -258,11 +292,16 @@ export function registerCoreServices({
258
292
  return rpcClient;
259
293
  });
260
294
 
261
- // 6. Health Check Registry (Global Singleton)
262
- const healthCheckRegistry = new CoreHealthCheckRegistry();
263
- registry.registerFactory(
264
- coreServices.healthCheckRegistry,
265
- () => healthCheckRegistry
295
+ // 6. Health Check Registry (Scoped Factory - auto-prefixes strategy IDs with pluginId)
296
+ const globalHealthCheckRegistry = new CoreHealthCheckRegistry();
297
+ registry.registerFactory(coreServices.healthCheckRegistry, (metadata) =>
298
+ createScopedHealthCheckRegistry(globalHealthCheckRegistry, metadata)
299
+ );
300
+
301
+ // 6b. Collector Registry (Scoped Factory - injects ownerPlugin automatically)
302
+ const globalCollectorRegistry = new CoreCollectorRegistry();
303
+ registry.registerFactory(coreServices.collectorRegistry, (metadata) =>
304
+ createScopedCollectorRegistry(globalCollectorRegistry, metadata)
266
305
  );
267
306
 
268
307
  // 7. RPC Service (Scoped Factory - uses pluginId for path derivation)
@@ -309,4 +348,7 @@ export function registerCoreServices({
309
348
  }
310
349
  return eventBusInstance;
311
350
  });
351
+
352
+ // Return global registries for lifecycle cleanup
353
+ return { collectorRegistry: globalCollectorRegistry };
312
354
  }
@@ -1,6 +1,7 @@
1
1
  import type { Hono } from "hono";
2
2
  import { adminPool, db } from "./db";
3
3
  import { ServiceRegistry } from "./services/service-registry";
4
+ import type { CoreCollectorRegistry } from "./services/collector-registry";
4
5
  import {
5
6
  BackendPlugin,
6
7
  ServiceRef,
@@ -46,14 +47,18 @@ export class PluginManager {
46
47
  // Hook subscriptions per plugin (for bulk unsubscribe)
47
48
  private hookSubscriptions = new Map<string, HookUnsubscribe[]>();
48
49
 
50
+ // Global collector registry reference for cleanup
51
+ private collectorRegistry: CoreCollectorRegistry;
52
+
49
53
  constructor() {
50
- registerCoreServices({
54
+ const registries = registerCoreServices({
51
55
  registry: this.registry,
52
56
  adminPool,
53
57
  pluginRpcRouters: this.pluginRpcRouters,
54
58
  pluginHttpHandlers: this.pluginHttpHandlers,
55
59
  pluginContractRegistry: this.pluginContractRegistry,
56
60
  });
61
+ this.collectorRegistry = registries.collectorRegistry;
57
62
  }
58
63
 
59
64
  registerExtensionPoint<T>(ref: ExtensionPoint<T>, impl: T) {
@@ -163,6 +168,16 @@ export class PluginManager {
163
168
  this.pluginContractRegistry.delete(pluginId);
164
169
  rootLogger.debug(` -> Removed routers and contracts for ${pluginId}`);
165
170
 
171
+ // 4b. Cleanup collectors
172
+ // - Remove collectors owned by this plugin
173
+ this.collectorRegistry.unregisterByOwner(pluginId);
174
+ // - Remove collectors that have no loaded strategy plugins
175
+ // (exclude the current plugin being deregistered)
176
+ const loadedPluginIds = new Set(
177
+ [...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId)
178
+ );
179
+ this.collectorRegistry.unregisterByMissingStrategies(loadedPluginIds);
180
+
166
181
  // 5. Remove permissions from registry
167
182
  const beforeCount = this.registeredPermissions.length;
168
183
  this.registeredPermissions = this.registeredPermissions.filter(
@@ -0,0 +1,115 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+ import type {
3
+ CollectorStrategy,
4
+ TransportClient,
5
+ RegisteredCollector,
6
+ } from "@checkstack/backend-api";
7
+ import { rootLogger } from "../logger";
8
+
9
+ /**
10
+ * Core implementation of the CollectorRegistry storage.
11
+ * This is the global singleton that stores all collectors.
12
+ */
13
+ export class CoreCollectorRegistry {
14
+ private collectors = new Map<string, RegisteredCollector>();
15
+
16
+ registerWithOwner(
17
+ collector: CollectorStrategy<TransportClient<unknown, unknown>>,
18
+ ownerPlugin: PluginMetadata
19
+ ): void {
20
+ // Use fully-qualified ID: ownerPluginId.collectorId
21
+ const qualifiedId = `${ownerPlugin.pluginId}.${collector.id}`;
22
+ if (this.collectors.has(qualifiedId)) {
23
+ rootLogger.warn(
24
+ `CollectorStrategy '${qualifiedId}' is already registered. Overwriting.`
25
+ );
26
+ }
27
+ this.collectors.set(qualifiedId, { qualifiedId, collector, ownerPlugin });
28
+ rootLogger.debug(`✅ Registered CollectorStrategy: ${qualifiedId}`);
29
+ }
30
+
31
+ /**
32
+ * Unregister all collectors owned by a specific plugin.
33
+ * Called when the collector-providing plugin is unloaded.
34
+ */
35
+ unregisterByOwner(ownerPluginId: string): void {
36
+ const toRemove: string[] = [];
37
+ for (const [id, entry] of this.collectors) {
38
+ if (entry.ownerPlugin.pluginId === ownerPluginId) {
39
+ toRemove.push(id);
40
+ }
41
+ }
42
+ for (const id of toRemove) {
43
+ this.collectors.delete(id);
44
+ rootLogger.debug(
45
+ `🗑️ Unregistered CollectorStrategy: ${id} (owner plugin unloaded)`
46
+ );
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Remove collectors that no longer have any supported strategies loaded.
52
+ * Called when a healthcheck strategy plugin is unloaded.
53
+ * @param loadedStrategyPluginIds - Set of plugin IDs that are still loaded
54
+ */
55
+ unregisterByMissingStrategies(loadedStrategyPluginIds: Set<string>): void {
56
+ const toRemove: string[] = [];
57
+ for (const [id, entry] of this.collectors) {
58
+ // Check if ANY of the collector's supported plugins are still loaded
59
+ const hasLoadedStrategy = entry.collector.supportedPlugins.some((p) =>
60
+ loadedStrategyPluginIds.has(p.pluginId)
61
+ );
62
+ // If none of the supported plugins are loaded, mark for removal
63
+ if (!hasLoadedStrategy) {
64
+ toRemove.push(id);
65
+ }
66
+ }
67
+ for (const id of toRemove) {
68
+ this.collectors.delete(id);
69
+ rootLogger.debug(
70
+ `🗑️ Unregistered CollectorStrategy: ${id} (no supported strategies loaded)`
71
+ );
72
+ }
73
+ }
74
+
75
+ getCollector(id: string): RegisteredCollector | undefined {
76
+ return this.collectors.get(id);
77
+ }
78
+
79
+ getCollectorsForPlugin(
80
+ pluginMetadata: PluginMetadata
81
+ ): RegisteredCollector[] {
82
+ return [...this.collectors.values()].filter((entry) =>
83
+ entry.collector.supportedPlugins.some(
84
+ (p) => p.pluginId === pluginMetadata.pluginId
85
+ )
86
+ );
87
+ }
88
+
89
+ getCollectors(): RegisteredCollector[] {
90
+ return [...this.collectors.values()];
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Creates a scoped CollectorRegistry that auto-injects owning plugin metadata.
96
+ */
97
+ export function createScopedCollectorRegistry(
98
+ globalRegistry: CoreCollectorRegistry,
99
+ ownerPlugin: PluginMetadata
100
+ ) {
101
+ return {
102
+ register(collector: CollectorStrategy<TransportClient<unknown, unknown>>) {
103
+ globalRegistry.registerWithOwner(collector, ownerPlugin);
104
+ },
105
+ getCollector(id: string) {
106
+ return globalRegistry.getCollector(id);
107
+ },
108
+ getCollectorsForPlugin(pluginMetadata: PluginMetadata) {
109
+ return globalRegistry.getCollectorsForPlugin(pluginMetadata);
110
+ },
111
+ getCollectors() {
112
+ return globalRegistry.getCollectors();
113
+ },
114
+ };
115
+ }
@@ -1,8 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, mock } from "bun:test";
2
- import { CoreHealthCheckRegistry } from "./health-check-registry";
2
+ import {
3
+ CoreHealthCheckRegistry,
4
+ createScopedHealthCheckRegistry,
5
+ } from "./health-check-registry";
3
6
  import { HealthCheckStrategy, Versioned } from "@checkstack/backend-api";
4
7
  import { createMockLogger } from "@checkstack/test-utils-backend";
5
8
  import { z } from "zod";
9
+ import type { PluginMetadata } from "@checkstack/common";
6
10
 
7
11
  // Mock logger
8
12
  const mockLogger = createMockLogger();
@@ -13,6 +17,8 @@ mock.module("../logger", () => ({
13
17
  describe("CoreHealthCheckRegistry", () => {
14
18
  let registry: CoreHealthCheckRegistry;
15
19
 
20
+ const mockOwner: PluginMetadata = { pluginId: "test-plugin" };
21
+
16
22
  const mockStrategy1: HealthCheckStrategy = {
17
23
  id: "test-strategy-1",
18
24
  displayName: "Test Strategy 1",
@@ -21,11 +27,17 @@ describe("CoreHealthCheckRegistry", () => {
21
27
  version: 1,
22
28
  schema: z.object({}),
23
29
  }),
30
+ result: new Versioned({
31
+ version: 1,
32
+ schema: z.record(z.string(), z.unknown()),
33
+ }),
24
34
  aggregatedResult: new Versioned({
25
35
  version: 1,
26
36
  schema: z.record(z.string(), z.unknown()),
27
37
  }),
28
- execute: mock(() => Promise.resolve({ status: "healthy" as const })),
38
+ createClient: mock(() =>
39
+ Promise.resolve({ client: { exec: async () => ({}) }, close: () => {} })
40
+ ),
29
41
  aggregateResult: mock(() => ({})),
30
42
  };
31
43
 
@@ -37,12 +49,16 @@ describe("CoreHealthCheckRegistry", () => {
37
49
  version: 1,
38
50
  schema: z.object({}),
39
51
  }),
52
+ result: new Versioned({
53
+ version: 1,
54
+ schema: z.record(z.string(), z.unknown()),
55
+ }),
40
56
  aggregatedResult: new Versioned({
41
57
  version: 1,
42
58
  schema: z.record(z.string(), z.unknown()),
43
59
  }),
44
- execute: mock(() =>
45
- Promise.resolve({ status: "unhealthy" as const, message: "Failed" })
60
+ createClient: mock(() =>
61
+ Promise.resolve({ client: { exec: async () => ({}) }, close: () => {} })
46
62
  ),
47
63
  aggregateResult: mock(() => ({})),
48
64
  };
@@ -51,31 +67,33 @@ describe("CoreHealthCheckRegistry", () => {
51
67
  registry = new CoreHealthCheckRegistry();
52
68
  });
53
69
 
54
- describe("register", () => {
55
- it("should register a new health check strategy", () => {
56
- registry.register(mockStrategy1);
57
- expect(registry.getStrategy(mockStrategy1.id)).toBe(mockStrategy1);
70
+ describe("registerWithOwner", () => {
71
+ it("should register a new health check strategy with qualified ID", () => {
72
+ registry.registerWithOwner(mockStrategy1, mockOwner);
73
+ // Should be stored with qualified ID: ownerPluginId.strategyId
74
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
75
+ expect(registry.getStrategy(qualifiedId)).toBe(mockStrategy1);
58
76
  });
59
77
 
60
- it("should overwrite an existing strategy with the same ID", () => {
61
- const overwritingStrategy: HealthCheckStrategy<any> = {
78
+ it("should overwrite an existing strategy with the same qualified ID", () => {
79
+ const overwritingStrategy: HealthCheckStrategy = {
62
80
  ...mockStrategy1,
63
81
  displayName: "New Name",
64
82
  };
65
- registry.register(mockStrategy1);
66
- registry.register(overwritingStrategy);
83
+ registry.registerWithOwner(mockStrategy1, mockOwner);
84
+ registry.registerWithOwner(overwritingStrategy, mockOwner);
67
85
 
68
- expect(registry.getStrategy(mockStrategy1.id)).toBe(overwritingStrategy);
69
- expect(registry.getStrategy(mockStrategy1.id)?.displayName).toBe(
70
- "New Name"
71
- );
86
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
87
+ expect(registry.getStrategy(qualifiedId)).toBe(overwritingStrategy);
88
+ expect(registry.getStrategy(qualifiedId)?.displayName).toBe("New Name");
72
89
  });
73
90
  });
74
91
 
75
- describe("getStrategySection", () => {
92
+ describe("getStrategy", () => {
76
93
  it("should return the strategy if it exists", () => {
77
- registry.register(mockStrategy1);
78
- expect(registry.getStrategy(mockStrategy1.id)).toBe(mockStrategy1);
94
+ registry.registerWithOwner(mockStrategy1, mockOwner);
95
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
96
+ expect(registry.getStrategy(qualifiedId)).toBe(mockStrategy1);
79
97
  });
80
98
 
81
99
  it("should return undefined if the strategy does not exist", () => {
@@ -85,8 +103,8 @@ describe("CoreHealthCheckRegistry", () => {
85
103
 
86
104
  describe("getStrategies", () => {
87
105
  it("should return all registered strategies", () => {
88
- registry.register(mockStrategy1);
89
- registry.register(mockStrategy2);
106
+ registry.registerWithOwner(mockStrategy1, mockOwner);
107
+ registry.registerWithOwner(mockStrategy2, mockOwner);
90
108
 
91
109
  const strategies = registry.getStrategies();
92
110
  expect(strategies).toHaveLength(2);
@@ -98,4 +116,27 @@ describe("CoreHealthCheckRegistry", () => {
98
116
  expect(registry.getStrategies()).toEqual([]);
99
117
  });
100
118
  });
119
+
120
+ describe("createScopedHealthCheckRegistry", () => {
121
+ it("should auto-qualify strategy IDs on register", () => {
122
+ const scoped = createScopedHealthCheckRegistry(registry, mockOwner);
123
+ scoped.register(mockStrategy1);
124
+
125
+ // The scoped registry should auto-prefix with pluginId
126
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
127
+ expect(registry.getStrategy(qualifiedId)).toBe(mockStrategy1);
128
+ });
129
+
130
+ it("should lookup strategies by both qualified and unqualified ID", () => {
131
+ const scoped = createScopedHealthCheckRegistry(registry, mockOwner);
132
+ scoped.register(mockStrategy1);
133
+
134
+ // Should be able to lookup by unqualified ID in scoped registry
135
+ expect(scoped.getStrategy(mockStrategy1.id)).toBe(mockStrategy1);
136
+
137
+ // Should also work with qualified ID
138
+ const qualifiedId = `${mockOwner.pluginId}.${mockStrategy1.id}`;
139
+ expect(scoped.getStrategy(qualifiedId)).toBe(mockStrategy1);
140
+ });
141
+ });
101
142
  });
@@ -1,27 +1,98 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
1
2
  import {
2
3
  HealthCheckRegistry,
3
4
  HealthCheckStrategy,
4
5
  } from "@checkstack/backend-api";
5
6
  import { rootLogger } from "../logger";
6
7
 
7
- export class CoreHealthCheckRegistry implements HealthCheckRegistry {
8
- private strategies = new Map<string, HealthCheckStrategy>();
8
+ /**
9
+ * Registered strategy with its fully qualified ID and owner info.
10
+ */
11
+ interface RegisteredStrategy {
12
+ strategy: HealthCheckStrategy;
13
+ ownerPlugin: PluginMetadata;
14
+ qualifiedId: string;
15
+ }
16
+
17
+ /**
18
+ * Core implementation of the HealthCheckRegistry storage.
19
+ * This is the global singleton that stores all strategies with fully qualified IDs.
20
+ */
21
+ export class CoreHealthCheckRegistry {
22
+ private strategies = new Map<string, RegisteredStrategy>();
9
23
 
10
- register(strategy: HealthCheckStrategy) {
11
- if (this.strategies.has(strategy.id)) {
24
+ /**
25
+ * Register a strategy with its owning plugin.
26
+ * The strategy ID is stored as: ownerPluginId.strategyId
27
+ */
28
+ registerWithOwner(
29
+ strategy: HealthCheckStrategy,
30
+ ownerPlugin: PluginMetadata
31
+ ): void {
32
+ const qualifiedId = `${ownerPlugin.pluginId}.${strategy.id}`;
33
+ if (this.strategies.has(qualifiedId)) {
12
34
  rootLogger.warn(
13
- `HealthCheckStrategy '${strategy.id}' is already registered. Overwriting.`
35
+ `HealthCheckStrategy '${qualifiedId}' is already registered. Overwriting.`
14
36
  );
15
37
  }
16
- this.strategies.set(strategy.id, strategy);
17
- rootLogger.debug(`✅ Registered HealthCheckStrategy: ${strategy.id}`);
38
+ this.strategies.set(qualifiedId, { strategy, ownerPlugin, qualifiedId });
39
+ rootLogger.debug(`✅ Registered HealthCheckStrategy: ${qualifiedId}`);
18
40
  }
19
41
 
20
- getStrategy(id: string) {
21
- return this.strategies.get(id);
42
+ getStrategy(qualifiedId: string) {
43
+ return this.strategies.get(qualifiedId)?.strategy;
22
44
  }
23
45
 
24
46
  getStrategies() {
25
- return [...this.strategies.values()];
47
+ return [...this.strategies.values()].map((r) => r.strategy);
26
48
  }
49
+
50
+ /**
51
+ * Get the owner plugin ID for a strategy.
52
+ */
53
+ getOwnerPluginId(qualifiedId: string): string | undefined {
54
+ return this.strategies.get(qualifiedId)?.ownerPlugin.pluginId;
55
+ }
56
+
57
+ /**
58
+ * Get all registered strategies with their metadata.
59
+ */
60
+ getStrategiesWithMeta(): Array<{
61
+ strategy: HealthCheckStrategy;
62
+ ownerPluginId: string;
63
+ qualifiedId: string;
64
+ }> {
65
+ return [...this.strategies.values()].map((r) => ({
66
+ strategy: r.strategy,
67
+ ownerPluginId: r.ownerPlugin.pluginId,
68
+ qualifiedId: r.qualifiedId,
69
+ }));
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Creates a scoped HealthCheckRegistry that auto-prefixes strategy IDs with plugin ID.
75
+ */
76
+ export function createScopedHealthCheckRegistry(
77
+ globalRegistry: CoreHealthCheckRegistry,
78
+ ownerPlugin: PluginMetadata
79
+ ): HealthCheckRegistry {
80
+ return {
81
+ register(strategy: HealthCheckStrategy) {
82
+ globalRegistry.registerWithOwner(strategy, ownerPlugin);
83
+ },
84
+ getStrategy(id: string) {
85
+ // Support both qualified and unqualified lookups
86
+ return (
87
+ globalRegistry.getStrategy(id) ||
88
+ globalRegistry.getStrategy(`${ownerPlugin.pluginId}.${id}`)
89
+ );
90
+ },
91
+ getStrategies() {
92
+ return globalRegistry.getStrategies();
93
+ },
94
+ getStrategiesWithMeta() {
95
+ return globalRegistry.getStrategiesWithMeta();
96
+ },
97
+ };
27
98
  }
@@ -60,6 +60,12 @@ mock.module(coreServicesPath, () => ({
60
60
  authenticate: async () => {},
61
61
  getCredentials: async () => ({ headers: {} }),
62
62
  getAnonymousPermissions: async () => [],
63
+ checkResourceTeamAccess: async () => ({ hasAccess: true }),
64
+ getAccessibleResourceIds: async ({
65
+ resourceIds,
66
+ }: {
67
+ resourceIds: string[];
68
+ }) => resourceIds,
63
69
  }));
64
70
 
65
71
  // Register mock fetch factory
@@ -110,5 +116,20 @@ mock.module(coreServicesPath, () => ({
110
116
  // eslint-disable-next-line unicorn/consistent-function-scoping
111
117
  subscribe: async () => () => {},
112
118
  }));
119
+
120
+ // Return the registries object to match actual function signature
121
+ // Create a mock collector registry
122
+ const collectors = new Map<string, unknown>();
123
+ return {
124
+ collectorRegistry: {
125
+ register: (collector: { id: string }) => {
126
+ collectors.set(collector.id, collector);
127
+ },
128
+ getCollector: (id: string) => collectors.get(id),
129
+ getAllCollectors: () => [...collectors.values()],
130
+ unregisterByOwner: () => {},
131
+ unregisterByMissingStrategies: () => {},
132
+ },
133
+ };
113
134
  },
114
135
  }));