@checkstack/backend 0.5.2 → 0.6.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,83 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 26d8bae: Distributed satellite health checks and Assignment IDE page
8
+
9
+ **Satellite System**
10
+
11
+ - New `satellite-backend`, `satellite-common`, `satellite-frontend`, and `satellite` agent packages for distributed health check execution
12
+ - WebSocket-based satellite connectivity with authentication, heartbeats, and live configuration push
13
+ - Satellite management UI with create dialog, status badges, and list page
14
+
15
+ **Live Configuration Updates**
16
+
17
+ - Added `assignmentChanged` hook to `healthcheck-backend` for cross-plugin communication
18
+ - `satellite-backend` subscribes to assignment changes and pushes config updates to connected satellites in real-time
19
+
20
+ **Assignment IDE Page**
21
+
22
+ - Replaced the 1028-line modal-based `SystemHealthCheckAssignment` component with a full-page IDE layout
23
+ - New modular components: `AssignmentTree`, `GeneralPanel`, `ThresholdsPanel`, `RetentionPanel`, `ExecutionPanel`
24
+ - Added unassign capability and sorted assignment lists for stable ordering
25
+
26
+ **Shared IDE Primitives**
27
+
28
+ - Extracted `IDETreeNode`, `IDETreeSection`, `IDEStatusBar`, `IDELayout` to `@checkstack/ui` for cross-plugin reuse
29
+ - Migrated existing health check IDE editor to use shared primitives
30
+
31
+ **Infrastructure**
32
+
33
+ - Added `Dockerfile.satellite` for containerized satellite deployment
34
+ - WebSocket route registry in `@checkstack/backend` and `@checkstack/backend-api`
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies [26d8bae]
39
+ - @checkstack/backend-api@0.12.0
40
+ - @checkstack/queue-api@0.2.13
41
+ - @checkstack/signal-backend@0.1.19
42
+
43
+ ## 0.5.3
44
+
45
+ ### Patch Changes
46
+
47
+ - d1a2796: Enforce stricter code quality standards and eliminate AI slop anti-patterns.
48
+
49
+ **New utility**
50
+
51
+ - `extractErrorMessage(error, fallback?)` in `@checkstack/common` for consistent error extraction
52
+
53
+ **ESLint rules**
54
+
55
+ - `react-hooks/rules-of-hooks` and `exhaustive-deps` for hook correctness
56
+ - `no-console` in frontend packages — forces `toast` over silent `console.error`
57
+ - `no-restricted-syntax` banning `instanceof Error` — forces `extractErrorMessage`
58
+ - Custom `no-eslint-disable-any` rule preventing `@typescript-eslint/no-explicit-any` circumvention
59
+
60
+ **Refactoring**
61
+
62
+ - Replace 141 `instanceof Error` boilerplate patterns across the codebase
63
+ - Replace swallowed `console.error` with user-visible `toast.error()` feedback
64
+ - Remove 15 redundant `as` type casts in IntegrationsPage and ProviderConnectionsPage
65
+ - Consolidate 3 identical callback handlers into `handleDialogClose`
66
+ - Fix conditional React hook call in `FormField.tsx`
67
+ - Fix unstable useMemo deps in `Dashboard.tsx`
68
+ - Replace `useEffect`→`setState` with derived `useMemo` in `RegisterPage.tsx`
69
+ - Rewrite `keystore.test.ts` with typed `DrizzleMockChain` (eliminating 7 `any` suppressions)
70
+ - Delete obvious comments in `encryption.ts` and Teams `provider.ts`
71
+
72
+ - Updated dependencies [d1a2796]
73
+ - @checkstack/common@0.6.5
74
+ - @checkstack/backend-api@0.11.1
75
+ - @checkstack/api-docs-common@0.1.9
76
+ - @checkstack/auth-common@0.6.1
77
+ - @checkstack/signal-backend@0.1.18
78
+ - @checkstack/signal-common@0.1.9
79
+ - @checkstack/queue-api@0.2.12
80
+
3
81
  ## 0.5.2
