@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 +129 -0
- package/package.json +1 -1
- package/src/health-check-plugin-integration.test.ts +12 -5
- package/src/plugin-manager/api-router.ts +5 -4
- package/src/plugin-manager/core-services.ts +49 -7
- package/src/plugin-manager.ts +16 -1
- package/src/services/collector-registry.ts +115 -0
- package/src/services/health-check-registry.test.ts +62 -21
- package/src/services/health-check-registry.ts +81 -10
- package/src/test-preload.ts +21 -0
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
|
@@ -30,10 +30,13 @@ describe("HealthCheck Plugin Integration", () => {
|
|
|
30
30
|
newResponse: mock(),
|
|
31
31
|
} as never;
|
|
32
32
|
|
|
33
|
-
// Define a mock
|
|
34
|
-
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
|
-
|
|
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?.
|
|
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 {
|
|
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 (
|
|
262
|
-
const
|
|
263
|
-
registry.registerFactory(
|
|
264
|
-
|
|
265
|
-
|
|
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
|
}
|
package/src/plugin-manager.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
Promise.resolve({
|
|
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("
|
|
55
|
-
it("should register a new health check strategy", () => {
|
|
56
|
-
registry.
|
|
57
|
-
|
|
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
|
|
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.
|
|
66
|
-
registry.
|
|
83
|
+
registry.registerWithOwner(mockStrategy1, mockOwner);
|
|
84
|
+
registry.registerWithOwner(overwritingStrategy, mockOwner);
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
expect(registry.getStrategy(
|
|
70
|
-
|
|
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("
|
|
92
|
+
describe("getStrategy", () => {
|
|
76
93
|
it("should return the strategy if it exists", () => {
|
|
77
|
-
registry.
|
|
78
|
-
|
|
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.
|
|
89
|
-
registry.
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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 '${
|
|
35
|
+
`HealthCheckStrategy '${qualifiedId}' is already registered. Overwriting.`
|
|
14
36
|
);
|
|
15
37
|
}
|
|
16
|
-
this.strategies.set(strategy
|
|
17
|
-
rootLogger.debug(`✅ Registered HealthCheckStrategy: ${
|
|
38
|
+
this.strategies.set(qualifiedId, { strategy, ownerPlugin, qualifiedId });
|
|
39
|
+
rootLogger.debug(`✅ Registered HealthCheckStrategy: ${qualifiedId}`);
|
|
18
40
|
}
|
|
19
41
|
|
|
20
|
-
getStrategy(
|
|
21
|
-
return this.strategies.get(
|
|
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
|
}
|
package/src/test-preload.ts
CHANGED
|
@@ -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
|
}));
|