json-rest-api 1.0.10 → 1.0.13

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 (117) hide show
  1. package/.claude/settings.local.json +9 -1
  2. package/AGENTS.md +34 -0
  3. package/CLAUDE.md +26 -1
  4. package/CRUSH.md +55 -0
  5. package/README.md +3 -3
  6. package/TODO.todo +8 -0
  7. package/docs/API.md +17 -18
  8. package/docs/COMPARISON.md +18 -13
  9. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
  10. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
  11. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
  12. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
  13. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
  14. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
  15. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
  16. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
  17. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
  18. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
  19. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
  20. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
  21. package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
  22. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
  23. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
  24. package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
  25. package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
  26. package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
  27. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
  28. package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
  29. package/docs/GUIDE/index.md +3 -0
  30. package/docs/QUICKSTART.md +12 -12
  31. package/docs/index.md +2 -2
  32. package/docs/mini-guides/file-uploads-complete.md +0 -1
  33. package/examples/url-override-example.js +137 -0
  34. package/index.js +7 -1
  35. package/package.json +7 -11
  36. package/plugins/core/bulk-operations-plugin.js +2 -0
  37. package/plugins/core/connectors/express-plugin.js +43 -24
  38. package/plugins/core/connectors/lib/request-helpers.js +2 -2
  39. package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
  40. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
  41. package/plugins/core/lib/anyapi/schema-utils.js +186 -0
  42. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
  43. package/plugins/core/lib/dbTablesOperations.js +305 -0
  44. package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
  45. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
  46. package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
  47. package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
  48. package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
  49. package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
  50. package/plugins/core/lib/querying/url-helpers.js +119 -0
  51. package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
  52. package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
  53. package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
  54. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
  55. package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
  56. package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
  57. package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
  58. package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
  59. package/plugins/core/lib/storage/storage-adapter.js +160 -0
  60. package/plugins/core/lib/writing/relationship-processor.js +4 -11
  61. package/plugins/core/rest-api-access.js +456 -0
  62. package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
  63. package/plugins/core/rest-api-knex-plugin.js +226 -87
  64. package/plugins/core/rest-api-multihome-plugin.js +89 -27
  65. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
  66. package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
  67. package/plugins/core/rest-api-plugin-methods/common.js +46 -10
  68. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
  69. package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
  70. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
  71. package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
  72. package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
  73. package/plugins/core/rest-api-plugin-methods/get.js +2 -5
  74. package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
  75. package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
  76. package/plugins/core/rest-api-plugin-methods/post.js +23 -11
  77. package/plugins/core/rest-api-plugin-methods/put.js +18 -21
  78. package/plugins/core/rest-api-plugin-methods/query.js +3 -4
  79. package/plugins/core/rest-api-plugin-methods/release.js +13 -0
  80. package/plugins/core/rest-api-plugin.js +3 -4
  81. package/plugins/core/rest-api-positioning-plugin.js +253 -76
  82. package/plugins/core/socketio-plugin.js +436 -502
  83. package/quickTest.js +2 -2
  84. package/scripts/debug-anyapi-review-filter.mjs +63 -0
  85. package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
  86. package/scripts/inspect-anyapi-descriptor.mjs +19 -0
  87. package/test-debug.js +59 -0
  88. package/tests/README.md +7 -1
  89. package/tests/anyapi-basic.test.js +102 -0
  90. package/tests/anyapi-cursor-pagination.test.js +144 -0
  91. package/tests/computed-fields.test.js +27 -12
  92. package/tests/cors.test.js +5 -6
  93. package/tests/curl.test.js +1 -1
  94. package/tests/custom-idproperty-relationships.test.js +37 -29
  95. package/tests/field-getters.test.js +25 -7
  96. package/tests/fixtures/api-configs.js +576 -150
  97. package/tests/helpers/storage-mode.js +65 -0
  98. package/tests/helpers/test-utils.js +85 -3
  99. package/tests/include-depth-validation.test.js +35 -1
  100. package/tests/nested-includes.test.js +130 -103
  101. package/tests/pagination-cursor-multifield.test.js +17 -13
  102. package/tests/pagination-enhanced.test.js +19 -24
  103. package/tests/polymorphic-simple.test.js +9 -9
  104. package/tests/positioning-advanced.test.js +19 -19
  105. package/tests/positioning-extended.test.js +25 -25
  106. package/tests/positioning-multifilter.test.js +62 -62
  107. package/tests/positioning.test.js +7 -7
  108. package/tests/queries.test.js +5 -5
  109. package/tests/query-limits.test.js +77 -92
  110. package/tests/rest-api-access.test.js +206 -0
  111. package/tests/searchschema-merge.test.js +182 -0
  112. package/tests/socketio.test.js +33 -14
  113. package/tests/virtual-fields.test.js +0 -1
  114. package/docs/DOCS_ALL.md +0 -21894
  115. package/plugins/core/jwt-auth-plugin.js +0 -897
  116. package/plugins/core/lib/jwt-auth-helpers.js +0 -130
  117. package/tests/jwt-auth.test.js +0 -692
