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