@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,497 @@
1
+ import type { AccessContext, Session } from '../access/types.js'
2
+ import type { FieldConfig, ListConfig, OpenSaasConfig, RelationshipField } from '../config/types.js'
3
+ import { checkAccess, getRelatedListConfig } from '../access/engine.js'
4
+ import { validateQueryFieldReadAccess, validateQueryKeys } from '../access/query-validation.js'
5
+ import type { FieldSelection } from '../query/index.js'
6
+ import { pickFields } from '../query/index.js'
7
+ import { MCP_NESTED_TAKE_DEFAULT, MCP_NESTED_TAKE_MAX } from './constants.js'
8
+
9
+ /** A scalar/virtual field in a `fields` projection is selected by naming it `true` — this advertises that, not the field's own value shape (`fieldToJsonSchema`, used for `create`/`update`, is a different schema entirely). */
10
+ function scalarSelectorSchema(fieldName: string): Record<string, unknown> {
11
+ return { type: 'boolean', description: `Include the "${fieldName}" field` }
12
+ }
13
+
14
+ /**
15
+ * `id`/`createdAt`/`updatedAt` are added to every list automatically and
16
+ * excluded from `listConfig.fields` (CLAUDE.md's "System Fields"), so they
17
+ * need their own selector entries at every level a `fields` projection can
18
+ * name fields — a scalar-only loop over `listConfig.fields` would otherwise
19
+ * never advertise or accept them. `id` is additionally forced into every
20
+ * `FieldSelection` this module builds (`withId`, below), never left to the
21
+ * caller: a record projected down to none of its own identifying columns
22
+ * cannot be the target of a follow-up `update`/`delete` call.
23
+ */
24
+ function systemFieldProperties(): Record<string, unknown> {
25
+ return {
26
+ id: {
27
+ type: 'boolean',
28
+ description: 'Include the "id" field (always returned regardless of selection)',
29
+ },
30
+ createdAt: scalarSelectorSchema('createdAt'),
31
+ updatedAt: scalarSelectorSchema('updatedAt'),
32
+ }
33
+ }
34
+
35
+ function isSystemFieldName(name: string): name is 'id' | 'createdAt' | 'updatedAt' {
36
+ return name === 'id' || name === 'createdAt' || name === 'updatedAt'
37
+ }
38
+
39
+ /** Force `id` into a field selection being built for one level of a `fields` projection — see `systemFieldProperties`'s doc comment. */
40
+ function withId(selection: Record<string, unknown>): Record<string, unknown> {
41
+ return { ...selection, id: true }
42
+ }
43
+
44
+ /**
45
+ * Thrown when a `query` tool's `fields` argument names something the
46
+ * generated projection schema does not advertise — an unknown field, a
47
+ * relation two levels deeper than enumerated, or the wrong shape for what
48
+ * it named. Caught in the MCP handler and turned into an `isError` tool
49
+ * result (never a JSON-RPC protocol error, #995) naming what was asked for
50
+ * and what is available, so the calling model can retry narrower.
51
+ */
52
+ export class McpProjectionRefusedError extends Error {
53
+ constructor(message: string) {
54
+ super(message)
55
+ this.name = 'McpProjectionRefusedError'
56
+ }
57
+ }
58
+
59
+ function isRelationshipField(
60
+ fieldConfig: FieldConfig | undefined,
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
62
+ ): fieldConfig is RelationshipField<any> {
63
+ return (
64
+ !!fieldConfig &&
65
+ fieldConfig.type === 'relationship' &&
66
+ 'ref' in fieldConfig &&
67
+ !!fieldConfig.ref
68
+ )
69
+ }
70
+
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
72
+ function isMany(fieldConfig: RelationshipField<any>): boolean {
73
+ return fieldConfig.many === true
74
+ }
75
+
76
+ /**
77
+ * Resolve a relation field's target list, or `null` if it should not be
78
+ * advertised/selectable at all: the ref doesn't resolve, its MCP tools are
79
+ * disabled (ADR-0033 — a list with `mcp.enabled: false` is unreachable
80
+ * through MCP, including as a relation target), or this session's
81
+ * operation-level `query` access on it is denied outright.
82
+ */
83
+ async function relatedListIfVisible(
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
85
+ fieldConfig: RelationshipField<any>,
86
+ config: OpenSaasConfig,
87
+ session: Session | null,
88
+ context: AccessContext,
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
90
+ ): Promise<{ listName: string; listConfig: ListConfig<any> } | null> {
91
+ const related = getRelatedListConfig(fieldConfig.ref, config)
92
+ if (!related) return null
93
+ if (related.listConfig.mcp?.enabled === false) return null
94
+
95
+ const queryAccess = related.listConfig.access?.operation?.query
96
+ const result = await checkAccess(queryAccess, { session, context })
97
+ if (result === false) return null
98
+
99
+ return related
100
+ }
101
+
102
+ /**
103
+ * Generate the JSON Schema for the `query` tool's `fields` projection
104
+ * argument: two levels of the list's own vocabulary (ADR-0033). Level 1 is
105
+ * this list's own scalar/virtual fields (booleans) and relations (nested
106
+ * objects); level 2, inside a relation's own `fields`, is the related
107
+ * list's scalar/virtual fields only — a relation named there terminates
108
+ * (not itself selectable further), which is what keeps the schema finite
109
+ * against a self-referential relationship. Denied/MCP-disabled relation
110
+ * targets are omitted from the vocabulary entirely, per-session.
111
+ */
112
+ export async function generateFieldsProjectionSchema(
113
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
114
+ listConfig: ListConfig<any>,
115
+ config: OpenSaasConfig,
116
+ session: Session | null,
117
+ context: AccessContext,
118
+ ): Promise<Record<string, unknown>> {
119
+ const properties: Record<string, unknown> = systemFieldProperties()
120
+
121
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
122
+ if (!isRelationshipField(fieldConfig)) {
123
+ properties[fieldName] = scalarSelectorSchema(fieldName)
124
+ continue
125
+ }
126
+
127
+ const related = await relatedListIfVisible(fieldConfig, config, session, context)
128
+ if (!related) continue
129
+
130
+ const level2Properties: Record<string, unknown> = systemFieldProperties()
131
+ for (const [relFieldName, relFieldConfig] of Object.entries(related.listConfig.fields)) {
132
+ if (isRelationshipField(relFieldConfig)) continue
133
+ level2Properties[relFieldName] = scalarSelectorSchema(relFieldName)
134
+ }
135
+
136
+ const many = isMany(fieldConfig)
137
+ properties[fieldName] = {
138
+ type: 'object',
139
+ description: many
140
+ ? `Select fields from the related ${related.listName} records`
141
+ : `Select fields from the related ${related.listName} record`,
142
+ properties: {
143
+ fields: {
144
+ type: 'object',
145
+ description: `Fields to return from ${related.listName}`,
146
+ properties: level2Properties,
147
+ additionalProperties: false,
148
+ },
149
+ ...(many
150
+ ? {
151
+ where: { type: 'object', description: `Prisma where clause for ${related.listName}` },
152
+ orderBy: { type: 'object', description: 'Sort order' },
153
+ take: {
154
+ type: 'number',
155
+ description: `Max rows to return (default ${MCP_NESTED_TAKE_DEFAULT}, hard cap ${MCP_NESTED_TAKE_MAX})`,
156
+ },
157
+ skip: { type: 'number', description: 'Rows to skip' },
158
+ count: {
159
+ type: 'boolean',
160
+ description:
161
+ 'Return the total row count instead of, or alongside, the rows themselves',
162
+ },
163
+ }
164
+ : {}),
165
+ },
166
+ ...(many ? {} : { required: ['fields'] }),
167
+ additionalProperties: false,
168
+ }
169
+ }
170
+
171
+ return {
172
+ type: 'object',
173
+ description:
174
+ 'Select which fields (and, for relations, which nested fields) to return. Omit to get scalars and virtuals only, unchanged from today.',
175
+ properties,
176
+ additionalProperties: false,
177
+ }
178
+ }
179
+
180
+ /** What `resolveFieldsProjection` produces: a `context.db` `include` to fetch, the field selection to project the result down to, and which relations asked for a count. */
181
+ export type ResolvedFieldsProjection = {
182
+ include: Record<string, unknown> | undefined
183
+ fieldSelection: FieldSelection<unknown>
184
+ countRequests: Map<string, 'only' | 'alongside'>
185
+ }
186
+
187
+ /**
188
+ * Build the `context.db` include entry for a to-many relation's own rows —
189
+ * shared by a `fields`-selected relation and a count-only one (the latter
190
+ * fetches identically, just never adds the key to `fieldSelection`, so the
191
+ * rows reach field-visibility's access check but never the caller).
192
+ */
193
+ function buildManyIncludeEntry(
194
+ many: boolean,
195
+ entry: Record<string, unknown>,
196
+ ): Record<string, unknown> | true {
197
+ if (!many) return true
198
+ const includeEntry: Record<string, unknown> = {}
199
+ if (entry.where !== undefined) includeEntry.where = entry.where
200
+ if (entry.orderBy !== undefined) includeEntry.orderBy = entry.orderBy
201
+ const requestedTake = entry.take !== undefined ? Number(entry.take) : MCP_NESTED_TAKE_DEFAULT
202
+ includeEntry.take = Math.min(requestedTake, MCP_NESTED_TAKE_MAX)
203
+ if (entry.skip !== undefined) includeEntry.skip = entry.skip
204
+ return includeEntry
205
+ }
206
+
207
+ /** Access-scoped `_count.select` entry for one to-many relation: the related list's `query` access ANDed with any caller `where` on that same relation — mirrors how `buildAccessScopedInclude` scopes the relation's own rows (`andWhere`), since Prisma's `_count` is a sibling key that walk does not itself touch. */
208
+ async function accessScopedCountEntry(
209
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
210
+ fieldConfig: RelationshipField<any>,
211
+ config: OpenSaasConfig,
212
+ session: Session | null,
213
+ context: AccessContext,
214
+ callerWhere: Record<string, unknown> | undefined,
215
+ ): Promise<true | { where: Record<string, unknown> } | null> {
216
+ const related = getRelatedListConfig(fieldConfig.ref, config)
217
+ if (!related) return null
218
+
219
+ const queryAccess = related.listConfig.access?.operation?.query
220
+ const result = await checkAccess(queryAccess, { session, context })
221
+ if (result === false) return null
222
+
223
+ const accessWhere = typeof result === 'object' ? result : undefined
224
+ const merged =
225
+ accessWhere && callerWhere ? { AND: [accessWhere, callerWhere] } : (accessWhere ?? callerWhere)
226
+ return merged ? { where: merged } : true
227
+ }
228
+
229
+ /**
230
+ * Validate a caller-supplied `fields` argument against the same vocabulary
231
+ * `generateFieldsProjectionSchema` advertises, and translate it into a
232
+ * `context.db` `include` (so the normal read pipeline — access scoping,
233
+ * `needs` folding, the depth cap — applies exactly as it does for any other
234
+ * caller `include`; no parallel read path) plus a `FieldSelection` for
235
+ * projecting the result down to what was asked for. Throws
236
+ * `McpProjectionRefusedError` naming what was asked for and what's
237
+ * available on any mismatch, never serves on a best-effort basis.
238
+ */
239
+ export async function resolveFieldsProjection(
240
+ fieldsArg: unknown,
241
+ listKey: string,
242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
243
+ listConfig: ListConfig<any>,
244
+ config: OpenSaasConfig,
245
+ session: Session | null,
246
+ context: AccessContext,
247
+ ): Promise<ResolvedFieldsProjection> {
248
+ if (fieldsArg === null || typeof fieldsArg !== 'object' || Array.isArray(fieldsArg)) {
249
+ throw new McpProjectionRefusedError(
250
+ `"fields" must be an object mapping field names to \`true\` or a relation selector.`,
251
+ )
252
+ }
253
+
254
+ const include: Record<string, unknown> = {}
255
+ // `id` is always projected back, whether or not the caller asked for it —
256
+ // see `systemFieldProperties`'s doc comment.
257
+ const fieldSelection: Record<string, unknown> = { id: true }
258
+ const countRequests = new Map<string, 'only' | 'alongside'>()
259
+ let hasIncludeEntries = false
260
+
261
+ for (const [fieldName, rawValue] of Object.entries(fieldsArg as Record<string, unknown>)) {
262
+ if (isSystemFieldName(fieldName)) {
263
+ if (rawValue !== true) {
264
+ throw new McpProjectionRefusedError(
265
+ `"${listKey}.${fieldName}" is a scalar — select it with \`true\`, not ${JSON.stringify(rawValue)}.`,
266
+ )
267
+ }
268
+ fieldSelection[fieldName] = true
269
+ continue
270
+ }
271
+
272
+ const fieldConfig = listConfig.fields[fieldName]
273
+ if (!fieldConfig) {
274
+ throw new McpProjectionRefusedError(
275
+ `"${listKey}" has no field "${fieldName}". Available fields: ${Object.keys(listConfig.fields).join(', ')}.`,
276
+ )
277
+ }
278
+
279
+ if (!isRelationshipField(fieldConfig)) {
280
+ if (rawValue !== true) {
281
+ throw new McpProjectionRefusedError(
282
+ `"${listKey}.${fieldName}" is a scalar — select it with \`true\`, not ${JSON.stringify(rawValue)}.`,
283
+ )
284
+ }
285
+ fieldSelection[fieldName] = true
286
+ continue
287
+ }
288
+
289
+ const related = await relatedListIfVisible(fieldConfig, config, session, context)
290
+ if (!related) {
291
+ throw new McpProjectionRefusedError(
292
+ `"${listKey}.${fieldName}" is not available for selection — its related list is not ` +
293
+ `queryable by this session, or has MCP disabled.`,
294
+ )
295
+ }
296
+
297
+ if (rawValue === null || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
298
+ throw new McpProjectionRefusedError(
299
+ `"${listKey}.${fieldName}" is a relation — select it with an object, e.g. { "fields": { ... } }.`,
300
+ )
301
+ }
302
+
303
+ const entry = rawValue as Record<string, unknown>
304
+ const many = isMany(fieldConfig)
305
+ const allowedKeys = many
306
+ ? new Set(['fields', 'where', 'orderBy', 'take', 'skip', 'count'])
307
+ : new Set(['fields'])
308
+ for (const key of Object.keys(entry)) {
309
+ if (!allowedKeys.has(key)) {
310
+ throw new McpProjectionRefusedError(
311
+ `"${listKey}.${fieldName}" does not accept "${key}" (allowed: ${[...allowedKeys].join(', ')}).`,
312
+ )
313
+ }
314
+ }
315
+
316
+ // Type-check each key before it reaches Prisma — a malformed value here
317
+ // would otherwise only surface as an opaque Prisma error out of the
318
+ // `context.db` call below, instead of a clear refusal naming the shape
319
+ // that was expected (mirrors `access-filter.ts`'s `asEntryObject`).
320
+ if (entry.where !== undefined && (entry.where === null || typeof entry.where !== 'object')) {
321
+ throw new McpProjectionRefusedError(`"${listKey}.${fieldName}.where" must be an object.`)
322
+ }
323
+ if (
324
+ entry.orderBy !== undefined &&
325
+ (entry.orderBy === null || typeof entry.orderBy !== 'object')
326
+ ) {
327
+ throw new McpProjectionRefusedError(
328
+ `"${listKey}.${fieldName}.orderBy" must be an object or an array of objects.`,
329
+ )
330
+ }
331
+ if (entry.take !== undefined && typeof entry.take !== 'number') {
332
+ throw new McpProjectionRefusedError(`"${listKey}.${fieldName}.take" must be a number.`)
333
+ }
334
+ if (entry.skip !== undefined && typeof entry.skip !== 'number') {
335
+ throw new McpProjectionRefusedError(`"${listKey}.${fieldName}.skip" must be a number.`)
336
+ }
337
+ if (entry.count !== undefined && typeof entry.count !== 'boolean') {
338
+ throw new McpProjectionRefusedError(`"${listKey}.${fieldName}.count" must be a boolean.`)
339
+ }
340
+ if (entry.take !== undefined && (entry.take as number) < 0) {
341
+ throw new McpProjectionRefusedError(
342
+ `"${listKey}.${fieldName}.take" must not be negative (nested reverse pagination isn't supported).`,
343
+ )
344
+ }
345
+
346
+ // A nested `where`/`orderBy` names fields on the RELATED list — validate
347
+ // them the same way the root read path validates the root list's own
348
+ // `where`/`orderBy` (#912/#915), or a session could infer a field-level-
349
+ // denied related field's value from which rows come back, their order,
350
+ // or (via `count`, below) how many there are.
351
+ if (entry.where !== undefined || entry.orderBy !== undefined) {
352
+ validateQueryKeys({
353
+ where: entry.where,
354
+ orderBy: entry.orderBy,
355
+ listConfig: related.listConfig,
356
+ listName: related.listName,
357
+ config,
358
+ isSudo: false,
359
+ })
360
+ await validateQueryFieldReadAccess({
361
+ where: entry.where,
362
+ orderBy: entry.orderBy,
363
+ listConfig: related.listConfig,
364
+ listName: related.listName,
365
+ session,
366
+ context,
367
+ isSudo: false,
368
+ })
369
+ }
370
+
371
+ const nestedFieldsArg = entry.fields
372
+ const wantsCount = many && entry.count === true
373
+
374
+ if (nestedFieldsArg === undefined && !wantsCount) {
375
+ throw new McpProjectionRefusedError(
376
+ `"${listKey}.${fieldName}" needs "fields"${many ? ' or "count"' : ''}.`,
377
+ )
378
+ }
379
+
380
+ const nestedSelection: Record<string, true> = {}
381
+ if (nestedFieldsArg !== undefined) {
382
+ if (
383
+ nestedFieldsArg === null ||
384
+ typeof nestedFieldsArg !== 'object' ||
385
+ Array.isArray(nestedFieldsArg)
386
+ ) {
387
+ throw new McpProjectionRefusedError(`"${listKey}.${fieldName}.fields" must be an object.`)
388
+ }
389
+ for (const [relFieldName, relValue] of Object.entries(
390
+ nestedFieldsArg as Record<string, unknown>,
391
+ )) {
392
+ if (!isSystemFieldName(relFieldName)) {
393
+ const relFieldConfig = related.listConfig.fields[relFieldName]
394
+ if (!relFieldConfig || isRelationshipField(relFieldConfig)) {
395
+ throw new McpProjectionRefusedError(
396
+ `"${related.listName}" has no selectable field "${relFieldName}" at this depth — relations ` +
397
+ `are not selectable two levels deep; issue a second query for that.`,
398
+ )
399
+ }
400
+ }
401
+ if (relValue !== true) {
402
+ throw new McpProjectionRefusedError(
403
+ `"${related.listName}.${relFieldName}" is a scalar — select it with \`true\`.`,
404
+ )
405
+ }
406
+ nestedSelection[relFieldName] = true
407
+ }
408
+
409
+ include[fieldName] = buildManyIncludeEntry(many, entry)
410
+ hasIncludeEntries = true
411
+ fieldSelection[fieldName] = { _type: 'fragment', _fields: withId(nestedSelection) }
412
+ } else if (wantsCount) {
413
+ // Count-only (no `fields`): still name the relation in the include,
414
+ // with the SAME rows a `fields`-and-`count` request would fetch —
415
+ // never a synthetic zero-row placeholder. Field-visibility's read-
416
+ // access check for the relationship field runs against whatever the
417
+ // include actually fetched (`accessItem: workingItem`); a rule that
418
+ // inspects the relation's own value (e.g. `item.comments.length`)
419
+ // would see a permanently-empty array under `take: 0` regardless of
420
+ // the true content, which is wrong in the opposite direction from
421
+ // what this whole mechanism exists to prevent. The rows themselves
422
+ // never reach the caller — `fieldSelection` has no entry for this key
423
+ // — only the access decision `filterReadableFields` made against them
424
+ // does, read off in `projectMcpResult` below via whether the key
425
+ // survived into the filtered result.
426
+ include[fieldName] = buildManyIncludeEntry(many, entry)
427
+ hasIncludeEntries = true
428
+ }
429
+
430
+ if (wantsCount) {
431
+ countRequests.set(fieldName, nestedFieldsArg !== undefined ? 'alongside' : 'only')
432
+ }
433
+ }
434
+
435
+ if (countRequests.size > 0) {
436
+ const countSelect: Record<string, unknown> = {}
437
+ for (const fieldName of countRequests.keys()) {
438
+ const fieldConfig = listConfig.fields[fieldName] as RelationshipField
439
+ const rawEntry = (fieldsArg as Record<string, unknown>)[fieldName] as Record<string, unknown>
440
+ const scoped = await accessScopedCountEntry(
441
+ fieldConfig,
442
+ config,
443
+ session,
444
+ context,
445
+ rawEntry.where as Record<string, unknown> | undefined,
446
+ )
447
+ if (scoped) countSelect[fieldName] = scoped
448
+ }
449
+ if (Object.keys(countSelect).length > 0) {
450
+ include._count = { select: countSelect }
451
+ hasIncludeEntries = true
452
+ }
453
+ }
454
+
455
+ return {
456
+ include: hasIncludeEntries ? include : undefined,
457
+ fieldSelection: fieldSelection as FieldSelection<unknown>,
458
+ countRequests,
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Project one raw (already access-checked and field-visibility-filtered)
464
+ * result row down to a resolved `fields` projection, folding in any
465
+ * requested relation counts from Prisma's `_count`.
466
+ *
467
+ * `_count` is not a declared field, so field-visibility (`filterReadableFields`)
468
+ * never applies the relationship field's own field-level `read` access to it
469
+ * the way it already does for the relation's rows. Rather than re-deriving
470
+ * that decision here — which would mean evaluating the field's access rule
471
+ * a second time against `rawItem`, a row `filterReadableFields` may have
472
+ * already stripped OTHER fields from, risking a different answer than the
473
+ * canonical one it computed against the true raw row — `resolveFieldsProjection`
474
+ * names every counted relation in the include (even a count-only one, with
475
+ * `take: 0`) purely so the ordinary pipeline makes that decision once, at the
476
+ * right place. A count is emitted only for a relation whose key survived
477
+ * into `rawItem`; one field-visibility dropped is a relation whose count
478
+ * must stay hidden too.
479
+ */
480
+ export function projectMcpResult(
481
+ rawItem: Record<string, unknown>,
482
+ resolved: ResolvedFieldsProjection,
483
+ ): Record<string, unknown> {
484
+ const picked = pickFields(rawItem, resolved.fieldSelection) as Record<string, unknown>
485
+ const rawCounts = rawItem._count
486
+ const counts =
487
+ rawCounts && typeof rawCounts === 'object' ? (rawCounts as Record<string, unknown>) : {}
488
+
489
+ for (const [key, mode] of resolved.countRequests) {
490
+ if (!(key in rawItem)) continue
491
+
492
+ const count = typeof counts[key] === 'number' ? (counts[key] as number) : 0
493
+ picked[key] = mode === 'only' ? count : { items: picked[key], count }
494
+ }
495
+
496
+ return picked
497
+ }
@@ -67,9 +67,11 @@ export class HashedPassword {
67
67
  return this.hash
68
68
  }
69
69
 
70
- // JSON.stringify calls this when present, so the hash serializes as a plain string.
71
- toJSON(): string {
72
- return this.hash
70
+ // JSON.stringify calls this when present, so this is the serialize-for-output
71
+ // redaction point — never return the hash here. toString()/valueOf()/
72
+ // Symbol.toPrimitive stay unredacted for internal string/compare use.
73
+ toJSON(): { isSet: boolean } {
74
+ return { isSet: !!this.hash }
73
75
  }
74
76
 
75
77
  valueOf(): string {
@@ -155,7 +155,7 @@ describe('Relationship Access Control', () => {
155
155
  // Test that buildAccessScopedInclude excludes the denied relationship
156
156
  const { buildAccessScopedInclude } = await import('../src/access/index.js')
157
157
 
158
- const include = await buildAccessScopedInclude(
158
+ const { include } = await buildAccessScopedInclude(
159
159
  { author: true },
160
160
  config.lists.Post.fields,
161
161
  {
@@ -323,7 +323,7 @@ describe('Relationship Access Control', () => {
323
323
  // Test that buildAccessScopedInclude creates the right where clause
324
324
  const { buildAccessScopedInclude } = await import('../src/access/index.js')
325
325
 
326
- const include = await buildAccessScopedInclude(
326
+ const { include } = await buildAccessScopedInclude(
327
327
  { posts: true },
328
328
  config.lists.User.fields,
329
329
  {
@@ -492,7 +492,7 @@ describe('Relationship Access Control', () => {
492
492
  // Test that buildAccessScopedInclude creates session-based where clause
493
493
  const { buildAccessScopedInclude } = await import('../src/access/index.js')
494
494
 
495
- const include = await buildAccessScopedInclude(
495
+ const { include } = await buildAccessScopedInclude(
496
496
  { posts: true },
497
497
  config.lists.User.fields,
498
498
  {
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
2
  import {
3
3
  checkAccess,
4
+ checkCreateAccess,
4
5
  mergeFilters,
5
6
  checkFieldAccess,
6
7
  filterReadableFields,
@@ -8,6 +9,7 @@ import {
8
9
  isBoolean,
9
10
  isPrismaFilter,
10
11
  } from '../src/access/index.js'
12
+ import { InvalidCreateAccessResultError } from '../src/access/errors.js'
11
13
  import type { AccessControl, FieldAccess, AccessContext } from '../src/access/types.js'
12
14
  import { ValidationError } from '../src/hooks/index.js'
13
15
 
@@ -112,6 +114,87 @@ describe('Access Control', () => {
112
114
  })
113
115
  })
114
116
 
117
+ /**
118
+ * #1009: `checkAccess`'s `AccessControl` return type also admits a
119
+ * `PrismaFilter` — the shape `query`/`update`/`delete` legitimately use to
120
+ * scope which rows an operation may touch. `create` has no row to re-check
121
+ * a filter against, so `checkCreateAccess` (the evaluator both create paths
122
+ * share) must reject anything that isn't a strict boolean instead of
123
+ * silently treating it as an allow.
124
+ */
125
+ describe('checkCreateAccess', () => {
126
+ it('denies (returns false) when no access control is defined', async () => {
127
+ const result = await checkCreateAccess('Post', undefined, {
128
+ session: null,
129
+ context: mockContext,
130
+ })
131
+
132
+ expect(result).toBe(false)
133
+ })
134
+
135
+ it('returns true when access control allows', async () => {
136
+ const accessControl: AccessControl = vi.fn(async () => true)
137
+
138
+ const result = await checkCreateAccess('Post', accessControl, {
139
+ session: null,
140
+ context: mockContext,
141
+ })
142
+
143
+ expect(result).toBe(true)
144
+ })
145
+
146
+ it('returns false when access control denies', async () => {
147
+ const accessControl: AccessControl = vi.fn(async () => false)
148
+
149
+ const result = await checkCreateAccess('Post', accessControl, {
150
+ session: null,
151
+ context: mockContext,
152
+ })
153
+
154
+ expect(result).toBe(false)
155
+ })
156
+
157
+ it('throws InvalidCreateAccessResultError, naming the list, when access control returns a filter', async () => {
158
+ const accessControl: AccessControl = vi.fn(async () => ({ ownerId: { equals: 'u1' } }))
159
+
160
+ await expect(
161
+ checkCreateAccess('Post', accessControl, {
162
+ session: { userId: 'u1' },
163
+ context: mockContext,
164
+ }),
165
+ ).rejects.toThrow(InvalidCreateAccessResultError)
166
+
167
+ try {
168
+ await checkCreateAccess('Post', accessControl, {
169
+ session: { userId: 'u1' },
170
+ context: mockContext,
171
+ })
172
+ expect.unreachable()
173
+ } catch (err) {
174
+ expect(err).toBeInstanceOf(InvalidCreateAccessResultError)
175
+ expect((err as InvalidCreateAccessResultError).listKey).toBe('Post')
176
+ expect((err as Error).message).toContain('Post')
177
+ expect((err as Error).message).toContain('boolean')
178
+ }
179
+ })
180
+
181
+ it('throws InvalidCreateAccessResultError when access control returns undefined', async () => {
182
+ const accessControl = vi.fn(async () => undefined) as unknown as AccessControl
183
+
184
+ await expect(
185
+ checkCreateAccess('Post', accessControl, { session: null, context: mockContext }),
186
+ ).rejects.toThrow(InvalidCreateAccessResultError)
187
+ })
188
+
189
+ it('throws InvalidCreateAccessResultError when access control returns a string', async () => {
190
+ const accessControl = vi.fn(async () => 'nope') as unknown as AccessControl
191
+
192
+ await expect(
193
+ checkCreateAccess('Post', accessControl, { session: null, context: mockContext }),
194
+ ).rejects.toThrow(InvalidCreateAccessResultError)
195
+ })
196
+ })
197
+
115
198
  describe('mergeFilters', () => {
116
199
  it('should return null when access is denied', () => {
117
200
  const result = mergeFilters({ id: '123' }, false)