@checkstack/backend-api 0.4.0 → 0.4.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,18 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 8a87cd4: Fixed anonymous user access to public endpoints with instance-level access rules
8
+
9
+ The RPC middleware now correctly checks if anonymous users have global access via the anonymous role before denying access to single-resource public endpoints. Also added support for contract-level `instanceAccess` override allowing bulk endpoints to share the same access rule as single endpoints.
10
+
11
+ - Updated dependencies [8a87cd4]
12
+ - @checkstack/common@0.5.0
13
+ - @checkstack/queue-api@0.1.3
14
+ - @checkstack/signal-common@0.1.3
15
+
3
16
  ## 0.4.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "scripts": {
package/src/rpc.test.ts CHANGED
@@ -35,14 +35,17 @@ const testContracts = {
35
35
  access: [
36
36
  accessPair(
37
37
  "system",
38
- { read: "View systems", manage: "Manage systems" },
39
- { listKey: "systems", readIsPublic: true }
38
+ {
39
+ read: { description: "View systems", isPublic: true },
40
+ manage: { description: "Manage systems" },
41
+ },
42
+ { listKey: "systems" },
40
43
  ).read,
41
44
  ],
42
45
  }).output(
43
46
  z.object({
44
47
  systems: z.array(z.object({ id: z.string(), name: z.string() })),
45
- })
48
+ }),
46
49
  ),
47
50
 
48
51
  // Authenticated endpoint
@@ -73,8 +76,11 @@ const testContracts = {
73
76
  access: [
74
77
  accessPair(
75
78
  "system",
76
- { read: "View systems", manage: "Manage systems" },
77
- { idParam: "systemId", readIsPublic: true }
79
+ {
80
+ read: { description: "View systems", isPublic: true },
81
+ manage: { description: "Manage systems" },
82
+ },
83
+ { idParam: "systemId" },
78
84
  ).read,
79
85
  ],
80
86
  })
@@ -96,7 +102,7 @@ const testContracts = {
96
102
  .output(
97
103
  z.object({
98
104
  statuses: z.record(z.string(), z.object({ status: z.string() })),
99
- })
105
+ }),
100
106
  ),
101
107
 
102
108
  // Mutation endpoint
@@ -107,6 +113,33 @@ const testContracts = {
107
113
  })
108
114
  .input(z.object({ name: z.string() }))
109
115
  .output(z.object({ id: z.string() })),
116
+
117
+ // Bulk record endpoint using instanceAccess OVERRIDE at contract level
118
+ // This tests the pattern where bulk endpoints share the same access rule
119
+ // as single endpoints but use recordKey instead of idParam
120
+ bulkRecordWithOverride: proc({
121
+ userType: "public",
122
+ operationType: "query",
123
+ // Uses same access rule as singleResourceEndpoint (has idParam)
124
+ // but overrides instanceAccess to use recordKey
125
+ access: [
126
+ accessPair(
127
+ "system",
128
+ {
129
+ read: { description: "View systems", isPublic: true },
130
+ manage: { description: "Manage systems" },
131
+ },
132
+ { idParam: "systemId" },
133
+ ).read,
134
+ ],
135
+ instanceAccess: { recordKey: "statuses" },
136
+ })
137
+ .input(z.object({ systemIds: z.array(z.string()) }))
138
+ .output(
139
+ z.object({
140
+ statuses: z.record(z.string(), z.object({ status: z.string() })),
141
+ }),
142
+ ),
110
143
  };
111
144
 
112
145
  // =============================================================================
@@ -121,7 +154,7 @@ const testImplementations = {
121
154
  publicGlobalEndpoint: implement(testContracts.publicGlobalEndpoint).handler(
122
155
  () => ({
123
156
  message: "Hello from public global",
124
- })
157
+ }),
125
158
  ),
126
159
 
