@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.
Files changed (130) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +150 -0
  3. package/CLAUDE.md +51 -0
  4. package/dist/access/access-filter.d.ts +83 -10
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +139 -18
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +224 -12
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/engine.d.ts +43 -1
  11. package/dist/access/engine.d.ts.map +1 -1
  12. package/dist/access/engine.js +58 -0
  13. package/dist/access/engine.js.map +1 -1
  14. package/dist/access/engine.test.d.ts +2 -0
  15. package/dist/access/engine.test.d.ts.map +1 -0
  16. package/dist/access/engine.test.js +56 -0
  17. package/dist/access/engine.test.js.map +1 -0
  18. package/dist/access/errors.d.ts +28 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +38 -2
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-access.d.ts +12 -0
  23. package/dist/access/field-access.d.ts.map +1 -1
  24. package/dist/access/field-access.js +22 -2
  25. package/dist/access/field-access.js.map +1 -1
  26. package/dist/access/field-access.test.js +66 -0
  27. package/dist/access/field-access.test.js.map +1 -1
  28. package/dist/access/field-visibility.d.ts +2 -1
  29. package/dist/access/field-visibility.d.ts.map +1 -1
  30. package/dist/access/field-visibility.js +45 -3
  31. package/dist/access/field-visibility.js.map +1 -1
  32. package/dist/access/index.d.ts +5 -2
  33. package/dist/access/index.d.ts.map +1 -1
  34. package/dist/access/index.js +4 -2
  35. package/dist/access/index.js.map +1 -1
  36. package/dist/config/types.d.ts +76 -10
  37. package/dist/config/types.d.ts.map +1 -1
  38. package/dist/context/index.d.ts +19 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +61 -17
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/context/nested-operations.d.ts.map +1 -1
  43. package/dist/context/nested-operations.js +54 -13
  44. package/dist/context/nested-operations.js.map +1 -1
  45. package/dist/context/write-pipeline.d.ts +3 -2
  46. package/dist/context/write-pipeline.d.ts.map +1 -1
  47. package/dist/context/write-pipeline.js +8 -5
  48. package/dist/context/write-pipeline.js.map +1 -1
  49. package/dist/fields/index.d.ts +13 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +50 -5
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  54. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  55. package/dist/fields/relationship-foreign-key.test.js +18 -0
  56. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lib/case-utils.d.ts +15 -0
  62. package/dist/lib/case-utils.d.ts.map +1 -1
  63. package/dist/lib/case-utils.js +21 -0
  64. package/dist/lib/case-utils.js.map +1 -1
  65. package/dist/lib/case-utils.test.js +23 -1
  66. package/dist/lib/case-utils.test.js.map +1 -1
  67. package/dist/lib/prisma-errors.d.ts +38 -0
  68. package/dist/lib/prisma-errors.d.ts.map +1 -0
  69. package/dist/lib/prisma-errors.js +53 -0
  70. package/dist/lib/prisma-errors.js.map +1 -0
  71. package/dist/mcp/constants.d.ts +23 -0
  72. package/dist/mcp/constants.d.ts.map +1 -0
  73. package/dist/mcp/constants.js +23 -0
  74. package/dist/mcp/constants.js.map +1 -0
  75. package/dist/mcp/field-schema.d.ts +8 -0
  76. package/dist/mcp/field-schema.d.ts.map +1 -0
  77. package/dist/mcp/field-schema.js +69 -0
  78. package/dist/mcp/field-schema.js.map +1 -0
  79. package/dist/mcp/handler.d.ts.map +1 -1
  80. package/dist/mcp/handler.js +76 -90
  81. package/dist/mcp/handler.js.map +1 -1
  82. package/dist/mcp/projection.d.ts +62 -0
  83. package/dist/mcp/projection.d.ts.map +1 -0
  84. package/dist/mcp/projection.js +390 -0
  85. package/dist/mcp/projection.js.map +1 -0
  86. package/dist/utils/password.d.ts +3 -1
  87. package/dist/utils/password.d.ts.map +1 -1
  88. package/dist/utils/password.js +4 -2
  89. package/dist/utils/password.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/access/access-filter.test.ts +278 -12
  92. package/src/access/access-filter.ts +207 -19
  93. package/src/access/engine.test.ts +67 -0
  94. package/src/access/engine.ts +81 -1
  95. package/src/access/errors.ts +42 -2
  96. package/src/access/field-access.test.ts +78 -0
  97. package/src/access/field-access.ts +34 -2
  98. package/src/access/field-visibility.ts +75 -10
  99. package/src/access/index.ts +14 -0
  100. package/src/config/types.ts +76 -10
  101. package/src/context/index.ts +95 -15
  102. package/src/context/nested-operations.ts +61 -16
  103. package/src/context/write-pipeline.ts +8 -4
  104. package/src/fields/index.ts +55 -5
  105. package/src/fields/relationship-foreign-key.test.ts +29 -0
  106. package/src/index.ts +17 -1
  107. package/src/lib/case-utils.test.ts +32 -0
  108. package/src/lib/case-utils.ts +26 -0
  109. package/src/lib/prisma-errors.ts +86 -0
  110. package/src/mcp/constants.ts +24 -0
  111. package/src/mcp/field-schema.ts +84 -0
  112. package/src/mcp/handler.ts +109 -107
  113. package/src/mcp/projection.ts +497 -0
  114. package/src/utils/password.ts +5 -3
  115. package/tests/access-relationships.test.ts +3 -3
  116. package/tests/access.test.ts +83 -0
  117. package/tests/context.test.ts +259 -12
  118. package/tests/field-types.test.ts +113 -0
  119. package/tests/mcp-fields-projection-access.test.ts +250 -0
  120. package/tests/mcp-handler.test.ts +695 -4
  121. package/tests/nested-access-and-hooks.test.ts +65 -0
  122. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  123. package/tests/password-types.test.ts +28 -0
  124. package/tests/password.test.ts +16 -2
  125. package/tests/prisma-errors.test.ts +141 -0
  126. package/tests/singleton.test.ts +9 -1
  127. package/tests/to-one-include-access-filter.test.ts +172 -0
  128. package/tests/with-session.test.ts +243 -0
  129. package/tests/write-pipeline.test.ts +29 -0
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -3,6 +3,7 @@ import { getContext } from '../src/context/index.js'
3
3
  import { config, list } from '../src/config/index.js'
