@opensaas/stack-core 0.39.2 → 0.41.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 (165) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +189 -0
  3. package/CLAUDE.md +103 -0
  4. package/dist/access/access-filter.d.ts +157 -14
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +470 -42
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +982 -17
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +54 -1
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +84 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/engine.test.d.ts +2 -0
  19. package/dist/access/engine.test.d.ts.map +1 -0
  20. package/dist/access/engine.test.js +56 -0
  21. package/dist/access/engine.test.js.map +1 -0
  22. package/dist/access/errors.d.ts +62 -0
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +85 -2
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-access.d.ts +12 -0
  27. package/dist/access/field-access.d.ts.map +1 -1
  28. package/dist/access/field-access.js +22 -2
  29. package/dist/access/field-access.js.map +1 -1
  30. package/dist/access/field-access.test.js +66 -0
  31. package/dist/access/field-access.test.js.map +1 -1
  32. package/dist/access/field-visibility.d.ts +2 -1
  33. package/dist/access/field-visibility.d.ts.map +1 -1
  34. package/dist/access/field-visibility.js +107 -15
  35. package/dist/access/field-visibility.js.map +1 -1
  36. package/dist/access/index.d.ts +9 -2
  37. package/dist/access/index.d.ts.map +1 -1
  38. package/dist/access/index.js +11 -2
  39. package/dist/access/index.js.map +1 -1
  40. package/dist/access/multi-column-read-write.test.js.map +1 -1
  41. package/dist/access/query-validation.d.ts +31 -0
  42. package/dist/access/query-validation.d.ts.map +1 -1
  43. package/dist/access/query-validation.js +75 -16
  44. package/dist/access/query-validation.js.map +1 -1
  45. package/dist/access/relationship-count.d.ts +41 -3
  46. package/dist/access/relationship-count.d.ts.map +1 -1
  47. package/dist/access/relationship-count.js +44 -7
  48. package/dist/access/relationship-count.js.map +1 -1
  49. package/dist/access/relationship-count.test.js +62 -0
  50. package/dist/access/relationship-count.test.js.map +1 -1
  51. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  52. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  53. package/dist/access/synthetic-include-read.test.js +121 -0
  54. package/dist/access/synthetic-include-read.test.js.map +1 -0
  55. package/dist/config/types.d.ts +98 -32
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/context/hook-pipeline.d.ts +2 -2
  58. package/dist/context/hook-pipeline.d.ts.map +1 -1
  59. package/dist/context/hook-pipeline.js.map +1 -1
  60. package/dist/context/index.d.ts +35 -2
  61. package/dist/context/index.d.ts.map +1 -1
  62. package/dist/context/index.js +121 -27
  63. package/dist/context/index.js.map +1 -1
  64. package/dist/context/nested-operations.d.ts +2 -2
  65. package/dist/context/nested-operations.d.ts.map +1 -1
  66. package/dist/context/nested-operations.js +74 -15
  67. package/dist/context/nested-operations.js.map +1 -1
  68. package/dist/context/write-pipeline.d.ts +3 -2
  69. package/dist/context/write-pipeline.d.ts.map +1 -1
  70. package/dist/context/write-pipeline.js +31 -34
  71. package/dist/context/write-pipeline.js.map +1 -1
  72. package/dist/fields/index.d.ts +13 -1
  73. package/dist/fields/index.d.ts.map +1 -1
  74. package/dist/fields/index.js +50 -5
  75. package/dist/fields/index.js.map +1 -1
  76. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  77. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  78. package/dist/fields/relationship-foreign-key.test.js +18 -0
  79. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  80. package/dist/hooks/index.d.ts +16 -15
  81. package/dist/hooks/index.d.ts.map +1 -1
  82. package/dist/hooks/index.js.map +1 -1
  83. package/dist/index.d.ts +4 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +14 -1
  86. package/dist/index.js.map +1 -1
  87. package/dist/lib/case-utils.d.ts +15 -0
  88. package/dist/lib/case-utils.d.ts.map +1 -1
  89. package/dist/lib/case-utils.js +21 -0
  90. package/dist/lib/case-utils.js.map +1 -1
  91. package/dist/lib/case-utils.test.js +23 -1
  92. package/dist/lib/case-utils.test.js.map +1 -1
  93. package/dist/lib/prisma-errors.d.ts +38 -0
  94. package/dist/lib/prisma-errors.d.ts.map +1 -0
  95. package/dist/lib/prisma-errors.js +53 -0
  96. package/dist/lib/prisma-errors.js.map +1 -0
  97. package/dist/mcp/constants.d.ts +23 -0
  98. package/dist/mcp/constants.d.ts.map +1 -0
  99. package/dist/mcp/constants.js +23 -0
  100. package/dist/mcp/constants.js.map +1 -0
  101. package/dist/mcp/field-schema.d.ts +8 -0
  102. package/dist/mcp/field-schema.d.ts.map +1 -0
  103. package/dist/mcp/field-schema.js +69 -0
  104. package/dist/mcp/field-schema.js.map +1 -0
  105. package/dist/mcp/handler.d.ts.map +1 -1
  106. package/dist/mcp/handler.js +76 -90
  107. package/dist/mcp/handler.js.map +1 -1
  108. package/dist/mcp/projection.d.ts +62 -0
  109. package/dist/mcp/projection.d.ts.map +1 -0
  110. package/dist/mcp/projection.js +390 -0
  111. package/dist/mcp/projection.js.map +1 -0
  112. package/dist/utils/password.d.ts +3 -1
  113. package/dist/utils/password.d.ts.map +1 -1
  114. package/dist/utils/password.js +4 -2
  115. package/dist/utils/password.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/access/access-filter.test.ts +1528 -18
  118. package/src/access/access-filter.ts +708 -42
  119. package/src/access/denied-relation-visibility.test.ts +173 -0
  120. package/src/access/engine.test.ts +67 -0
  121. package/src/access/engine.ts +109 -1
  122. package/src/access/errors.ts +97 -2
  123. package/src/access/field-access.test.ts +78 -0
  124. package/src/access/field-access.ts +34 -2
  125. package/src/access/field-visibility.ts +171 -25
  126. package/src/access/index.ts +25 -0
  127. package/src/access/multi-column-read-write.test.ts +3 -2
  128. package/src/access/query-validation.ts +136 -9
  129. package/src/access/relationship-count.test.ts +75 -0
  130. package/src/access/relationship-count.ts +53 -10
  131. package/src/access/synthetic-include-read.test.ts +179 -0
  132. package/src/config/types.ts +98 -32
  133. package/src/context/hook-pipeline.ts +4 -2
  134. package/src/context/index.ts +203 -42
  135. package/src/context/nested-operations.ts +96 -27
  136. package/src/context/write-pipeline.ts +60 -36
  137. package/src/fields/index.ts +55 -5
  138. package/src/fields/relationship-foreign-key.test.ts +29 -0
  139. package/src/hooks/index.ts +22 -15
  140. package/src/index.ts +17 -1
  141. package/src/lib/case-utils.test.ts +32 -0
  142. package/src/lib/case-utils.ts +26 -0
  143. package/src/lib/prisma-errors.ts +86 -0
  144. package/src/mcp/constants.ts +24 -0
  145. package/src/mcp/field-schema.ts +84 -0
  146. package/src/mcp/handler.ts +109 -107
  147. package/src/mcp/projection.ts +497 -0
  148. package/src/utils/password.ts +5 -3
  149. package/tests/access-relationships.test.ts +3 -3
  150. package/tests/access.test.ts +83 -0
  151. package/tests/context.test.ts +452 -17
  152. package/tests/field-types.test.ts +113 -0
  153. package/tests/hook-context-secured.test.ts +424 -0
  154. package/tests/mcp-fields-projection-access.test.ts +250 -0
  155. package/tests/mcp-handler.test.ts +695 -4
  156. package/tests/nested-access-and-hooks.test.ts +389 -0
  157. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  158. package/tests/password-types.test.ts +28 -0
  159. package/tests/password.test.ts +16 -2
  160. package/tests/prisma-errors.test.ts +141 -0
  161. package/tests/singleton.test.ts +9 -1
  162. package/tests/to-one-include-access-filter.test.ts +172 -0
  163. package/tests/with-session.test.ts +243 -0
  164. package/tests/write-pipeline.test.ts +29 -0
  165. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterReadableFields } from './field-visibility.js'
