@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/drizzle/0000_loose_yellow_claw.sql +28 -0
  3. package/drizzle/meta/0000_snapshot.json +187 -0
  4. package/drizzle/meta/_journal.json +13 -0
  5. package/drizzle.config.ts +10 -0
  6. package/package.json +42 -0
  7. package/src/db.ts +20 -0
  8. package/src/health-check-plugin-integration.test.ts +93 -0
  9. package/src/index.ts +419 -0
  10. package/src/integration/event-bus.integration.test.ts +313 -0
  11. package/src/logger.ts +65 -0
  12. package/src/openapi-router.ts +177 -0
  13. package/src/plugin-lifecycle.test.ts +276 -0
  14. package/src/plugin-manager/api-router.ts +163 -0
  15. package/src/plugin-manager/core-services.ts +312 -0
  16. package/src/plugin-manager/dependency-sorter.ts +103 -0
  17. package/src/plugin-manager/deregistration-guard.ts +41 -0
  18. package/src/plugin-manager/extension-points.ts +85 -0
  19. package/src/plugin-manager/index.ts +13 -0
  20. package/src/plugin-manager/plugin-admin-router.ts +89 -0
  21. package/src/plugin-manager/plugin-loader.ts +464 -0
  22. package/src/plugin-manager/types.ts +14 -0
  23. package/src/plugin-manager.test.ts +464 -0
  24. package/src/plugin-manager.ts +431 -0
  25. package/src/rpc-rest-compat.test.ts +80 -0
  26. package/src/schema.ts +46 -0
  27. package/src/services/config-service.test.ts +66 -0
  28. package/src/services/config-service.ts +322 -0
  29. package/src/services/event-bus.test.ts +469 -0
  30. package/src/services/event-bus.ts +317 -0
  31. package/src/services/health-check-registry.test.ts +101 -0
  32. package/src/services/health-check-registry.ts +27 -0
  33. package/src/services/jwt.ts +45 -0
  34. package/src/services/keystore.test.ts +198 -0
  35. package/src/services/keystore.ts +136 -0
  36. package/src/services/plugin-installer.test.ts +90 -0
  37. package/src/services/plugin-installer.ts +70 -0
  38. package/src/services/queue-manager.ts +382 -0
  39. package/src/services/queue-plugin-registry.ts +17 -0
  40. package/src/services/queue-proxy.ts +182 -0
  41. package/src/services/service-registry.ts +35 -0
  42. package/src/test-preload.ts +114 -0
  43. package/src/utils/plugin-discovery.test.ts +383 -0
  44. package/src/utils/plugin-discovery.ts +157 -0
  45. package/src/utils/strip-public-schema.ts +40 -0
  46. 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
+ });