4
4
  import { text, relationship } from '../src/fields/index.js'
5
5
  import { checkFieldAccess } from '../src/access/field-access.js'
6
+ import { InvalidCreateAccessResultError } from '../src/access/errors.js'
6
7
 
7
8
  /**
8
9
  * Mock Prisma Client for testing
@@ -215,6 +216,70 @@ describe('Nested Operations - Access Control and Hooks', () => {
215
216
  ).rejects.toThrow('Access denied: Cannot create related item')
216
217
  })
217
218
 
219
+ // #1009: a nested `create` rule returning a filter reads as though it
220
+ // scopes the create, but the nested-create path (like the top-level one)
221
+ // has no existing row to re-check that filter against. Before this fix
222
+ // the check only tested `=== false`, so a filter fell through the nested
223
+ // path exactly like the top-level one and was silently treated as allow.
224
+ it('should throw when nested create access control returns a Prisma filter', async () => {
225
+ const testConfig = config({
226
+ db: {
227
+ provider: 'postgresql',
228
+ url: 'postgresql://localhost:5432/test',
229
+ },
230
+ lists: {
231
+ User: list({
232
+ fields: {
233
+ name: text(),
234
+ email: text(),
235
+ },
236
+ access: {
237
+ operation: {
238
+ query: () => true,
239
+ // Looks like it scopes the nested create by owner. Cannot be
240
+ // honoured here — there is no row yet.
241
+ create: () => ({ ownerId: { equals: 'someone' } }),
242
+ },
243
+ },
244
+ }),
245
+ Post: list({
246
+ fields: {
247
+ title: text(),
248
+ author: relationship({ ref: 'User.posts' }),
249
+ },
250
+ access: {
251
+ operation: {
252
+ query: () => true,
253
+ update: () => true,
254
+ },
255
+ },
256
+ }),
257
+ },
258
+ })
259
+
260
+ mockPrisma.post.findUnique.mockResolvedValue({
261
+ id: '1',
262
+ title: 'Original Title',
263
+ })
264
+
265
+ const context = getContext(await testConfig, mockPrisma, null)
266
+
267
+ await expect(
268
+ context.db.post.update({
269
+ where: { id: '1' },
270
+ data: {
271
+ title: 'Updated Title',
272
+ author: {
273
+ create: {
274
+ name: 'John',
275
+ email: 'john@example.com',
276
+ },
277
+ },
278
+ },
279
+ }),
280
+ ).rejects.toThrow(InvalidCreateAccessResultError)
281
+ })
282
+
218
283
  it('should apply field-level access control to nested create', async () => {
219
284
  const testConfig = config({
220
285
  db: {
@@ -0,0 +1,437 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { getContext } from '../src/context/index.js'
3
+ import { config, list } from '../src/config/index.js'
4
+ import { text, relationship } from '../src/fields/index.js'
5
+ import { ValidationError } from '../src/hooks/index.js'
6
+
7
+ /**
8
+ * #978: a nested write through a list-only ref's SYNTHETIC reverse relation
9
+ * (`from_<SourceList>_<sourceField>`, generated because Prisma requires an
10
+ * opposite field but the config never declares one) must run the target
11
+ * list's full write pipeline — the same resolveInput/validate/field-rules/
12
+ * beforeOperation/afterOperation a nested write through a DECLARED
13
+ * relationship field gets. Before this fix, the field went unrecognised by
14
+ * `processNestedOperations` and was handed to Prisma completely untransformed
15
+ * — no hooks, no validation, silently committing whatever the caller sent.
16
+ *
17
+ * `Account` declares no relationship field back to `ChargeRequest` — only
18
+ * `ChargeRequest.account: relationship({ ref: 'Account' })` (list-only), so
19
+ * the generator synthesizes `from_ChargeRequest_account` on Account. Reachable
20
+ * only under `sudo()`: the undeclared-key guard refuses it otherwise (pinned
21
+ * below too).
22
+ */
23
+
24
+ const SYNTHETIC_FIELD = 'from_ChargeRequest_account'
25
+
26
+ /**
27
+ * A transaction-aware in-memory Prisma mock for Account (one) / ChargeRequest
28
+ * (many), modeling the synthetic back-relation as a real field on the Account
29
+ * model — exactly as Prisma's generated client would, since the field is a
30
+ * genuine schema artefact even though it has no entry in the OpenSaaS config.
31
+ */
32
+ function createAccountPrisma() {
33
+ const tables: Record<string, Map<string, Record<string, unknown>>> = {
34
+ account: new Map(),
35
+ chargeRequest: new Map(),
36
+ }
37
+ const chargeToAccount = new Map<string, string>()
38
+ let idCounter = 0
39
+ const nextId = () => `cr-${++idCounter}`
40
+
41
+ function linkedChargeRequests(accountId: string): Array<Record<string, unknown>> {
42
+ const rows: Array<Record<string, unknown>> = []
43
+ for (const [crId, owner] of chargeToAccount.entries()) {
44
+ if (owner === accountId) {
45
+ const row = tables.chargeRequest.get(crId)
46
+ if (row) rows.push(row)
47
+ }
48
+ }
49
+ return rows
50
+ }
51
+
52
+ function applyChargeRequestOps(accountId: string, ops: Record<string, unknown>) {
53
+ if (ops.create) {
54
+ const creates = Array.isArray(ops.create) ? ops.create : [ops.create]
55
+ for (const data of creates as Array<Record<string, unknown>>) {
56
+ const id = (data.id as string) ?? nextId()
57
+ tables.chargeRequest.set(id, { id, ...data })
58
+ chargeToAccount.set(id, accountId)
59
+ }
60
+ }
61
+ if (ops.update) {
62
+ const updates = Array.isArray(ops.update) ? ops.update : [ops.update]
63
+ for (const u of updates as Array<{ where: { id: string }; data: Record<string, unknown> }>) {
64
+ const existing = tables.chargeRequest.get(u.where.id) ?? { id: u.where.id }
65
+ tables.chargeRequest.set(u.where.id, { ...existing, ...u.data })
66
+ }
67
+ }
68
+ if (ops.delete) {
69
+ const deletes = Array.isArray(ops.delete) ? ops.delete : [ops.delete]
70
+ for (const d of deletes as Array<{ id: string }>) {
71
+ tables.chargeRequest.delete(d.id)
72
+ chargeToAccount.delete(d.id)
73
+ }
74
+ }
75
+ }
76
+
77
+ function buildAccountResult(
78
+ accountId: string,
79
+ include?: Record<string, unknown>,
80
+ ): Record<string, unknown> {
81
+ const base = tables.account.get(accountId) ?? { id: accountId }
82
+ if (include?.[SYNTHETIC_FIELD]) {
83
+ return { ...base, [SYNTHETIC_FIELD]: linkedChargeRequests(accountId) }
84
+ }
85
+ return { ...base }
86
+ }
87
+
88
+ const accountModel = {
89
+ findUnique: vi.fn(
90
+ async ({ where, include }: { where: { id: string }; include?: Record<string, unknown> }) => {
91
+ if (!tables.account.has(where.id)) return null
92
+ return buildAccountResult(where.id, include)
93
+ },
94
+ ),
95
+ findFirst: vi.fn(async ({ where }: { where?: { id?: string } }) => {
96
+ if (where?.id) return tables.account.get(where.id) ?? null
97
+ return tables.account.values().next().value ?? null
98
+ }),
99
+ findMany: vi.fn(async () => Array.from(tables.account.values())),
100
+ count: vi.fn(async () => tables.account.size),
101
+ create: vi.fn(
102
+ async ({
103
+ data,
104
+ include,
105
+ }: {
106
+ data: Record<string, unknown>
107
+ include?: Record<string, unknown>
108
+ }) => {
109
+ const id = (data.id as string) ?? `a-${++idCounter}`
110
+ const { [SYNTHETIC_FIELD]: nested, ...scalars } = data
111
+ tables.account.set(id, { id, ...scalars })
112
+ if (nested) applyChargeRequestOps(id, nested as Record<string, unknown>)
113
+ return buildAccountResult(id, include)
114
+ },
115
+ ),
116
+ update: vi.fn(
117
+ async ({
118
+ where,
119
+ data,
120
+ include,
121
+ }: {
122
+ where: { id: string }
123
+ data: Record<string, unknown>
124
+ include?: Record<string, unknown>
125
+ }) => {
126
+ const existing = tables.account.get(where.id) ?? { id: where.id }
127
+ const { [SYNTHETIC_FIELD]: nested, ...scalars } = data
128
+ tables.account.set(where.id, { ...existing, ...scalars })
129
+ if (nested) applyChargeRequestOps(where.id, nested as Record<string, unknown>)
130
+ return buildAccountResult(where.id, include)
131
+ },
132
+ ),
133
+ delete: vi.fn(async ({ where }: { where: { id: string } }) => {
134
+ const existing = tables.account.get(where.id) ?? { id: where.id }
135
+ tables.account.delete(where.id)
136
+ return existing
137
+ }),
138
+ }
139
+
140
+ function makeChargeRequestModel() {
141
+ return {
142
+ findUnique: vi.fn(
143
+ async ({ where }: { where: { id: string } }) => tables.chargeRequest.get(where.id) ?? null,
144
+ ),
145
+ findFirst: vi.fn(async () => tables.chargeRequest.values().next().value ?? null),
146
+ findMany: vi.fn(async () => Array.from(tables.chargeRequest.values())),
147
+ count: vi.fn(async () => tables.chargeRequest.size),
148
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
149
+ const id = (data.id as string) ?? nextId()
150
+ const row = { id, ...data }
151
+ tables.chargeRequest.set(id, row)
152
+ return row
153
+ }),
154
+ update: vi.fn(
155
+ async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) => {
156
+ const existing = tables.chargeRequest.get(where.id) ?? { id: where.id }
157
+ const updated = { ...existing, ...data }
158
+ tables.chargeRequest.set(where.id, updated)
159
+ return updated
160
+ },
161
+ ),
162
+ delete: vi.fn(async ({ where }: { where: { id: string } }) => {
163
+ const existing = tables.chargeRequest.get(where.id) ?? { id: where.id }
164
+ tables.chargeRequest.delete(where.id)
165
+ chargeToAccount.delete(where.id)
166
+ return existing
167
+ }),
168
+ }
169
+ }
170
+
171
+ const client: Record<string, unknown> = {
172
+ account: accountModel,
173
+ chargeRequest: makeChargeRequestModel(),
174
+ }
175
+
176
+ client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => {
177
+ const snapshot = {
178
+ account: new Map(tables.account),
179
+ chargeRequest: new Map(tables.chargeRequest),
180
+ links: new Map(chargeToAccount),
181
+ }
182
+ try {
183
+ return await fn(client)
184
+ } catch (err) {
185
+ tables.account = snapshot.account
186
+ tables.chargeRequest = snapshot.chargeRequest
187
+ chargeToAccount.clear()
188
+ for (const [k, v] of snapshot.links) chargeToAccount.set(k, v)
189
+ throw err
190
+ }
191
+ }
192
+
193
+ function seedChargeRequest(accountId: string, row: Record<string, unknown>) {
194
+ tables.chargeRequest.set(row.id as string, row)
195
+ chargeToAccount.set(row.id as string, accountId)
196
+ }
197
+
198
+ return { client, tables, seedChargeRequest, linkedChargeRequests }
199
+ }
200
+
201
+ function buildConfig(hooks: Parameters<typeof list>[0]['hooks'] = {}) {
202
+ return config({
203
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
204
+ lists: {
205
+ Account: list({
206
+ fields: { name: text() },
207
+ access: {
208
+ operation: {
209
+ query: () => true,
210
+ create: () => true,
211
+ update: () => true,
212
+ delete: () => true,
213
+ },
214
+ },
215
+ }),
216
+ // No `requests`/`chargeRequests` field on Account — this ref is
217
+ // list-only, so the generator synthesizes `from_ChargeRequest_account`.
218
+ ChargeRequest: list({
219
+ fields: {
220
+ kind: text(),
221
+ account: relationship({ ref: 'Account' }),
222
+ },
223
+ access: {
224
+ operation: {
225
+ query: () => true,
226
+ create: () => true,
227
+ update: () => true,
228
+ delete: () => true,
229
+ },
230
+ },
231
+ hooks,
232
+ }),
233
+ },
234
+ })
235
+ }
236
+
237
+ describe('#978 nested writes through a synthetic reverse relation', () => {
238
+ it('a non-sudo write through the synthetic key is still refused — unchanged', async () => {
239
+ const mock = createAccountPrisma()
240
+ mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
241
+ const context = getContext(await buildConfig(), mock.client, { userId: '1' })
242
+
243
+ await expect(
244
+ context.db.account.update({
245
+ where: { id: 'a1' },
246
+ data: { [SYNTHETIC_FIELD]: { create: { kind: 'DEPOSIT' } } },
247
+ }),
248
+ ).rejects.toThrow(/from_ChargeRequest_account/)
249
+ })
250
+
251
+ it('sudo nested create through the synthetic key runs the target list validate hook and refuses an invalid row', async () => {
252
+ const mock = createAccountPrisma()
253
+ mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
254
+
255
+ const testConfig = buildConfig({
256
+ validate: ({ resolvedData, addValidationError }) => {
257
+ if (resolvedData.kind === 'INCOHERENT') {
258
+ addValidationError('kind must not be INCOHERENT')
259
+ }
260
+ },
261
+ })
262
+ const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
263
+
264
+ await expect(
265
+ context.db.account.update({
266
+ where: { id: 'a1' },
267
+ data: { [SYNTHETIC_FIELD]: { create: { kind: 'INCOHERENT' } } },
268
+ }),
269
+ ).rejects.toThrow(ValidationError)
270
+
271
+ // Refused before commit — nothing persisted, matching the atomic write
272
+ // pipeline's "throw rolls back the whole transaction" contract.
273
+ expect(mock.tables.chargeRequest.size).toBe(0)
274
+ })
275
+
276
+ it('sudo nested create through the synthetic key runs resolveInput, beforeOperation, and afterOperation', async () => {
277
+ const mock = createAccountPrisma()
278
+ mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
279
+
280
+ const events: string[] = []
281
+ const testConfig = buildConfig({
282
+ resolveInput: ({ resolvedData }) => {
283
+ events.push('resolveInput')
284
+ return { ...resolvedData, kind: (resolvedData.kind as string).toUpperCase() }
285
+ },
286
+ beforeOperation: ({ operation }) => {
287
+ events.push(`beforeOperation:${operation}`)
288
+ },
289
+ afterOperation: ({ operation, item }) => {
290
+ events.push(`afterOperation:${operation}`)
291
+ expect(item).toBeDefined()
292
+ expect((item as Record<string, unknown>).id).toBeDefined()
293
+ },
294
+ })
295
+ const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
296
+
297
+ await context.db.account.update({
298
+ where: { id: 'a1' },
299
+ data: { [SYNTHETIC_FIELD]: { create: { kind: 'deposit' } } },
300
+ })
301
+
302
+ expect(events).toEqual(['resolveInput', 'beforeOperation:create', 'afterOperation:create'])
303
+ const created = Array.from(mock.tables.chargeRequest.values())
304
+ expect(created).toHaveLength(1)
305
+ // resolveInput's transform actually landed in the persisted row.
306
+ expect(created[0].kind).toBe('DEPOSIT')
307
+ })
308
+
309
+ it('created-row recovery works for a synthetic-relation nested create, like the declared path', async () => {
310
+ const mock = createAccountPrisma()
311
+ mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
312
+ mock.seedChargeRequest('a1', { id: 'pre-1', kind: 'PRE_EXISTING' })
313
+
314
+ const createdItems: Array<Record<string, unknown>> = []
315
+ const testConfig = buildConfig({
316
+ afterOperation: ({ operation, item }) => {
317
+ if (operation === 'create') createdItems.push(item as Record<string, unknown>)
318
+ },
319
+ })
320
+ const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
321
+
322
+ await context.db.account.update({
323
+ where: { id: 'a1' },
324
+ data: { [SYNTHETIC_FIELD]: { create: [{ kind: 'DEPOSIT' }, { kind: 'WITHDRAW' }] } },
325
+ })
326
+
327
+ // Exactly the two NEW rows fired afterOperation, each with its own
328
+ // distinct persisted id — never the pre-existing row, never a shared id.
329
+ expect(createdItems).toHaveLength(2)
330
+ const ids = createdItems.map((c) => c.id)
331
+ expect(new Set(ids).size).toBe(2)
332
+ expect(ids).not.toContain('pre-1')
333
+ expect(createdItems.map((c) => c.kind).sort()).toEqual(['DEPOSIT', 'WITHDRAW'])
334
+
335
+ expect(
336
+ mock
337
+ .linkedChargeRequests('a1')
338
+ .map((c) => c.kind)
339
+ .sort(),
340
+ ).toEqual(['DEPOSIT', 'PRE_EXISTING', 'WITHDRAW'])
341
+ })
342
+
343
+ it('sudo nested update through the synthetic key fires afterOperation with originalItem and the updated item', async () => {
344
+ const mock = createAccountPrisma()
345
+ mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
346
+ mock.seedChargeRequest('a1', { id: 'cr-1', kind: 'DEPOSIT' })
347
+
348
+ const afterOp = vi.fn()
349
+ const testConfig = buildConfig({ afterOperation: afterOp })
350
+ const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
351
+
352
+ await context.db.account.update({
353
+ where: { id: 'a1' },
354
+ data: {
355
+ [SYNTHETIC_FIELD]: { update: { where: { id: 'cr-1' }, data: { kind: 'WITHDRAW' } } },
356
+ },
357
+ })
358
+
359
+ expect(afterOp).toHaveBeenCalledWith(
360
+ expect.objectContaining({
361
+ operation: 'update',
362
+ originalItem: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
363
+ item: expect.objectContaining({ id: 'cr-1', kind: 'WITHDRAW' }),
364
+ }),
365
+ )
366
+ })
367
+
368
+ it('sudo nested delete through the synthetic key fires before/afterOperation with originalItem', async () => {
369
+ const mock = createAccountPrisma()
370
+ mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
371
+ mock.seedChargeRequest('a1', { id: 'cr-1', kind: 'DEPOSIT' })
372
+
373
+ const listBefore = vi.fn()
374
+ const listAfter = vi.fn()
375
+ const testConfig = buildConfig({ beforeOperation: listBefore, afterOperation: listAfter })
376
+ const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
377
+
378
+ await context.db.account.update({
379
+ where: { id: 'a1' },
380
+ data: { [SYNTHETIC_FIELD]: { delete: { id: 'cr-1' } } },
381
+ })
382
+
383
+ expect(listBefore).toHaveBeenCalledWith(
384
+ expect.objectContaining({
385
+ operation: 'delete',
386
+ item: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
387
+ }),
388
+ )
389
+ expect(listAfter).toHaveBeenCalledWith(
390
+ expect.objectContaining({
391
+ operation: 'delete',
392
+ originalItem: expect.objectContaining({ id: 'cr-1', kind: 'DEPOSIT' }),
393
+ }),
394
+ )
395
+ expect(mock.tables.chargeRequest.has('cr-1')).toBe(false)
396
+ })
397
+
398
+ it('a throwing nested afterOperation on the synthetic path rolls back the whole write (atomicity)', async () => {
399
+ const mock = createAccountPrisma()
400
+ mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
401
+
402
+ const testConfig = buildConfig({
403
+ afterOperation: ({ operation }) => {
404
+ if (operation === 'create') throw new Error('synthetic afterOperation boom')
405
+ },
406
+ })
407
+ const context = getContext(await testConfig, mock.client, { userId: '1' }).sudo()
408
+
409
+ await expect(
410
+ context.db.account.update({
411
+ where: { id: 'a1' },
412
+ data: {
413
+ name: 'Should NOT persist',
414
+ [SYNTHETIC_FIELD]: { create: { kind: 'DEPOSIT' } },
415
+ },
416
+ }),
417
+ ).rejects.toThrow('synthetic afterOperation boom')
418
+
419
+ expect(mock.tables.account.get('a1')?.name).toBe('Acme')
420
+ expect(mock.tables.chargeRequest.size).toBe(0)
421
+ })
422
+
423
+ it('a genuinely unknown key (not a synthetic reverse relation) is refused even under sudo', async () => {
424
+ const mock = createAccountPrisma()
425
+ mock.tables.account.set('a1', { id: 'a1', name: 'Acme' })
426
+ const context = getContext(await buildConfig(), mock.client, { userId: '1' }).sudo()
427
+
428
+ await expect(
429
+ context.db.account.update({
430
+ where: { id: 'a1' },
431
+ data: { totallyBogusKey: { create: { kind: 'DEPOSIT' } } },
432
+ }),
433
+ ).rejects.toThrow(/totallyBogusKey/)
434
+
435
+ expect(mock.tables.account.get('a1')?.name).toBe('Acme')
436
+ })
437
+ })
@@ -143,4 +143,32 @@ describe('Password Field Type Safety', () => {
143
143
  expect(await passwordField.compare('test')).toBe(false)
144
144
  }
145
145
  })