3
+ import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
4
+ import type { ToOneAccessVisibilityTree } from './access-filter.js'
5
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
6
+ import type { AccessContext } from './types.js'
7
+
8
+ /**
9
+ * Regression coverage for the `filterReadableFields` half of issue #1103: a
10
+ * relation `buildAccessScopedInclude` denied outright is absent from the raw
11
+ * row (Prisma never fetched it), so the main per-field loop — which only
12
+ * ever visits keys `Object.entries(workingItem)` contains — never sees it.
13
+ * The dedicated post-query pass this file exercises is what forces the key
14
+ * present anyway: `null` for a to-one relation (issue #974, pre-existing),
15
+ * `[]` for a to-many one (issue #1103, this fix) — never a silently absent
16
+ * key, which is what previously broke the fragment API's typed contract
17
+ * (`ResultOf` types a to-many relation as an array).
18
+ */
19
+
20
+ function rel(ref: string, many = false): FieldConfig {
21
+ return { type: 'relationship', ref, many } as unknown as FieldConfig
22
+ }
23
+
24
+ function makeContext(): AccessContext {
25
+ return {
26
+ session: null,
27
+ _isSudo: false,
28
+ _resolveOutputChain: [],
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
30
+ } as any
31
+ }
32
+
33
+ function config(): OpenSaasConfig {
34
+ return {
35
+ db: { provider: 'sqlite' },
36
+ lists: {
37
+ Author: {
38
+ fields: {
39
+ name: { type: 'text' } as FieldConfig,
40
+ secrets: rel('Secret.author', true),
41
+ profile: rel('Profile.author', false),
42
+ hiddenSecrets: {
43
+ ...rel('Secret.author', true),
44
+ access: { read: () => false },
45
+ } as unknown as FieldConfig,
46
+ },
47
+ access: { operation: { query: () => true } },
48
+ },
49
+ Secret: {
50
+ fields: { value: { type: 'text' } as FieldConfig },
51
+ access: { operation: { query: () => false } },
52
+ },
53
+ Profile: {
54
+ fields: { bio: { type: 'text' } as FieldConfig },
55
+ access: { operation: { query: () => false } },
56
+ },
57
+ },
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
59
+ } as any as OpenSaasConfig
60
+ }
61
+
62
+ // Term ← Bill.term (list-only ref, no field on Term) — the synthetic
63
+ // back-relation case, always to-many (#1082).
64
+ function syntheticConfig(): OpenSaasConfig {
65
+ return {
66
+ db: { provider: 'sqlite' },
67
+ lists: {
68
+ Term: {
69
+ fields: { name: { type: 'text' } as FieldConfig },
70
+ access: { operation: { query: () => true } },
71
+ },
72
+ Bill: {
73
+ fields: { amount: { type: 'integer' } as FieldConfig, term: rel('Term') },
74
+ access: { operation: { query: () => false } },
75
+ },
76
+ },
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
78
+ } as any as OpenSaasConfig
79
+ }
80
+
81
+ describe('filterReadableFields — denied relations forced present (issues #974 / #1103)', () => {
82
+ it('forces a denied to-many relation to [], not an absent key', async () => {
83
+ const cfg = config()
84
+ const authorRow = { id: 'a1', name: 'A' } // `secrets` absent — never fetched by Prisma
85
+ const toOneVisibility: ToOneAccessVisibilityTree = {
86
+ filters: { secrets: { kind: 'denied' } },
87
+ nested: {},
88
+ }
89
+
90
+ const result = await filterReadableFields(
91
+ authorRow,
92
+ cfg.lists.Author.fields,
93
+ { session: null, context: makeContext() },
94
+ cfg,
95
+ 0,
96
+ 'Author',
97
+ emptyDeclaredOnlyTree(),
98
+ undefined,
99
+ toOneVisibility,
100
+ )
101
+
102
+ expect((result as Record<string, unknown>).secrets).toEqual([])
103
+ })
104
+
105
+ it('still forces a denied to-one relation to null (regression check, issue #974)', async () => {
106
+ const cfg = config()
107
+ const authorRow = { id: 'a1', name: 'A' } // `profile` absent — never fetched by Prisma
108
+ const toOneVisibility: ToOneAccessVisibilityTree = {
109
+ filters: { profile: { kind: 'denied' } },
110
+ nested: {},
111
+ }
112
+
113
+ const result = await filterReadableFields(
114
+ authorRow,
115
+ cfg.lists.Author.fields,
116
+ { session: null, context: makeContext() },
117
+ cfg,
118
+ 0,
119
+ 'Author',
120
+ emptyDeclaredOnlyTree(),
121
+ undefined,
122
+ toOneVisibility,
123
+ )
124
+
125
+ expect((result as Record<string, unknown>).profile).toBeNull()
126
+ })
127
+
128
+ it('forces a denied synthetic back-relation to [] — always to-many (#1082 interaction)', async () => {
129
+ const cfg = syntheticConfig()
130
+ const termRow = { id: 't1', name: 'Term 1' } // `from_Bill_term` absent — never fetched
131
+ const toOneVisibility: ToOneAccessVisibilityTree = {
132
+ filters: { from_Bill_term: { kind: 'denied' } },
133
+ nested: {},
134
+ }
135
+
136
+ const result = await filterReadableFields(
137
+ termRow,
138
+ cfg.lists.Term.fields,
139
+ { session: null, context: makeContext() },
140
+ cfg,
141
+ 0,
142
+ 'Term',
143
+ emptyDeclaredOnlyTree(),
144
+ undefined,
145
+ toOneVisibility,
146
+ )
147
+
148
+ expect((result as Record<string, unknown>).from_Bill_term).toEqual([])
149
+ })
150
+
151
+ it('omits a denied relation entirely when field-level read access also denies it, rather than forcing []', async () => {
152
+ const cfg = config()
153
+ const authorRow = { id: 'a1', name: 'A' }
154
+ const toOneVisibility: ToOneAccessVisibilityTree = {
155
+ filters: { hiddenSecrets: { kind: 'denied' } },
156
+ nested: {},
157
+ }
158
+
159
+ const result = await filterReadableFields(
160
+ authorRow,
161
+ cfg.lists.Author.fields,
162
+ { session: null, context: makeContext() },
163
+ cfg,
164
+ 0,
165
+ 'Author',
166
+ emptyDeclaredOnlyTree(),
167
+ undefined,
168
+ toOneVisibility,
169
+ )
170
+
171
+ expect('hiddenSecrets' in result).toBe(false)
172
+ })
173
+ })
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { resolveSyntheticReverseRelation } from './engine.js'
3
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
4
+
5
+ // A relationship field pointing at another list.
6
+ function rel(ref: string, many = false): FieldConfig {
7
+ return { type: 'relationship', ref, many } as unknown as FieldConfig
8
+ }
9
+
10
+ /**
11
+ * #978: a list-only `ref` (`ref: 'ListName'`, no target field) generates a
12
+ * synthetic back-relation on its target — `from_<SourceList>_<sourceField>`
13
+ * (see `getSyntheticFieldName` in fields/index.ts). `resolveSyntheticReverseRelation`
14
+ * is the runtime counterpart that recognises the same name and resolves it
15
+ * back to the declared field that owns it, so a nested write addressed to it
16
+ * gets the same hook pipeline a declared relationship field gets.
17
+ */
18
+ describe('resolveSyntheticReverseRelation', () => {
19
+ const config: OpenSaasConfig = {
20
+ db: { provider: 'sqlite', url: 'file:./dev.db' },
21
+ lists: {
22
+ Account: { fields: { name: { type: 'text' } as unknown as FieldConfig } },
23
+ ChargeRequest: {
24
+ fields: {
25
+ kind: { type: 'text' } as unknown as FieldConfig,
26
+ // List-only ref — no field named on Account, so a back-relation is
27
+ // synthesized on it as `from_ChargeRequest_account`.
28
+ account: rel('Account'),
29
+ },
30
+ },
31
+ // A bidirectional relationship — Post.author names User.posts directly,
32
+ // so no back-relation is synthesized anywhere for it.
33
+ User: { fields: { posts: rel('Post.author', true) } },
34
+ Post: { fields: { author: rel('User.posts') } },
35
+ },
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ } as any
38
+
39
+ it('resolves the synthetic back-relation name to its owning field', () => {
40
+ const result = resolveSyntheticReverseRelation('from_ChargeRequest_account', 'Account', config)
41
+ expect(result).not.toBeNull()
42
+ expect(result?.sourceListName).toBe('ChargeRequest')
43
+ expect(result?.sourceFieldName).toBe('account')
44
+ expect(result?.sourceFieldConfig.ref).toBe('Account')
45
+ })
46
+
47
+ it('returns null for a name that does not match the synthetic construction', () => {
48
+ expect(
49
+ resolveSyntheticReverseRelation('from_ChargeRequest_amount', 'Account', config),
50
+ ).toBeNull()
51
+ expect(resolveSyntheticReverseRelation('chargeRequests', 'Account', config)).toBeNull()
52
+ })
53
+
54
+ it('returns null on the wrong parent list', () => {
55
+ expect(
56
+ resolveSyntheticReverseRelation('from_ChargeRequest_account', 'ChargeRequest', config),
57
+ ).toBeNull()
58
+ })
59
+
60
+ it('returns null for a bidirectional ref — its other side is a real declared field, not a synthetic one', () => {
61
+ expect(resolveSyntheticReverseRelation('from_Post_author', 'User', config)).toBeNull()
62
+ })
63
+
64
+ it('returns null for a genuinely unknown key', () => {
65
+ expect(resolveSyntheticReverseRelation('totallyBogusKey', 'Account', config)).toBeNull()
66
+ })
67
+ })
@@ -1,5 +1,7 @@
1
1
  import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js'
