@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
|
@@ -3,15 +3,10 @@ import { checkFieldAccess } from '../access/field-access.js';
|
|
|
3
3
|
import { executeResolveInput, executeValidate, executeFieldResolveInputHooks, executeBeforeOperation, executeAfterOperation, executeFieldBeforeOperationHooks, executeFieldAfterOperationHooks, executeFieldValidateHooks, validateFieldRules, splitMultiColumnFields, ValidationError, } from '../hooks/index.js';
|
|
4
4
|
import { getDbKey } from '../lib/case-utils.js';
|
|
5
5
|
import { applyCreateDefaults } from './apply-defaults.js';
|
|
6
|
-
/**
|
|
7
|
-
* Check if a field config is a relationship field
|
|
8
|
-
*/
|
|
9
6
|
function isRelationshipField(fieldConfig) {
|
|
10
7
|
return fieldConfig?.type === 'relationship';
|
|
11
8
|
}
|
|
12
|
-
/**
|
|
13
|
-
* Resolve the related list name for a related list config (config object identity).
|
|
14
|
-
*/
|
|
9
|
+
/** Resolve a list name by matching config object identity, not by name. */
|
|
15
10
|
function findListName(
|
|
16
11
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
17
12
|
relatedListConfig, config) {
|
|
@@ -26,8 +21,7 @@ relatedListConfig, config) {
|
|
|
26
21
|
* Read the rows of a parent's included relation as an array.
|
|
27
22
|
*
|
|
28
23
|
* A to-one relation comes back as a single row (or `null`); a to-many relation
|
|
29
|
-
* comes back as an array.
|
|
30
|
-
* a uniform id-diff.
|
|
24
|
+
* comes back as an array. Normalises both so callers can apply a uniform id-diff.
|
|
31
25
|
*/
|
|
32
26
|
function includedRows(parentResult, fieldName) {
|
|
33
27
|
const included = parentResult[fieldName];
|
|
@@ -49,13 +43,10 @@ function recoverUpdatedRow(parentResult, fieldName, knownId) {
|
|
|
49
43
|
return includedRows(parentResult, fieldName).find((r) => r.id === knownId);
|
|
50
44
|
}
|
|
51
45
|
/**
|
|
52
|
-
* Recover the CREATED nested rows from the parent result by id-diff
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* ids captured before the persist). Returned in include order, which the create
|
|
57
|
-
* handler pairs to its create-payload entries by position (see
|
|
58
|
-
* {@link CreatedRowRecovery}).
|
|
46
|
+
* Recover the CREATED nested rows from the parent result by id-diff: the
|
|
47
|
+
* included rows whose ids are NOT in `preExistingIds`. Returned in include
|
|
48
|
+
* order, which {@link CreatedRowRecovery} pairs to create-payload entries by
|
|
49
|
+
* position — reordering this would break that pairing.
|
|
59
50
|
*/
|
|
60
51
|
function recoverCreatedRows(parentResult, fieldName, preExistingIds) {
|
|
61
52
|
return includedRows(parentResult, fieldName).filter((r) => typeof r.id === 'string' && !preExistingIds.has(r.id));
|
|
@@ -76,14 +67,11 @@ function createCreatedRowRecovery(fieldName, preExistingIds) {
|
|
|
76
67
|
}
|
|
77
68
|
/**
|
|
78
69
|
* Capture the ids of the rows currently linked to the parent via `fieldName`,
|
|
79
|
-
* BEFORE the parent persists
|
|
80
|
-
* (
|
|
70
|
+
* BEFORE the parent persists, so created rows can later be identified by id-diff.
|
|
71
|
+
* Empty for a parent CREATE (no parent row exists yet to have related rows).
|
|
81
72
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* - For a parent UPDATE we read the parent row's current relation and collect
|
|
85
|
-
* its ids. The same `tx` client is used so the read participates in the
|
|
86
|
-
* transaction and sees a consistent snapshot.
|
|
73
|
+
* `prisma` must be the transaction client — the read has to participate in the
|
|
74
|
+
* transaction to see a consistent snapshot.
|
|
87
75
|
*/
|
|
88
76
|
async function capturePreExistingIds(parentListName, parentOriginalItem, fieldName, prisma) {
|
|
89
77
|
const ids = new Set();
|
|
@@ -108,18 +96,15 @@ async function capturePreExistingIds(parentListName, parentOriginalItem, fieldNa
|
|
|
108
96
|
return ids;
|
|
109
97
|
}
|
|
110
98
|
/**
|
|
111
|
-
* Process nested create operations
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* field-rules → filter-writable → recurse) AND its `beforeOperation`, then
|
|
115
|
-
* registers an `afterOperation` task keyed to the parent's included relation.
|
|
99
|
+
* Process nested create operations: run the target list's full input pipeline
|
|
100
|
+
* and `beforeOperation`, then register an `afterOperation` task keyed to the
|
|
101
|
+
* parent's included relation.
|
|
116
102
|
*/
|
|
117
103
|
async function processNestedCreate(items, fieldName, relatedListName,
|
|
118
104
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
119
105
|
relatedListConfig, context, config, prisma, afterTasks, recovery) {
|
|
120
106
|
const itemsArray = Array.isArray(items) ? items : [items];
|
|
121
107
|
const processedItems = await Promise.all(itemsArray.map(async (item, index) => {
|
|
122
|
-
// 1. Check create access (skip if sudo mode)
|
|
123
108
|
if (!context._isSudo) {
|
|
124
109
|
const createAccess = relatedListConfig.access?.operation?.create;
|
|
125
110
|
const accessResult = await checkAccess(createAccess, {
|
|
@@ -130,7 +115,6 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
|
|
|
130
115
|
throw new Error('Access denied: Cannot create related item');
|
|
131
116
|
}
|
|
132
117
|
}
|
|
133
|
-
// 2. Execute list-level resolveInput hook
|
|
134
118
|
let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
|
|
135
119
|
listKey: relatedListName,
|
|
136
120
|
operation: 'create',
|
|
@@ -139,14 +123,11 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
|
|
|
139
123
|
item: undefined,
|
|
140
124
|
context,
|
|
141
125
|
});
|
|
142
|
-
// 3. Execute field-level resolveInput hooks
|
|
143
126
|
resolvedData = await executeFieldResolveInputHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// `isRequired`. Create-only; explicit values (incl. null) are preserved.
|
|
127
|
+
// Apply field defaults to omitted inputs (resolve-then-validate, #615) so
|
|
128
|
+
// a nested required-with-default field resolves before `isRequired` runs,
|
|
129
|
+
// mirroring the top-level pipeline. Create-only.
|
|
148
130
|
resolvedData = applyCreateDefaults(resolvedData, relatedListConfig.fields);
|
|
149
|
-
// 4. Execute validate hook
|
|
150
131
|
await executeValidate(relatedListConfig.hooks, {
|
|
151
132
|
listKey: relatedListName,
|
|
152
133
|
operation: 'create',
|
|
@@ -155,32 +136,26 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
|
|
|
155
136
|
item: undefined,
|
|
156
137
|
context,
|
|
157
138
|
});
|
|
158
|
-
// 4.5 Field-level validate hooks
|
|
159
139
|
await executeFieldValidateHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
|
|
160
|
-
// 5. Field validation (built-in rules)
|
|
161
140
|
const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'create');
|
|
162
141
|
if (validation.errors.length > 0) {
|
|
163
142
|
throw new ValidationError(validation.errors, validation.fieldErrors);
|
|
164
143
|
}
|
|
165
|
-
//
|
|
166
|
-
//
|
|
144
|
+
// Split multi-column fields into physical columns (#789) — must run after
|
|
145
|
+
// validation, which operates on the logical (pre-split) value.
|
|
167
146
|
resolvedData = await splitMultiColumnFields(item, resolvedData, relatedListConfig.fields, 'create', context);
|
|
168
|
-
// 6. Filter writable fields
|
|
169
147
|
const filtered = await filterWritableFields(resolvedData, relatedListConfig.fields, 'create', {
|
|
170
148
|
session: context.session,
|
|
171
149
|
context,
|
|
172
150
|
inputData: item,
|
|
173
151
|
});
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// (parent originalItem is undefined → empty pre-existing set).
|
|
152
|
+
// This nested row is itself being created, so it has no pre-existing
|
|
153
|
+
// related rows of its own (parent originalItem is undefined below).
|
|
177
154
|
const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(filtered, relatedListConfig.fields, config, { ...context, prisma }, 'create', relatedListName, undefined,
|
|
178
|
-
//
|
|
179
|
-
//
|
|
155
|
+
// Its own create payload is the enclosing inputData for the connect-site
|
|
156
|
+
// owning-field gate (#588).
|
|
180
157
|
item);
|
|
181
|
-
// 8. Field-level beforeOperation (side effects) for this nested create
|
|
182
158
|
await executeFieldBeforeOperationHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
|
|
183
|
-
// 9. List-level beforeOperation for this nested create
|
|
184
159
|
await executeBeforeOperation(relatedListConfig.hooks, {
|
|
185
160
|
listKey: relatedListName,
|
|
186
161
|
operation: 'create',
|
|
@@ -188,26 +163,19 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
|
|
|
188
163
|
resolvedData,
|
|
189
164
|
context,
|
|
190
165
|
});
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// to-many `create: [{A},{B}]` fires once per row, each against its OWN
|
|
195
|
-
// distinct row, and never against a pre-existing sibling.
|
|
166
|
+
// Fires once the parent has persisted. The row is recovered by id-diff and
|
|
167
|
+
// paired to THIS entry by position (see CreatedRowRecovery), so a to-many
|
|
168
|
+
// `create: [{A},{B}]` fires once per row against its own distinct row.
|
|
196
169
|
afterTasks.push({
|
|
197
170
|
fieldName,
|
|
198
171
|
run: async (parentResult) => {
|
|
199
172
|
const createdItem = recovery.rowAt(parentResult, index);
|
|
200
173
|
if (!createdItem) {
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// before-persist hooks have already run; we deliberately SKIP this
|
|
207
|
-
// record's create `afterOperation` rather than fire it with a bogus
|
|
208
|
-
// item. Real Prisma always echoes the `include`d relation, so this
|
|
209
|
-
// skip is reached only by clients/mocks that omit it. `item`
|
|
210
|
-
// correctness is the must-have; a missing row is never fabricated.
|
|
174
|
+
// Could not identify the created row by id-diff — the parent write
|
|
175
|
+
// didn't echo this nested relation (e.g. a client/mock that doesn't
|
|
176
|
+
// honour `include`; real Prisma always does). Deliberately SKIP this
|
|
177
|
+
// record's `afterOperation` rather than fabricate an id-less item —
|
|
178
|
+
// `item` correctness is the must-have, so a missing row is never faked.
|
|
211
179
|
return;
|
|
212
180
|
}
|
|
213
181
|
await executeAfterOperation(relatedListConfig.hooks, {
|
|
@@ -219,7 +187,6 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
|
|
|
219
187
|
context,
|
|
220
188
|
});
|
|
221
189
|
await executeFieldAfterOperationHooks(createdItem, item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
|
|
222
|
-
// Run any deeper nested afterOperation tasks, scoped to the persisted row.
|
|
223
190
|
await runAfterTasks(childAfterTasks, createdItem);
|
|
224
191
|
},
|
|
225
192
|
});
|
|
@@ -230,23 +197,18 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
|
|
|
230
197
|
/**
|
|
231
198
|
* Verify that a single connection target is reachable for the caller.
|
|
232
199
|
*
|
|
233
|
-
* Connecting an existing row
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* nested-relation predicates and boolean combinators (`AND`/`OR`/`some`/
|
|
240
|
-
* `none`/`not`). The existence check is folded into the reachability query so a
|
|
241
|
-
* non-existent id is still denied.
|
|
200
|
+
* Connecting references an existing row rather than modifying it, so — mirroring
|
|
201
|
+
* Keystone — it requires **read/query** access on the target (#578), not update.
|
|
202
|
+
* A filter-result query access is evaluated in the DATABASE via
|
|
203
|
+
* `findFirst({ where: { AND: [connection, accessFilter] } })` rather than in
|
|
204
|
+
* memory, so it correctly handles arbitrary nested-relation predicates and
|
|
205
|
+
* boolean combinators; a non-existent id is folded into the same check.
|
|
242
206
|
*
|
|
243
|
-
* In ADDITION
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* field's field-level access denies, the connect is denied even when the target
|
|
249
|
-
* row is readable/reachable.
|
|
207
|
+
* In ADDITION, the OWNING relationship field's field-level access (e.g.
|
|
208
|
+
* `Post.author`'s `create`/`update` access) must permit the connect (#588) —
|
|
209
|
+
* the other half Keystone requires: read access on the target AND write access
|
|
210
|
+
* on the owning field. A deny here denies the connect even when the target row
|
|
211
|
+
* is readable/reachable.
|
|
250
212
|
*
|
|
251
213
|
* Sudo bypasses the entire check (handled by the caller).
|
|
252
214
|
*/
|
|
@@ -256,18 +218,10 @@ relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclo
|
|
|
256
218
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
257
219
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
258
220
|
const model = prisma[getDbKey(relatedListName)];
|
|
259
|
-
// #588
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
// `true` under sudo, but the caller already skips this whole function for
|
|
264
|
-
// sudo, so the gate never fires for trusted writes.
|
|
265
|
-
//
|
|
266
|
-
// `item`/`inputData` are the ENCLOSING write's `originalItem`/`inputData` —
|
|
267
|
-
// the SAME values the canonical Phase-5 `filterWritableFields` call passes for
|
|
268
|
-
// this field — so a field-access rule that depends on `item` or `inputData`
|
|
269
|
-
// (e.g. `({ item }) => item.status === 'draft'`) evaluates identically here and
|
|
270
|
-
// at Phase 5, and the two gates cannot diverge into a spurious connect denial.
|
|
221
|
+
// #588 owning-field gate (see docblock above). `item`/`inputData` are the
|
|
222
|
+
// ENCLOSING write's `originalItem`/`inputData` — the same values the canonical
|
|
223
|
+
// Phase-5 `filterWritableFields` call passes for this field — so an
|
|
224
|
+
// item-/inputData-dependent field-access rule can't diverge between the two gates.
|
|
271
225
|
const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
|
|
272
226
|
session: context.session,
|
|
273
227
|
item: enclosingItem,
|
|
@@ -277,18 +231,15 @@ relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclo
|
|
|
277
231
|
if (!owningFieldAllowed) {
|
|
278
232
|
throw new Error('Access denied: Cannot connect to this item');
|
|
279
233
|
}
|
|
280
|
-
// Connecting references an existing row; it requires READ (query) access on
|
|
281
|
-
// the target, not update access.
|
|
282
234
|
const queryAccess = relatedListConfig.access?.operation?.query;
|
|
283
235
|
const accessResult = await checkAccess(queryAccess, {
|
|
284
236
|
session: context.session,
|
|
285
237
|
context,
|
|
286
238
|
});
|
|
287
|
-
// Explicit denial.
|
|
288
239
|
if (accessResult === false) {
|
|
289
240
|
throw new Error('Access denied: Cannot connect to this item');
|
|
290
241
|
}
|
|
291
|
-
// Full access
|
|
242
|
+
// Full access still verifies the row exists, to keep "Item not found" behaviour.
|
|
292
243
|
if (accessResult === true) {
|
|
293
244
|
const item = await model.findUnique({ where: connection });
|
|
294
245
|
if (!item) {
|
|
@@ -296,11 +247,8 @@ relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclo
|
|
|
296
247
|
}
|
|
297
248
|
return;
|
|
298
249
|
}
|
|
299
|
-
// Filter result:
|
|
300
|
-
//
|
|
301
|
-
// A non-existent id and an unreachable row both yield no row → denied. This
|
|
302
|
-
// correctly evaluates arbitrary nested-relation predicates and boolean
|
|
303
|
-
// combinators because the database does the matching, not an in-memory walk.
|
|
250
|
+
// Filter result: reachable iff AND-combining the connection with the filter
|
|
251
|
+
// returns a row (see docblock above).
|
|
304
252
|
const reachable = await model.findFirst({
|
|
305
253
|
where: { AND: [connection, accessResult] },
|
|
306
254
|
});
|
|
@@ -317,7 +265,6 @@ async function processNestedConnect(connections, relatedListName,
|
|
|
317
265
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
318
266
|
relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData) {
|
|
319
267
|
const connectionsArray = Array.isArray(connections) ? connections : [connections];
|
|
320
|
-
// Check read access for each item being connected (skip if sudo mode)
|
|
321
268
|
if (!context._isSudo) {
|
|
322
269
|
for (const connection of connectionsArray) {
|
|
323
270
|
await verifyConnectReachable(connection, relatedListName, relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData);
|
|
@@ -341,15 +288,12 @@ relatedListConfig, context, config, prisma, afterTasks) {
|
|
|
341
288
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
342
289
|
const model = prisma[getDbKey(relatedListName)];
|
|
343
290
|
const where = update.where;
|
|
344
|
-
//
|
|
291
|
+
// Fetched here and reused as `originalItem` for afterOperation.
|
|
345
292
|
const originalItem = await model.findUnique({ where });
|
|
346
293
|
if (!originalItem) {
|
|
347
294
|
throw new Error('Cannot update: Item not found');
|
|
348
295
|
}
|
|
349
|
-
// The updated row's id is known up front, so the included-result read-back
|
|
350
|
-
// finds this row directly by id.
|
|
351
296
|
const knownId = typeof originalItem.id === 'string' ? originalItem.id : undefined;
|
|
352
|
-
// Check update access (skip if sudo mode)
|
|
353
297
|
if (!context._isSudo) {
|
|
354
298
|
const updateAccess = relatedListConfig.access?.operation?.update;
|
|
355
299
|
const accessResult = await checkAccess(updateAccess, {
|
|
@@ -361,7 +305,6 @@ relatedListConfig, context, config, prisma, afterTasks) {
|
|
|
361
305
|
throw new Error('Access denied: Cannot update related item');
|
|
362
306
|
}
|
|
363
307
|
}
|
|
364
|
-
// Execute list-level resolveInput hook
|
|
365
308
|
const updateData = update.data;
|
|
366
309
|
let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
|
|
367
310
|
listKey: relatedListName,
|
|
@@ -371,9 +314,7 @@ relatedListConfig, context, config, prisma, afterTasks) {
|
|
|
371
314
|
item: originalItem,
|
|
372
315
|
context,
|
|
373
316
|
});
|
|
374
|
-
// Execute field-level resolveInput hooks
|
|
375
317
|
resolvedData = await executeFieldResolveInputHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
|
|
376
|
-
// Execute validate hook
|
|
377
318
|
await executeValidate(relatedListConfig.hooks, {
|
|
378
319
|
listKey: relatedListName,
|
|
379
320
|
operation: 'update',
|
|
@@ -382,32 +323,27 @@ relatedListConfig, context, config, prisma, afterTasks) {
|
|
|
382
323
|
item: originalItem,
|
|
383
324
|
context,
|
|
384
325
|
});
|
|
385
|
-
// Field-level validate hooks
|
|
386
326
|
await executeFieldValidateHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
|
|
387
|
-
// Field validation (built-in rules)
|
|
388
327
|
const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'update');
|
|
389
328
|
if (validation.errors.length > 0) {
|
|
390
329
|
throw new ValidationError(validation.errors, validation.fieldErrors);
|
|
391
330
|
}
|
|
392
|
-
// Split multi-column fields into physical columns (#789)
|
|
393
|
-
//
|
|
331
|
+
// Split multi-column fields into physical columns (#789) — must run after
|
|
332
|
+
// validation, which operates on the logical (pre-split) value.
|
|
394
333
|
resolvedData = await splitMultiColumnFields(updateData, resolvedData, relatedListConfig.fields, 'update', context, originalItem);
|
|
395
|
-
// Filter writable fields
|
|
396
334
|
const filtered = await filterWritableFields(resolvedData, relatedListConfig.fields, 'update', {
|
|
397
335
|
session: context.session,
|
|
398
336
|
item: originalItem,
|
|
399
337
|
context,
|
|
400
338
|
inputData: updateData,
|
|
401
339
|
});
|
|
402
|
-
//
|
|
403
|
-
//
|
|
340
|
+
// This nested row is being updated, so its own relations' pre-existing
|
|
341
|
+
// rows are captured from `originalItem` below.
|
|
404
342
|
const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(filtered, relatedListConfig.fields, config, { ...context, prisma }, 'update', relatedListName, originalItem,
|
|
405
|
-
//
|
|
406
|
-
//
|
|
343
|
+
// Its own update payload is the enclosing inputData for the connect-site
|
|
344
|
+
// owning-field gate (#588).
|
|
407
345
|
updateData);
|
|
408
|
-
// Field-level beforeOperation (side effects)
|
|
409
346
|
await executeFieldBeforeOperationHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
|
|
410
|
-
// List-level beforeOperation
|
|
411
347
|
await executeBeforeOperation(relatedListConfig.hooks, {
|
|
412
348
|
listKey: relatedListName,
|
|
413
349
|
operation: 'update',
|
|
@@ -416,8 +352,7 @@ relatedListConfig, context, config, prisma, afterTasks) {
|
|
|
416
352
|
resolvedData,
|
|
417
353
|
context,
|
|
418
354
|
});
|
|
419
|
-
//
|
|
420
|
-
// is recovered from the parent's included relation by its known id.
|
|
355
|
+
// Fires after the parent persist; the row is recovered by its known id.
|
|
421
356
|
afterTasks.push({
|
|
422
357
|
fieldName,
|
|
423
358
|
run: async (parentResult) => {
|
|
@@ -470,7 +405,6 @@ relatedListConfig, context, prisma, afterTasks) {
|
|
|
470
405
|
if (!originalItem) {
|
|
471
406
|
throw new Error('Cannot delete: Item not found');
|
|
472
407
|
}
|
|
473
|
-
// Check delete access (skip if sudo mode)
|
|
474
408
|
if (!context._isSudo) {
|
|
475
409
|
const deleteAccess = relatedListConfig.access?.operation?.delete;
|
|
476
410
|
const accessResult = await checkAccess(deleteAccess, {
|
|
@@ -482,26 +416,22 @@ relatedListConfig, context, prisma, afterTasks) {
|
|
|
482
416
|
throw new Error('Access denied: Cannot delete related item');
|
|
483
417
|
}
|
|
484
418
|
}
|
|
485
|
-
// List-level validate (delete)
|
|
486
419
|
await executeValidate(relatedListConfig.hooks, {
|
|
487
420
|
listKey: relatedListName,
|
|
488
421
|
operation: 'delete',
|
|
489
422
|
item: originalItem,
|
|
490
423
|
context,
|
|
491
424
|
});
|
|
492
|
-
// Field-level validate (delete)
|
|
493
425
|
await executeFieldValidateHooks(undefined, undefined, relatedListConfig.fields, 'delete', context, relatedListName, originalItem);
|
|
494
|
-
// Field-level beforeOperation (delete)
|
|
495
426
|
await executeFieldBeforeOperationHooks({}, {}, relatedListConfig.fields, 'delete', context, relatedListName, originalItem);
|
|
496
|
-
// List-level beforeOperation (delete)
|
|
497
427
|
await executeBeforeOperation(relatedListConfig.hooks, {
|
|
498
428
|
listKey: relatedListName,
|
|
499
429
|
operation: 'delete',
|
|
500
430
|
item: originalItem,
|
|
501
431
|
context,
|
|
502
432
|
});
|
|
503
|
-
//
|
|
504
|
-
//
|
|
433
|
+
// The row is gone after persist, so originalItem is the authoritative
|
|
434
|
+
// record passed to the after-hooks below.
|
|
505
435
|
afterTasks.push({
|
|
506
436
|
fieldName: '',
|
|
507
437
|
run: async () => {
|
|
@@ -517,41 +447,25 @@ relatedListConfig, context, prisma, afterTasks) {
|
|
|
517
447
|
}));
|
|
518
448
|
return deletes;
|
|
519
449
|
}
|
|
520
|
-
/**
|
|
521
|
-
* Process nested connectOrCreate operations
|
|
522
|
-
*/
|
|
523
450
|
async function processNestedConnectOrCreate(operations, fieldName, relatedListName,
|
|
524
451
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
525
452
|
relatedListConfig, context, config, prisma, afterTasks, recovery, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData) {
|
|
526
453
|
const operationsArray = Array.isArray(operations) ? operations : [operations];
|
|
527
454
|
const processedOps = await Promise.all(operationsArray.map(async (op) => {
|
|
528
455
|
const opRecord = op;
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
// DB reachability for filter results, PLUS the owning relationship field's
|
|
535
|
-
// field-level access (#588). When the row does not exist we fall through to
|
|
536
|
-
// create. We must NOT swallow an access-denied error: only the genuine
|
|
537
|
-
// "row absent" case may fall back to create.
|
|
456
|
+
// When the target row exists, connectOrCreate applies the same connect
|
|
457
|
+
// semantics as processNestedConnect (read access + #588 owning-field gate,
|
|
458
|
+
// both evaluated via DB reachability); otherwise it falls through to
|
|
459
|
+
// create. An access-denied error must NOT be swallowed into that fallback
|
|
460
|
+
// — only a genuine "row absent" may fall back to create.
|
|
538
461
|
let rowExists = false;
|
|
539
462
|
if (!context._isSudo) {
|
|
540
463
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
541
464
|
const model = prisma[getDbKey(relatedListName)];
|
|
542
465
|
const where = opRecord.where;
|
|
543
466
|
const existingItem = await model.findUnique({ where });
|
|
544
|
-
// Only enforce connect access when the row actually exists; otherwise
|
|
545
|
-
// the create branch is used.
|
|
546
467
|
if (existingItem) {
|
|
547
468
|
rowExists = true;
|
|
548
|
-
// #588 — gate the connect branch by the OWNING relationship field's
|
|
549
|
-
// field-level access, identical to processNestedConnect. A deny here
|
|
550
|
-
// denies the connect even if the target row is readable/reachable.
|
|
551
|
-
// `item`/`inputData` are the ENCLOSING write's `originalItem`/
|
|
552
|
-
// `inputData` (the same values Phase-5 `filterWritableFields` passes),
|
|
553
|
-
// so item-/inputData-dependent field rules cannot diverge between the
|
|
554
|
-
// two gates.
|
|
555
469
|
const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
|
|
556
470
|
session: context.session,
|
|
557
471
|
item: enclosingItem,
|
|
@@ -570,8 +484,6 @@ relatedListConfig, context, config, prisma, afterTasks, recovery, owningFieldAcc
|
|
|
570
484
|
if (accessResult === false) {
|
|
571
485
|
throw new Error('Access denied: Cannot connect to existing item');
|
|
572
486
|
}
|
|
573
|
-
// Filter result: confirm the existing row is reachable under the
|
|
574
|
-
// access filter via DB reachability (handles nested/boolean filters).
|
|
575
487
|
if (accessResult !== true) {
|
|
576
488
|
const reachable = await model.findFirst({
|
|
577
489
|
where: { AND: [where, accessResult] },
|
|
@@ -582,11 +494,10 @@ relatedListConfig, context, config, prisma, afterTasks, recovery, owningFieldAcc
|
|
|
582
494
|
}
|
|
583
495
|
}
|
|
584
496
|
}
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
// create pipeline run its hooks (sudo bypasses access only, not hooks).
|
|
497
|
+
// Only register an afterOperation task when the create branch will
|
|
498
|
+
// actually run (row absent), so a pure connect doesn't fire create hooks.
|
|
499
|
+
// Under sudo we can't tell statically, so the create pipeline always runs
|
|
500
|
+
// its hooks there (sudo bypasses access only, not hooks).
|
|
590
501
|
const runCreateHooks = context._isSudo || !rowExists;
|
|
591
502
|
const createAfterTasks = runCreateHooks ? afterTasks : [];
|
|
592
503
|
const processedCreate = await processNestedCreate(opRecord.create, fieldName, relatedListName, relatedListConfig, context, config, prisma, createAfterTasks, recovery);
|
|
@@ -708,22 +619,18 @@ async function processFieldNestedOps(fieldName, valueRecord, args, includeFields
|
|
|
708
619
|
* parent persist via {@link runAfterTasks}). See ADR-0010.
|
|
709
620
|
*/
|
|
710
621
|
export async function processNestedOperations(data, fieldConfigs, config, context, operation, parentListName, parentOriginalItem,
|
|
711
|
-
// The enclosing write's
|
|
712
|
-
//
|
|
713
|
-
// finding) so item-/inputData-dependent field-access rules cannot diverge between
|
|
714
|
-
// Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
|
|
622
|
+
// The enclosing write's inputData, for the #588 owning-field gate (see
|
|
623
|
+
// verifyConnectReachable). `undefined` is tolerated (defaults to `{}`).
|
|
715
624
|
parentInputData = undefined) {
|
|
716
625
|
const afterTasks = [];
|
|
717
626
|
const includeFields = new Set();
|
|
718
627
|
const processed = {};
|
|
719
628
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
720
629
|
const fieldConfig = fieldConfigs[fieldName];
|
|
721
|
-
// If not a relationship field or no value, pass through
|
|
722
630
|
if (!isRelationshipField(fieldConfig) || value === null || value === undefined) {
|
|
723
631
|
processed[fieldName] = value;
|
|
724
632
|
continue;
|
|
725
633
|
}
|
|
726
|
-
// Get related list config
|
|
727
634
|
const relationshipField = fieldConfig;
|
|
728
635
|
const relatedConfig = getRelatedListConfig(relationshipField.ref, config);
|
|
729
636
|
if (!relatedConfig) {
|
|
@@ -731,22 +638,17 @@ parentInputData = undefined) {
|
|
|
731
638
|
continue;
|
|
732
639
|
}
|
|
733
640
|
const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig;
|
|
734
|
-
// Sanity: ensure the resolved list name matches the config identity.
|
|
735
641
|
const resolvedListName = relatedListName || findListName(relatedListConfig, config);
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
// connect/connectOrCreate handlers can gate connects by this field's
|
|
739
|
-
// create/update access, in addition to the target's read access.
|
|
642
|
+
// The owning relationship field's field-level access, for the #588 gate
|
|
643
|
+
// in verifyConnectReachable.
|
|
740
644
|
const owningFieldAccess = fieldConfig.access;
|
|
741
645
|
processed[fieldName] = await processFieldNestedOps(fieldName, value, {
|
|
742
646
|
relatedListName: resolvedListName,
|
|
743
647
|
relatedListConfig,
|
|
744
648
|
owningFieldAccess,
|
|
745
649
|
enclosingOperation: operation,
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
// the connect-site owning-field gate evaluates item-/inputData-dependent
|
|
749
|
-
// rules identically and cannot diverge into a spurious connect denial (#588).
|
|
650
|
+
// Same values Phase-5 `filterWritableFields` passes for this field, so
|
|
651
|
+
// the #588 owning-field gate can't diverge from it.
|
|
750
652
|
enclosingItem: parentOriginalItem,
|
|
751
653
|
enclosingInputData: parentInputData ?? {},
|
|
752
654
|
context,
|