sveltekit-admin 0.2.1 → 0.5.3

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/dist/index.d.ts +2 -7
  4. package/dist/index.js +2 -13
  5. package/dist/server/auth.d.ts +7 -0
  6. package/dist/server/auth.js +19 -0
  7. package/dist/server/data.d.ts +38 -0
  8. package/dist/server/data.js +116 -0
  9. package/dist/server/handler.d.ts +123 -4
  10. package/dist/server/handler.js +600 -819
  11. package/dist/server/introspection/parser.d.ts +19 -12
  12. package/dist/server/introspection/parser.js +71 -61
  13. package/dist/server/introspection/relations.d.ts +49 -0
  14. package/dist/server/introspection/relations.js +128 -0
  15. package/dist/server/query/filterDetection.d.ts +71 -0
  16. package/dist/server/query/filterDetection.js +153 -0
  17. package/dist/server/query/listQuery.d.ts +92 -0
  18. package/dist/server/query/listQuery.js +442 -0
  19. package/dist/server/query/urls.d.ts +33 -0
  20. package/dist/server/query/urls.js +58 -0
  21. package/dist/server/router.d.ts +6 -0
  22. package/dist/server/router.js +27 -0
  23. package/dist/server/views/Dashboard.svelte +29 -0
  24. package/dist/server/views/Dashboard.svelte.d.ts +15 -0
  25. package/dist/server/views/FieldInput.svelte +63 -0
  26. package/dist/server/views/FieldInput.svelte.d.ts +9 -0
  27. package/dist/server/views/Form.svelte +127 -0
  28. package/dist/server/views/Form.svelte.d.ts +12 -0
  29. package/dist/server/views/Layout.svelte +78 -0
  30. package/dist/server/views/Layout.svelte.d.ts +13 -0
  31. package/dist/server/views/List.svelte +240 -0
  32. package/dist/server/views/List.svelte.d.ts +27 -0
  33. package/dist/server/views/ListFilters.svelte +257 -0
  34. package/dist/server/views/ListFilters.svelte.d.ts +18 -0
  35. package/dist/server/views/ModelCard.svelte +11 -0
  36. package/dist/server/views/ModelCard.svelte.d.ts +8 -0
  37. package/dist/server/views/NotFound.svelte +7 -0
  38. package/dist/server/views/NotFound.svelte.d.ts +7 -0
  39. package/dist/server/views/RelatedBlock.svelte +74 -0
  40. package/dist/server/views/RelatedBlock.svelte.d.ts +11 -0
  41. package/dist/server/views/RelationCheckboxes.svelte +53 -0
  42. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  43. package/dist/server/views/RelationSelect.svelte +53 -0
  44. package/dist/server/views/RelationSelect.svelte.d.ts +12 -0
  45. package/dist/server/views/StatCard.svelte +18 -0
  46. package/dist/server/views/StatCard.svelte.d.ts +8 -0
  47. package/dist/server/views/html.d.ts +5 -0
  48. package/dist/server/views/html.js +41 -0
  49. package/dist/server/views/theme.d.ts +1 -0
  50. package/dist/server/views/theme.js +537 -0
  51. package/dist/server/views/types.d.ts +57 -0
  52. package/dist/server/views/types.js +1 -0
  53. package/package.json +24 -26
  54. package/dist/admin.d.ts +0 -227
  55. package/dist/admin.js +0 -369
  56. package/dist/components/AdminForm.svelte +0 -423
  57. package/dist/components/AdminForm.svelte.d.ts +0 -30
  58. package/dist/components/AdminLayout.svelte +0 -328
  59. package/dist/components/AdminLayout.svelte.d.ts +0 -20
  60. package/dist/components/DataTable.svelte +0 -573
  61. package/dist/components/DataTable.svelte.d.ts +0 -25
  62. package/dist/components/index.d.ts +0 -3
  63. package/dist/components/index.js +0 -3
  64. package/dist/server/auth/guard.d.ts +0 -36
  65. package/dist/server/auth/guard.js +0 -38
  66. package/dist/server/auth/index.d.ts +0 -1
  67. package/dist/server/auth/index.js +0 -1
  68. package/dist/server/crud/index.d.ts +0 -1
  69. package/dist/server/crud/index.js +0 -1
  70. package/dist/server/crud/operations.d.ts +0 -87
  71. package/dist/server/crud/operations.js +0 -276
  72. package/dist/server/introspection/index.d.ts +0 -1
  73. package/dist/server/introspection/index.js +0 -1
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Query string parsing and Prisma `where` construction for list search
3
+ * and filters.
4
+ *
5
+ * Design reference: docs/design/list-search-filters.md.
6
+ *
7
+ * Golden rule (§4.3 of the design doc): the query string SELECTS from a
8
+ * finite whitelist of operators derived from the field's type. It never
9
+ * describes a Prisma clause. No operator ever flows from the URL into the
10
+ * `where` object as a key — the operator string from the URL is only ever
11
+ * used to look up a fixed table; the table's value (never the URL's raw
12
+ * string) becomes the Prisma operator key.
13
+ */
14
+ import type { PrismaModel } from '../introspection/parser.js';
15
+ export type FilterOp = 'equals' | 'contains' | 'startsWith' | 'gte' | 'lte' | 'isnull';
16
+ export interface ActiveFilter {
17
+ field: string;
18
+ op: FilterOp;
19
+ /** Already coerced to the JS type Prisma expects for this field/op. */
20
+ value: unknown;
21
+ /** Original string from the query string, kept to re-render the UI. */
22
+ raw: string;
23
+ }
24
+ export interface IgnoredFilter {
25
+ /** Raw query param key, e.g. "f.passwordHash" or "f.nope__gte". */
26
+ param: string;
27
+ reason: 'unknown-field' | 'not-filterable' | 'bad-operator' | 'bad-value';
28
+ }
29
+ export interface ListQuery {
30
+ q: string | null;
31
+ searchFields: string[];
32
+ filters: ActiveFilter[];
33
+ ignored: IgnoredFilter[];
34
+ }
35
+ /**
36
+ * Field-name candidates for the default search heuristic (§2.1). Relation-label
37
+ * resolution (handler.ts) keeps its own separate list — the two are not shared.
38
+ */
39
+ export declare const DEFAULT_LABEL_FIELDS: string[];
40
+ /**
41
+ * Fields eligible for the free-text search box.
42
+ *
43
+ * Explicit `searchFields` config always wins. Otherwise: String fields,
44
+ * not sensitive, not relation/list/id, whose name is in `labelFields`
45
+ * (same list used for relation labels — one heuristic, not two that could
46
+ * drift apart). Empty result means "no search box rendered", never a
47
+ * fallback that scans every String column.
48
+ *
49
+ * `hidden` excludes a field in EVERY case, config included (§3.5): a
50
+ * field hidden from the list/form display must not remain a value-
51
+ * confirmation oracle via `?q=`/`contains` just because a developer
52
+ * explicitly listed it in `searchFields` — that's the exact §0.a leak
53
+ * closed for sensitive-by-name fields, `hidden` is a second independent
54
+ * source that must close the same way (§10).
55
+ */
56
+ export declare function resolveSearchFields(model: PrismaModel, configured: string[] | undefined, labelFields?: string[], hidden?: Set<string>): string[];
57
+ export interface DateRange {
58
+ gte: Date;
59
+ lt: Date;
60
+ }
61
+ /**
62
+ * DateTime shortcuts, à la Django. Upper bound is always EXCLUSIVE (`lt`),
63
+ * never `lte`: `lte 23:59:59.000` misses the last second's milliseconds,
64
+ * a classic bug invisible in tests unless caught explicitly (§5.5).
65
+ *
66
+ * `now` is injectable so tests are deterministic (not "will break at
67
+ * midnight UTC in CI").
68
+ */
69
+ export declare function resolveDateShortcut(raw: string, now?: () => Date): DateRange | undefined;
70
+ /**
71
+ * Parse `?q=` and `?f.*=` into a `ListQuery`. Pure function: no I/O, no
72
+ * Prisma. `filterableFields` is the set of field names the caller allows
73
+ * to be filtered (already validated against config/heuristics + the
74
+ * shared sensitive-name predicate) — this function does not decide
75
+ * *which* fields are filterable, only how to parse a value once a field
76
+ * is known to be eligible.
77
+ */
78
+ export declare function parseListQuery(searchParams: URLSearchParams, model: PrismaModel, enums: Map<string, string[]>, searchFields: string[], filterableFields: Set<string>, now?: () => Date): ListQuery;
79
+ /** A Prisma `where` clause built from a `ListQuery`. Opaque to callers — pass straight to Prisma. */
80
+ export type PrismaWhere = Record<string, unknown>;
81
+ /**
82
+ * Compose the final Prisma `where`: `AND: [scope, ...filters, {OR: search}]`.
83
+ * NEVER a spread — a spread of `{...scope, ...filterWhere}` lets a filter
84
+ * on the same field as the developer's scoping silently overwrite it
85
+ * (docs/design §0.c, the exact IDOR the previous `?filter=` had). Two
86
+ * clauses on the same field inside `AND` intersect; they never merge.
87
+ *
88
+ * Returns `undefined` when nothing is active, so the query shape sent to
89
+ * Prisma is byte-for-byte identical to today's unfiltered call — no
90
+ * regression on existing snapshots/assertions.
91
+ */
92
+ export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): PrismaWhere | undefined;
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Query string parsing and Prisma `where` construction for list search
3
+ * and filters.
4
+ *
5
+ * Design reference: docs/design/list-search-filters.md.
6
+ *
7
+ * Golden rule (§4.3 of the design doc): the query string SELECTS from a
8
+ * finite whitelist of operators derived from the field's type. It never
9
+ * describes a Prisma clause. No operator ever flows from the URL into the
10
+ * `where` object as a key — the operator string from the URL is only ever
11
+ * used to look up a fixed table; the table's value (never the URL's raw
12
+ * string) becomes the Prisma operator key.
13
+ */
14
+ import { isSensitiveFieldName } from '../introspection/parser.js';
15
+ /** Max length accepted for the free-text search term. Longer input is truncated. */
16
+ const MAX_SEARCH_LENGTH = 200;
17
+ /**
18
+ * Field-name candidates for the default search heuristic (§2.1). Relation-label
19
+ * resolution (handler.ts) keeps its own separate list — the two are not shared.
20
+ */
21
+ export const DEFAULT_LABEL_FIELDS = [
22
+ 'name',
23
+ 'title',
24
+ 'label',
25
+ 'email',
26
+ 'username',
27
+ 'slug',
28
+ 'description',
29
+ 'content',
30
+ 'body',
31
+ 'text'
32
+ ];
33
+ /** Types eligible for the free-text search heuristic (String only, see §2.1). */
34
+ function isSearchableByHeuristic(field) {
35
+ return (field.type === 'String' &&
36
+ !field.isList &&
37
+ !field.relation &&
38
+ !isSensitiveFieldName(field.name) &&
39
+ !field.isId);
40
+ }
41
+ /**
42
+ * Fields eligible for the free-text search box.
43
+ *
44
+ * Explicit `searchFields` config always wins. Otherwise: String fields,
45
+ * not sensitive, not relation/list/id, whose name is in `labelFields`
46
+ * (same list used for relation labels — one heuristic, not two that could
47
+ * drift apart). Empty result means "no search box rendered", never a
48
+ * fallback that scans every String column.
49
+ *
50
+ * `hidden` excludes a field in EVERY case, config included (§3.5): a
51
+ * field hidden from the list/form display must not remain a value-
52
+ * confirmation oracle via `?q=`/`contains` just because a developer
53
+ * explicitly listed it in `searchFields` — that's the exact §0.a leak
54
+ * closed for sensitive-by-name fields, `hidden` is a second independent
55
+ * source that must close the same way (§10).
56
+ */
57
+ export function resolveSearchFields(model, configured, labelFields = DEFAULT_LABEL_FIELDS, hidden = new Set()) {
58
+ if (configured) {
59
+ return configured.filter((name) => {
60
+ const field = model.fields.find((f) => f.name === name);
61
+ return field && isFilterableFieldType(field) && !isSensitiveFieldName(name) && !hidden.has(name);
62
+ });
63
+ }
64
+ return model.fields
65
+ .filter((f) => isSearchableByHeuristic(f) && labelFields.includes(f.name) && !hidden.has(f.name))
66
+ .map((f) => f.name);
67
+ }
68
+ /** Whether a field's Prisma type can ever appear in a where clause we build (excludes Json/Bytes/relations/lists). */
69
+ function isFilterableFieldType(field) {
70
+ if (field.relation || field.isList)
71
+ return false;
72
+ return !['Json', 'Bytes'].includes(field.type);
73
+ }
74
+ /**
75
+ * Whitelist of operators per Prisma scalar type. The URL provides a
76
+ * *string* op name; this table is the only place that turns it into a
77
+ * real Prisma operator. Anything not listed here for the field's type is
78
+ * rejected (§4.3).
79
+ */
80
+ function allowedOpsFor(field) {
81
+ if (field.isEnum)
82
+ return ['equals'];
83
+ switch (field.type) {
84
+ case 'String':
85
+ return ['equals', 'contains', 'startsWith'];
86
+ case 'Int':
87
+ case 'BigInt':
88
+ case 'Float':
89
+ case 'Decimal':
90
+ return ['equals', 'gte', 'lte'];
91
+ case 'Boolean':
92
+ return ['equals'];
93
+ case 'DateTime':
94
+ return ['equals', 'gte', 'lte'];
95
+ default:
96
+ return [];
97
+ }
98
+ }
99
+ /**
100
+ * Coerce a raw query-string value to the JS type Prisma expects, or return
101
+ * `undefined` if it can't be coerced.
102
+ *
103
+ * DateTime `equals`/`gte`/`lte` and enum membership are handled entirely
104
+ * inside `parseOneFilter` and never reach this function; `allowedOpsFor`
105
+ * already returns `[]` for Json/Bytes/relations/lists, so `parseOneFilter`
106
+ * rejects those before reaching this function too. `isnull` DOES reach
107
+ * here (it's field-type-agnostic), which is why it's handled first.
108
+ */
109
+ function coerceValue(field, op, raw) {
110
+ if (op === 'isnull') {
111
+ if (raw === '1' || raw === 'true')
112
+ return true;
113
+ if (raw === '0' || raw === 'false')
114
+ return false;
115
+ return undefined;
116
+ }
117
+ switch (field.type) {
118
+ case 'String':
119
+ return raw;
120
+ case 'Int':
121
+ case 'BigInt': {
122
+ // Strict integer pattern — NOT parseInt: parseInt("12abc") === 12 and
123
+ // parseInt("") is NaN silently. Both are real bugs the previous
124
+ // `?filter=` implementation had (see docs/design §0.b).
125
+ if (!/^-?\d+$/.test(raw))
126
+ return undefined;
127
+ if (field.type === 'BigInt') {
128
+ // `BigInt(str)` never throws once `raw` has already matched the
129
+ // strict integer regex above — no try/catch needed, and a "just
130
+ // in case" catch here would be unreachable code the coverage
131
+ // threshold would force us to fake-test.
132
+ return BigInt(raw);
133
+ }
134
+ const n = Number(raw);
135
+ return Number.isSafeInteger(n) ? n : undefined;
136
+ }
137
+ case 'Float':
138
+ case 'Decimal': {
139
+ if (!/^-?\d+(\.\d+)?$/.test(raw))
140
+ return undefined;
141
+ // Decimal is passed to Prisma as a string to avoid precision loss on
142
+ // a round-trip through JS `Number`; Float uses the numeric value.
143
+ return field.type === 'Decimal' ? raw : Number(raw);
144
+ }
145
+ case 'Boolean':
146
+ default:
147
+ // Boolean is the only remaining branch reachable in practice; the
148
+ // `default` exists only so TypeScript accepts a non-exhaustive
149
+ // switch over `string`, it carries no distinct behaviour.
150
+ if (raw === 'true' || raw === '1')
151
+ return true;
152
+ if (raw === 'false' || raw === '0')
153
+ return false;
154
+ return undefined;
155
+ }
156
+ }
157
+ /**
158
+ * DateTime shortcuts, à la Django. Upper bound is always EXCLUSIVE (`lt`),
159
+ * never `lte`: `lte 23:59:59.000` misses the last second's milliseconds,
160
+ * a classic bug invisible in tests unless caught explicitly (§5.5).
161
+ *
162
+ * `now` is injectable so tests are deterministic (not "will break at
163
+ * midnight UTC in CI").
164
+ */
165
+ export function resolveDateShortcut(raw, now = () => new Date()) {
166
+ const today = () => {
167
+ const d = now();
168
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
169
+ };
170
+ const addDays = (d, n) => new Date(d.getTime() + n * 86_400_000);
171
+ if (raw === 'today') {
172
+ const start = today();
173
+ return { gte: start, lt: addDays(start, 1) };
174
+ }
175
+ if (raw === '7d') {
176
+ const end = addDays(today(), 1);
177
+ return { gte: addDays(today(), -6), lt: end };
178
+ }
179
+ if (raw === 'month') {
180
+ const d = now();
181
+ const start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
182
+ const end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
183
+ return { gte: start, lt: end };
184
+ }
185
+ if (raw === 'year') {
186
+ const d = now();
187
+ const start = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
188
+ const end = new Date(Date.UTC(d.getUTCFullYear() + 1, 0, 1));
189
+ return { gte: start, lt: end };
190
+ }
191
+ // A single ISO date (no time component): treat as a day-long interval,
192
+ // never as `equals` — a DateTime stores a time, so `equals` on a bare
193
+ // date never matches anything (§5.5).
194
+ const dayMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
195
+ if (dayMatch) {
196
+ const [, y, m, d] = dayMatch;
197
+ const year = Number(y);
198
+ const month = Number(m);
199
+ const day = Number(d);
200
+ // `Date.UTC` silently rolls over out-of-range components (month 13 ->
201
+ // January next year); reject explicitly instead of trusting it.
202
+ if (month < 1 || month > 12 || day < 1 || day > 31)
203
+ return undefined;
204
+ const start = new Date(Date.UTC(year, month - 1, day));
205
+ if (Number.isNaN(start.getTime()) ||
206
+ start.getUTCFullYear() !== year ||
207
+ start.getUTCMonth() !== month - 1 ||
208
+ start.getUTCDate() !== day) {
209
+ return undefined;
210
+ }
211
+ return { gte: start, lt: addDays(start, 1) };
212
+ }
213
+ return undefined;
214
+ }
215
+ /** Parse a `gte`/`lte` DateTime bound: full ISO datetime or a bare date. */
216
+ function parseDateBound(raw) {
217
+ const d = new Date(raw);
218
+ return Number.isNaN(d.getTime()) ? undefined : d;
219
+ }
220
+ /**
221
+ * Split a `f.<field>` or `f.<field>__<op>` param name into its parts.
222
+ * Cuts on the FIRST `__` after the `f.` prefix — Prisma identifiers can't
223
+ * contain `__` at all in practice for this parser's purposes, so this is
224
+ * unambiguous.
225
+ *
226
+ * Always called with a key that already starts with `f.` — both call
227
+ * sites in `parseListQuery` guarantee it (one filters on it explicitly,
228
+ * the other builds the string literally for the legacy `?filter=` path) —
229
+ * so there's no `null`-returning guard here to fake-test.
230
+ */
231
+ function splitFilterParam(key) {
232
+ const rest = key.slice(2);
233
+ const sep = rest.indexOf('__');
234
+ if (sep === -1)
235
+ return { field: rest, op: null };
236
+ return { field: rest.slice(0, sep), op: rest.slice(sep + 2) };
237
+ }
238
+ function parseOneFilter(param, raw, ctx) {
239
+ // Empty value (e.g. the "All" option of a <select> FK/range filter,
240
+ // submitted via a plain GET form) means "no filter" — the design's
241
+ // nominal, expected case (§5.4: "?f.x= -> ignored. Important: it's
242
+ // what a <select> emits for its 'All' option"). This is checked BEFORE
243
+ // any field lookup and produces NEITHER an ActiveFilter NOR an
244
+ // IgnoredFilter: it must never surface as a rendered "unknown field"
245
+ // warning (that was a real regression found in review — classifying
246
+ // this as `ignored` made the UI show a false error banner on the most
247
+ // common sidebar interaction there is).
248
+ if (raw === '') {
249
+ return { skip: true };
250
+ }
251
+ const { field: fieldName, op: opParam } = splitFilterParam(param);
252
+ const field = ctx.model.fields.find((f) => f.name === fieldName);
253
+ // Sensitive field: rejected here too, not just by the caller's
254
+ // `filterableFields` set — defense in depth. Same predicate as
255
+ // `getDisplayFields`/relation labels (isSensitiveFieldName), and the
256
+ // exact fix for the oracle described in docs/design §0.a: a sensitive
257
+ // field is treated EXACTLY like an unknown one, never a distinct
258
+ // "forbidden" message that would confirm its existence.
259
+ if (!field || !ctx.filterableFields.has(fieldName) || isSensitiveFieldName(fieldName)) {
260
+ return { ignored: { param, reason: 'unknown-field' } };
261
+ }
262
+ const allowed = allowedOpsFor(field);
263
+ if (allowed.length === 0) {
264
+ return { ignored: { param, reason: 'not-filterable' } };
265
+ }
266
+ const wantsIsnull = opParam === 'isnull';
267
+ const op = wantsIsnull ? 'isnull' : (opParam || 'equals');
268
+ if (op === 'isnull') {
269
+ if (field.isRequired)
270
+ return { ignored: { param, reason: 'not-filterable' } };
271
+ }
272
+ else if (!allowed.includes(op)) {
273
+ return { ignored: { param, reason: 'bad-operator' } };
274
+ }
275
+ if (field.type === 'DateTime' && op !== 'isnull') {
276
+ if (op === 'equals') {
277
+ const range = resolveDateShortcut(raw, ctx.now);
278
+ if (!range)
279
+ return { ignored: { param, reason: 'bad-value' } };
280
+ // A day/shortcut becomes two filters merged by the caller into one
281
+ // AND pair; represented here as a single filter carrying both
282
+ // bounds so buildWhere can emit `{gte, lt}` from one ActiveFilter.
283
+ return {
284
+ filter: { field: fieldName, op: 'gte', value: range, raw }
285
+ };
286
+ }
287
+ const bound = parseDateBound(raw);
288
+ if (!bound)
289
+ return { ignored: { param, reason: 'bad-value' } };
290
+ return { filter: { field: fieldName, op, value: bound, raw } };
291
+ }
292
+ if (field.isEnum) {
293
+ const members = ctx.enums.get(field.type) ?? [];
294
+ if (!members.includes(raw))
295
+ return { ignored: { param, reason: 'bad-value' } };
296
+ return { filter: { field: fieldName, op: 'equals', value: raw, raw } };
297
+ }
298
+ const value = coerceValue(field, op, raw);
299
+ if (value === undefined)
300
+ return { ignored: { param, reason: 'bad-value' } };
301
+ return { filter: { field: fieldName, op, value, raw } };
302
+ }
303
+ /**
304
+ * Parse `?q=` and `?f.*=` into a `ListQuery`. Pure function: no I/O, no
305
+ * Prisma. `filterableFields` is the set of field names the caller allows
306
+ * to be filtered (already validated against config/heuristics + the
307
+ * shared sensitive-name predicate) — this function does not decide
308
+ * *which* fields are filterable, only how to parse a value once a field
309
+ * is known to be eligible.
310
+ */
311
+ export function parseListQuery(searchParams, model, enums, searchFields, filterableFields, now) {
312
+ const rawQ = searchParams.get('q');
313
+ const q = rawQ && rawQ.trim() ? rawQ.trim().slice(0, MAX_SEARCH_LENGTH) : null;
314
+ const ctx = { model, enums, searchFields, filterableFields, now };
315
+ const filters = [];
316
+ const ignored = [];
317
+ for (const [key] of searchParams) {
318
+ if (!key.startsWith('f.'))
319
+ continue;
320
+ const raw = searchParams.get(key);
321
+ const result = parseOneFilter(key, raw, ctx);
322
+ if ('filter' in result)
323
+ filters.push(result.filter);
324
+ else if ('ignored' in result)
325
+ ignored.push(result.ignored);
326
+ // else: { skip: true } — empty "All" value, silently dropped, not
327
+ // even recorded in `ignored` (see parseOneFilter's comment).
328
+ }
329
+ // Legacy `?filter=field:value` — routed through the exact same
330
+ // whitelist/coercion path as `f.*`, so it inherits the security fix
331
+ // (docs/design §4.4, §0.a). If both are present for the same field,
332
+ // `f.*` wins (parsed above, so it's already in `filters`); the legacy
333
+ // value is only added if that field has no `f.*` entry.
334
+ const legacy = searchParams.get('filter');
335
+ if (legacy && legacy.includes(':')) {
336
+ const sep = legacy.indexOf(':');
337
+ const legacyField = legacy.slice(0, sep);
338
+ const legacyValue = legacy.slice(sep + 1);
339
+ const alreadyHasField = filters.some((f) => f.field === legacyField);
340
+ if (!alreadyHasField) {
341
+ const result = parseOneFilter(`f.${legacyField}`, legacyValue, ctx);
342
+ if ('filter' in result)
343
+ filters.push(result.filter);
344
+ else if ('ignored' in result)
345
+ ignored.push({ param: 'filter', reason: result.ignored.reason });
346
+ // else: { skip: true } — an empty legacy value (`?filter=field:`)
347
+ // is likewise a no-op, never surfaced as an "ignored" warning.
348
+ }
349
+ }
350
+ return { q, searchFields, filters, ignored };
351
+ }
352
+ function clauseOf(filter) {
353
+ if (filter.op === 'gte' && filter.value && typeof filter.value === 'object' && 'gte' in filter.value) {
354
+ // Date shortcut carrying both bounds (see parseOneFilter's DateTime branch).
355
+ const range = filter.value;
356
+ return { [filter.field]: { gte: range.gte, lt: range.lt } };
357
+ }
358
+ if (filter.op === 'isnull') {
359
+ return { [filter.field]: filter.value ? { equals: null } : { not: null } };
360
+ }
361
+ if (filter.op === 'equals') {
362
+ return { [filter.field]: filter.value };
363
+ }
364
+ return { [filter.field]: { [filter.op]: filter.value } };
365
+ }
366
+ /**
367
+ * Compose the final Prisma `where`: `AND: [scope, ...filters, {OR: search}]`.
368
+ * NEVER a spread — a spread of `{...scope, ...filterWhere}` lets a filter
369
+ * on the same field as the developer's scoping silently overwrite it
370
+ * (docs/design §0.c, the exact IDOR the previous `?filter=` had). Two
371
+ * clauses on the same field inside `AND` intersect; they never merge.
372
+ *
373
+ * Returns `undefined` when nothing is active, so the query shape sent to
374
+ * Prisma is byte-for-byte identical to today's unfiltered call — no
375
+ * regression on existing snapshots/assertions.
376
+ */
377
+ export function buildWhere(query, scope, caseInsensitiveSearch, model) {
378
+ const and = [];
379
+ if (scope)
380
+ and.push(scope);
381
+ for (const f of query.filters)
382
+ and.push(clauseOf(f));
383
+ if (query.q && query.searchFields.length > 0) {
384
+ const or = [];
385
+ for (const fieldName of query.searchFields) {
386
+ const field = model.fields.find((f) => f.name === fieldName);
387
+ const clause = searchClauseFor(field, query.q, caseInsensitiveSearch);
388
+ if (clause)
389
+ or.push({ [fieldName]: clause });
390
+ }
391
+ // Never emit `{OR: []}` — in Prisma that matches nothing, which would
392
+ // silently turn "no searchable field" (or "every clause omitted", §2.4)
393
+ // into "empty result". A no-op search must add nothing to the where.
394
+ if (or.length > 0)
395
+ and.push({ OR: or });
396
+ }
397
+ if (and.length === 0)
398
+ return undefined;
399
+ if (and.length === 1)
400
+ return and[0];
401
+ return { AND: and };
402
+ }
403
+ /**
404
+ * The per-field-type clause for a `searchFields` entry (§2.4):
405
+ * - String @id -> `equals` (a `contains` on a cuid/uuid can't use the
406
+ * index and never makes semantic sense; §2.1 talks ONLY about the id
407
+ * here — an earlier version of this function over-generalized to
408
+ * `@id || @unique`, which silently broke fragment search on the most
409
+ * common real-world case: `email`/`slug` fields are `@unique` in
410
+ * nearly every Prisma schema and are exactly what §2.3's "a title, an
411
+ * email" example means by free-text search. `@unique` alone is NOT a
412
+ * reason to switch to `equals` — only `@id` is).
413
+ * - other String (including @unique) -> `contains` (+ `mode:
414
+ * 'insensitive'` when the provider supports it).
415
+ * - Int/BigInt/Float/Decimal -> `equals` if `q` coerces to that type,
416
+ * otherwise the clause is OMITTED — never `contains` on a numeric
417
+ * column, which Prisma rejects with a hard error (`Unknown argument
418
+ * contains`), turning any legitimate `?q=` into a 500 (§10's known
419
+ * trap, discovered via review — the original implementation searched
420
+ * this exactly wrong).
421
+ * - anything else (enum, Boolean, DateTime, relation, Json/Bytes):
422
+ * omitted. `resolveSearchFields`'s auto heuristic never proposes these,
423
+ * but explicit `searchFields` config isn't type-checked against §2.4 at
424
+ * boot (only against `isFilterableFieldType`), so this is reached in
425
+ * practice for a misconfigured field — degrading to "omitted" here
426
+ * keeps the guarantee that a legitimate URL never 500s, without adding
427
+ * a boot-time validation pass this design doc doesn't ask for.
428
+ */
429
+ function searchClauseFor(field, q, caseInsensitiveSearch) {
430
+ if (!field)
431
+ return undefined;
432
+ if (field.type === 'String') {
433
+ if (field.isId)
434
+ return { equals: q };
435
+ return caseInsensitiveSearch ? { contains: q, mode: 'insensitive' } : { contains: q };
436
+ }
437
+ if (['Int', 'BigInt', 'Float', 'Decimal'].includes(field.type)) {
438
+ const coerced = coerceValue(field, 'equals', q);
439
+ return coerced === undefined ? undefined : { equals: coerced };
440
+ }
441
+ return undefined;
442
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * URL helpers shared by every piece of UI that builds a list-view URL:
3
+ * the search box, the filter sidebar links, and pagination.
4
+ *
5
+ * Every list URL MUST go through `buildListUrl` — including pagination,
6
+ * which today builds `?page=N` by hand. A hand-rolled URL bypasses the
7
+ * "always drop `page` on filter change" invariant (docs/design §3.3, §7.1)
8
+ * and risks parameter-order drift, which makes snapshots flaky.
9
+ */
10
+ /**
11
+ * Build a list-view URL from the current one, applying a patch of query
12
+ * params. `null` in the patch removes that key. `page` is ALWAYS dropped
13
+ * unless the patch explicitly sets it (pagination is the one caller that
14
+ * does) — every other caller (search box, filter link) resets to page 1
15
+ * implicitly by omitting `page` from its patch.
16
+ *
17
+ * Keys are sorted before serialization so the resulting URL is
18
+ * deterministic — required for stable snapshot tests, and incidentally
19
+ * nicer to read/bookmark.
20
+ */
21
+ export declare function buildListUrl(currentUrl: URL, patch: Record<string, string | null>): string;
22
+ /**
23
+ * Hidden `<input>` params to re-emit inside a `<form method="GET">` so
24
+ * submitting it doesn't wipe out every other active param — a bare GET
25
+ * form REPLACES the whole query string with just its own fields (docs/design
26
+ * §3.3). `exclude` lists the param(s) the form itself controls (e.g. `q`
27
+ * for the search box, `f.published` for a single-field filter form);
28
+ * `page` is always excluded too, since any new search/filter resets it.
29
+ */
30
+ export declare function hiddenParams(currentUrl: URL, exclude: string[]): {
31
+ name: string;
32
+ value: string;
33
+ }[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * URL helpers shared by every piece of UI that builds a list-view URL:
3
+ * the search box, the filter sidebar links, and pagination.
4
+ *
5
+ * Every list URL MUST go through `buildListUrl` — including pagination,
6
+ * which today builds `?page=N` by hand. A hand-rolled URL bypasses the
7
+ * "always drop `page` on filter change" invariant (docs/design §3.3, §7.1)
8
+ * and risks parameter-order drift, which makes snapshots flaky.
9
+ */
10
+ /**
11
+ * Build a list-view URL from the current one, applying a patch of query
12
+ * params. `null` in the patch removes that key. `page` is ALWAYS dropped
13
+ * unless the patch explicitly sets it (pagination is the one caller that
14
+ * does) — every other caller (search box, filter link) resets to page 1
15
+ * implicitly by omitting `page` from its patch.
16
+ *
17
+ * Keys are sorted before serialization so the resulting URL is
18
+ * deterministic — required for stable snapshot tests, and incidentally
19
+ * nicer to read/bookmark.
20
+ */
21
+ export function buildListUrl(currentUrl, patch) {
22
+ const params = new URLSearchParams(currentUrl.search);
23
+ if (!('page' in patch)) {
24
+ params.delete('page');
25
+ }
26
+ for (const [key, value] of Object.entries(patch)) {
27
+ if (value === null)
28
+ params.delete(key);
29
+ else
30
+ params.set(key, value);
31
+ }
32
+ const sorted = new URLSearchParams();
33
+ for (const key of [...params.keys()].sort()) {
34
+ for (const value of params.getAll(key)) {
35
+ sorted.append(key, value);
36
+ }
37
+ }
38
+ const qs = sorted.toString();
39
+ return qs ? `${currentUrl.pathname}?${qs}` : currentUrl.pathname;
40
+ }
41
+ /**
42
+ * Hidden `<input>` params to re-emit inside a `<form method="GET">` so
43
+ * submitting it doesn't wipe out every other active param — a bare GET
44
+ * form REPLACES the whole query string with just its own fields (docs/design
45
+ * §3.3). `exclude` lists the param(s) the form itself controls (e.g. `q`
46
+ * for the search box, `f.published` for a single-field filter form);
47
+ * `page` is always excluded too, since any new search/filter resets it.
48
+ */
49
+ export function hiddenParams(currentUrl, exclude) {
50
+ const excluded = new Set([...exclude, 'page']);
51
+ const out = [];
52
+ for (const [key, value] of currentUrl.searchParams) {
53
+ if (excluded.has(key))
54
+ continue;
55
+ out.push({ name: key, value });
56
+ }
57
+ return out;
58
+ }
@@ -0,0 +1,6 @@
1
+ export interface ParsedRoute {
2
+ view: 'dashboard' | 'list' | 'create' | 'edit' | 'notFound' | 'search' | 'logout';
3
+ model?: string;
4
+ id?: string;
5
+ }
6
+ export declare function parseRoute(pathname: string, basePath: string): ParsedRoute;
@@ -0,0 +1,27 @@
1
+ export function parseRoute(pathname, basePath) {
2
+ // Le `replace` n'est PAS redondant avec le `filter(Boolean)` plus bas : il est ce
3
+ // qui fait que `/admin/` et `/admin///` donnent un `path` vide, donc le dashboard.
4
+ // Sans lui, `path` vaudrait '/' — truthy — et le chemin tomberait sur `notFound`.
5
+ const path = pathname.slice(basePath.length).replace(/^\/+|\/+$/g, '');
6
+ if (!path) {
7
+ return { view: 'dashboard' };
8
+ }
9
+ const segments = path.split('/').filter(Boolean);
10
+ if (segments.length === 1) {
11
+ if (segments[0] === '_search') {
12
+ return { view: 'search' };
13
+ }
14
+ if (segments[0] === '_logout') {
15
+ return { view: 'logout' };
16
+ }
17
+ return { view: 'list', model: segments[0] };
18
+ }
19
+ if (segments.length === 2) {
20
+ if (segments[1] === 'new') {
21
+ return { view: 'create', model: segments[0] };
22
+ }
23
+ return { view: 'edit', model: segments[0], id: segments[1] };
24
+ }
25
+ // 3 segments ou plus : aucune vue ne correspond.
26
+ return { view: 'notFound' };
27
+ }