@checkstack/backend-api 0.0.2 → 0.2.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,123 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e43507: # Teams and Resource-Level Access Control
8
+
9
+ This release introduces a comprehensive Teams system for organizing users and controlling access to resources at a granular level.
10
+
11
+ ## Features
12
+
13
+ ### Team Management
14
+
15
+ - Create, update, and delete teams with name and description
16
+ - Add/remove users from teams
17
+ - Designate team managers with elevated privileges
18
+ - View team membership and manager status
19
+
20
+ ### Resource-Level Access Control
21
+
22
+ - Grant teams access to specific resources (systems, health checks, incidents, maintenances)
23
+ - Configure read-only or manage permissions per team
24
+ - Resource-level "Team Only" mode that restricts access exclusively to team members
25
+ - Separate `resourceAccessSettings` table for resource-level settings (not per-grant)
26
+ - Automatic cleanup of grants when teams are deleted (database cascade)
27
+
28
+ ### Middleware Integration
29
+
30
+ - Extended `autoAuthMiddleware` to support resource access checks
31
+ - Single-resource pre-handler validation for detail endpoints
32
+ - Automatic list filtering for collection endpoints
33
+ - S2S endpoints for access verification
34
+
35
+ ### Frontend Components
36
+
37
+ - `TeamsTab` component for managing teams in Auth Settings
38
+ - `TeamAccessEditor` component for assigning team access to resources
39
+ - Resource-level "Team Only" toggle in `TeamAccessEditor`
40
+ - Integration into System, Health Check, Incident, and Maintenance editors
41
+
42
+ ## Breaking Changes
43
+
44
+ ### API Response Format Changes
45
+
46
+ List endpoints now return objects with named keys instead of arrays directly:
47
+
48
+ ```typescript
49
+ // Before
50
+ const systems = await catalogApi.getSystems();
51
+
52
+ // After
53
+ const { systems } = await catalogApi.getSystems();
54
+ ```
55
+
56
+ Affected endpoints:
57
+
58
+ - `catalog.getSystems` → `{ systems: [...] }`
59
+ - `healthcheck.getConfigurations` → `{ configurations: [...] }`
60
+ - `incident.listIncidents` → `{ incidents: [...] }`
61
+ - `maintenance.listMaintenances` → `{ maintenances: [...] }`
62
+
63
+ ### User Identity Enrichment
64
+
65
+ `RealUser` and `ApplicationUser` types now include `teamIds: string[]` field with team memberships.
66
+
67
+ ## Documentation
68
+
69
+ See `docs/backend/teams.md` for complete API reference and integration guide.
70
+
71
+ ### Patch Changes
72
+
73
+ - 97c5a6b: Fix collector lookup when health check is assigned to a system
74
+
75
+ Collectors are now stored in the registry with their fully-qualified ID format (ownerPluginId.collectorId) to match how they are referenced in health check configurations. Added `qualifiedId` field to `RegisteredCollector` interface to avoid re-constructing the ID at query time. This fixes the "Collector not found" warning that occurred when executing health checks with assigned systems.
76
+
77
+ - Updated dependencies [8e43507]
78
+ - @checkstack/common@0.1.0
79
+ - @checkstack/queue-api@0.0.4
80
+ - @checkstack/signal-common@0.0.4
81
+
82
+ ## 0.1.0
83
+
84
+ ### Minor Changes
85
+
86
+ - f5b1f49: Added collector registry lifecycle cleanup during plugin unloading.
87
+
88
+ - Added `unregisterByOwner(pluginId)` to remove collectors owned by unloading plugins
89
+ - Added `unregisterByMissingStrategies(loadedPluginIds)` for dependency-based pruning
90
+ - Integrated registry cleanup into `PluginManager.deregisterPlugin()`
91
+ - Updated `registerCoreServices` to return global registries for lifecycle management
92
+
93
+ ### Patch Changes
94
+
95
+ - f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
96
+
97
+ **JSONPath Assertions:**
98
+
99
+ - Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
100
+ - Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
101
+ - Added `jsonPath` field to `CollectorAssertionSchema` for persistence
102
+ - HTTP Request collector body field now supports JSONPath assertions
103
+
104
+ **Fully Qualified Strategy IDs:**
105
+
106
+ - HealthCheckRegistry now uses scoped factories like CollectorRegistry
107
+ - Strategies are stored with `pluginId.strategyId` format
108
+ - Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
109
+ - Router returns qualified IDs so frontend can correctly fetch collectors
110
+
111
+ **UI Improvements:**
112
+
113
+ - Save button disabled when collector configs have invalid required fields
114
+ - Fixed nested button warning in CollectorList accordion
115
+
116
+ - Updated dependencies [f5b1f49]
117
+ - @checkstack/common@0.0.3
118
+ - @checkstack/queue-api@0.0.3
119
+ - @checkstack/signal-common@0.0.3
120
+
3
121
  ## 0.0.2
