@checkstack/backend-api 0.3.1 → 0.3.3
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 +79 -0
- package/package.json +1 -1
- package/src/plugin-admin-contract.ts +11 -14
- package/src/rpc.test.ts +325 -406
- package/src/rpc.ts +88 -1
- package/src/schema-utils.ts +15 -1
- package/src/test-utils.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# @checkstack/backend-api
|
|
2
2
|
|
|
3
|
+
## 0.3.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d94121b: Add group-to-role mapping for SAML and LDAP authentication
|
|
8
|
+
|
|
9
|
+
**Features:**
|
|
10
|
+
|
|
11
|
+
- SAML and LDAP users can now be automatically assigned Checkstack roles based on their directory group memberships
|
|
12
|
+
- Configure group mappings in the authentication strategy settings with dynamic role dropdowns
|
|
13
|
+
- Managed role sync: roles configured in mappings are fully synchronized (added when user gains group, removed when user leaves group)
|
|
14
|
+
- Unmanaged roles (manually assigned, not in any mapping) are preserved during sync
|
|
15
|
+
- Optional default role for all users from a directory
|
|
16
|
+
|
|
17
|
+
**Bug Fix:**
|
|
18
|
+
|
|
19
|
+
- Fixed `x-options-resolver` not working for fields inside arrays with `.default([])` in DynamicForm schemas
|
|
20
|
+
- @checkstack/queue-api@0.1.1
|
|
21
|
+
|
|
22
|
+
## 0.3.2
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- 7a23261: ## TanStack Query Integration
|
|
27
|
+
|
|
28
|
+
Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
|
|
29
|
+
|
|
30
|
+
### New Features
|
|
31
|
+
|
|
32
|
+
- **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
|
|
33
|
+
- **Automatic request deduplication**: Multiple components requesting the same data share a single network request
|
|
34
|
+
- **Built-in caching**: Configurable stale time and cache duration per query
|
|
35
|
+
- **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
|
|
36
|
+
- **Background refetching**: Stale data is automatically refreshed when components mount
|
|
37
|
+
|
|
38
|
+
### Contract Changes
|
|
39
|
+
|
|
40
|
+
All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
const getItems = proc()
|
|
44
|
+
.meta({ operationType: "query", access: [access.read] })
|
|
45
|
+
.output(z.array(itemSchema))
|
|
46
|
+
.query();
|
|
47
|
+
|
|
48
|
+
const createItem = proc()
|
|
49
|
+
.meta({ operationType: "mutation", access: [access.manage] })
|
|
50
|
+
.input(createItemSchema)
|
|
51
|
+
.output(itemSchema)
|
|
52
|
+
.mutation();
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Migration
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// Before (forPlugin pattern)
|
|
59
|
+
const api = useApi(myPluginApiRef);
|
|
60
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
api.getItems().then(setItems);
|
|
63
|
+
}, [api]);
|
|
64
|
+
|
|
65
|
+
// After (usePluginClient pattern)
|
|
66
|
+
const client = usePluginClient(MyPluginApi);
|
|
67
|
+
const { data: items, isLoading } = client.getItems.useQuery({});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Bug Fixes
|
|
71
|
+
|
|
72
|
+
- Fixed `rpc.test.ts` test setup for middleware type inference
|
|
73
|
+
- Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
|
|
74
|
+
- Fixed null→undefined warnings in notification and queue frontends
|
|
75
|
+
|
|
76
|
+
- Updated dependencies [180be38]
|
|
77
|
+
- Updated dependencies [7a23261]
|
|
78
|
+
- @checkstack/queue-api@0.1.0
|
|
79
|
+
- @checkstack/common@0.3.0
|
|
80
|
+
- @checkstack/signal-common@0.1.1
|
|
81
|
+
|
|
3
82
|
## 0.3.1
|
|
4
83
|
|
|
5
84
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
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:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
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:
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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:
|
|
52
|
-
|
|
53
|
-
|
|
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:
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
113
|
+
// TEST IMPLEMENTATIONS
|
|
83
114
|
// =============================================================================
|
|
84
115
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
message: "success",
|
|
119
|
-
})),
|
|
143
|
+
userOnlyEndpoint: implement(testContracts.userOnlyEndpoint).handler(() => ({
|
|
144
|
+
message: "Hello from user",
|
|
145
|
+
})),
|
|
120
146
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
})),
|
|
167
|
+
mutationEndpoint: implement(testContracts.mutationEndpoint).handler(() => ({
|
|
168
|
+
id: "new-id",
|
|
169
|
+
})),
|
|
170
|
+
};
|
|
132
171
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
//
|
|
152
|
-
//
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Anonymous Access
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
153
186
|
|
|
154
|
-
describe("anonymous endpoints
|
|
155
|
-
it("should allow access without
|
|
156
|
-
const
|
|
157
|
-
context
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
mockContext.auth.getAnonymousAccessRules as Mock<
|
|
196
|
-
() => Promise<string[]>
|
|
197
|
-
>
|
|
198
|
-
).mockResolvedValue([]);
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Public Access
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
199
206
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
accessRules: [
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
239
|
-
context
|
|
240
|
-
|
|
241
|
-
|
|
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(
|
|
251
|
-
context:
|
|
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
|
-
//
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
(
|
|
286
|
-
|
|
287
|
-
|
|
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(
|
|
292
|
-
context: mockContext,
|
|
293
|
-
});
|
|
272
|
+
const result = await call(procedure, undefined, { context: mockContext });
|
|
294
273
|
|
|
295
|
-
|
|
296
|
-
expect(result.systems).toHaveLength(0);
|
|
274
|
+
expect(result).toEqual({ message: "success" });
|
|
297
275
|
});
|
|
298
276
|
|
|
299
|
-
it("should
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
312
|
-
|
|
283
|
+
expect(
|
|
284
|
+
call(procedure, undefined, {
|
|
285
|
+
context: { ...mockContext, user: undefined },
|
|
286
|
+
})
|
|
287
|
+
).rejects.toThrow("Authentication required");
|
|
313
288
|
});
|
|
289
|
+
});
|
|
314
290
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
id: "user-1",
|
|
319
|
-
accessRules: [], // No global access
|
|
320
|
-
};
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// User-only Access
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
321
294
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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(
|
|
330
|
-
context: mockContext,
|
|
331
|
-
});
|
|
302
|
+
const result = await call(procedure, undefined, { context: mockContext });
|
|
332
303
|
|
|
333
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const result = await call(
|
|
380
|
-
context:
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
};
|
|
368
|
+
const result = await call(
|
|
369
|
+
procedure,
|
|
370
|
+
{ systemId: "test-123" },
|
|
371
|
+
{ context: mockContext }
|
|
372
|
+
);
|
|
420
373
|
|
|
421
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
//
|
|
460
|
-
//
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// List Filtering (listKey)
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
461
406
|
|
|
462
|
-
describe("
|
|
463
|
-
it("should
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
id: "user-1",
|
|
484
|
-
accessRules: [],
|
|
485
|
-
};
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// Record Filtering (recordKey)
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
486
428
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
>
|
|
491
|
-
|
|
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
|
-
|
|
495
|
-
{
|
|
441
|
+
procedure,
|
|
442
|
+
{ systemIds: ["sys-1", "sys-2"] },
|
|
496
443
|
{ context: mockContext }
|
|
497
444
|
);
|
|
498
445
|
|
|
499
|
-
expect(result.
|
|
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) =>
|
|
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/schema-utils.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { getConfigMeta } from "./zod-config";
|
|
|
9
9
|
*/
|
|
10
10
|
function addSchemaMetadata(
|
|
11
11
|
zodSchema: z.ZodTypeAny,
|
|
12
|
-
jsonSchema: Record<string, unknown
|
|
12
|
+
jsonSchema: Record<string, unknown>,
|
|
13
13
|
): void {
|
|
14
14
|
// Handle arrays - recurse into items
|
|
15
15
|
if (zodSchema instanceof z.ZodArray) {
|
|
@@ -28,6 +28,20 @@ function addSchemaMetadata(
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Handle default - unwrap and recurse
|
|
32
|
+
if (zodSchema instanceof z.ZodDefault) {
|
|
33
|
+
const innerSchema = zodSchema.def.innerType as z.ZodTypeAny;
|
|
34
|
+
addSchemaMetadata(innerSchema, jsonSchema);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Handle nullable - unwrap and recurse
|
|
39
|
+
if (zodSchema instanceof z.ZodNullable) {
|
|
40
|
+
const innerSchema = zodSchema.unwrap() as z.ZodTypeAny;
|
|
41
|
+
addSchemaMetadata(innerSchema, jsonSchema);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
// Type guard to check if this is an object schema
|
|
32
46
|
if (!("shape" in zodSchema)) return;
|
|
33
47
|
|
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
|
-
|
|
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
|
};
|