json-rest-api 1.0.23 → 1.0.25

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 (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. package/tests/test-development-guide.md +5 -5
@@ -0,0 +1,603 @@
1
+ # Row Policies
2
+
3
+ `RowPolicyPlugin` is the server-side visibility seam for resources whose readable rows depend on more than a fixed equality filter.
4
+
5
+ Use it when the database must decide which rows are visible **before** it sorts, counts, or paginates them. Typical examples are:
6
+
7
+ - a user may see records assigned directly to them or to one of their teams
8
+ - a manager may see an organisation unit and all of its descendants
9
+ - access is granted through a membership or permission table
10
+ - several domain packages contribute different ways to make a row visible
11
+
12
+ A row policy is not a client filter and is not post-processing. It changes the SQL query that selects the resource.
13
+
14
+ ## The problem it solves
15
+
16
+ This is unsafe and paginates the wrong dataset:
17
+
18
+ ```js
19
+ const page = await api.resources.organisation_units.query({
20
+ queryParams: { page: { number: 1, size: 20 } },
21
+ simplified: false
22
+ }, context)
23
+
24
+ page.data = page.data.filter((unit) => canSeeUnit(context, unit))
25
+ ```
26
+
27
+ The database chose 20 rows before `canSeeUnit()` ran. If only two of those rows are visible, the caller receives a two-row page even when later matching rows exist. Counts, cursors, relationship data, and included children can also disagree with the filtered result.
28
+
29
+ With a row policy, visibility is part of the database query:
30
+
31
+ ```js
32
+ await api.use(RowPolicyPlugin, {
33
+ policies: {
34
+ organisationUnitsVisible: organisationUnitVisibilityPolicy
35
+ }
36
+ })
37
+
38
+ await api.addResource('organisation_units', {
39
+ schema: organisationUnitSchema,
40
+ rowPolicy: 'organisationUnitsVisible'
41
+ })
42
+ ```
43
+
44
+ The effective order is:
45
+
46
+ ```text
47
+ request context
48
+ │
49
+ ▼
50
+ client filters + mandatory row policy
51
+ │
52
+ ▼
53
+ sorting
54
+ │
55
+ ▼
56
+ pagination / count
57
+ │
58
+ ▼
59
+ JSON:API response
60
+ ```
61
+
62
+ The page size and count now describe the visible dataset.
63
+
64
+ ## Choosing the right mechanism
65
+
66
+ | Requirement | Use |
67
+ | --- | --- |
68
+ | The caller asks to filter by a public search field | Normal `queryParams.filters` / `searchSchema` |
69
+ | Every row must match one or more context-derived persisted fields, and those fields should be stamped on writes | [`AutoFilterPlugin`](GUIDE_X_Autofiltering.md) |
70
+ | Visibility needs `OR`, `EXISTS`, joins, hierarchy traversal, or domain-composed SQL | `RowPolicyPlugin` |
71
+ | The caller may perform an action such as update, approve, or delete | Permission hooks, usually in addition to a row policy |
72
+ | A response-only field must be calculated | Computed fields or query projections |
73
+
74
+ `AutoFilterPlugin` and `RowPolicyPlugin` can be installed together. Their predicates are combined with the other query filters. Autofilter remains the better choice for simple workspace ownership because it also stamps and validates writes. A row policy is deliberately read-oriented and does not stamp `POST` data.
75
+
76
+ ## Installation
77
+
78
+ Install the REST plugin, one supported Knex storage plugin, and then the row-policy plugin. Install `RowPolicyPlugin` before adding resources that declare `rowPolicy`.
79
+
80
+ ### Normal table storage
81
+
82
+ ```js
83
+ import { Api } from 'hooked-api'
84
+ import {
85
+ RestApiPlugin,
86
+ RestApiKnexPlugin,
87
+ RowPolicyPlugin
88
+ } from 'json-rest-api'
89
+
90
+ const api = new Api({ name: 'application-api' })
91
+
92
+ await api.use(RestApiPlugin)
93
+ await api.use(RestApiKnexPlugin, { knex })
94
+ await api.use(RowPolicyPlugin, {
95
+ policies: {
96
+ workspaceMember: ({ query, context, column, value }) => {
97
+ const workspaceId = context.session?.workspaceId
98
+ if (!workspaceId) return false
99
+
100
+ query.where(
101
+ column('workspace_id'),
102
+ value('workspace_id', workspaceId)
103
+ )
104
+ return true
105
+ }
106
+ }
107
+ })
108
+ ```
109
+
110
+ ### AnyAPI canonical storage
111
+
112
+ Use the same plugin and logical field names:
113
+
114
+ ```js
115
+ await api.use(RestApiPlugin)
116
+ await api.use(RestApiAnyapiKnexPlugin, {
117
+ knex,
118
+ tenantId: 'application'
119
+ })
120
+ await api.use(RowPolicyPlugin, {
121
+ policies: {
122
+ workspaceMember: ({ query, context, column, value }) => {
123
+ const workspaceId = context.session?.workspaceId
124
+ if (!workspaceId) return false
125
+
126
+ query.where(
127
+ column('workspace_id'),
128
+ value('workspace_id', workspaceId)
129
+ )
130
+ return true
131
+ }
132
+ }
133
+ })
134
+ ```
135
+
136
+ `column()` and `value()` translate logical schema fields and values through the active storage adapter. The example therefore works with both normal tables and AnyAPI canonical slots.
137
+
138
+ ## Resource configuration
139
+
140
+ ### Registered policy
141
+
142
+ Registered policies are preferred for shared or package-owned behavior:
143
+
144
+ ```js
145
+ await api.addResource('documents', {
146
+ schema: {
147
+ id: { type: 'id' },
148
+ title: { type: 'string', required: true },
149
+ workspace_id: { type: 'string', required: true }
150
+ },
151
+ rowPolicy: 'workspaceMember',
152
+ tableName: 'documents'
153
+ })
154
+ ```
155
+
156
+ The string must match a function in `RowPolicyPlugin`'s `policies` option. Unknown names fail while the resource is being added.
157
+
158
+ ### Inline policy
159
+
160
+ An inline function is supported for a policy that belongs to one resource:
161
+
162
+ ```js
163
+ await api.addResource('personal_notes', {
164
+ schema: {
165
+ id: { type: 'id' },
166
+ body: { type: 'string', required: true },
167
+ owner_id: { type: 'string', required: true }
168
+ },
169
+ rowPolicy: ({ query, context, column, value }) => {
170
+ const subjectId = context.subject?.id
171
+ if (!subjectId) return false
172
+
173
+ query.where(column('owner_id'), value('owner_id', subjectId))
174
+ return true
175
+ }
176
+ })
177
+ ```
178
+
179
+ Omitting `rowPolicy`, or setting it to `false`, leaves that resource unrestricted by this plugin.
180
+
181
+ ## The policy contract
182
+
183
+ A policy receives one object:
184
+
185
+ | Property | Meaning |
186
+ | --- | --- |
187
+ | `query` | The active query builder. Add the mandatory visibility predicate to this builder. |
188
+ | `context` | The original operation context, including application-owned identity/session values. |
189
+ | `scopeName` | The resource currently being selected. For includes, this is the child resource. |
190
+ | `tableName` | The table name or active query alias for this selection. |
191
+ | `queryPurpose` | Why the selection is running; see the table below. |
192
+ | `db` | The active Knex connection or transaction. Use it to build subqueries. |
193
+ | `isAnyApi` | `true` when the AnyAPI storage path is active. |
194
+ | `column(field, options?)` | Translate and qualify a logical field. |
195
+ | `value(field, rawValue, options?)` | Translate a value for a logical field. |
196
+ | `storageAdapter` | The current resource's storage adapter. Prefer `column()` and `value()` for normal policy code. |
197
+ | `api` | The API instance. This is mainly useful for AnyAPI descriptor lookup in storage-specific subqueries. |
198
+
199
+ `column()` accepts an optional object:
200
+
201
+ ```js
202
+ column('owner_id')
203
+ column('id', { scopeName: 'users', alias: 'visible_users' })
204
+ column('status', { alias: null }) // translated but deliberately unqualified
205
+ ```
206
+
207
+ `value()` accepts the same `scopeName` option:
208
+
209
+ ```js
210
+ value('owner_id', context.subject.id)
211
+ value('status', 'active', { scopeName: 'projects' })
212
+ ```
213
+
214
+ ### Required return value
215
+
216
+ Every invocation must make an explicit decision:
217
+
218
+ - return `true` after applying the intended predicate
219
+ - return `false` to deny all rows
220
+ - throw when the request context itself is invalid and the request should fail
221
+
222
+ Any other return value is a `REST_API_ROW_POLICY_CONTRACT` error and the query does not run. This makes a missing `return`, an unfinished branch, or a mistakenly omitted decision fail closed.
223
+
224
+ ```js
225
+ function assignedTeamPolicy ({ query, context, column, value }) {
226
+ const teamIds = context.subject?.teamIds
227
+
228
+ if (!Array.isArray(teamIds) || teamIds.length === 0) {
229
+ return false
230
+ }
231
+
232
+ query.whereIn(
233
+ column('team_id'),
234
+ teamIds.map((id) => value('team_id', id))
235
+ )
236
+
237
+ return true
238
+ }
239
+ ```
240
+
241
+ Do not return the Knex builder. Knex builders are thenable; returning one from an async policy can execute it accidentally. Mutate `query`, then return the boolean decision.
242
+
243
+ ### Group `OR` branches
244
+
245
+ Client filters, autofilters, and row policies share one query. Group a policy that contains `OR` so it cannot change the meaning of earlier predicates:
246
+
247
+ ```js
248
+ function documentVisibilityPolicy ({ query, context, column, value }) {
249
+ const subjectId = context.subject?.id
250
+ const teamIds = context.subject?.teamIds || []
251
+ if (!subjectId) return false
252
+
253
+ query.where(function documentVisibilityGroup () {
254
+ this.where(column('owner_id'), value('owner_id', subjectId))
255
+
256
+ if (teamIds.length > 0) {
257
+ this.orWhereIn(
258
+ column('team_id'),
259
+ teamIds.map((id) => value('team_id', id))
260
+ )
261
+ }
262
+ })
263
+
264
+ return true
265
+ }
266
+ ```
267
+
268
+ Do not add an ungrouped top-level `orWhere()` from a mandatory policy.
269
+
270
+ ## Query purposes and lifecycle coverage
271
+
272
+ Policies are reused for the resource selections that define externally visible data:
273
+
274
+ | `queryPurpose` | Selection |
275
+ | --- | --- |
276
+ | `collection` | Top-level collection query, before sorting and pagination. On AnyAPI this filtered builder is also cloned for the matching count. |
277
+ | `count` | A separate count query, including native offset-pagination counts and standalone count helpers. |
278
+ | `single` | Minimal lookup used by `get`, and by existing-record preflights for `put`, `patch`, and `delete`. |
279
+ | `include` | A target resource loaded into JSON:API `included` data. |
280
+ | `relationship-identifiers` | Child identifiers attached to a relationship, including has-many and AnyAPI many-to-many links. |
281
+ | `relationship-validation` | A target record referenced by a write payload. |
282
+ | `relationship-parent` | The parent of a relationship route such as `getRelated`. |
283
+ | `unspecified` | A custom call to the lower-level query-filtering seam did not provide a purpose. |
284
+
285
+ The important invariant is not the label. It is that the predicate is attached before any limit, offset, cursor boundary, per-parent include limit, or count is evaluated.
286
+
287
+ Treat `queryPurpose` as diagnostic context, not as permission to weaken a policy. A policy should normally add the same visibility predicate for every purpose. If an advanced policy uses a purpose-specific SQL shape, each branch must enforce equivalent visibility and must be tested independently; never skip a predicate for counts, includes, identifiers, or relationship validation.
288
+
289
+ ### Collections and counts
290
+
291
+ Offset pagination applies the same mandatory policy to the data query and its count. This remains true when the caller supplies no `filters` object.
292
+
293
+ Cursor predicates are added after the row policy, so the cursor moves through visible rows rather than through a page that will later be filtered.
294
+
295
+ ### Single records and writes
296
+
297
+ An existing row outside the policy behaves as not found for `get`, `patch`, and `delete`. Existing-record `put` authorization uses the same scoped minimal lookup.
298
+
299
+ The policy does not stamp or validate new `POST` attributes. Use schema validation, write hooks, and `AutoFilterPlugin` for write ownership. A row policy also does not replace action permissions: being able to see a record does not necessarily mean the caller may edit or delete it.
300
+
301
+ ### Includes and relationship data
302
+
303
+ The target resource's policy is used when the framework loads:
304
+
305
+ - included belongs-to, has-one, has-many, many-to-many, and polymorphic targets
306
+ - has-many and many-to-many relationship identifiers
307
+ - nested includes
308
+ - per-parent windowed include queries
309
+ - a relationship target during write validation
310
+
311
+ Filtering the parent resource does not automatically define child visibility. Put `rowPolicy` on every child resource that has its own visibility rule.
312
+
313
+ Relationship routes preserve the caller's trusted context when they invoke the target resource. Filters, sorting, and pagination supplied for a related-resource request are applied to that target query; they are deliberately withheld from the preliminary parent-visibility lookup. This keeps a target-only filter from being misinterpreted as a parent field while still enforcing the parent's policy.
314
+
315
+ ## Hierarchies and recursive CTEs
316
+
317
+ A recursive common table expression (recursive CTE) is a temporary query result that can refer to itself. For a tree, it starts with one or more roots and repeatedly follows `parent_id` until no more children are found.
318
+
319
+ Conceptually:
320
+
321
+ ```text
322
+ manager's unit
323
+ ├── child
324
+ │ └── grandchild
325
+ └── child
326
+ ```
327
+
328
+ The CTE produces the set `{ manager's unit, child, grandchild, child }`. The outer resource query then keeps rows whose id is in that set. The recursion happens in SQL before pagination; it is not recursive JavaScript and it does not load the whole tree into memory.
329
+
330
+ ### Normal-table Knex example
331
+
332
+ ```js
333
+ function organisationUnitVisibilityPolicy ({
334
+ query,
335
+ context,
336
+ db,
337
+ column,
338
+ value,
339
+ isAnyApi,
340
+ api,
341
+ storageAdapter
342
+ }) {
343
+ const rootUnitId = context.subject?.managedOrganisationUnitId
344
+ if (!rootUnitId) return false
345
+
346
+ if (isAnyApi) {
347
+ return applyAnyApiOrganisationUnitPolicy({
348
+ query,
349
+ context,
350
+ db,
351
+ column,
352
+ value,
353
+ api,
354
+ storageAdapter
355
+ })
356
+ }
357
+
358
+ const visibleUnitIds = db
359
+ .withRecursive('visible_units', ['unit_id'], (cte) => {
360
+ cte
361
+ .select('id as unit_id')
362
+ .from('organisation_units')
363
+ .where('id', value('id', rootUnitId))
364
+ .unionAll(function descendantUnits () {
365
+ this
366
+ .select('child.id as unit_id')
367
+ .from('organisation_units as child')
368
+ .join('visible_units as visible', 'child.parent_id', 'visible.unit_id')
369
+ })
370
+ })
371
+ .select('unit_id')
372
+ .from('visible_units')
373
+
374
+ query.whereIn(column('id'), visibleUnitIds)
375
+ return true
376
+ }
377
+ ```
378
+
379
+ This example assumes an acyclic organisation-unit tree. Enforce the tree invariant in writes and add the indexes described below.
380
+
381
+ ### AnyAPI hierarchy branch
382
+
383
+ Simple policies need no backend branch. A raw recursive CTE does, because AnyAPI stores logical fields in canonical slots and multiple resources share the canonical table.
384
+
385
+ The policy can resolve the resource descriptor and constrain every CTE leg by tenant and resource:
386
+
387
+ ```js
388
+ async function applyAnyApiOrganisationUnitPolicy ({
389
+ query,
390
+ context,
391
+ db,
392
+ column,
393
+ value,
394
+ api,
395
+ storageAdapter
396
+ }) {
397
+ const rootUnitId = context.subject?.managedOrganisationUnitId
398
+ if (!rootUnitId) return false
399
+
400
+ const descriptor = await api.anyapi.registry.getDescriptor(
401
+ api.anyapi.tenantId,
402
+ 'organisation_units'
403
+ )
404
+ if (!descriptor) return false
405
+
406
+ const table = descriptor.canonical.tableName
407
+ const tenantColumn = descriptor.canonical.tenantColumn
408
+ const resourceColumn = descriptor.canonical.resourceColumn
409
+ const idColumn = storageAdapter.translateColumn('id')
410
+ const parentColumn = storageAdapter.translateColumn('parent_id')
411
+
412
+ const visibleUnitIds = db
413
+ .withRecursive('visible_units', ['unit_id'], (cte) => {
414
+ cte
415
+ .select(`root.${idColumn} as unit_id`)
416
+ .from({ root: table })
417
+ .where(`root.${tenantColumn}`, descriptor.tenant)
418
+ .where(`root.${resourceColumn}`, descriptor.resource)
419
+ .where(`root.${idColumn}`, value('id', rootUnitId))
420
+ .unionAll(function descendantUnits () {
421
+ this
422
+ .select(`child.${idColumn} as unit_id`)
423
+ .from({ child: table })
424
+ .join('visible_units as visible', `child.${parentColumn}`, 'visible.unit_id')
425
+ .where(`child.${tenantColumn}`, descriptor.tenant)
426
+ .where(`child.${resourceColumn}`, descriptor.resource)
427
+ })
428
+ })
429
+ .select('unit_id')
430
+ .from('visible_units')
431
+
432
+ query.whereIn(column('id'), visibleUnitIds)
433
+ return true
434
+ }
435
+ ```
436
+
437
+ Pass `api` and `storageAdapter` from the outer policy call if you split the implementation into helpers. Do not reuse normal-table names or assume a particular AnyAPI slot. Resolve AnyAPI's physical table and slot names from its descriptor and storage adapter at query construction time.
438
+
439
+ For very large or frequently queried trees, a closure table can be a better model. A closure table stores ancestor/descendant pairs explicitly, turning traversal into an indexed `EXISTS` or join at the cost of more complex writes.
440
+
441
+ Recommended indexes for an adjacency-list tree are:
442
+
443
+ - the resource primary key
444
+ - `parent_id`
445
+ - every membership or permission-table foreign key used by the policy
446
+
447
+ Inspect the query plan on the production database; recursive queries that are fast on a small fixture can still need database-specific tuning.
448
+
449
+ ## Composing visibility without package cycles
450
+
451
+ `RowPolicyPlugin` deliberately owns query timing and logical-field translation. It does not know which domain package grants access.
452
+
453
+ When several packages can grant visibility, the package that owns the resource can own a small contributor registry:
454
+
455
+ ```js
456
+ const contributors = new Map()
457
+
458
+ export function registerOrganisationUnitVisibility ({ id, apply }) {
459
+ if (!id || typeof apply !== 'function') {
460
+ throw new Error('Visibility contributors require an id and apply function')
461
+ }
462
+ if (contributors.has(id)) {
463
+ throw new Error(`Duplicate organisation-unit visibility contributor '${id}'`)
464
+ }
465
+ contributors.set(id, apply)
466
+ }
467
+
468
+ export function organisationUnitVisibilityPolicy (policyContext) {
469
+ if (contributors.size === 0) return false
470
+
471
+ policyContext.query.where(function visibilityContributors () {
472
+ for (const [id, apply] of contributors) {
473
+ this.orWhere(function oneVisibilityGrant () {
474
+ const result = apply({ ...policyContext, query: this })
475
+
476
+ if (result === false) {
477
+ this.whereRaw('1 = 0')
478
+ } else if (result !== true) {
479
+ throw new Error(
480
+ `Visibility contributor '${id}' must return true or false`
481
+ )
482
+ }
483
+ })
484
+ }
485
+ })
486
+
487
+ return true
488
+ }
489
+ ```
490
+
491
+ Each contributor synchronously adds one grouped SQL grant and returns `true`, or returns `false` when it grants nothing for this request. A `false` contributor becomes an always-false branch; an omitted return fails the request. Do not return a promise from a Knex grouping callback.
492
+
493
+ The package ownership is then:
494
+
495
+ ```text
496
+ organisation-units package
497
+ owns the resource
498
+ owns the contributor registry
499
+ sets rowPolicy: 'organisationUnitsVisible'
500
+
501
+ safety package
502
+ depends on organisation-units
503
+ registers the safety-manager descendant grant
504
+
505
+ organisation-units package does not import safety
506
+ ```
507
+
508
+ For example, the safety package can register at application boot:
509
+
510
+ ```js
511
+ registerOrganisationUnitVisibility({
512
+ id: 'safety-manager-descendants',
513
+ apply: ({ query, context, db, column }) => {
514
+ const subjectId = context.subject?.id
515
+ if (!subjectId) return false
516
+
517
+ query.whereExists(function safetyManagerGrant () {
518
+ this
519
+ .select(db.raw('1'))
520
+ .from('safety_manager_visible_units as visible')
521
+ .where('visible.user_id', subjectId)
522
+ .whereColumn('visible.organisation_unit_id', column('id'))
523
+ })
524
+
525
+ return true
526
+ }
527
+ })
528
+ ```
529
+
530
+ Here `safety_manager_visible_units` may be a normal table, a view, or a query backed by a recursive CTE. The important part is that the grant becomes SQL on the organisation-unit query before pagination.
531
+
532
+ This registry is an application/domain composition pattern, not a second filtering engine. Register contributors during boot, reject duplicate ids, and do not mutate the registry while requests are running.
533
+
534
+ ## Inspection
535
+
536
+ The plugin exposes compiled configuration without exposing function source:
537
+
538
+ ```js
539
+ api.rowPolicies.getConfig()
540
+ // { policies: ['workspaceMember', 'organisationUnitsVisible'] }
541
+
542
+ api.rowPolicies.getScopeConfig('organisation_units')
543
+ // { policy: 'organisationUnitsVisible', source: 'registry' }
544
+
545
+ api.rowPolicies.getScopeConfig('public_settings')
546
+ // null
547
+ ```
548
+
549
+ Inline policies report `policy: '<inline>'` and `source: 'inline'`.
550
+
551
+ ## Security rules
552
+
553
+ 1. Derive identity and scope values from trusted context, not unverified filter/query parameters.
554
+ 2. Return `false` or throw when required identity is missing. Never silently skip the predicate.
555
+ 3. Group every policy-owned `OR` expression.
556
+ 4. Use `column()` and `value()` for logical fields.
557
+ 5. Constrain tenant and resource columns in every raw AnyAPI subquery or CTE leg.
558
+ 6. Put policies on child resources too; a parent policy does not imply child visibility.
559
+ 7. Keep action authorization in permission hooks. Row visibility alone is not permission to mutate.
560
+ 8. Test allowed and denied rows in an interleaved order so post-pagination filtering cannot accidentally pass.
561
+ 9. Test counts, subsequent pages, cursors, includes, relationship identifiers, and relationship writes.
562
+ 10. Treat a policy error as a request failure. Do not catch it and retry without the policy.
563
+
564
+ ## Testing recipe
565
+
566
+ A useful regression fixture inserts rows in this order:
567
+
568
+ ```text
569
+ allowed 1
570
+ denied 1
571
+ allowed 2
572
+ denied 2
573
+ allowed 3
574
+ ```
575
+
576
+ Then request page size 2 and assert:
577
+
578
+ - page 1 is `allowed 1`, `allowed 2`
579
+ - page 2 is `allowed 3`
580
+ - total is 3
581
+ - a cursor from page 1 reaches only `allowed 3`
582
+ - direct `get`, `patch`, and `delete` of `denied 1` behave as not found
583
+ - included children and relationship identifiers omit denied targets
584
+ - a relationship write cannot reference a denied target
585
+ - missing identity returns no rows or the documented context error
586
+ - the same suite passes with `RestApiKnexPlugin` and `RestApiAnyapiKnexPlugin`
587
+
588
+ Do not seed all allowed rows together and all denied rows together. That arrangement can hide the exact paginate-then-filter bug this feature is meant to prevent.
589
+
590
+ ## Migration checklist
591
+
592
+ When replacing post-query filtering:
593
+
594
+ 1. Identify the resource that owns the list query.
595
+ 2. Move the visibility decision into a named row policy.
596
+ 3. Keep the public resource/filter definition unchanged unless it truly needs a new client-facing filter.
597
+ 4. Delete the in-memory filter only after pagination, count, include, and relationship tests pass.
598
+ 5. Add the policy to dynamically loaded child resources with independent visibility rules.
599
+ 6. Run the suite once with normal Knex storage and once with AnyAPI.
600
+ 7. Inspect production query plans and add hierarchy/membership indexes.
601
+ 8. Keep permission hooks for create/update/delete actions.
602
+
603
+ The end state is one resource-owned visibility contract, applied consistently by the storage layer before pagination.
@@ -30,19 +30,17 @@ The Socket.IO plugin provides real-time notifications when resources change in y
30
30
 
31
31
  The Socket.IO plugin is included in json-rest-api core plugins. To use it, you need to:
32
32
 
33
- 1. Install Socket.IO dependencies:
33
+ 1. Install Socket.IO. Redis packages are only needed when you configure the Redis adapter:
34
34
  ```bash
35
- npm install socket.io @socket.io/redis-adapter redis
35
+ npm install socket.io
36
+ npm install @socket.io/redis-adapter redis
36
37
  ```
37
38
 
38
39
  2. Use the plugin and start the Socket.IO server:
39
40
 
40
41
  ```javascript
41
- import { Api } from 'json-rest-api';
42
- import { RestApiPlugin } from 'json-rest-api/plugins/rest-api';
43
- import { RestApiKnexPlugin } from 'json-rest-api/plugins/rest-api-knex';
44
- import { SocketIOPlugin } from 'json-rest-api/plugins/socketio';
45
- import { JWTAuthPlugin } from 'json-rest-api/plugins/jwt-auth';
42
+ import { Api } from 'hooked-api';
43
+ import { RestApiPlugin, RestApiKnexPlugin, SocketIOPlugin } from 'json-rest-api';
46
44
 
47
45
  // Create your API instance
48
46
  const api = new Api({
@@ -52,8 +50,15 @@ const api = new Api({
52
50
  // Add required plugins
53
51
  await api.use(RestApiPlugin);
54
52
  await api.use(RestApiKnexPlugin, { knex: knexInstance });
55
- await api.use(JWTAuthPlugin, { secret: process.env.JWT_SECRET });
56
- await api.use(SocketIOPlugin);
53
+ await api.use(SocketIOPlugin, {
54
+ auth: {
55
+ requireAuth: true,
56
+ authenticate: async ({ socket }) => {
57
+ const token = socket.handshake.auth?.token;
58
+ return verifyToken(token); // App-defined token verification
59
+ }
60
+ }
61
+ });
57
62
 
58
63
  // Start your HTTP server
59
64
  const server = app.listen(3000).on('error', (err) => {
@@ -1032,4 +1037,4 @@ async function testWebSocketSubscription() {
1032
1037
  });
1033
1038
  });
1034
1039
  }
1035
- ```
1040
+ ```