@checkstack/backend-api 0.3.0 → 0.3.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 CHANGED
@@ -1,5 +1,72 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 7a23261: ## TanStack Query Integration
8
+
9
+ Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
10
+
11
+ ### New Features
12
+
13
+ - **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
14
+ - **Automatic request deduplication**: Multiple components requesting the same data share a single network request
15
+ - **Built-in caching**: Configurable stale time and cache duration per query
16
+ - **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
17
+ - **Background refetching**: Stale data is automatically refreshed when components mount
18
+
19
+ ### Contract Changes
20
+
21
+ All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
22
+
23
+ ```typescript
24
+ const getItems = proc()
25
+ .meta({ operationType: "query", access: [access.read] })
26
+ .output(z.array(itemSchema))
27
+ .query();
28
+
29
+ const createItem = proc()
30
+ .meta({ operationType: "mutation", access: [access.manage] })
31
+ .input(createItemSchema)
32
+ .output(itemSchema)
33
+ .mutation();
34
+ ```
35
+
36
+ ### Migration
37
+
38
+ ```typescript
39
+ // Before (forPlugin pattern)
40
+ const api = useApi(myPluginApiRef);
41
+ const [items, setItems] = useState<Item[]>([]);
42
+ useEffect(() => {
43
+ api.getItems().then(setItems);
44
+ }, [api]);
45
+
46
+ // After (usePluginClient pattern)
47
+ const client = usePluginClient(MyPluginApi);
48
+ const { data: items, isLoading } = client.getItems.useQuery({});
49
+ ```
50
+
51
+ ### Bug Fixes
52
+
53
+ - Fixed `rpc.test.ts` test setup for middleware type inference
54
+ - Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
55
+ - Fixed null→undefined warnings in notification and queue frontends
56
+
57
+ - Updated dependencies [180be38]
58
+ - Updated dependencies [7a23261]
59
+ - @checkstack/queue-api@0.1.0
60
+ - @checkstack/common@0.3.0
61
+ - @checkstack/signal-common@0.1.1
62
+
63
+ ## 0.3.1
64
+
65
+ ### Patch Changes
66
+
67
+ - Updated dependencies [9a27800]
68
+ - @checkstack/queue-api@0.0.6
69
+
3
70
  ## 0.3.0
4
71
 
