@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 +78 -0
- package/package.json +10 -10
- package/src/index.ts +88 -11
- package/src/plugin-manager/api-router.ts +9 -5
- package/src/plugin-manager/core-services.ts +12 -2
- package/src/plugin-manager.ts +13 -0
- package/src/services/keystore.test.ts +73 -82
- package/src/services/queue-manager.ts +2 -1
- package/src/services/ws-route-registry.test.ts +93 -0
- package/src/services/ws-route-registry.ts +46 -0
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.
|
|
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.
|
|
17
|
-
"@checkstack/auth-common": "0.6.
|
|
18
|
-
"@checkstack/backend-api": "0.
|
|
19
|
-
"@checkstack/common": "0.6.
|
|
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.
|
|
22
|
-
"@checkstack/signal-backend": "0.1.
|
|
23
|
-
"@checkstack/signal-common": "0.1.
|
|
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.
|
|
41
|
+
"@checkstack/tsconfig": "0.0.5",
|
|
42
42
|
"@checkstack/scripts": "0.1.2",
|
|
43
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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<
|
|
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
|
-
|
|
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
|
|
502
|
+
data: {} as ServerWsData,
|
|
450
503
|
|
|
451
|
-
open(ws: import("bun").ServerWebSocket<
|
|
452
|
-
|
|
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<
|
|
457
|
-
message: string | Buffer
|
|
518
|
+
ws: import("bun").ServerWebSocket<ServerWsData>,
|
|
519
|
+
message: string | Buffer,
|
|
458
520
|
) {
|
|
459
|
-
|
|
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<
|
|
532
|
+
ws: import("bun").ServerWebSocket<ServerWsData>,
|
|
464
533
|
code: number,
|
|
465
|
-
reason: string
|
|
534
|
+
reason: string,
|
|
466
535
|
) {
|
|
467
|
-
|
|
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
|
-
(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
}
|
package/src/plugin-manager.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
46
|
+
const dbMock = createDrizzleMockChain();
|
|
47
47
|
|
|
48
|
-
mock.module("../db", () => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
};
|
|
52
|
-
});
|
|
48
|
+
mock.module("../db", () => ({
|
|
49
|
+
db: dbMock,
|
|
50
|
+
}));
|
|
53
51
|
|
|
54
52
|
describe("KeyStore", () => {
|
|
55
53
|
let store: KeyStore;
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// Reset default behavior
|
|
70
|
-
// eslint-disable-next-line unicorn/no-thenable
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// Pre-generate a valid
|
|
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
|
|
96
|
-
|
|
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");
|
|
103
|
+
expect(result.kid).toBe("generated-kid");
|
|
107
104
|
expect(result.key).toBeTruthy();
|
|
108
|
-
expect(
|
|
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(),
|
|
122
|
+
createdAt: new Date().toISOString(),
|
|
126
123
|
expiresAt: undefined,
|
|
127
124
|
revokedAt: undefined,
|
|
128
125
|
};
|
|
129
126
|
|
|
130
|
-
//
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
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]);
|
|
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");
|
|
185
|
-
expect(
|
|
186
|
-
expect(
|
|
187
|
-
expect(
|
|
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(
|
|
196
|
-
expect(
|
|
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 =
|
|
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
|
+
}
|