sveltekit-admin 0.2.0 → 0.5.0

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