@checkstack/backend 0.0.2
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 +225 -0
- package/drizzle/0000_loose_yellow_claw.sql +28 -0
- package/drizzle/meta/0000_snapshot.json +187 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +42 -0
- package/src/db.ts +20 -0
- package/src/health-check-plugin-integration.test.ts +93 -0
- package/src/index.ts +419 -0
- package/src/integration/event-bus.integration.test.ts +313 -0
- package/src/logger.ts +65 -0
- package/src/openapi-router.ts +177 -0
- package/src/plugin-lifecycle.test.ts +276 -0
- package/src/plugin-manager/api-router.ts +163 -0
- package/src/plugin-manager/core-services.ts +312 -0
- package/src/plugin-manager/dependency-sorter.ts +103 -0
- package/src/plugin-manager/deregistration-guard.ts +41 -0
- package/src/plugin-manager/extension-points.ts +85 -0
- package/src/plugin-manager/index.ts +13 -0
- package/src/plugin-manager/plugin-admin-router.ts +89 -0
- package/src/plugin-manager/plugin-loader.ts +464 -0
- package/src/plugin-manager/types.ts +14 -0
- package/src/plugin-manager.test.ts +464 -0
- package/src/plugin-manager.ts +431 -0
- package/src/rpc-rest-compat.test.ts +80 -0
- package/src/schema.ts +46 -0
- package/src/services/config-service.test.ts +66 -0
- package/src/services/config-service.ts +322 -0
- package/src/services/event-bus.test.ts +469 -0
- package/src/services/event-bus.ts +317 -0
- package/src/services/health-check-registry.test.ts +101 -0
- package/src/services/health-check-registry.ts +27 -0
- package/src/services/jwt.ts +45 -0
- package/src/services/keystore.test.ts +198 -0
- package/src/services/keystore.ts +136 -0
- package/src/services/plugin-installer.test.ts +90 -0
- package/src/services/plugin-installer.ts +70 -0
- package/src/services/queue-manager.ts +382 -0
- package/src/services/queue-plugin-registry.ts +17 -0
- package/src/services/queue-proxy.ts +182 -0
- package/src/services/service-registry.ts +35 -0
- package/src/test-preload.ts +114 -0
- package/src/utils/plugin-discovery.test.ts +383 -0
- package/src/utils/plugin-discovery.ts +157 -0
- package/src/utils/strip-public-schema.ts +40 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type { Queue, QueueManager } from "@checkstack/queue-api";
|
|
2
|
+
import type {
|
|
3
|
+
Hook,
|
|
4
|
+
HookSubscribeOptions,
|
|
5
|
+
HookUnsubscribe,
|
|
6
|
+
Logger,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import type { EventBus as IEventBus } from "@checkstack/backend-api";
|
|
9
|
+
|
|
10
|
+
export type HookListener<T> = (payload: T) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
interface ListenerRegistration {
|
|
13
|
+
id: string;
|
|
14
|
+
pluginId: string;
|
|
15
|
+
hookId: string;
|
|
16
|
+
consumerGroup: string;
|
|
17
|
+
listener: HookListener<unknown>;
|
|
18
|
+
mode: "broadcast" | "work-queue";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface LocalListenerRegistration {
|
|
22
|
+
id: string;
|
|
23
|
+
pluginId: string;
|
|
24
|
+
hookId: string;
|
|
25
|
+
listener: HookListener<unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* EventBus service for distributed event/hook system
|
|
30
|
+
*
|
|
31
|
+
* Leverages the Queue system to provide pub/sub patterns that work
|
|
32
|
+
* across multiple backend instances.
|
|
33
|
+
*/
|
|
34
|
+
export class EventBus implements IEventBus {
|
|
35
|
+
private queueChannels = new Map<string, Queue<unknown>>();
|
|
36
|
+
private listeners = new Map<string, ListenerRegistration[]>();
|
|
37
|
+
private localListeners = new Map<string, LocalListenerRegistration[]>();
|
|
38
|
+
private workerGroups = new Map<string, Set<string>>(); // pluginId -> Set<workerGroup>
|
|
39
|
+
private instanceId = crypto.randomUUID();
|
|
40
|
+
|
|
41
|
+
constructor(private queueManager: QueueManager, private logger: Logger) {}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Subscribe to a hook
|
|
45
|
+
*/
|
|
46
|
+
async subscribe<T>(
|
|
47
|
+
pluginId: string,
|
|
48
|
+
hook: Hook<T>,
|
|
49
|
+
listener: HookListener<T>,
|
|
50
|
+
options: HookSubscribeOptions = {}
|
|
51
|
+
): Promise<HookUnsubscribe> {
|
|
52
|
+
// Type narrowing for discriminated union
|
|
53
|
+
const mode = options.mode ?? "broadcast";
|
|
54
|
+
const workerGroup =
|
|
55
|
+
"workerGroup" in options ? options.workerGroup : undefined;
|
|
56
|
+
const maxRetries = "maxRetries" in options ? options.maxRetries ?? 3 : 3;
|
|
57
|
+
|
|
58
|
+
// Handle instance-local mode separately (no queue involvement)
|
|
59
|
+
if (mode === "instance-local") {
|
|
60
|
+
return this.subscribeLocal(pluginId, hook, listener);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Validation: workerGroup required for work-queue mode
|
|
64
|
+
if (mode === "work-queue" && !workerGroup) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`workerGroup is required when mode is 'work-queue' for hook ${hook.id} in plugin ${pluginId}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Duplicate detection
|
|
71
|
+
if (mode === "work-queue" && workerGroup) {
|
|
72
|
+
const pluginWorkerGroups = this.workerGroups.get(pluginId) || new Set();
|
|
73
|
+
|
|
74
|
+
if (pluginWorkerGroups.has(workerGroup)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Duplicate workerGroup '${workerGroup}' detected in plugin ${pluginId} for hook ${hook.id}. ` +
|
|
77
|
+
`Each workerGroup must be unique within a plugin.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pluginWorkerGroups.add(workerGroup);
|
|
82
|
+
this.workerGroups.set(pluginId, pluginWorkerGroups);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Determine consumer group with plugin namespacing
|
|
86
|
+
const consumerGroup =
|
|
87
|
+
mode === "broadcast"
|
|
88
|
+
? `${pluginId}.${hook.id}.broadcast.${this.instanceId}` // Unique per instance
|
|
89
|
+
: `${pluginId}.${workerGroup}`; // Shared across instances (namespaced)
|
|
90
|
+
|
|
91
|
+
const listenerId = crypto.randomUUID();
|
|
92
|
+
|
|
93
|
+
const registration: ListenerRegistration = {
|
|
94
|
+
id: listenerId,
|
|
95
|
+
pluginId,
|
|
96
|
+
hookId: hook.id,
|
|
97
|
+
consumerGroup,
|
|
98
|
+
listener: listener as HookListener<unknown>,
|
|
99
|
+
mode,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const listeners = this.listeners.get(hook.id) || [];
|
|
103
|
+
listeners.push(registration);
|
|
104
|
+
this.listeners.set(hook.id, listeners);
|
|
105
|
+
|
|
106
|
+
// Create queue channel if needed
|
|
107
|
+
if (!this.queueChannels.has(hook.id)) {
|
|
108
|
+
const channel = this.queueManager.getQueue<T>(hook.id);
|
|
109
|
+
this.queueChannels.set(hook.id, channel);
|
|
110
|
+
|
|
111
|
+
this.logger.debug(`Created event channel for hook: ${hook.id}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const channel = this.queueChannels.get(hook.id)!;
|
|
115
|
+
|
|
116
|
+
// Register consumer with appropriate group
|
|
117
|
+
await channel.consume(
|
|
118
|
+
async (job) => {
|
|
119
|
+
// Find listener by ID and invoke
|
|
120
|
+
const currentListeners = this.listeners.get(hook.id) || [];
|
|
121
|
+
const targetListener = currentListeners.find(
|
|
122
|
+
(l) => l.id === listenerId
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (targetListener) {
|
|
126
|
+
await this.invokeListener(targetListener, job.data);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
consumerGroup,
|
|
131
|
+
maxRetries: mode === "work-queue" ? maxRetries : 0,
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
this.logger.debug(
|
|
136
|
+
`Subscribed to hook ${hook.id} (plugin: ${pluginId}, mode: ${mode}, group: ${consumerGroup})`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Return unsubscribe function
|
|
140
|
+
return async () => {
|
|
141
|
+
await this.unsubscribe(pluginId, hook.id, listenerId, workerGroup);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Unsubscribe from a hook
|
|
147
|
+
*/
|
|
148
|
+
private async unsubscribe(
|
|
149
|
+
pluginId: string,
|
|
150
|
+
hookId: string,
|
|
151
|
+
listenerId: string,
|
|
152
|
+
workerGroup?: string
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
// Remove listener registration
|
|
155
|
+
const listeners = this.listeners.get(hookId) || [];
|
|
156
|
+
const updatedListeners = listeners.filter((l) => l.id !== listenerId);
|
|
157
|
+
|
|
158
|
+
if (updatedListeners.length === 0) {
|
|
159
|
+
this.listeners.delete(hookId);
|
|
160
|
+
|
|
161
|
+
// Stop queue if no more listeners
|
|
162
|
+
const channel = this.queueChannels.get(hookId);
|
|
163
|
+
if (channel) {
|
|
164
|
+
await channel.stop();
|
|
165
|
+
this.queueChannels.delete(hookId);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
this.listeners.set(hookId, updatedListeners);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Remove from workerGroup tracking
|
|
172
|
+
if (workerGroup) {
|
|
173
|
+
const pluginWorkerGroups = this.workerGroups.get(pluginId);
|
|
174
|
+
if (pluginWorkerGroups) {
|
|
175
|
+
pluginWorkerGroups.delete(workerGroup);
|
|
176
|
+
if (pluginWorkerGroups.size === 0) {
|
|
177
|
+
this.workerGroups.delete(pluginId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.logger.debug(
|
|
183
|
+
`Unsubscribed listener ${listenerId} from hook ${hookId}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Emit a hook
|
|
189
|
+
*/
|
|
190
|
+
async emit<T>(hook: Hook<T>, payload: T): Promise<void> {
|
|
191
|
+
let channel = this.queueChannels.get(hook.id);
|
|
192
|
+
|
|
193
|
+
// Create channel lazily if not exists
|
|
194
|
+
if (!channel) {
|
|
195
|
+
channel = this.queueManager.getQueue<T>(hook.id);
|
|
196
|
+
this.queueChannels.set(hook.id, channel);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await channel.enqueue(payload);
|
|
200
|
+
this.logger.debug(`Emitted hook: ${hook.id}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Emit a hook locally only (not distributed).
|
|
205
|
+
* Use this for instance-local hooks like pluginDeregistering.
|
|
206
|
+
* Uses Promise.allSettled to ensure one listener error doesn't block others.
|
|
207
|
+
*/
|
|
208
|
+
async emitLocal<T>(hook: Hook<T>, payload: T): Promise<void> {
|
|
209
|
+
const registrations = this.localListeners.get(hook.id) || [];
|
|
210
|
+
|
|
211
|
+
if (registrations.length === 0) {
|
|
212
|
+
this.logger.debug(`No local listeners for hook: ${hook.id}`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const results = await Promise.allSettled(
|
|
217
|
+
registrations.map(async (reg) => {
|
|
218
|
+
try {
|
|
219
|
+
await reg.listener(payload);
|
|
220
|
+
this.logger.debug(
|
|
221
|
+
`Local listener ${reg.id} (${reg.pluginId}) processed successfully`
|
|
222
|
+
);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
this.logger.error(
|
|
225
|
+
`Local listener ${reg.id} (${reg.pluginId}) failed:`,
|
|
226
|
+
error
|
|
227
|
+
);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const failures = results.filter((r) => r.status === "rejected");
|
|
234
|
+
if (failures.length > 0) {
|
|
235
|
+
this.logger.warn(
|
|
236
|
+
`${failures.length}/${registrations.length} local listeners failed for hook: ${hook.id}`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.logger.debug(
|
|
241
|
+
`Emitted local hook: ${hook.id} (${registrations.length} listeners)`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Subscribe to a hook in instance-local mode (not distributed)
|
|
247
|
+
*/
|
|
248
|
+
private async subscribeLocal<T>(
|
|
249
|
+
pluginId: string,
|
|
250
|
+
hook: Hook<T>,
|
|
251
|
+
listener: HookListener<T>
|
|
252
|
+
): Promise<HookUnsubscribe> {
|
|
253
|
+
const listenerId = crypto.randomUUID();
|
|
254
|
+
|
|
255
|
+
const registration: LocalListenerRegistration = {
|
|
256
|
+
id: listenerId,
|
|
257
|
+
pluginId,
|
|
258
|
+
hookId: hook.id,
|
|
259
|
+
listener: listener as HookListener<unknown>,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const registrations = this.localListeners.get(hook.id) || [];
|
|
263
|
+
registrations.push(registration);
|
|
264
|
+
this.localListeners.set(hook.id, registrations);
|
|
265
|
+
|
|
266
|
+
this.logger.debug(
|
|
267
|
+
`Subscribed to local hook ${hook.id} (plugin: ${pluginId})`
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// Return unsubscribe function
|
|
271
|
+
return async () => {
|
|
272
|
+
const current = this.localListeners.get(hook.id) || [];
|
|
273
|
+
const updated = current.filter((l) => l.id !== listenerId);
|
|
274
|
+
|
|
275
|
+
if (updated.length === 0) {
|
|
276
|
+
this.localListeners.delete(hook.id);
|
|
277
|
+
} else {
|
|
278
|
+
this.localListeners.set(hook.id, updated);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.logger.debug(
|
|
282
|
+
`Unsubscribed local listener ${listenerId} from hook ${hook.id}`
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Invoke a listener with error handling
|
|
289
|
+
*/
|
|
290
|
+
private async invokeListener(
|
|
291
|
+
registration: ListenerRegistration,
|
|
292
|
+
payload: unknown
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
try {
|
|
295
|
+
await registration.listener(payload);
|
|
296
|
+
this.logger.debug(
|
|
297
|
+
`Listener ${registration.id} (${registration.consumerGroup}) processed successfully`
|
|
298
|
+
);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
this.logger.error(
|
|
301
|
+
`Listener ${registration.id} (${registration.consumerGroup}) failed:`,
|
|
302
|
+
error
|
|
303
|
+
);
|
|
304
|
+
throw error; // Let queue handle retry
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Shutdown the event bus
|
|
310
|
+
*/
|
|
311
|
+
async shutdown(): Promise<void> {
|
|
312
|
+
await Promise.all(
|
|
313
|
+
[...this.queueChannels.values()].map((channel) => channel.stop())
|
|
314
|
+
);
|
|
315
|
+
this.logger.info("EventBus shut down");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
|
2
|
+
import { CoreHealthCheckRegistry } from "./health-check-registry";
|
|
3
|
+
import { HealthCheckStrategy, Versioned } from "@checkstack/backend-api";
|
|
4
|
+
import { createMockLogger } from "@checkstack/test-utils-backend";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
// Mock logger
|
|
8
|
+
const mockLogger = createMockLogger();
|
|
9
|
+
mock.module("../logger", () => ({
|
|
10
|
+
rootLogger: mockLogger,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("CoreHealthCheckRegistry", () => {
|
|
14
|
+
let registry: CoreHealthCheckRegistry;
|
|
15
|
+
|
|
16
|
+
const mockStrategy1: HealthCheckStrategy = {
|
|
17
|
+
id: "test-strategy-1",
|
|
18
|
+
displayName: "Test Strategy 1",
|
|
19
|
+
description: "A test strategy",
|
|
20
|
+
config: new Versioned({
|
|
21
|
+
version: 1,
|
|
22
|
+
schema: z.object({}),
|
|
23
|
+
}),
|
|
24
|
+
aggregatedResult: new Versioned({
|
|
25
|
+
version: 1,
|
|
26
|
+
schema: z.record(z.string(), z.unknown()),
|
|
27
|
+
}),
|
|
28
|
+
execute: mock(() => Promise.resolve({ status: "healthy" as const })),
|
|
29
|
+
aggregateResult: mock(() => ({})),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const mockStrategy2: HealthCheckStrategy = {
|
|
33
|
+
id: "test-strategy-2",
|
|
34
|
+
displayName: "Test Strategy 2",
|
|
35
|
+
description: "Another test strategy",
|
|
36
|
+
config: new Versioned({
|
|
37
|
+
version: 1,
|
|
38
|
+
schema: z.object({}),
|
|
39
|
+
}),
|
|
40
|
+
aggregatedResult: new Versioned({
|
|
41
|
+
version: 1,
|
|
42
|
+
schema: z.record(z.string(), z.unknown()),
|
|
43
|
+
}),
|
|
44
|
+
execute: mock(() =>
|
|
45
|
+
Promise.resolve({ status: "unhealthy" as const, message: "Failed" })
|
|
46
|
+
),
|
|
47
|
+
aggregateResult: mock(() => ({})),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
registry = new CoreHealthCheckRegistry();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("register", () => {
|
|
55
|
+
it("should register a new health check strategy", () => {
|
|
56
|
+
registry.register(mockStrategy1);
|
|
57
|
+
expect(registry.getStrategy(mockStrategy1.id)).toBe(mockStrategy1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should overwrite an existing strategy with the same ID", () => {
|
|
61
|
+
const overwritingStrategy: HealthCheckStrategy<any> = {
|
|
62
|
+
...mockStrategy1,
|
|
63
|
+
displayName: "New Name",
|
|
64
|
+
};
|
|
65
|
+
registry.register(mockStrategy1);
|
|
66
|
+
registry.register(overwritingStrategy);
|
|
67
|
+
|
|
68
|
+
expect(registry.getStrategy(mockStrategy1.id)).toBe(overwritingStrategy);
|
|
69
|
+
expect(registry.getStrategy(mockStrategy1.id)?.displayName).toBe(
|
|
70
|
+
"New Name"
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("getStrategySection", () => {
|
|
76
|
+
it("should return the strategy if it exists", () => {
|
|
77
|
+
registry.register(mockStrategy1);
|
|
78
|
+
expect(registry.getStrategy(mockStrategy1.id)).toBe(mockStrategy1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should return undefined if the strategy does not exist", () => {
|
|
82
|
+
expect(registry.getStrategy("non-existent")).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("getStrategies", () => {
|
|
87
|
+
it("should return all registered strategies", () => {
|
|
88
|
+
registry.register(mockStrategy1);
|
|
89
|
+
registry.register(mockStrategy2);
|
|
90
|
+
|
|
91
|
+
const strategies = registry.getStrategies();
|
|
92
|
+
expect(strategies).toHaveLength(2);
|
|
93
|
+
expect(strategies).toContain(mockStrategy1);
|
|
94
|
+
expect(strategies).toContain(mockStrategy2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should return an empty array if no strategies are registered", () => {
|
|
98
|
+
expect(registry.getStrategies()).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HealthCheckRegistry,
|
|
3
|
+
HealthCheckStrategy,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { rootLogger } from "../logger";
|
|
6
|
+
|
|
7
|
+
export class CoreHealthCheckRegistry implements HealthCheckRegistry {
|
|
8
|
+
private strategies = new Map<string, HealthCheckStrategy>();
|
|
9
|
+
|
|
10
|
+
register(strategy: HealthCheckStrategy) {
|
|
11
|
+
if (this.strategies.has(strategy.id)) {
|
|
12
|
+
rootLogger.warn(
|
|
13
|
+
`HealthCheckStrategy '${strategy.id}' is already registered. Overwriting.`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
this.strategies.set(strategy.id, strategy);
|
|
17
|
+
rootLogger.debug(`✅ Registered HealthCheckStrategy: ${strategy.id}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getStrategy(id: string) {
|
|
21
|
+
return this.strategies.get(id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getStrategies() {
|
|
25
|
+
return [...this.strategies.values()];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify, importJWK, JWK } from "jose";
|
|
2
|
+
import { keyStore } from "./keystore";
|
|
3
|
+
|
|
4
|
+
export const jwtService = {
|
|
5
|
+
/**
|
|
6
|
+
* Signs a JWT payload for service-to-service communication
|
|
7
|
+
*/
|
|
8
|
+
sign: async (payload: Record<string, unknown>, expiresIn = "1h") => {
|
|
9
|
+
const { kid, key } = await keyStore.getSigningKey();
|
|
10
|
+
|
|
11
|
+
return await new SignJWT(payload)
|
|
12
|
+
.setProtectedHeader({ alg: "RS256", kid })
|
|
13
|
+
.setIssuedAt()
|
|
14
|
+
.setExpirationTime(expiresIn)
|
|
15
|
+
.sign(key);
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Verifies a JWT token using the KeyStore
|
|
20
|
+
*/
|
|
21
|
+
verify: async (token: string) => {
|
|
22
|
+
try {
|
|
23
|
+
const { keys } = await keyStore.getPublicJWKS();
|
|
24
|
+
|
|
25
|
+
// Custom GetKey function for jose
|
|
26
|
+
const getKey = async (protectedHeader: { kid?: string }) => {
|
|
27
|
+
const kid = protectedHeader.kid;
|
|
28
|
+
if (!kid) throw new Error("Missing kid in header");
|
|
29
|
+
|
|
30
|
+
const jwk = keys.find((k: { kid?: string }) => k.kid === kid);
|
|
31
|
+
if (!jwk) {
|
|
32
|
+
throw new Error(`Key with kid ${kid} not found`);
|
|
33
|
+
}
|
|
34
|
+
return importJWK(jwk as JWK, "RS256");
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const { payload } = await jwtVerify(token, getKey, {
|
|
38
|
+
algorithms: ["RS256"],
|
|
39
|
+
});
|
|
40
|
+
return payload;
|
|
41
|
+
} catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { KeyStore } from "./keystore";
|
|
3
|
+
|
|
4
|
+
// 1. Mock the DB module
|
|
5
|
+
const mockDb = {
|
|
6
|
+
insert: mock(() => ({
|
|
7
|
+
values: mock(() => Promise.resolve()),
|
|
8
|
+
})),
|
|
9
|
+
select: mock(() => mockDb),
|
|
10
|
+
from: mock(() => mockDb),
|
|
11
|
+
where: mock(() => mockDb),
|
|
12
|
+
orderBy: mock(() => mockDb),
|
|
13
|
+
limit: mock(() => mockDb),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Return empty list by default for selects
|
|
17
|
+
// We will override implementation per test if needed
|
|
18
|
+
// But since the chain returns `mockDb` (itself), the final await needs to return data.
|
|
19
|
+
// Wait, `await db.select()...` means the object must be thenable or the last method returns a Promise.
|
|
20
|
+
// Drizzle: .execute() or await directly.
|
|
21
|
+
// In the code: `const validKeys = await db.select()...`
|
|
22
|
+
// So the object returned by `limit()` must be thenable.
|
|
23
|
+
|
|
24
|
+
const mockChain = () => {
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
const chain: any = {};
|
|
27
|
+
chain.insert = mock(() => chain);
|
|
28
|
+
chain.values = mock(() => Promise.resolve());
|
|
29
|
+
chain.update = mock(() => chain);
|
|
30
|
+
chain.set = mock(() => chain);
|
|
31
|
+
chain.delete = mock(() => chain);
|
|
32
|
+
|
|
33
|
+
chain.select = mock(() => chain);
|
|
34
|
+
chain.from = mock(() => chain);
|
|
35
|
+
chain.where = mock(() => chain);
|
|
36
|
+
chain.orderBy = mock(() => chain);
|
|
37
|
+
chain.limit = mock(() => chain); // limit is the last one called in getSigningKey
|
|
38
|
+
|
|
39
|
+
// Make it thenable to simulate 'await'
|
|
40
|
+
// eslint-disable-next-line unicorn/no-thenable, @typescript-eslint/no-explicit-any
|
|
41
|
+
chain.then = (resolve: any) => resolve([]); // Default empty array
|
|
42
|
+
|
|
43
|
+
return chain;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const dbMockInstance = mockChain();
|
|
47
|
+
|
|
48
|
+
mock.module("../db", () => {
|
|
49
|
+
return {
|
|
50
|
+
db: dbMockInstance,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("KeyStore", () => {
|
|
55
|
+
let store: KeyStore;
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
let mockKeyForGeneration: any;
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
store = new KeyStore();
|
|
61
|
+
// Reset mocks
|
|
62
|
+
dbMockInstance.select.mockClear();
|
|
63
|
+
dbMockInstance.insert.mockClear();
|
|
64
|
+
dbMockInstance.update.mockClear();
|
|
65
|
+
dbMockInstance.set.mockClear();
|
|
66
|
+
dbMockInstance.delete.mockClear();
|
|
67
|
+
dbMockInstance.where.mockClear();
|
|
68
|
+
|
|
69
|
+
// Reset default behavior
|
|
70
|
+
// eslint-disable-next-line unicorn/no-thenable, @typescript-eslint/no-explicit-any
|
|
71
|
+
dbMockInstance.then = (resolve: any) => resolve([]);
|
|
72
|
+
|
|
73
|
+
// Pre-generate a valid key for mocking responses
|
|
74
|
+
const { generateKeyPair, exportJWK } = await import("jose");
|
|
75
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
76
|
+
extractable: true,
|
|
77
|
+
});
|
|
78
|
+
const publicJwk = await exportJWK(publicKey);
|
|
79
|
+
const privateJwk = await exportJWK(privateKey);
|
|
80
|
+
|
|
81
|
+
mockKeyForGeneration = {
|
|
82
|
+
id: "generated-kid",
|
|
83
|
+
publicKey: JSON.stringify(publicJwk),
|
|
84
|
+
privateKey: JSON.stringify(privateJwk),
|
|
85
|
+
algorithm: "RS256",
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
expiresAt: undefined,
|
|
88
|
+
revokedAt: undefined,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should generate a new key if no active key exists", async () => {
|
|
93
|
+
// Mock DB returning empty array for existing keys first, then the new key
|
|
94
|
+
let callCount = 0;
|
|
95
|
+
// eslint-disable-next-line unicorn/no-thenable, @typescript-eslint/no-explicit-any
|
|
96
|
+
dbMockInstance.then = (resolve: any) => {
|
|
97
|
+
callCount++;
|
|
98
|
+
if (callCount === 1) {
|
|
99
|
+
return resolve([]); // First call: no active key
|
|
100
|
+
}
|
|
101
|
+
return resolve([mockKeyForGeneration]);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const result = await store.getSigningKey();
|
|
105
|
+
|
|
106
|
+
expect(result.kid).toBe("generated-kid"); // The mock key ID
|
|
107
|
+
expect(result.key).toBeTruthy();
|
|
108
|
+
expect(dbMockInstance.insert).toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should return the existing key if it is valid", async () => {
|
|
112
|
+
const { generateKeyPair, exportJWK } = await import("jose");
|
|
113
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
114
|
+
extractable: true,
|
|
115
|
+
});
|
|
116
|
+
const publicJwk = await exportJWK(publicKey);
|
|
117
|
+
const privateJwk = await exportJWK(privateKey);
|
|
118
|
+
const kid = "test-kid";
|
|
119
|
+
|
|
120
|
+
const mockKeyRow = {
|
|
121
|
+
id: kid,
|
|
122
|
+
publicKey: JSON.stringify(publicJwk),
|
|
123
|
+
privateKey: JSON.stringify(privateJwk),
|
|
124
|
+
algorithm: "RS256",
|
|
125
|
+
createdAt: new Date().toISOString(), // Fresh
|
|
126
|
+
expiresAt: undefined,
|
|
127
|
+
revokedAt: undefined,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Mock DB return
|
|
131
|
+
// eslint-disable-next-line unicorn/no-thenable, @typescript-eslint/no-explicit-any
|
|
132
|
+
dbMockInstance.then = (resolve: any) => resolve([mockKeyRow]);
|
|
133
|
+
|
|
134
|
+
const result = await store.getSigningKey();
|
|
135
|
+
|
|
136
|
+
expect(result.kid).toBe(kid);
|
|
137
|
+
// Should NOT have called insert (no rotation)
|
|
138
|
+
expect(dbMockInstance.insert).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should rotate key if the existing one is too old", async () => {
|
|
142
|
+
// Generate a real key
|
|
143
|
+
const { generateKeyPair, exportJWK } = await import("jose");
|
|
144
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
145
|
+
extractable: true,
|
|
146
|
+
});
|
|
147
|
+
const publicJwk = await exportJWK(publicKey);
|
|
148
|
+
const privateJwk = await exportJWK(privateKey);
|
|
149
|
+
const kid = "old-kid";
|
|
150
|
+
|
|
151
|
+
// Create an OLD date > 1 hour ago
|
|
152
|
+
const oldDate = new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString();
|
|
153
|
+
|
|
154
|
+
const mockKeyRow = {
|
|
155
|
+
id: kid,
|
|
156
|
+
publicKey: JSON.stringify(publicJwk),
|
|
157
|
+
privateKey: JSON.stringify(privateJwk),
|
|
158
|
+
algorithm: "RS256",
|
|
159
|
+
createdAt: oldDate,
|
|
160
|
+
expiresAt: undefined,
|
|
161
|
+
revokedAt: undefined,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
let callCount = 0;
|
|
165
|
+
// eslint-disable-next-line unicorn/no-thenable, @typescript-eslint/no-explicit-any
|
|
166
|
+
dbMockInstance.then = (resolve: any) => {
|
|
167
|
+
callCount++;
|
|
168
|
+
if (callCount === 1) {
|
|
169
|
+
return resolve([mockKeyRow]); // First call: check active
|
|
170
|
+
}
|
|
171
|
+
// Second call: fetch new key (in rotate logic)
|
|
172
|
+
// We need to return a valid new key so it doesn't crash
|
|
173
|
+
return resolve([
|
|
174
|
+
{
|
|
175
|
+
...mockKeyRow,
|
|
176
|
+
id: "new-kid",
|
|
177
|
+
createdAt: new Date().toISOString(),
|
|
178
|
+
},
|
|
179
|
+
]);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const result = await store.getSigningKey();
|
|
183
|
+
|
|
184
|
+
expect(result.kid).toBe("new-kid"); // Should return the NEW key
|
|
185
|
+
expect(dbMockInstance.insert).toHaveBeenCalled();
|
|
186
|
+
expect(dbMockInstance.update).toHaveBeenCalled(); // Should set expiresAt on old key
|
|
187
|
+
expect(dbMockInstance.set).toHaveBeenCalledWith(
|
|
188
|
+
expect.objectContaining({ expiresAt: expect.any(String) })
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should delete expired keys in cleanupKeys", async () => {
|
|
193
|
+
await store.cleanupKeys();
|
|
194
|
+
|
|
195
|
+
expect(dbMockInstance.delete).toHaveBeenCalled();
|
|
196
|
+
expect(dbMockInstance.where).toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
});
|