@checkstack/backend-api 0.2.0 → 0.3.1

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,122 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [9a27800]
8
+ - @checkstack/queue-api@0.0.6
9
+
10
+ ## 0.3.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 9faec1f: # Unified AccessRule Terminology Refactoring
15
+
16
+ This release completes a comprehensive terminology refactoring from "permission" to "accessRule" across the entire codebase, establishing a consistent and modern access control vocabulary.
17
+
18
+ ## Changes
19
+
20
+ ### Core Infrastructure (`@checkstack/common`)
21
+
22
+ - Introduced `AccessRule` interface as the primary access control type
23
+ - Added `accessPair()` helper for creating read/manage access rule pairs
24
+ - Added `access()` builder for individual access rules
25
+ - Replaced `Permission` type with `AccessRule` throughout
26
+
27
+ ### API Changes
28
+
29
+ - `env.registerPermissions()` → `env.registerAccessRules()`
30
+ - `meta.permissions` → `meta.access` in RPC contracts
31
+ - `usePermission()` → `useAccess()` in frontend hooks
32
+ - Route `permission:` field → `accessRule:` field
33
+
34
+ ### UI Changes
35
+
36
+ - "Roles & Permissions" tab → "Roles & Access Rules"
37
+ - "You don't have permission..." → "You don't have access..."
38
+ - All permission-related UI text updated
39
+
40
+ ### Documentation & Templates
41
+
42
+ - Updated 18 documentation files with AccessRule terminology
43
+ - Updated 7 scaffolding templates with `accessPair()` pattern
44
+ - All code examples use new AccessRule API
45
+
46
+ ## Migration Guide
47
+
48
+ ### Backend Plugins
49
+
50
+ ```diff
51
+ - import { permissionList } from "./permissions";
52
+ - env.registerPermissions(permissionList);
53
+ + import { accessRules } from "./access";
54
+ + env.registerAccessRules(accessRules);
55
+ ```
56
+
57
+ ### RPC Contracts
58
+
59
+ ```diff
60
+ - .meta({ userType: "user", permissions: [permissions.read.id] })
61
+ + .meta({ userType: "user", access: [access.read] })
62
+ ```
63
+
64
+ ### Frontend Hooks
65
+
66
+ ```diff
67
+ - const canRead = accessApi.usePermission(permissions.read.id);
68
+ + const canRead = accessApi.useAccess(access.read);
69
+ ```
70
+
71
+ ### Routes
72
+
73
+ ```diff
74
+ - permission: permissions.entityRead.id,
75
+ + accessRule: access.read,
76
+ ```
77
+
78
+ - 827b286: Add array assertion operators for string array fields
79
+
80
+ New operators for asserting on array fields (e.g., playerNames in RCON collectors):
81
+
82
+ - **includes** - Check if array contains a specific value
83
+ - **notIncludes** - Check if array does NOT contain a specific value
84
+ - **lengthEquals** - Check if array length equals a value
85
+ - **lengthGreaterThan** - Check if array length is greater than a value
86
+ - **lengthLessThan** - Check if array length is less than a value
87
+ - **isEmpty** - Check if array is empty
88
+ - **isNotEmpty** - Check if array has at least one element
89
+
90
+ Also exports a new `arrayField()` schema factory for creating array assertion schemas.
91
+
92
+ ### Patch Changes
93
+
94
+ - f533141: Enforce health result factory function usage via branded types
95
+
96
+ - Added `healthResultSchema()` builder that enforces the use of factory functions at compile-time
97
+ - Added `healthResultArray()` factory for array fields (e.g., DNS resolved values)
98
+ - Added branded `HealthResultField<T>` type to mark schemas created by factory functions
99
+ - Consolidated `ChartType` and `HealthResultMeta` into `@checkstack/common` as single source of truth
100
+ - Updated all 12 health check strategies and 11 collectors to use `healthResultSchema()`
101
+ - Using raw `z.number()` etc. inside `healthResultSchema()` now causes a TypeScript error
102
+
103
+ - aa4a8ab: Fix anonymous users not seeing public list endpoints
104
+
105
+ Anonymous users with global access rules (e.g., `catalog.system.read` assigned to the "anonymous" role) were incorrectly getting empty results from list endpoints with `instanceAccess.listKey`. The middleware now properly checks if anonymous users have global access before filtering.
106
+
107
+ Added comprehensive test suite for `autoAuthMiddleware` covering:
108
+
109
+ - Anonymous endpoints (userType: "anonymous")
110
+ - Public endpoints with global and instance-level access rules
111
+ - Authenticated, user-only, and service-only endpoints
112
+ - Single resource access with team-based filtering
113
+
114
+ - Updated dependencies [9faec1f]
115
+ - Updated dependencies [f533141]
116
+ - @checkstack/common@0.2.0
117
+ - @checkstack/signal-common@0.1.0
118
+ - @checkstack/queue-api@0.0.5
119
+
3
120
  ## 0.2.0
