@opensaas/stack-core 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +191 -0
- package/CLAUDE.md +42 -3
- package/dist/access/access-filter.d.ts +70 -113
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +183 -210
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +62 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +61 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +91 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -5
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +89 -41
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +223 -215
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +55 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +41 -117
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +236 -272
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +117 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +97 -12
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +99 -45
- package/src/access/index.ts +17 -5
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +3 -11
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +311 -276
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +82 -235
- package/src/query/relationship-options.ts +1 -1
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +727 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/access/errors.ts
CHANGED
|
@@ -2,15 +2,19 @@ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Thrown when a caller-supplied `include` names a relation nested deeper than
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* swallow this.
|
|
5
|
+
* `READ_INCLUDE_MAX_DEPTH`. Deliberately distinct from `ValidationError`: this
|
|
6
|
+
* is not bad user input, it is the engine declining to serve a tree this
|
|
7
|
+
* expensive. Code that catches `ValidationError` to report form errors must
|
|
8
|
+
* not silently swallow this.
|
|
10
9
|
*
|
|
11
|
-
* Only an explicit
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Only an explicit request naming something at or past the cap triggers this
|
|
11
|
+
* — a read that simply doesn't reach this deep never throws. Before
|
|
12
|
+
* ADR-0026 made the read pipeline caller-directed, this cap was the engine's
|
|
13
|
+
* last line of defense against returning a relation it could not prove was
|
|
14
|
+
* row/field scoped (ADR-0022, issue #830); a request naming anything at this
|
|
15
|
+
* depth is now scoped exactly like every other named relation; the cap
|
|
16
|
+
* exists solely to bound how deep a request may cost the engine to serve. See
|
|
17
|
+
* ADR-0026 and `docs/adr/0022-access-control-fails-closed-when-it-cannot-scope.md`.
|
|
14
18
|
*/
|
|
15
19
|
export class AccessScopeDepthExceededError extends Error {
|
|
16
20
|
public listKey: string
|
|
@@ -19,10 +23,10 @@ export class AccessScopeDepthExceededError extends Error {
|
|
|
19
23
|
|
|
20
24
|
constructor(listKey: string, fieldKey: string, depth: number) {
|
|
21
25
|
super(
|
|
22
|
-
`Cannot
|
|
23
|
-
`
|
|
24
|
-
`
|
|
25
|
-
`
|
|
26
|
+
`Cannot include "${listKey}.${fieldKey}" at include depth ${depth}: this exceeds the read ` +
|
|
27
|
+
`pipeline's maximum include depth (${READ_INCLUDE_MAX_DEPTH}). This is a cost limit, not an ` +
|
|
28
|
+
`inability to scope — the engine declines to serve a tree this deep rather than returning ` +
|
|
29
|
+
`it. Restructure the query to fetch this relation separately.`,
|
|
26
30
|
)
|
|
27
31
|
this.name = 'AccessScopeDepthExceededError'
|
|
28
32
|
this.listKey = listKey
|
|
@@ -60,3 +64,84 @@ export class ResolveOutputCycleError extends Error {
|
|
|
60
64
|
this.chain = chain
|
|
61
65
|
}
|
|
62
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Thrown when a relation filter in a caller's `where` (`some`/`every`/`none`/
|
|
70
|
+
* `is`/`isNot`) names a relationship whose related list denies operation-level
|
|
71
|
+
* `query` access outright (`=== false`). Deliberately a loud failure, not a
|
|
72
|
+
* silently narrowed `{ id: { in: [] } }` or a pass-through: ADR-0022 requires
|
|
73
|
+
* an engine that cannot compute a scope to deny, never pass through, and here
|
|
74
|
+
* the engine CAN compute the scope (denied) — passing that through as a
|
|
75
|
+
* silently-empty match would itself be a distinguishable signal for a caller
|
|
76
|
+
* probing which relations they may filter on. `include`'s equivalent case
|
|
77
|
+
* (`buildAccessScopedInclude`) drops the relation silently instead, because a
|
|
78
|
+
* `null`/missing key in a response is indistinguishable from "not requested";
|
|
79
|
+
* a `where` predicate has no such neutral outcome, so a relation filter on a
|
|
80
|
+
* fully denied relation is refused instead. `sudo` bypasses this check
|
|
81
|
+
* entirely, matching every other access-control escape hatch. See #916 and
|
|
82
|
+
* `docs/adr/0022-access-control-fails-closed-when-it-cannot-scope.md`.
|
|
83
|
+
*/
|
|
84
|
+
export class RelationFilterAccessDeniedError extends Error {
|
|
85
|
+
public listKey: string
|
|
86
|
+
public fieldKey: string
|
|
87
|
+
public relatedListKey: string
|
|
88
|
+
|
|
89
|
+
constructor(listKey: string, fieldKey: string, relatedListKey: string) {
|
|
90
|
+
super(
|
|
91
|
+
`Cannot filter "${listKey}.${fieldKey}" — the related list "${relatedListKey}" denies ` +
|
|
92
|
+
`query access to this session, so this relation filter cannot be scoped. Denial is loud ` +
|
|
93
|
+
`rather than silently narrowed to match nothing (ADR-0022). Use sudo to bypass.`,
|
|
94
|
+
)
|
|
95
|
+
this.name = 'RelationFilterAccessDeniedError'
|
|
96
|
+
this.listKey = listKey
|
|
97
|
+
this.fieldKey = fieldKey
|
|
98
|
+
this.relatedListKey = relatedListKey
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function describeFieldAccessResult(result: unknown): string {
|
|
103
|
+
if (result === null) return 'null'
|
|
104
|
+
if (result === undefined) return 'undefined'
|
|
105
|
+
if (typeof result === 'object') return 'an object (e.g. a Prisma filter)'
|
|
106
|
+
return `a ${typeof result}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Thrown when a field-level access control function returns anything other
|
|
111
|
+
* than a strict `boolean`. `FieldAccessControl` is typed to return `boolean`
|
|
112
|
+
* only: field access is a single per-field visibility decision, not a row
|
|
113
|
+
* filter, and a denied field is removed rather than used to scope rows (see
|
|
114
|
+
* the "Field-level access" glossary entry in `CONTEXT.md`, ADR-0001, and
|
|
115
|
+
* ADR-0030).
|
|
116
|
+
*
|
|
117
|
+
* A rule that type-checks can never reach this — the only way here is a
|
|
118
|
+
* caller that bypasses the type (an untyped JS config, or a value forced past
|
|
119
|
+
* the checker), most notably a Prisma filter, which is the shape
|
|
120
|
+
* operation-level `AccessControl` accepts but `FieldAccessControl` does not.
|
|
121
|
+
* Before #913 this fell through to an unconditional `return true`, silently
|
|
122
|
+
* granting the field blanket access; it now fails loudly and closed instead,
|
|
123
|
+
* for `read`, `create`, and `update` alike.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately does not expose the offending result as a public field: unlike
|
|
126
|
+
* `AccessScopeDepthExceededError`/`ResolveOutputCycleError`'s fields, there is
|
|
127
|
+
* no concretely-typed shape to give it (the whole problem is that it isn't
|
|
128
|
+
* the `boolean` the caller's rule promised), and the root CLAUDE.md forbids
|
|
129
|
+
* exposing `unknown`/`any` as part of a package's external API. A description
|
|
130
|
+
* of what was returned instead is folded into the message text.
|
|
131
|
+
*/
|
|
132
|
+
export class InvalidFieldAccessResultError extends Error {
|
|
133
|
+
public operation: 'read' | 'create' | 'update'
|
|
134
|
+
|
|
135
|
+
constructor(operation: 'read' | 'create' | 'update', result: unknown) {
|
|
136
|
+
super(
|
|
137
|
+
`Field-level access control for operation "${operation}" returned ` +
|
|
138
|
+
`${describeFieldAccessResult(result)}, not a boolean. Field access is a per-field ` +
|
|
139
|
+
`visibility decision — it must return true or false, and (unlike operation-level access) ` +
|
|
140
|
+
`cannot scope which rows are affected. If you meant to restrict access based on the row or ` +
|
|
141
|
+
`the write payload, evaluate the condition yourself and return a boolean, e.g. ` +
|
|
142
|
+
`\`({ item, session }) => item?.ownerId === session?.userId\`.`,
|
|
143
|
+
)
|
|
144
|
+
this.name = 'InvalidFieldAccessResultError'
|
|
145
|
+
this.operation = operation
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, expectTypeOf } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
checkFieldAccess,
|
|
4
|
+
filterWritableFields,
|
|
5
|
+
isFieldReadableForPredicate,
|
|
6
|
+
} from './field-access.js'
|
|
7
|
+
import { InvalidFieldAccessResultError } from './errors.js'
|
|
3
8
|
import { ValidationError } from '../hooks/index.js'
|
|
9
|
+
import type { FieldAccess, FieldAccessControl } from './types.js'
|
|
4
10
|
|
|
5
11
|
// A non-sudo access context. The cast is localized to test setup (mirrors the
|
|
6
12
|
// existing sudo-context casts in this file): the runtime AccessContext carries
|
|
@@ -21,6 +27,283 @@ function sudoContext() {
|
|
|
21
27
|
} as any
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
// ── #914: `FieldAccess['read']`'s `item` must type as present, matching what
|
|
31
|
+
// Field Visibility (field-visibility.ts) actually passes ──
|
|
32
|
+
|
|
33
|
+
describe("FieldAccess['read'] item typing (issue #914)", () => {
|
|
34
|
+
type Item = { ownerId: string }
|
|
35
|
+
|
|
36
|
+
it('types item as present for a read rule that reads a property off it — no cast, no `any`', () => {
|
|
37
|
+
const fieldAccess: FieldAccess<Item> = {
|
|
38
|
+
read: ({ session, item }) => item.ownerId === session?.userId,
|
|
39
|
+
}
|
|
40
|
+
expect(typeof fieldAccess.read).toBe('function')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('still compiles a read rule that ignores item entirely', () => {
|
|
44
|
+
const fieldAccess: FieldAccess<Item> = {
|
|
45
|
+
read: () => true,
|
|
46
|
+
}
|
|
47
|
+
expect(typeof fieldAccess.read).toBe('function')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('leaves the create branch unchanged — item is still absent, there genuinely is no row yet', () => {
|
|
51
|
+
// Pinned against `FieldAccessControl`'s discriminated union directly
|
|
52
|
+
// (rather than `FieldAccess['create']`'s destructured callback), because
|
|
53
|
+
// `create`/`update` — unlike `read` — are untouched by this fix and keep
|
|
54
|
+
// accepting the full `FieldAccessControl` union in `FieldAccess`.
|
|
55
|
+
type CreateArgs = Extract<Parameters<FieldAccessControl<Item>>[0], { operation: 'create' }>
|
|
56
|
+
expectTypeOf<CreateArgs['item']>().toEqualTypeOf<undefined>()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Type-level pin: `resolveReadableFieldValue` (field-visibility.ts) is the
|
|
60
|
+
// sole caller of `checkFieldAccess` for `operation: 'read'`, and always
|
|
61
|
+
// supplies `item: accessItem` — a full row, never `undefined`. This
|
|
62
|
+
// assertion has no runtime effect (`expectTypeOf` is a no-op outside
|
|
63
|
+
// `vitest --typecheck`); its value is that `pnpm build`/`tsc` fails on this
|
|
64
|
+
// file the moment `FieldAccess['read']`'s `item` type drifts back to
|
|
65
|
+
// optional/absent, so the declared type and the call site cannot silently
|
|
66
|
+
// diverge again.
|
|
67
|
+
it("pins FieldAccess['read']'s item type against the field-visibility.ts call site", () => {
|
|
68
|
+
type ReadArgs = Parameters<NonNullable<FieldAccess<Item>['read']>>[0]
|
|
69
|
+
expectTypeOf<ReadArgs['item']>().toEqualTypeOf<Item>()
|
|
70
|
+
expectTypeOf<ReadArgs['operation']>().toEqualTypeOf<'read'>()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ── #913: a field rule returning a filter must not be granted blanket access ──
|
|
75
|
+
|
|
76
|
+
describe('checkFieldAccess', () => {
|
|
77
|
+
it('allows when the rule returns true', async () => {
|
|
78
|
+
const allowed = await checkFieldAccess({ read: () => true }, 'read', {
|
|
79
|
+
session: null,
|
|
80
|
+
item: { ownerId: 'someone-else' },
|
|
81
|
+
context: nonSudoContext(),
|
|
82
|
+
})
|
|
83
|
+
expect(allowed).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('denies when the rule returns false', async () => {
|
|
87
|
+
const allowed = await checkFieldAccess({ read: () => false }, 'read', {
|
|
88
|
+
session: null,
|
|
89
|
+
item: { ownerId: 'someone-else' },
|
|
90
|
+
context: nonSudoContext(),
|
|
91
|
+
})
|
|
92
|
+
expect(allowed).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('allows when no field access is configured', async () => {
|
|
96
|
+
const allowed = await checkFieldAccess(undefined, 'read', {
|
|
97
|
+
session: null,
|
|
98
|
+
context: nonSudoContext(),
|
|
99
|
+
})
|
|
100
|
+
expect(allowed).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('allows when no rule is configured for the operation', async () => {
|
|
104
|
+
const allowed = await checkFieldAccess({ update: () => false }, 'read', {
|
|
105
|
+
session: null,
|
|
106
|
+
context: nonSudoContext(),
|
|
107
|
+
})
|
|
108
|
+
expect(allowed).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('throws InvalidFieldAccessResultError, not allow, when a read rule returns a filter', async () => {
|
|
112
|
+
// The exact reproduction from the issue: a rule written to scope a field
|
|
113
|
+
// by row, which the previous fail-open default granted full access to.
|
|
114
|
+
const fieldAccess = {
|
|
115
|
+
read: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
} as any
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
checkFieldAccess(fieldAccess, 'read', {
|
|
121
|
+
session: null,
|
|
122
|
+
item: { ownerId: 'the-owner' },
|
|
123
|
+
context: nonSudoContext(),
|
|
124
|
+
}),
|
|
125
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('throws for a filter-returning rule on create, where there is no item to test it against', async () => {
|
|
129
|
+
const fieldAccess = {
|
|
130
|
+
create: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
132
|
+
} as any
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
checkFieldAccess(fieldAccess, 'create', {
|
|
136
|
+
session: null,
|
|
137
|
+
context: nonSudoContext(),
|
|
138
|
+
inputData: { ownerId: 'the-owner' },
|
|
139
|
+
}),
|
|
140
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('throws for a filter-returning rule on update', async () => {
|
|
144
|
+
const fieldAccess = {
|
|
145
|
+
update: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
146
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
147
|
+
} as any
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
checkFieldAccess(fieldAccess, 'update', {
|
|
151
|
+
session: null,
|
|
152
|
+
item: { ownerId: 'the-owner' },
|
|
153
|
+
context: nonSudoContext(),
|
|
154
|
+
inputData: { ownerId: 'someone-else' },
|
|
155
|
+
}),
|
|
156
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('throws for a non-boolean, non-filter result too (e.g. undefined)', async () => {
|
|
160
|
+
const fieldAccess = {
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
+
read: () => undefined as any,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await expect(
|
|
166
|
+
checkFieldAccess(fieldAccess, 'read', {
|
|
167
|
+
session: null,
|
|
168
|
+
item: {},
|
|
169
|
+
context: nonSudoContext(),
|
|
170
|
+
}),
|
|
171
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('throws with a descriptive message for null, and for other primitive results', async () => {
|
|
175
|
+
const nullAccess = {
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
177
|
+
read: () => null as any,
|
|
178
|
+
}
|
|
179
|
+
await expect(
|
|
180
|
+
checkFieldAccess(nullAccess, 'read', {
|
|
181
|
+
session: null,
|
|
182
|
+
item: {},
|
|
183
|
+
context: nonSudoContext(),
|
|
184
|
+
}),
|
|
185
|
+
).rejects.toThrow(/returned null, not a boolean/)
|
|
186
|
+
|
|
187
|
+
const numberAccess = {
|
|
188
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
189
|
+
read: () => 42 as any,
|
|
190
|
+
}
|
|
191
|
+
await expect(
|
|
192
|
+
checkFieldAccess(numberAccess, 'read', {
|
|
193
|
+
session: null,
|
|
194
|
+
item: {},
|
|
195
|
+
context: nonSudoContext(),
|
|
196
|
+
}),
|
|
197
|
+
).rejects.toThrow(/returned a number, not a boolean/)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('sudo bypasses the rule entirely, so a filter-returning rule never reaches the throw', async () => {
|
|
201
|
+
const fieldAccess = {
|
|
202
|
+
read: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
204
|
+
} as any
|
|
205
|
+
|
|
206
|
+
const allowed = await checkFieldAccess(fieldAccess, 'read', {
|
|
207
|
+
session: null,
|
|
208
|
+
item: { ownerId: 'the-owner' },
|
|
209
|
+
context: sudoContext(),
|
|
210
|
+
})
|
|
211
|
+
expect(allowed).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('isFieldReadableForPredicate (#915)', () => {
|
|
216
|
+
it('allows when there is no field access configured', async () => {
|
|
217
|
+
const readable = await isFieldReadableForPredicate(undefined, {
|
|
218
|
+
session: null,
|
|
219
|
+
context: nonSudoContext(),
|
|
220
|
+
})
|
|
221
|
+
expect(readable).toBe(true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('allows when the rule only inspects session (never touches item)', async () => {
|
|
225
|
+
const readable = await isFieldReadableForPredicate(
|
|
226
|
+
{ read: ({ session }) => session?.userId === 'admin' },
|
|
227
|
+
{ session: { userId: 'admin' }, context: nonSudoContext() },
|
|
228
|
+
)
|
|
229
|
+
expect(readable).toBe(true)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('denies when the rule returns false', async () => {
|
|
233
|
+
const readable = await isFieldReadableForPredicate(
|
|
234
|
+
{ read: () => false },
|
|
235
|
+
{ session: null, context: nonSudoContext() },
|
|
236
|
+
)
|
|
237
|
+
expect(readable).toBe(false)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('denies a row-dependent rule that dereferences `item` directly', async () => {
|
|
241
|
+
const readable = await isFieldReadableForPredicate(
|
|
242
|
+
{ read: ({ item, session }) => item.ownerId === session?.userId },
|
|
243
|
+
{ session: { userId: 'user-1' }, context: nonSudoContext() },
|
|
244
|
+
)
|
|
245
|
+
expect(readable).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('denies a row-dependent rule even when it reads `item` via optional chaining', async () => {
|
|
249
|
+
// This is the exact idiom `InvalidFieldAccessResultError`'s own message
|
|
250
|
+
// recommends (`item?.ownerId === session?.userId`) — it must still deny,
|
|
251
|
+
// not silently misevaluate `undefined === session?.userId` against a
|
|
252
|
+
// poisoned `item`.
|
|
253
|
+
const readable = await isFieldReadableForPredicate(
|
|
254
|
+
{ read: ({ item, session }) => item?.ownerId === session?.userId },
|
|
255
|
+
{ session: null, context: nonSudoContext() },
|
|
256
|
+
)
|
|
257
|
+
expect(readable).toBe(false)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('denies a row-dependent rule that only enumerates `item`s keys', async () => {
|
|
261
|
+
const readable = await isFieldReadableForPredicate(
|
|
262
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
263
|
+
{ read: ({ item }) => Object.keys(item as any).length > 0 },
|
|
264
|
+
{ session: null, context: nonSudoContext() },
|
|
265
|
+
)
|
|
266
|
+
expect(readable).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('propagates InvalidFieldAccessResultError instead of folding it into a denial', async () => {
|
|
270
|
+
const fieldAccess = {
|
|
271
|
+
read: () => ({ ownerId: { equals: 'someone-else' } }),
|
|
272
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
273
|
+
} as any
|
|
274
|
+
|
|
275
|
+
await expect(
|
|
276
|
+
isFieldReadableForPredicate(fieldAccess, { session: null, context: nonSudoContext() }),
|
|
277
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('propagates a genuine error the rule throws for its own reasons, not just non-boolean results', async () => {
|
|
281
|
+
// The rule never touches `item` — it throws for a reason of its own.
|
|
282
|
+
// This must NOT be folded into an ordinary `false` denial (that would
|
|
283
|
+
// mask a real bug in the rule as "field not readable"), matching how the
|
|
284
|
+
// post-query path (`filterReadableFields`) already lets such a throw
|
|
285
|
+
// propagate unchanged.
|
|
286
|
+
const fieldAccess: FieldAccess = {
|
|
287
|
+
read: ({ session }) => {
|
|
288
|
+
if (session === null) throw new Error('unexpected anonymous access')
|
|
289
|
+
return true
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await expect(
|
|
294
|
+
isFieldReadableForPredicate(fieldAccess, { session: null, context: nonSudoContext() }),
|
|
295
|
+
).rejects.toThrow('unexpected anonymous access')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('sudo bypasses the rule entirely, so a row-dependent rule never denies', async () => {
|
|
299
|
+
const readable = await isFieldReadableForPredicate(
|
|
300
|
+
{ read: ({ item, session }) => item.ownerId === session?.userId },
|
|
301
|
+
{ session: null, context: sudoContext() },
|
|
302
|
+
)
|
|
303
|
+
expect(readable).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
24
307
|
describe('filterWritableFields', () => {
|
|
25
308
|
it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
|
|
26
309
|
// Setup: Define field configs with a relationship field
|
|
@@ -361,6 +644,33 @@ describe('filterWritableFields', () => {
|
|
|
361
644
|
).rejects.toThrow(/status/)
|
|
362
645
|
})
|
|
363
646
|
|
|
647
|
+
// ── #913: a field rule returning a filter must not grant blanket write access ──
|
|
648
|
+
|
|
649
|
+
it('throws InvalidFieldAccessResultError, not a blanket write, when field access returns a filter (non-sudo)', async () => {
|
|
650
|
+
const fieldConfigs = {
|
|
651
|
+
title: { type: 'text' },
|
|
652
|
+
status: {
|
|
653
|
+
type: 'text',
|
|
654
|
+
access: {
|
|
655
|
+
// Written as a row-scoping rule; field access does not honour filters.
|
|
656
|
+
update: () => ({ status: { equals: 'draft' } }),
|
|
657
|
+
create: () => ({ status: { equals: 'draft' } }),
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
661
|
+
} as any
|
|
662
|
+
const data = { title: 'Test', status: 'published' }
|
|
663
|
+
|
|
664
|
+
await expect(
|
|
665
|
+
filterWritableFields(data, fieldConfigs, 'update', {
|
|
666
|
+
session: null,
|
|
667
|
+
item: { id: 'post-1', status: 'draft' },
|
|
668
|
+
context: nonSudoContext(),
|
|
669
|
+
inputData: data,
|
|
670
|
+
}),
|
|
671
|
+
).rejects.toThrow(InvalidFieldAccessResultError)
|
|
672
|
+
})
|
|
673
|
+
|
|
364
674
|
it('does NOT throw on a would-be-denied field under sudo', async () => {
|
|
365
675
|
const fieldConfigs = {
|
|
366
676
|
title: { type: 'text' },
|