146
+
147
+ it('should redact the password field when a read row is JSON.stringify-ed', async () => {
148
+ const readableConfig = config({
149
+ db: { provider: 'sqlite' },
150
+ lists: {
151
+ User: list({
152
+ fields: {
153
+ email: text({ validation: { isRequired: true } }),
154
+ password: password({ validation: { isRequired: true } }),
155
+ },
156
+ access: { operation: { query: () => true } },
157
+ }),
158
+ },
159
+ })
160
+ const readablePrismaClient = {
161
+ user: {
162
+ ...mockPrismaClient.user,
163
+ findFirst: mockPrismaClient.user.findUnique,
164
+ },
165
+ } as unknown as PrismaClient
166
+ const context = getContext(await readableConfig, readablePrismaClient, null)
167
+
168
+ const user = await context.db.user.findUnique({ where: { id: '1' } })
169
+
170
+ const serialized = JSON.stringify(user)
171
+ expect(serialized).not.toContain('$2a$10$hashedpassword')
172
+ expect(JSON.parse(serialized).password).toEqual({ isSet: true })
173
+ })
146
174
  })
@@ -161,12 +161,26 @@ describe('Password Utilities', () => {
161
161
  expect(`Password: ${wrapped}`).toBe(`Password: ${hash}`)
162
162
  })
163
163
 
164
- it('should serialize to JSON correctly', async () => {
164
+ it('should redact the hash when serialized to JSON', async () => {
165
165
  const hash = await hashPassword('test')
166
166
  const wrapped = new HashedPassword(hash)
167
167
 
168
+ expect(JSON.stringify(wrapped)).toBe(JSON.stringify({ isSet: true }))
169
+
168
170
  const json = JSON.stringify({ password: wrapped })
169
- expect(json).toBe(JSON.stringify({ password: hash }))
171
+ expect(json).toBe(JSON.stringify({ password: { isSet: true } }))
172
+ expect(json).not.toContain(hash)
173
+ })
174
+
175
+ it('should keep returning the raw hash from string-coercion surfaces', async () => {
176
+ const hash = await hashPassword('test')
177
+ const wrapped = new HashedPassword(hash)
178
+
179
+ expect(wrapped.toString()).toBe(hash)
180
+ expect(wrapped.valueOf()).toBe(hash)
181
+ expect(String(wrapped)).toBe(hash)
182
+ expect(`${wrapped}`).toBe(hash)
183
+ expect(wrapped == hash).toBe(true)
170
184
  })
171
185
 
172
186
  it('should work with valueOf', async () => {