127
160
  publicListEndpoint: implement(testContracts.publicListEndpoint).handler(
@@ -131,13 +164,13 @@ const testImplementations = {
131
164
  { id: "system-2", name: "System 2" },
132
165
  { id: "system-3", name: "System 3" },
133
166
  ],
134
- })
167
+ }),
135
168
  ),
136
169
 
137
170
  authenticatedEndpoint: implement(testContracts.authenticatedEndpoint).handler(
138
171
  () => ({
139
172
  message: "Hello from authenticated",
140
- })
173
+ }),
141
174
  ),
142
175
 
143
176
  userOnlyEndpoint: implement(testContracts.userOnlyEndpoint).handler(() => ({
@@ -147,11 +180,11 @@ const testImplementations = {
147
180
  serviceOnlyEndpoint: implement(testContracts.serviceOnlyEndpoint).handler(
148
181
  () => ({
149
182
  message: "Hello from service",
150
- })
183
+ }),
151
184
  ),
152
185
 
153
186
  singleResourceEndpoint: implement(
154
- testContracts.singleResourceEndpoint
187
+ testContracts.singleResourceEndpoint,
155
188
  ).handler(({ input }) => ({
156
189
  system: { id: input.systemId },
157
190
  })),
@@ -159,14 +192,22 @@ const testImplementations = {
159
192
  recordEndpoint: implement(testContracts.recordEndpoint).handler(
160
193
  ({ input }) => ({
161
194
  statuses: Object.fromEntries(
162
- input.systemIds.map((id) => [id, { status: "ok" }])
195
+ input.systemIds.map((id) => [id, { status: "ok" }]),
163
196
  ),
164
- })
197
+ }),
165
198
  ),
166
199
 
167
200
  mutationEndpoint: implement(testContracts.mutationEndpoint).handler(() => ({
168
201
  id: "new-id",
169
202
  })),
203
+
204
+ bulkRecordWithOverride: implement(
205
+ testContracts.bulkRecordWithOverride,
206
+ ).handler(({ input }) => ({
207
+ statuses: Object.fromEntries(
208
+ input.systemIds.map((id) => [id, { status: "ok" }]),
209
+ ),
210
+ })),
170
211
  };
171
212
 
172
213
  // =============================================================================
@@ -229,7 +270,7 @@ describe("autoAuthMiddleware", () => {
229
270
  .handler(() => ({ message: "success" }));
230
271
 
231
272
  expect(
232
- call(procedure, undefined, { context: contextWithNoAccess })
273
+ call(procedure, undefined, { context: contextWithNoAccess }),
233
274
  ).rejects.toThrow();
234
275
  });
235
276
 
@@ -283,7 +324,7 @@ describe("autoAuthMiddleware", () => {
283
324
  expect(
284
325
  call(procedure, undefined, {
285
326
  context: { ...mockContext, user: undefined },
286
- })
327
+ }),
287
328
  ).rejects.toThrow("Authentication required");
288
329
  });
289
330
  });
@@ -316,7 +357,7 @@ describe("autoAuthMiddleware", () => {
316
357
  ...mockContext,
317
358
  user: { type: "service" as const, pluginId: "test-service" },
318
359
  },
319
- })
360
+ }),
320
361
  ).rejects.toThrow("This endpoint is for users only");
321
362
  });
322
363
  });
@@ -349,7 +390,7 @@ describe("autoAuthMiddleware", () => {
349
390
  .handler(() => ({ message: "success" }));
350
391
 
351
392
  expect(
352
- call(procedure, undefined, { context: mockContext })
393
+ call(procedure, undefined, { context: mockContext }),
353
394
  ).rejects.toThrow("This endpoint is for services only");
354
395
  });
355
396
  });
