@opensaas/stack-core 0.24.0 → 0.25.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.
Files changed (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +223 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/types.d.ts +318 -0
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/context/index.d.ts +19 -1
  22. package/dist/context/index.d.ts.map +1 -1
  23. package/dist/context/index.js +153 -26
  24. package/dist/context/index.js.map +1 -1
  25. package/dist/context/nested-operations.d.ts +59 -3
  26. package/dist/context/nested-operations.d.ts.map +1 -1
  27. package/dist/context/nested-operations.js +552 -129
  28. package/dist/context/nested-operations.js.map +1 -1
  29. package/dist/context/transaction-boundary.d.ts +91 -0
  30. package/dist/context/transaction-boundary.d.ts.map +1 -0
  31. package/dist/context/transaction-boundary.js +329 -0
  32. package/dist/context/transaction-boundary.js.map +1 -0
  33. package/dist/context/write-pipeline.d.ts +15 -1
  34. package/dist/context/write-pipeline.d.ts.map +1 -1
  35. package/dist/context/write-pipeline.js +173 -10
  36. package/dist/context/write-pipeline.js.map +1 -1
  37. package/dist/fields/calendar-day.test.d.ts +2 -0
  38. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  39. package/dist/fields/calendar-day.test.js +120 -0
  40. package/dist/fields/calendar-day.test.js.map +1 -0
  41. package/dist/fields/index.d.ts +18 -2
  42. package/dist/fields/index.d.ts.map +1 -1
  43. package/dist/fields/index.js +93 -17
  44. package/dist/fields/index.js.map +1 -1
  45. package/dist/hooks/index.d.ts +116 -0
  46. package/dist/hooks/index.d.ts.map +1 -1
  47. package/dist/hooks/index.js +154 -0
  48. package/dist/hooks/index.js.map +1 -1
  49. package/dist/validation/schema.test.js +222 -1
  50. package/dist/validation/schema.test.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/access/access-filter.ts +156 -0
  53. package/src/access/field-access.test.ts +255 -0
  54. package/src/access/field-access.ts +91 -5
  55. package/src/access/index.ts +1 -1
  56. package/src/access/types.ts +45 -0
  57. package/src/config/types.ts +364 -0
  58. package/src/context/index.ts +207 -37
  59. package/src/context/nested-operations.ts +969 -143
  60. package/src/context/transaction-boundary.ts +440 -0
  61. package/src/context/write-pipeline.ts +234 -13
  62. package/src/fields/calendar-day.test.ts +140 -0
  63. package/src/fields/index.ts +96 -16
  64. package/src/hooks/index.ts +265 -0
  65. package/src/validation/schema.test.ts +266 -1
  66. package/tests/access.test.ts +24 -16
  67. package/tests/context.test.ts +481 -0
  68. package/tests/field-types.test.ts +17 -3
  69. package/tests/nested-access-and-hooks.test.ts +1130 -54
  70. package/tests/nested-operation-registry.test.ts +28 -3
  71. package/tests/nested-write-hooks.test.ts +864 -0
  72. package/tests/transaction-boundary-hooks.test.ts +465 -0
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,4 @@
1
1
 
2
- > @opensaas/stack-core@0.24.0 build /home/runner/work/stack/stack/packages/core
2
+ > @opensaas/stack-core@0.25.0 build /home/runner/work/stack/stack/packages/core
3
3
  > tsc
4
4
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,228 @@
1
1
  # @opensaas/stack-core
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#602](https://github.com/OpenSaasAU/stack/pull/602) [`44ec937`](https://github.com/OpenSaasAU/stack/commit/44ec9375baa4dacab4e34b03cbefb27c8aec07c9) Thanks [@borisno2](https://github.com/borisno2)! - Make `calendarDay` a `YYYY-MM-DD` string end-to-end (Keystone's CalendarDay scalar)
8
+
9
+ `calendarDay` is now a `YYYY-MM-DD` **string** at the `context.db` boundary in
10
+ both directions, so its type, validation, and runtime value finally agree.
11
+ Previously the field validated a `YYYY-MM-DD` string but its TypeScript type was
12
+ `Date`, so a typed caller passing `new Date(...)` hit a runtime `ValidationError`.
13
+ - The field/read type and the generated `CreateInput`/`UpdateInput` input types
14
+ are now `string`.
15
+ - Writes accept only a `YYYY-MM-DD` string; a malformed string or a `Date` is
16
+ rejected at runtime by validation (a `ValidationError`).
17
+ - Storage is unchanged: `DateTime @db.Date` on Postgres/MySQL, the SQLite TEXT
18
+ fallback as before.
19
+
20
+ **Behavioral change (reads):** reading a `calendarDay` now returns a
21
+ `YYYY-MM-DD` string instead of a `Date`. A field `resolveOutput` transform
22
+ normalises the value Prisma returns from the `@db.Date` column, using UTC
23
+ components to avoid timezone off-by-one. Consumers that previously relied on a
24
+ `Date` on read should update to the string form:
25
+
26
+ ```typescript
27
+ const event = await context.db.event.findUnique({ where: { id } })
28
+ event?.startDate // => '2025-01-15' (string, not Date)
29
+
30
+ // Writes: pass YYYY-MM-DD strings, not Date objects
31
+ await context.db.event.create({ data: { startDate: '2025-01-15' } })
32
+ ```
33
+
34
+ - [#593](https://github.com/OpenSaasAU/stack/pull/593) [`fadd9db`](https://github.com/OpenSaasAU/stack/commit/fadd9dbd17085f4dd15899371a054ec46f943ce4) Thanks [@{](https://github.com/{)! - Nested relation writes now run the full hook pipeline inside one transaction ([#569](https://github.com/OpenSaasAU/stack/issues/569))
35
+
36
+ A record written via a nested `create`, `update`, or `delete` now fires the SAME
37
+ list- and field-level `beforeOperation`/`afterOperation` hooks as the equivalent
38
+ top-level write — so side effects (workflows, notifications, billing) are
39
+ identical whether a record is written nested or top-level. Previously nested
40
+ writes ran only `resolveInput`/`validate`/field-rules and silently skipped the
41
+ before/after side-effect hooks.
42
+ - Nested **create** runs `beforeOperation` (create) → persist → `afterOperation`
43
+ receiving the created `item`.
44
+ - Nested **update** runs `afterOperation` receiving both `originalItem` (the row
45
+ before) and the updated `item`.
46
+ - Nested **delete** runs `beforeOperation`/`afterOperation` receiving the
47
+ `originalItem`.
48
+
49
+ Existing access control, validation, silent-failure, sudo-bypass, and the [#578](https://github.com/OpenSaasAU/stack/issues/578)
50
+ nested-`connect`/`connectOrCreate` read-access + DB-reachability behavior are
51
+ unchanged. Pass-through nested kinds (`disconnect`/`set`/`updateMany`/
52
+ `deleteMany`) are out of scope and behave as before. See ADR-0010.
53
+
54
+ For to-many nested creates (`create: [{A},{B}]`), each created record's
55
+ `afterOperation` now fires exactly once against its OWN distinct row, recovered
56
+ by id-diff against the rows that existed before the write — so a pre-existing
57
+ sibling is never passed as the "created" item, and multiple creates no longer
58
+ collapse to a single row.
59
+
60
+ BEHAVIOR CHANGE — every write is now transactional, and a throwing
61
+ `beforeOperation`/`afterOperation` (or validation) rolls the whole write back.
62
+ The entire operation (parent + all nested writes) now runs inside one
63
+ `prisma.$transaction`, so it is atomic. Previously an `afterOperation` that threw
64
+ left the row committed; now it rolls back with the transaction (more
65
+ Keystone-correct). If you relied on a thrown `afterOperation` leaving the row
66
+ persisted, move that work to run after the write returns.
67
+
68
+ Inside a `beforeOperation`/`afterOperation` hook, `context.db` (and
69
+ `context.prisma`) are now bound to the write's transaction, so any `context.db`
70
+ write a hook performs participates in — and rolls back with — the same
71
+ transaction. Externally-visible side effects that must survive a rollback should
72
+ not use `context.db` from within these hooks (transaction-boundary hooks for
73
+ that are deferred — see [#590](https://github.com/OpenSaasAU/stack/issues/590)).
74
+
75
+ ```ts
76
+ // Nested create now fires the related list's beforeOperation/afterOperation,
77
+ // atomically with the parent — a throw anywhere rolls the whole write back.
78
+ await context.db.post.update({
79
+ where: { id },
80
+ data: {
81
+ title: 'Updated',
82
+ create: { name: 'New Author' } }, // User hooks fire; atomic
83
+ },
84
+ })
85
+ ```
86
+
87
+ - [#594](https://github.com/OpenSaasAU/stack/pull/594) [`4f0d407`](https://github.com/OpenSaasAU/stack/commit/4f0d40721feff1a3109647a81fcbe47db5970026) Thanks [@borisno2](https://github.com/borisno2)! - Add an opt-in **Node build** of the generated `.opensaas/` bundle (ADR-0011, [#579](https://github.com/OpenSaasAU/stack/issues/579)).
88
+
89
+ Setting `output: { buildTarget: 'node' }` in `opensaas.config.ts` makes `opensaas generate` additionally compile the bundle to a plain-Node-loadable ESM form under `.opensaas/dist/` — `.js` + `.d.ts` with a `{"type":"module"}` marker — alongside the default `.ts` bundler form. The compiled entry is `.opensaas/dist/context.js`, with the Prisma client subtree at `.opensaas/dist/prisma-client/**` and the project config compiled in as a sibling, so a live module (e.g. better-auth's Prisma adapter) can be imported in a bundler-less runtime — plain Node, a Playwright e2e helper, or a build-time script — that the default `.ts` form cannot execute.
90
+
91
+ The Node build is purely additive: with `output.buildTarget` absent (the default), generation behaves exactly as before and no `.opensaas/dist/` is emitted.
92
+
93
+ ```typescript
94
+ // opensaas.config.ts
95
+ export default config({
96
+ output: { buildTarget: 'node' },
97
+ // ...
98
+ })
99
+
100
+ // then, from a plain-Node consumer (no bundler, no tsx):
101
+ import { createAuth } from '@opensaas/stack-auth/server'
102
+ import { config, rawOpensaasContext } from './.opensaas/dist/context.js'
103
+
104
+ const auth = createAuth(config, rawOpensaasContext)
105
+ await auth.api.signUpEmail({ body: { email, password, name } })
106
+ ```
107
+
108
+ The compile runs via the TypeScript compiler API with `rewriteRelativeImportExtensions` (turning the bundle's `.ts`-extension imports into runnable `.js` specifiers), `declaration`, `skipLibCheck`, and `noEmitOnError: false`, so it reuses the bundle's type-clean guarantee without adding a build dependency. `'node'` is the only `buildTarget` today; the field is a string-literal union so future compiled targets can be added without a breaking change.
109
+
110
+ - [#592](https://github.com/OpenSaasAU/stack/pull/592) [`e355c05`](https://github.com/OpenSaasAU/stack/commit/e355c05a0787980b997609c4571271ab5c250f36) Thanks [@borisno2](https://github.com/borisno2)! - Make the generated `.opensaas/prisma-client` subtree statically resolvable by default and add a `db.prismaGeneratorOptions` passthrough.
111
+
112
+ The generated `generator client { ... }` block now emits `importFileExtension = "ts"` and `moduleFormat = "esm"` by default, so the prisma-client subtree uses explicit `.ts` import extensions and matches the extension style the rest of the `.opensaas` bundle already uses — the whole import graph is statically resolvable by a bundler out of the box, no post-generation surgery required.
113
+
114
+ A new optional `db.prismaGeneratorOptions` lets you override these values when you need a different module/extension story (e.g. emitting `.js` extensions for a plain-Node consumer). Any value you supply wins; omitted keys fall back to the `ts`/`esm` defaults. The existing `previewFeatures = ["multiSchema"]` emission (when `db.schemas` is set) is preserved and coexists with the new options.
115
+
116
+ ```typescript
117
+ export default config({
118
+ db: {
119
+ provider: 'postgresql',
120
+ prismaGeneratorOptions: {
121
+ importFileExtension: 'js',
122
+ moduleFormat: 'commonjs',
123
+ },
124
+ // ... rest of config
125
+ },
126
+ // ...
127
+ })
128
+ ```
129
+
130
+ - [#600](https://github.com/OpenSaasAU/stack/pull/600) [`a93cebb`](https://github.com/OpenSaasAU/stack/commit/a93cebb5a6ba6550d8cdbb94f010c902ad7e29f1) Thanks [@relationship({](https://github.com/relationship({)! - Gate nested `connect` by the owning relationship field's field-level access
131
+
132
+ Nested `connect` (and the connect branch of `connectOrCreate`) is now gated by
133
+ the owning relationship field's create/update field-level access, in addition to
134
+ the target list's read/query access and DB-reachability check. This completes
135
+ the Keystone-parity rule that a connect requires both read access on the target
136
+ AND write access on the owning relationship field. `sudo` bypasses the check.
137
+
138
+ ```typescript
139
+ Post: list({
140
+ fields: {
141
+ // A non-sudo caller can only connect an author when this field's
142
+ // update access permits it (and the target User is readable/reachable).
143
+
144
+ ref: 'User.posts',
145
+ access: { update: ({ session }) => session?.role === 'editor' },
146
+ }),
147
+ },
148
+ })
149
+ ```
150
+
151
+ - [#584](https://github.com/OpenSaasAU/stack/pull/584) [`b17ec45`](https://github.com/OpenSaasAU/stack/commit/b17ec45127fe55f02437892e9fd389c67373635a) Thanks [@borisno2](https://github.com/borisno2)! - Add `findFirst` to access-controlled `context.db.<list>` delegates
152
+
153
+ `findFirst` is sugar over the existing access-filtered `findMany` (`take: 1`), so
154
+ it introduces no new access surface: it applies the exact same query-access checks
155
+ and access-controlled include building as `findMany`, then returns the first
156
+ matching row or `null`. It honours the read-side silent-failure contract — an
157
+ access-denied query yields `null` rather than throwing.
158
+
159
+ ```ts
160
+ // Non-unique single-row lookup
161
+ const account = await context.db.account.findFirst({
162
+ where: { userId: '123' },
163
+ orderBy: { createdAt: 'desc' },
164
+ })
165
+
166
+ // Narrow the single result with a query fragment
167
+ const post = await context.db.post.findFirst({
168
+ where: { published: true },
169
+ query: postFragment,
170
+ })
171
+ // post: ResultOf<typeof postFragment> | null
172
+ ```
173
+
174
+ The CLI type generator now emits a `findFirst` method (and `<List>FindFirstArgs`
175
+ type) for each list in the generated `.opensaas/types.ts`, so migrated apps that
176
+ reach for the familiar Prisma `findFirst` pattern get full type support.
177
+
178
+ - [#601](https://github.com/OpenSaasAU/stack/pull/601) [`8f98e25`](https://github.com/OpenSaasAU/stack/commit/8f98e25fbef4ec0fc3ff0cba456ff7f2f7ba2ea8) Thanks [@borisno2](https://github.com/borisno2)! - Add `beforeTransaction` / `afterTransaction` transaction-boundary hooks (list- and field-level)
179
+
180
+ These run OUTSIDE the write's database transaction (in addition to the in-transaction `beforeOperation`/`afterOperation`), for non-transactional side effects like external API calls that must not hold a transaction open and cannot be rolled back. They fire per `(list, operation)` involved in the write (the top-level list plus each nested create/update/delete list) and form a symmetric compensation bracket: `afterTransaction` always runs when its paired `beforeTransaction` ran, receiving the outcome (`status: 'committed' | 'rolled-back'` plus `error` on rollback). On commit it gets the persisted `item` (and `originalItem` for update/delete) **only for the top-level record** — for nested lists these are `undefined`, since the per-record persisted row is not recoverable outside the transaction; use the in-transaction `afterOperation` for per-record nested compensation. On rollback it gets no `item` so it can undo what `beforeTransaction` did. `connectOrCreate` is enumerated as a best-effort create involvement (a resolve-to-connect still fires the bracket with no write), so compensators should be idempotent.
181
+
182
+ ```typescript
183
+ list({
184
+ fields: { name: text() },
185
+ hooks: {
186
+ // Runs before the transaction opens.
187
+ beforeTransaction: async ({ operation, inputData }) => {
188
+ await billing.reserveSeat(inputData.seatId)
189
+ },
190
+ // Always runs after the transaction settles.
191
+ afterTransaction: async (args) => {
192
+ if (args.status === 'rolled-back') {
193
+ // The write did not persist (args.error explains why) — compensate.
194
+ await billing.releaseSeat(args.inputData.seatId)
195
+ } else {
196
+ await billing.confirmSeat(args.item.seatId)
197
+ }
198
+ },
199
+ },
200
+ })
201
+ ```
202
+
203
+ A throwing `beforeTransaction` aborts the write (the transaction never opens) and fires `afterTransaction` (`rolled-back`) only for lists whose `beforeTransaction` already ran. A throwing `afterTransaction` does not stop the other compensators; errors are surfaced afterward. Sudo does not affect these hooks. This is an additive, non-Keystone extension and does not change the existing `beforeOperation`/`afterOperation` semantics.
204
+
205
+ ### Patch Changes
206
+
207
+ - [#603](https://github.com/OpenSaasAU/stack/pull/603) [`be9a896`](https://github.com/OpenSaasAU/stack/commit/be9a8965ad6338c279e99cfe3bf24162e63ffb92) Thanks [@borisno2](https://github.com/borisno2)! - Enforce required json fields on create: an omitted key is now rejected while any
208
+ present value (object, array, primitive, or null) is still accepted.
209
+
210
+ - [#583](https://github.com/OpenSaasAU/stack/pull/583) [`e39d6e9`](https://github.com/OpenSaasAU/stack/commit/e39d6e9e37be2337c8cf1979053e76877f14296c) Thanks [@borisno2](https://github.com/borisno2)! - Make non-sudo writes fail loud in `filterWritableFields` (Keystone parity).
211
+
212
+ Undeclared `data` keys on create/update now throw instead of passing through unchecked ([#564](https://github.com/OpenSaasAU/stack/issues/564)), and fields denied by field-level access now throw instead of being silently stripped ([#568](https://github.com/OpenSaasAU/stack/issues/568)). `sudo` remains the single trusted bypass; system fields and relationship foreign keys still pass through. Raw multi-column split columns (e.g. `media_url`/`media_size` from an `image()`/`file()` field) are now gated by their owning field's write access — supplying them directly under non-sudo when that field denies the write throws, instead of bypassing the field's `access.create`/`access.update`.
213
+
214
+ Behavioural narrowing: a list-level `resolveInput` hook that adds keys to `resolvedData` which are not declared fields will now be rejected by the undeclared-key throw. No production hook does this today.
215
+
216
+ - [#605](https://github.com/OpenSaasAU/stack/pull/605) [`ca4973b`](https://github.com/OpenSaasAU/stack/commit/ca4973b504eadb123d179e8f4d16d6ec8c9f8fc1) Thanks [@borisno2](https://github.com/borisno2)! - Required json fields now reject a present `null` during validation rather than failing later as a DB NOT NULL violation. Omitted keys on update are still allowed; the Prisma column nullability is unchanged.
217
+
218
+ - [#602](https://github.com/OpenSaasAU/stack/pull/602) [`44ec937`](https://github.com/OpenSaasAU/stack/commit/44ec9375baa4dacab4e34b03cbefb27c8aec07c9) Thanks [@borisno2](https://github.com/borisno2)! - Fix update validation rejecting omitted required fields under zod 4.4 by using key-optionality (`.optional()`) instead of `z.union([schema, z.undefined()])`. Partial updates that omit a required-on-create field now validate; present values still enforce their rules.
219
+
220
+ - [#587](https://github.com/OpenSaasAU/stack/pull/587) [`ecbf834`](https://github.com/OpenSaasAU/stack/commit/ecbf834059a072c428b0739d6ebcf4c74be8c893) Thanks [@borisno2](https://github.com/borisno2)! - Fix false denial of nested `connect` (and `connectOrCreate`'s connect branch): connect now requires read/query access on the target and evaluates filter results via DB reachability (`findFirst({ where: { AND: [connection, accessFilter] } })`), so nested-relation and `AND`/`OR`/`some`/`none`/`not` filters no longer always fail.
221
+
222
+ - [#589](https://github.com/OpenSaasAU/stack/pull/589) [`481d6e0`](https://github.com/OpenSaasAU/stack/commit/481d6e00be90b1159b0b30eff015e5079c840158) Thanks [@borisno2](https://github.com/borisno2)! - Fix row-level access bypass when an explicit `include` is passed to non-sudo `findUnique`/`findMany`. The caller's `include` is now merged with (not replaced by) the access-controlled include: denied relations are dropped, each relation's access `where` is AND-combined with any caller nested `where`, and nested includes are filtered at every level. Sudo and query-fragment paths are unchanged. When no access-controlled include is computed (inside a `resolveOutput`/virtual-field context, at max include depth, or for a list with no relationships), the caller's `include` is passed through unchanged rather than dropped — avoiding fail-closed data loss.
223
+
224
+ - [#586](https://github.com/OpenSaasAU/stack/pull/586) [`4622b5f`](https://github.com/OpenSaasAU/stack/commit/4622b5fa8fc731e2c8995011f1be0cfe341578da) Thanks [@borisno2](https://github.com/borisno2)! - Enforce unique-`where` for `context.db.<list>.findUnique` — a non-unique `where` now throws a clear error instead of silently returning a nondeterministic row. Use `findFirst` for non-unique single-row lookups.
225
+
3
226
  ## 0.24.0
4
227
 
5
228
  ### Minor Changes
@@ -26,4 +26,43 @@ export declare function buildIncludeWithAccessControl(fieldConfigs: Record<strin
26
26
  where?: PrismaFilter;
27
27
  include?: Record<string, boolean | /*elided*/ any>;
28
28
  }> | undefined>;
29
+ /**
30
+ * Merge a caller-supplied `include` with the access-controlled include — phase-1
31
+ * row/relation scoping for explicit caller selections.
32
+ *
33
+ * The caller's `include` decides WHICH relations to fetch; access control decides
34
+ * WHETHER each relation may be fetched and WITH WHAT filter. Replacing the
35
+ * access-controlled include with the caller's wholesale (the bug in #566) drops
36
+ * every per-relation access `where` and denied-relation exclusion, silently
37
+ * bypassing row-level access on any non-sudo read that passes `include`.
38
+ *
39
+ * For each relation the caller asks to include:
40
+ * - If the relation is a config-declared relationship but is ABSENT from the
41
+ * access-controlled include, its `query` access returned `false` → it is DROPPED
42
+ * (not fetched).
43
+ * - If it is present (allowed, possibly with a filter), the access entry is used
44
+ * as the base: the access `where` is AND-combined with any caller-supplied
45
+ * nested `where`, and nested includes are recursively merged using the related
46
+ * list's field configs (so deeply-nested selections are filtered at every
47
+ * level). A bare caller `true` becomes the access-controlled shape (filter +
48
+ * nested filtered include), never bare `true`.
49
+ * - If the caller names a key that is NOT a config-declared relationship, it is
50
+ * passed through unchanged (access control does not govern it).
51
+ *
52
+ * The access-controlled include is recursive to `MAX_DEPTH` (see
53
+ * `buildIncludeWithAccessControl`); beyond that depth no auto-include exists, so
54
+ * deeper caller selections pass through unscoped — consistent with the existing
55
+ * auto-include behaviour.
56
+ *
57
+ * `accessControlledInclude` being `undefined` is NOT "every relation denied". It
58
+ * means no access-controlled include was computed at all — a non-denial outcome
59
+ * that `buildIncludeWithAccessControl` returns when inside a `resolveOutput`/
60
+ * virtual-field context, at `MAX_DEPTH`, or when the list has no relationships.
61
+ * In every one of those cases there is nothing to merge against, so the caller's
62
+ * `include` is passed through unchanged (matching the prior `args.include || …`
63
+ * fallback). This is distinct from an `undefined` ENTRY inside a defined access
64
+ * include, which DOES mean the relation was denied and must be dropped (see the
65
+ * per-relation loop below). Only the whole-object `undefined` is a passthrough.
66
+ */
67
+ export declare function mergeIncludeWithAccessControl(callerInclude: Record<string, unknown>, accessControlledInclude: Record<string, unknown> | undefined, fieldConfigs: Record<string, FieldConfig>, config: OpenSaasConfig): Record<string, unknown>;
29
68
  //# sourceMappingURL=access-filter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"access-filter.d.ts","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGrE;;;;;;;;;;;;;;GAcG;AAEH;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,EACD,MAAM,EAAE,cAAc,EACtB,KAAK,GAAE,MAAU;YAeuB,YAAY;cAAY,MAAM,CAAC,MAAM,2BAAe;gBAkD7F"}
1
+ {"version":3,"file":"access-filter.d.ts","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGrE;;;;;;;;;;;;;;GAcG;AAEH;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,EACD,MAAM,EAAE,cAAc,EACtB,KAAK,GAAE,MAAU;YAeuB,YAAY;cAAY,MAAM,CAAC,MAAM,2BAAe;gBAkD7F;AAiDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,6BAA6B,CAC3C,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACtC,uBAAuB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5D,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,MAAM,EAAE,cAAc,GACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgEzB"}
@@ -65,4 +65,125 @@ export async function buildIncludeWithAccessControl(fieldConfigs, args, config,
65
65
  }
66
66
  return hasRelationships ? include : undefined;
67
67
  }
68
+ /**
69
+ * Narrow an unknown include value to the structured object form (vs bare `true`
70
+ * or any other primitive). Caller-supplied includes arrive untyped at the
71
+ * runtime boundary, so we validate the shape here rather than casting.
72
+ */
73
+ function asEntryObject(value) {
74
+ if (value && typeof value === 'object') {
75
+ const obj = value;
76
+ const where = obj.where;
77
+ const include = obj.include;
78
+ const entry = {};
79
+ if (where && typeof where === 'object')
80
+ entry.where = where;
81
+ if (include && typeof include === 'object')
82
+ entry.include = include;
83
+ return entry;
84
+ }
85
+ return null;
86
+ }
87
+ /**
88
+ * AND-combine an access `where` with a caller-supplied nested `where`.
89
+ *
90
+ * The access filter is authoritative: the caller's filter may only NARROW the
91
+ * result further, never widen past what access permits. We therefore wrap both
92
+ * in a Prisma `AND` so neither can override the other. If only one side is
93
+ * present, it is returned as-is; if neither is present, the result is undefined.
94
+ */
95
+ function andWhere(accessWhere, callerWhere) {
96
+ if (accessWhere && callerWhere) {
97
+ return { AND: [accessWhere, callerWhere] };
98
+ }
99
+ return accessWhere ?? callerWhere;
100
+ }
101
+ /**
102
+ * Merge a caller-supplied `include` with the access-controlled include — phase-1
103
+ * row/relation scoping for explicit caller selections.
104
+ *
105
+ * The caller's `include` decides WHICH relations to fetch; access control decides
106
+ * WHETHER each relation may be fetched and WITH WHAT filter. Replacing the
107
+ * access-controlled include with the caller's wholesale (the bug in #566) drops
108
+ * every per-relation access `where` and denied-relation exclusion, silently
109
+ * bypassing row-level access on any non-sudo read that passes `include`.
110
+ *
111
+ * For each relation the caller asks to include:
112
+ * - If the relation is a config-declared relationship but is ABSENT from the
113
+ * access-controlled include, its `query` access returned `false` → it is DROPPED
114
+ * (not fetched).
115
+ * - If it is present (allowed, possibly with a filter), the access entry is used
116
+ * as the base: the access `where` is AND-combined with any caller-supplied
117
+ * nested `where`, and nested includes are recursively merged using the related
118
+ * list's field configs (so deeply-nested selections are filtered at every
119
+ * level). A bare caller `true` becomes the access-controlled shape (filter +
120
+ * nested filtered include), never bare `true`.
121
+ * - If the caller names a key that is NOT a config-declared relationship, it is
122
+ * passed through unchanged (access control does not govern it).
123
+ *
124
+ * The access-controlled include is recursive to `MAX_DEPTH` (see
125
+ * `buildIncludeWithAccessControl`); beyond that depth no auto-include exists, so
126
+ * deeper caller selections pass through unscoped — consistent with the existing
127
+ * auto-include behaviour.
128
+ *
129
+ * `accessControlledInclude` being `undefined` is NOT "every relation denied". It
130
+ * means no access-controlled include was computed at all — a non-denial outcome
131
+ * that `buildIncludeWithAccessControl` returns when inside a `resolveOutput`/
132
+ * virtual-field context, at `MAX_DEPTH`, or when the list has no relationships.
133
+ * In every one of those cases there is nothing to merge against, so the caller's
134
+ * `include` is passed through unchanged (matching the prior `args.include || …`
135
+ * fallback). This is distinct from an `undefined` ENTRY inside a defined access
136
+ * include, which DOES mean the relation was denied and must be dropped (see the
137
+ * per-relation loop below). Only the whole-object `undefined` is a passthrough.
138
+ */
139
+ export function mergeIncludeWithAccessControl(callerInclude, accessControlledInclude, fieldConfigs, config) {
140
+ // No access-controlled include was computed (resolveOutput/virtual context,
141
+ // MAX_DEPTH, or a list with no relationships) → nothing to scope against, so
142
+ // pass the caller's include through unchanged. Dropping relations here would be
143
+ // fail-closed data loss, not a denial. Denied relations are dropped only when a
144
+ // defined access include OMITS them (handled per-relation below).
145
+ if (accessControlledInclude === undefined) {
146
+ return callerInclude;
147
+ }
148
+ const merged = {};
149
+ const accessInclude = accessControlledInclude;
150
+ for (const [relationName, callerValue] of Object.entries(callerInclude)) {
151
+ const fieldConfig = fieldConfigs[relationName];
152
+ const isDeclaredRelationship = fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref;
153
+ // Not a config-declared relationship → access control does not govern it; pass through unchanged.
154
+ if (!isDeclaredRelationship) {
155
+ merged[relationName] = callerValue;
156
+ continue;
157
+ }
158
+ const accessValue = accessInclude[relationName];
159
+ // Declared relationship absent from the access include → query access denied → drop it.
160
+ if (accessValue === undefined) {
161
+ continue;
162
+ }
163
+ const accessEntry = asEntryObject(accessValue);
164
+ const callerEntry = asEntryObject(callerValue);
165
+ // Resolve the related list's field configs so nested includes merge recursively.
166
+ const relatedConfig = getRelatedListConfig(fieldConfig.ref, config);
167
+ const relatedFields = relatedConfig?.listConfig.fields;
168
+ const mergedWhere = andWhere(accessEntry?.where, callerEntry?.where);
169
+ let mergedNested;
170
+ if (callerEntry?.include && relatedFields) {
171
+ // Recurse: scope the caller's nested selection against the nested access include.
172
+ mergedNested = mergeIncludeWithAccessControl(callerEntry.include, accessEntry?.include, relatedFields, config);
173
+ }
174
+ else if (accessEntry?.include) {
175
+ // Caller selected the relation bare (no nested include); keep the
176
+ // access-controlled nested include so deeper relations stay filtered.
177
+ mergedNested = accessEntry.include;
178
+ }
179
+ const entry = {};
180
+ if (mergedWhere)
181
+ entry.where = mergedWhere;
182
+ if (mergedNested && Object.keys(mergedNested).length > 0)
183
+ entry.include = mergedNested;
184
+ // A bare-`true` relation with no access filter and no nested include stays `true`.
185
+ merged[relationName] = Object.keys(entry).length > 0 ? entry : true;
186
+ }
187
+ return merged;
188
+ }
68
189
  //# sourceMappingURL=access-filter.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"access-filter.js","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAE/D;;;;;;;;;;;;;;GAcG;AAEH;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,6BAA6B,CACjD,YAAyC,EACzC,IAGC,EACD,MAAsB,EACtB,QAAgB,CAAC;IAEjB,MAAM,SAAS,GAAG,CAAC,CAAA;IACnB,IAAI,KAAK,IAAI,SAAS,EAAE,CAAC;QACvB,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,qEAAqE;IACrE,uEAAuE;IACvE,gFAAgF;IAChF,0EAA0E;IAC1E,IAAI,IAAI,CAAC,OAAO,CAAC,qBAAqB,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC;QACjD,OAAO,SAAS,CAAA;IAClB,CAAC;IAID,MAAM,OAAO,GAAiC,EAAE,CAAA;IAChD,IAAI,gBAAgB,GAAG,KAAK,CAAA;IAE5B,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,IAAI,WAAW,EAAE,IAAI,KAAK,cAAc,IAAI,KAAK,IAAI,WAAW,IAAI,WAAW,CAAC,GAAG,EAAE,CAAC;YACpF,gBAAgB,GAAG,IAAI,CAAA;YACvB,MAAM,aAAa,GAAG,oBAAoB,CAAC,WAAW,CAAC,GAAa,EAAE,MAAM,CAAC,CAAA;YAE7E,IAAI,aAAa,EAAE,CAAC;gBAClB,0CAA0C;gBAC1C,MAAM,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAA;gBACrE,MAAM,YAAY,GAAG,MAAM,WAAW,CAAC,WAAW,EAAE;oBAClD,OAAO,EAAE,IAAI,CAAC,OAAO;oBACrB,OAAO,EAAE,IAAI,CAAC,OAAO;iBACtB,CAAC,CAAA;gBAEF,4DAA4D;gBAC5D,IAAI,YAAY,KAAK,KAAK,EAAE,CAAC;oBAC3B,SAAQ;gBACV,CAAC;gBAED,0BAA0B;gBAC1B,MAAM,YAAY,GAA4B,EAAE,CAAA;gBAEhD,yDAAyD;gBACzD,IAAI,OAAO,YAAY,KAAK,QAAQ,EAAE,CAAC;oBACrC,YAAY,CAAC,KAAK,GAAG,YAAY,CAAA;gBACnC,CAAC;gBAED,oCAAoC;gBACpC,MAAM,aAAa,GAAG,MAAM,6BAA6B,CACvD,aAAa,CAAC,UAAU,CAAC,MAAM,EAC/B,IAAI,EACJ,MAAM,EACN,KAAK,GAAG,CAAC,CACV,CAAA;gBAED,IAAI,aAAa,IAAI,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAC3D,YAAY,CAAC,OAAO,GAAG,aAAa,CAAA;gBACtC,CAAC;gBAED,wBAAwB;gBACxB,OAAO,CAAC,SAAS,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAA;YACjF,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,gBAAgB,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;AAC/C,CAAC"}
1
+ {"version":3,"file":"access-filter.js","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"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"}
@@ -33,6 +33,7 @@ export declare function checkFieldAccess(fieldAccess: FieldAccess | undefined, o
33
33
  export declare function filterWritableFields<T extends Record<string, unknown>>(data: T, fieldConfigs: Record<string, {
34
34
  access?: FieldAccess;
35
35
  type?: string;
36
+ getColumnNames?: (fieldName: string) => string[];
36
37
  }>, operation: 'create' | 'update', args: {
37
38
  session: Session | null;
38
39
  item?: Record<string, unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"field-access.d.ts","sourceRoot":"","sources":["../../src/access/field-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AACxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,WAAW,GAAG,SAAS,EACpC,SAAS,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,EACvC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,OAAO,EAAE,aAAa,GAAG;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC,GACA,OAAO,CAAC,OAAO,CAAC,CAmClB;AA8BD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1E,IAAI,EAAE,CAAC,EACP,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,MAAM,CAAC,EAAE,WAAW,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,EACrE,SAAS,EAAE,QAAQ,GAAG,QAAQ,EAC9B,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,OAAO,EAAE,aAAa,GAAG;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC,GACA,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAgDrB"}
1
+ {"version":3,"file":"field-access.d.ts","sourceRoot":"","sources":["../../src/access/field-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AACxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAM7C;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,WAAW,GAAG,SAAS,EACpC,SAAS,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,EACvC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,OAAO,EAAE,aAAa,GAAG;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC,GACA,OAAO,CAAC,OAAO,CAAC,CAmClB;AA8BD;;GAEG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1E,IAAI,EAAE,CAAC,EACP,YAAY,EAAE,MAAM,CAClB,MAAM,EACN;IACE,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,EAAE,CAAA;CACjD,CACF,EACD,SAAS,EAAE,QAAQ,GAAG,QAAQ,EAC9B,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,OAAO,EAAE,aAAa,GAAG;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpC,GACA,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CA2HrB"}
@@ -1,3 +1,7 @@
1
+ // `ValidationError` is referenced only inside function bodies (call-time), never
2
+ // at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
3
+ // under ESM live bindings.
4
+ import { ValidationError } from '../hooks/index.js';
1
5
  /**
2
6
  * Shared field-level access evaluation.
3
7
  *
@@ -84,6 +88,23 @@ export async function filterWritableFields(data, fieldConfigs, operation, args)
84
88
  // Build a set of foreign key field names to exclude
85
89
  // Foreign keys should not be in the data when using Prisma's relation syntax
86
90
  const foreignKeyFields = new Set();
91
+ // Map each raw per-part column name contributed by a multi-column field
92
+ // (e.g. storage image()/file() in Keystone-parity mode) back to its OWNING
93
+ // declared field. These columns are injected into the write payload by the
94
+ // field's `splitColumns` AFTER resolveInput and are intentionally NOT declared
95
+ // as their own entries in `fieldConfigs`, so without this map they would trip
96
+ // the #564 undeclared-key reject below.
97
+ //
98
+ // SECURITY (#568): a raw column must NOT be blanket-passed through. The hooks
99
+ // layer (`executeFieldResolveInputHooks`) only gates the owning field when the
100
+ // LOGICAL key (e.g. `media`) is present, because it iterates declared fields,
101
+ // not data keys. A non-sudo caller who supplies the raw columns DIRECTLY
102
+ // (`data: { media_url, media_size }`) never produces that logical key, so that
103
+ // gate never fires. We therefore gate each raw column HERE by its owning
104
+ // field's write access — denied (non-sudo) throws, allowed (or sudo) passes
105
+ // through — so the legitimate multi-column write path is preserved while the
106
+ // direct-raw-column bypass is closed.
107
+ const splitColumnOwners = new Map();
87
108
  for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
88
109
  if (fieldConfig.type === 'relationship') {
89
110
  // For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
@@ -92,7 +113,13 @@ export async function filterWritableFields(data, fieldConfigs, operation, args)
92
113
  foreignKeyFields.add(`${fieldName}Id`);
93
114
  }
94
115
  }
116
+ if (typeof fieldConfig.getColumnNames === 'function') {
117
+ for (const column of fieldConfig.getColumnNames(fieldName)) {
118
+ splitColumnOwners.set(column, { fieldName, access: fieldConfig.access });
119
+ }
120
+ }
95
121
  }
122
+ const isSudo = args.context._isSudo === true;
96
123
  for (const [fieldName, value] of Object.entries(data)) {
97
124
  const fieldConfig = fieldConfigs[fieldName];
98
125
  // Skip system fields
@@ -109,14 +136,62 @@ export async function filterWritableFields(data, fieldConfigs, operation, args)
109
136
  if (foreignKeyFields.has(fieldName)) {
110
137
  continue;
111
138
  }
112
- // Check field access (checkFieldAccess already handles sudo mode)
113
- const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
139
+ // Raw per-part columns produced by a multi-column field's `splitColumns`.
140
+ // They are undeclared by design, so they must not trip the #564 reject — but
141
+ // they must NOT be blanket-passed through either: gate each one by its
142
+ // OWNING field's write access (see the SECURITY note where the map is built).
143
+ // This is the real gate for callers who supply the raw columns directly,
144
+ // because the logical-key gate in `executeFieldResolveInputHooks` never fires
145
+ // for them. Denied (non-sudo) throws — same fail-loud behaviour as a denied
146
+ // declared field (#568); allowed (or sudo, via `checkFieldAccess`) passes
147
+ // through, preserving the legitimate multi-column write path.
148
+ const splitColumnOwner = splitColumnOwners.get(fieldName);
149
+ if (splitColumnOwner) {
150
+ const canWrite = await checkFieldAccess(splitColumnOwner.access, operation, {
151
+ ...args,
152
+ inputData: args.inputData,
153
+ });
154
+ if (!canWrite) {
155
+ throw new ValidationError([
156
+ `Cannot ${operation} "${splitColumnOwner.fieldName}" (via column "${fieldName}"): ` +
157
+ `field-level access denied.`,
158
+ ]);
159
+ }
160
+ filtered[fieldName] = value;
161
+ continue;
162
+ }
163
+ // #564 — undeclared data keys must fail CLOSED.
164
+ // A key with no entry in `fieldConfigs` is not a field the list config
165
+ // exposes. The generated Prisma model has MORE fields than the config
166
+ // declares (e.g. back-relations like `from_Enrolment_student`), so allowing
167
+ // an undeclared key to pass through lets a non-sudo caller drive ungated
168
+ // nested writes on undeclared back-relations. Mirror Keystone's
169
+ // GraphQL-schema behaviour and reject it. `sudo` is the single trusted
170
+ // bypass, so undeclared keys still pass through under sudo.
171
+ if (!fieldConfig) {
172
+ if (isSudo) {
173
+ filtered[fieldName] = value;
174
+ continue;
175
+ }
176
+ throw new ValidationError([
177
+ `Cannot ${operation} "${fieldName}": it is not a field of this list. ` +
178
+ `Undeclared data keys are rejected (use sudo to bypass).`,
179
+ ]);
180
+ }
181
+ // #568 — fields denied by field-level access must THROW, not be silently
182
+ // dropped. Keystone threw a GraphQL access error for the same situation;
183
+ // silently stripping the field lets a write "succeed" while doing less than
184
+ // asked (and skips any hook side effects gated on that field).
185
+ // `checkFieldAccess` already returns `true` under sudo, so sudo writes never
186
+ // reach the throw below — no parallel sudo path is needed here.
187
+ const canWrite = await checkFieldAccess(fieldConfig.access, operation, {
114
188
  ...args,
115
189
  inputData: args.inputData,
116
190
  });
117
- if (canWrite) {
118
- filtered[fieldName] = value;
191
+ if (!canWrite) {
192
+ throw new ValidationError([`Cannot ${operation} "${fieldName}": field-level access denied.`]);
119
193
  }
194
+ filtered[fieldName] = value;
120
195
  }
121
196
  return filtered;
122
197
  }
@@ -1 +1 @@
1
- {"version":3,"file":"field-access.js","sourceRoot":"","sources":["../../src/access/field-access.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,WAAoC,EACpC,SAAuC,EACvC,IAKC;IAED,iCAAiC;IACjC,IAAI,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;QACzB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,OAAO,IAAI,CAAA,CAAC,8BAA8B;IAC5C,CAAC;IAED,MAAM,aAAa,GAAG,WAAW,CAAC,SAAS,CAAC,CAAA;IAC5C,IAAI,CAAC,aAAa,EAAE,CAAC;QACnB,OAAO,IAAI,CAAA,CAAC,yCAAyC;IACvD,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC;QACjC,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,SAAS;KAC6B,CAAC,CAAA;IAEzC,kCAAkC;IAClC,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;QACrB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,kCAAkC;IAClC,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QACpB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,mDAAmD;IACnD,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;GAGG;AACH,SAAS,aAAa,CAAC,IAA6B,EAAE,MAA+B;IACnF,KAAK,MAAM,CAAC,GAAG,EAAE,SAAS,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACtD,IAAI,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;YACxD,kDAAkD;YAClD,IAAI,QAAQ,IAAI,SAAS,EAAE,CAAC;gBAC1B,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,SAAS,CAAC,MAAM,EAAE,CAAC;oBACnC,OAAO,KAAK,CAAA;gBACd,CAAC;YACH,CAAC;iBAAM,IAAI,KAAK,IAAI,SAAS,EAAE,CAAC;gBAC9B,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,SAAS,CAAC,GAAG,EAAE,CAAC;oBAChC,OAAO,KAAK,CAAA;gBACd,CAAC;YACH,CAAC;YACD,qCAAqC;QACvC,CAAC;aAAM,CAAC;YACN,wBAAwB;YACxB,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,SAAS,EAAE,CAAC;gBAC5B,OAAO,KAAK,CAAA;YACd,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,IAAO,EACP,YAAqE,EACrE,SAA8B,EAC9B,IAKC;IAED,MAAM,QAAQ,GAA4B,EAAE,CAAA;IAE5C,oDAAoD;IACpD,6EAA6E;IAC7E,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAU,CAAA;IAC1C,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,IAAI,WAAW,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;YACxC,wFAAwF;YACxF,MAAM,SAAS,GAAG,WAAiC,CAAA;YACnD,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;gBACpB,gBAAgB,CAAC,GAAG,CAAC,GAAG,SAAS,IAAI,CAAC,CAAA;YACxC,CAAC;QACH,CAAC;IACH,CAAC;IAED,KAAK,MAAM,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACtD,MAAM,WAAW,GAAG,YAAY,CAAC,SAAS,CAAC,CAAA;QAE3C,qBAAqB;QACrB,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;YACzD,SAAQ;QACV,CAAC;QAED,qDAAqD;QACrD,wEAAwE;QACxE,IAAI,WAAW,IAAI,SAAS,IAAI,WAAW,IAAI,WAAW,CAAC,OAAO,EAAE,CAAC;YACnE,SAAQ;QACV,CAAC;QAED,8FAA8F;QAC9F,kGAAkG;QAClG,IAAI,gBAAgB,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;YACpC,SAAQ;QACV,CAAC;QAED,kEAAkE;QAClE,MAAM,QAAQ,GAAG,MAAM,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE;YACtE,GAAG,IAAI;YACP,SAAS,EAAE,IAAI,CAAC,SAAS;SAC1B,CAAC,CAAA;QAEF,IAAI,QAAQ,EAAE,CAAC;YACb,QAAQ,CAAC,SAAS,CAAC,GAAG,KAAK,CAAA;QAC7B,CAAC;IACH,CAAC;IAED,OAAO,QAAsB,CAAA;AAC/B,CAAC"}
1
+ {"version":3,"file":"field-access.js","sourceRoot":"","sources":["../../src/access/field-access.ts"],"names":[],"mappings":"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"}