4
122
 
5
123
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.0.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "scripts": {
@@ -0,0 +1,44 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+ import type { CollectorStrategy } from "./collector-strategy";
3
+ import type { TransportClient } from "./transport-client";
4
+
5
+ /**
6
+ * A registered collector with its owning plugin metadata.
7
+ */
8
+ export interface RegisteredCollector {
9
+ /** The fully-qualified collector ID (ownerPluginId.collectorId) */
10
+ qualifiedId: string;
11
+ /** The collector strategy */
12
+ collector: CollectorStrategy<TransportClient<unknown, unknown>>;
13
+ /** The plugin that registered this collector */
14
+ ownerPlugin: PluginMetadata;
15
+ }
16
+
17
+ /**
18
+ * Scoped collector registry interface.
19
+ * The owning plugin metadata is automatically injected via factory.
20
+ */
21
+ export interface CollectorRegistry {
22
+ /**
23
+ * Register a collector strategy.
24
+ * The owning plugin metadata is automatically captured from the scoped context.
25
+ */
26
+ register(
27
+ collector: CollectorStrategy<TransportClient<unknown, unknown>>
28
+ ): void;
29
+
30
+ /**
31
+ * Get a collector by ID.
32
+ */
33
+ getCollector(id: string): RegisteredCollector | undefined;
34
+
35
+ /**
36
+ * Get all collectors that support a specific transport plugin.
37
+ */
38
+ getCollectorsForPlugin(pluginMetadata: PluginMetadata): RegisteredCollector[];
39
+
40
+ /**
41
+ * Get all registered collectors.
42
+ */
43
+ getCollectors(): RegisteredCollector[];
44
+ }
@@ -0,0 +1,83 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+ import type { TransportClient } from "./transport-client";
3
+ import type { Versioned } from "./config-versioning";
4
+ import type { HealthCheckRunForAggregation } from "./health-check";
5
+
6
+ /**
7
+ * Result from a collector execution.
8
+ */
9
+ export interface CollectorResult<TResult> {
10
+ /** Collector-specific result data */
11
+ result: TResult;
12
+ /** Optional error message if collection partially failed */
13
+ error?: string;
14
+ }
15
+
16
+ /**
17
+ * Generic collector strategy interface.
18
+ *
19
+ * Collectors extend health check strategies by providing additional metrics
20
+ * collection capabilities. They receive a connected transport client and
21
+ * produce typed results with chart metadata.
22
+ *
23
+ * @template TClient - Transport client type (e.g., SshTransportClient)
24
+ * @template TConfig - Collector configuration schema
25
+ * @template TResult - Per-execution result type
26
+ * @template TAggregated - Aggregated result for buckets
27
+ */
28
+ export interface CollectorStrategy<
29
+ TClient extends TransportClient<unknown, unknown>,
30
+ TConfig = unknown,
31
+ TResult = Record<string, unknown>,
32
+ TAggregated = Record<string, unknown>
33
+ > {
34
+ /** Unique identifier for this collector */
35
+ id: string;
36
+
37
+ /** Human-readable name */
38
+ displayName: string;
39
+
40
+ /** Optional description */
41
+ description?: string;
42
+
43
+ /**
44
+ * PluginMetadata of transport strategies this collector supports.
45
+ * The registry uses this to match collectors to compatible strategies.
46
+ */
47
+ supportedPlugins: PluginMetadata[];
48
+
49
+ /**
50
+ * Whether multiple instances of this collector can be added to one config.
51
+ * Default: false (one instance per collector type)
52
+ */
53
+ allowMultiple?: boolean;
54
+
55
+ /** Collector configuration schema with versioning */
56
+ config: Versioned<TConfig>;
57
+
58
+ /** Per-execution result schema (with x-chart-* metadata) */
59
+ result: Versioned<TResult>;
60
+
61
+ /** Aggregated result schema for bucket storage */
62
+ aggregatedResult: Versioned<TAggregated>;
63
+
64
+ /**
65
+ * Execute the collector using the provided transport client.
66
+ *
67
+ * @param params.config - Validated collector configuration
68
+ * @param params.client - Connected transport client
69
+ * @param params.pluginId - ID of the transport strategy invoking this collector
70
+ * @returns Collector result with typed metadata
71
+ */
72
+ execute(params: {
73
+ config: TConfig;
74
+ client: TClient;
75
+ pluginId: string;
76
+ }): Promise<CollectorResult<TResult>>;
77
+
78
+ /**
79
+ * Aggregate results from multiple runs into a summary.
80
+ * Called during retention processing.
81
+ */
82
+ aggregateResult(runs: HealthCheckRunForAggregation<TResult>[]): TAggregated;
83
+ }
@@ -1,10 +1,8 @@
1
1
  import { createServiceRef } from "./service-ref";
2
2
  import type { RpcService } from "./rpc";
3
3
  import type { HealthCheckRegistry } from "./health-check";
4
- import type {
5
- QueuePluginRegistry,
6
- QueueManager,
7
- } from "@checkstack/queue-api";
4
+ import type { CollectorRegistry } from "./collector-registry";
5
+ import type { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
8
6
  import type { ConfigService } from "./config-service";
9
7
  import type { SignalService } from "@checkstack/signal-common";
10
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -32,6 +30,9 @@ export const coreServices = {
32
30
  healthCheckRegistry: createServiceRef<HealthCheckRegistry>(
33
31
  "core.healthCheckRegistry"
34
32
  ),
33
+ collectorRegistry: createServiceRef<CollectorRegistry>(
34
+ "core.collectorRegistry"
35
+ ),
35
36
  pluginInstaller: createServiceRef<PluginInstaller>("core.pluginInstaller"),
36
37
  rpc: createServiceRef<RpcService>("core.rpc"),
37
38
  rpcClient: createServiceRef<RpcClient>("core.rpcClient"),
@@ -1,4 +1,5 @@
1
1
  import { Versioned } from "./config-versioning";
2
+ import type { TransportClient } from "./transport-client";
2
3
 
3
4
  /**
4
5
  * Health check result with typed metadata.
@@ -23,13 +24,35 @@ export interface HealthCheckRunForAggregation<
23
24
  }
24
25
 
25
26
  /**
26
- * Health check strategy definition with typed config and result.
27
+ * Connected transport client with cleanup capability.
28
+ */
29
+ export interface ConnectedClient<
30
+ TClient extends TransportClient<unknown, unknown>
31
+ > {
32
+ /** The connected transport client */
33
+ client: TClient;
34
+ /** Close the connection and release resources */
35
+ close(): void;
36
+ }
37
+
38
+ /**
39
+ * Health check strategy definition with typed config and transport client.
40
+ *
41
+ * Strategies provide a `createClient` function that establishes a connection
42
+ * and returns a transport client. The platform executor handles running
43
+ * collectors and basic health check logic (connectivity test, latency measurement).
44
+ *
27
45
  * @template TConfig - Configuration type for this strategy
28
- * @template TResult - Per-run result type
46
+ * @template TClient - Transport client type (e.g., SshTransportClient)
47
+ * @template TResult - Per-run result type (for aggregation)
29
48
  * @template TAggregatedResult - Aggregated result type for buckets
30
49
  */
31
50
  export interface HealthCheckStrategy<
32
51
  TConfig = unknown,
52
+ TClient extends TransportClient<unknown, unknown> = TransportClient<
53
+ unknown,
54
+ unknown
55
+ >,
33
56
  TResult = Record<string, unknown>,
34
57
  TAggregatedResult = Record<string, unknown>
35
58
  > {
@@ -46,7 +69,15 @@ export interface HealthCheckStrategy<
46
69
  /** Aggregated result schema for long-term bucket storage */
47
70
  aggregatedResult: Versioned<TAggregatedResult>;
48
71
 
49
- execute(config: TConfig): Promise<HealthCheckResult<TResult>>;
72
+ /**
73
+ * Create a connected transport client from the configuration.
74
+ * The platform will use this client to execute collectors.
75
+ *
76
+ * @param config - Validated strategy configuration
77
+ * @returns Connected client wrapper with close() method
78
+ * @throws Error if connection fails (will be caught by executor)
79
+ */
80
+ createClient(config: TConfig): Promise<ConnectedClient<TClient>>;
50
81
 
51
82
  /**
52
83
  * Aggregate results from multiple runs into a summary for bucket storage.
@@ -59,10 +90,47 @@ export interface HealthCheckStrategy<
59
90
  ): TAggregatedResult;
60
91
  }
61
92
 
93
+ /**
94
+ * A registered strategy with its owning plugin metadata and qualified ID.
95
+ */
96
+ export interface RegisteredStrategy {
97
+ strategy: HealthCheckStrategy<
98
+ unknown,
99
+ TransportClient<unknown, unknown>,
100
+ unknown,
101
+ unknown
102
+ >;
103
+ ownerPluginId: string;
104
+ qualifiedId: string;
105
+ }
106
+
62
107
  export interface HealthCheckRegistry {
63
- register(strategy: HealthCheckStrategy<unknown, unknown, unknown>): void;
108
+ register(
109
+ strategy: HealthCheckStrategy<
110
+ unknown,
111
+ TransportClient<unknown, unknown>,
112
+ unknown,
113
+ unknown
114
+ >
115
+ ): void;
64
116
  getStrategy(
65
117
  id: string
66
- ): HealthCheckStrategy<unknown, unknown, unknown> | undefined;
67
- getStrategies(): HealthCheckStrategy<unknown, unknown, unknown>[];
118
+ ):
119
+ | HealthCheckStrategy<
120
+ unknown,
121
+ TransportClient<unknown, unknown>,
122
+ unknown,
123
+ unknown
124
+ >
125
+ | undefined;
126
+ getStrategies(): HealthCheckStrategy<
127
+ unknown,
128
+ TransportClient<unknown, unknown>,
129
+ unknown,
130
+ unknown
131
+ >[];
132
+ /**
133
+ * Get all registered strategies with their metadata (qualified ID, owner plugin).
134
+ */
135
+ getStrategiesWithMeta(): RegisteredStrategy[];
68
136
  }
package/src/index.ts CHANGED
@@ -21,3 +21,6 @@ export * from "./markdown";
21
21
  export * from "./email-layout";
22
22
  export * from "./assertions";
23
23
  export * from "./chart-metadata";
24
+ export * from "./transport-client";
25
+ export * from "./collector-strategy";
26
+ export * from "./collector-registry";
package/src/rpc.ts CHANGED
@@ -1,13 +1,12 @@
1
1
  import { os as baseOs, ORPCError, Router } from "@orpc/server";
2
2
  import { AnyContractRouter } from "@orpc/contract";
3
3
  import { HealthCheckRegistry } from "./health-check";
4
- import {
5
- QueuePluginRegistry,
6
- QueueManager,
7
- } from "@checkstack/queue-api";
4
+ import { CollectorRegistry } from "./collector-registry";
5
+ import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
8
6
  import {
9
7
  ProcedureMetadata,
10
8
  qualifyPermissionId,
9
+ qualifyResourceType,
11
10
  } from "@checkstack/common";
12
11
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
13
12
  import {
@@ -43,6 +42,7 @@ export interface RpcContext {
43
42
  auth: AuthService;
44
43
  user?: AuthUser;
45
44
  healthCheckRegistry: HealthCheckRegistry;
45
+ collectorRegistry: CollectorRegistry;
46
46
  queuePluginRegistry: QueuePluginRegistry;
47
47
  queueManager: QueueManager;
48
48
  /** Emit a hook event for cross-plugin communication */
@@ -87,10 +87,11 @@ export type { ProcedureMetadata } from "@checkstack/common";
87
87
  * Use this in backend routers: `implement(contract).$context<RpcContext>().use(autoAuthMiddleware)`
88
88
  */
89
89
  export const autoAuthMiddleware = os.middleware(
90
- async ({ next, context, procedure }) => {
90
+ async ({ next, context, procedure }, input: unknown) => {
91
91
  const meta = procedure["~orpc"]?.meta as ProcedureMetadata | undefined;
92
92
  const requiredUserType = meta?.userType || "authenticated";
93
93
  const contractPermissions = meta?.permissions || [];
94
+ const resourceAccessConfigs = meta?.resourceAccess || [];
94
95
 
95
96
  // Prefix contract permissions with pluginId to get fully-qualified permission IDs
96
97
  // Contract defines: "catalog.read" -> Stored in DB as: "catalog.catalog.read"
@@ -98,30 +99,9 @@ export const autoAuthMiddleware = os.middleware(
98
99
  qualifyPermissionId(context.pluginMetadata, { id: p })
99
100
  );
100
101
 
101
- // Helper to wrap next() with error logging
102
- const nextWithErrorLogging = async () => {
103
- try {
104
- return await next({});
105
- } catch (error) {
106
- // Log the full error before oRPC sanitizes it to a generic 500
107
- if (error instanceof ORPCError) {
108
- // ORPCError is intentional - log at debug level
109
- context.logger.debug("RPC error response:", {
110
- code: error.code,
111
- message: error.message,
112
- data: error.data,
113
- });
114
- } else {
115
- // Unexpected error - log at error level with full stack trace
116
- context.logger.error("Unexpected RPC error:", error);
117
- }
118
- throw error;
119
- }
120
- };
121
-
122
102
  // 1. Handle anonymous endpoints - no auth required, no permission checks
123
103
  if (requiredUserType === "anonymous") {
124
- return nextWithErrorLogging();
104
+ return next({});
125
105
  }
126
106
 
127
107
  // 2. Handle public endpoints - anyone can attempt, but permissions are checked
@@ -164,7 +144,7 @@ export const autoAuthMiddleware = os.middleware(
164
144
  }
165
145
  }
166
146
  }
167
- return nextWithErrorLogging();
147
+ return next({});
168
148
  }
169
149
 
170
150
  // 3. Enforce authentication for user/service/authenticated types
@@ -206,11 +186,227 @@ export const autoAuthMiddleware = os.middleware(
206
186
  }
207
187
  }
208
188
 
209
- // Pass through - services are trusted with all permissions
210
- return nextWithErrorLogging();
189
+ // 6. Resource-level access control
190
+ // Skip if no resource access configs or if user is a service
191
+ if (resourceAccessConfigs.length === 0 || user.type === "service") {
192
+ return next({});
193
+ }
194
+
195
+ const userId = user.id;
196
+ const userType = user.type;
197
+ const action = inferActionFromPermissions(contractPermissions);
198
+ const hasGlobalPermission =
199
+ user.permissions?.includes("*") ||
200
+ contractPermissions.some((p) =>
201
+ user.permissions?.includes(
202
+ qualifyPermissionId(context.pluginMetadata, { id: p })
203
+ )
204
+ );
205
+
206
+ // Separate single vs list configs
207
+ const singleConfigs = resourceAccessConfigs.filter(
208
+ (c) => c.filterMode !== "list"
209
+ );
210
+ const listConfigs = resourceAccessConfigs.filter(
211
+ (c) => c.filterMode === "list"
212
+ );
213
+
214
+ // Pre-check: Single resource access
215
+ for (const config of singleConfigs) {
216
+ if (!config.idParam) continue;
217
+ const resourceId = getNestedValue(input, config.idParam);
218
+ if (!resourceId) continue;
219
+
220
+ const qualifiedType = qualifyResourceType(
221
+ context.pluginMetadata.pluginId,
222
+ config.resourceType
223
+ );
224
+
225
+ const hasAccess = await checkResourceAccessViaS2S({
226
+ auth: context.auth,
227
+ userId,
228
+ userType,
229
+ resourceType: qualifiedType,
230
+ resourceId,
231
+ action,
232
+ hasGlobalPermission,
233
+ });
234
+
235
+ if (!hasAccess) {
236
+ throw new ORPCError("FORBIDDEN", {
237
+ message: `Access denied to resource ${config.resourceType}:${resourceId}`,
238
+ });
239
+ }
240
+ }
241
+
242
+ // Execute handler
243
+ const result = await next({});
244
+
245
+ // Post-filter: List endpoints
246
+ if (
247
+ listConfigs.length > 0 &&
248
+ result.output &&
249
+ typeof result.output === "object"
250
+ ) {
251
+ const mutableOutput = result.output as Record<string, unknown>;
252
+
253
+ for (const config of listConfigs) {
254
+ if (!config.outputKey) {
255
+ context.logger.error(
256
+ `resourceAccess: filterMode "list" requires outputKey`
257
+ );
258
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
259
+ message: "Invalid resource access configuration",
260
+ });
261
+ }
262
+
263
+ const items = mutableOutput[config.outputKey];
264
+
265
+ if (items === undefined) {
266
+ context.logger.error(
267
+ `resourceAccess: expected "${config.outputKey}" in response but not found`
268
+ );
269
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
270
+ message: "Invalid response shape for filtered endpoint",
271
+ });
272
+ }
273
+
274
+ if (!Array.isArray(items)) {
275
+ context.logger.error(
276
+ `resourceAccess: "${config.outputKey}" must be an array`
277
+ );
278
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
279
+ message: "Invalid response shape for filtered endpoint",
280
+ });
281
+ }
282
+
283
+ const qualifiedType = qualifyResourceType(
284
+ context.pluginMetadata.pluginId,
285
+ config.resourceType
286
+ );
287
+
288
+ const resourceIds = items
289
+ .map((item) => (item as { id?: string }).id)
290
+ .filter((id): id is string => typeof id === "string");
291
+
292
+ const accessibleIds = await getAccessibleResourceIdsViaS2S({
293
+ auth: context.auth,
294
+ userId,
295
+ userType,
296
+ resourceType: qualifiedType,
297
+ resourceIds,
298
+ action,
299
+ hasGlobalPermission,
300
+ });
301
+
302
+ const accessibleSet = new Set(accessibleIds);
303
+ mutableOutput[config.outputKey] = items.filter((item) => {
304
+ const id = (item as { id?: string }).id;
305
+ return id && accessibleSet.has(id);
306
+ });
307
+ }
308
+ }
309
+
310
+ return result;
211
311
  }
