@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -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 +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -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 +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +98 -32
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +74 -15
- 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 +31 -34
- 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/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- 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 +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- 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 +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -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 +389 -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
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { Session, AccessContext, PrismaFilter } from './types.js'
|
|
1
|
+
import type { Session, AccessContext, PrismaFilter, FieldAccess } from './types.js'
|
|
2
2
|
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
3
3
|
import type { FilterOperator, RelationshipCountFilterMarker } from '../filter/types.js'
|
|
4
4
|
import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js'
|
|
5
5
|
import { checkAccess, getRelatedListConfig } from './engine.js'
|
|
6
|
+
import { isFieldReadableForPredicate } from './field-access.js'
|
|
6
7
|
import { getDbKey } from '../lib/case-utils.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -14,8 +15,10 @@ import { getDbKey } from '../lib/case-utils.js'
|
|
|
14
15
|
* `_count` (`_count: { select: { orders: { where: <access filter> } } }`), so no
|
|
15
16
|
* per-row query is issued and the count can never include rows the session
|
|
16
17
|
* cannot read. This module is the single place the related list's
|
|
17
|
-
* operation-level `query` access
|
|
18
|
-
* `
|
|
18
|
+
* operation-level `query` access, AND the counting list's own field-level
|
|
19
|
+
* `read` access on the relationship field being counted (issue #1111), are
|
|
20
|
+
* folded into that `_count`, mirroring how `buildAccessScopedInclude` folds
|
|
21
|
+
* both into relation includes.
|
|
19
22
|
*
|
|
20
23
|
* It also resolves the count Filter spec's markers: Prisma cannot compare a
|
|
21
24
|
* relation count in a `where`, so a to-many relationship's Filter spec emits a
|
|
@@ -46,11 +49,56 @@ export function isToManyRelationshipField(field: FieldConfig | undefined): boole
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/** The per-relation entry the count `_count.select` uses for one relationship. */
|
|
49
|
-
type CountAccessEntry =
|
|
52
|
+
export type CountAccessEntry =
|
|
50
53
|
| { kind: 'all' } // related list fully readable → count every related row
|
|
51
54
|
| { kind: 'scoped'; where: PrismaFilter } // count only rows matching the access filter
|
|
52
55
|
| { kind: 'denied' } // related list not readable at all → count is always 0
|
|
53
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Resolve one related list's operation-level `query` access, and — when the
|
|
59
|
+
* counting list has a field of its own for the relationship being counted —
|
|
60
|
+
* that field's own field-level `read` access, into the entry its `_count`
|
|
61
|
+
* select needs. The shared decision both `relationshipCountAccessEntry` below
|
|
62
|
+
* (field → related list, for this module's own admin-list-view and
|
|
63
|
+
* count-filter callers) and `access-filter.ts`'s caller-`_count` scoping
|
|
64
|
+
* (issue #1087, which already has the related list resolved — including a
|
|
65
|
+
* synthetic back-relation's, which has no field of its own on the counting
|
|
66
|
+
* list) build on.
|
|
67
|
+
*
|
|
68
|
+
* `fieldAccess` is the COUNTING list's own relationship field access — not
|
|
69
|
+
* the related list's — mirroring the check `filterReadableFields`
|
|
70
|
+
* (`field-visibility.ts`) runs before returning an ordinary `include` of that
|
|
71
|
+
* same field (issue #1111: a `_count` used to leak the true count of a
|
|
72
|
+
* relationship whose field-level `read` access denied the relationship
|
|
73
|
+
* itself, even when the related list's own rows were otherwise fully
|
|
74
|
+
* readable). Evaluated via `isFieldReadableForPredicate` — the pre-query
|
|
75
|
+
* evaluator #915 already built for the identical problem on `where`/`orderBy`
|
|
76
|
+
* — since there is no fetched row yet at `_count`-select-build time; a rule
|
|
77
|
+
* that depends on one denies here, the same as it would there. Checked BEFORE
|
|
78
|
+
* the related list's `query` access so a field-level denial short-circuits
|
|
79
|
+
* without needing the related list's access rule at all. Omitted entirely
|
|
80
|
+
* (`undefined`) for a synthetic back-relation, which has no field of its own
|
|
81
|
+
* on the counting list to evaluate.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveCountAccessEntryForList(
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
85
|
+
relatedListConfig: ListConfig<any>,
|
|
86
|
+
args: CountArgs,
|
|
87
|
+
fieldAccess?: FieldAccess,
|
|
88
|
+
): Promise<CountAccessEntry> {
|
|
89
|
+
if (fieldAccess) {
|
|
90
|
+
const canReadField = await isFieldReadableForPredicate(fieldAccess, args)
|
|
91
|
+
if (!canReadField) return { kind: 'denied' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const queryAccess = relatedListConfig.access?.operation?.query
|
|
95
|
+
const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
|
|
96
|
+
|
|
97
|
+
if (result === false) return { kind: 'denied' }
|
|
98
|
+
if (typeof result === 'object') return { kind: 'scoped', where: result }
|
|
99
|
+
return { kind: 'all' }
|
|
100
|
+
}
|
|
101
|
+
|
|
54
102
|
/**
|
|
55
103
|
* Resolve the related list's operation-level `query` access for one to-many
|
|
56
104
|
* relationship into the entry its `_count` select needs. Returns `null` when the
|
|
@@ -69,12 +117,7 @@ async function relationshipCountAccessEntry(
|
|
|
69
117
|
const related = getRelatedListConfig(ref, config)
|
|
70
118
|
if (!related) return null
|
|
71
119
|
|
|
72
|
-
|
|
73
|
-
const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
|
|
74
|
-
|
|
75
|
-
if (result === false) return { kind: 'denied' }
|
|
76
|
-
if (typeof result === 'object') return { kind: 'scoped', where: result }
|
|
77
|
-
return { kind: 'all' }
|
|
120
|
+
return resolveCountAccessEntryForList(related.listConfig, args, field.access)
|
|
78
121
|
}
|
|
79
122
|
|
|
80
123
|
/**
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterReadableFields } from './field-visibility.js'
|
|
3
|
+
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
4
|
+
import type { AccessContext } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression coverage for issue #1082, Field Visibility half.
|
|
8
|
+
*
|
|
9
|
+
* `access-filter.test.ts` covers the pre-query scoping of a synthetic
|
|
10
|
+
* back-relation (`buildAccessScopedInclude`); these tests cover what happens
|
|
11
|
+
* to the rows it fetches. Before this fix, `filterReadableFields` recognised
|
|
12
|
+
* only a DECLARED relationship field (`fieldConfig?.type === 'relationship'`)
|
|
13
|
+
* as something to recurse into — a synthetic key has no declared field on the
|
|
14
|
+
* list it's fetched through, so it fell to the generic non-relationship
|
|
15
|
+
* branch and was copied into the result verbatim: no field-level `read` gate
|
|
16
|
+
* on the related rows, and no virtual field computed on them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// A relationship field pointing at another list.
|
|
20
|
+
function rel(ref: string, many = false): FieldConfig {
|
|
21
|
+
return { type: 'relationship', ref, many } as unknown as FieldConfig
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A virtual field computed via resolveOutput.
|
|
25
|
+
function virtualField(
|
|
26
|
+
resolveOutput: (args: { item: Record<string, unknown> }) => unknown,
|
|
27
|
+
): FieldConfig {
|
|
28
|
+
return {
|
|
29
|
+
type: 'virtual',
|
|
30
|
+
virtual: true,
|
|
31
|
+
hooks: { resolveOutput },
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
|
|
33
|
+
} as any as FieldConfig
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeContext(): AccessContext {
|
|
37
|
+
return {
|
|
38
|
+
session: null,
|
|
39
|
+
_isSudo: false,
|
|
40
|
+
_resolveOutputChain: [],
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
42
|
+
} as any
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Term ← Bill.term (list-only ref, no field on Term) — schema generation
|
|
46
|
+
// synthesizes `from_Bill_term` on Term, which no list config declares.
|
|
47
|
+
function syntheticConfig(): OpenSaasConfig {
|
|
48
|
+
return {
|
|
49
|
+
db: { provider: 'sqlite' },
|
|
50
|
+
lists: {
|
|
51
|
+
Term: {
|
|
52
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
53
|
+
access: { operation: { query: () => true } },
|
|
54
|
+
},
|
|
55
|
+
Bill: {
|
|
56
|
+
fields: {
|
|
57
|
+
amount: { type: 'integer' } as FieldConfig,
|
|
58
|
+
term: rel('Term'),
|
|
59
|
+
internalNotes: {
|
|
60
|
+
type: 'text',
|
|
61
|
+
access: { read: () => false },
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
|
|
63
|
+
} as any as FieldConfig,
|
|
64
|
+
label: virtualField(({ item }) => `Bill #${item.amount}`),
|
|
65
|
+
},
|
|
66
|
+
access: { operation: { query: () => true } },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
70
|
+
} as any as OpenSaasConfig
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('filterReadableFields — synthetic back-relation (#1082)', () => {
|
|
74
|
+
it('applies the owning list field-level read access to rows fetched through a synthetic key', async () => {
|
|
75
|
+
const config = syntheticConfig()
|
|
76
|
+
const termRow = {
|
|
77
|
+
id: 't1',
|
|
78
|
+
name: 'Term 1',
|
|
79
|
+
from_Bill_term: [{ id: 'b1', amount: 5, internalNotes: 'secret' }],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = await filterReadableFields(
|
|
83
|
+
termRow,
|
|
84
|
+
config.lists.Term.fields,
|
|
85
|
+
{ session: null, context: makeContext() },
|
|
86
|
+
config,
|
|
87
|
+
0,
|
|
88
|
+
'Term',
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const bills = result.from_Bill_term as Record<string, unknown>[]
|
|
92
|
+
expect(bills).toHaveLength(1)
|
|
93
|
+
expect(bills[0].amount).toBe(5)
|
|
94
|
+
expect('internalNotes' in bills[0]).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('computes a virtual field on rows fetched through a synthetic key', async () => {
|
|
98
|
+
const config = syntheticConfig()
|
|
99
|
+
const termRow = {
|
|
100
|
+
id: 't1',
|
|
101
|
+
name: 'Term 1',
|
|
102
|
+
from_Bill_term: [{ id: 'b1', amount: 5 }],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await filterReadableFields(
|
|
106
|
+
termRow,
|
|
107
|
+
config.lists.Term.fields,
|
|
108
|
+
{ session: null, context: makeContext() },
|
|
109
|
+
config,
|
|
110
|
+
0,
|
|
111
|
+
'Term',
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const bills = result.from_Bill_term as Record<string, unknown>[]
|
|
115
|
+
expect(bills[0].label).toBe('Bill #5')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('recurses through a to-one value fetched through a synthetic key the same as a to-many', async () => {
|
|
119
|
+
const config = syntheticConfig()
|
|
120
|
+
const termRow = {
|
|
121
|
+
id: 't1',
|
|
122
|
+
name: 'Term 1',
|
|
123
|
+
from_Bill_term: { id: 'b1', amount: 5, internalNotes: 'secret' },
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = await filterReadableFields(
|
|
127
|
+
termRow,
|
|
128
|
+
config.lists.Term.fields,
|
|
129
|
+
{ session: null, context: makeContext() },
|
|
130
|
+
config,
|
|
131
|
+
0,
|
|
132
|
+
'Term',
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const bill = result.from_Bill_term as Record<string, unknown>
|
|
136
|
+
expect(bill.amount).toBe(5)
|
|
137
|
+
expect(bill.label).toBe('Bill #5')
|
|
138
|
+
expect('internalNotes' in bill).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('leaves a declared, non-relationship field on the row untouched by the synthetic path', async () => {
|
|
142
|
+
// `name` is a declared scalar field on Term itself — the synthetic
|
|
143
|
+
// fallback only ever applies to a key ABSENT from `fieldConfigs`, so it
|
|
144
|
+
// must never interfere with the list's own ordinary fields.
|
|
145
|
+
const config = syntheticConfig()
|
|
146
|
+
const termRow = { id: 't1', name: 'Term 1' }
|
|
147
|
+
|
|
148
|
+
const result = await filterReadableFields(
|
|
149
|
+
termRow,
|
|
150
|
+
config.lists.Term.fields,
|
|
151
|
+
{ session: null, context: makeContext() },
|
|
152
|
+
config,
|
|
153
|
+
0,
|
|
154
|
+
'Term',
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(result).toEqual({ id: 't1', name: 'Term 1' })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('leaves an undeclared, non-synthetic key untouched (no config supplied)', async () => {
|
|
161
|
+
// Mirrors the pre-#1082 narrow-unit-test contract: without `config`
|
|
162
|
+
// there is nothing to resolve a synthetic key against, so the field
|
|
163
|
+
// passes through as a plain value — the same fallback
|
|
164
|
+
// `filterWritableFields` documents for its own `config`-less callers.
|
|
165
|
+
const config = syntheticConfig()
|
|
166
|
+
const termRow = { id: 't1', name: 'Term 1', from_Bill_term: [{ id: 'b1', amount: 5 }] }
|
|
167
|
+
|
|
168
|
+
const result = await filterReadableFields(
|
|
169
|
+
termRow,
|
|
170
|
+
config.lists.Term.fields,
|
|
171
|
+
{ session: null, context: makeContext() },
|
|
172
|
+
undefined,
|
|
173
|
+
0,
|
|
174
|
+
'Term',
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
expect(result.from_Bill_term).toEqual([{ id: 'b1', amount: 5 }])
|
|
178
|
+
})
|
|
179
|
+
})
|
package/src/config/types.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type FieldResolveInputHookArgs<
|
|
|
21
21
|
inputData: TTypeInfo['inputs']['create']
|
|
22
22
|
item: undefined
|
|
23
23
|
resolvedData: TTypeInfo['inputs']['create']
|
|
24
|
-
context: import('../
|
|
24
|
+
context: import('../context/index.js').StackContext
|
|
25
25
|
}
|
|
26
26
|
| {
|
|
27
27
|
listKey: string
|
|
@@ -30,7 +30,7 @@ export type FieldResolveInputHookArgs<
|
|
|
30
30
|
inputData: TTypeInfo['inputs']['update']
|
|
31
31
|
item: TTypeInfo['item']
|
|
32
32
|
resolvedData: TTypeInfo['inputs']['update']
|
|
33
|
-
context: import('../
|
|
33
|
+
context: import('../context/index.js').StackContext
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** Arguments for {@link FieldHooks.validate} (and its deprecated `validateInput` alias). */
|
|
@@ -45,7 +45,7 @@ export type FieldValidateHookArgs<
|
|
|
45
45
|
inputData: TTypeInfo['inputs']['create']
|
|
46
46
|
item: undefined
|
|
47
47
|
resolvedData: TTypeInfo['inputs']['create']
|
|
48
|
-
context: import('../
|
|
48
|
+
context: import('../context/index.js').StackContext
|
|
49
49
|
addValidationError: (msg: string) => void
|
|
50
50
|
}
|
|
51
51
|
| {
|
|
@@ -55,7 +55,7 @@ export type FieldValidateHookArgs<
|
|
|
55
55
|
inputData: TTypeInfo['inputs']['update']
|
|
56
56
|
item: TTypeInfo['item']
|
|
57
57
|
resolvedData: TTypeInfo['inputs']['update']
|
|
58
|
-
context: import('../
|
|
58
|
+
context: import('../context/index.js').StackContext
|
|
59
59
|
addValidationError: (msg: string) => void
|
|
60
60
|
}
|
|
61
61
|
| {
|
|
@@ -63,7 +63,7 @@ export type FieldValidateHookArgs<
|
|
|
63
63
|
fieldKey: TFieldKey
|
|
64
64
|
operation: 'delete'
|
|
65
65
|
item: TTypeInfo['item']
|
|
66
|
-
context: import('../
|
|
66
|
+
context: import('../context/index.js').StackContext
|
|
67
67
|
addValidationError: (msg: string) => void
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -78,7 +78,7 @@ export type FieldBeforeOperationHookArgs<
|
|
|
78
78
|
operation: 'create'
|
|
79
79
|
inputData: TTypeInfo['inputs']['create']
|
|
80
80
|
resolvedData: TTypeInfo['inputs']['create']
|
|
81
|
-
context: import('../
|
|
81
|
+
context: import('../context/index.js').StackContext
|
|
82
82
|
}
|
|
83
83
|
| {
|
|
84
84
|
listKey: string
|
|
@@ -87,14 +87,14 @@ export type FieldBeforeOperationHookArgs<
|
|
|
87
87
|
inputData: TTypeInfo['inputs']['update']
|
|
88
88
|
item: TTypeInfo['item']
|
|
89
89
|
resolvedData: TTypeInfo['inputs']['update']
|
|
90
|
-
context: import('../
|
|
90
|
+
context: import('../context/index.js').StackContext
|
|
91
91
|
}
|
|
92
92
|
| {
|
|
93
93
|
listKey: string
|
|
94
94
|
fieldKey: TFieldKey
|
|
95
95
|
operation: 'delete'
|
|
96
96
|
item: TTypeInfo['item']
|
|
97
|
-
context: import('../
|
|
97
|
+
context: import('../context/index.js').StackContext
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/** Arguments for {@link FieldHooks.afterOperation}. */
|
|
@@ -109,7 +109,7 @@ export type FieldAfterOperationHookArgs<
|
|
|
109
109
|
inputData: TTypeInfo['inputs']['create']
|
|
110
110
|
item: TTypeInfo['item']
|
|
111
111
|
resolvedData: TTypeInfo['inputs']['create']
|
|
112
|
-
context: import('../
|
|
112
|
+
context: import('../context/index.js').StackContext
|
|
113
113
|
}
|
|
114
114
|
| {
|
|
115
115
|
listKey: string
|
|
@@ -119,14 +119,14 @@ export type FieldAfterOperationHookArgs<
|
|
|
119
119
|
originalItem: TTypeInfo['item']
|
|
120
120
|
item: TTypeInfo['item']
|
|
121
121
|
resolvedData: TTypeInfo['inputs']['update']
|
|
122
|
-
context: import('../
|
|
122
|
+
context: import('../context/index.js').StackContext
|
|
123
123
|
}
|
|
124
124
|
| {
|
|
125
125
|
listKey: string
|
|
126
126
|
fieldKey: TFieldKey
|
|
127
127
|
operation: 'delete'
|
|
128
128
|
originalItem: TTypeInfo['item']
|
|
129
|
-
context: import('../
|
|
129
|
+
context: import('../context/index.js').StackContext
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
@@ -591,6 +591,32 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
|
|
|
591
591
|
* ```
|
|
592
592
|
*/
|
|
593
593
|
description?: string
|
|
594
|
+
/**
|
|
595
|
+
* Whether this field belongs in a list/related-list table's DEFAULT
|
|
596
|
+
* column set (issue #1018) — the columns shown when nothing explicitly
|
|
597
|
+
* names them (`ui.listView.initialColumns` on the list, or a
|
|
598
|
+
* relationship's own `ui.itemView.columns`). Naming the field explicitly
|
|
599
|
+
* in either of those always shows it regardless of this flag; it governs
|
|
600
|
+
* only what appears absent an explicit column list.
|
|
601
|
+
*
|
|
602
|
+
* This is a PRESENTATION default, not an access control — a field can be
|
|
603
|
+
* read-denied and still default to `true` here (it simply renders empty
|
|
604
|
+
* for a viewer who can't read it), and setting this to `false` hides a
|
|
605
|
+
* column without restricting who can read the underlying value. The
|
|
606
|
+
* field-level `access.read` deny remains the only real boundary.
|
|
607
|
+
*
|
|
608
|
+
* @default true
|
|
609
|
+
*
|
|
610
|
+
* @example Hide an internal field from default table views without denying read access
|
|
611
|
+
* ```typescript
|
|
612
|
+
* fields: {
|
|
613
|
+
* internalScore: integer({ ui: { listView: { defaultColumn: false } } }),
|
|
614
|
+
* }
|
|
615
|
+
* ```
|
|
616
|
+
*/
|
|
617
|
+
listView?: {
|
|
618
|
+
defaultColumn?: boolean
|
|
619
|
+
}
|
|
594
620
|
/**
|
|
595
621
|
* Transform field value before sending to client (browser)
|
|
596
622
|
* Useful for sensitive fields (e.g., passwords) or complex data structures
|
|
@@ -844,6 +870,7 @@ export type IntegerField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfi
|
|
|
844
870
|
min?: number
|
|
845
871
|
max?: number
|
|
846
872
|
}
|
|
873
|
+
isIndexed?: boolean | 'unique'
|
|
847
874
|
}
|
|
848
875
|
|
|
849
876
|
export type DecimalField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
|
|
@@ -885,6 +912,7 @@ export type CheckboxField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConf
|
|
|
885
912
|
export type TimestampField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
|
|
886
913
|
type: 'timestamp'
|
|
887
914
|
defaultValue?: { kind: 'now' } | Date
|
|
915
|
+
isIndexed?: boolean | 'unique'
|
|
888
916
|
}
|
|
889
917
|
|
|
890
918
|
export type CalendarDayField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
|
|
@@ -989,6 +1017,7 @@ export type SelectField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig
|
|
|
989
1017
|
validation?: {
|
|
990
1018
|
isRequired?: boolean
|
|
991
1019
|
}
|
|
1020
|
+
isIndexed?: boolean | 'unique'
|
|
992
1021
|
ui?: {
|
|
993
1022
|
displayMode?: 'select' | 'segmented-control' | 'radio'
|
|
994
1023
|
}
|
|
@@ -1015,8 +1044,10 @@ export type RelationshipItemViewConfig = {
|
|
|
1015
1044
|
* The related list's fields to show as Relationship-table columns, in order.
|
|
1016
1045
|
*
|
|
1017
1046
|
* When omitted, the columns default to the related list's own column
|
|
1018
|
-
* curation (`ui.listView.initialColumns`, else
|
|
1019
|
-
*
|
|
1047
|
+
* curation (`ui.listView.initialColumns`, else every field whose own
|
|
1048
|
+
* `ui.listView.defaultColumn` declaration holds — see {@link
|
|
1049
|
+
* BaseFieldConfig.ui}) minus the back-reference field that points at the
|
|
1050
|
+
* parent record.
|
|
1020
1051
|
*
|
|
1021
1052
|
* @example
|
|
1022
1053
|
* ```typescript
|
|
@@ -1133,13 +1164,20 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
|
|
|
1133
1164
|
*/
|
|
1134
1165
|
isNullable?: boolean
|
|
1135
1166
|
/**
|
|
1136
|
-
* Controls foreign key placement and column name
|
|
1137
|
-
* Can be a boolean or an object with a map property
|
|
1138
|
-
* Only valid on single (non-many) relationships
|
|
1139
|
-
* Cannot be true on both sides of a one-to-one relationship
|
|
1167
|
+
* Controls foreign key placement and column name.
|
|
1168
|
+
* Can be a boolean or an object with a map property.
|
|
1169
|
+
* Only valid on single (non-many) relationships.
|
|
1170
|
+
* Cannot be true on both sides of a one-to-one relationship.
|
|
1140
1171
|
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1172
|
+
* The boolean form (the "which side owns the foreign key" sense) is only
|
|
1173
|
+
* meaningful on a bidirectional ref (`ref: 'ListName.fieldName'`) — a
|
|
1174
|
+
* list-only ref (`ref: 'ListName'`) always owns the foreign key, so a
|
|
1175
|
+
* boolean here is rejected. The `{ map }` form (the column-name sense)
|
|
1176
|
+
* works on both: it renames the foreign key column without changing
|
|
1177
|
+
* ownership.
|
|
1178
|
+
*
|
|
1179
|
+
* When a boolean, defaults the foreign key column name to the field name.
|
|
1180
|
+
* When an object with map, uses the provided column name.
|
|
1143
1181
|
*
|
|
1144
1182
|
* @example
|
|
1145
1183
|
* ```typescript
|
|
@@ -1164,6 +1202,14 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
|
|
|
1164
1202
|
* user: relationship({ ref: 'User.account' }) // No foreign key on this side
|
|
1165
1203
|
* }
|
|
1166
1204
|
* })
|
|
1205
|
+
*
|
|
1206
|
+
* // List-only ref: rename the foreign key column (ownership is implicit)
|
|
1207
|
+
* Post: list({
|
|
1208
|
+
* fields: {
|
|
1209
|
+
* category: relationship({ ref: 'Category', db: { foreignKey: { map: 'category_id' } } })
|
|
1210
|
+
* // Generates: categoryId String? @map("category_id")
|
|
1211
|
+
* }
|
|
1212
|
+
* })
|
|
1167
1213
|
* ```
|
|
1168
1214
|
*/
|
|
1169
1215
|
foreignKey?: boolean | { map?: string }
|
|
@@ -1488,6 +1534,18 @@ export interface TypeInfo<
|
|
|
1488
1534
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1489
1535
|
export type OperationAccess<T = any> = {
|
|
1490
1536
|
query?: AccessControl<T>
|
|
1537
|
+
/**
|
|
1538
|
+
* Shares `AccessControl`'s signature (so a filter still type-checks here),
|
|
1539
|
+
* but at runtime `create` accepts a `boolean` result only. There is no
|
|
1540
|
+
* existing row to scope with a filter, and — unlike `update`/`delete`,
|
|
1541
|
+
* which re-check a returned filter against the target row via
|
|
1542
|
+
* `findFirst` — no equivalent re-check exists for a row that doesn't exist
|
|
1543
|
+
* in the database yet. A rule that returns a filter (or any other
|
|
1544
|
+
* non-boolean) throws `InvalidCreateAccessResultError` rather than being
|
|
1545
|
+
* treated as an allow (see #1009, ADR-0022, ADR-0030). To scope create by
|
|
1546
|
+
* ownership, evaluate the condition in a `resolveInput`/`validate` hook,
|
|
1547
|
+
* where the input data is in scope.
|
|
1548
|
+
*/
|
|
1491
1549
|
create?: AccessControl<T>
|
|
1492
1550
|
update?: AccessControl<T>
|
|
1493
1551
|
delete?: AccessControl<T>
|
|
@@ -1551,7 +1609,7 @@ export type ResolveInputHookArgs<
|
|
|
1551
1609
|
inputData: TCreateInput
|
|
1552
1610
|
resolvedData: TCreateInput
|
|
1553
1611
|
item: undefined
|
|
1554
|
-
context: import('../
|
|
1612
|
+
context: import('../context/index.js').StackContext
|
|
1555
1613
|
}
|
|
1556
1614
|
| {
|
|
1557
1615
|
listKey: string
|
|
@@ -1559,7 +1617,7 @@ export type ResolveInputHookArgs<
|
|
|
1559
1617
|
inputData: TUpdateInput
|
|
1560
1618
|
resolvedData: TUpdateInput
|
|
1561
1619
|
item: TOutput
|
|
1562
|
-
context: import('../
|
|
1620
|
+
context: import('../context/index.js').StackContext
|
|
1563
1621
|
}
|
|
1564
1622
|
|
|
1565
1623
|
/**
|
|
@@ -1579,7 +1637,7 @@ export type ValidateHookArgs<
|
|
|
1579
1637
|
inputData: TCreateInput
|
|
1580
1638
|
resolvedData: TCreateInput
|
|
1581
1639
|
item: undefined
|
|
1582
|
-
context: import('../
|
|
1640
|
+
context: import('../context/index.js').StackContext
|
|
1583
1641
|
addValidationError: (msg: string) => void
|
|
1584
1642
|
}
|
|
1585
1643
|
| {
|
|
@@ -1588,14 +1646,14 @@ export type ValidateHookArgs<
|
|
|
1588
1646
|
inputData: TUpdateInput
|
|
1589
1647
|
resolvedData: TUpdateInput
|
|
1590
1648
|
item: TOutput
|
|
1591
|
-
context: import('../
|
|
1649
|
+
context: import('../context/index.js').StackContext
|
|
1592
1650
|
addValidationError: (msg: string) => void
|
|
1593
1651
|
}
|
|
1594
1652
|
| {
|
|
1595
1653
|
listKey: string
|
|
1596
1654
|
operation: 'delete'
|
|
1597
1655
|
item: TOutput
|
|
1598
|
-
context: import('../
|
|
1656
|
+
context: import('../context/index.js').StackContext
|
|
1599
1657
|
addValidationError: (msg: string) => void
|
|
1600
1658
|
}
|
|
1601
1659
|
|
|
@@ -1615,7 +1673,7 @@ export type BeforeOperationHookArgs<
|
|
|
1615
1673
|
operation: 'create'
|
|
1616
1674
|
inputData: TCreateInput
|
|
1617
1675
|
resolvedData: TCreateInput
|
|
1618
|
-
context: import('../
|
|
1676
|
+
context: import('../context/index.js').StackContext
|
|
1619
1677
|
}
|
|
1620
1678
|
| {
|
|
1621
1679
|
listKey: string
|
|
@@ -1623,13 +1681,13 @@ export type BeforeOperationHookArgs<
|
|
|
1623
1681
|
inputData: TUpdateInput
|
|
1624
1682
|
item: TOutput
|
|
1625
1683
|
resolvedData: TUpdateInput
|
|
1626
|
-
context: import('../
|
|
1684
|
+
context: import('../context/index.js').StackContext
|
|
1627
1685
|
}
|
|
1628
1686
|
| {
|
|
1629
1687
|
listKey: string
|
|
1630
1688
|
operation: 'delete'
|
|
1631
1689
|
item: TOutput
|
|
1632
|
-
context: import('../
|
|
1690
|
+
context: import('../context/index.js').StackContext
|
|
1633
1691
|
}
|
|
1634
1692
|
|
|
1635
1693
|
/**
|
|
@@ -1649,7 +1707,7 @@ export type AfterOperationHookArgs<
|
|
|
1649
1707
|
inputData: TCreateInput
|
|
1650
1708
|
item: TOutput
|
|
1651
1709
|
resolvedData: TCreateInput
|
|
1652
|
-
context: import('../
|
|
1710
|
+
context: import('../context/index.js').StackContext
|
|
1653
1711
|
}
|
|
1654
1712
|
| {
|
|
1655
1713
|
listKey: string
|
|
@@ -1658,13 +1716,13 @@ export type AfterOperationHookArgs<
|
|
|
1658
1716
|
originalItem: TOutput
|
|
1659
1717
|
item: TOutput
|
|
1660
1718
|
resolvedData: TUpdateInput
|
|
1661
|
-
context: import('../
|
|
1719
|
+
context: import('../context/index.js').StackContext
|
|
1662
1720
|
}
|
|
1663
1721
|
| {
|
|
1664
1722
|
listKey: string
|
|
1665
1723
|
operation: 'delete'
|
|
1666
1724
|
originalItem: TOutput
|
|
1667
|
-
context: import('../
|
|
1725
|
+
context: import('../context/index.js').StackContext
|
|
1668
1726
|
}
|
|
1669
1727
|
|
|
1670
1728
|
/**
|
|
@@ -1975,6 +2033,12 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
|
|
|
1975
2033
|
* the bad field too — no entry is ever silently dropped or emitted as
|
|
1976
2034
|
* invalid Prisma.
|
|
1977
2035
|
*
|
|
2036
|
+
* `createdAt`/`updatedAt` are a valid entry even when the list has no
|
|
2037
|
+
* matching declared field — an entry may name either as long as the
|
|
2038
|
+
* list's auto-timestamps (`db.timestamps`, global or per-list) are
|
|
2039
|
+
* enabled for that column, since the auto-injected column has no `@map`
|
|
2040
|
+
* of its own and its Prisma field name is exactly `createdAt`/`updatedAt`.
|
|
2041
|
+
*
|
|
1978
2042
|
* @example One audition per student per production (composite unique)
|
|
1979
2043
|
* ```typescript
|
|
1980
2044
|
* Audition: list({
|
|
@@ -2241,14 +2305,16 @@ export interface BulkAction {
|
|
|
2241
2305
|
* `ui.listView`.
|
|
2242
2306
|
*
|
|
2243
2307
|
* When omitted, the admin UI falls back to its existing defaults: every
|
|
2244
|
-
*
|
|
2308
|
+
* field whose own `ui.listView.defaultColumn` declaration holds is shown as
|
|
2309
|
+
* a column (see {@link BaseFieldConfig.ui}) and no default sort is applied.
|
|
2245
2310
|
*/
|
|
2246
2311
|
export type ListViewUIConfig = {
|
|
2247
2312
|
/**
|
|
2248
2313
|
* The fields to show as columns in the list table, in order.
|
|
2249
2314
|
*
|
|
2250
2315
|
* Drives both the column **selection** and their **order**. When omitted,
|
|
2251
|
-
*
|
|
2316
|
+
* every field whose own `ui.listView.defaultColumn` declaration holds is
|
|
2317
|
+
* shown (current default behaviour).
|
|
2252
2318
|
*
|
|
2253
2319
|
* @example
|
|
2254
2320
|
* ```typescript
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ListConfig } from '../config/types.js'
|
|
2
|
-
import type {
|
|
2
|
+
import type { StackContext } from './index.js'
|
|
3
3
|
import {
|
|
4
4
|
executeResolveInput,
|
|
5
5
|
executeValidate,
|
|
@@ -25,7 +25,9 @@ export interface HookPipelineArgs {
|
|
|
25
25
|
listConfig: ListConfig<any>
|
|
26
26
|
inputData: Record<string, unknown>
|
|
27
27
|
item: Record<string, unknown> | undefined
|
|
28
|
-
|
|
28
|
+
// #1176: the full secured context (sudo/withSession/transaction), bound to
|
|
29
|
+
// the write's transaction client — see `bindContextToTransaction`.
|
|
30
|
+
context: StackContext
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface HookPipelineResult {
|