4
82
 
5
83
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "checkstack": {
5
5
  "type": "backend"
6
6
  },
@@ -13,14 +13,14 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/api-docs-common": "0.1.8",
17
- "@checkstack/auth-common": "0.6.0",
18
- "@checkstack/backend-api": "0.10.0",
19
- "@checkstack/common": "0.6.4",
16
+ "@checkstack/api-docs-common": "0.1.9",
17
+ "@checkstack/auth-common": "0.6.1",
18
+ "@checkstack/backend-api": "0.11.1",
19
+ "@checkstack/common": "0.6.5",
20
20
  "@checkstack/drizzle-helper": "0.0.4",
21
- "@checkstack/queue-api": "0.2.9",
22
- "@checkstack/signal-backend": "0.1.15",
23
- "@checkstack/signal-common": "0.1.8",
21
+ "@checkstack/queue-api": "0.2.12",
22
+ "@checkstack/signal-backend": "0.1.18",
23
+ "@checkstack/signal-common": "0.1.9",
24
24
  "@hono/zod-validator": "^0.7.6",
25
25
  "@orpc/client": "^1.13.14",
26
26
  "@orpc/contract": "^1.13.14",
@@ -38,8 +38,8 @@
38
38
  "devDependencies": {
39
39
  "@types/pg": "^8.11.0",
40
40
  "@types/bun": "latest",
41
- "@checkstack/tsconfig": "0.0.4",
41
+ "@checkstack/tsconfig": "0.0.5",
42
42
  "@checkstack/scripts": "0.1.2",
43
- "@checkstack/test-utils-backend": "0.1.15"
43
+ "@checkstack/test-utils-backend": "0.1.18"
44
44
  }
45
45
  }
package/src/index.ts CHANGED
@@ -18,6 +18,26 @@ import {
18
18
  SignalServiceImpl,
19
19
  type WebSocketData,
20
20
  } from "@checkstack/signal-backend";