212
312
  );
213
313
 
314
+ /**
315
+ * Extract a nested value from an object using dot notation.
316
+ * E.g., getNestedValue({ params: { id: "123" } }, "params.id") => "123"
317
+ */
318
+ function getNestedValue(obj: unknown, path: string): string | undefined {
319
+ const parts = path.split(".");
320
+ let current: unknown = obj;
321
+ for (const part of parts) {
322
+ if (current === null || current === undefined) return undefined;
323
+ current = (current as Record<string, unknown>)[part];
324
+ }
325
+ return typeof current === "string" ? current : undefined;
326
+ }
327
+
328
+ /**
329
+ * Determine action from permission suffix (read vs manage).
330
+ */
331
+ function inferActionFromPermissions(permissions: string[]): "read" | "manage" {
332
+ const perm = permissions[0] || "";
333
+ if (perm.endsWith(".manage") || perm.endsWith("Manage")) return "manage";
334
+ return "read";
335
+ }
336
+
337
+ /**
338
+ * Check resource access via auth service S2S endpoint.
339
+ */
340
+ async function checkResourceAccessViaS2S({
341
+ auth,
342
+ userId,
343
+ userType,
344
+ resourceType,
345
+ resourceId,
346
+ action,
347
+ hasGlobalPermission,
348
+ }: {
349
+ auth: AuthService;
350
+ userId: string;
351
+ userType: "user" | "application";
352
+ resourceType: string;
353
+ resourceId: string;
354
+ action: "read" | "manage";
355
+ hasGlobalPermission: boolean;
356
+ }): Promise<boolean> {
357
+ try {
358
+ const result = await auth.checkResourceTeamAccess({
359
+ userId,
360
+ userType,
361
+ resourceType,
362
+ resourceId,
363
+ action,
364
+ hasGlobalPermission,
365
+ });
366
+ return result.hasAccess;
367
+ } catch {
368
+ // If team access check fails (e.g., service not available), fall back to global permission
369
+ return hasGlobalPermission;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Get accessible resource IDs via auth service S2S endpoint.
375
+ */
376
+ async function getAccessibleResourceIdsViaS2S({
377
+ auth,
378
+ userId,
379
+ userType,
380
+ resourceType,
381
+ resourceIds,
382
+ action,
383
+ hasGlobalPermission,
384
+ }: {
385
+ auth: AuthService;
386
+ userId: string;
387
+ userType: "user" | "application";
388
+ resourceType: string;
389
+ resourceIds: string[];
390
+ action: "read" | "manage";
391
+ hasGlobalPermission: boolean;
392
+ }): Promise<string[]> {
393
+ if (resourceIds.length === 0) return [];
394
+
395
+ try {
396
+ return await auth.getAccessibleResourceIds({
397
+ userId,
398
+ userType,
399
+ resourceType,
400
+ resourceIds,
401
+ action,
402
+ hasGlobalPermission,
403
+ });
404
+ } catch {
405
+ // If team access check fails, fall back to global permission behavior
406
+ return hasGlobalPermission ? resourceIds : [];
407
+ }
408
+ }
409
+
214
410
  // =============================================================================
215
411
  // CONTRACT BUILDER
216
412
  // =============================================================================
package/src/test-utils.ts CHANGED
@@ -2,10 +2,8 @@ import { mock } from "bun:test";
2
2
  import { RpcContext, EmitHookFn } from "./rpc";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { HealthCheckRegistry } from "./health-check";
5
- import {
6
- QueuePluginRegistry,
7
- QueueManager,
8
- } from "@checkstack/queue-api";
5
+ import { CollectorRegistry } from "./collector-registry";
6
+ import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
9
7
 
10
8
  /**
11
9
  * Creates a mocked oRPC context for testing.
@@ -37,12 +35,24 @@ export function createMockRpcContext(
37
35
  authenticate: mock(),
38
36
  getCredentials: mock().mockResolvedValue({ headers: {} }),
39
37
  getAnonymousPermissions: mock().mockResolvedValue([]),
38
+ checkResourceTeamAccess: mock().mockResolvedValue({ hasAccess: true }),
39
+ getAccessibleResourceIds: mock().mockImplementation(
40
+ (params: { resourceIds: string[] }) =>
41
+ Promise.resolve(params.resourceIds)
42
+ ),
40
43
  },
41
44
  healthCheckRegistry: {
42
- registerStrategy: mock(),
45
+ register: mock(),
43
46
  getStrategies: mock().mockReturnValue([]),
44
47
  getStrategy: mock(),
48
+ getStrategiesWithMeta: mock().mockReturnValue([]),
45
49
  } as unknown as HealthCheckRegistry,
50
+ collectorRegistry: {
51
+ register: mock(),
52
+ getCollector: mock(),
53
+ getCollectors: mock().mockReturnValue([]),
54
+ getCollectorsForPlugin: mock().mockReturnValue([]),
55
+ } as unknown as CollectorRegistry,
46
56
  queuePluginRegistry: {
47
57
  register: mock(),
48
58
  getPlugin: mock(),
@@ -0,0 +1,2 @@
1
+ // Re-export TransportClient from common for convenience
2
+ export type { TransportClient } from "@checkstack/common";
package/src/types.ts CHANGED
@@ -31,6 +31,7 @@ export interface RealUser {
31
31
  name?: string;
32
32
  permissions?: string[];
33
33
  roles?: string[];
34
+ teamIds?: string[];
34
35
  [key: string]: unknown;
35
36
  }
36
37
 
@@ -53,6 +54,7 @@ export interface ApplicationUser {
53
54
  name: string;
54
55
  permissions?: string[];
55
56
  roles?: string[];
57
+ teamIds?: string[];
56
58
  }
57
59
 
58
60
  /**
@@ -70,6 +72,29 @@ export interface AuthService {
70
72
  * users on "public" userType endpoints.
71
73
  */
72
74
  getAnonymousPermissions(): Promise<string[]>;
75
+ /**
76
+ * Check if a user has access to a specific resource via team grants.
77
+ */
78
+ checkResourceTeamAccess(params: {
79
+ userId: string;
80
+ userType: "user" | "application";
81
+ resourceType: string;
82
+ resourceId: string;
83
+ action: "read" | "manage";
84
+ hasGlobalPermission: boolean;
85
+ }): Promise<{ hasAccess: boolean }>;
86
+ /**
87
+ * Get IDs of resources the user can access from a given list.
88
+ * Used for bulk filtering of list endpoints.
89
+ */
90
+ getAccessibleResourceIds(params: {
91
+ userId: string;
92
+ userType: "user" | "application";
93
+ resourceType: string;
94
+ resourceIds: string[];
95
+ action: "read" | "manage";
96
+ hasGlobalPermission: boolean;
97
+ }): Promise<string[]>;
73
98
  }
74
99
 
75
100
  /**