@adonis-agora/filter 0.6.0 → 0.7.1

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.
@@ -0,0 +1,304 @@
1
+ ---
2
+ name: filter-safety
3
+ description: >
4
+ Lock down a public @adonis-agora/filter endpoint in AdonisJS. Covers the
5
+ allowed/sortable/searchable allow-lists as the security boundary, AllowList
6
+ forms ('*' | string[] | predicate), throwOnInvalid + InvalidColumnFilterError
7
+ mapped to HTTP 400, built-in structural validation (validateColumnFilters,
8
+ MAX_FILTER_DEPTH, field charset, operator aliases), query-string value
9
+ coercion with fieldTypes/coerceFilterValue, escapeLike for hand-built ILIKE
10
+ patterns, the trust boundary of low-level applyColumnFilters/applySort/
11
+ applySearch inside whereHas callbacks, layering VineJS for domain validation,
12
+ and unit-testing allow-listing with @adonis-agora/filter/testing
13
+ MockQueryBuilder. Use when exposing filters to untrusted clients, preventing
14
+ arbitrary-column probing, turning silent drops into 400s, or writing filter
15
+ unit tests.
16
+ metadata:
17
+ type: core
18
+ library: "@adonis-agora/filter"
19
+ library_version: "0.7.0"
20
+ framework: adonisjs
21
+ sources:
22
+ - "DavideCarvalho/adonis-filter:docs/guides/filter-classes.mdx"
23
+ - "DavideCarvalho/adonis-filter:docs/guides/validation.mdx"
24
+ - "DavideCarvalho/adonis-filter:docs/guides/operators.mdx"
25
+ - "DavideCarvalho/adonis-filter:docs/testing/index.mdx"
26
+ - "DavideCarvalho/adonis-filter:docs/guides/lucid.mdx"
27
+ ---
28
+
29
+ # Filter safety: the allow-list is the security boundary
30
+
31
+ `allowed`, `sortable`, and `searchable` are not conveniences — they are the only
32
+ thing standing between a client query string and your columns. Any field a
33
+ client references outside them is pruned before it reaches Lucid. This skill
34
+ covers enforcing that boundary loudly, coercing hostile string values, and
35
+ testing it without a database.
36
+
37
+ ## Setup
38
+
39
+ ```typescript
40
+ // app/controllers/users_controller.ts
41
+ import type { HttpContext } from '@adonisjs/core/http'
42
+ import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
43
+ import User from '#models/user'
44
+
45
+ export default class UsersController {
46
+ async index({ request, response }: HttpContext) {
47
+ const input = parseFilterRequest(request.qs())
48
+ const query = User.query()
49
+
50
+ try {
51
+ const { page, size } = applyFilter(query, input, {
52
+ allowed: ['name', 'email', 'status'], // explicit array — never '*' on public endpoints
53
+ sortable: ['name', 'createdAt'],
54
+ searchable: ['name', 'email'],
55
+ maxSize: 100,
56
+ throwOnInvalid: true,
57
+ })
58
+ return query.paginate(page, size)
59
+ } catch (err) {
60
+ if (err instanceof InvalidColumnFilterError) {
61
+ return response.badRequest({ message: err.message })
62
+ }
63
+ throw err
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ (Import `InvalidColumnFilterError` from `@adonis-agora/filter` alongside
70
+ `parseFilterRequest` / `applyFilter`.)
71
+
72
+ ## Core patterns
73
+
74
+ ### 1. The AllowList has three forms
75
+
76
+ `type AllowList = '*' | string[] | ((field) => boolean)`. Prefer an explicit
77
+ array everywhere a client can reach; use the predicate form for rules a flat
78
+ list cannot express — e.g. whitelisting one hop into `posts` under a cap:
79
+
80
+ ```typescript
81
+ applyFilter(query, input, {
82
+ allowed: (field) => !field.includes('.') || /^posts\.[a-z]+$/i.test(field),
83
+ })
84
+ ```
85
+
86
+ The predicate receives the already alias-resolved target, never the client-facing alias key.
87
+
88
+ Source: `docs/guides/filter-classes.mdx`
89
+
90
+ ### 2. throwOnInvalid — reject instead of silently dropping
91
+
92
+ By default disallowed fields are dropped silently. With `throwOnInvalid: true`,
93
+ both structural failures and allow-list rejections surface as
94
+ `InvalidColumnFilterError`; handle it once globally instead of per controller:
95
+
96
+ ```typescript
97
+ // app/exceptions/handler.ts
98
+ import { InvalidColumnFilterError } from '@adonis-agora/filter'
99
+
100
+ async handle(error: unknown, ctx: HttpContext) {
101
+ if (error instanceof InvalidColumnFilterError) {
102
+ return ctx.response.badRequest({ message: error.message })
103
+ }
104
+ return super.handle(error, ctx)
105
+ }
106
+ ```
107
+
108
+ Structural validation runs on every `applyFilter`: field charset
109
+ (`^[a-zA-Z_][a-zA-Z0-9_.]*$`), known operators or SQL-symbol aliases
110
+ (normalized in place), per-operator value shapes, and `AND`/`OR` nesting capped
111
+ at `MAX_FILTER_DEPTH` (10). `validateColumnFilters` is exported to run the same
112
+ checks manually.
113
+
114
+ Source: `docs/guides/validation.mdx`
115
+
116
+ ### 3. Coerce query-string values with fieldTypes
117
+
118
+ Query-string values are always strings. Declare kinds so bad values are
119
+ rejected up front (dropped, or `InvalidColumnFilterError` → 400 under
120
+ `throwOnInvalid`) instead of surfacing as Postgres cast errors:
121
+
122
+ ```typescript
123
+ applyFilter(query, input, {
124
+ allowed: ['advisorId', 'dayOfWeek', 'isRecurring'],
125
+ fieldTypes: {
126
+ dayOfWeek: { kind: 'number' },
127
+ isRecurring: { kind: 'boolean' },
128
+ },
129
+ })
130
+ ```
131
+
132
+ Kinds: `'string' | 'number' | 'boolean' | 'date' | 'json' | 'unknown'`.
133
+ Coercion rules worth knowing: an empty string is not a valid number;
134
+ `null` passes every kind (legitimate `IS NULL`); a `date` string is validated
135
+ but returned verbatim.
136
+
137
+ Source: `docs/definitions/index.mdx` (Field types & value coercion)
138
+
139
+ ### 4. Escape LIKE patterns you build by hand
140
+
141
+ All library string operators escape `%`, `_`, and `\` via `escapeLike()`
142
+ before building patterns. If you build an ILIKE pattern yourself (outside the
143
+ adapter), do the same:
144
+
145
+ ```typescript
146
+ import { escapeLike } from '@adonis-agora/filter'
147
+
148
+ qb.whereILike('code', `${escapeLike(prefix)}%`)
149
+ ```
150
+
151
+ Source: `docs/guides/operators.mdx` (Security)
152
+
153
+ ### 5. Unit-test the boundary with the shipped mock
154
+
155
+ `@adonis-agora/filter/testing` exports a recording `QueryBuilderLike` stand-in,
156
+ so allow-listing is assertable with no database:
157
+
158
+ ```typescript
159
+ import { test } from '@japa/runner'
160
+ import { applyFilter } from '@adonis-agora/filter'
161
+ import { makeMockQueryBuilder } from '@adonis-agora/filter/testing'
162
+
163
+ test('drops disallowed fields', ({ assert }) => {
164
+ const qb = makeMockQueryBuilder()
165
+ applyFilter(
166
+ qb,
167
+ { filters: [
168
+ { field: 'name', operator: 'equals', value: 'Al' },
169
+ { field: 'secret', operator: 'equals', value: 'x' },
170
+ ] },
171
+ { allowed: ['name'] },
172
+ )
173
+ const flat = qb.flatten()
174
+ assert.deepInclude(flat, { method: 'where', args: ['name', 'Al'] })
175
+ assert.isUndefined(flat.find((c) => c.args.includes('secret')))
176
+ })
177
+ ```
178
+
179
+ Source: `docs/testing/index.mdx`, `docs/guides/testing.mdx`
180
+
181
+ ## Common mistakes
182
+
183
+ ### [CRITICAL] Allowing every column with `'*'`
184
+
185
+ Wrong:
186
+
187
+ ```typescript
188
+ applyFilter(query, input, { allowed: '*' })
189
+ ```
190
+
191
+ Correct:
192
+
193
+ ```typescript
194
+ applyFilter(query, input, { allowed: ['name', 'email', 'status'] })
195
+ ```
196
+
197
+ A star allow-list disables column filtering entirely — any base column
198
+ (`passwordHash`, internal flags) becomes probeable through filter values and
199
+ result counts. Reserve it for trusted internal endpoints.
200
+
201
+ Source: docs/guides/filter-classes.mdx ("The allow-list is the security boundary" warning)
202
+
203
+ ### [CRITICAL] Feeding client filters to low-level apply functions unpruned
204
+
205
+ Wrong:
206
+
207
+ ```typescript
208
+ const raw = parseFilterRequest(request.qs())
209
+ query.whereHas('posts', (p) => applyColumnFilters(p, raw.filters ?? []))
210
+ ```
211
+
212
+ Correct:
213
+
214
+ ```typescript
215
+ const raw = parseFilterRequest(request.qs())
216
+ // prune against a policy first (or validate fields yourself)…
217
+ const scratch = User.query()
218
+ applyFilter(scratch, raw, { allowed: ['title', 'status'] })
219
+ query.whereHas('posts', (p) =>
220
+ applyColumnFilters(p, [{ field: 'title', operator: 'equals', value: 'Hi' }]),
221
+ )
222
+ ```
223
+
224
+ `applyColumnFilters` / `applySort` / `applySearch` trust their input — they
225
+ enforce no allow-list — so client-derived filters handed straight to them can
226
+ reference arbitrary columns inside the subquery.
227
+
228
+ Source: docs/guides/lucid.mdx (low-level functions warning); docs/guides/relations.mdx
229
+
230
+ ### [HIGH] Trusting silent-drop while debugging
231
+
232
+ Wrong:
233
+
234
+ ```typescript
235
+ applyFilter(query, input, { allowed: ['name', 'createdAt'] })
236
+ // ?sort=-craetedAt does nothing, no error anywhere
237
+ ```
238
+
239
+ Correct:
240
+
241
+ ```typescript
242
+ try {
243
+ const { page, size } = applyFilter(query, input, {
244
+ allowed: ['name', 'createdAt'],
245
+ throwOnInvalid: true,
246
+ })
247
+ } catch (err) {
248
+ if (err instanceof InvalidColumnFilterError) {
249
+ return response.badRequest({ message: err.message })
250
+ }
251
+ throw err
252
+ }
253
+ ```
254
+
255
+ With defaults, a typo'd sort or filter just vanishes and the query "works" but
256
+ ignores the client's intent — contract drift ships unnoticed.
257
+
258
+ Source: docs/guides/validation.mdx (throwOnInvalid)
259
+
260
+ ### [HIGH] Assuming query-string numbers arrive as numbers
261
+
262
+ Wrong:
263
+
264
+ ```typescript
265
+ // ?filter[isRecurring]=xyz → Postgres: invalid input syntax for type boolean → 500
266
+ applyFilter(query, input, { allowed: ['isRecurring'] })
267
+ ```
268
+
269
+ Correct:
270
+
271
+ ```typescript
272
+ applyFilter(query, input, {
273
+ allowed: ['isRecurring'],
274
+ fieldTypes: { isRecurring: { kind: 'boolean' } }, // rejected up front → drop or 400
275
+ })
276
+ ```
277
+
278
+ Every query-string value parses as a string; SQL implicit casts hide the gap
279
+ until an uncastable value raises at the database as a user-input-driven 500.
280
+
281
+ Source: docs/definitions/index.mdx (Field types & value coercion); docs/guides/operators.mdx
282
+
283
+ ### [MEDIUM] Building ILIKE patterns from unescaped user input
284
+
285
+ Wrong:
286
+
287
+ ```typescript
288
+ qb.whereILike('name', `%${userInput}%`) // userInput '%%' widens the match arbitrarily
289
+ ```
290
+
291
+ Correct:
292
+
293
+ ```typescript
294
+ import { escapeLike } from '@adonis-agora/filter'
295
+ qb.whereILike('name', `%${escapeLike(userInput)}%`)
296
+ ```
297
+
298
+ The adapter escapes `%`, `_` and `\` on every string operator it emits, but a
299
+ pattern you interpolate yourself skips that protection entirely.
300
+
301
+ Source: docs/guides/operators.mdx (Security — LIKE escaping)
302
+
303
+ See also: `../filter-querying/SKILL.md` (cursor pagination and search route
304
+ through this same boundary), `../filter-basics/SKILL.md` (endpoint wiring).