4
121
 
5
122
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "scripts": {
@@ -9,6 +9,7 @@ import {
9
9
  booleanField,
10
10
  enumField,
11
11
  jsonPathField,
12
+ arrayField,
12
13
  } from "./assertions";
13
14
  import { z } from "zod";
14
15
 
@@ -130,6 +131,32 @@ describe("Assertion Schema Factories", () => {
130
131
  });
131
132
  });
132
133
 
134
+ describe("arrayField", () => {
135
+ it("creates a schema with array operators", () => {
136
+ const schema = arrayField("playerNames");
137
+
138
+ const includes = schema.safeParse({
139
+ field: "playerNames",
140
+ operator: "includes",
141
+ value: "Steve",
142
+ });
143
+ expect(includes.success).toBe(true);
144
+
145
+ const lengthEquals = schema.safeParse({
146
+ field: "playerNames",
147
+ operator: "lengthEquals",
148
+ value: 5,
149
+ });
150
+ expect(lengthEquals.success).toBe(true);
151
+
152
+ const isEmpty = schema.safeParse({
153
+ field: "playerNames",
154
+ operator: "isEmpty",
155
+ });
156
+ expect(isEmpty.success).toBe(true);
157
+ });
158
+ });
159
+
133
160
  describe("jsonPathField", () => {
134
161
  it("creates a schema with dynamic operators", () => {
135
162
  const schema = jsonPathField();
@@ -239,6 +266,107 @@ describe("evaluateAssertion", () => {
239
266
  });
240
267
  });
241
268
 
269
+ describe("array operators", () => {
270
+ it("evaluates includes correctly", () => {
271
+ const result = evaluateAssertion(
272
+ { field: "playerNames", operator: "includes", value: "Steve" },
273
+ { playerNames: ["Steve", "Alex", "Notch"] }
274
+ );
275
+ expect(result.passed).toBe(true);
276
+
277
+ const failed = evaluateAssertion(
278
+ { field: "playerNames", operator: "includes", value: "Herobrine" },
279
+ { playerNames: ["Steve", "Alex", "Notch"] }
280
+ );
281
+ expect(failed.passed).toBe(false);
282
+ expect(failed.message).toContain("to include");
283
+ });
284
+
285
+ it("evaluates notIncludes correctly", () => {
286
+ const result = evaluateAssertion(
287
+ { field: "playerNames", operator: "notIncludes", value: "Herobrine" },
288
+ { playerNames: ["Steve", "Alex"] }
289
+ );
290
+ expect(result.passed).toBe(true);
291
+
292
+ const failed = evaluateAssertion(
293
+ { field: "playerNames", operator: "notIncludes", value: "Steve" },
294
+ { playerNames: ["Steve", "Alex"] }
295
+ );
296
+ expect(failed.passed).toBe(false);
297
+ });
298
+
299
+ it("evaluates lengthEquals correctly", () => {
300
+ const result = evaluateAssertion(
301
+ { field: "playerNames", operator: "lengthEquals", value: 3 },
302
+ { playerNames: ["Steve", "Alex", "Notch"] }
303
+ );
304
+ expect(result.passed).toBe(true);
305
+
306
+ const failed = evaluateAssertion(
307
+ { field: "playerNames", operator: "lengthEquals", value: 5 },
308
+ { playerNames: ["Steve", "Alex", "Notch"] }
309
+ );
310
+ expect(failed.passed).toBe(false);
311
+ });
312
+
313
+ it("evaluates lengthGreaterThan correctly", () => {
314
+ const result = evaluateAssertion(
315
+ { field: "playerNames", operator: "lengthGreaterThan", value: 2 },
316
+ { playerNames: ["Steve", "Alex", "Notch"] }
317
+ );
318
+ expect(result.passed).toBe(true);
319
+
320
+ const failed = evaluateAssertion(
321
+ { field: "playerNames", operator: "lengthGreaterThan", value: 5 },
322
+ { playerNames: ["Steve", "Alex"] }
323
+ );
324
+ expect(failed.passed).toBe(false);
325
+ });
326
+
327
+ it("evaluates lengthLessThan correctly", () => {
328
+ const result = evaluateAssertion(
329
+ { field: "playerNames", operator: "lengthLessThan", value: 5 },
330
+ { playerNames: ["Steve", "Alex"] }
331
+ );
332
+ expect(result.passed).toBe(true);
333
+
334
+ const failed = evaluateAssertion(
335
+ { field: "playerNames", operator: "lengthLessThan", value: 2 },
336
+ { playerNames: ["Steve", "Alex", "Notch"] }
337
+ );
338
+ expect(failed.passed).toBe(false);
339
+ });
340
+
341
+ it("evaluates isEmpty correctly for arrays", () => {
342
+ const result = evaluateAssertion(
343
+ { field: "playerNames", operator: "isEmpty" },
344
+ { playerNames: [] }
345
+ );
346
+ expect(result.passed).toBe(true);
347
+
348
+ const failed = evaluateAssertion(
349
+ { field: "playerNames", operator: "isEmpty" },
350
+ { playerNames: ["Steve"] }
351
+ );
352
+ expect(failed.passed).toBe(false);
353
+ });
354
+
355
+ it("evaluates isNotEmpty correctly for arrays", () => {
356
+ const result = evaluateAssertion(
357
+ { field: "playerNames", operator: "isNotEmpty" },
358
+ { playerNames: ["Steve", "Alex"] }
359
+ );
360
+ expect(result.passed).toBe(true);
361
+
362
+ const failed = evaluateAssertion(
363
+ { field: "playerNames", operator: "isNotEmpty" },
364
+ { playerNames: [] }
365
+ );
366
+ expect(failed.passed).toBe(false);
367
+ });
368
+ });
369
+
242
370
  describe("existence operators", () => {
243
371
  it("evaluates exists correctly", () => {
244
372
  const result = evaluateAssertion(
package/src/assertions.ts CHANGED
@@ -40,6 +40,21 @@ export const StringOperators = z.enum([
40
40
  */
41
41
  export const BooleanOperators = z.enum(["isTrue", "isFalse"]);
42
42
 
43
+ /**
44
+ * Operators for array fields.
45
+ */
46
+ export const ArrayOperators = z.enum([
47
+ "includes",
48
+ "notIncludes",
49
+ "lengthEquals",
50
+ "lengthGreaterThan",
51
+ "lengthLessThan",
52
+ "isEmpty",
53
+ "isNotEmpty",
54
+ "exists",
55
+ "notExists",
56
+ ]);
57
+
43
58
  /**
44
59
  * Universal operators for dynamic/unknown types (JSONPath values).
45
60
  * Works via runtime type coercion.
@@ -118,6 +133,23 @@ export function booleanField(name: string) {
118
133
  });
119
134
  }
120
135
 
136
+ /**
137
+ * Creates an assertion schema for array fields.
138
+ * Supports checking array contents and length.
139
+ */
140
+ export function arrayField(name: string) {
141
+ return z.object({
142
+ field: z.literal(name),
143
+ operator: ArrayOperators,
144
+ value: z
145
+ .union([z.string(), z.number()])
146
+ .optional()
147
+ .describe(
148
+ "Value to check (string for includes, number for length operators)"
149
+ ),
150
+ });
151
+ }
152
+
121
153
  /**
122
154
  * Creates an assertion schema for enum fields (e.g., status codes).
123
155
  */
@@ -258,8 +290,38 @@ function evaluateOperator(
258
290
  if (op === "isTrue") return actual === true;
259
291
  if (op === "isFalse") return actual === false;
260
292
 
261
- // Empty check
293
+ // Array operators
294
+ if (Array.isArray(actual)) {
295
+ switch (op) {
296
+ case "includes": {
297
+ const strExpected = String(expected ?? "");
298
+ return actual.some((item) => String(item) === strExpected);
299
+ }
300
+ case "notIncludes": {
301
+ const strExpected = String(expected ?? "");
302
+ return !actual.some((item) => String(item) === strExpected);
303
+ }
304
+ case "lengthEquals": {
305
+ return actual.length === Number(expected);
306
+ }
307
+ case "lengthGreaterThan": {
308
+ return actual.length > Number(expected);
309
+ }
310
+ case "lengthLessThan": {
311
+ return actual.length < Number(expected);
312
+ }
313
+ case "isEmpty": {
314
+ return actual.length === 0;
315
+ }
316
+ case "isNotEmpty": {
317
+ return actual.length > 0;
318
+ }
319
+ }
320
+ }
321
+
322
+ // Empty check (for strings)
262
323
  if (op === "isEmpty") return !actual || String(actual).trim() === "";
324
+ if (op === "isNotEmpty") return !!actual && String(actual).trim() !== "";
263
325
 
264
326
  // For numeric operators, try to coerce to numbers
265
327
  if (
@@ -350,17 +412,30 @@ function formatFailureMessage(
350
412
  endsWith: "to end with",
351
413
  matches: "to match pattern",
352
414
  isEmpty: "to be empty",
415
+ isNotEmpty: "to not be empty",
353
416
  exists: "to exist",
354
417
  notExists: "not to exist",
355
418
  isTrue: "to be true",
356
419
  isFalse: "to be false",
420
+ includes: "to include",
421
+ notIncludes: "to not include",
422
+ lengthEquals: "to have length equal to",
423
+ lengthGreaterThan: "to have length greater than",
424
+ lengthLessThan: "to have length less than",
357
425
  };
358
426
 
359
427
  const opLabel = opLabels[operator] || operator;
360
428
 
361
429
  // For operators without expected values
362
430
  if (
363
- ["isEmpty", "exists", "notExists", "isTrue", "isFalse"].includes(operator)
431
+ [
432
+ "isEmpty",
433
+ "isNotEmpty",
434
+ "exists",
435
+ "notExists",
436
+ "isTrue",
437
+ "isFalse",
438
+ ].includes(operator)
364
439
  ) {
365
440
  return `${field}: expected ${opLabel}, got ${JSON.stringify(actual)}`;
366
441
  }
@@ -26,30 +26,7 @@
26
26
  * ```
27
27
  */
28
28
 
29
- /**
30
- * Available chart types for auto-generated visualizations.
31
- *
32
- * Numeric types:
33
- * - line: Time series line chart for numeric metrics over time
34
- * - bar: Bar chart for distributions (record of string to number)
35
- * - counter: Simple count display with trend indicator
36
- * - gauge: Percentage gauge for rates/percentages (0-100)
37
- *
38
- * Non-numeric types:
39
- * - boolean: Boolean indicator (success/failure, connected/disconnected)
40
- * - text: Text display for string values
41
- * - status: Status badge for error/warning states
42
- *
43
- * Note: Fields without chart annotations simply won't render - no "hidden" type needed.
44
- */
45
- export type ChartType =
46
- | "line"
47
- | "bar"
48
- | "counter"
49
- | "gauge"
50
- | "boolean"
51
- | "text"
52
- | "status";
29
+ import type { ChartType } from "@checkstack/common";
53
30
 
54
31
  /**
55
32
  * Chart metadata to attach to Zod schema fields via .meta().
package/src/hooks.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Permission } from "@checkstack/common";
1
+ import type { AccessRule } from "@checkstack/common";
2
2
 
3
3
  /**
4
4
  * Hook definition for type-safe event emission and subscription
@@ -20,12 +20,12 @@ export function createHook<T>(id: string): Hook<T> {
20
20
  */
21
21
  export const coreHooks = {
22
22
  /**
23
- * Emitted when a plugin registers permissions
23
+ * Emitted when a plugin registers access rules
24
24
  */
25
- permissionsRegistered: createHook<{
25
+ accessRulesRegistered: createHook<{
26
26
  pluginId: string;
27
- permissions: Permission[];
28
- }>("core.permissions.registered"),
27
+ accessRules: AccessRule[];
28
+ }>("core.accessRules.registered"),
29
29
 
30
30
  /**
31
31
  * Emitted when plugin configuration is updated
@@ -65,7 +65,7 @@ export const coreHooks = {
65
65
 
66
66
  /**
67
67
  * Emitted AFTER a plugin has been fully removed.
68
- * Use this for orphan cleanup (e.g., removing permissions from DB).
68
+ * Use this for orphan cleanup (e.g., removing access rules from DB).
69
69
  * Should be emitted with work-queue mode for DB operations.
70
70
  */
71
71
  pluginDeregistered: createHook<{
@@ -366,10 +366,10 @@ export interface RegisteredNotificationStrategy<
366
366
  /** Plugin that registered this strategy */
367
367
  ownerPluginId: string;
368
368
  /**
369
- * Dynamically generated permission ID for this strategy.
369
+ * Dynamically generated access rule ID for this strategy.
370
370
  * Format: `{ownerPluginId}.strategy.{id}.use`
371
371
  */
372
- permissionId: string;
372
+ accessRuleId: string;
373
373
  }
374
374
 
375
375
  /**
@@ -404,12 +404,12 @@ export interface NotificationStrategyRegistry {
404
404
  getStrategies(): RegisteredNotificationStrategy<unknown, unknown, unknown>[];
405
405
 
406
406
  /**
407
- * Get all strategies that a user has permission to use.
407
+ * Get all strategies that a user has access to use.
408
408
  *
409
- * @param userPermissions - Set of permission IDs the user has
409
+ * @param userAccessRules - Set of access rule IDs the user has
410
410
  */
411
411
  getStrategiesForUser(
412
- userPermissions: Set<string>
412
+ userAccessRules: Set<string>
413
413
  ): RegisteredNotificationStrategy<unknown, unknown, unknown>[];
414
414
  }
415
415
 
@@ -1,22 +1,20 @@
1
1
  import { z } from "zod";
2
2
  import { oc } from "@orpc/contract";
3
- import type { ProcedureMetadata } from "@checkstack/common";
4
- import type { Permission } from "@checkstack/common";
3
+ import { access, type ProcedureMetadata } from "@checkstack/common";
5
4
 
6
5
  // ─────────────────────────────────────────────────────────────────────────────
7
- // Permissions
6
+ // Access Rules
8
7
  // ─────────────────────────────────────────────────────────────────────────────
9
8
 
10
- export const pluginAdminPermissions = {
11
- install: {
12
- id: "plugin.install",
13
- description: "Install new plugins from npm",
14
- },
15
- deregister: {
16
- id: "plugin.deregister",
17
- description: "Deregister (uninstall) plugins",
18
- },
19
- } as const satisfies Record<string, Permission>;
9
+ export const pluginAdminAccess = {
10
+ install: access("plugin", "manage", "Install new plugins from npm"),
11
+ deregister: access("plugin", "manage", "Deregister (uninstall) plugins"),
12
+ };
13
+
14
+ export const pluginAdminAccessRules = [
15
+ pluginAdminAccess.install,
16
+ pluginAdminAccess.deregister,
17
+ ];
20
18
 
21
19
  // ─────────────────────────────────────────────────────────────────────────────
22
20
  // Contract
@@ -31,7 +29,7 @@ export const pluginAdminContract = {
31
29
  install: _base
32
30
  .meta({
33
31
  userType: "user",
34
- permissions: [pluginAdminPermissions.install.id],
32
+ access: [pluginAdminAccess.install],
35
33
  })
36
34
  .input(
37
35
  z.object({
@@ -52,7 +50,7 @@ export const pluginAdminContract = {
52
50
  deregister: _base
53
51
  .meta({
54
52
  userType: "user",
55
- permissions: [pluginAdminPermissions.deregister.id],
53
+ access: [pluginAdminAccess.deregister],
56
54
  })
57
55
  .input(
58
56
  z.object({
@@ -1,7 +1,7 @@
1
1
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
2
  import { ServiceRef } from "./service-ref";
3
3
  import { ExtensionPoint } from "./extension-point";
4
- import type { Permission, PluginMetadata } from "@checkstack/common";
4
+ import type { AccessRule, PluginMetadata } from "@checkstack/common";
5
5
  import type { Hook, HookSubscribeOptions, HookUnsubscribe } from "./hooks";
6
6
  import { Router } from "@orpc/server";
7
7
  import { RpcContext } from "./rpc";
@@ -70,7 +70,10 @@ export type BackendPluginRegistry = {
70
70
  registerService: <S>(ref: ServiceRef<S>, impl: S) => void;
71
71
  registerExtensionPoint: <T>(ref: ExtensionPoint<T>, impl: T) => void;
72
72
  getExtensionPoint: <T>(ref: ExtensionPoint<T>) => T;
73
- registerPermissions: (permissions: Permission[]) => void;
73
+ /**
74
+ * Register access rules for this plugin.
75
+ */
76
+ registerAccessRules: (accessRules: AccessRule[]) => void;
74
77
  /**
75
78
  * Registers an oRPC router and its contract for this plugin.
76
79
  * The contract is used for OpenAPI generation.
@@ -85,7 +88,7 @@ export type BackendPluginRegistry = {
85
88
  */
86
89
  registerCleanup: (cleanup: () => Promise<void>) => void;
87
90
  pluginManager: {
88
- getAllPermissions: () => { id: string; description?: string }[];
91
+ getAllAccessRules: () => { id: string; description?: string }[];
89
92
  };
90
93
  };
91
94