sveltekit-admin 0.6.0 → 0.8.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.
Files changed (45) hide show
  1. package/README.md +61 -4
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +1 -1
  4. package/dist/server/adapters/drizzle/dataAdapter.js +121 -36
  5. package/dist/server/adapters/drizzle/index.d.ts +10 -1
  6. package/dist/server/adapters/drizzle/index.js +4 -0
  7. package/dist/server/adapters/prisma/dataAdapter.js +52 -10
  8. package/dist/server/adapters/prisma/handler.d.ts +14 -0
  9. package/dist/server/adapters/prisma/handler.js +37 -0
  10. package/dist/server/adapters/retry.d.ts +27 -0
  11. package/dist/server/adapters/retry.js +53 -0
  12. package/dist/server/adapters/types.d.ts +9 -2
  13. package/dist/server/audit.d.ts +65 -0
  14. package/dist/server/audit.js +106 -0
  15. package/dist/server/csrf.d.ts +33 -0
  16. package/dist/server/csrf.js +55 -0
  17. package/dist/server/errors.d.ts +47 -0
  18. package/dist/server/errors.js +90 -0
  19. package/dist/server/handler.d.ts +57 -26
  20. package/dist/server/handler.js +212 -561
  21. package/dist/server/mutations.d.ts +9 -0
  22. package/dist/server/mutations.js +295 -0
  23. package/dist/server/plugin.d.ts +47 -0
  24. package/dist/server/plugin.js +1 -0
  25. package/dist/server/pluginAccess.d.ts +7 -0
  26. package/dist/server/pluginAccess.js +79 -0
  27. package/dist/server/pluginRegistry.d.ts +12 -0
  28. package/dist/server/pluginRegistry.js +72 -0
  29. package/dist/server/query/listQuery.d.ts +1 -1
  30. package/dist/server/relationLoaders.d.ts +42 -0
  31. package/dist/server/relationLoaders.js +188 -0
  32. package/dist/server/router.d.ts +10 -0
  33. package/dist/server/router.js +42 -19
  34. package/dist/server/runtime.d.ts +46 -0
  35. package/dist/server/runtime.js +210 -0
  36. package/dist/server/search.d.ts +14 -0
  37. package/dist/server/search.js +78 -0
  38. package/dist/server/views/Form.svelte +26 -2
  39. package/dist/server/views/Form.svelte.d.ts +2 -1
  40. package/dist/server/views/Layout.svelte +27 -2
  41. package/dist/server/views/Layout.svelte.d.ts +2 -0
  42. package/dist/server/views/List.svelte +28 -3
  43. package/dist/server/views/List.svelte.d.ts +2 -2
  44. package/dist/server/views/types.d.ts +8 -0
  45. package/package.json +23 -21
@@ -3,392 +3,58 @@
3
3
  * Zero files needed in routes - everything handled via hook
4
4
  */
5
5
  import { render } from 'svelte/server';
6
- import { isSensitiveFieldName } from './introspection/parser.js';
7
- import { buildRelationGraph } from './introspection/relations.js';
8
- import { parseRoute } from './router.js';
9
- import { primaryKeyOf, coerceId, formDataToPrisma, paginate } from './data.js';
6
+ import { matchRoute, BUILTIN_ROUTES } from './router.js';
7
+ import { paginate, coerceId } from './data.js';
10
8
  import { parseListQuery, buildWhere, resolveSearchFields } from './query/listQuery.js';
11
- import { createPrismaIntrospector } from './adapters/prisma/introspector.js';
12
- import { createPrismaDataAdapter } from './adapters/prisma/dataAdapter.js';
13
- import { resolveCaseInsensitiveSearch } from './adapters/prisma/index.js';
14
- import { normalizeScope, OPAQUE_FILTER_ERROR } from './adapters/filter.js';
15
- import { resolveListFilters, validateListFilterConfig, findFkEdge } from './query/filterDetection.js';
9
+ import { normalizeScope } from './adapters/filter.js';
10
+ import { resolveListFilters } from './query/filterDetection.js';
16
11
  import { escapeHtml, toLabel } from './views/html.js';
17
12
  import NotFound from './views/NotFound.svelte';
18
13
  import Layout from './views/Layout.svelte';
19
14
  import Dashboard from './views/Dashboard.svelte';
20
15
  import Form from './views/Form.svelte';
21
16
  import List from './views/List.svelte';
22
- const PER_PAGE = 20;
23
- function scopeFrom(relConfig, ctx) {
24
- return relConfig?.where ? normalizeScope(relConfig.where(ctx)) : undefined;
25
- }
17
+ import { createAdminRuntime, listScopeFrom, modelScopeFrom } from './runtime.js';
18
+ import { loadRelationOptions, resolveFkFilterOptions, loadRelatedCounts } from './relationLoaders.js';
19
+ import { handleSearch } from './search.js';
20
+ import { handleMutation } from './mutations.js';
21
+ import { AdminMutationError, AdminConfigError } from './errors.js';
22
+ import { verifyOrigin, resolveCsrfConfig } from './csrf.js';
23
+ import { resolvePluginRegistry, actionsForModel } from './pluginRegistry.js';
24
+ import { createPluginPageContext } from './pluginAccess.js';
26
25
  // ============================================
27
26
  // Main Handler
28
27
  // ============================================