5
72
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "scripts": {
@@ -1,6 +1,5 @@
1
1
  import { z } from "zod";
2
- import { oc } from "@orpc/contract";
3
- import { access, type ProcedureMetadata } from "@checkstack/common";
2
+ import { access, proc } from "@checkstack/common";
4
3
 
5
4
  // ─────────────────────────────────────────────────────────────────────────────
6
5
  // Access Rules
@@ -20,17 +19,15 @@ export const pluginAdminAccessRules = [
20
19
  // Contract
21
20
  // ─────────────────────────────────────────────────────────────────────────────
22
21
 
23
- const _base = oc.$meta<ProcedureMetadata>({});
24
-
25
22
  export const pluginAdminContract = {
26
23
  /**
27
24
  * Install a plugin from npm and load it across all instances.
28
25
  */
29
- install: _base
30
- .meta({
31
- userType: "user",
32
- access: [pluginAdminAccess.install],
33
- })
26
+ install: proc({
27
+ operationType: "mutation",
28
+ userType: "user",
29
+ access: [pluginAdminAccess.install],
30
+ })
34
31
  .input(
35
32
  z.object({
36
33
  packageName: z.string().min(1, "Package name is required"),
@@ -47,11 +44,11 @@ export const pluginAdminContract = {
47
44
  /**
48
45
  * Deregister a plugin across all instances.
49
46
  */
50
- deregister: _base
51
- .meta({
52
- userType: "user",
53
- access: [pluginAdminAccess.deregister],
54
- })
47
+ deregister: proc({
48
+ operationType: "mutation",
49
+ userType: "user",
50
+ access: [pluginAdminAccess.deregister],
51
+ })
55
52
  .input(
56
53
  z.object({
57
54
  pluginId: z.string().min(1, "Plugin ID is required"),
package/src/rpc.test.ts CHANGED
@@ -1,530 +1,449 @@
1
1
  import { describe, expect, it, mock, beforeEach, type Mock } from "bun:test";
2
- import { oc } from "@orpc/contract";
3
2
  import { call, implement } from "@orpc/server";
4
3
  import { z } from "zod";
5
4
  import { autoAuthMiddleware, RpcContext } from "./rpc";
6
5
  import { createMockRpcContext } from "./test-utils";
7
- import { access, accessPair, ProcedureMetadata } from "@checkstack/common";
6
+ import { access, accessPair, proc } from "@checkstack/common";
8
7
 
9
8
  // =============================================================================
10
9
  // TEST CONTRACT DEFINITIONS
11
10
  // =============================================================================
12
11
 
13
- const _base = oc.$meta<ProcedureMetadata>({});
14
-
15
12
  /**
16
13
  * Test contracts for different access patterns.
14
+ * All use proc() helper with required operationType.
17
15
  */
18
16
  const testContracts = {
19
17
  // Anonymous endpoint - no auth required
20
- anonymousEndpoint: _base
21
- .meta({ userType: "anonymous" })
22
- .output(z.object({ message: z.string() })),
18
+ anonymousEndpoint: proc({
19
+ userType: "anonymous",
20
+ operationType: "query",
21
+ access: [],
22
+ }).output(z.object({ message: z.string() })),
23
23
 
24
24
  // Public endpoint with global access rules only (no instance access)
25
- publicGlobalEndpoint: _base
26
- .meta({
27
- userType: "public",
28
- access: [access("resource", "read", "Test access")],
29
- })
30
- .output(z.object({ message: z.string() })),
25
+ publicGlobalEndpoint: proc({
26
+ userType: "public",
27
+ operationType: "query",
28
+ access: [access("resource", "read", "Test access")],
29
+ }).output(z.object({ message: z.string() })),
31
30
 
32
31
  // Public endpoint with list filtering
33
- publicListEndpoint: _base
34
- .meta({
35
- userType: "public",
36
- access: [
37
- accessPair(
38
- "system",
39
- { read: "View systems", manage: "Manage systems" },
40
- { listKey: "systems", readIsPublic: true }
41
- ).read,
42
- ],
32
+ publicListEndpoint: proc({
33
+ userType: "public",
34
+ operationType: "query",
35
+ access: [
36
+ accessPair(
37
+ "system",
38
+ { read: "View systems", manage: "Manage systems" },
39
+ { listKey: "systems", readIsPublic: true }
40
+ ).read,
41
+ ],
42
+ }).output(
43
+ z.object({
44
+ systems: z.array(z.object({ id: z.string(), name: z.string() })),
43
45
  })
44
- .output(
45
- z.object({
46
- systems: z.array(z.object({ id: z.string(), name: z.string() })),
47
- })
48
- ),
46
+ ),
49
47
 
50
48
  // Authenticated endpoint
51
- authenticatedEndpoint: _base
52
- .meta({ userType: "authenticated" })
53
- .output(z.object({ message: z.string() })),
49
+ authenticatedEndpoint: proc({
50
+ userType: "authenticated",
51
+ operationType: "query",
52
+ access: [],
53
+ }).output(z.object({ message: z.string() })),
54
54
 
55
55
  // User-only endpoint
56
- userOnlyEndpoint: _base
57
- .meta({ userType: "user" })
58
- .output(z.object({ message: z.string() })),
56
+ userOnlyEndpoint: proc({
57
+ userType: "user",
58
+ operationType: "query",
59
+ access: [],
60
+ }).output(z.object({ message: z.string() })),
59
61
 
60
62
  // Service-only endpoint
61
- serviceOnlyEndpoint: _base
62
- .meta({ userType: "service" })
63
- .output(z.object({ message: z.string() })),
63
+ serviceOnlyEndpoint: proc({
64
+ userType: "service",
65
+ operationType: "query",
66
+ access: [],
67
+ }).output(z.object({ message: z.string() })),
64
68
 
65
69
  // Single resource endpoint with idParam
66
- singleResourceEndpoint: _base
67
- .meta({
68
- userType: "public",
69
- access: [
70
- accessPair(
71
- "system",
72
- { read: "View systems", manage: "Manage systems" },
73
- { idParam: "systemId", readIsPublic: true }
74
- ).read,
75
- ],
76
- })
70
+ singleResourceEndpoint: proc({
71
+ userType: "public",
72
+ operationType: "query",
73
+ access: [
74
+ accessPair(
75
+ "system",
76
+ { read: "View systems", manage: "Manage systems" },
77
+ { idParam: "systemId", readIsPublic: true }
78
+ ).read,
79
+ ],
80
+ })
77
81
  .input(z.object({ systemId: z.string() }))
78
82
  .output(z.object({ system: z.object({ id: z.string() }).nullable() })),
83
+
84
+ // Bulk record endpoint with recordKey (like getBulkSystemHealthStatus)
85
+ recordEndpoint: proc({
86
+ userType: "public",
87
+ operationType: "query",
88
+ access: [
89
+ access("bulk", "read", "Bulk read", {
90
+ recordKey: "statuses",
91
+ isPublic: true,
92
+ }),
93
+ ],
94
+ })
95
+ .input(z.object({ systemIds: z.array(z.string()) }))
96
+ .output(
97
+ z.object({
98
+ statuses: z.record(z.string(), z.object({ status: z.string() })),
99
+ })
100
+ ),
101
+
102
+ // Mutation endpoint
103
+ mutationEndpoint: proc({
104
+ userType: "authenticated",
105
+ operationType: "mutation",
106
+ access: [],
107
+ })
108
+ .input(z.object({ name: z.string() }))
109
+ .output(z.object({ id: z.string() })),
79
110
  };
80
111
 
81
112
  // =============================================================================
82
- // TEST ROUTER IMPLEMENTATION
113
+ // TEST IMPLEMENTATIONS
83
114
  // =============================================================================
84
115
 
85
- /**
86
- * Create the test router using implement + contract pattern.
87
- */
88
- function createTestRouter() {
89
- return implement(testContracts)
90
- .$context<RpcContext>()
91
- .use(autoAuthMiddleware)
92
- .router({
93
- anonymousEndpoint: implement(testContracts.anonymousEndpoint)
94
- .$context<RpcContext>()
95
- .handler(async () => ({
96
- message: "success",
97
- })),
116
+ const testImplementations = {
117
+ anonymousEndpoint: implement(testContracts.anonymousEndpoint).handler(() => ({
118
+ message: "Hello from anonymous",
119
+ })),
98
120
 
99
- publicGlobalEndpoint: implement(testContracts.publicGlobalEndpoint)
100
- .$context<RpcContext>()
101
- .handler(async () => ({
102
- message: "success",
103
- })),
121
+ publicGlobalEndpoint: implement(testContracts.publicGlobalEndpoint).handler(
122
+ () => ({
123
+ message: "Hello from public global",
124
+ })
125
+ ),
126
+
127
+ publicListEndpoint: implement(testContracts.publicListEndpoint).handler(
128
+ () => ({
129
+ systems: [
130
+ { id: "system-1", name: "System 1" },
131
+ { id: "system-2", name: "System 2" },
132
+ { id: "system-3", name: "System 3" },
133
+ ],
134
+ })
135
+ ),
104
136
 
105
- publicListEndpoint: implement(testContracts.publicListEndpoint)
106
- .$context<RpcContext>()
107
- .handler(async () => ({
108
- systems: [
109
- { id: "sys-1", name: "System 1" },
110
- { id: "sys-2", name: "System 2" },
111
- { id: "sys-3", name: "System 3" },
112
- ],
113
- })),
137
+ authenticatedEndpoint: implement(testContracts.authenticatedEndpoint).handler(
138
+ () => ({
139
+ message: "Hello from authenticated",
140
+ })
141
+ ),
114
142
 
115
- authenticatedEndpoint: implement(testContracts.authenticatedEndpoint)
116
- .$context<RpcContext>()
117
- .handler(async () => ({
118
- message: "success",
119
- })),
143
+ userOnlyEndpoint: implement(testContracts.userOnlyEndpoint).handler(() => ({
144
+ message: "Hello from user",
145
+ })),
120
146
 
121
- userOnlyEndpoint: implement(testContracts.userOnlyEndpoint)
122
- .$context<RpcContext>()
123
- .handler(async () => ({
124
- message: "success",
125
- })),
147
+ serviceOnlyEndpoint: implement(testContracts.serviceOnlyEndpoint).handler(
148
+ () => ({
149
+ message: "Hello from service",
150
+ })
151
+ ),
152
+
153
+ singleResourceEndpoint: implement(
154
+ testContracts.singleResourceEndpoint
155
+ ).handler(({ input }) => ({
156
+ system: { id: input.systemId },
157
+ })),
158
+
159
+ recordEndpoint: implement(testContracts.recordEndpoint).handler(
160
+ ({ input }) => ({
161
+ statuses: Object.fromEntries(
162
+ input.systemIds.map((id) => [id, { status: "ok" }])
163
+ ),
164
+ })
165
+ ),
126
166
 
127
- serviceOnlyEndpoint: implement(testContracts.serviceOnlyEndpoint)
128
- .$context<RpcContext>()
129
- .handler(async () => ({
130
- message: "success",
131
- })),
167
+ mutationEndpoint: implement(testContracts.mutationEndpoint).handler(() => ({
168
+ id: "new-id",
169
+ })),
170
+ };
132
171
 
133
- singleResourceEndpoint: implement(testContracts.singleResourceEndpoint)
134
- .$context<RpcContext>()
135
- .handler(async ({ input }) => ({
136
- system: { id: input.systemId },
137
- })),
138
- });
139
- }
172
+ // =============================================================================
173
+ // TESTS
174
+ // =============================================================================
140
175
 
141
176
  describe("autoAuthMiddleware", () => {
142
177
  let mockContext: RpcContext;
143
- let router: ReturnType<typeof createTestRouter>;
144
178
 
145
179
  beforeEach(() => {
146
180
  mockContext = createMockRpcContext();
147
- router = createTestRouter();
148
181
  });
149
182
 
150
- // ==========================================================================
151
- // ANONYMOUS ENDPOINTS (userType: "anonymous")
152
- // ==========================================================================
183
+ // ---------------------------------------------------------------------------
184
+ // Anonymous Access
185
+ // ---------------------------------------------------------------------------
153
186
 
154
- describe("anonymous endpoints (userType: anonymous)", () => {
155
- it("should allow access without authentication", async () => {
156
- const result = await call(router.anonymousEndpoint, undefined, {
157
- context: mockContext,
158
- });
159
- expect(result).toEqual({ message: "success" });
160
- });
187
+ describe("anonymous endpoints", () => {
188
+ it("should allow anonymous access without auth", async () => {
189
+ const procedure = implement(testContracts.anonymousEndpoint)
190
+ .$context<RpcContext>()
191
+ .$context<RpcContext>()
192
+ .use(autoAuthMiddleware)
193
+ .handler(() => ({ message: "success" }));
161
194
 
162
- it("should skip all access rule checks", async () => {
163
- const result = await call(router.anonymousEndpoint, undefined, {
164
- context: mockContext,
195
+ const result = await call(procedure, undefined, {
196
+ context: { ...mockContext, user: undefined },
165
197
  });
166
- expect(result).toEqual({ message: "success" });
167
- // getAnonymousAccessRules should NOT be called for anonymous endpoints
168
- expect(mockContext.auth.getAnonymousAccessRules).not.toHaveBeenCalled();
169
- });
170
- });
171
198
 
172
- // ==========================================================================
173
- // PUBLIC ENDPOINTS - Global Access Rules
174
- // ==========================================================================
175
-
176
- describe("public endpoints with global access rules", () => {
177
- it("should allow anonymous users with matching access rule", async () => {
178
- // Mock anonymous role has the required access rule
179
- (
180
- mockContext.auth.getAnonymousAccessRules as Mock<
181
- () => Promise<string[]>
182
- >
183
- ).mockResolvedValue(["test-plugin.resource.read"]);
184
-
185
- const result = await call(router.publicGlobalEndpoint, undefined, {
186
- context: mockContext,
187
- });
188
199
  expect(result).toEqual({ message: "success" });
189
- expect(mockContext.auth.getAnonymousAccessRules).toHaveBeenCalled();
190
200
  });
201
+ });
191
202
 
192
- it("should reject anonymous users without matching access rule", async () => {
193
- // Mock anonymous role does NOT have the required access rule
194
- (
195
- mockContext.auth.getAnonymousAccessRules as Mock<
196
- () => Promise<string[]>
197
- >
198
- ).mockResolvedValue([]);
203
+ // ---------------------------------------------------------------------------
204
+ // Public Access
205
+ // ---------------------------------------------------------------------------
199
206
 
200
- await expect(
201
- call(router.publicGlobalEndpoint, undefined, { context: mockContext })
202
- ).rejects.toThrow();
203
- });
207
+ describe("public endpoints", () => {
208
+ it("should allow authenticated users with proper access", async () => {
209
+ const procedure = implement(testContracts.publicGlobalEndpoint)
210
+ .$context<RpcContext>()
211
+ .use(autoAuthMiddleware)
212
+ .handler(() => ({ message: "success" }));
204
213
 
205
- it("should allow authenticated users with matching access rule", async () => {
206
- mockContext.user = {
207
- type: "user",
208
- id: "user-1",
209
- accessRules: ["test-plugin.resource.read"],
210
- };
214
+ const result = await call(procedure, undefined, { context: mockContext });
211
215
 
212
- const result = await call(router.publicGlobalEndpoint, undefined, {
213
- context: mockContext,
214
- });
215
216
  expect(result).toEqual({ message: "success" });
216
- expect(mockContext.auth.getAnonymousAccessRules).not.toHaveBeenCalled();
217
217
  });
218
218
 
219
- it("should reject authenticated users without matching access rule", async () => {
220
- mockContext.user = {
221
- type: "user",
222
- id: "user-1",
223
- accessRules: ["other.permission"],
219
+ it("should deny authenticated users without proper access", async () => {
220
+ // Set user with no access rules to simulate denied access
221
+ const contextWithNoAccess = {
222
+ ...mockContext,
223
+ user: { type: "user" as const, id: "user-1", accessRules: [] },
224
224
  };
225
225
 
226
- await expect(
227
- call(router.publicGlobalEndpoint, undefined, { context: mockContext })
226
+ const procedure = implement(testContracts.publicGlobalEndpoint)
227
+ .$context<RpcContext>()
228
+ .use(autoAuthMiddleware)
229
+ .handler(() => ({ message: "success" }));
230
+
231
+ expect(
232
+ call(procedure, undefined, { context: contextWithNoAccess })
228
233
  ).rejects.toThrow();
229
234
  });
230
235
 
231
- it("should allow users with wildcard access rule", async () => {
232
- mockContext.user = {
233
- type: "user",
234
- id: "user-1",
235
- accessRules: ["*"],
236
+ it("should allow anonymous users for public endpoints with correct access", async () => {
237
+ // Mock anonymous access rules to include the required access
238
+ const contextWithAnonymousAccess = {
239
+ ...mockContext,
240
+ user: undefined,
241
+ auth: {
242
+ ...mockContext.auth,
243
+ getAnonymousAccessRules: () =>
244
+ Promise.resolve(["test-plugin.resource.read"]),
245
+ },
236
246
  };
237
247
 
238
- const result = await call(router.publicGlobalEndpoint, undefined, {
239
- context: mockContext,
240
- });
241
- expect(result).toEqual({ message: "success" });
242
- });
243
-
244
- it("should allow service users without checking access rules", async () => {
245
- mockContext.user = {
246
- type: "service",
247
- pluginId: "other-plugin",
248
- };
248
+ const procedure = implement(testContracts.publicGlobalEndpoint)
249
+ .$context<RpcContext>()
250
+ .use(autoAuthMiddleware)
251
+ .handler(() => ({ message: "success" }));
249
252
 
250
- const result = await call(router.publicGlobalEndpoint, undefined, {
251
- context: mockContext,
253
+ const result = await call(procedure, undefined, {
254
+ context: contextWithAnonymousAccess,
252
255
  });
256
+
253
257
  expect(result).toEqual({ message: "success" });
254
258
  });
255
259
  });
256
260
 
257
- // ==========================================================================
258
- // PUBLIC ENDPOINTS - List Filtering (Regression test for anonymous access)
259
- // ==========================================================================
260
-
261
- describe("public endpoints with list filtering (instanceAccess.listKey)", () => {
262
- it("should return all items for anonymous users WITH global access", async () => {
263
- // Anonymous role HAS the required access rule
264
- (
265
- mockContext.auth.getAnonymousAccessRules as Mock<
266
- () => Promise<string[]>
267
- >
268
- ).mockResolvedValue(["test-plugin.system.read"]);
269
-
270
- const result = await call(router.publicListEndpoint, undefined, {
271
- context: mockContext,
272
- });
273
-
274
- // Should return all systems since anonymous has global access
275
- expect(result.systems).toHaveLength(3);
276
- expect(result.systems.map((s) => s.id)).toEqual([
277
- "sys-1",
278
- "sys-2",
279
- "sys-3",
280
- ]);
281
- });
261
+ // ---------------------------------------------------------------------------
262
+ // Authenticated Access
263
+ // ---------------------------------------------------------------------------
282
264
 
283
- it("should return empty list for anonymous users WITHOUT global access", async () => {
284
- // Anonymous role does NOT have the required access rule
285
- (
286
- mockContext.auth.getAnonymousAccessRules as Mock<
287
- () => Promise<string[]>
288
- >
289
- ).mockResolvedValue([]);
265
+ describe("authenticated endpoints", () => {
266
+ it("should allow authenticated users", async () => {
267
+ const procedure = implement(testContracts.authenticatedEndpoint)
268
+ .$context<RpcContext>()
269
+ .use(autoAuthMiddleware)
270
+ .handler(() => ({ message: "success" }));
290
271
 
291
- const result = await call(router.publicListEndpoint, undefined, {
292
- context: mockContext,
293
- });
272
+ const result = await call(procedure, undefined, { context: mockContext });
294
273
 
295
- // Should return empty list since anonymous has no access
296
- expect(result.systems).toHaveLength(0);
274
+ expect(result).toEqual({ message: "success" });
297
275
  });
298
276
 
299
- it("should return all items for anonymous users with wildcard access", async () => {
300
- // Anonymous role has wildcard access
301
- (
302
- mockContext.auth.getAnonymousAccessRules as Mock<
303
- () => Promise<string[]>
304
- >
305
- ).mockResolvedValue(["*"]);
306
-
307
- const result = await call(router.publicListEndpoint, undefined, {
308
- context: mockContext,
309
- });
277
+ it("should deny anonymous users", async () => {
278
+ const procedure = implement(testContracts.authenticatedEndpoint)
279
+ .$context<RpcContext>()
280
+ .use(autoAuthMiddleware)
281
+ .handler(() => ({ message: "success" }));
310
282
 
311
- // Should return all systems
312
- expect(result.systems).toHaveLength(3);
283
+ expect(
284
+ call(procedure, undefined, {
285
+ context: { ...mockContext, user: undefined },
286
+ })
287
+ ).rejects.toThrow("Authentication required");
313
288
  });
289
+ });
314
290
 
315
- it("should filter items via S2S for authenticated users without global access", async () => {
316
- mockContext.user = {
317
- type: "user",
318
- id: "user-1",
319
- accessRules: [], // No global access
320
- };
291
+ // ---------------------------------------------------------------------------
292
+ // User-only Access
293
+ // ---------------------------------------------------------------------------
321
294
 
322
- // Mock S2S call returns only accessible IDs
323
- (
324
- mockContext.auth.getAccessibleResourceIds as Mock<
325
- () => Promise<string[]>
326
- >
327
- ).mockResolvedValue(["sys-1", "sys-3"]);
295
+ describe("user-only endpoints", () => {
296
+ it("should allow frontend users", async () => {
297
+ const procedure = implement(testContracts.userOnlyEndpoint)
298
+ .$context<RpcContext>()
299
+ .use(autoAuthMiddleware)
300
+ .handler(() => ({ message: "success" }));
328
301
 
329
- const result = await call(router.publicListEndpoint, undefined, {
330
- context: mockContext,
331
- });
302
+ const result = await call(procedure, undefined, { context: mockContext });
332
303
 
333
- // Should return only filtered systems
334
- expect(result.systems).toHaveLength(2);
335
- expect(result.systems.map((s) => s.id)).toEqual(["sys-1", "sys-3"]);
304
+ expect(result).toEqual({ message: "success" });
336
305
  });
337
306
 
338
- it("should return all items for authenticated users with global access", async () => {
339
- mockContext.user = {
340
- type: "user",
341
- id: "user-1",
342
- accessRules: ["test-plugin.system.read"], // Has global access
343
- };
344
-
345
- // S2S should return all items when user has global access
346
- (
347
- mockContext.auth.getAccessibleResourceIds as Mock<
348
- () => Promise<string[]>
349
- >
350
- ).mockResolvedValue(["sys-1", "sys-2", "sys-3"]);
351
-
352
- const result = await call(router.publicListEndpoint, undefined, {
353
- context: mockContext,
354
- });
355
-
356
- expect(result.systems).toHaveLength(3);
307
+ it("should deny services", async () => {
308
+ const procedure = implement(testContracts.userOnlyEndpoint)
309
+ .$context<RpcContext>()
310
+ .use(autoAuthMiddleware)
311
+ .handler(() => ({ message: "success" }));
312
+
313
+ expect(
314
+ call(procedure, undefined, {
315
+ context: {
316
+ ...mockContext,
317
+ user: { type: "service" as const, pluginId: "test-service" },
318
+ },
319
+ })
320
+ ).rejects.toThrow("This endpoint is for users only");
357
321
  });
358
322
  });
359
323
 
360
- // ==========================================================================
361
- // AUTHENTICATED ENDPOINTS
362
- // ==========================================================================
363
-
364
- describe("authenticated endpoints (userType: authenticated)", () => {
365
- it("should reject unauthenticated requests", async () => {
366
- // No user in context
367
- await expect(
368
- call(router.authenticatedEndpoint, undefined, { context: mockContext })
369
- ).rejects.toThrow("Authentication required");
370
- });
324
+ // ---------------------------------------------------------------------------
325
+ // Service-only Access
326
+ // ---------------------------------------------------------------------------
371
327
 
372
- it("should allow authenticated users", async () => {
373
- mockContext.user = {
374
- type: "user",
375
- id: "user-1",
376
- accessRules: [],
377
- };
378
-
379
- const result = await call(router.authenticatedEndpoint, undefined, {
380
- context: mockContext,
328
+ describe("service-only endpoints", () => {
329
+ it("should allow services", async () => {
330
+ const procedure = implement(testContracts.serviceOnlyEndpoint)
331
+ .$context<RpcContext>()
332
+ .use(autoAuthMiddleware)
333
+ .handler(() => ({ message: "success" }));
334
+
335
+ const result = await call(procedure, undefined, {
336
+ context: {
337
+ ...mockContext,
338
+ user: { type: "service" as const, pluginId: "test-service" },
339
+ },
381
340
  });
341
+
382
342
  expect(result).toEqual({ message: "success" });
383
343
  });
384
344
 
385
- it("should allow service users", async () => {
386
- mockContext.user = {
387
- type: "service",
388
- pluginId: "other-plugin",
389
- };
345
+ it("should deny frontend users", async () => {
346
+ const procedure = implement(testContracts.serviceOnlyEndpoint)
347
+ .$context<RpcContext>()
348
+ .use(autoAuthMiddleware)
349
+ .handler(() => ({ message: "success" }));
390
350
 
391
- const result = await call(router.authenticatedEndpoint, undefined, {
392
- context: mockContext,
393
- });
394
- expect(result).toEqual({ message: "success" });
351
+ expect(
352
+ call(procedure, undefined, { context: mockContext })
353
+ ).rejects.toThrow("This endpoint is for services only");
395
354
  });
396
355
  });
397
356
 
398
- // ==========================================================================
399
- // USER-ONLY ENDPOINTS
400
- // ==========================================================================
401
-
402
- describe("user-only endpoints (userType: user)", () => {
403
- it("should reject service users", async () => {
404
- mockContext.user = {
405
- type: "service",
406
- pluginId: "other-plugin",
407
- };
357
+ // ---------------------------------------------------------------------------
358
+ // Instance-level Access (idParam)
359
+ // ---------------------------------------------------------------------------
408
360
 
409
- await expect(
410
- call(router.userOnlyEndpoint, undefined, { context: mockContext })
411
- ).rejects.toThrow("This endpoint is for users only");
412
- });
361
+ describe("single resource endpoints with idParam", () => {
362
+ it("should check instance-level access with idParam", async () => {
363
+ const procedure = implement(testContracts.singleResourceEndpoint)
364
+ .$context<RpcContext>()
365
+ .use(autoAuthMiddleware)
366
+ .handler(({ input }) => ({ system: { id: input.systemId } }));
413
367
 
414
- it("should allow real users", async () => {
415
- mockContext.user = {
416
- type: "user",
417
- id: "user-1",
418
- accessRules: [],
419
- };
368
+ const result = await call(
369
+ procedure,
370
+ { systemId: "test-123" },
371
+ { context: mockContext }
372
+ );
420
373
 
421
- const result = await call(router.userOnlyEndpoint, undefined, {
422
- context: mockContext,
423
- });
424
- expect(result).toEqual({ message: "success" });
374
+ expect(result).toEqual({ system: { id: "test-123" } });
425
375
  });
426
- });
427
376
 
428
- // ==========================================================================
429
- // SERVICE-ONLY ENDPOINTS
430
- // ==========================================================================
431
-
432
- describe("service-only endpoints (userType: service)", () => {
433
- it("should reject real users", async () => {
434
- mockContext.user = {
435
- type: "user",
436
- id: "user-1",
437
- accessRules: [],
377
+ it("should deny access when instance check fails", async () => {
378
+ // Set user with no access rules AND mock auth to deny team access
379
+ const contextWithNoAccess = {
380
+ ...mockContext,
381
+ user: { type: "user" as const, id: "user-1", accessRules: [] },
382
+ auth: {
383
+ ...mockContext.auth,
384
+ checkResourceTeamAccess: () => Promise.resolve({ hasAccess: false }),
385
+ },
438
386
  };
439
387
 
440
- await expect(
441
- call(router.serviceOnlyEndpoint, undefined, { context: mockContext })
442
- ).rejects.toThrow("This endpoint is for services only");
443
- });
444
-
445
- it("should allow service users", async () => {
446
- mockContext.user = {
447
- type: "service",
448
- pluginId: "other-plugin",
449
- };
388
+ const procedure = implement(testContracts.singleResourceEndpoint)
389
+ .$context<RpcContext>()
390
+ .use(autoAuthMiddleware)
391
+ .handler(({ input }) => ({ system: { id: input.systemId } }));
450
392
 
451
- const result = await call(router.serviceOnlyEndpoint, undefined, {
452
- context: mockContext,
453
- });
454
- expect(result).toEqual({ message: "success" });
393
+ expect(
394
+ call(
395
+ procedure,
396
+ { systemId: "forbidden-id" },
397
+ { context: contextWithNoAccess }
398
+ )
399
+ ).rejects.toThrow();
455
400
  });
456
401
  });
457
402
 
458
- // ==========================================================================
459
- // SINGLE RESOURCE ACCESS
460
- // ==========================================================================
403
+ // ---------------------------------------------------------------------------
404
+ // List Filtering (listKey)
405
+ // ---------------------------------------------------------------------------
461
406
 
462
- describe("single resource access (instanceAccess.idParam)", () => {
463
- it("should deny anonymous users access to single resources", async () => {
464
- // Anonymous role has the access rule
465
- (
466
- mockContext.auth.getAnonymousAccessRules as Mock<
467
- () => Promise<string[]>
468
- >
469
- ).mockResolvedValue(["test-plugin.system.read"]);
407
+ describe("list endpoints with listKey", () => {
408
+ it("should check global access for list endpoints", async () => {
409
+ const procedure = implement(testContracts.publicListEndpoint)
410
+ .$context<RpcContext>()
411
+ .use(autoAuthMiddleware)
412
+ .handler(() => ({
413
+ systems: [
414
+ { id: "1", name: "System 1" },
415
+ { id: "2", name: "System 2" },
416
+ ],
417
+ }));
470
418
 
471
- await expect(
472
- call(
473
- router.singleResourceEndpoint,
474
- { systemId: "sys-1" },
475
- { context: mockContext }
476
- )
477
- ).rejects.toThrow("Authentication required to access system:sys-1");
419
+ const result = await call(procedure, undefined, { context: mockContext });
420
+
421
+ expect(result.systems).toHaveLength(2);
478
422
  });
423
+ });
479
424
 
480
- it("should check team access via S2S for authenticated users", async () => {
481
- mockContext.user = {
482
- type: "user",
483
- id: "user-1",
484
- accessRules: [],
485
- };
425
+ // ---------------------------------------------------------------------------
426
+ // Record Filtering (recordKey)
427
+ // ---------------------------------------------------------------------------
486
428
 
487
- (
488
- mockContext.auth.checkResourceTeamAccess as Mock<
489
- () => Promise<{ hasAccess: boolean }>
490
- >
491
- ).mockResolvedValue({ hasAccess: true });
429
+ describe("record endpoints with recordKey", () => {
430
+ it("should check global access for record endpoints", async () => {
431
+ const procedure = implement(testContracts.recordEndpoint)
432
+ .$context<RpcContext>()
433
+ .use(autoAuthMiddleware)
434
+ .handler(({ input }) => ({
435
+ statuses: Object.fromEntries(
436
+ input.systemIds.map((id) => [id, { status: "ok" }])
437
+ ),
438
+ }));
492
439
 
493
440
  const result = await call(
494
- router.singleResourceEndpoint,
495
- { systemId: "sys-1" },
441
+ procedure,
442
+ { systemIds: ["sys-1", "sys-2"] },
496
443
  { context: mockContext }
497
444
  );
498
445
 
499
- expect(result.system?.id).toBe("sys-1");
500
- expect(mockContext.auth.checkResourceTeamAccess).toHaveBeenCalledWith(
501
- expect.objectContaining({
502
- userId: "user-1",
503
- resourceId: "sys-1",
504
- })
505
- );
506
- });
507
-
508
- it("should deny access when team access check fails", async () => {
509
- mockContext.user = {
510
- type: "user",
511
- id: "user-1",
512
- accessRules: [],
513
- };
514
-
515
- (
516
- mockContext.auth.checkResourceTeamAccess as Mock<
517
- () => Promise<{ hasAccess: boolean }>
518
- >
519
- ).mockResolvedValue({ hasAccess: false });
520
-
521
- await expect(
522
- call(
523
- router.singleResourceEndpoint,
524
- { systemId: "sys-1" },
525
- { context: mockContext }
526
- )
527
- ).rejects.toThrow("Access denied to resource system:sys-1");
446
+ expect(Object.keys(result.statuses)).toHaveLength(2);
528
447
  });
529
448
  });
530
449
  });
package/src/rpc.ts CHANGED
@@ -112,11 +112,17 @@ export const autoAuthMiddleware = os.middleware(
112
112
  const globalOnlyRules = qualifiedRules.filter((r) => !r.instanceAccess);
113
113
  const instanceRules = qualifiedRules.filter((r) => r.instanceAccess);
114
114
  const singleResourceRules = instanceRules.filter(
115
- (r) => r.instanceAccess?.idParam
115
+ (r) =>
116
+ r.instanceAccess?.idParam &&
117
+ !r.instanceAccess?.listKey &&
118
+ !r.instanceAccess?.recordKey
116
119
  );
117
120
  const listResourceRules = instanceRules.filter(
118
121
  (r) => r.instanceAccess?.listKey
119
122
  );
123
+ const recordResourceRules = instanceRules.filter(
124
+ (r) => r.instanceAccess?.recordKey
125
+ );
120
126
 
121
127
  // 1. Handle anonymous endpoints - no auth required, no access checks
122
128
  if (requiredUserType === "anonymous") {
@@ -331,6 +337,87 @@ export const autoAuthMiddleware = os.middleware(
331
337
  }
332
338
  }
333
339
 
340
+ // Post-filter: Record endpoints (bulk queries returning Record<resourceId, data>)
341
+ // For these, remove record keys user doesn't have access to
342
+ if (
343
+ recordResourceRules.length > 0 &&
344
+ result.output &&
345
+ typeof result.output === "object"
346
+ ) {
347
+ const mutableOutput = result.output as Record<string, unknown>;
348
+
349
+ for (const rule of recordResourceRules) {
350
+ const outputKey = rule.instanceAccess!.recordKey!;
351
+ const record = mutableOutput[outputKey];
352
+
353
+ if (record === undefined) {
354
+ context.logger.error(
355
+ `resourceAccess: expected "${outputKey}" in response but not found`
356
+ );
357
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
358
+ message: "Invalid response shape for filtered endpoint",
359
+ });
360
+ }
361
+
362
+ if (
363
+ typeof record !== "object" ||
364
+ record === null ||
365
+ Array.isArray(record)
366
+ ) {
367
+ context.logger.error(
368
+ `resourceAccess: "${outputKey}" must be an object (record)`
369
+ );
370
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
371
+ message: "Invalid response shape for filtered endpoint",
372
+ });
373
+ }
374
+
375
+ const recordObj = record as Record<string, unknown>;
376
+ const resourceIds = Object.keys(recordObj);
377
+
378
+ // If no user (anonymous), check if they have global access via anonymous role
379
+ if (!userId || !userType) {
380
+ const anonymousAccessRules =
381
+ await context.auth.getAnonymousAccessRules();
382
+ const hasGlobalAccess =
383
+ anonymousAccessRules.includes("*") ||
384
+ anonymousAccessRules.includes(rule.qualifiedId);
385
+
386
+ if (hasGlobalAccess) {
387
+ // Anonymous user has global access - return all items
388
+ continue;
389
+ } else {
390
+ // No global access and can't have team grants - return empty record
391
+ mutableOutput[outputKey] = {};
392
+ continue;
393
+ }
394
+ }
395
+
396
+ const hasGlobalAccess =
397
+ userAccessRules.includes("*") ||
398
+ userAccessRules.includes(rule.qualifiedId);
399
+
400
+ const accessibleIds = await getAccessibleResourceIdsViaS2S({
401
+ auth: context.auth,
402
+ userId,
403
+ userType,
404
+ resourceType: rule.qualifiedResourceType,
405
+ resourceIds,
406
+ action: rule.level,
407
+ hasGlobalAccess,
408
+ });
409
+
410
+ const accessibleSet = new Set(accessibleIds);
411
+ const filteredRecord: Record<string, unknown> = {};
412
+ for (const [key, value] of Object.entries(recordObj)) {
413
+ if (accessibleSet.has(key)) {
414
+ filteredRecord[key] = value;
415
+ }
416
+ }
417
+ mutableOutput[outputKey] = filteredRecord;
418
+ }
419
+ }
420
+
334
421
  return result;
335
422
  }
336
423
  );
package/src/test-utils.ts CHANGED
@@ -7,6 +7,7 @@ import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
7
7
 
8
8
  /**
9
9
  * Creates a mocked oRPC context for testing.
10
+ * By default provides an authenticated user with wildcard access.
10
11
  */
11
12
  export function createMockRpcContext(
12
13
  overrides: Partial<RpcContext> = {}
@@ -68,7 +69,8 @@ export function createMockRpcContext(
68
69
  startPolling: mock(),
69
70
  shutdown: mock(),
70
71
  } as unknown as QueueManager,
71
- user: undefined,
72
+ // Default: authenticated user with wildcard access for testing
73
+ user: { type: "user" as const, id: "test-user", accessRules: ["*"] },
72
74
  emitHook: mock() as unknown as EmitHookFn,
73
75
  ...overrides,
74
76
  };