@checkstack/backend-api 0.2.0 → 0.3.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/src/rpc.ts CHANGED
@@ -5,7 +5,7 @@ import { CollectorRegistry } from "./collector-registry";
5
5
  import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
6
6
  import {
7
7
  ProcedureMetadata,
8
- qualifyPermissionId,
8
+ qualifyAccessRuleId,
9
9
  qualifyResourceType,
10
10
  } from "@checkstack/common";
11
11
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
@@ -77,12 +77,18 @@ export type { ProcedureMetadata } from "@checkstack/common";
77
77
  *
78
78
  * Automatically enforces based on contract metadata:
79
79
  * 1. User type (from meta.userType):
80
- * - "anonymous": No authentication required, no permission checks
81
- * - "public": Anyone can attempt, but permissions are checked (anonymous role for guests)
80
+ * - "anonymous": No authentication required, no access checks
81
+ * - "public": Anyone can attempt, access checked based on user type
82
82
  * - "user": Only real users (frontend authenticated)
83
83
  * - "service": Only services (backend-to-backend)
84
84
  * - "authenticated": Either users or services, but must be authenticated (default)
85
- * 2. Permissions (from meta.permissions, only for real users or public anonymous)
85
+ * 2. Access rules (from meta.access): unified access rules + resource-level access control
86
+ *
87
+ * Access Control Logic:
88
+ * - Rules WITHOUT instanceAccess: require global access
89
+ * - Rules WITH instanceAccess: S2S call to auth-backend decides based on:
90
+ * - Global access OR team grants (when resource is NOT teamOnly)
91
+ * - Team grants only (when resource IS teamOnly)
86
92
  *
87
93
  * Use this in backend routers: `implement(contract).$context<RpcContext>().use(autoAuthMiddleware)`
88
94
  */