29
28
  export function createAdminHandler(config) {
30
- const { prisma, prismaSchemaPath = './prisma/schema.prisma', basePath = '/admin', authCheck, logout, logoutRedirectTo = '/', exclude = [], hidePivotTables = true, models: modelsConfig = {} } = config;
31
- if (!config.adapter && !prisma) {
32
- throw new Error('[sveltekit-admin] createAdminHandler requires either `prisma` (with optional `prismaSchemaPath`) or `adapter` — neither was provided.');
33
- }
34
- const introspector = config.adapter?.introspector ?? createPrismaIntrospector({ schemaPath: prismaSchemaPath });
35
- // Introspect the schema once at startup — same failure handling as before:
36
- // a broken/missing schema source degrades to "no models known" rather than
37
- // throwing out of `createAdminHandler` itself.
38
- let schema = null;
39
- let relationGraph = null;
40
- try {
41
- const introspected = introspector.introspect();
42
- if (introspected instanceof Promise) {
43
- throw new Error('[sveltekit-admin] SchemaIntrospector.introspect() returned a Promise — ' +
44
- 'createAdminHandler only supports synchronous introspection today.');
45
- }
46
- schema = introspected;
47
- relationGraph = buildRelationGraph(schema);
48
- for (const d of relationGraph.diagnostics) {
49
- console.warn(`[sveltekit-admin] ${d}`);
50
- }
29
+ if (!config.adapter) {
30
+ throw new Error('[sveltekit-admin] createAdminHandler requires `adapter`.');
51
31
  }
52
- catch (e) {
53
- console.warn('[sveltekit-admin] Could not introspect schema:', e);
54
- }
55
- const filteredModels = schema?.models.filter((m) => {
56
- // Exclude explicitly excluded models
57
- if (exclude.includes(m.name))
58
- return false;
59
- // Exclude pivot tables if option is enabled
60
- if (hidePivotTables && m.isPivotTable)
61
- return false;
62
- return true;
63
- }) || [];
64
- // Valider `listFilter` au démarrage : une config invalide (champ
65
- // inexistant, sensible, relation, type non supporté) doit échouer fort
66
- // ici plutôt que produire silencieusement un filtre mort à chaque rendu
67
- // de liste (docs/design §8, même politique que le groupe ambigu de
68
- // relations.ts).
69
- const hiddenFieldsOf = (m) => new Set(modelsConfig[m.name]?.hidden ?? []);
70
- for (const m of filteredModels) {
71
- const entries = modelsConfig[m.name]?.listFilter;
72
- // Non-null par construction : `filteredModels` n'existe que si le schéma
73
- // a été parsé, et le graphe est construit dans la même branche de boot.
74
- if (entries)
75
- validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
76
- }
77
- const labelOf = (m) => {
78
- const configured = modelsConfig[m.name]?.label;
79
- if (configured)
80
- return configured;
81
- const label = toLabel(m.name);
82
- return label.charAt(0).toUpperCase() + label.slice(1);
83
- };
84
- const modelList = filteredModels.map((m) => ({ name: m.name, label: labelOf(m) }));
85
- const findModel = (name) => filteredModels.find((m) => m.name.toLowerCase() === name?.toLowerCase());
86
- const viewModel = (m) => ({
87
- name: m.name,
88
- label: labelOf(m),
89
- fields: m.fields,
90
- primaryKey: primaryKeyOf(m),
91
- // Non-null par construction : `m` vient toujours de `filteredModels`,
92
- // dérivé du schéma qu'on vient de parser avec succès.
93
- relationGraph: relationGraph
94
- });
95
- const redirectToList = (model) => new Response(null, {
96
- status: 303,
97
- headers: { Location: `${basePath}/${model.toLowerCase()}` }
98
- });
99
- const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
100
- const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
101
- const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
102
- 'name', 'title', 'label', 'email', 'username', 'slug'
103
- ];
104
- // `mode: 'insensitive'` n'est supporté par Prisma que sur
105
- // postgresql/cockroachdb/mongodb — l'émettre sur sqlite/mysql/sqlserver
106
- // lève une erreur Prisma dure. Détection auto via le provider extrait du
107
- // schéma ; `search.mode` permet de forcer le comportement (provider non
108
- // littéral dans le schéma, index citext, etc.). Voir docs/design §2.5.
109
- const caseInsensitiveSearch = resolveCaseInsensitiveSearch(schema, config.search?.mode);
110
- const adapter = config.adapter ?? { introspector, data: createPrismaDataAdapter(prisma, { caseInsensitiveSearch }) };
111
- /**
112
- * Champs qu'un `?f.<field>=` est autorisé à cibler pour ce modèle : tout
113
- * champ scalaire non-liste, non-relation, de type filtrable
114
- * (String/Int/Float/Decimal/BigInt/Boolean/DateTime/enum — donc pas
115
- * Json/Bytes), non sensible, et non listé dans `hidden` pour ce modèle.
116
- * Sans ce dernier point, `hidden: ['internalNotes']` ne fait que masquer
117
- * l'affichage : le champ reste un oracle de confirmation de valeur via
118
- * `?f.internalNotes=...contains...`, exactement la faille §0.a fermée
119
- * ailleurs pour les champs sensibles par nom — `hidden` et le prédicat
120
- * de sensibilité sont deux sources distinctes, toutes deux doivent
121
- * fermer l'oracle (docs/design §10, "deux sources, un seul prédicat
122
- * partagé, sinon divergence garantie"). Défense en profondeur :
123
- * `listQuery.ts` revérifie lui-même la sensibilité par nom, ce set est
124
- * la première passe et la seule à connaître la config `hidden`.
125
- */
126
- const resolveFilterableFields = (model) => {
127
- const hidden = hiddenFieldsOf(model);
128
- const out = new Set();
129
- for (const f of model.fields) {
130
- if (f.relation || f.isList)
131
- continue;
132
- if (['Json', 'Bytes'].includes(f.type))
133
- continue;
134
- if (isSensitiveFieldName(f.name))
135
- continue;
136
- if (hidden.has(f.name))
137
- continue;
138
- out.add(f.name);
139
- }
140
- return out;
141
- };
142
- /**
143
- * Résout le label BRUT (non échappé) d'une ligne : premier champ String
144
- * candidat présent, sinon template `{a} {b}` si configuré, sinon la PK.
145
- * Déterministe. Svelte échappe automatiquement à l'interpolation dans les
146
- * composants — pas besoin d'échapper ici.
147
- */
148
- const resolveLabel = (targetModel, row, labelTemplate) => {
149
- if (labelTemplate) {
150
- return labelTemplate.replace(/\{(\w+)\}/g, (_, k) => String(row[k] ?? ''));
151
- }
152
- for (const candidate of labelFieldCandidates) {
153
- const field = targetModel.fields.find((f) => f.name === candidate);
154
- if (field && field.type === 'String' && row[candidate] != null) {
155
- return String(row[candidate]);
156
- }
157
- }
158
- return String(row[primaryKeyOf(targetModel)]);
159
- };
160
- /**
161
- * Charge les options pour toutes les arêtes to-one-owning et m2m
162
- * d'un modèle. Une requête COUNT par relation avant le findMany : évite de
163
- * charger 10k lignes pour découvrir qu'il y en a 10k.
164
- */
165
- const loadRelationOptions = async (model, ctx, currentId) => {
166
- const edges = [...relationGraph.edges.values()].filter((edge) => {
167
- if (edge.model !== model.name)
168
- return false;
169
- if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m')
170
- return false;
171
- if (edge.unsupported)
172
- return false;
173
- const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
174
- return relConfig?.widget !== 'hidden';
175
- });
176
- // Une relation ne dépend pas de l'autre : chargées en parallèle plutôt
177
- // qu'en série (un modèle avec N relations ne doit pas payer N
178
- // aller-retours DB empilés pour afficher un seul formulaire).
179
- const entries = await Promise.all(edges.map(async (edge) => {
180
- const key = `${edge.model}.${edge.field}`;
181
- const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
182
- const targetModel = schema.models.find((m) => m.name === edge.target);
183
- const filter = scopeFrom(relConfig, ctx);
184
- try {
185
- const total = await adapter.data.countRecords(targetModel, filter);
186
- if (total > selectThreshold || relConfig?.widget === 'raw-id') {
187
- const selectedIds = edge.kind === 'm2m' && currentId
188
- ? await adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId)
189
- : undefined;
190
- return [key, { tooMany: true, options: [], selectedIds }];
191
- }
192
- const rows = await adapter.data.findMany(targetModel, { filter, orderBy: relConfig?.orderBy });
193
- const options = rows.map((row) => ({
194
- id: row[primaryKeyOf(targetModel)],
195
- label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
196
- }));
197
- const selectedIds = edge.kind === 'm2m' && currentId
198
- ? await adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId)
199
- : undefined;
200
- return [key, { tooMany: false, options, selectedIds }];
201
- }
202
- catch {
203
- // Cible absente de la base ou client incomplet : repli raw-id pour
204
- // garder le champ éditable plutôt que de faire échouer tout le form.
205
- return [key, { tooMany: true, options: [] }];
206
- }
207
- }));
208
- return new Map(entries);
209
- };
210
- /**
211
- * Options d'un filtre FK : charge et scope les valeurs possibles pour la
212
- * sidebar, ET résout le label du chip actif. Doctrine IDOR (docs/design
213
- * §6.3) : les options ET le label du chip passent par le `where` de
214
- * scoping de la relation — un chip forgé avec un ID hors scope affiche
215
- * l'ID brut, jamais le label (sinon c'est un oracle sur le nom d'un
216
- * enregistrement d'un autre tenant).
217
- */
218
- const resolveFkFilterOptions = async (model, fkFieldName, label, ctx, activeRawValue) => {
219
- // Appelé uniquement pour un filtre `kind: 'fk'` retourné par
220
- // resolveListFilters avec CE MÊME graphe : graphe et arête existent donc
221
- // par construction. Garder des gardes here masquerait une incohérence
222
- // interne et ajouterait du code mort (coverage artificielle).
223
- const edge = findFkEdge(relationGraph, model.name, fkFieldName);
224
- const targetModel = schema.models.find((m) => m.name === edge.target);
225
- // Non-null par construction : `edge` vient du graphe dérivé du même
226
- // schéma parsé avec succès — une arête ne peut pas cibler un modèle qui
227
- // n'existe pas dans `schema.models`.
228
- const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
229
- const scope = scopeFrom(relConfig, ctx);
230
- // Options de la sidebar (comptées puis chargées si sous le seuil) et
231
- // label du chip actif (§6.3.b) sont deux requêtes indépendantes — l'une
232
- // ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
233
- // série.
234
- const loadOptions = async () => {
235
- try {
236
- const total = await adapter.data.countRecords(targetModel, scope);
237
- if (total > selectThreshold) {
238
- return { options: [], tooMany: true };
239
- }
240
- const rows = await adapter.data.findMany(targetModel, { filter: scope, orderBy: relConfig?.orderBy });
241
- const options = rows.map((row) => ({
242
- id: row[primaryKeyOf(targetModel)],
243
- label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
244
- }));
245
- return { options, tooMany: false };
246
- }
247
- catch {
248
- return { options: [], tooMany: true };
249
- }
250
- };
251
- // Un ID hors scope retourne null ici → le composant affiche l'ID brut,
252
- // jamais le label (sinon c'est un oracle sur le nom d'un enregistrement
253
- // d'un autre tenant).
254
- const loadActiveLabel = async () => {
255
- if (activeRawValue === undefined)
256
- return undefined;
257
- const activeId = coerceId(activeRawValue, targetModel);
258
- try {
259
- const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: activeId };
260
- const filter = scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter;
261
- const row = await adapter.data.findFirst(targetModel, filter);
262
- return row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
263
- }
264
- catch {
265
- return undefined;
266
- }
267
- };
268
- const [{ options, tooMany }, activeLabel] = await Promise.all([loadOptions(), loadActiveLabel()]);
269
- return {
270
- field: fkFieldName,
271
- label,
272
- relationField: edge.field,
273
- targetModel: edge.target,
274
- options,
275
- mode: tooMany ? 'raw-id' : options.length <= filterLinkThreshold ? 'links' : 'select',
276
- tooMany,
277
- activeLabel,
278
- // Une cible exclue/masquée n'a pas de page admin : le chip reste du
279
- // texte, jamais un lien mort (docs/design §6.4).
280
- activeHref: activeLabel && findModel(edge.target)
281
- ? `${basePath}/${edge.target.toLowerCase()}/${encodeURIComponent(activeRawValue)}`
282
- : undefined
283
- };
284
- };
285
- /**
286
- * Compte, pour chaque relation inverse (1-N, 1-1) d'un modèle, le nombre
287
- * d'enregistrements liés côté cible. Résilient : une cible dont le client
288
- * échoue (mock partiel, modèle absent) retombe sur 0 plutôt que de casser
289
- * le rendu du formulaire.
290
- */
291
- const loadRelatedCounts = async (model, currentId) => {
292
- const edges = [...relationGraph.edges.values()].filter((edge) => edge.model === model.name && (edge.kind === 'to-many-inverse' || edge.kind === 'to-one-inverse'));
293
- // Un count par relation inverse, indépendants entre eux : en parallèle
294
- // plutôt qu'empilés un par un (même raisonnement que loadRelationOptions).
295
- const entries = await Promise.all(edges.map(async (edge) => {
296
- const owning = [...relationGraph.edges.values()].find((o) => o.model === edge.target && o.kind === 'to-one-owning' && o.relationName === edge.relationName);
297
- if (!owning || owning.unsupported)
298
- return undefined;
299
- const scalarName = owning.scalarFields[0];
300
- const key = `${edge.model}.${edge.field}`;
301
- const targetModel = schema.models.find((m) => m.name === edge.target);
302
- try {
303
- const count = await adapter.data.countRecords(targetModel, {
304
- op: 'eq',
305
- field: scalarName,
306
- value: coerceId(currentId, model)
307
- });
308
- return [key, count];
309
- }
310
- catch {
311
- return [key, 0];
312
- }
313
- }));
314
- return new Map(entries.filter((e) => e !== undefined));
315
- };
316
- /**
317
- * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
318
- * Sert les options d'une relation to-one-owning ou m2m en JSON
319
- * paginé — la voie prévue pour un futur widget autocomplete côté client
320
- * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
321
- * de scoping configuré sur la relation, comme le select et la validation
322
- * POST : même garantie anti-IDOR sur les trois chemins.
323
- */
324
- const handleSearch = async (event) => {
325
- const relParam = event.url.searchParams.get('rel') ?? '';
326
- const [modelName, fieldName] = relParam.split('.');
327
- const q = event.url.searchParams.get('q') ?? '';
328
- const { page } = paginate(event.url.searchParams.get('page'), PER_PAGE);
329
- const model = findModel(modelName);
330
- const edge = model && relationGraph
331
- ? relationGraph.edges.get(`${model.name}.${fieldName}`)
332
- : undefined;
333
- if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m') || edge.unsupported) {
334
- return new Response(JSON.stringify({ error: 'unknown relation' }), {
335
- status: 404,
336
- headers: { 'Content-Type': 'application/json' }
337
- });
338
- }
339
- const targetModel = schema.models.find((m) => m.name === edge.target);
340
- const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
341
- const configWhere = scopeFrom(relConfig, { locals: event.locals });
342
- // Recherche sur le premier champ String candidat du modèle cible — le
343
- // même champ que celui utilisé pour construire le label par défaut.
344
- const searchField = labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
345
- // `_search` must stay case-sensitive on every adapter/provider. A
346
- // `{ op: 'contains' }` leaf would pick up the adapter-wide
347
- // `caseInsensitiveSearch` flag (Prisma `mode: 'insensitive'`, Drizzle
348
- // `ilike`). `containsExact` compiles to `{ contains }` / `LIKE` with
349
- // no case-folding — same observable Prisma behavior as the previous
350
- // opaque `{ [field]: { contains: q } }` pass-through.
351
- const containsFilter = q && searchField
352
- ? { op: 'containsExact', field: searchField, value: q }
353
- : undefined;
354
- const searchFilter = containsFilter && configWhere
355
- ? { op: 'and', clauses: [configWhere, containsFilter] }
356
- : containsFilter ?? configWhere;
357
- try {
358
- // Count + fetch are independent reads — run them in parallel (as this
359
- // endpoint always has, pre-refactor) rather than doubling latency with
360
- // two sequential awaits.
361
- const [total, rows] = await Promise.all([
362
- adapter.data.countRecords(targetModel, searchFilter),
363
- adapter.data.findMany(targetModel, {
364
- filter: searchFilter,
365
- orderBy: relConfig?.orderBy,
366
- skip: (page - 1) * PER_PAGE,
367
- take: PER_PAGE
368
- })
369
- ]);
370
- const options = rows.map((row) => ({
371
- id: row[primaryKeyOf(targetModel)],
372
- label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
373
- }));
374
- return new Response(JSON.stringify({ options, total, page }), {
375
- headers: { 'Content-Type': 'application/json' }
376
- });
377
- }
378
- catch {
379
- return new Response(JSON.stringify({ error: 'search failed' }), {
380
- status: 500,
381
- headers: { 'Content-Type': 'application/json' }
382
- });
383
- }
384
- };
32
+ const runtime = createAdminRuntime(config);
33
+ const csrf = resolveCsrfConfig(config.csrf);
34
+ const registry = resolvePluginRegistry(config.plugins ?? [], BUILTIN_ROUTES, runtime.models);
35
+ const { authCheck, logout, logoutRedirectTo = '/' } = config;
385
36
  return async ({ event, resolve }) => {
386
37
  const { pathname } = event.url;
387
38
  // Only handle admin routes
388
- if (!pathname.startsWith(basePath)) {
39
+ if (!pathname.startsWith(runtime.basePath)) {
389
40
  return resolve(event);
390
41
  }
391
- const route = parseRoute(pathname, basePath);
42
+ // Avant `matchRoute` : couvre le logout (dispatché avant `authCheck`),
43
+ // `_search`, les mutations, et toute route ajoutée plus tard.
44
+ const forbidden = verifyOrigin(csrf, event);
45
+ if (forbidden)
46
+ return forbidden;
47
+ // Plugin routes are checked BEFORE builtins: `resolvePluginRegistry` only
48
+ // rejects a plugin pattern that is an EXACT token-for-token match of a
49
+ // builtin one (e.g. `[':model', ':id']`), not one that merely happens to
50
+ // overlap at match time (e.g. `[':model', 'stats']` vs the builtin edit
51
+ // route `[':model', ':id']` — both match `user/stats`, ':id' being a
52
+ // wildcard). Checking builtins first would let that generic edit route
53
+ // silently swallow every such plugin page.
54
+ const route = matchRoute(pathname, runtime.basePath, [
55
+ ...registry.routes,
56
+ ...BUILTIN_ROUTES
57
+ ]);
392
58
  // Logout: dispatched BEFORE authCheck, deliberately. A user whose
393
59
  // session already expired (authCheck would now reject them) must
394
60
  // still be able to hit this route to clear client-side state (a
@@ -412,246 +78,196 @@ export function createAdminHandler(config) {
412
78
  return new Response('Unauthorized', { status: 401 });
413
79
  }
414
80
  }
415
- let content = '';
416
- let currentModel;
417
81
  if (route.view === 'search') {
418
- return handleSearch(event);
82
+ return handleSearch(runtime, event);
419
83
  }
84
+ // Plugin page views are GET-only: dispatched here, BEFORE `handleMutation`,
85
+ // so a forged POST to a plugin route (e.g. `/user/1/graph` with
86
+ // `_action=delete`) can never reach the mutation path just because its
87
+ // pattern happens to overlap `:model/:id`-shaped segments.
88
+ const pluginPage = registry.pagesByView.get(route.view);
89
+ if (pluginPage) {
90
+ if (event.request.method !== 'GET') {
91
+ return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET' } });
92
+ }
93
+ }
94
+ let content = '';
95
+ let currentModel;
96
+ let extraStyles = '';
97
+ let extraScripts = '';
98
+ let mutationError;
420
99
  try {
421
100
  // Handle POST requests (create, update, delete). Unrecognised actions fall
422
101
  // through to the GET rendering below, as they always have.
102
+ // Invariant: a plugin view never reaches this call — `pluginPage` above
103
+ // already 405'd any non-GET before this `try` block. `handleMutation`
104
+ // only ever reads `route.model` / `route.id` / the `_action` form
105
+ // field, never `route.view`, so it cannot be confused by a plugin's
106
+ // view id landing here.
423
107
  if (event.request.method === 'POST') {
424
- const formData = await event.request.formData();
425
- const action = formData.get('_action');
426
- if (route.model) {
427
- const model = findModel(route.model);
428
- if (!model) {
429
- throw new Error(`Model "${route.model}" not found`);
108
+ // `try` propre au chemin de mutation, et non le `catch` partagé plus
109
+ // bas : celui-ci couvre aussi le rendu GET et les pages de plugin,
110
+ // dont le contrat (rendre le message levé) ne change pas ici.
111
+ try {
112
+ const mutationResponse = await handleMutation(runtime, event, route);
113
+ if (mutationResponse)
114
+ return mutationResponse;
115
+ }
116
+ catch (e) {
117
+ // Plus de classification ici : `handleMutation` a déjà consommé le
118
+ // corps de la requête, donc ce site ne peut plus lire `_action` et
119
+ // ne saurait pas distinguer une création d'une suppression. La
120
+ // classification par code pilote se fait désormais dans
121
+ // `mutations.ts`, au site d'appel qui connaît l'action réelle —
122
+ // ce `catch` ne fait plus qu'un aiguillage à trois branches sur le
123
+ // type de l'erreur déjà classée.
124
+ if (e instanceof AdminMutationError) {
125
+ // Message construit par la bibliothèque (validation, scope, ou
126
+ // code pilote reconnu) : sûr à rendre tel quel.
127
+ mutationError = e.message;
430
128
  }
431
- if (action === 'delete' && route.id) {
432
- await adapter.data.deleteRecord(model, route.id);
433
- return redirectToList(route.model);
129
+ else if (e instanceof AdminConfigError) {
130
+ // Mauvaise configuration côté consommateur : message écrit par la
131
+ // bibliothèque, destiné au développeur. Le `catch` partagé plus bas
132
+ // garde son contrat historique — on le laisse la traiter.
133
+ throw e;
434
134
  }
435
- if (action === 'create' || action === 'update') {
436
- const data = formDataToPrisma(formData, model);
437
- const m2mInput = {};
438
- // Validation des FK owning : coercion + existence + self-ref.
439
- // Rejoue le `where` de scoping : un ID hors du where est rejeté,
440
- // pas seulement caché du select (IDOR par POST forgé).
441
- if (relationGraph) {
442
- for (const edge of relationGraph.edges.values()) {
443
- if (edge.model !== model.name || edge.kind !== 'to-one-owning')
444
- continue;
445
- if (edge.unsupported)
446
- continue;
447
- const scalarName = edge.scalarFields[0];
448
- // Lu directement depuis le FormData plutôt que `data` :
449
- // `formDataToPrisma` omet la clé pour un scalaire required
450
- // laissé vide, donc `data[scalarName]` ne suffirait pas ici.
451
- const raw = formData.get(scalarName);
452
- if (raw === null)
453
- continue;
454
- const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
455
- // Vide sur relation optionnelle null (disconnect).
456
- if (raw === '' || raw === undefined || raw === null) {
457
- if (edge.isRequired) {
458
- throw new Error(`${edge.field} is required`);
459
- }
460
- data[scalarName] = null;
461
- continue;
462
- }
463
- // Coercion vers le type de la PK cible. `targetModel` existe
464
- // toujours : le graphe n'aurait pas produit d'arête sinon.
465
- const targetModel = schema.models.find((m) => m.name === edge.target);
466
- const pkField = targetModel.fields.find((f) => f.isId);
467
- const coerced = pkField?.type === 'Int' ? parseInt(String(raw)) : String(raw);
468
- if (pkField?.type === 'Int' && !Number.isSafeInteger(coerced)) {
469
- throw new Error(`${edge.field}: invalid id`);
470
- }
471
- // Self-ref : la ligne courante ne peut pas être sa propre cible.
472
- if (edge.selfReferential && route.id && String(coerced) === String(coerceId(route.id, model))) {
473
- throw new Error(`${edge.field}: cannot reference itself`);
474
- }
475
- // Existence + scoping. findFirst et non findUnique : le where
476
- // peut porter des conditions arbitraires (scoping multi-tenant).
477
- // Si le client ne sait pas répondre, on ne bloque pas l'écriture.
478
- try {
479
- const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: coerced };
480
- const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
481
- const filter = scopeFilter ? { op: 'and', clauses: [idFilter, scopeFilter] } : idFilter;
482
- const found = await adapter.data.findFirst(targetModel, filter);
483
- if (!found) {
484
- throw new Error(`${edge.field}: invalid value`);
485
- }
486
- }
487
- catch (e) {
488
- if (e?.message?.includes('invalid value'))
489
- throw e;
490
- if (e?.message &&
491
- (e.message.includes(OPAQUE_FILTER_ERROR) ||
492
- OPAQUE_FILTER_ERROR.startsWith(e.message))) {
493
- throw new Error(`${edge.field}: invalid value`);
494
- }
495
- if (e?.message?.includes('unknown field')) {
496
- throw new Error(`${edge.field}: invalid value`);
497
- }
498
- // Client incapable de vérifier (mock partiel, etc.) : on laisse passer.
499
- }
500
- data[scalarName] = coerced;
501
- }
502
- // N-N implicite : lit `__rel__<field>` (valeurs cochées) et
503
- // `__rel_present__<field>` (sentinelle). Sans le sentinelle,
504
- // le champ est absent du form (readonly/exclu) → no-op.
505
- // Avec le sentinelle mais zéro valeur cochée → vider la
506
- // relation (`set: []` / rien à connecter en création).
507
- for (const edge of relationGraph.edges.values()) {
508
- if (edge.model !== model.name || edge.kind !== 'm2m')
509
- continue;
510
- // Pas de garde `edge.unsupported` ici : par construction du
511
- // graphe, `unsupported` n'est jamais posé sur une arête
512
- // m2m (seulement sur to-one-owning / groupes
513
- // ambigus, qui retombent toujours en to-one-owning).
514
- const present = formData.get(`__rel_present__${edge.field}`);
515
- if (present === null)
516
- continue;
517
- const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
518
- const targetModel = schema.models.find((m) => m.name === edge.target);
519
- const targetPk = primaryKeyOf(targetModel);
520
- const pkIsInt = targetModel.fields.find((f) => f.isId)?.type === 'Int';
521
- const submitted = formData.getAll(`__rel__${edge.field}`).map(String);
522
- const rawIds = submitted.length === 1 && submitted[0].includes(',')
523
- ? submitted[0].split(',').map((s) => s.trim()).filter(Boolean)
524
- : submitted;
525
- const ids = rawIds.map((v) => pkIsInt ? parseInt(v) : v);
526
- if (pkIsInt && ids.some((v) => !Number.isSafeInteger(v))) {
527
- throw new Error(`${edge.field}: invalid id`);
528
- }
529
- // Existence + scoping en une requête, sur l'ensemble des IDs
530
- // soumis. Un compte différent = au moins un ID invalide ou
531
- // hors scoping — IDOR bloqué au même titre que pour les FK.
532
- if (ids.length > 0) {
533
- const inFilter = { op: 'in', field: targetPk, value: ids };
534
- const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
535
- const filter = scopeFilter ? { op: 'and', clauses: [inFilter, scopeFilter] } : inFilter;
536
- try {
537
- const found = await adapter.data.findMany(targetModel, { filter });
538
- if (found.length !== new Set(ids.map(String)).size) {
539
- throw new Error(`${edge.field}: invalid value`);
540
- }
541
- }
542
- catch (e) {
543
- if (e?.message?.includes('invalid value'))
544
- throw e;
545
- if (e?.message &&
546
- (e.message.includes(OPAQUE_FILTER_ERROR) ||
547
- OPAQUE_FILTER_ERROR.startsWith(e.message))) {
548
- throw new Error(`${edge.field}: invalid value`);
549
- }
550
- if (e?.message?.includes('unknown field')) {
551
- throw new Error(`${edge.field}: invalid value`);
552
- }
553
- // Client incapable de vérifier : on laisse passer.
554
- }
135
+ else {
136
+ // Tout le reste est présumé venir du moteur : son texte peut porter le nom
137
+ // de la table, un fragment de requête ou un dump d'arguments. Jamais rendu.
138
+ console.error('[sveltekit-admin] mutation failed:', e);
139
+ mutationError = 'The change could not be saved.';
140
+ }
141
+ }
142
+ }
143
+ // GET requests - render views
144
+ if (pluginPage) {
145
+ const hasModel = pluginPage.pattern.includes(':model');
146
+ const hasId = pluginPage.pattern.includes(':id');
147
+ if (hasModel) {
148
+ currentModel = route.model;
149
+ const model = runtime.findModel(route.model);
150
+ const allowed = model &&
151
+ (!pluginPage.models ||
152
+ pluginPage.models.some((n) => n.toLowerCase() === model.name.toLowerCase()));
153
+ if (!model || !allowed) {
154
+ content = render(NotFound, {
155
+ props: { message: 'Page not found', basePath: runtime.basePath }
156
+ }).body;
157
+ }
158
+ else if (hasId) {
159
+ const ctx = createPluginPageContext(runtime, event, route);
160
+ const loaded = await ctx.loadRecord(model.name, route.id);
161
+ if (!loaded) {
162
+ content = render(NotFound, {
163
+ props: {
164
+ message: `${model.name} with ID "${route.id}" not found`,
165
+ basePath: runtime.basePath
555
166
  }
556
- m2mInput[edge.field] = { targetPkField: targetPk, ids };
557
- }
558
- }
559
- if (action === 'create') {
560
- await adapter.data.createRecord(model, { scalars: data, m2m: m2mInput });
167
+ }).body;
561
168
  }
562
- else if (route.id) {
563
- await adapter.data.updateRecord(model, route.id, { scalars: data, m2m: m2mInput });
169
+ else {
170
+ const result = await pluginPage.render(createPluginPageContext(runtime, event, route, loaded));
171
+ content = result.html;
172
+ extraStyles = result.styles ?? '';
173
+ extraScripts = result.scripts ?? '';
564
174
  }
565
- return redirectToList(route.model);
566
175
  }
176
+ else {
177
+ const result = await pluginPage.render(createPluginPageContext(runtime, event, route));
178
+ content = result.html;
179
+ extraStyles = result.styles ?? '';
180
+ extraScripts = result.scripts ?? '';
181
+ }
182
+ }
183
+ else {
184
+ const result = await pluginPage.render(createPluginPageContext(runtime, event, route));
185
+ content = result.html;
186
+ extraStyles = result.styles ?? '';
187
+ extraScripts = result.scripts ?? '';
567
188
  }
568
189
  }
569
- // GET requests - render views
570
- if (route.view === 'notFound') {
571
- content = render(NotFound, { props: { message: 'Page not found', basePath } }).body;
190
+ else if (route.view === 'notFound') {
191
+ content = render(NotFound, { props: { message: 'Page not found', basePath: runtime.basePath } }).body;
572
192
  }
573
193
  else if (route.view === 'dashboard') {
574
- const modelsWithCounts = await Promise.all(filteredModels.map(async (m) => {
194
+ const modelsWithCounts = await Promise.all(runtime.models.map(async (m) => {
575
195
  let count = 0;
576
196
  try {
577
- count = await adapter.data.countRecords(m);
197
+ count = await runtime.adapter.data.countRecords(m, modelScopeFrom(runtime, m, { locals: event.locals }));
578
198
  }
579
199
  catch {
580
200
  // model absent from the database
581
201
  }
582
- return { name: m.name, label: labelOf(m), count };
202
+ return { name: m.name, label: runtime.labelOf(m), count };
583
203
  }));
584
204
  const totalRecords = modelsWithCounts.reduce((sum, m) => sum + m.count, 0);
585
205
  content = render(Dashboard, {
586
206
  props: {
587
207
  models: modelsWithCounts,
588
208
  stats: { total: totalRecords, models: modelsWithCounts.length },
589
- basePath
209
+ basePath: runtime.basePath
590
210
  }
591
211
  }).body;
592
212
  }
593
213
  else if (route.model) {
594
214
  currentModel = route.model;
595
- const model = findModel(route.model);
215
+ const model = runtime.findModel(route.model);
596
216
  if (!model) {
597
217
  content = render(NotFound, {
598
- props: { message: `Model "${route.model}" not found`, basePath }
218
+ props: { message: `Model "${route.model}" not found`, basePath: runtime.basePath }
599
219
  }).body;
600
220
  }
601
221
  else if (route.view === 'list') {
602
- const { page } = paginate(event.url.searchParams.get('page'), PER_PAGE);
222
+ const modelsConfig = runtime.config.models ?? {};
223
+ const { page } = paginate(event.url.searchParams.get('page'), runtime.perPage);
603
224
  const modelSearchConfig = modelsConfig[model.name]?.searchFields;
604
- const searchFields = resolveSearchFields(model, modelSearchConfig, labelFieldCandidates, hiddenFieldsOf(model));
605
- const filterableFields = resolveFilterableFields(model);
606
- const listQuery = parseListQuery(event.url.searchParams, model, schema.enums, searchFields, filterableFields);
607
- const listScope = modelsConfig[model.name]?.listWhere?.({ locals: event.locals });
608
- // A scope function returning `{}` (falsy-looking but truthy as
609
- // an object) would otherwise silently fail OPEN — `{}` composed
610
- // into an AND matches every row, exactly the opposite of what a
611
- // caller configuring listWhere expects (real gap found in
612
- // review: `locals.userId` undefined after a session expires is
613
- // a realistic way to hit this). Fail loud instead: a scope
614
- // function is either omitted entirely, or must return at least
615
- // one condition every time it runs.
616
- if (listScope && Object.keys(listScope).length === 0) {
617
- throw new Error(`[sveltekit-admin] models.${model.name}.listWhere returned an empty object ({}), ` +
618
- `which would silently disable list scoping (fail-open). Return undefined/omit the ` +
619
- `scope entirely if there is genuinely nothing to scope by for this request, or a ` +
620
- `condition that actually restricts rows otherwise.`);
621
- }
622
- const filter = buildWhere(listQuery, listScope, caseInsensitiveSearch, model);
623
- const { rows: items, total } = await adapter.data.listRecords(model, { filter, skip: (page - 1) * PER_PAGE, take: PER_PAGE });
624
- const listFilters = resolveListFilters(model, schema.enums, modelsConfig[model.name]?.listFilter, toLabel, relationGraph, hiddenFieldsOf(model), config.listFilterDefaults?.autoDetect ?? true);
225
+ const searchFields = resolveSearchFields(model, modelSearchConfig, runtime.labelFieldCandidates, runtime.hiddenFieldsOf(model));
226
+ const filterableFields = runtime.resolveFilterableFields(model);
227
+ const listQuery = parseListQuery(event.url.searchParams, model, runtime.schema.enums, searchFields, filterableFields);
228
+ const listScope = listScopeFrom(runtime, model, { locals: event.locals });
229
+ const modelScope = modelScopeFrom(runtime, model, { locals: event.locals });
230
+ const scope = modelScope && listScope
231
+ ? { op: 'and', clauses: [modelScope, normalizeScope(listScope)] }
232
+ : modelScope ?? listScope;
233
+ // Adapter compiles case-sensitivity; this arg is unused by buildWhere.
234
+ const filter = buildWhere(listQuery, scope, false, model);
235
+ const { rows: items, total } = await runtime.adapter.data.listRecords(model, { filter, skip: (page - 1) * runtime.perPage, take: runtime.perPage });
236
+ const listFilters = resolveListFilters(model, runtime.schema.enums, modelsConfig[model.name]?.listFilter, toLabel, runtime.relationGraph, runtime.hiddenFieldsOf(model), runtime.config.listFilterDefaults?.autoDetect ?? true);
625
237
  // Un filtre FK ne dépend pas de l'autre : résolus en parallèle
626
238
  // plutôt qu'un par un (même raisonnement que loadRelationOptions).
627
239
  const fkFilterEntries = await Promise.all(listFilters
628
240
  .filter((filter) => filter.kind === 'fk')
629
241
  .map(async (filter) => {
630
242
  const activeRawValue = listQuery.filters.find((f) => f.field === filter.field && f.op === 'equals')?.raw;
631
- const meta = await resolveFkFilterOptions(model, filter.field, filter.label, { locals: event.locals }, activeRawValue);
243
+ const meta = await resolveFkFilterOptions(runtime, model, filter.field, filter.label, { locals: event.locals }, activeRawValue);
632
244
  return [filter.field, meta];
633
245
  }));
634
246
  const fkFilterMeta = new Map(fkFilterEntries);
635
247
  content = render(List, {
636
248
  props: {
637
- model: viewModel(model),
249
+ model: runtime.viewModel(model),
638
250
  items,
639
- pagination: { page, perPage: PER_PAGE, total },
640
- basePath,
641
- config,
251
+ pagination: { page, perPage: runtime.perPage, total },
252
+ basePath: runtime.basePath,
253
+ config: runtime.config,
642
254
  query: listQuery,
643
255
  currentUrl: event.url,
644
256
  listFilters,
645
- fkFilterMeta
257
+ fkFilterMeta,
258
+ recordActions: actionsForModel(registry, model.name).map((action) => ({
259
+ label: action.label,
260
+ hrefFor: (id) => action.href({ model: model.name, id, basePath: runtime.basePath })
261
+ }))
646
262
  }
647
263
  }).body;
648
264
  }
649
265
  else if (route.view === 'create') {
650
- const relationOptions = await loadRelationOptions(model, { locals: event.locals });
266
+ const relationOptions = await loadRelationOptions(runtime, model, { locals: event.locals });
651
267
  // Pré-remplissage FK depuis la query string (`?authorId=3`), posé
652
268
  // par le lien "Ajouter" du bloc de liaisons inverses.
653
269
  const prefill = {};
654
- for (const edge of relationGraph.edges.values()) {
270
+ for (const edge of runtime.relationGraph.edges.values()) {
655
271
  if (edge.model !== model.name || edge.kind !== 'to-one-owning')
656
272
  continue;
657
273
  const scalarName = edge.scalarFields[0];
@@ -663,10 +279,11 @@ export function createAdminHandler(config) {
663
279
  content = render(Form, {
664
280
  props: {
665
281
  mode: 'create',
666
- model: { ...viewModel(model), relationOptions },
667
- basePath,
668
- config,
669
- item: itemPrefill
282
+ model: { ...runtime.viewModel(model), relationOptions },
283
+ basePath: runtime.basePath,
284
+ config: runtime.config,
285
+ item: itemPrefill,
286
+ recordActions: []
670
287
  }
671
288
  }).body;
672
289
  }
@@ -676,21 +293,38 @@ export function createAdminHandler(config) {
676
293
  // atteint ce `else` — or 'edit' est la branche à 2 segments, donc `id` y est
677
294
  // toujours défini. La variante 'notFound' ne porte pas de `model` : elle est
678
295
  // interceptée en amont et ne peut pas arriver ici.
679
- const item = await adapter.data.getRecord(model, route.id);
680
- const relationOptions = await loadRelationOptions(model, { locals: event.locals }, route.id);
681
- const relatedCounts = item ? await loadRelatedCounts(model, route.id) : undefined;
296
+ const modelScope = modelScopeFrom(runtime, model, { locals: event.locals });
297
+ const item = modelScope
298
+ ? await runtime.adapter.data.findFirst(model, {
299
+ op: 'and',
300
+ clauses: [
301
+ { op: 'eq', field: runtime.viewModel(model).primaryKey, value: coerceId(route.id, model) },
302
+ modelScope
303
+ ]
304
+ })
305
+ : await runtime.adapter.data.getRecord(model, route.id);
306
+ const relationOptions = await loadRelationOptions(runtime, model, { locals: event.locals }, route.id);
307
+ const relatedCounts = item ? await loadRelatedCounts(runtime, model, route.id, { locals: event.locals }) : undefined;
682
308
  content = item
683
309
  ? render(Form, {
684
310
  props: {
685
311
  mode: 'edit',
686
- model: { ...viewModel(model), relationOptions, relatedCounts },
687
- basePath,
688
- config,
689
- item
312
+ model: { ...runtime.viewModel(model), relationOptions, relatedCounts },
313
+ basePath: runtime.basePath,
314
+ config: runtime.config,
315
+ item,
316
+ recordActions: actionsForModel(registry, model.name).map((action) => ({
317
+ label: action.label,
318
+ href: action.href({
319
+ model: model.name,
320
+ id: item[runtime.viewModel(model).primaryKey],
321
+ basePath: runtime.basePath
322
+ })
323
+ }))
690
324
  }
691
325
  }).body
692
326
  : render(NotFound, {
693
- props: { message: `${model.name} with ID "${route.id}" not found`, basePath }
327
+ props: { message: `${model.name} with ID "${route.id}" not found`, basePath: runtime.basePath }
694
328
  }).body;
695
329
  }
696
330
  }
@@ -699,7 +333,24 @@ export function createAdminHandler(config) {
699
333
  console.error('[sveltekit-admin] Error:', e);
700
334
  content = `<div class="ska-alert ska-alert--error">Error: ${escapeHtml(e.message || 'Unknown error')}</div>`;
701
335
  }
702
- const html = render(Layout, { props: { content, config, modelList, currentModel } }).body;
336
+ if (mutationError) {
337
+ // Même préfixe « Error: » que le `catch` partagé, pour une seule
338
+ // convention d'alerte dans toute la page (cf. handler.test.ts, l'alerte
339
+ // « modèle inconnu en POST »).
340
+ content =
341
+ `<div class="ska-alert ska-alert--error">Error: ${escapeHtml(mutationError)}</div>` +
342
+ content;
343
+ }
344
+ const html = render(Layout, {
345
+ props: {
346
+ content,
347
+ config: runtime.config,
348
+ modelList: runtime.modelList,
349
+ currentModel,
350
+ extraStyles,
351
+ extraScripts
352
+ }
353
+ }).body;
703
354
  return new Response(html, {
704
355
  headers: {
705
356
  'Content-Type': 'text/html; charset=utf-8'