2
- import type { OpenSaasConfig, ListConfig } from '../config/types.js'
2
+ import type { OpenSaasConfig, ListConfig, RelationshipField } from '../config/types.js'
3
+ import { getSyntheticFieldName } from '../fields/index.js'
4
+ import { InvalidCreateAccessResultError } from './errors.js'
3
5
 
4
6
  /**
5
7
  * Access engine — operation-level access control and shared helpers.
@@ -45,6 +47,81 @@ export function getRelatedListConfig(
45
47
  return { listName, listConfig }
46
48
  }
47
49
 
50
+ /** A synthetic reverse relation, resolved back to the declared field that owns it. */
51
+ export interface SyntheticReverseRelation {
52
+ sourceListName: string
53
+ sourceFieldName: string
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
55
+ sourceFieldConfig: RelationshipField<any>
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
57
+ sourceListConfig: ListConfig<any>
58
+ }
59
+
60
+ /**
61
+ * Resolve a candidate data key as the synthetic back-relation a list-only
62
+ * `ref` (`ref: 'ListName'`, no target field) generates on its target model —
63
+ * Prisma requires an opposite field there, but the config never declares one,
64
+ * so it never appears in `parentListName`'s own `fields`. Reuses
65
+ * `getSyntheticFieldName` (the same construction `getPrismaRelation` emits the
66
+ * schema with) rather than re-deriving the `from_<List>_<field>` format by
67
+ * string parsing, so the two cannot drift (#978).
68
+ *
69
+ * Returns the declared relationship field that owns the relation — the write
70
+ * pipeline treats a resolved synthetic key exactly like a nested write through
71
+ * that field, so it runs the same hooks/access/recovery machinery a declared
72
+ * relationship field gets. Returns `null` when `fieldName` isn't one of these
73
+ * on `parentListName` (a genuinely unknown key, or a bidirectional relation's
74
+ * ref, which never synthesizes a back-relation).
75
+ */
76
+ export function resolveSyntheticReverseRelation(
77
+ fieldName: string,
78
+ parentListName: string,
79
+ config: OpenSaasConfig,
80
+ ): SyntheticReverseRelation | null {
81
+ for (const [sourceListName, sourceListConfig] of Object.entries(config.lists)) {
82
+ for (const [sourceFieldName, sourceFieldConfig] of Object.entries(sourceListConfig.fields)) {
83
+ if (sourceFieldConfig.type !== 'relationship') continue
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
85
+ const rel = sourceFieldConfig as RelationshipField<any>
86
+ // Only a list-only ref ('ListName', no '.fieldName') synthesizes a
87
+ // back-relation — a bidirectional ref's other side is a real field.
88
+ const refParts = rel.ref.split('.')
89
+ if (refParts.length !== 1 || refParts[0] !== parentListName) continue
90
+ if (getSyntheticFieldName(sourceListName, sourceFieldName) !== fieldName) continue
91
+ return { sourceListName, sourceFieldName, sourceFieldConfig: rel, sourceListConfig }
92
+ }
93
+ }
94
+ return null
95
+ }
96
+
97
+ /**
98
+ * Enumerate every synthetic back-relation name a list-only `ref` elsewhere in
99
+ * the config synthesizes onto `parentListName` — the same relations
100
+ * `resolveSyntheticReverseRelation` resolves one at a time given a candidate
101
+ * key, returned here as the full set for a caller that instead needs "every
102
+ * relation this list carries" with no candidate to check (e.g. `_count:
103
+ * true`'s "count every relation" expansion in `access-filter.ts`, issue
104
+ * #1087 — a bare `_count: true` must include a synthetic back-relation's
105
+ * count exactly as it always has, not only a caller-named one).
106
+ */
107
+ export function listSyntheticReverseRelationNames(
108
+ parentListName: string,
109
+ config: OpenSaasConfig,
110
+ ): string[] {
111
+ const names: string[] = []
112
+ for (const [sourceListName, sourceListConfig] of Object.entries(config.lists)) {
113
+ for (const [sourceFieldName, sourceFieldConfig] of Object.entries(sourceListConfig.fields)) {
114
+ if (sourceFieldConfig.type !== 'relationship') continue
115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
116
+ const rel = sourceFieldConfig as RelationshipField<any>
117
+ const refParts = rel.ref.split('.')
118
+ if (refParts.length !== 1 || refParts[0] !== parentListName) continue
119
+ names.push(getSyntheticFieldName(sourceListName, sourceFieldName))
120
+ }
121
+ }
122
+ return names
123
+ }
124
+
48
125
  export async function checkAccess<T = Record<string, unknown>>(
49
126
  accessControl: AccessControl<T> | undefined,
50
127
  args: {
@@ -63,6 +140,37 @@ export async function checkAccess<T = Record<string, unknown>>(
63
140
  return result
64
141
  }
65
142
 
143
+ /**
144
+ * Evaluate operation-level `create` access. The single evaluator shared by the
145
+ * write pipeline's top-level create and the nested-create path — both must
146
+ * reject the same way, or the two drift again (#1009).
147
+ *
148
+ * Unlike `checkAccess` (which `query`/`update`/`delete` call directly and which
149
+ * legitimately returns a filter for them to re-check against a row), create
150
+ * has no existing row and no way to test a filter against input data. A rule
151
+ * that returns anything other than a strict boolean — most notably a filter,
152
+ * which type-checks against the shared `AccessControl` signature and reads as
153
+ * though it scopes the create — throws `InvalidCreateAccessResultError`
154
+ * rather than being silently treated as an allow. See that error's doc,
155
+ * ADR-0022, and ADR-0030.
156
+ */
157
+ export async function checkCreateAccess<T = Record<string, unknown>>(
158
+ listKey: string,
159
+ accessControl: AccessControl<T> | undefined,
160
+ args: {
161
+ session: Session | null
162
+ context: AccessContext
163
+ },
164
+ ): Promise<boolean> {
165
+ const result = await checkAccess(accessControl, args)
166
+
167
+ if (isBoolean(result)) {
168
+ return result
169
+ }
170
+
171
+ throw new InvalidCreateAccessResultError(listKey, result)
172
+ }
173
+
66
174
  export function mergeFilters(
67
175
  userFilter: PrismaFilter | undefined,
68
176
  accessFilter: boolean | PrismaFilter,
@@ -99,7 +99,62 @@ export class RelationFilterAccessDeniedError extends Error {
99
99
  }
100
100
  }
101
101
 
102
- function describeFieldAccessResult(result: unknown): string {
102
+ /**
103
+ * Thrown when a caller-supplied `include` names a key that resolves to
104
+ * neither a declared relationship, a synthetic back-relation (a list-only
105
+ * `ref`'s ORM-required opposite field, which no list config declares — see
106
+ * `resolveSyntheticReverseRelation`), nor `_count`. Before this,
107
+ * `buildAccessScopedInclude`'s scoping walk passed an unrecognised key
108
+ * straight through unscoped — the one surface among `data`/`where`/`orderBy`/
109
+ * `include` that failed open rather than closed (issue #1082). `sudo` never
110
+ * reaches this: it skips `buildAccessScopedInclude` entirely (see
111
+ * `resolveReadInclude` in `context/index.ts`), matching every other
112
+ * access-control escape hatch.
113
+ */
114
+ export class UndeclaredIncludeKeyError extends Error {
115
+ public listKey: string
116
+ public fieldKey: string
117
+
118
+ constructor(listKey: string, fieldKey: string) {
119
+ super(
120
+ `Cannot include "${listKey}.${fieldKey}" — it is not a field of this list. ` +
121
+ `Undeclared include keys are rejected, matching data/where/orderBy keys.`,
122
+ )
123
+ this.name = 'UndeclaredIncludeKeyError'
124
+ this.listKey = listKey
125
+ this.fieldKey = fieldKey
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Thrown when a caller-supplied `_count.select` in `include` names a key that
131
+ * is not a countable to-many relation — undeclared, a scalar, a virtual, or a
132
+ * to-one relationship. Mirrors `UndeclaredIncludeKeyError`'s rejection for the
133
+ * ordinary `include` walk, for the one key shape that was allowlisted through
134
+ * unscoped rather than rejected (#1082's "Out of scope", closed here by
135
+ * #1087). A key naming a synthetic back-relation (#1082) is NOT rejected — it
136
+ * is a genuine countable to-many, resolved the same way the ordinary
137
+ * `include` walk resolves it. `sudo` never reaches this: it skips
138
+ * `buildAccessScopedInclude` entirely (see `resolveReadInclude` in
139
+ * `context/index.ts`), matching every other access-control escape hatch.
140
+ */
141
+ export class UndeclaredCountKeyError extends Error {
142
+ public listKey: string
143
+ public fieldKey: string
144
+
145
+ constructor(listKey: string, fieldKey: string) {
146
+ super(
147
+ `Cannot count "${listKey}.${fieldKey}" — it is not a countable to-many relationship on this ` +
148
+ `list. A \`_count.select\` key must name a declared to-many relationship or a synthetic ` +
149
+ `back-relation, matching the ordinary include keys this list accepts.`,
150
+ )
151
+ this.name = 'UndeclaredCountKeyError'
152
+ this.listKey = listKey
153
+ this.fieldKey = fieldKey
154
+ }
155
+ }
156
+
157
+ function describeAccessResult(result: unknown): string {
103
158
  if (result === null) return 'null'
104
159
  if (result === undefined) return 'undefined'
105
160
  if (typeof result === 'object') return 'an object (e.g. a Prisma filter)'
@@ -135,7 +190,7 @@ export class InvalidFieldAccessResultError extends Error {
135
190
  constructor(operation: 'read' | 'create' | 'update', result: unknown) {
136
191
  super(
137
192
  `Field-level access control for operation "${operation}" returned ` +
138
- `${describeFieldAccessResult(result)}, not a boolean. Field access is a per-field ` +
193
+ `${describeAccessResult(result)}, not a boolean. Field access is a per-field ` +
139
194
  `visibility decision — it must return true or false, and (unlike operation-level access) ` +
140
195
  `cannot scope which rows are affected. If you meant to restrict access based on the row or ` +
141
196
  `the write payload, evaluate the condition yourself and return a boolean, e.g. ` +
@@ -145,3 +200,43 @@ export class InvalidFieldAccessResultError extends Error {
145
200
  this.operation = operation
146
201
  }
147
202
  }
203
+
204
+ /**
205
+ * Thrown when operation-level `create` access control returns anything other
206
+ * than a strict `boolean`. `OperationAccess['create']` is typed as
207
+ * `AccessControl`, which also accepts a `PrismaFilter` — the shape
208
+ * `query`/`update`/`delete` legitimately use to scope which rows an
209
+ * operation may touch (see ADR-0001). Create has none of that to scope: there
210
+ * is no existing row, and — unlike update/delete, which re-check a returned
211
+ * filter against the target via `findFirst` — no equivalent re-check is
212
+ * possible against data that doesn't exist in the database yet.
213
+ *
214
+ * A filter-returning `create` rule type-checks (the shared `AccessControl`
215
+ * type admits it) and reads as though it scopes the create. Before this it
216
+ * fell through a check that only tested `=== false` and was silently treated
217
+ * as a full allow. This is the same defect shape ADR-0030 closed for
218
+ * field-level access, resolved the same way per ADR-0022 (an engine that
219
+ * cannot compute a scope must deny, never pass through) — a loud, distinct
220
+ * failure rather than a wider `null`/`[]` Silent failure, because this is a
221
+ * config bug, not an access denial.
222
+ *
223
+ * Deliberately does not expose the offending result as a public field, for
224
+ * the same reason as `InvalidFieldAccessResultError`: there is no
225
+ * concretely-typed shape to give it, and the root CLAUDE.md forbids exposing
226
+ * `unknown`/`any` as part of a package's external API.
227
+ */
228
+ export class InvalidCreateAccessResultError extends Error {
229
+ public listKey: string
230
+
231
+ constructor(listKey: string, result: unknown) {
232
+ super(
233
+ `Operation-level "create" access control for "${listKey}" returned ` +
234
+ `${describeAccessResult(result)}, not a boolean. Create cannot be row-scoped — there is no ` +
235
+ `existing row, and no input data is available to test a filter against here. Return a ` +
236
+ `boolean from create access, or move the ownership check into a \`resolveInput\` or ` +
237
+ `\`validate\` hook, where the input data is in scope.`,
238
+ )
239
+ this.name = 'InvalidCreateAccessResultError'
240
+ this.listKey = listKey
241
+ }
242
+ }
@@ -712,3 +712,81 @@ describe('filterWritableFields', () => {
712
712
  expect(filtered.author).toEqual({ connect: { id: 'user-1' } })
713
713
  })
714
714
  })
715
+
716
+ // ── #978: under sudo, only a synthetic reverse-relation key is a recognised
717
+ // undeclared key — anything else is refused, even under sudo ──────────────
718
+
719
+ describe('filterWritableFields — #978 tightened sudo undeclared-key guard', () => {
720
+ // A minimal config: Account is the target of ChargeRequest's list-only ref
721
+ // (`ref: 'Account'`, no field named on Account), so the generator
722
+ // synthesizes `from_ChargeRequest_account` on Account.
723
+ const config = {
724
+ db: { provider: 'sqlite', url: 'file:./dev.db' },
725
+ lists: {
726
+ Account: { fields: { name: { type: 'text' } } },
727
+ ChargeRequest: {
728
+ fields: {
729
+ kind: { type: 'text' },
730
+ account: { type: 'relationship', ref: 'Account' },
731
+ },
732
+ },
733
+ },
734
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
735
+ } as any
736
+
737
+ it('passes a synthetic reverse-relation key through under sudo, when config/listName are supplied', async () => {
738
+ const fieldConfigs = { name: { type: 'text' } }
739
+ const data = {
740
+ name: 'Acme',
741
+ from_ChargeRequest_account: { create: { kind: 'DEPOSIT' } },
742
+ }
743
+
744
+ const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
745
+ session: null,
746
+ item: { id: 'a1' },
747
+ context: sudoContext(),
748
+ inputData: data,
749
+ listName: 'Account',
750
+ config,
751
+ })
752
+
753
+ expect(filtered).toHaveProperty('name', 'Acme')
754
+ expect(filtered).toHaveProperty('from_ChargeRequest_account')
755
+ })
756
+
757
+ it('throws on a genuinely unknown key under sudo, when config/listName are supplied', async () => {
758
+ const fieldConfigs = { name: { type: 'text' } }
759
+ const data = {
760
+ name: 'Acme',
761
+ totallyBogusKey: 'value',
762
+ }
763
+
764
+ await expect(
765
+ filterWritableFields(data, fieldConfigs, 'update', {
766
+ session: null,
767
+ item: { id: 'a1' },
768
+ context: sudoContext(),
769
+ inputData: data,
770
+ listName: 'Account',
771
+ config,
772
+ }),
773
+ ).rejects.toThrow(/totallyBogusKey/)
774
+ })
775
+
776
+ it('keeps the pre-#978 blanket sudo passthrough when config/listName are omitted', async () => {
777
+ // Pins the fallback direct unit tests above (e.g. "passes undeclared data
778
+ // keys through under sudo") rely on: with no config to resolve a synthetic
779
+ // key against, any undeclared key still passes through under sudo.
780
+ const fieldConfigs = { name: { type: 'text' } }
781
+ const data = { name: 'Acme', totallyBogusKey: 'value' }
782
+
783
+ const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
784
+ session: null,
785
+ item: { id: 'a1' },
786
+ context: sudoContext(),
787
+ inputData: data,
788
+ })
789
+
790
+ expect(filtered).toHaveProperty('totallyBogusKey', 'value')
791
+ })
792
+ })
@@ -1,10 +1,12 @@
1
1
  import type { Session, AccessContext } from './types.js'
2
2
  import type { FieldAccess, FieldAccessControl } from './types.js'
3
+ import type { OpenSaasConfig } from '../config/types.js'
3
4
  // `ValidationError` is referenced only inside function bodies (call-time), never
4
5
  // at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
5
6
  // under ESM live bindings.
6
7
  import { ValidationError } from '../hooks/index.js'
7
8
  import { InvalidFieldAccessResultError } from './errors.js'
9
+ import { resolveSyntheticReverseRelation } from './engine.js'
8
10
 
9
11
  /**
10
12
  * Marks a throw caused by touching {@link createPoisonedItem}'s `item`, as
@@ -196,6 +198,17 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
196
198
  item?: Record<string, unknown>
197
199
  context: AccessContext & { _isSudo?: boolean }
198
200
  inputData?: Record<string, unknown>
201
+ /**
202
+ * The list being written and the full config — used ONLY to recognise a
203
+ * synthetic reverse-relation key (`from_<List>_<field>`, #978) among the
204
+ * undeclared keys sudo would otherwise pass through unchecked. Both
205
+ * production call sites (the write pipeline, nested-operations) supply
206
+ * these; a direct unit test that omits them keeps the pre-#978 sudo
207
+ * behaviour of passing any undeclared key through, since it has no config
208
+ * to resolve a synthetic key against.
209
+ */
210
+ listName?: string
211
+ config?: OpenSaasConfig
199
212
  },
200
213
  ): Promise<Partial<T>> {
201
214
  const filtered: Record<string, unknown> = {}
@@ -286,10 +299,29 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
286
299
  // declares (e.g. back-relations like `from_Enrolment_student`), so allowing
287
300
  // an undeclared key to pass through lets a non-sudo caller drive ungated
288
301
  // nested writes on undeclared back-relations. Mirror Keystone's
289
- // GraphQL-schema behaviour and reject it. `sudo` is the single trusted
290
- // bypass, so undeclared keys still pass through under sudo.
302
+ // GraphQL-schema behaviour and reject it.
291
303
  if (!fieldConfig) {
292
304
  if (isSudo) {
305
+ // #978 — sudo bypasses ACCESS CONTROL, not the hooks/validation a
306
+ // recognised relation is entitled to. A synthetic reverse-relation key
307
+ // (a list-only ref's back-relation) is handed to the caller unchanged
308
+ // so processNestedOperations can run its target list's full pipeline,
309
+ // exactly as it would for a declared relationship field. Any other
310
+ // undeclared key has no such route to hooks — passing it straight to
311
+ // Prisma is the same silent-bypass shape this issue closed for
312
+ // relations, so it is refused even under sudo. `listName`/`config`
313
+ // are omitted only by direct unit tests of this function, which keep
314
+ // the pre-#978 blanket sudo passthrough since they have no config to
315
+ // resolve a synthetic key against.
316
+ if (args.listName && args.config) {
317
+ const synthetic = resolveSyntheticReverseRelation(fieldName, args.listName, args.config)
318
+ if (!synthetic) {
319
+ throw new ValidationError([
320
+ `Cannot ${operation} "${fieldName}": it is not a field of this list. ` +
321
+ `Undeclared data keys are rejected, even under sudo.`,
322
+ ])
323
+ }
324
+ }
293
325
  filtered[fieldName] = value
294
326
  continue
295
327
  }