21
+ import type { WsConnectionHandlers } from "@checkstack/backend-api";
22
+
23
+ // =============================================================================
24
+ // SERVER-LEVEL WEBSOCKET DATA
25
+ // =============================================================================
26
+
27
+ /**
28
+ * Discriminated union for all WebSocket connection types.
29
+ * Signal connections are handled by signal-backend.
30
+ * Plugin WS connections are routed via the generic WebSocket route registry.
31
+ */
32
+ type ServerWsData =
33
+ | ({ connectionType: "signal" } & WebSocketData)
34
+ | {
35
+ connectionType: "plugin";
36
+ createdAt: number;
37
+ pluginHandlers: WsConnectionHandlers;
38
+ /** Mutable proxy — patched in open() to the real Bun WS */
39
+ wsProxy: { send: (data: string) => void; close: () => void };
40
+ };
21
41
  import {
22
42
  PLUGIN_INSTALLED,
23
43
  PLUGIN_DEREGISTERED,
@@ -398,7 +418,7 @@ void init();
398
418
  // Custom fetch handler that handles WebSocket upgrades
399
419
  const fetch = async (
400
420
  req: Request,
401
- server: Server<WebSocketData>
421
+ server: Server<ServerWsData>
402
422
  ): Promise<Response | undefined> => {
403
423
  // Set the server reference for WebSocket pub/sub after startup
404
424
  if (wsHandler && !server.upgrade) {
@@ -407,7 +427,8 @@ const fetch = async (
407
427
  }
408
428
 
409
429
  // Give the WebSocket handler the server reference if needed
410
- wsHandler?.setServer(server);
430
+ // Cast is safe: signal handler only reads its own fields via connectionType guard
431
+ wsHandler?.setServer(server as unknown as Server<WebSocketData>);
411
432
 
412
433
  const url = new URL(req.url);
413
434
 
@@ -427,6 +448,7 @@ const fetch = async (
427
448
 
428
449
  const success = server.upgrade(req, {
429
450
  data: {
451
+ connectionType: "signal" as const,
430
452
  userId, // undefined for anonymous, set for authenticated users
431
453
  createdAt: Date.now(),
432
454
  },
@@ -437,6 +459,37 @@ const fetch = async (
437
459
  : new Response("WebSocket upgrade failed", { status: 500 });
438
460
  }
439
461
 
462
+ // Handle WebSocket upgrade for plugin-registered routes (/api/ws/*)
463
+ const WS_PREFIX = "/api/ws/";
464
+ if (url.pathname.startsWith(WS_PREFIX)) {
465
+ const pluginPath = url.pathname.slice(WS_PREFIX.length);
466
+ const handler = pluginManager.getWsStore().getHandler(pluginPath);
467
+ if (!handler) {
468
+ return new Response("Not Found", { status: 404 });
469
+ }
470
+
471
+ // Mutable WsConnection proxy — starts as no-op, patched in open() to the real Bun WS.
472
+ // The handler captures this object reference, so patching its methods works.
473
+ const wsProxy = {
474
+ send: (_: string) => {},
475
+ close: () => {},
476
+ };
477
+ const pluginHandlers = handler.onConnection(wsProxy);
478
+
479
+ const success = server.upgrade(req, {
480
+ data: {
481
+ connectionType: "plugin" as const,
482
+ createdAt: Date.now(),
483
+ pluginHandlers,
484
+ wsProxy,
485
+ },
486
+ });
487
+
488
+ return success
489
+ ? undefined
490
+ : new Response("WebSocket upgrade failed", { status: 500 });
491
+ }
492
+
440
493
  // Handle regular HTTP requests with Hono
441
494
  return app.fetch(req, server);
442
495
  };
@@ -446,25 +499,49 @@ export default {
446
499
  fetch,
447
500
  websocket: {
448
501
  // Type template for ws.data
449
- data: {} as WebSocketData,
502
+ data: {} as ServerWsData,
450
503
 
451
- open(ws: import("bun").ServerWebSocket<WebSocketData>) {
452
- wsHandler?.websocket.open(ws);
504
+ open(ws: import("bun").ServerWebSocket<ServerWsData>) {
505
+ if (ws.data.connectionType === "plugin") {
506
+ // Patch the mutable proxy to wire through to the real Bun WebSocket
507
+ ws.data.wsProxy.send = (data: string) => ws.send(data);
508
+ ws.data.wsProxy.close = () => ws.close();
509
+ return;
510
+ }
511
+ // Signal connection
512
+ wsHandler?.websocket.open(
513
+ ws as unknown as import("bun").ServerWebSocket<WebSocketData>,
514
+ );
453
515
  },
454
516
 
455
517
  message(
456
- ws: import("bun").ServerWebSocket<WebSocketData>,
457
- message: string | Buffer
518
+ ws: import("bun").ServerWebSocket<ServerWsData>,
519
+ message: string | Buffer,
458
520
  ) {
459
- wsHandler?.websocket.message(ws, message);
521
+ if (ws.data.connectionType === "plugin") {
522
+ void ws.data.pluginHandlers.onMessage(message.toString());
523
+ return;
524
+ }
525
+ wsHandler?.websocket.message(
526
+ ws as unknown as import("bun").ServerWebSocket<WebSocketData>,
527
+ message,
528
+ );
460
529
  },
461
530
 
462
531
  close(
463
- ws: import("bun").ServerWebSocket<WebSocketData>,
532
+ ws: import("bun").ServerWebSocket<ServerWsData>,
464
533
  code: number,
465
- reason: string
534
+ reason: string,
466
535
  ) {
467
- wsHandler?.websocket.close(ws, code, reason);
536
+ if (ws.data.connectionType === "plugin") {
537
+ ws.data.pluginHandlers.onClose();
538
+ return;
539
+ }
540
+ wsHandler?.websocket.close(
541
+ ws as unknown as import("bun").ServerWebSocket<WebSocketData>,
542
+ code,
543
+ reason,
544
+ );
468
545
  },
469
546
  },
470
547
  };
@@ -68,11 +68,15 @@ export function createApiRouteHandler({
68
68
  return await next(rest);
69
69
  } catch (error) {
70
70
  if (logger) {
71
- (logger as Logger).error(
72
- `RPC procedure error: ${String(error)}`,
73
- );
74
- if (error instanceof Error && error.stack) {
75
- (logger as Logger).error(`Stack trace: ${error.stack}`);
71
+ logger.error(`RPC procedure error: ${String(error)}`);
72
+ const stack =
73
+ error !== null &&
74
+ typeof error === "object" &&
75
+ "stack" in error
76
+ ? (error as { stack: string }).stack
77
+ : undefined;
78
+ if (stack) {
79
+ logger.error(`Stack trace: ${stack}`);
76
80
  }
77
81
  }
78
82
  throw error;
@@ -26,6 +26,10 @@ import {
26
26
  import { EventBus } from "../services/event-bus.js";
27
27
  import { getPluginSchemaName } from "@checkstack/drizzle-helper";
28
28
  import { createScopedDb } from "../utils/scoped-db.js";
29
+ import {
30
+ WebSocketRouteStoreImpl,
31
+ createScopedWsRegistry,
32
+ } from "../services/ws-route-registry";
29
33
 
30
34
  /**
31
35
  * Check if a PostgreSQL schema exists.
@@ -55,7 +59,7 @@ export function registerCoreServices({
55
59
  pluginRpcRouters: Map<string, unknown>;
56
60
  pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
57
61
  pluginContractRegistry: Map<string, unknown>;
58
- }): { collectorRegistry: CoreCollectorRegistry } {
62
+ }): { collectorRegistry: CoreCollectorRegistry; wsStore: WebSocketRouteStoreImpl } {
59
63
  // 1. Database Factory (Scoped)
60
64
  registry.registerFactory(coreServices.database, async (metadata) => {
61
65
  const { pluginId, previousPluginIds } = metadata;
@@ -346,6 +350,12 @@ export function registerCoreServices({
346
350
  return eventBusInstance;
347
351
  });
348
352
 
353
+ // 10. WebSocket Route Registry (Scoped Factory - auto-prefixes with pluginId)
354
+ const globalWsStore = new WebSocketRouteStoreImpl();
355
+ registry.registerFactory(coreServices.wsRegistry, (metadata) =>
356
+ createScopedWsRegistry(globalWsStore, metadata.pluginId),
357
+ );
358
+
349
359
  // Return global registries for lifecycle cleanup
350
- return { collectorRegistry: globalCollectorRegistry };
360
+ return { collectorRegistry: globalCollectorRegistry, wsStore: globalWsStore };
351
361
  }
@@ -2,6 +2,7 @@ import type { Hono } from "hono";
2
2
  import { adminPool, db } from "./db";
3
3
  import { ServiceRegistry } from "./services/service-registry";
4
4
  import type { CoreCollectorRegistry } from "./services/collector-registry";
5
+ import type { WebSocketRouteStoreImpl } from "./services/ws-route-registry";
5
6
  import {
6
7
  BackendPlugin,
7
8
  ServiceRef,
@@ -50,6 +51,9 @@ export class PluginManager {
50
51
  // Global collector registry reference for cleanup
51
52
  private collectorRegistry: CoreCollectorRegistry;
52
53
 
54
+ // Global WebSocket route store for server-level routing
55
+ private wsStore: WebSocketRouteStoreImpl;
56
+
53
57
  constructor() {
54
58
  const registries = registerCoreServices({
55
59
  registry: this.registry,
@@ -59,6 +63,15 @@ export class PluginManager {
59
63
  pluginContractRegistry: this.pluginContractRegistry,
60
64
  });
61
65
  this.collectorRegistry = registries.collectorRegistry;
66
+ this.wsStore = registries.wsStore;
67
+ }
68
+
69
+ /**
70
+ * Get the global WebSocket route store for the backend server to use
71
+ * during WebSocket upgrade routing.
72
+ */
73
+ getWsStore(): WebSocketRouteStoreImpl {
74
+ return this.wsStore;
62
75
  }
63
76
 
64
77
  registerExtensionPoint<T>(ref: ExtensionPoint<T>, impl: T) {
@@ -1,76 +1,74 @@
1
1
  import { describe, it, expect, mock, beforeEach } from "bun:test";
2
2
  import { KeyStore } from "./keystore";
3
3
 
4
- // 1. Mock the DB module
5
- const mockDb = {
6
- insert: mock(() => ({
4
+ /**
5
+ * Drizzle fluent-chain mock factory.
6
+ *
7
+ * Drizzle's API returns `this` from every query-builder method (select, from,
8
+ * where, …). The final object is thenable — awaiting it resolves the query.
9
+ * This factory builds a single object that satisfies that pattern without
10
+ * resorting to `any`.
11
+ */
12
+ interface DrizzleMockChain {
13
+ insert: ReturnType<typeof mock>;
14
+ values: ReturnType<typeof mock>;
15
+ update: ReturnType<typeof mock>;
16
+ set: ReturnType<typeof mock>;
17
+ delete: ReturnType<typeof mock>;
18
+ select: ReturnType<typeof mock>;
19
+ from: ReturnType<typeof mock>;
20
+ where: ReturnType<typeof mock>;
21
+ orderBy: ReturnType<typeof mock>;
22
+ limit: ReturnType<typeof mock>;
23
+ // eslint-disable-next-line unicorn/no-thenable -- Required: Drizzle chains are awaitable via a custom .then()
24
+ then: (resolve: (rows: unknown[]) => void) => void;
25
+ }
26
+
27
+ function createDrizzleMockChain(): DrizzleMockChain {
28
+ const chain: DrizzleMockChain = {
29
+ insert: mock(() => chain),
7
30
  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
31
+ update: mock(() => chain),
32
+ set: mock(() => chain),
33
+ delete: mock(() => chain),
34
+ select: mock(() => chain),
35
+ from: mock(() => chain),
36
+ where: mock(() => chain),
37
+ orderBy: mock(() => chain),
38
+ limit: mock(() => chain),
39
+ // eslint-disable-next-line unicorn/no-thenable -- Required: Drizzle chains are awaitable via a custom .then()
40
+ then: (resolve) => resolve([]),
41
+ };
42
42
 
43
43
  return chain;
44
- };
44
+ }
45
45
 
46
- const dbMockInstance = mockChain();
46
+ const dbMock = createDrizzleMockChain();
47
47
 
48
- mock.module("../db", () => {
49
- return {
50
- db: dbMockInstance,
51
- };
52
- });
48
+ mock.module("../db", () => ({
49
+ db: dbMock,
50
+ }));
53
51
 
54
52
  describe("KeyStore", () => {
55
53
  let store: KeyStore;
56
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
- let mockKeyForGeneration: any;
54
+ let mockKeyForGeneration: Record<string, unknown>;
58
55
 
59
56
  beforeEach(async () => {
60
57
  store = new KeyStore();
58
+
61
59
  // 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
60
+ dbMock.select.mockClear();
61
+ dbMock.insert.mockClear();
62
+ dbMock.update.mockClear();
63
+ dbMock.set.mockClear();
64
+ dbMock.delete.mockClear();
65
+ dbMock.where.mockClear();
66
+
67
+ // Reset default behavior — empty result set
68
+ // eslint-disable-next-line unicorn/no-thenable -- Required: Drizzle chains are awaitable via a custom .then()
69
+ dbMock.then = (resolve) => resolve([]);
70
+
71
+ // Pre-generate a valid RSA keypair for mock responses
74
72
  const { generateKeyPair, exportJWK } = await import("jose");
75
73
  const { publicKey, privateKey } = await generateKeyPair("RS256", {
76
74
  extractable: true,
@@ -90,10 +88,9 @@ describe("KeyStore", () => {
90
88
  });
91
89
 
92
90
  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
91
  let callCount = 0;
95
- // eslint-disable-next-line unicorn/no-thenable, @typescript-eslint/no-explicit-any
96
- dbMockInstance.then = (resolve: any) => {
92
+ // eslint-disable-next-line unicorn/no-thenable -- Required: Drizzle chains are awaitable via a custom .then()
93
+ dbMock.then = (resolve) => {
97
94
  callCount++;
98
95
  if (callCount === 1) {
99
96
  return resolve([]); // First call: no active key
@@ -103,9 +100,9 @@ describe("KeyStore", () => {
103
100
 
104
101
  const result = await store.getSigningKey();
105
102
 
106
- expect(result.kid).toBe("generated-kid"); // The mock key ID
103
+ expect(result.kid).toBe("generated-kid");
107
104
  expect(result.key).toBeTruthy();
108
- expect(dbMockInstance.insert).toHaveBeenCalled();
105
+ expect(dbMock.insert).toHaveBeenCalled();
109
106
  });
110
107
 
111
108
  it("should return the existing key if it is valid", async () => {
@@ -122,24 +119,21 @@ describe("KeyStore", () => {
122
119
  publicKey: JSON.stringify(publicJwk),
123
120
  privateKey: JSON.stringify(privateJwk),
124
121
  algorithm: "RS256",
125
- createdAt: new Date().toISOString(), // Fresh
122
+ createdAt: new Date().toISOString(),
126
123
  expiresAt: undefined,
127
124
  revokedAt: undefined,
128
125
  };
129
126
 
130
- // Mock DB return
131
- // eslint-disable-next-line unicorn/no-thenable, @typescript-eslint/no-explicit-any
132
- dbMockInstance.then = (resolve: any) => resolve([mockKeyRow]);
127
+ // eslint-disable-next-line unicorn/no-thenable -- Required: Drizzle chains are awaitable via a custom .then()
128
+ dbMock.then = (resolve) => resolve([mockKeyRow]);
133
129
 
134
130
  const result = await store.getSigningKey();
135
131
 
136
132
  expect(result.kid).toBe(kid);
137
- // Should NOT have called insert (no rotation)
138
- expect(dbMockInstance.insert).not.toHaveBeenCalled();
133
+ expect(dbMock.insert).not.toHaveBeenCalled();
139
134
  });
140
135
 
141
136
  it("should rotate key if the existing one is too old", async () => {
142
- // Generate a real key
143
137
  const { generateKeyPair, exportJWK } = await import("jose");
144
138
  const { publicKey, privateKey } = await generateKeyPair("RS256", {
145
139
  extractable: true,
@@ -148,7 +142,6 @@ describe("KeyStore", () => {
148
142
  const privateJwk = await exportJWK(privateKey);
149
143
  const kid = "old-kid";
150
144
 
151
- // Create an OLD date > 1 hour ago
152
145
  const oldDate = new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString();
153
146
 
154
147
  const mockKeyRow = {
@@ -162,14 +155,12 @@ describe("KeyStore", () => {
162
155
  };
163
156
 
164
157
  let callCount = 0;
165
- // eslint-disable-next-line unicorn/no-thenable, @typescript-eslint/no-explicit-any
166
- dbMockInstance.then = (resolve: any) => {
158
+ // eslint-disable-next-line unicorn/no-thenable -- Required: Drizzle chains are awaitable via a custom .then()
159
+ dbMock.then = (resolve) => {
167
160
  callCount++;
168
161
  if (callCount === 1) {
169
- return resolve([mockKeyRow]); // First call: check active
162
+ return resolve([mockKeyRow]);
170
163
  }
171
- // Second call: fetch new key (in rotate logic)
172
- // We need to return a valid new key so it doesn't crash
173
164
  return resolve([
174
165
  {
175
166
  ...mockKeyRow,
@@ -181,10 +172,10 @@ describe("KeyStore", () => {
181
172
 
182
173
  const result = await store.getSigningKey();
183
174
 
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(
175
+ expect(result.kid).toBe("new-kid");
176
+ expect(dbMock.insert).toHaveBeenCalled();
177
+ expect(dbMock.update).toHaveBeenCalled();
178
+ expect(dbMock.set).toHaveBeenCalledWith(
188
179
  expect.objectContaining({ expiresAt: expect.any(String) })
189
180
  );
190
181
  });
@@ -192,7 +183,7 @@ describe("KeyStore", () => {
192
183
  it("should delete expired keys in cleanupKeys", async () => {
193
184
  await store.cleanupKeys();
194
185
 
195
- expect(dbMockInstance.delete).toHaveBeenCalled();
196
- expect(dbMockInstance.where).toHaveBeenCalled();
186
+ expect(dbMock.delete).toHaveBeenCalled();
187
+ expect(dbMock.where).toHaveBeenCalled();
197
188
  });
198
189
  });
@@ -9,6 +9,7 @@ import type { QueuePluginRegistryImpl } from "./queue-plugin-registry";
9
9
  import type { Logger, ConfigService } from "@checkstack/backend-api";
10
10
  import { z } from "zod";
11
11
  import { QueueProxy } from "./queue-proxy";
12
+ import { extractErrorMessage } from "@checkstack/common";
12
13
 
13
14
  // Schema for active plugin pointer with version for multi-instance coordination
14
15
  const activePluginPointerSchema = z.object({
@@ -151,7 +152,7 @@ export class QueueManagerImpl implements QueueManager {
151
152
  await testQueue.stop();
152
153
  this.logger.info("✅ Connection test successful");
153
154
  } catch (error) {
154
- const message = error instanceof Error ? error.message : String(error);
155
+ const message = extractErrorMessage(error);
155
156
  this.logger.error(`❌ Connection test failed: ${message}`);
156
157
  throw new Error(`Failed to connect to queue: ${message}`);
157
158
  }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ WebSocketRouteStoreImpl,
4
+ createScopedWsRegistry,
5
+ } from "./ws-route-registry";
6
+ import type { WebSocketRouteHandler } from "@checkstack/backend-api";
7
+
8
+ function createMockHandler(): WebSocketRouteHandler {
9
+ return {
10
+ onConnection: () => ({
11
+ onMessage: () => {},
12
+ onClose: () => {},
13
+ }),
14
+ };
15
+ }
16
+
17
+ describe("WebSocketRouteStoreImpl", () => {
18
+ it("should register and retrieve a handler by full path", () => {
19
+ const store = new WebSocketRouteStoreImpl();
20
+ const handler = createMockHandler();
21
+
22
+ store.registerHandler("satellite", handler);
23
+
24
+ expect(store.getHandler("satellite")).toBe(handler);
25
+ });
26
+
27
+ it("should return undefined for unregistered paths", () => {
28
+ const store = new WebSocketRouteStoreImpl();
29
+
30
+ expect(store.getHandler("nonexistent")).toBeUndefined();
31
+ });
32
+
33
+ it("should reject duplicate registrations", () => {
34
+ const store = new WebSocketRouteStoreImpl();
35
+ const handler = createMockHandler();
36
+
37
+ store.registerHandler("satellite", handler);
38
+
39
+ expect(() => store.registerHandler("satellite", handler)).toThrow(
40
+ "WebSocket route already registered: /api/ws/satellite",
41
+ );
42
+ });
43
+ });
44
+
45
+ describe("createScopedWsRegistry", () => {
46
+ it('should auto-prefix pluginId when path is "/"', () => {
47
+ const store = new WebSocketRouteStoreImpl();
48
+ const scoped = createScopedWsRegistry(store, "satellite");
49
+ const handler = createMockHandler();
50
+
51
+ scoped.register("/", handler);
52
+
53
+ expect(store.getHandler("satellite")).toBe(handler);
54
+ });
55
+
56
+ it("should auto-prefix pluginId with sub-path", () => {
57
+ const store = new WebSocketRouteStoreImpl();
58
+ const scoped = createScopedWsRegistry(store, "my-plugin");
59
+ const handler = createMockHandler();
60
+
61
+ scoped.register("/connect", handler);
62
+
63
+ expect(store.getHandler("my-plugin/connect")).toBe(handler);
64
+ });
65
+
66
+ it("should namespace different plugins independently", () => {
67
+ const store = new WebSocketRouteStoreImpl();
68
+ const scopedA = createScopedWsRegistry(store, "plugin-a");
69
+ const scopedB = createScopedWsRegistry(store, "plugin-b");
70
+ const handlerA = createMockHandler();
71
+ const handlerB = createMockHandler();
72
+
73
+ scopedA.register("/", handlerA);
74
+ scopedB.register("/", handlerB);
75
+
76
+ expect(store.getHandler("plugin-a")).toBe(handlerA);
77
+ expect(store.getHandler("plugin-b")).toBe(handlerB);
78
+ });
79
+
80
+ it("should prevent cross-plugin path collisions", () => {
81
+ const store = new WebSocketRouteStoreImpl();
82
+ const scopedA = createScopedWsRegistry(store, "plugin-a");
83
+ const scopedB = createScopedWsRegistry(store, "plugin-a");
84
+ const handler = createMockHandler();
85
+
86
+ scopedA.register("/", handler);
87
+
88
+ // Same pluginId + same path = collision
89
+ expect(() => scopedB.register("/", handler)).toThrow(
90
+ "WebSocket route already registered",
91
+ );
92
+ });
93
+ });
@@ -0,0 +1,46 @@
1
+ import type {
2
+ WebSocketRouteRegistry,
3
+ WebSocketRouteHandler,
4
+ WebSocketRouteStore,
5
+ } from "@checkstack/backend-api";
6
+
7
+ /**
8
+ * Global store for all registered WebSocket route handlers.
9
+ * Plugins don't interact with this directly — they use a scoped
10
+ * `WebSocketRouteRegistry` that auto-prefixes the pluginId.
11
+ */
12
+ export class WebSocketRouteStoreImpl implements WebSocketRouteStore {
13
+ private handlers = new Map<string, WebSocketRouteHandler>();
14
+
15
+ /** Register a handler at a fully-qualified path (e.g., "satellite" or "satellite/connect"). */
16
+ registerHandler(fullPath: string, handler: WebSocketRouteHandler): void {
17
+ if (this.handlers.has(fullPath)) {
18
+ throw new Error(
19
+ `WebSocket route already registered: /api/ws/${fullPath}`,
20
+ );
21
+ }
22
+ this.handlers.set(fullPath, handler);
23
+ }
24
+
25
+ getHandler(fullPath: string): WebSocketRouteHandler | undefined {
26
+ return this.handlers.get(fullPath);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Create a scoped WebSocket route registry for a specific plugin.
32
+ * Auto-prefixes all paths with the plugin's ID.
33
+ */
34
+ export function createScopedWsRegistry(
35
+ store: WebSocketRouteStoreImpl,
36
+ pluginId: string,
37
+ ): WebSocketRouteRegistry {
38
+ return {
39
+ register(path: string, handler: WebSocketRouteHandler): void {
40
+ // Normalize: "/" maps to just the pluginId, "/foo" maps to "pluginId/foo"
41
+ const suffix = path === "/" ? "" : path.replace(/^\//, "/");
42
+ const fullPath = `${pluginId}${suffix}`;
43
+ store.registerHandler(fullPath, handler);
44
+ },
45
+ };
46
+ }