@opensaas/stack-core 0.38.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 +123 -0
- package/CLAUDE.md +21 -0
- package/dist/access/access-filter.d.ts +46 -1
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +113 -4
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +4 -9
- package/dist/access/declared-dependencies.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 +49 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +75 -0
- 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 +0 -4
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +13 -39
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -2
- 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.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 +190 -193
- 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 +26 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +14 -117
- package/dist/query/index.js.map +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.ts +152 -5
- package/src/access/declared-dependencies.ts +4 -9
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +81 -0
- 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 +16 -47
- package/src/access/index.ts +20 -2
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +2 -10
- 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 +263 -247
- 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 +29 -235
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/context.test.ts +700 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
package/dist/utils/password.js
CHANGED
|
@@ -1,44 +1,12 @@
|
|
|
1
1
|
import bcrypt from 'bcryptjs';
|
|
2
|
-
|
|
3
|
-
* Default bcrypt cost factor (rounds)
|
|
4
|
-
* Higher values = more secure but slower
|
|
5
|
-
* 10 is a good balance for production use
|
|
6
|
-
*/
|
|
2
|
+
// Bcrypt cost factor: higher is slower but more resistant to brute force.
|
|
7
3
|
const DEFAULT_COST_FACTOR = 10;
|
|
8
|
-
/**
|
|
9
|
-
* Hash a plain text password using bcrypt
|
|
10
|
-
*
|
|
11
|
-
* @param plainPassword - The plain text password to hash
|
|
12
|
-
* @param costFactor - The bcrypt cost factor (default: 10)
|
|
13
|
-
* @returns Promise resolving to the hashed password
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* ```typescript
|
|
17
|
-
* const hashed = await hashPassword('mypassword')
|
|
18
|
-
* // Returns: $2a$10$...
|
|
19
|
-
* ```
|
|
20
|
-
*/
|
|
21
4
|
export async function hashPassword(plainPassword, costFactor = DEFAULT_COST_FACTOR) {
|
|
22
5
|
if (typeof plainPassword !== 'string' || plainPassword.length === 0) {
|
|
23
6
|
throw new Error('Password must be a non-empty string');
|
|
24
7
|
}
|
|
25
8
|
return bcrypt.hash(plainPassword, costFactor);
|
|
26
9
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Compare a plain text password with a hashed password
|
|
29
|
-
*
|
|
30
|
-
* @param plainPassword - The plain text password to compare
|
|
31
|
-
* @param hashedPassword - The hashed password to compare against
|
|
32
|
-
* @returns Promise resolving to true if passwords match, false otherwise
|
|
33
|
-
*
|
|
34
|
-
* @example
|
|
35
|
-
* ```typescript
|
|
36
|
-
* const isValid = await comparePassword('mypassword', hashedPassword)
|
|
37
|
-
* if (isValid) {
|
|
38
|
-
* // Password is correct
|
|
39
|
-
* }
|
|
40
|
-
* ```
|
|
41
|
-
*/
|
|
42
10
|
export async function comparePassword(plainPassword, hashedPassword) {
|
|
43
11
|
if (typeof plainPassword !== 'string' || typeof hashedPassword !== 'string') {
|
|
44
12
|
return false;
|
|
@@ -50,36 +18,19 @@ export async function comparePassword(plainPassword, hashedPassword) {
|
|
|
50
18
|
return await bcrypt.compare(plainPassword, hashedPassword);
|
|
51
19
|
}
|
|
52
20
|
catch (error) {
|
|
53
|
-
//
|
|
21
|
+
// Fail closed on a malformed hash or other bcrypt error, rather than
|
|
22
|
+
// letting it propagate as an authentication-bypassing exception.
|
|
54
23
|
console.error('Password comparison failed:', error);
|
|
55
24
|
return false;
|
|
56
25
|
}
|
|
57
26
|
}
|
|
58
|
-
/**
|
|
59
|
-
* Check if a string appears to be a bcrypt hash
|
|
60
|
-
* Bcrypt hashes follow the format: $2a$10$...
|
|
61
|
-
*
|
|
62
|
-
* @param value - The string to check
|
|
63
|
-
* @returns True if the string looks like a bcrypt hash
|
|
64
|
-
*/
|
|
65
27
|
export function isHashedPassword(value) {
|
|
66
28
|
if (typeof value !== 'string')
|
|
67
29
|
return false;
|
|
68
|
-
// Bcrypt hashes start with $2a$, $2b$, or $2y$ followed by cost factor
|
|
69
|
-
// and are typically 60 characters long
|
|
70
30
|
return /^\$2[aby]\$\d{2}\$.{53}$/.test(value);
|
|
71
31
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
* This allows password field values to be used as strings while also
|
|
75
|
-
* providing a convenient compare() method for authentication
|
|
76
|
-
*
|
|
77
|
-
* @example
|
|
78
|
-
* ```typescript
|
|
79
|
-
* const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
80
|
-
* const isValid = await user.password.compare('plaintextPassword')
|
|
81
|
-
* ```
|
|
82
|
-
*/
|
|
32
|
+
// Wraps a bcrypt hash so a password field's value can still be used as a
|
|
33
|
+
// plain string while also exposing compare() for authentication.
|
|
83
34
|
export class HashedPassword {
|
|
84
35
|
hash;
|
|
85
36
|
constructor(hash) {
|
|
@@ -88,43 +39,23 @@ export class HashedPassword {
|
|
|
88
39
|
throw new Error('HashedPassword requires a non-empty hash string');
|
|
89
40
|
}
|
|
90
41
|
}
|
|
91
|
-
/**
|
|
92
|
-
* Compare a plain text password with this hashed password
|
|
93
|
-
*
|
|
94
|
-
* @param plainPassword - The plain text password to compare
|
|
95
|
-
* @returns Promise resolving to true if passwords match
|
|
96
|
-
*/
|
|
97
42
|
async compare(plainPassword) {
|
|
98
43
|
return comparePassword(plainPassword, this.hash);
|
|
99
44
|
}
|
|
100
|
-
/**
|
|
101
|
-
* Get the underlying hash string
|
|
102
|
-
* This allows the HashedPassword to be used anywhere a string is expected
|
|
103
|
-
*/
|
|
104
45
|
toString() {
|
|
105
46
|
return this.hash;
|
|
106
47
|
}
|
|
107
|
-
|
|
108
|
-
* Get the underlying hash when used in string contexts
|
|
109
|
-
* This allows the HashedPassword to be coerced to a string automatically
|
|
110
|
-
*/
|
|
48
|
+
// Implicit coercion (template literals, string concatenation) resolves here.
|
|
111
49
|
[Symbol.toPrimitive](hint) {
|
|
112
50
|
if (hint === 'string' || hint === 'default') {
|
|
113
51
|
return this.hash;
|
|
114
52
|
}
|
|
115
53
|
return this.hash;
|
|
116
54
|
}
|
|
117
|
-
|
|
118
|
-
* Return the hash for JSON serialization
|
|
119
|
-
* This ensures the hash is properly serialized when converting to JSON
|
|
120
|
-
*/
|
|
55
|
+
// JSON.stringify calls this when present, so the hash serializes as a plain string.
|
|
121
56
|
toJSON() {
|
|
122
57
|
return this.hash;
|
|
123
58
|
}
|
|
124
|
-
/**
|
|
125
|
-
* Get the underlying hash value
|
|
126
|
-
* This allows accessing the raw hash string
|
|
127
|
-
*/
|
|
128
59
|
valueOf() {
|
|
129
60
|
return this.hash;
|
|
130
61
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B
|
|
1
|
+
{"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B,0EAA0E;AAC1E,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAE9B,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,aAAqB,EACrB,UAAU,GAAW,mBAAmB;IAExC,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAA;AAC/C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,aAAqB,EACrB,cAAsB;IAEtB,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;QAC5E,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC9D,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,OAAO,CAAC,aAAa,EAAE,cAAc,CAAC,CAAA;IAC5D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,qEAAqE;QACrE,iEAAiE;QACjE,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAA;QACnD,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAa;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAE3C,OAAO,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;AAC/C,CAAC;AAED,yEAAyE;AACzE,iEAAiE;AACjE,MAAM,OAAO,cAAc;IACI,IAAI;IAAjC,YAA6B,IAAY;oBAAZ,IAAI;QAC/B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtC,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAA;QACpE,CAAC;IACH,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,aAAqB;QACjC,OAAO,eAAe,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IAClD,CAAC;IAED,QAAQ;QACN,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED,6EAA6E;IAC7E,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAY;QAC/B,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC5C,OAAO,IAAI,CAAC,IAAI,CAAA;QAClB,CAAC;QACD,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED,oFAAoF;IACpF,MAAM;QACJ,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED,OAAO;QACL,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-config.d.ts","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAErE;;;;;;;;;GASG;AACH,MAAM,WAAW,0BAA0B;IACzC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;IAChB,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,aAAa,EAAE,eAAe,GAAG,mBAAmB,GAAG,cAAc,GAAG,mBAAmB,CAAA;IAC3F,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAA;CAChB;
|
|
1
|
+
{"version":3,"file":"field-config.d.ts","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAErE;;;;;;;;;GASG;AACH,MAAM,WAAW,0BAA0B;IACzC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;IAChB,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,aAAa,EAAE,eAAe,GAAG,mBAAmB,GAAG,cAAc,GAAG,mBAAmB,CAAA;IAC3F,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAA;CAChB;AAqCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,GACf,0BAA0B,EAAE,CAgC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,0BAA0B,EAAE,CAWzF"}
|
|
@@ -17,9 +17,6 @@ function hasFieldMethod(field, method) {
|
|
|
17
17
|
const value = Reflect.get(field, method);
|
|
18
18
|
return typeof value === 'function';
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
21
|
-
* Build the canonical error message for a missing contract method.
|
|
22
|
-
*/
|
|
23
20
|
function buildMessage(fieldType, method, fieldKey, listKey) {
|
|
24
21
|
const location = listKey ? `Field "${listKey}.${fieldKey}"` : `Field "${fieldKey}"`;
|
|
25
22
|
return (`${location} (type "${fieldType}") is not self-contained: it does not implement ` +
|
|
@@ -61,17 +58,14 @@ export function validateFieldConfig(field, fieldKey, listKey) {
|
|
|
61
58
|
}
|
|
62
59
|
};
|
|
63
60
|
if (field.type === 'relationship') {
|
|
64
|
-
// Relationships render through the relationship path only.
|
|
65
61
|
requireMethod('getPrismaRelation');
|
|
66
62
|
return errors;
|
|
67
63
|
}
|
|
68
64
|
if (field.virtual === true || field.type === 'virtual') {
|
|
69
|
-
// Virtual fields are not persisted, so getPrismaType is intentionally absent.
|
|
70
65
|
requireMethod('getTypeScriptType');
|
|
71
66
|
requireMethod('getZodSchema');
|
|
72
67
|
return errors;
|
|
73
68
|
}
|
|
74
|
-
// Stored scalar fields must implement the full generation contract.
|
|
75
69
|
requireMethod('getPrismaType');
|
|
76
70
|
requireMethod('getTypeScriptType');
|
|
77
71
|
requireMethod('getZodSchema');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-config.js","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAyBA;;;GAGG;AACH,SAAS,iBAAiB,CAAC,KAAkB;IAC3C,OAAO,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AACzF,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,cAAc,CAAC,KAAkB,EAAE,MAAc;IACxD,MAAM,KAAK,GAAY,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IACjD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAA;AACpC,CAAC;AAED
|
|
1
|
+
{"version":3,"file":"field-config.js","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAyBA;;;GAGG;AACH,SAAS,iBAAiB,CAAC,KAAkB;IAC3C,OAAO,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AACzF,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,cAAc,CAAC,KAAkB,EAAE,MAAc;IACxD,MAAM,KAAK,GAAY,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IACjD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAA;AACpC,CAAC;AAED,SAAS,YAAY,CACnB,SAAiB,EACjB,MAAmD,EACnD,QAAgB,EAChB,OAAgB;IAEhB,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,UAAU,OAAO,IAAI,QAAQ,GAAG,CAAC,CAAC,CAAC,UAAU,QAAQ,GAAG,CAAA;IACnF,OAAO,CACL,GAAG,QAAQ,WAAW,SAAS,kDAAkD;QACjF,GAAG,MAAM,mEAAmE;QAC5E,8DAA8D,CAC/D,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAkB,EAClB,QAAgB,EAChB,OAAgB;IAEhB,MAAM,MAAM,GAAiC,EAAE,CAAA;IAC/C,MAAM,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;IAE1C,MAAM,aAAa,GAAG,CAAC,MAAmD,EAAQ,EAAE;QAClF,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC;YACnC,MAAM,CAAC,IAAI,CAAC;gBACV,OAAO;gBACP,QAAQ;gBACR,SAAS;gBACT,aAAa,EAAE,MAAM;gBACrB,OAAO,EAAE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC;aAC5D,CAAC,CAAA;QACJ,CAAC;IACH,CAAC,CAAA;IAED,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;QAClC,aAAa,CAAC,mBAAmB,CAAC,CAAA;QAClC,OAAO,MAAM,CAAA;IACf,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACvD,aAAa,CAAC,mBAAmB,CAAC,CAAA;QAClC,aAAa,CAAC,cAAc,CAAC,CAAA;QAC7B,OAAO,MAAM,CAAA;IACf,CAAC;IAED,aAAa,CAAC,eAAe,CAAC,CAAA;IAC9B,aAAa,CAAC,mBAAmB,CAAC,CAAA;IAClC,aAAa,CAAC,cAAc,CAAC,CAAA;IAE7B,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAsB;IACzD,MAAM,MAAM,GAAiC,EAAE,CAAA;IAE/C,KAAK,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QACjE,IAAI,CAAC,UAAU,EAAE,MAAM;YAAE,SAAQ;QACjC,KAAK,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;YACxE,MAAM,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAA;QACrE,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC"}
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { FieldConfig } from '../config/types.js';
|
|
3
|
-
/**
|
|
4
|
-
* Generate Zod schema from field configurations
|
|
5
|
-
*/
|
|
6
3
|
export declare function generateZodSchema(fieldConfigs: Record<string, FieldConfig>, operation?: 'create' | 'update'): z.ZodObject;
|
|
7
|
-
/**
|
|
8
|
-
* Validate data against field configurations using Zod
|
|
9
|
-
* Returns structured errors by field
|
|
10
|
-
*/
|
|
11
4
|
export declare function validateWithZod(data: Record<string, unknown>, fieldConfigs: Record<string, FieldConfig>, operation?: 'create' | 'update'): {
|
|
12
5
|
success: true;
|
|
13
6
|
} | {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAErD
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAErD,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,SAAS,GAAE,QAAQ,GAAG,QAAmB,GACxC,CAAC,CAAC,SAAS,CAsBb;AAED,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,SAAS,GAAE,QAAQ,GAAG,QAAmB,GACxC;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAgBxE"}
|
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
/**
|
|
3
|
-
* Generate Zod schema from field configurations
|
|
4
|
-
*/
|
|
5
2
|
export function generateZodSchema(fieldConfigs, operation = 'create') {
|
|
6
3
|
const shape = {};
|
|
7
4
|
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
8
|
-
// Skip system fields, relationships, and virtual fields
|
|
9
5
|
// Virtual fields don't accept input - they only compute output
|
|
10
6
|
if (['id', 'createdAt', 'updatedAt'].includes(fieldName) ||
|
|
11
7
|
fieldConfig.type === 'relationship' ||
|
|
12
8
|
fieldConfig.virtual) {
|
|
13
9
|
continue;
|
|
14
10
|
}
|
|
15
|
-
// Use the field's schema generator
|
|
16
11
|
if (fieldConfig.getZodSchema) {
|
|
17
12
|
shape[fieldName] = fieldConfig.getZodSchema(fieldName, operation);
|
|
18
13
|
}
|
|
@@ -23,17 +18,12 @@ export function generateZodSchema(fieldConfigs, operation = 'create') {
|
|
|
23
18
|
}
|
|
24
19
|
return z.object(shape);
|
|
25
20
|
}
|
|
26
|
-
/**
|
|
27
|
-
* Validate data against field configurations using Zod
|
|
28
|
-
* Returns structured errors by field
|
|
29
|
-
*/
|
|
30
21
|
export function validateWithZod(data, fieldConfigs, operation = 'create') {
|
|
31
22
|
const schema = generateZodSchema(fieldConfigs, operation);
|
|
32
23
|
const result = schema.safeParse(data);
|
|
33
24
|
if (result.success) {
|
|
34
25
|
return { success: true };
|
|
35
26
|
}
|
|
36
|
-
// Convert Zod errors to field-specific error messages
|
|
37
27
|
const errors = {};
|
|
38
28
|
for (const issue of result.error.issues) {
|
|
39
29
|
const fieldPath = issue.path.join('.');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB,MAAM,UAAU,iBAAiB,CAC/B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,KAAK,GAAiC,EAAE,CAAA;IAE9C,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,+DAA+D;QAC/D,IACE,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;YACpD,WAAW,CAAC,IAAI,KAAK,cAAc;YACnC,WAAW,CAAC,OAAO,EACnB,CAAC;YACD,SAAQ;QACV,CAAC;QAED,IAAI,WAAW,CAAC,YAAY,EAAE,CAAC;YAC7B,KAAK,CAAC,SAAS,CAAC,GAAG,WAAW,CAAC,YAAY,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;QACnE,CAAC;aAAM,CAAC;YACN,4DAA4D;YAC5D,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAA;QACvC,CAAC;IACH,CAAC;IAED,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC;AAED,MAAM,UAAU,eAAe,CAC7B,IAA6B,EAC7B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,MAAM,GAAG,iBAAiB,CAAC,YAAY,EAAE,SAAS,CAAC,CAAA;IAEzD,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IAErC,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,MAAM,GAA2B,EAAE,CAAA;IACzC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QACxC,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACtC,MAAM,CAAC,SAAS,CAAC,GAAG,KAAK,CAAC,OAAO,CAAA;IACnC,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;AACnC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { Session, AccessContext, PrismaFilter } from './types.js'
|
|
2
|
-
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
2
|
+
import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js'
|
|
3
3
|
import { checkAccess, getRelatedListConfig } from './engine.js'
|
|
4
4
|
import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
|
|
5
|
-
import { AccessScopeDepthExceededError } from './errors.js'
|
|
5
|
+
import { AccessScopeDepthExceededError, RelationFilterAccessDeniedError } from './errors.js'
|
|
6
|
+
import {
|
|
7
|
+
LOGICAL_OPERATORS,
|
|
8
|
+
RELATION_QUANTIFIERS,
|
|
9
|
+
resolveQueryField,
|
|
10
|
+
walkWhereReadAccess,
|
|
11
|
+
} from './query-validation.js'
|
|
6
12
|
|
|
7
13
|
/**
|
|
8
14
|
* Access Filter — phase 1 of the two-phase read (pre-query).
|
|
@@ -63,8 +69,7 @@ function asEntryObject(value: unknown): IncludeEntryObject | null {
|
|
|
63
69
|
*
|
|
64
70
|
* The access filter is authoritative: the caller's filter may only NARROW the
|
|
65
71
|
* result further, never widen past what access permits. We therefore wrap both
|
|
66
|
-
* in a Prisma `AND` so neither can override the other.
|
|
67
|
-
* present, it is returned as-is; if neither is present, the result is undefined.
|
|
72
|
+
* in a Prisma `AND` so neither can override the other.
|
|
68
73
|
*/
|
|
69
74
|
function andWhere(
|
|
70
75
|
accessWhere: PrismaFilter | undefined,
|
|
@@ -173,6 +178,149 @@ export async function buildAccessScopedInclude(
|
|
|
173
178
|
return result
|
|
174
179
|
}
|
|
175
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Scope every relation filter (`some`/`every`/`none`/`is`/`isNot`) nested in a
|
|
183
|
+
* caller's `where` by the related list's own `query` access — the `where`
|
|
184
|
+
* counterpart to `buildAccessScopedInclude` above, closing #916 (the
|
|
185
|
+
* "unclosed half of ADR-0022"). `include` and `where` are fundamentally
|
|
186
|
+
* different requests (which relations come back, vs. which parent rows
|
|
187
|
+
* match), so this is a distinct function, but it shares every primitive that
|
|
188
|
+
* matters: `checkAccess`/`getRelatedListConfig` (the same access-evaluation
|
|
189
|
+
* calls `buildAccessScopedInclude` makes), `andWhere` (the same AND-fold), and
|
|
190
|
+
* `resolveQueryField`/`LOGICAL_OPERATORS`/`RELATION_QUANTIFIERS`/
|
|
191
|
+
* `walkWhereReadAccess` (the same shape-recognition and field-read check
|
|
192
|
+
* `query-validation.ts` already uses for #912/#915) — there is no second,
|
|
193
|
+
* parallel implementation of any of those decisions.
|
|
194
|
+
*
|
|
195
|
+
* For each relationship key found (at any depth — the walk recurses through
|
|
196
|
+
* `AND`/`OR`/`NOT` and through every hop of a chain):
|
|
197
|
+
* - Not a declared relationship, or the key #912 already rejected (this walk
|
|
198
|
+
* runs strictly after that check) → passed through unchanged.
|
|
199
|
+
* - The related list's `query` access denies it (`=== false`) → THROWS
|
|
200
|
+
* `RelationFilterAccessDeniedError`. Unlike `buildAccessScopedInclude`'s
|
|
201
|
+
* silent drop, this is a loud failure: a `where` predicate has no neutral
|
|
202
|
+
* "not requested" outcome the way a missing `include` key does, so a
|
|
203
|
+
* silently-empty match would itself be a distinguishable signal (ADR-0022).
|
|
204
|
+
* - Otherwise → the access filter (if any) is AND-combined into the relation
|
|
205
|
+
* quantifier's nested clause (never replacing the caller's own condition,
|
|
206
|
+
* mirroring `andWhere`'s include-side contract), keys inside that nested
|
|
207
|
+
* clause are checked against the RELATED list's field-level `read` access
|
|
208
|
+
* (`walkWhereReadAccess`, closing #915's stated gap for this path), and the
|
|
209
|
+
* walk recurses into the related list's own fields for a further hop.
|
|
210
|
+
*
|
|
211
|
+
* One quantifier is deliberately conservative rather than exactly precise:
|
|
212
|
+
* folding the access filter into `every`'s nested clause with a plain AND
|
|
213
|
+
* makes `every` require every related row to be BOTH access-visible AND
|
|
214
|
+
* matching, not "every access-visible row matches" (the latter needs a
|
|
215
|
+
* `NOT`/`OR` transform this does not attempt). The conservative version can
|
|
216
|
+
* only reject a query that the precise version would allow — it never
|
|
217
|
+
* widens what a caller can learn — so it is the safe direction to ship; a
|
|
218
|
+
* caller can observe that an inaccessible related row exists (an `every`
|
|
219
|
+
* that "should" pass instead fails), but never that row's field values,
|
|
220
|
+
* which is the property this ticket exists to close.
|
|
221
|
+
*/
|
|
222
|
+
export async function buildAccessScopedWhere(
|
|
223
|
+
where: unknown,
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
225
|
+
listConfig: ListConfig<any>,
|
|
226
|
+
listName: string,
|
|
227
|
+
config: OpenSaasConfig,
|
|
228
|
+
args: {
|
|
229
|
+
session: Session | null
|
|
230
|
+
context: AccessContext
|
|
231
|
+
},
|
|
232
|
+
): Promise<unknown> {
|
|
233
|
+
if (where === null || typeof where !== 'object') return where
|
|
234
|
+
|
|
235
|
+
if (Array.isArray(where)) {
|
|
236
|
+
return Promise.all(
|
|
237
|
+
where.map((entry) => buildAccessScopedWhere(entry, listConfig, listName, config, args)),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const result: Record<string, unknown> = {}
|
|
242
|
+
|
|
243
|
+
for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
|
|
244
|
+
if (LOGICAL_OPERATORS.has(key)) {
|
|
245
|
+
result[key] = await buildAccessScopedWhere(value, listConfig, listName, config, args)
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const resolved = resolveQueryField(key, listConfig.fields)
|
|
250
|
+
if (
|
|
251
|
+
!resolved ||
|
|
252
|
+
!resolved.isRelationship ||
|
|
253
|
+
value === null ||
|
|
254
|
+
typeof value !== 'object' ||
|
|
255
|
+
Array.isArray(value)
|
|
256
|
+
) {
|
|
257
|
+
result[key] = value
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
|
|
262
|
+
if (!related) {
|
|
263
|
+
result[key] = value
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const queryAccess = related.listConfig.access?.operation?.query
|
|
268
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
269
|
+
session: args.session,
|
|
270
|
+
context: args.context,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
if (accessResult === false) {
|
|
274
|
+
throw new RelationFilterAccessDeniedError(listName, key, related.listName)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
|
|
278
|
+
const relationEntries = Object.entries(value as Record<string, unknown>)
|
|
279
|
+
const hasQuantifier = relationEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
|
|
280
|
+
|
|
281
|
+
if (hasQuantifier) {
|
|
282
|
+
const nestedEntry: Record<string, unknown> = {}
|
|
283
|
+
for (const [quantifier, quantifierValue] of relationEntries) {
|
|
284
|
+
if (!RELATION_QUANTIFIERS.has(quantifier)) {
|
|
285
|
+
nestedEntry[quantifier] = quantifierValue
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
await walkWhereReadAccess(quantifierValue, related.listConfig, related.listName, args)
|
|
289
|
+
const scopedNested = await buildAccessScopedWhere(
|
|
290
|
+
quantifierValue,
|
|
291
|
+
related.listConfig,
|
|
292
|
+
related.listName,
|
|
293
|
+
config,
|
|
294
|
+
args,
|
|
295
|
+
)
|
|
296
|
+
nestedEntry[quantifier] = accessWhere
|
|
297
|
+
? andWhere(accessWhere, scopedNested as PrismaFilter | undefined)
|
|
298
|
+
: scopedNested
|
|
299
|
+
}
|
|
300
|
+
result[key] = nestedEntry
|
|
301
|
+
} else {
|
|
302
|
+
// Prisma's direct-nesting to-one form: the whole value IS the nested
|
|
303
|
+
// WHERE clause, with no `is` wrapper. Only wrap it in `is` when there is
|
|
304
|
+
// an access filter to fold in — an unwrapped value that needs no fold
|
|
305
|
+
// (fully-allowed related list) is returned exactly as the caller wrote
|
|
306
|
+
// it, so an already-permitted query is not perturbed by this pass.
|
|
307
|
+
await walkWhereReadAccess(value, related.listConfig, related.listName, args)
|
|
308
|
+
const scopedNested = await buildAccessScopedWhere(
|
|
309
|
+
value,
|
|
310
|
+
related.listConfig,
|
|
311
|
+
related.listName,
|
|
312
|
+
config,
|
|
313
|
+
args,
|
|
314
|
+
)
|
|
315
|
+
result[key] = accessWhere
|
|
316
|
+
? { is: andWhere(accessWhere, scopedNested as PrismaFilter | undefined) }
|
|
317
|
+
: scopedNested
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result
|
|
322
|
+
}
|
|
323
|
+
|
|
176
324
|
/**
|
|
177
325
|
* Remove keys that correspond to `virtual` fields from a Prisma `include`
|
|
178
326
|
* object, recursing into nested relationship includes using the related
|
|
@@ -201,7 +349,6 @@ export function stripVirtualFieldsFromInclude(
|
|
|
201
349
|
for (const [key, value] of Object.entries(include)) {
|
|
202
350
|
const fieldConfig = fieldConfigs[key]
|
|
203
351
|
|
|
204
|
-
// Virtual fields have no database column — drop them from the include.
|
|
205
352
|
if (fieldConfig?.virtual) continue
|
|
206
353
|
|
|
207
354
|
const isDeclaredRelationship =
|
|
@@ -180,12 +180,8 @@ export function foldDeclaredDependencies(
|
|
|
180
180
|
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
|
|
181
181
|
if (!relatedConfig) continue
|
|
182
182
|
// Defensive cycle guard (see module doc comment) — a DECLARATION-ADDED
|
|
183
|
-
// edge into a list already on this path stops here
|
|
184
|
-
//
|
|
185
|
-
// deliberately not applied to an edge the request itself named: that
|
|
186
|
-
// recursion is bounded by the request's own finite literal and cannot
|
|
187
|
-
// loop, so stopping it would silently drop the folds beneath a request
|
|
188
|
-
// that merely revisits a list (e.g. `Post → author → posts`).
|
|
183
|
+
// edge into a list already on this path stops here; the value at `key`
|
|
184
|
+
// is left exactly as-is.
|
|
189
185
|
if (declaredOnly.keys.has(key) && visitedLists.includes(relatedConfig.listName)) continue
|
|
190
186
|
|
|
191
187
|
// A branch added purely by the fold has no fragment scope of its own —
|
|
@@ -213,9 +209,8 @@ export function foldDeclaredDependencies(
|
|
|
213
209
|
}
|
|
214
210
|
}
|
|
215
211
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// individual nested-key tracking of its own.
|
|
212
|
+
// Declaration-added branches need no nested tracking of their own (see
|
|
213
|
+
// module doc comment) — only caller/fragment-named branches do.
|
|
219
214
|
if (!declaredOnly.keys.has(key) && !isDeclaredOnlyTreeEmpty(nested.declaredOnly)) {
|
|
220
215
|
declaredOnly.nested[key] = nested.declaredOnly
|
|
221
216
|
}
|
package/src/access/engine.ts
CHANGED
|
@@ -16,28 +16,14 @@ import type { OpenSaasConfig, ListConfig } from '../config/types.js'
|
|
|
16
16
|
* and the access-control glossary in `CONTEXT.md`.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Check if access control result is a boolean
|
|
21
|
-
*/
|
|
22
19
|
export function isBoolean(value: unknown): value is boolean {
|
|
23
20
|
return typeof value === 'boolean'
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
/**
|
|
27
|
-
* Check if access control result is a Prisma filter
|
|
28
|
-
*/
|
|
29
23
|
export function isPrismaFilter(value: unknown): value is PrismaFilter {
|
|
30
24
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
31
25
|
}
|
|
32
26
|
|
|
33
|
-
/**
|
|
34
|
-
* Parse a relationship ref and get the related list configuration
|
|
35
|
-
* Relationship refs are in the format "ListName.fieldName"
|
|
36
|
-
*
|
|
37
|
-
* @param relationshipRef - The ref string (e.g., "Post.author")
|
|
38
|
-
* @param config - The OpenSaas configuration
|
|
39
|
-
* @returns The related list name and config, or null if not found
|
|
40
|
-
*/
|
|
41
27
|
export function getRelatedListConfig(
|
|
42
28
|
relationshipRef: string,
|
|
43
29
|
config: OpenSaasConfig,
|
|
@@ -59,9 +45,6 @@ export function getRelatedListConfig(
|
|
|
59
45
|
return { listName, listConfig }
|
|
60
46
|
}
|
|
61
47
|
|
|
62
|
-
/**
|
|
63
|
-
* Execute an access control function
|
|
64
|
-
*/
|
|
65
48
|
export async function checkAccess<T = Record<string, unknown>>(
|
|
66
49
|
accessControl: AccessControl<T> | undefined,
|
|
67
50
|
args: {
|
|
@@ -75,35 +58,27 @@ export async function checkAccess<T = Record<string, unknown>>(
|
|
|
75
58
|
return false
|
|
76
59
|
}
|
|
77
60
|
|
|
78
|
-
// Execute the access control function
|
|
79
61
|
const result = await accessControl(args)
|
|
80
62
|
|
|
81
63
|
return result
|
|
82
64
|
}
|
|
83
65
|
|
|
84
|
-
/**
|
|
85
|
-
* Merge user filter with access control filter
|
|
86
|
-
*/
|
|
87
66
|
export function mergeFilters(
|
|
88
67
|
userFilter: PrismaFilter | undefined,
|
|
89
68
|
accessFilter: boolean | PrismaFilter,
|
|
90
69
|
): PrismaFilter | null {
|
|
91
|
-
// If access is denied, return null
|
|
92
70
|
if (accessFilter === false) {
|
|
93
71
|
return null
|
|
94
72
|
}
|
|
95
73
|
|
|
96
|
-
// If access is fully granted, use user filter
|
|
97
74
|
if (accessFilter === true) {
|
|
98
75
|
return userFilter || {}
|
|
99
76
|
}
|
|
100
77
|
|
|
101
|
-
// Merge access filter with user filter
|
|
102
78
|
if (!userFilter) {
|
|
103
79
|
return accessFilter
|
|
104
80
|
}
|
|
105
81
|
|
|
106
|
-
// Combine filters with AND
|
|
107
82
|
return {
|
|
108
83
|
AND: [accessFilter, userFilter],
|
|
109
84
|
}
|
package/src/access/errors.ts
CHANGED
|
@@ -64,3 +64,84 @@ export class ResolveOutputCycleError extends Error {
|
|
|
64
64
|
this.chain = chain
|
|
65
65
|
}
|
|
66
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
|
+
}
|