@@ -368,7 +409,7 @@ describe("autoAuthMiddleware", () => {
368
409
  const result = await call(
369
410
  procedure,
370
411
  { systemId: "test-123" },
371
- { context: mockContext }
412
+ { context: mockContext },
372
413
  );
373
414
 
374
415
  expect(result).toEqual({ system: { id: "test-123" } });
@@ -394,10 +435,62 @@ describe("autoAuthMiddleware", () => {
394
435
  call(
395
436
  procedure,
396
437
  { systemId: "forbidden-id" },
397
- { context: contextWithNoAccess }
398
- )
438
+ { context: contextWithNoAccess },
439
+ ),
399
440
  ).rejects.toThrow();
400
441
  });
442
+
443
+ // Regression tests for anonymous user access to single resources (issue: 403 for public endpoints)
444
+ it("should allow anonymous users with global access to single resource endpoints", async () => {
445
+ // Mock anonymous access rules to include the required access
446
+ const contextWithAnonymousAccess = {
447
+ ...mockContext,
448
+ user: undefined,
449
+ auth: {
450
+ ...mockContext.auth,
451
+ getAnonymousAccessRules: () =>
452
+ Promise.resolve(["test-plugin.system.read"]),
453
+ },
454
+ };
455
+
456
+ const procedure = implement(testContracts.singleResourceEndpoint)
457
+ .$context<RpcContext>()
458
+ .use(autoAuthMiddleware)
459
+ .handler(({ input }) => ({ system: { id: input.systemId } }));
460
+
461
+ const result = await call(
462
+ procedure,
463
+ { systemId: "system-123" },
464
+ { context: contextWithAnonymousAccess },
465
+ );
466
+
467
+ expect(result).toEqual({ system: { id: "system-123" } });
468
+ });
469
+
470
+ it("should deny anonymous users without global access to single resource endpoints", async () => {
471
+ // Mock anonymous access rules to NOT include the required access
472
+ const contextWithoutAnonymousAccess = {
473
+ ...mockContext,
474
+ user: undefined,
475
+ auth: {
476
+ ...mockContext.auth,
477
+ getAnonymousAccessRules: () => Promise.resolve([]),
478
+ },
479
+ };
480
+
481
+ const procedure = implement(testContracts.singleResourceEndpoint)
482
+ .$context<RpcContext>()
483
+ .use(autoAuthMiddleware)
484
+ .handler(({ input }) => ({ system: { id: input.systemId } }));
485
+
486
+ expect(
487
+ call(
488
+ procedure,
489
+ { systemId: "system-123" },
490
+ { context: contextWithoutAnonymousAccess },
491
+ ),
492
+ ).rejects.toThrow("Authentication required to access");
493
+ });
401
494
  });
402
495
 
403
496
  // ---------------------------------------------------------------------------
@@ -433,17 +526,85 @@ describe("autoAuthMiddleware", () => {
433
526
  .use(autoAuthMiddleware)
434
527
  .handler(({ input }) => ({
435
528
  statuses: Object.fromEntries(
436
- input.systemIds.map((id) => [id, { status: "ok" }])
529
+ input.systemIds.map((id) => [id, { status: "ok" }]),
437
530
  ),
438
531
  }));
439
532
 
440
533
  const result = await call(
441
534
  procedure,
442
535
  { systemIds: ["sys-1", "sys-2"] },
443
- { context: mockContext }
536
+ { context: mockContext },
444
537
  );
445
538
 
446
539
  expect(Object.keys(result.statuses)).toHaveLength(2);
447
540
  });