@@ -0,0 +1,456 @@
1
+ import { RestApiResourceError } from '../../lib/rest-api-errors.js'
2
+
3
+ export const AccessPlugin = {
4
+ name: 'rest-api-access',
5
+ dependencies: ['rest-api'],
6
+
7
+ async install({ addHook, helpers, log, pluginOptions = {}, scopes }) {
8
+ const ownershipOptions = pluginOptions.ownership || pluginOptions.autoOwnership || {}
9
+
10
+ const config = {
11
+ ownership: {
12
+ enabled: ownershipOptions.enabled !== false,
13
+ field: ownershipOptions.field || 'user_id',
14
+ userResource: ownershipOptions.userResource || 'users',
15
+ excludeResources: ownershipOptions.excludeResources || [],
16
+ filterByOwner: ownershipOptions.filterByOwner !== false,
17
+ requireOwnership: ownershipOptions.requireOwnership || false
18
+ }
19
+ }
20
+
21
+ const state = {
22
+ authCheckers: new Map()
23
+ }
24
+
25
+ const ownershipField = () => config.ownership.field
26
+
27
+ const evaluateOwnership = ({ record, field, schemaInfo, userId }) => {
28
+ if (!record) return 'unknown'
29
+
30
+ debugger
31
+ const idProperty = schemaInfo?.idProperty || 'id'
32
+ const schemaStructure = schemaInfo?.schemaStructure || {}
33
+ const schemaRelationships = schemaInfo?.schemaRelationships || {}
34
+
35
+ const matchesUser = (value) => value !== undefined && value !== null && String(value) === userId
36
+
37
+ if (field === idProperty) {
38
+ if (record.id === undefined || record.id === null) {
39
+ return 'unknown'
40
+ }
41
+ return matchesUser(record.id) ? 'match' : 'mismatch'
42
+ }
43
+
44
+ const fieldSchema = schemaStructure[field]
45
+
46
+ if (record.type && record.attributes && fieldSchema && !fieldSchema.belongsTo) {
47
+ const attributeValue = record.attributes[field]
48
+ if (attributeValue !== undefined && attributeValue !== null) {
49
+ return matchesUser(attributeValue) ? 'match' : 'mismatch'
50
+ }
51
+ }
52
+
53
+ let relationshipName
54
+ if (fieldSchema?.belongsTo) {
55
+ relationshipName = fieldSchema.as || field
56
+ } else if (schemaRelationships[field]) {
57
+ relationshipName = field
58
+ }
59
+
60
+ if (!relationshipName) {
61
+ return 'unknown'
62
+ }
63
+
64
+ const relationship = record.relationships?.[relationshipName]
65
+ const relData = relationship?.data
66
+ if (!relData) {
67
+ return 'unknown'
68
+ }
69
+
70
+ if (Array.isArray(relData)) {
71
+ return relData.some((item) => matchesUser(item?.id)) ? 'match' : 'mismatch'
72
+ }
73
+
74
+ if (relData?.id === undefined || relData?.id === null) {
75
+ return 'unknown'
76
+ }
77
+
78
+ return matchesUser(relData.id) ? 'match' : 'mismatch'
79
+ }
80
+
81
+ const resolveStorageAdapter = (scopeName, hookContext = {}) => {
82
+ if (!scopeName) return null
83
+
84
+ if (hookContext.storageAdapter) return hookContext.storageAdapter
85
+ if (hookContext.knexQuery?.storageAdapter) return hookContext.knexQuery.storageAdapter
86
+
87
+ if (helpers.getStorageAdapter) {
88
+ return helpers.getStorageAdapter(scopeName)
89
+ }
90
+ return null
91
+ }
92
+
93
+ const translateColumnReference = ({ field, tableName, scopeName, hookContext }) => {
94
+ const storageAdapter = resolveStorageAdapter(scopeName, hookContext)
95
+ const translated = storageAdapter?.translateColumn?.(field) ?? field
96
+ if (tableName) {
97
+ return `${tableName}.${translated}`
98
+ }
99
+ return translated
100
+ }
101
+
102
+ const translateFilterValue = ({ field, value, scopeName, hookContext }) => {
103
+ const storageAdapter = resolveStorageAdapter(scopeName, hookContext)
104
+ if (!storageAdapter?.translateFilterValue) return value
105
+ return storageAdapter.translateFilterValue(field, value)
106
+ }
107
+
108
+ function registerBuiltinCheckers() {
109
+ state.authCheckers.set('public', () => true)
110
+
111
+ state.authCheckers.set('authenticated', (context) => {
112
+ if (context.auth?.system === true) return true
113
+ return !!(context.auth?.userId || context.auth?.providerId)
114
+ })
115
+
116
+ state.authCheckers.set('owns', (context, { existingRecord, scopeVars }) => {
117
+ if (context.auth?.system === true) return true
118
+ if (!context.auth?.userId) return false
119
+ if (context.method === 'post') return true
120
+
121
+ // Minimal record is always prepared upstream (dataGetMinimal for reads, request payload snapshot for POST).
122
+ const record = existingRecord
123
+ if (!record) return true
124
+
125
+ const userId = String(context.auth.userId)
126
+ const field = scopeVars?.ownershipField || ownershipField()
127
+ const schemaInfo = scopeVars?.schemaInfo || {}
128
+
129
+ const ownershipStatus = evaluateOwnership({
130
+ record,
131
+ field,
132
+ schemaInfo,
133
+ userId
134
+ })
135
+
136
+ return ownershipStatus === 'match'
137
+ })
138
+ }
139
+
140
+ registerBuiltinCheckers()
141
+
142
+ addHook('scope:added', 'rest-auth-process-rules', {}, ({ context, scopes }) => {
143
+ const { scopeName } = context
144
+ const scope = scopes[scopeName]
145
+
146
+ const auth = context.scopeOptions?.auth
147
+ if (!auth) return
148
+
149
+ debugger
150
+ scope.vars.authRules = auth
151
+ scope.vars.ownershipField = context.scopeOptions?.ownershipField
152
+
153
+ log?.info?.(`Auth rules registered for ${scopeName}`, {
154
+ query: auth.query,
155
+ get: auth.get,
156
+ post: auth.post,
157
+ patch: auth.patch,
158
+ delete: auth.delete
159
+ })
160
+ })
161
+
162
+ addHook('schema:enrich', 'rest-auth-auto-ownership-field', {}, ({ context, scopeOptions }) => {
163
+ if (!config.ownership.enabled) return
164
+ const { fields, scopeName } = context
165
+ if (config.ownership.excludeResources.includes(scopeName)) return
166
+ if (scopeOptions?.ownership === false) return
167
+
168
+ const field = ownershipField()
169
+ const relationshipName = fields[field]?.as || fields[field]?.belongsTo || field
170
+
171
+ if (fields[field]) {
172
+ if (!fields[field].belongsTo) {
173
+ fields[field].belongsTo = config.ownership.userResource
174
+ }
175
+ if (!fields[field].as) {
176
+ fields[field].as = relationshipName
177
+ }
178
+ return
179
+ }
180
+
181
+ fields[field] = {
182
+ type: 'number',
183
+ belongsTo: config.ownership.userResource,
184
+ ...(relationshipName ? { as: relationshipName } : {}),
185
+ nullable: true,
186
+ indexed: true,
187
+ description: 'Automatically managed ownership field'
188
+ }
189
+ })
190
+
191
+ function setOwnerOnInput({ context, scopeName, scopes, scopeOptions }) {
192
+ if (!config.ownership.enabled) return
193
+ if (config.ownership.excludeResources.includes(scopeName)) return
194
+ if (!context.auth?.userId) {
195
+ if (config.ownership.requireOwnership) {
196
+ throw new Error(`Cannot operate on ${scopeName} without authentication`)
197
+ }
198
+ return
199
+ }
200
+
201
+ if (scopeOptions?.ownership === false) return
202
+
203
+ const field = ownershipField()
204
+ const scope = scopes[scopeName]
205
+ const schemaInfo = scope?.vars?.schemaInfo
206
+ const fieldSchema = schemaInfo?.schemaStructure?.[field]
207
+ const hasField = !!fieldSchema
208
+ const shouldSet = scopeOptions?.ownership === true || (scopeOptions?.ownership === undefined && hasField)
209
+ if (!shouldSet || !hasField) return
210
+
211
+ if (context.auth.roles?.includes?.('admin')) {
212
+ return
213
+ }
214
+
215
+ if (!context.inputRecord?.data) return
216
+
217
+ const relationships = context.inputRecord.data.relationships = context.inputRecord.data.relationships || {}
218
+ const attributes = context.inputRecord.data.attributes = context.inputRecord.data.attributes || {}
219
+
220
+ const relationshipName = fieldSchema.as || fieldSchema.belongsTo || field
221
+
222
+ if (fieldSchema.belongsTo) {
223
+ relationships[relationshipName] = {
224
+ data: {
225
+ type: fieldSchema.belongsTo || config.ownership.userResource || 'users',
226
+ id: String(context.auth.userId)
227
+ }
228
+ }
229
+ } else {
230
+ attributes[field] = context.auth.userId
231
+ }
232
+ }
233
+
234
+ addHook('beforeProcessingPost', 'rest-auth-auto-set-owner', {}, ({ context, scopeName, scopes, scopeOptions }) => {
235
+ setOwnerOnInput({ context, scopeName, scopes, scopeOptions })
236
+ })
237
+ addHook('beforeProcessingPatch', 'rest-auth-auto-set-owner', {}, ({ context, scopeName, scopes, scopeOptions }) => {
238
+ setOwnerOnInput({ context, scopeName, scopes, scopeOptions })
239
+ })
240
+ addHook('beforeProcessingPut', 'rest-auth-auto-set-owner', {}, ({ context, scopeName, scopes, scopeOptions }) => {
241
+ setOwnerOnInput({ context, scopeName, scopes, scopeOptions })
242
+ })
243
+
244
+ addHook('knexQueryFiltering', 'rest-auth-filter-by-owner', { sequence: -40 }, ({ context, scopes, scopeOptions }) => {
245
+ if (!config.ownership.enabled || !config.ownership.filterByOwner) return
246
+
247
+ const { query, tableName, scopeName } = context.knexQuery || {}
248
+
249
+ if (!query || !tableName) {
250
+ throw new Error('AccessPlugin: knexQuery must provide query and tableName for ownership filtering')
251
+ }
252
+
253
+ if (config.ownership.excludeResources.includes(scopeName)) return
254
+ if (!context.auth?.userId) {
255
+ if (config.ownership.requireOwnership) {
256
+ throw new Error(`Cannot query ${scopeName} without authentication`)
257
+ }
258
+ return
259
+ }
260
+
261
+ if (context.auth.roles?.includes?.('admin')) return
262
+ if (scopeOptions?.ownership === false) return
263
+
264
+ const field = ownershipField()
265
+ const scope = scopes[scopeName]
266
+ const schemaInfo = scope?.vars?.schemaInfo
267
+ const hasField = !!schemaInfo?.schemaStructure?.[field]
268
+ const shouldFilter = scopeOptions?.ownership === true || (scopeOptions?.ownership === undefined && hasField)
269
+ if (!shouldFilter || !hasField) return
270
+
271
+ const storageAdapter = resolveStorageAdapter(scopeName, context)
272
+ if (storageAdapter && !context.storageAdapter) {
273
+ context.storageAdapter = storageAdapter
274
+ }
275
+
276
+ const columnRef = translateColumnReference({ field, tableName, scopeName, hookContext: context })
277
+ const ownerValue = translateFilterValue({ field, value: context.auth.userId, scopeName, hookContext: context })
278
+
279
+ if (ownerValue === null) {
280
+ query.whereNull(columnRef)
281
+ } else {
282
+ query.where(columnRef, ownerValue)
283
+ }
284
+ })
285
+
286
+ addHook('checkPermissions', 'rest-auth-enforce', { sequence: -100 }, async ({ context, scope, scopeName }) => {
287
+ const operation = context.method
288
+ debugger
289
+ const scopeVars = scope?.vars
290
+ const authRules = scopeVars?.authRules
291
+ const minimalRecord = context.originalContext?.minimalRecord
292
+
293
+ if (!authRules) return
294
+ const rules = authRules[operation]
295
+ if (!rules) {
296
+ throw new Error(`Operation '${operation}' not allowed on resource '${scopeName}'`)
297
+ }
298
+
299
+ let passed = false
300
+ const failures = []
301
+
302
+ for (const rule of rules) {
303
+ // if (rule === 'owns') debugger
304
+ try {
305
+ const [checkerName, ...paramParts] = rule.split(':')
306
+ const checker = state.authCheckers.get(checkerName)
307
+ const param = paramParts.join(':') || undefined
308
+ if (!checker) {
309
+ throw new Error(`Unknown auth rule: ${rule}`)
310
+ }
311
+
312
+ const result = await checker(context.originalContext || context, {
313
+ existingRecord: minimalRecord,
314
+ scopeVars,
315
+ param
316
+ })
317
+
318
+ if (result) {
319
+ passed = true
320
+ break
321
+ }
322
+
323
+ failures.push(rule)
324
+ } catch (error) {
325
+ failures.push(`${rule} (error: ${error.message})`)
326
+ }
327
+ }
328
+
329
+ if (!passed) {
330
+ const err = new Error(
331
+ `Access denied. Required one of: ${rules.join(', ')}. Failed checks: ${failures.join(', ')}`
332
+ )
333
+ err.statusCode = 403
334
+ throw err
335
+ }
336
+
337
+ context.authGranted = true
338
+ })
339
+
340
+ addHook('checkPermissions', 'rest-auth-check-get-ownership', { sequence: -80 }, ({ context, scope, scopeName, scopeOptions }) => {
341
+ if (!config.ownership.enabled || !config.ownership.filterByOwner) return
342
+ if (config.ownership.excludeResources.includes(scopeName)) return
343
+
344
+ if (!['get', 'put', 'patch', 'delete'].includes(context.method)) return
345
+
346
+ const auth = context.originalContext?.auth || context.auth
347
+ if (!auth?.userId) return
348
+ if (auth.roles?.includes?.('admin')) return
349
+ if (scopeOptions?.ownership === false) return
350
+
351
+ const field = ownershipField()
352
+ const schemaInfo = scope?.vars?.schemaInfo || {}
353
+ const hasField = !!schemaInfo?.schemaStructure?.[field]
354
+ const shouldCheck = scopeOptions?.ownership === true || (scopeOptions?.ownership === undefined && hasField)
355
+ if (!shouldCheck) return
356
+
357
+ const record = context.originalContext?.minimalRecord
358
+ if (!record) return
359
+
360
+ const idProperty = schemaInfo.idProperty || 'id'
361
+ const userId = String(auth.userId)
362
+
363
+ // Ownership by primary key (e.g., users modifying themselves)
364
+ if (field === idProperty) {
365
+ if (record.id !== undefined && record.id !== null && String(record.id) !== userId) {
366
+ throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
367
+ }
368
+ return
369
+ }
370
+
371
+ const ownershipStatus = evaluateOwnership({
372
+ record,
373
+ field,
374
+ schemaInfo,
375
+ userId
376
+ })
377
+
378
+ if (ownershipStatus === 'mismatch') {
379
+ throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
380
+ }
381
+ })
382
+
383
+ if (!helpers.auth) {
384
+ helpers.auth = {}
385
+ }
386
+
387
+ helpers.auth.requireAuth = function requireAuth(context) {
388
+ if (!context.auth?.userId) {
389
+ const error = new Error('Authentication required')
390
+ error.statusCode = 401
391
+ throw error
392
+ }
393
+ return context.auth
394
+ }
395
+
396
+ helpers.auth.requireOwnership = function requireOwnership(context, resourceOrUserId) {
397
+ const auth = helpers.auth.requireAuth(context)
398
+ const field = ownershipField()
399
+
400
+ let ownerId
401
+
402
+ if (typeof resourceOrUserId === 'object' && resourceOrUserId !== null) {
403
+ ownerId = resourceOrUserId[field]
404
+ if (!ownerId) {
405
+ throw new Error(`Resource does not have ownership field '${field}'`)
406
+ }
407
+ } else if (resourceOrUserId !== undefined) {
408
+ ownerId = resourceOrUserId
409
+ } else if (context.existingRecord) {
410
+ ownerId = context.existingRecord[field]
411
+ if (!ownerId) {
412
+ throw new Error(`Resource does not have ownership field '${field}'`)
413
+ }
414
+ } else {
415
+ throw new Error('No resource or user ID provided for ownership check')
416
+ }
417
+
418
+ if (String(auth.userId) !== String(ownerId)) {
419
+ const error = new Error('Access denied: you do not own this resource')
420
+ error.statusCode = 403
421
+ throw error
422
+ }
423
+
424
+ return auth
425
+ }
426
+
427
+ helpers.auth.registerChecker = function registerChecker(name, checkerFn) {
428
+ state.authCheckers.set(name, checkerFn)
429
+ }
430
+
431
+ helpers.auth.checkPermission = async function checkPermission(context, rules, options = {}) {
432
+ if (!rules || rules.length === 0) return true
433
+
434
+ const { existingRecord, scopeVars } = options
435
+
436
+ for (const rule of rules) {
437
+ const [checkerName, ...paramParts] = rule.split(':')
438
+ const checker = state.authCheckers.get(checkerName)
439
+ if (!checker) continue
440
+
441
+ const param = paramParts.join(':') || undefined
442
+ if (await checker(context, { existingRecord, scopeVars, param })) {
443
+ return true
444
+ }
445
+ }
446
+
447
+ return false
448
+ }
449
+
450
+ helpers.auth.cleanup = function cleanup() {
451
+ state.authCheckers.clear()
452
+ }
453
+ }
454
+ }
455
+
456
+ export default AccessPlugin