@checkstack/backend-api 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 ADDED
@@ -0,0 +1,228 @@
1
+ # @checkstack/backend-api
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/common@0.0.2
10
+ - @checkstack/queue-api@0.0.2
11
+ - @checkstack/signal-common@0.0.2
12
+
13
+ ## 1.1.0
14
+
15
+ ### Minor Changes
16
+
17
+ - a65e002: Add compile-time type safety for Lucide icon names
18
+
19
+ - Add `LucideIconName` type and `lucideIconSchema` Zod schema to `@checkstack/common`
20
+ - Update backend interfaces (`AuthStrategy`, `NotificationStrategy`, `IntegrationProvider`, `CommandDefinition`) to use `LucideIconName`
21
+ - Update RPC contracts to use `lucideIconSchema` for proper type inference across RPC boundaries
22
+ - Simplify `SocialProviderButton` to use `DynamicIcon` directly (removes 30+ lines of pascalCase conversion)
23
+ - Replace static `iconMap` in `SearchDialog` with `DynamicIcon` for dynamic icon rendering
24
+ - Add fallback handling in `DynamicIcon` when icon name isn't found
25
+ - Fix legacy kebab-case icon names to PascalCase: `mail`→`Mail`, `send`→`Send`, `github`→`Github`, `key-round`→`KeyRound`, `network`→`Network`, `AlertCircle`→`CircleAlert`
26
+
27
+ ### Patch Changes
28
+
29
+ - b4eb432: Fixed TypeScript generic contravariance issue in notification strategy registration.
30
+
31
+ The `register` and `addStrategy` methods now use generic type parameters instead of `unknown`, allowing notification strategy plugins with typed OAuth configurations to be registered without compiler errors. This fixes contravariance issues where function parameters in `StrategyOAuthConfig<TConfig>` could not be assigned when `TConfig` was a specific type.
32
+
33
+ - Updated dependencies [a65e002]
34
+ - @checkstack/common@0.2.0
35
+ - @checkstack/queue-api@1.0.1
36
+ - @checkstack/signal-common@0.1.1
37
+
38
+ ## 1.0.0
39
+
40
+ ### Major Changes
41
+
42
+ - 81f3f85: ## Breaking: Unified Versioned<T> Architecture
43
+
44
+ Refactored the versioning system to use a unified `Versioned<T>` class instead of separate `VersionedSchema`, `VersionedData`, and `VersionedConfig` types.
45
+
46
+ ### Breaking Changes
47
+
48
+ - **`VersionedSchema<T>`** is replaced by `Versioned<T>` class
49
+ - **`VersionedData<T>`** is replaced by `VersionedRecord<T>` interface
50
+ - **`VersionedConfig<T>`** is replaced by `VersionedPluginRecord<T>` interface
51
+ - **`ConfigMigration<F, T>`** is replaced by `Migration<F, T>` interface
52
+ - **`MigrationChain<T>`** is removed (use `Migration<unknown, unknown>[]`)
53
+ - **`migrateVersionedData()`** is removed (use `versioned.parse()`)
54
+ - **`ConfigMigrationRunner`** is removed (migrations are internal to Versioned)
55
+
56
+ ### Migration Guide
57
+
58
+ Before:
59
+
60
+ ```typescript
61
+ const strategy: HealthCheckStrategy = {
62
+ config: {
63
+ version: 1,
64
+ schema: mySchema,
65
+ migrations: [],
66
+ },
67
+ };
68
+ const data = await migrateVersionedData(stored, 1, migrations);
69
+ ```
70
+
71
+ After:
72
+
73
+ ```typescript
74
+ const strategy: HealthCheckStrategy = {
75
+ config: new Versioned({
76
+ version: 1,
77
+ schema: mySchema,
78
+ migrations: [],
79
+ }),
80
+ };
81
+ const data = await strategy.config.parse(stored);
82
+ ```
83
+
84
+ ### Minor Changes
85
+
86
+ - ffc28f6: ### Anonymous Role and Public Access
87
+
88
+ Introduces a configurable "anonymous" role for managing permissions available to unauthenticated users.
89
+
90
+ **Core Changes:**
91
+
92
+ - Added `userType: "public"` - endpoints accessible by both authenticated users (with their permissions) and anonymous users (with anonymous role permissions)
93
+ - Renamed `userType: "both"` to `"authenticated"` for clarity
94
+ - Renamed `isDefault` to `isAuthenticatedDefault` on Permission interface
95
+ - Added `isPublicDefault` flag for permissions that should be granted to the anonymous role by default
96
+
97
+ **Backend Infrastructure:**
98
+
99
+ - New `anonymous` system role created during auth-backend initialization
100
+ - New `disabled_public_default_permission` table tracks admin-disabled public defaults
101
+ - `autoAuthMiddleware` now checks anonymous role permissions for unauthenticated public endpoint access
102
+ - `AuthService.getAnonymousPermissions()` with 1-minute caching for performance
103
+ - Anonymous role filtered from `getRoles` endpoint (not assignable to users)
104
+ - Validation prevents assigning anonymous role to users
105
+
106
+ **Catalog Integration:**
107
+
108
+ - `catalog.read` permission now has both `isAuthenticatedDefault` and `isPublicDefault`
109
+ - Read endpoints (`getSystems`, `getGroups`, `getEntities`) now use `userType: "public"`
110
+
111
+ **UI:**
112
+
113
+ - New `PermissionGate` component for conditionally rendering content based on permissions
114
+
115
+ - 71275dd: fix: Anonymous and non-admin user authorization
116
+
117
+ - Fixed permission metadata preservation in `plugin-manager.ts` - changed from outdated `isDefault` field to `isAuthenticatedDefault` and `isPublicDefault`
118
+ - Added `pluginId` to `RpcContext` to enable proper permission ID matching
119
+ - Updated `autoAuthMiddleware` to prefix contract permission IDs with the pluginId from context, ensuring that contract permissions (e.g., `catalog.read`) correctly match database permissions (e.g., `catalog-backend.catalog.read`)
120
+ - Route now uses `/api/:pluginId/*` pattern with Hono path parameters for clean pluginId extraction
121
+
122
+ - ae19ff6: Add configurable state thresholds for health check evaluation
123
+
124
+ **@checkstack/backend-api:**
125
+
126
+ - Added `VersionedData<T>` generic interface as base for all versioned data structures
127
+ - `VersionedConfig<T>` now extends `VersionedData<T>` and adds `pluginId`
128
+ - Added `migrateVersionedData()` utility function for running migrations on any `VersionedData` subtype
129
+
130
+ **@checkstack/backend:**
131
+
132
+ - Refactored `ConfigMigrationRunner` to use the new `migrateVersionedData` utility
133
+
134
+ **@checkstack/healthcheck-common:**
135
+
136
+ - Added state threshold schemas with two evaluation modes (consecutive, window)
137
+ - Added `stateThresholds` field to `AssociateHealthCheckSchema`
138
+ - Added `getSystemHealthStatus` RPC endpoint contract
139
+
140
+ **@checkstack/healthcheck-backend:**
141
+
142
+ - Added `stateThresholds` column to `system_health_checks` table
143
+ - Added `state-evaluator.ts` with health status evaluation logic
144
+ - Added `state-thresholds-migrations.ts` with migration infrastructure
145
+ - Added `getSystemHealthStatus` RPC handler
146
+
147
+ **@checkstack/healthcheck-frontend:**
148
+
149
+ - Updated `SystemHealthBadge` to use new backend endpoint
150
+
151
+ - b55fae6: Added realtime Signal Service for backend-to-frontend push notifications via WebSockets.
152
+
153
+ ## New Packages
154
+
155
+ - **@checkstack/signal-common**: Shared types including `Signal`, `SignalService`, `createSignal()`, and WebSocket protocol messages
156
+ - **@checkstack/signal-backend**: `SignalServiceImpl` with EventBus integration and Bun WebSocket handler using native pub/sub
157
+ - **@checkstack/signal-frontend**: React `SignalProvider` and `useSignal()` hook for consuming typed signals
158
+
159
+ ## Changes
160
+
161
+ - **@checkstack/backend-api**: Added `coreServices.signalService` reference for plugins to emit signals
162
+ - **@checkstack/backend**: Integrated WebSocket server at `/api/signals/ws` with session-based authentication
163
+
164
+ ## Usage
165
+
166
+ Backend plugins can emit signals:
167
+
168
+ ```typescript
169
+ import { coreServices } from "@checkstack/backend-api";
170
+ import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
171
+
172
+ const signalService = context.signalService;
173
+ await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, { ... });
174
+ ```
175
+
176
+ Frontend components subscribe to signals:
177
+
178
+ ```tsx
179
+ import { useSignal } from "@checkstack/signal-frontend";
180
+ import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
181
+
182
+ useSignal(NOTIFICATION_RECEIVED, (payload) => {
183
+ // Handle realtime notification
184
+ });
185
+ ```
186
+
187
+ - b354ab3: # Strategy Instructions Support & Telegram Notification Plugin
188
+
189
+ ## Strategy Instructions Interface
190
+
191
+ Added `adminInstructions` and `userInstructions` optional fields to the `NotificationStrategy` interface. These allow strategies to export markdown-formatted setup guides that are displayed in the configuration UI:
192
+
193
+ - **`adminInstructions`**: Shown when admins configure platform-wide strategy settings (e.g., how to create API keys)
194
+ - **`userInstructions`**: Shown when users configure their personal settings (e.g., how to link their account)
195
+
196
+ ### Updated Components
197
+
198
+ - `StrategyConfigCard` now accepts an `instructions` prop and renders it before config sections
199
+ - `StrategyCard` passes `adminInstructions` to `StrategyConfigCard`
200
+ - `UserChannelCard` renders `userInstructions` when users need to connect
201
+
202
+ ## New Telegram Notification Plugin
203
+
204
+ Added `@checkstack/notification-telegram-backend` plugin for sending notifications via Telegram:
205
+
206
+ - Uses [grammY](https://grammy.dev/) framework for Telegram Bot API integration
207
+ - Sends messages with MarkdownV2 formatting and inline keyboard buttons for actions
208
+ - Includes comprehensive admin instructions for bot setup via @BotFather
209
+ - Includes user instructions for account linking
210
+
211
+ ### Configuration
212
+
213
+ Admins need to configure a Telegram Bot Token obtained from @BotFather.
214
+
215
+ ### User Linking
216
+
217
+ The strategy uses `contactResolution: { type: "custom" }` for Telegram Login Widget integration. Full frontend integration for the Login Widget is pending future work.
218
+
219
+ ### Patch Changes
220
+
221
+ - Updated dependencies [ffc28f6]
222
+ - Updated dependencies [e4d83fc]
223
+ - Updated dependencies [b55fae6]
224
+ - Updated dependencies [8e889b4]
225
+ - Updated dependencies [81f3f85]
226
+ - @checkstack/common@0.1.0
227
+ - @checkstack/queue-api@1.0.0
228
+ - @checkstack/signal-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@checkstack/backend-api",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkstack/common": "workspace:*",
13
+ "@checkstack/queue-api": "workspace:*",
14
+ "@checkstack/signal-common": "workspace:*",
15
+ "@orpc/client": "^1.13.2",
16
+ "@orpc/openapi": "^1.13.2",
17
+ "@orpc/server": "^1.13.2",
18
+ "@orpc/zod": "^1.13.2",
19
+ "drizzle-orm": "^0.45.1",
20
+ "hono": "^4.0.0",
21
+ "marked": "^17.0.1",
22
+ "zod": "^4.2.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bun": "latest",
26
+ "@checkstack/tsconfig": "workspace:*",
27
+ "@checkstack/scripts": "workspace:*"
28
+ },
29
+ "peerDependencies": {
30
+ "hono": "^4.0.0",
31
+ "drizzle-orm": "^0.45.1"
32
+ }
33
+ }
@@ -0,0 +1,345 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ evaluateAssertion,
4
+ evaluateAssertions,
5
+ evaluateJsonPathAssertions,
6
+ numericField,
7
+ timeThresholdField,
8
+ stringField,
9
+ booleanField,
10
+ enumField,
11
+ jsonPathField,
12
+ } from "./assertions";
13
+ import { z } from "zod";
14
+
15
+ describe("Assertion Schema Factories", () => {
16
+ describe("numericField", () => {
17
+ it("creates a schema with numeric operators", () => {
18
+ const schema = numericField("packetLoss", { min: 0, max: 100 });
19
+
20
+ const valid = schema.safeParse({
21
+ field: "packetLoss",
22
+ operator: "lessThan",
23
+ value: 50,
24
+ });
25
+ expect(valid.success).toBe(true);
26
+
27
+ const invalid = schema.safeParse({
28
+ field: "packetLoss",
29
+ operator: "lessThan",
30
+ value: 150,
31
+ });
32
+ expect(invalid.success).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe("timeThresholdField", () => {
37
+ it("only allows lessThan and lessThanOrEqual operators", () => {
38
+ const schema = timeThresholdField("latency");
39
+
40
+ const valid = schema.safeParse({
41
+ field: "latency",
42
+ operator: "lessThan",
43
+ value: 100,
44
+ });
45
+ expect(valid.success).toBe(true);
46
+
47
+ const invalid = schema.safeParse({
48
+ field: "latency",
49
+ operator: "greaterThan",
50
+ value: 100,
51
+ });
52
+ expect(invalid.success).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("stringField", () => {
57
+ it("creates a schema with string operators", () => {
58
+ const schema = stringField("banner");
59
+
60
+ const valid = schema.safeParse({
61
+ field: "banner",
62
+ operator: "contains",
63
+ value: "SSH",
64
+ });
65
+ expect(valid.success).toBe(true);
66
+ });
67
+ });
68
+
69
+ describe("booleanField", () => {
70
+ it("creates a schema with isTrue/isFalse operators", () => {
71
+ const schema = booleanField("isExpired");
72
+
73
+ const isTrue = schema.safeParse({
74
+ field: "isExpired",
75
+ operator: "isTrue",
76
+ });
77
+ expect(isTrue.success).toBe(true);
78
+
79
+ const isFalse = schema.safeParse({
80
+ field: "isExpired",
81
+ operator: "isFalse",
82
+ });
83
+ expect(isFalse.success).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("enumField", () => {
88
+ it("creates a schema for enum values", () => {
89
+ const schema = enumField("status", ["SERVING", "NOT_SERVING"] as const);
90
+
91
+ const valid = schema.safeParse({
92
+ field: "status",
93
+ operator: "equals",
94
+ value: "SERVING",
95
+ });
96
+ expect(valid.success).toBe(true);
97
+
98
+ const invalid = schema.safeParse({
99
+ field: "status",
100
+ operator: "equals",
101
+ value: "INVALID",
102
+ });
103
+ expect(invalid.success).toBe(false);
104
+ });
105
+
106
+ it("generates JSON Schema with enum values for select rendering", () => {
107
+ const schema = enumField("status", [
108
+ "SERVING",
109
+ "NOT_SERVING",
110
+ "UNKNOWN",
111
+ ] as const);
112
+ const jsonSchema = schema.toJSONSchema() as Record<string, unknown>;
113
+ const properties = jsonSchema.properties as Record<
114
+ string,
115
+ Record<string, unknown>
116
+ >;
117
+
118
+ // Field should have const for discriminator
119
+ expect(properties.field.const).toBe("status");
120
+
121
+ // Operator should have const "equals"
122
+ expect(properties.operator.const).toBe("equals");
123
+
124
+ // Value should have enum array for select rendering
125
+ expect(properties.value.enum).toEqual([
126
+ "SERVING",
127
+ "NOT_SERVING",
128
+ "UNKNOWN",
129
+ ]);
130
+ });
131
+ });
132
+
133
+ describe("jsonPathField", () => {
134
+ it("creates a schema with dynamic operators", () => {
135
+ const schema = jsonPathField();
136
+
137
+ const valid = schema.safeParse({
138
+ path: "$.status",
139
+ operator: "equals",
140
+ value: "ok",
141
+ });
142
+ expect(valid.success).toBe(true);
143
+
144
+ const exists = schema.safeParse({ path: "$.data", operator: "exists" });
145
+ expect(exists.success).toBe(true);
146
+ });
147
+ });
148
+ });
149
+
150
+ describe("evaluateAssertion", () => {
151
+ describe("numeric operators", () => {
152
+ it("evaluates equals correctly", () => {
153
+ const result = evaluateAssertion(
154
+ { field: "count", operator: "equals", value: 5 },
155
+ { count: 5 }
156
+ );
157
+ expect(result.passed).toBe(true);
158
+ });
159
+
160
+ it("evaluates lessThan correctly", () => {
161
+ const result = evaluateAssertion(
162
+ { field: "latency", operator: "lessThan", value: 100 },
163
+ { latency: 50 }
164
+ );
165
+ expect(result.passed).toBe(true);
166
+
167
+ const failed = evaluateAssertion(
168
+ { field: "latency", operator: "lessThan", value: 100 },
169
+ { latency: 150 }
170
+ );
171
+ expect(failed.passed).toBe(false);
172
+ expect(failed.message).toContain("less than");
173
+ });
174
+
175
+ it("evaluates greaterThanOrEqual correctly", () => {
176
+ const result = evaluateAssertion(
177
+ { field: "uptime", operator: "greaterThanOrEqual", value: 99 },
178
+ { uptime: 99 }
179
+ );
180
+ expect(result.passed).toBe(true);
181
+ });
182
+ });
183
+
184
+ describe("string operators", () => {
185
+ it("evaluates contains correctly", () => {
186
+ const result = evaluateAssertion(
187
+ { field: "stdout", operator: "contains", value: "OK" },
188
+ { stdout: "Status: OK" }
189
+ );
190
+ expect(result.passed).toBe(true);
191
+ });
192
+
193
+ it("evaluates startsWith correctly", () => {
194
+ const result = evaluateAssertion(
195
+ { field: "banner", operator: "startsWith", value: "SSH-2.0" },
196
+ { banner: "SSH-2.0-OpenSSH" }
197
+ );
198
+ expect(result.passed).toBe(true);
199
+ });
200
+
201
+ it("evaluates matches correctly", () => {
202
+ const result = evaluateAssertion(
203
+ { field: "version", operator: "matches", value: "v\\d+\\.\\d+" },
204
+ { version: "v1.2.3" }
205
+ );
206
+ expect(result.passed).toBe(true);
207
+ });
208
+
209
+ it("evaluates isEmpty correctly", () => {
210
+ const result = evaluateAssertion(
211
+ { field: "stderr", operator: "isEmpty" },
212
+ { stderr: "" }
213
+ );
214
+ expect(result.passed).toBe(true);
215
+ });
216
+ });
217
+
218
+ describe("boolean operators", () => {
219
+ it("evaluates isTrue correctly", () => {
220
+ const result = evaluateAssertion(
221
+ { field: "connected", operator: "isTrue" },
222
+ { connected: true }
223
+ );
224
+ expect(result.passed).toBe(true);
225
+
226
+ const failed = evaluateAssertion(
227
+ { field: "connected", operator: "isTrue" },
228
+ { connected: false }
229
+ );
230
+ expect(failed.passed).toBe(false);
231
+ });
232
+
233
+ it("evaluates isFalse correctly", () => {
234
+ const result = evaluateAssertion(
235
+ { field: "isExpired", operator: "isFalse" },
236
+ { isExpired: false }
237
+ );
238
+ expect(result.passed).toBe(true);
239
+ });
240
+ });
241
+
242
+ describe("existence operators", () => {
243
+ it("evaluates exists correctly", () => {
244
+ const result = evaluateAssertion(
245
+ { field: "data", operator: "exists" },
246
+ { data: { foo: "bar" } }
247
+ );
248
+ expect(result.passed).toBe(true);
249
+
250
+ const notExists = evaluateAssertion(
251
+ { field: "data", operator: "exists" },
252
+ { data: null }
253
+ );
254
+ expect(notExists.passed).toBe(false);
255
+ });
256
+
257
+ it("evaluates notExists correctly", () => {
258
+ const result = evaluateAssertion(
259
+ { field: "error", operator: "notExists" },
260
+ { error: undefined }
261
+ );
262
+ expect(result.passed).toBe(true);
263
+ });
264
+ });
265
+ });
266
+
267
+ describe("evaluateAssertions", () => {
268
+ it("returns null when all assertions pass", () => {
269
+ const assertions = [
270
+ { field: "status", operator: "equals", value: 200 },
271
+ { field: "latency", operator: "lessThan", value: 100 },
272
+ ];
273
+ const result = evaluateAssertions(assertions, { status: 200, latency: 50 });
274
+ expect(result).toBe(null);
275
+ });
276
+
277
+ it("returns the first failed assertion", () => {
278
+ const assertions = [
279
+ { field: "status", operator: "equals", value: 200 },
280
+ { field: "latency", operator: "lessThan", value: 100 },
281
+ ];
282
+ const result = evaluateAssertions(assertions, {
283
+ status: 200,
284
+ latency: 150,
285
+ });
286
+ expect(result).toEqual({
287
+ field: "latency",
288
+ operator: "lessThan",
289
+ value: 100,
290
+ });
291
+ });
292
+
293
+ it("returns null for empty or undefined assertions", () => {
294
+ expect(evaluateAssertions([], {})).toBe(null);
295
+ expect(evaluateAssertions(undefined, {})).toBe(null);
296
+ });
297
+ });
298
+
299
+ describe("evaluateJsonPathAssertions", () => {
300
+ const extractPath = (path: string, json: unknown) => {
301
+ // Simple mock extractor for testing
302
+ if (path === "$.status") return (json as Record<string, unknown>)?.status;
303
+ if (path === "$.count") return (json as Record<string, unknown>)?.count;
304
+ return undefined;
305
+ };
306
+
307
+ it("evaluates JSONPath assertions with string coercion", () => {
308
+ const assertions = [{ path: "$.status", operator: "equals", value: "ok" }];
309
+ const result = evaluateJsonPathAssertions(
310
+ assertions,
311
+ { status: "ok" },
312
+ extractPath
313
+ );
314
+ expect(result).toBe(null);
315
+ });
316
+
317
+ it("evaluates JSONPath assertions with numeric coercion", () => {
318
+ const assertions = [
319
+ { path: "$.count", operator: "greaterThan", value: "5" },
320
+ ];
321
+ const result = evaluateJsonPathAssertions(
322
+ assertions,
323
+ { count: 10 },
324
+ extractPath
325
+ );
326
+ expect(result).toBe(null);
327
+
328
+ const failed = evaluateJsonPathAssertions(
329
+ assertions,
330
+ { count: 3 },
331
+ extractPath
332
+ );
333
+ expect(failed).toEqual(assertions[0]);
334
+ });
335
+
336
+ it("evaluates existence checks", () => {
337
+ const assertions = [{ path: "$.status", operator: "exists" }];
338
+ const result = evaluateJsonPathAssertions(
339
+ assertions,
340
+ { status: "ok" },
341
+ extractPath
342
+ );
343
+ expect(result).toBe(null);
344
+ });
345
+ });