@opensaas/stack-core 0.39.2 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +150 -0
- package/CLAUDE.md +51 -0
- package/dist/access/access-filter.d.ts +83 -10
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +139 -18
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +224 -12
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/engine.d.ts +43 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +58 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +28 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +38 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +45 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +5 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +4 -2
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +76 -10
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +61 -17
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +54 -13
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +8 -5
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +278 -12
- package/src/access/access-filter.ts +207 -19
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +81 -1
- package/src/access/errors.ts +42 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +75 -10
- package/src/access/index.ts +14 -0
- package/src/config/types.ts +76 -10
- package/src/context/index.ts +95 -15
- package/src/context/nested-operations.ts +61 -16
- package/src/context/write-pipeline.ts +8 -4
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +259 -12
- package/tests/field-types.test.ts +113 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +65 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- 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
|
+
}
|
package/src/utils/password.ts
CHANGED
|
@@ -67,9 +67,11 @@ export class HashedPassword {
|
|
|
67
67
|
return this.hash
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// JSON.stringify calls this when present, so
|
|
71
|
-
|
|
72
|
-
|
|
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
|
{
|
package/tests/access.test.ts
CHANGED
|
@@ -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)
|