448
541
  });
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // Contract-level instanceAccess Override
545
+ // ---------------------------------------------------------------------------
546
+
547
+ describe("instanceAccess override at contract level", () => {
548
+ it("should use contract-level instanceAccess instead of access rule instanceAccess", async () => {
549
+ // This test verifies that bulk endpoints can share the same access rule
550
+ // as single endpoints, using instanceAccess override at contract level
551
+ const contextWithAnonymousAccess = {
552
+ ...mockContext,
553
+ user: undefined,
554
+ auth: {
555
+ ...mockContext.auth,
556
+ getAnonymousAccessRules: () =>
557
+ Promise.resolve(["test-plugin.system.read"]),
558
+ },
559
+ };
560
+
561
+ const procedure = implement(testContracts.bulkRecordWithOverride)
562
+ .$context<RpcContext>()
563
+ .use(autoAuthMiddleware)
564
+ .handler(({ input }) => ({
565
+ statuses: Object.fromEntries(
566
+ input.systemIds.map((id) => [id, { status: "ok" }]),
567
+ ),
568
+ }));
569
+
570
+ // Should use recordKey filtering (from contract override),
571
+ // not idParam check (from access rule)
572
+ const result = await call(
573
+ procedure,
574
+ { systemIds: ["sys-1", "sys-2"] },
575
+ { context: contextWithAnonymousAccess },
576
+ );
577
+
578
+ expect(Object.keys(result.statuses)).toHaveLength(2);
579
+ });
580
+
581
+ it("should deny anonymous users without access on override endpoints", async () => {
582
+ const contextWithoutAccess = {
583
+ ...mockContext,
584
+ user: undefined,
585
+ auth: {
586
+ ...mockContext.auth,
587
+ getAnonymousAccessRules: () => Promise.resolve([]),
588
+ },
589
+ };
590
+
591
+ const procedure = implement(testContracts.bulkRecordWithOverride)
592
+ .$context<RpcContext>()
593
+ .use(autoAuthMiddleware)
594
+ .handler(({ input }) => ({
595
+ statuses: Object.fromEntries(
596
+ input.systemIds.map((id) => [id, { status: "ok" }]),
597
+ ),
598
+ }));
599
+
600
+ // Without access, should return empty record (filtered out)
601
+ const result = await call(
602
+ procedure,
603
+ { systemIds: ["sys-1", "sys-2"] },
604
+ { context: contextWithoutAccess },
605
+ );
606
+
607
+ expect(Object.keys(result.statuses)).toHaveLength(0);
608
+ });
609
+ });
449
610
  });