@@ -90,65 +96,82 @@ export const autoAuthMiddleware = os.middleware(
90
96
  async ({ next, context, procedure }, input: unknown) => {
91
97
  const meta = procedure["~orpc"]?.meta as ProcedureMetadata | undefined;
92
98
  const requiredUserType = meta?.userType || "authenticated";
93
- const contractPermissions = meta?.permissions || [];
94
- const resourceAccessConfigs = meta?.resourceAccess || [];
99
+ const accessRules = meta?.access || [];
95
100
 
96
- // Prefix contract permissions with pluginId to get fully-qualified permission IDs
97
- // Contract defines: "catalog.read" -> Stored in DB as: "catalog.catalog.read"
98
- const requiredPermissions = contractPermissions.map((p: string) =>
99
- qualifyPermissionId(context.pluginMetadata, { id: p })
101
+ // Build qualified access rule IDs for each access rule
102
+ const qualifiedRules = accessRules.map((rule) => ({
103
+ ...rule,
104
+ qualifiedId: qualifyAccessRuleId(context.pluginMetadata, rule),
105
+ qualifiedResourceType: qualifyResourceType(
106
+ context.pluginMetadata.pluginId,
107
+ rule.resource
108
+ ),
109
+ }));
110
+
111
+ // Separate rules by type
112
+ const globalOnlyRules = qualifiedRules.filter((r) => !r.instanceAccess);
113
+ const instanceRules = qualifiedRules.filter((r) => r.instanceAccess);
114
+ const singleResourceRules = instanceRules.filter(
115
+ (r) => r.instanceAccess?.idParam
116
+ );
117
+ const listResourceRules = instanceRules.filter(
118
+ (r) => r.instanceAccess?.listKey
100
119
  );
101
120
 
102
- // 1. Handle anonymous endpoints - no auth required, no permission checks
121
+ // 1. Handle anonymous endpoints - no auth required, no access checks
103
122
  if (requiredUserType === "anonymous") {
104
123
  return next({});
105
124
  }
106
125
 
107
- // 2. Handle public endpoints - anyone can attempt, but permissions are checked
126
+ // 2. Handle public endpoints - anyone can attempt
108
127
  if (requiredUserType === "public") {
109
- if (context.user) {
110
- // Authenticated user or application - check their permissions
111
- if (
112
- (context.user.type === "user" ||
113
- context.user.type === "application") &&
114
- requiredPermissions.length > 0
115
- ) {
116
- const userPermissions = context.user.permissions || [];
117
- const hasPermission = requiredPermissions.some(
118
- (p: string) =>
119
- userPermissions.includes("*") || userPermissions.includes(p)
120
- );
121
-
122
- if (!hasPermission) {
123
- throw new ORPCError("FORBIDDEN", {
124
- message: `Missing permission: ${requiredPermissions.join(
125
- " or "
126
- )}`,
127
- });
128
+ const user = context.user;
129
+
130
+ // Check global-only rules based on user status
131
+ if (globalOnlyRules.length > 0) {
132
+ if (user && (user.type === "user" || user.type === "application")) {
133
+ // Authenticated user - check their global accesss
134
+ const userAccessRules = user.accessRules || [];
135
+ for (const rule of globalOnlyRules) {
136
+ const hasAccess =
137
+ userAccessRules.includes("*") ||
138
+ userAccessRules.includes(rule.qualifiedId);
139
+ if (!hasAccess) {
140
+ throw new ORPCError("FORBIDDEN", {
141
+ message: `Missing access: ${rule.qualifiedId}`,
142
+ });
143
+ }
128
144
  }
129
- }
130
- // Services are trusted with all permissions
131
- } else {
132
- // Anonymous user - check anonymous role permissions
133
- if (requiredPermissions.length > 0) {
134
- const anonymousPermissions =
135
- await context.auth.getAnonymousPermissions();
136
- const hasPermission = requiredPermissions.some((p: string) =>
137
- anonymousPermissions.includes(p)
138
- );
139
-
140
- if (!hasPermission) {
141
- throw new ORPCError("FORBIDDEN", {
142
- message: `Anonymous access not permitted for this resource`,
143
- });
145
+ } else if (user && user.type === "service") {
146
+ // Services are trusted
147
+ } else {
148
+ // Anonymous - check anonymous role
149
+ const anonymousAccessRules =
150
+ await context.auth.getAnonymousAccessRules();
151
+ for (const rule of globalOnlyRules) {
152
+ if (!anonymousAccessRules.includes(rule.qualifiedId)) {
153
+ throw new ORPCError("FORBIDDEN", {
154
+ message: `Anonymous access not permitted for this resource`,
155
+ });
156
+ }
144
157
  }
145
158
  }
146
159
  }
147
- return next({});
160
+
161
+ // For rules WITH instanceAccess on public endpoints:
162
+ // - If user is authenticated, check via S2S (step 6)
163
+ // - If anonymous, they get empty results from list filtering
164
+ // (since they have no team grants - S2S will filter everything)
165
+
166
+ // If there are no instance rules, we can return early for public endpoints
167
+ if (instanceRules.length === 0) {
168
+ return next({});
169
+ }
170
+ // Otherwise, fall through to step 6 for instance-level checks
148
171
  }
149
172
 
150
173
  // 3. Enforce authentication for user/service/authenticated types
151
- if (!context.user) {
174
+ if (requiredUserType !== "public" && !context.user) {
152
175
  throw new ORPCError("UNAUTHORIZED", {
153
176
  message: "Authentication required",
154
177
  });
@@ -157,84 +180,74 @@ export const autoAuthMiddleware = os.middleware(
157
180
  const user = context.user;
158
181
 
159
182
  // 4. Enforce user type
160
- if (requiredUserType === "user" && user.type !== "user") {
161
- throw new ORPCError("FORBIDDEN", {
162
- message: "This endpoint is for users only",
163
- });
164
- }
165
- if (requiredUserType === "service" && user.type !== "service") {
166
- throw new ORPCError("FORBIDDEN", {
167
- message: "This endpoint is for services only",
168
- });
169
- }
170
-
171
- // 5. Enforce permissions (for real users and applications)
172
- if (
173
- (user.type === "user" || user.type === "application") &&
174
- requiredPermissions.length > 0
175
- ) {
176
- const userPermissions = user.permissions || [];
177
- const hasPermission = requiredPermissions.some(
178
- (p: string) =>
179
- userPermissions.includes("*") || userPermissions.includes(p)
180
- );
181
-
182
- if (!hasPermission) {
183
+ if (user) {
184
+ if (requiredUserType === "user" && user.type !== "user") {
185
+ throw new ORPCError("FORBIDDEN", {
186
+ message: "This endpoint is for users only",
187
+ });
188
+ }
189
+ if (requiredUserType === "service" && user.type !== "service") {
183
190
  throw new ORPCError("FORBIDDEN", {
184
- message: `Missing permission: ${requiredPermissions.join(" or ")}`,
191
+ message: "This endpoint is for services only",
185
192
  });
186
193
  }
187
194
  }
188
195
 
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") {
196
+ // 5. Skip remaining checks for services - they are trusted
197
+ if (user?.type === "service") {
192
198
  return next({});
193
199
  }
194
200
 
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
- );
201
+ // 6. Check access rules (for real users, applications, and anonymous on public endpoints)
202
+ const userId = user?.id;
203
+ const userType = user?.type as "user" | "application" | undefined;
204
+ const userAccessRules = user?.accessRules || [];
205
+
206
+ // Check global-only rules (for non-public endpoints - public already handled above)
207
+ if (requiredUserType !== "public") {
208
+ for (const rule of globalOnlyRules) {
209
+ const hasAccess =
210
+ userAccessRules.includes("*") ||
211
+ userAccessRules.includes(rule.qualifiedId);
212
+ if (!hasAccess) {
213
+ throw new ORPCError("FORBIDDEN", {
214
+ message: `Missing access: ${rule.qualifiedId}`,
215
+ });
216
+ }
217
+ }
218
+ }
213
219
 
214
- // Pre-check: Single resource access
215
- for (const config of singleConfigs) {
216
- if (!config.idParam) continue;
217
- const resourceId = getNestedValue(input, config.idParam);
220
+ // Pre-check: Single resource access rules
221
+ // For these, user MUST have either global access OR team grant
222
+ for (const rule of singleResourceRules) {
223
+ const resourceId = getNestedValue(input, rule.instanceAccess!.idParam!);
218
224
  if (!resourceId) continue;
219
225
 
220
- const qualifiedType = qualifyResourceType(
221
- context.pluginMetadata.pluginId,
222
- config.resourceType
223
- );
226
+ // If no user (anonymous on public endpoint), deny access to single resources
227
+ // (they can't have team grants)
228
+ if (!userId || !userType) {
229
+ throw new ORPCError("FORBIDDEN", {
230
+ message: `Authentication required to access ${rule.resource}:${resourceId}`,
231
+ });
232
+ }
233
+
234
+ const hasGlobalAccess =
235
+ userAccessRules.includes("*") ||
236
+ userAccessRules.includes(rule.qualifiedId);
224
237
 
225
238
  const hasAccess = await checkResourceAccessViaS2S({
226
239
  auth: context.auth,
227
240
  userId,
228
241
  userType,
229
- resourceType: qualifiedType,
242
+ resourceType: rule.qualifiedResourceType,
230
243
  resourceId,
231
- action,
232
- hasGlobalPermission,
244
+ action: rule.level,
245
+ hasGlobalAccess,
233
246
  });
234
247
 
235
248
  if (!hasAccess) {
236
249
  throw new ORPCError("FORBIDDEN", {
237
- message: `Access denied to resource ${config.resourceType}:${resourceId}`,
250
+ message: `Access denied to resource ${rule.resource}:${resourceId}`,
238
251
  });
239
252
  }
240
253
  }
@@ -243,28 +256,21 @@ export const autoAuthMiddleware = os.middleware(
243
256
  const result = await next({});
244
257
 
245
258
  // Post-filter: List endpoints
259
+ // For these, return only resources user has access to (via global perm OR team grant)
246
260
  if (
247
- listConfigs.length > 0 &&
261
+ listResourceRules.length > 0 &&
248
262
  result.output &&
249
263
  typeof result.output === "object"
250
264
  ) {
251
265
  const mutableOutput = result.output as Record<string, unknown>;
252
266
 
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];
267
+ for (const rule of listResourceRules) {
268
+ const outputKey = rule.instanceAccess!.listKey!;
269
+ const items = mutableOutput[outputKey];
264
270
 
265
271
  if (items === undefined) {
266
272
  context.logger.error(
267
- `resourceAccess: expected "${config.outputKey}" in response but not found`
273
+ `resourceAccess: expected "${outputKey}" in response but not found`
268
274
  );
269
275
  throw new ORPCError("INTERNAL_SERVER_ERROR", {
270
276
  message: "Invalid response shape for filtered endpoint",
@@ -273,34 +279,52 @@ export const autoAuthMiddleware = os.middleware(
273
279
 
274
280
  if (!Array.isArray(items)) {
275
281
  context.logger.error(
276
- `resourceAccess: "${config.outputKey}" must be an array`
282
+ `resourceAccess: "${outputKey}" must be an array`
277
283
  );
278
284
  throw new ORPCError("INTERNAL_SERVER_ERROR", {
279
285
  message: "Invalid response shape for filtered endpoint",
280
286
  });
281
287
  }
282
288
 
283
- const qualifiedType = qualifyResourceType(
284
- context.pluginMetadata.pluginId,
285
- config.resourceType
286
- );
289
+ // If no user (anonymous), check if they have global access via anonymous role
290
+ if (!userId || !userType) {
291
+ // Anonymous users can't have team grants, but may have global access
292
+ const anonymousAccessRules =
293
+ await context.auth.getAnonymousAccessRules();
294
+ const hasGlobalAccess =
295
+ anonymousAccessRules.includes("*") ||
296
+ anonymousAccessRules.includes(rule.qualifiedId);
297
+
298
+ if (hasGlobalAccess) {
299
+ // Anonymous user has global access - return all items
300
+ continue;
301
+ } else {
302
+ // No global access and can't have team grants - return empty
303
+ mutableOutput[outputKey] = [];
304
+ continue;
305
+ }
306
+ }
287
307
 
288
308
  const resourceIds = items
289
309
  .map((item) => (item as { id?: string }).id)
290
310
  .filter((id): id is string => typeof id === "string");
291
311
 
312
+ const hasGlobalAccess =
313
+ userAccessRules.includes("*") ||
314
+ userAccessRules.includes(rule.qualifiedId);
315
+
292
316
  const accessibleIds = await getAccessibleResourceIdsViaS2S({
293
317
  auth: context.auth,
294
318
  userId,
295
319
  userType,
296
- resourceType: qualifiedType,
320
+ resourceType: rule.qualifiedResourceType,
297
321
  resourceIds,
298
- action,
299
- hasGlobalPermission,
322
+ action: rule.level,
323
+ hasGlobalAccess,
300
324
  });
301
325
 
302
326
  const accessibleSet = new Set(accessibleIds);
303
- mutableOutput[config.outputKey] = items.filter((item) => {
327
+ mutableOutput[outputKey] = items.filter((item) => {
304
328
  const id = (item as { id?: string }).id;
305
329
  return id && accessibleSet.has(id);
306
330
  });
@@ -325,15 +349,6 @@ function getNestedValue(obj: unknown, path: string): string | undefined {
325
349
  return typeof current === "string" ? current : undefined;
326
350
  }
327
351
 
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
352
  /**
338
353
  * Check resource access via auth service S2S endpoint.
339
354
  */
@@ -344,7 +359,7 @@ async function checkResourceAccessViaS2S({
344
359
  resourceType,
345
360
  resourceId,
346
361
  action,
347
- hasGlobalPermission,
362
+ hasGlobalAccess,
348
363
  }: {
349
364
  auth: AuthService;
350
365
  userId: string;
@@ -352,7 +367,7 @@ async function checkResourceAccessViaS2S({
352
367
  resourceType: string;
353
368
  resourceId: string;
354
369
  action: "read" | "manage";
355
- hasGlobalPermission: boolean;
370
+ hasGlobalAccess: boolean;
356
371
  }): Promise<boolean> {
357
372
  try {
358
373
  const result = await auth.checkResourceTeamAccess({
@@ -361,12 +376,12 @@ async function checkResourceAccessViaS2S({
361
376
  resourceType,
362
377
  resourceId,
363
378
  action,
364
- hasGlobalPermission,
379
+ hasGlobalAccess,
365
380
  });
366
381
  return result.hasAccess;
367
382
  } catch {
368
- // If team access check fails (e.g., service not available), fall back to global permission
369
- return hasGlobalPermission;
383
+ // If team access check fails (e.g., service not available), fall back to global access
384
+ return hasGlobalAccess;
370
385
  }
371
386
  }
372
387
 
@@ -380,7 +395,7 @@ async function getAccessibleResourceIdsViaS2S({
380
395
  resourceType,
381
396
  resourceIds,
382
397
  action,
383
- hasGlobalPermission,
398
+ hasGlobalAccess,
384
399
  }: {
385
400
  auth: AuthService;
386
401
  userId: string;
@@ -388,7 +403,7 @@ async function getAccessibleResourceIdsViaS2S({
388
403
  resourceType: string;
389
404
  resourceIds: string[];
390
405
  action: "read" | "manage";
391
- hasGlobalPermission: boolean;
406
+ hasGlobalAccess: boolean;
392
407
  }): Promise<string[]> {
393
408
  if (resourceIds.length === 0) return [];
394
409
 
@@ -399,11 +414,11 @@ async function getAccessibleResourceIdsViaS2S({
399
414
  resourceType,
400
415
  resourceIds,
401
416
  action,
402
- hasGlobalPermission,
417
+ hasGlobalAccess,
403
418
  });
404
419
  } catch {
405
- // If team access check fails, fall back to global permission behavior
406
- return hasGlobalPermission ? resourceIds : [];
420
+ // If team access check fails, fall back to global access behavior
421
+ return hasGlobalAccess ? resourceIds : [];
407
422
  }
408
423
  }
409
424
 
@@ -417,16 +432,16 @@ async function getAccessibleResourceIdsViaS2S({
417
432
  * All plugin contracts should use this builder. It ensures that:
418
433
  * 1. All procedures are authenticated by default
419
434
  * 2. User type is enforced based on meta.userType
420
- * 3. Permissions are enforced based on meta.permissions
435
+ * 3. Access rules are enforced based on meta.access
421
436
  *
422
437
  * @example
423
438
  * import { baseContractBuilder } from "@checkstack/backend-api";
424
- * import { permissions } from "./permissions";
439
+ * import { access } from "./access";
425
440
  *
426
441
  * const myContract = {
427
- * // User-only endpoint with specific permission
442
+ * // User-only endpoint with specific access rule
428
443
  * getItems: baseContractBuilder
429
- * .meta({ userType: "user", permissions: [permissions.myPluginRead.id] })
444
+ * .meta({ userType: "user", accessRules: [access.myPluginRead.id] })
430
445
  * .output(z.array(ItemSchema)),
431
446
  *
432
447
  * // Service-only endpoint (backend-to-backend)
package/src/test-utils.ts CHANGED
@@ -34,7 +34,7 @@ export function createMockRpcContext(
34
34
  auth: {
35
35
  authenticate: mock(),
36
36
  getCredentials: mock().mockResolvedValue({ headers: {} }),
37
- getAnonymousPermissions: mock().mockResolvedValue([]),
37
+ getAnonymousAccessRules: mock().mockResolvedValue([]),
38
38
  checkResourceTeamAccess: mock().mockResolvedValue({ hasAccess: true }),
39
39
  getAccessibleResourceIds: mock().mockImplementation(
40
40
  (params: { resourceIds: string[] }) =>
package/src/types.ts CHANGED
@@ -22,14 +22,14 @@ export interface Fetch {
22
22
 
23
23
  /**
24
24
  * Real user authenticated via session/token (human users).
25
- * Has permissions and roles from the RBAC system.
25
+ * Has access rules and roles from the RBAC system.
26
26
  */
27
27
  export interface RealUser {
28
28
  type: "user";
29
29
  id: string;
30
30
  email?: string;
31
31
  name?: string;
32
- permissions?: string[];
32
+ accessRules?: string[];
33
33
  roles?: string[];
34
34
  teamIds?: string[];
35
35
  [key: string]: unknown;
@@ -37,7 +37,7 @@ export interface RealUser {
37
37
 
38
38
  /**
39
39
  * Service user for backend-to-backend calls.
40
- * Trusted implicitly - no permissions/roles needed.
40
+ * Trusted implicitly - no accesss/roles needed.
41
41
  */
42
42
  export interface ServiceUser {
43
43
  type: "service";
@@ -46,13 +46,13 @@ export interface ServiceUser {
46
46
 
47
47
  /**
48
48
  * External application authenticated via API key.
49
- * Has permissions and roles from the RBAC system like RealUser.
49
+ * Has access rules and roles from the RBAC system like RealUser.
50
50
  */
51
51
  export interface ApplicationUser {
52
52
  type: "application";
53
53
  id: string;
54
54
  name: string;
55
- permissions?: string[];
55
+ accessRules?: string[];
56
56
  roles?: string[];
57
57
  teamIds?: string[];
58
58
  }
@@ -67,11 +67,11 @@ export interface AuthService {
67
67
  authenticate(request: Request): Promise<AuthUser | undefined>;
68
68
  getCredentials(): Promise<{ headers: Record<string, string> }>;
69
69
  /**
70
- * Get permissions assigned to the anonymous role.
71
- * Used by autoAuthMiddleware to check permissions for unauthenticated
70
+ * Get access rules assigned to the anonymous role.
71
+ * Used by autoAuthMiddleware to check accesss for unauthenticated
72
72
  * users on "public" userType endpoints.
73
73
  */
74
- getAnonymousPermissions(): Promise<string[]>;
74
+ getAnonymousAccessRules(): Promise<string[]>;
75
75
  /**
76
76
  * Check if a user has access to a specific resource via team grants.
77
77
  */
@@ -81,7 +81,7 @@ export interface AuthService {
81
81
  resourceType: string;
82
82
  resourceId: string;
83
83
  action: "read" | "manage";
84
- hasGlobalPermission: boolean;
84
+ hasGlobalAccess: boolean;
85
85
  }): Promise<{ hasAccess: boolean }>;
86
86
  /**
87
87
  * Get IDs of resources the user can access from a given list.
@@ -93,7 +93,7 @@ export interface AuthService {
93
93
  resourceType: string;
94
94
  resourceIds: string[];
95
95
  action: "read" | "manage";
96
- hasGlobalPermission: boolean;
96
+ hasGlobalAccess: boolean;
97
97
  }): Promise<string[]>;
98
98
  }
99
99
 
@@ -113,7 +113,7 @@ export interface PluginInstaller {
113
113
  * Options for declarative route definitions (Deprecated, will be replaced by oRPC procedures).
114
114
  */
115
115
  export interface RouteOptions {
116
- permission?: string | string[];
116
+ accessRule?: string | string[];
117
117
  schema?: ZodSchema;
118
118
  }
119
119