package/src/rpc.ts CHANGED
@@ -97,14 +97,19 @@ export const autoAuthMiddleware = os.middleware(
97
97
  const meta = procedure["~orpc"]?.meta as ProcedureMetadata | undefined;
98
98
  const requiredUserType = meta?.userType || "authenticated";
99
99
  const accessRules = meta?.access || [];
100
+ // Contract-level instanceAccess override (used for bulk endpoints)
101
+ const instanceAccessOverride = meta?.instanceAccess;
100
102
 
101
103
  // Build qualified access rule IDs for each access rule
104
+ // If contract has instanceAccess override, apply it to ALL rules
102
105
  const qualifiedRules = accessRules.map((rule) => ({
103
106
  ...rule,
107
+ // Use contract-level override if provided, otherwise use rule's config
108
+ instanceAccess: instanceAccessOverride ?? rule.instanceAccess,
104
109
  qualifiedId: qualifyAccessRuleId(context.pluginMetadata, rule),
105
110
  qualifiedResourceType: qualifyResourceType(
106
111
  context.pluginMetadata.pluginId,
107
- rule.resource
112
+ rule.resource,
108
113
  ),
109
114
  }));
110
115
 
@@ -115,13 +120,13 @@ export const autoAuthMiddleware = os.middleware(
115
120
  (r) =>
116
121
  r.instanceAccess?.idParam &&
117
122
  !r.instanceAccess?.listKey &&
118
- !r.instanceAccess?.recordKey
123
+ !r.instanceAccess?.recordKey,
119
124
  );
120
125
  const listResourceRules = instanceRules.filter(
121
- (r) => r.instanceAccess?.listKey
126
+ (r) => r.instanceAccess?.listKey,
122
127
  );
123
128
  const recordResourceRules = instanceRules.filter(
124
- (r) => r.instanceAccess?.recordKey
129
+ (r) => r.instanceAccess?.recordKey,
125
130
  );
126
131
 
127
132
  // 1. Handle anonymous endpoints - no auth required, no access checks
@@ -229,9 +234,20 @@ export const autoAuthMiddleware = os.middleware(
229
234
  const resourceId = getNestedValue(input, rule.instanceAccess!.idParam!);
230
235
  if (!resourceId) continue;
231
236
 
232
- // If no user (anonymous on public endpoint), deny access to single resources
233
- // (they can't have team grants)
237
+ // If no user (anonymous on public endpoint), check if anonymous role has global access
234
238
  if (!userId || !userType) {
239
+ const anonymousAccessRules =
240
+ await context.auth.getAnonymousAccessRules();
241
+ const hasGlobalAccess =
242
+ anonymousAccessRules.includes("*") ||
243
+ anonymousAccessRules.includes(rule.qualifiedId);
244
+
245
+ if (hasGlobalAccess) {
246
+ // Anonymous user has global access - allow access to this resource
247
+ continue;
248
+ }
249
+
250
+ // No global access and can't have team grants - deny access
235
251
  throw new ORPCError("FORBIDDEN", {
236
252
  message: `Authentication required to access ${rule.resource}:${resourceId}`,
237
253
  });
@@ -276,7 +292,7 @@ export const autoAuthMiddleware = os.middleware(
276
292
 
277
293
  if (items === undefined) {
278
294
  context.logger.error(
279
- `resourceAccess: expected "${outputKey}" in response but not found`
295
+ `resourceAccess: expected "${outputKey}" in response but not found`,
280
296
  );
281
297
  throw new ORPCError("INTERNAL_SERVER_ERROR", {
282
298
  message: "Invalid response shape for filtered endpoint",
@@ -285,7 +301,7 @@ export const autoAuthMiddleware = os.middleware(
285
301
 
286
302
  if (!Array.isArray(items)) {
287
303
  context.logger.error(
288
- `resourceAccess: "${outputKey}" must be an array`
304
+ `resourceAccess: "${outputKey}" must be an array`,
289
305
  );
290
306
  throw new ORPCError("INTERNAL_SERVER_ERROR", {
291
307
  message: "Invalid response shape for filtered endpoint",
@@ -352,7 +368,7 @@ export const autoAuthMiddleware = os.middleware(
352
368
 
353
369
  if (record === undefined) {
354
370
  context.logger.error(
355
- `resourceAccess: expected "${outputKey}" in response but not found`
371
+ `resourceAccess: expected "${outputKey}" in response but not found`,
356
372
  );
357
373
  throw new ORPCError("INTERNAL_SERVER_ERROR", {
358
374
  message: "Invalid response shape for filtered endpoint",
@@ -365,7 +381,7 @@ export const autoAuthMiddleware = os.middleware(
365
381
  Array.isArray(record)
366
382
  ) {
367
383
  context.logger.error(
368
- `resourceAccess: "${outputKey}" must be an object (record)`
384
+ `resourceAccess: "${outputKey}" must be an object (record)`,
369
385
  );
370
386
  throw new ORPCError("INTERNAL_SERVER_ERROR", {
371
387
  message: "Invalid response shape for filtered endpoint",
@@ -419,7 +435,7 @@ export const autoAuthMiddleware = os.middleware(
419
435
  }
420
436
 
421
437
  return result;
422
- }
438
+ },
423
439
  );
424
440
 
425
441
  /**
@@ -562,7 +578,7 @@ export interface RpcService {
562
578
  */
563
579
  registerRouter<C extends AnyContractRouter>(
564
580
  router: Router<C, RpcContext>,
565
- contract: C
581
+ contract: C,
566
582
  ): void;
567
583
 
568
584
  /**
@@ -574,7 +590,7 @@ export interface RpcService {
574
590
  */
575
591
  registerHttpHandler(
576
592
  handler: (req: Request) => Promise<Response>,
577
- path?: string
593
+ path?: string,
578
594
  ): void;
579
595
  }
580
596