sveltekit-admin 0.6.0 → 0.9.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 (69) 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 +196 -42
  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 +71 -11
  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 +42 -3
  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/data.d.ts +18 -2
  18. package/dist/server/data.js +39 -8
  19. package/dist/server/errors.d.ts +47 -0
  20. package/dist/server/errors.js +90 -0
  21. package/dist/server/handler.d.ts +92 -26
  22. package/dist/server/handler.js +263 -560
  23. package/dist/server/introspection/parser.d.ts +15 -0
  24. package/dist/server/introspection/parser.js +17 -0
  25. package/dist/server/mutations.d.ts +13 -0
  26. package/dist/server/mutations.js +476 -0
  27. package/dist/server/plugin.d.ts +47 -0
  28. package/dist/server/plugin.js +1 -0
  29. package/dist/server/pluginAccess.d.ts +7 -0
  30. package/dist/server/pluginAccess.js +79 -0
  31. package/dist/server/pluginRegistry.d.ts +12 -0
  32. package/dist/server/pluginRegistry.js +72 -0
  33. package/dist/server/query/listColumns.d.ts +19 -0
  34. package/dist/server/query/listColumns.js +43 -0
  35. package/dist/server/query/listQuery.d.ts +1 -1
  36. package/dist/server/query/pageSize.d.ts +19 -0
  37. package/dist/server/query/pageSize.js +26 -0
  38. package/dist/server/query/sortQuery.d.ts +31 -0
  39. package/dist/server/query/sortQuery.js +33 -0
  40. package/dist/server/query/urls.js +9 -1
  41. package/dist/server/relationLoaders.d.ts +42 -0
  42. package/dist/server/relationLoaders.js +188 -0
  43. package/dist/server/router.d.ts +10 -0
  44. package/dist/server/router.js +42 -19
  45. package/dist/server/runtime.d.ts +51 -0
  46. package/dist/server/runtime.js +263 -0
  47. package/dist/server/search.d.ts +14 -0
  48. package/dist/server/search.js +78 -0
  49. package/dist/server/submitted.d.ts +20 -0
  50. package/dist/server/submitted.js +54 -0
  51. package/dist/server/views/FieldInput.svelte +114 -8
  52. package/dist/server/views/FieldInput.svelte.d.ts +7 -0
  53. package/dist/server/views/Form.svelte +96 -5
  54. package/dist/server/views/Form.svelte.d.ts +19 -1
  55. package/dist/server/views/Layout.svelte +32 -4
  56. package/dist/server/views/Layout.svelte.d.ts +2 -0
  57. package/dist/server/views/List.svelte +156 -26
  58. package/dist/server/views/List.svelte.d.ts +7 -2
  59. package/dist/server/views/RelationCheckboxes.svelte +26 -5
  60. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  61. package/dist/server/views/RelationSelect.svelte +14 -3
  62. package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
  63. package/dist/server/views/html.d.ts +19 -0
  64. package/dist/server/views/html.js +25 -0
  65. package/dist/server/views/pagination.d.ts +9 -0
  66. package/dist/server/views/pagination.js +31 -0
  67. package/dist/server/views/theme.js +147 -3
  68. package/dist/server/views/types.d.ts +15 -0
  69. package/package.json +24 -21
@@ -16,6 +16,21 @@ export declare function parseSchemaContent(content: string): PrismaSchema;
16
16
  * cherchable/filtrable par URL forgée.
17
17
  */
18
18
  export declare function isSensitiveFieldName(name: string): boolean;
19
+ /**
20
+ * Restreint `isSensitiveFieldName` aux colonnes de texte, pour le chemin
21
+ * formulaire/écriture.
22
+ *
23
+ * Le prédicat de nom matche par sous-chaîne : sans le filtre de type, un
24
+ * `tokenCount` ou un `hashtagCount` (`Int`) serait traité comme un secret et
25
+ * deviendrait impossible à éditer dans l'admin. Un mot de passe, un secret,
26
+ * un token sont du texte ; un compteur ne l'est pas.
27
+ *
28
+ * Volontairement plus étroit que `isSensitiveFieldName` seul, qui reste le
29
+ * prédicat utilisé pour l'affichage, la recherche, les filtres et l'audit :
30
+ * là, ne pas montrer un `tokenCount` est sans conséquence, alors qu'ici ça
31
+ * retirerait une capacité.
32
+ */
33
+ export declare function isSensitiveStringField(field: Pick<PrismaField, 'name' | 'type'>): boolean;
19
34
  /**
20
35
  * Get display fields for a model (fields suitable for list view)
21
36
  */
@@ -193,6 +193,23 @@ export function isSensitiveFieldName(name) {
193
193
  const lower = name.toLowerCase();
194
194
  return SENSITIVE_FIELD_NAMES.some((hidden) => lower.includes(hidden));
195
195
  }
196
+ /**
197
+ * Restreint `isSensitiveFieldName` aux colonnes de texte, pour le chemin
198
+ * formulaire/écriture.
199
+ *
200
+ * Le prédicat de nom matche par sous-chaîne : sans le filtre de type, un
201
+ * `tokenCount` ou un `hashtagCount` (`Int`) serait traité comme un secret et
202
+ * deviendrait impossible à éditer dans l'admin. Un mot de passe, un secret,
203
+ * un token sont du texte ; un compteur ne l'est pas.
204
+ *
205
+ * Volontairement plus étroit que `isSensitiveFieldName` seul, qui reste le
206
+ * prédicat utilisé pour l'affichage, la recherche, les filtres et l'audit :
207
+ * là, ne pas montrer un `tokenCount` est sans conséquence, alors qu'ici ça
208
+ * retirerait une capacité.
209
+ */
210
+ export function isSensitiveStringField(field) {
211
+ return field.type === 'String' && isSensitiveFieldName(field.name);
212
+ }
196
213
  /**
197
214
  * Get display fields for a model (fields suitable for list view)
198
215
  */
@@ -0,0 +1,13 @@
1
+ /**
2
+ * POST create/update/delete handling — split out of `handler.ts`, pure
3
+ * orchestration over `AdminRuntime`.
4
+ *
5
+ * Le corps est lu par l'appelant et reçu en paramètre, pas consommé ici : le
6
+ * handler doit garder ce qui a été soumis pour le re-rendre si cette fonction
7
+ * lève (`readSubmittedForm`), et un corps de requête ne se lit qu'une fois.
8
+ * L'appelant n'invoque cette fonction que sur `POST`, donc aucun GET ne lit de
9
+ * corps.
10
+ */
11
+ import type { ParsedRoute } from './router.js';
12
+ import { type AdminRuntime } from './runtime.js';
13
+ export declare function handleMutation(runtime: AdminRuntime, event: any, route: ParsedRoute, formData: FormData): Promise<Response | null>;
@@ -0,0 +1,476 @@
1
+ /**
2
+ * POST create/update/delete handling — split out of `handler.ts`, pure
3
+ * orchestration over `AdminRuntime`.
4
+ *
5
+ * Le corps est lu par l'appelant et reçu en paramètre, pas consommé ici : le
6
+ * handler doit garder ce qui a été soumis pour le re-rendre si cette fonction
7
+ * lève (`readSubmittedForm`), et un corps de requête ne se lit qu'une fois.
8
+ * L'appelant n'invoque cette fonction que sur `POST`, donc aucun GET ne lit de
9
+ * corps.
10
+ */
11
+ import { primaryKeyOf, coerceId, formDataToPrisma } from './data.js';
12
+ import { isSensitiveStringField } from './introspection/parser.js';
13
+ import { AdminMutationError, classifyWriteError } from './errors.js';
14
+ import { buildAuditEvent, emitAudit, readAuditSnapshot } from './audit.js';
15
+ import { scopeFrom, modelScopeFrom, modelScopeValues } from './runtime.js';
16
+ /** Plafond de sélection : la page la plus large de l'admin fait 200 lignes. */
17
+ const MAX_BULK_IDS = 200;
18
+ export async function handleMutation(runtime, event, route, formData) {
19
+ const modelsConfig = runtime.config.models ?? {};
20
+ const audit = runtime.config.audit;
21
+ const action = formData.get('_action');
22
+ if (!route.model)
23
+ return null;
24
+ const model = runtime.findModel(route.model);
25
+ if (!model) {
26
+ throw new AdminMutationError('notFound', `Model "${route.model}" not found`);
27
+ }
28
+ const redirectToList = (modelName) => new Response(null, {
29
+ status: 303,
30
+ headers: { Location: `${runtime.basePath}/${modelName.toLowerCase()}` }
31
+ });
32
+ const scopedRecord = async (id) => {
33
+ const modelScope = modelScopeFrom(runtime, model, { locals: event.locals });
34
+ if (!modelScope)
35
+ return true;
36
+ return runtime.adapter.data.findFirst(model, {
37
+ op: 'and',
38
+ clauses: [{ op: 'eq', field: primaryKeyOf(model), value: coerceId(String(id), model) }, modelScope]
39
+ });
40
+ };
41
+ if (action === 'delete' && route.id) {
42
+ const id = coerceId(route.id, model);
43
+ if (!(await scopedRecord(id)))
44
+ return null;
45
+ const before = audit
46
+ ? await readAuditSnapshot((m, recId) => runtime.adapter.data.getRecord(m, recId), model, id)
47
+ : null;
48
+ try {
49
+ await runtime.adapter.data.deleteRecord(model, route.id, modelScopeFrom(runtime, model, { locals: event.locals }));
50
+ }
51
+ catch (e) {
52
+ // Classé ici et non dans `handler.ts` : seul ce site connaît l'action
53
+ // réellement en cours, et `reference` / `restrict` partagent le même code
54
+ // SQLSTATE — c'est l'action qui les sépare. Un code non reconnu est relayé
55
+ // tel quel, et c'est le handler qui le masquera.
56
+ throw classifyWriteError(e, 'delete') ?? e;
57
+ }
58
+ if (audit) {
59
+ await emitAudit(audit, buildAuditEvent({
60
+ event,
61
+ action: 'delete',
62
+ model,
63
+ id,
64
+ hidden: runtime.hiddenFieldsOf(model),
65
+ before
66
+ }));
67
+ }
68
+ return redirectToList(route.model);
69
+ }
70
+ /**
71
+ * Suppression en masse. Une seule opération d'écriture, pas une boucle de
72
+ * `deleteRecord` : une boucle qui casse au septième id sur une contrainte de
73
+ * clé étrangère laisserait six lignes supprimées et rien pour revenir en
74
+ * arrière. Ici, tout part ou rien ne part.
75
+ *
76
+ * La portée du modèle est composée avec les ids DANS le `where` de
77
+ * l'adapter, jamais vérifiée à part : un id hors portée ne matche pas, sans
78
+ * erreur — donc rien ne distingue « n'existe pas » de « appartient à un
79
+ * autre tenant ». Le compte rendu est celui des lignes réellement
80
+ * supprimées ; un écart avec la sélection ne peut venir que d'un POST forgé,
81
+ * puisque l'interface n'offre que des lignes en portée.
82
+ */
83
+ if (action === 'bulk-delete') {
84
+ const ids = formData.getAll('ids').map(String);
85
+ if (ids.length === 0) {
86
+ throw new AdminMutationError('validation', 'Select at least one record to delete.');
87
+ }
88
+ // L'interface ne peut cocher que ce qu'elle affiche, et une page plafonne
89
+ // à 200 lignes : au-delà la requête est forgée, et un `IN (…)` de plusieurs
90
+ // milliers d'éléments est un vecteur de charge à lui seul.
91
+ if (ids.length > MAX_BULK_IDS) {
92
+ throw new AdminMutationError('validation', `Cannot delete more than ${MAX_BULK_IDS} records at once.`);
93
+ }
94
+ const coerced = ids.map((id) => coerceId(id, model));
95
+ const scope = modelScopeFrom(runtime, model, { locals: event.locals });
96
+ // Instantané AVANT suppression, et seulement si un puits d'audit existe :
97
+ // sans lui le journal serait muet sur l'opération la plus destructive.
98
+ // Lu avec la même portée que la suppression, donc exactement les lignes qui
99
+ // vont partir.
100
+ const idFilter = { op: 'in', field: primaryKeyOf(model), value: coerced };
101
+ const before = audit
102
+ ? await runtime.adapter.data.findMany(model, {
103
+ filter: scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter
104
+ })
105
+ : [];
106
+ let deleted;
107
+ try {
108
+ deleted = await runtime.adapter.data.deleteMany(model, coerced, scope);
109
+ }
110
+ catch (e) {
111
+ throw classifyWriteError(e, 'delete') ?? e;
112
+ }
113
+ for (const row of before) {
114
+ await emitAudit(audit, buildAuditEvent({
115
+ event,
116
+ action: 'delete',
117
+ model,
118
+ id: row[primaryKeyOf(model)],
119
+ hidden: runtime.hiddenFieldsOf(model),
120
+ before: row
121
+ }));
122
+ }
123
+ return new Response(null, {
124
+ status: 303,
125
+ headers: {
126
+ Location: `${runtime.basePath}/${route.model.toLowerCase()}?deleted=${deleted}`
127
+ }
128
+ });
129
+ }
130
+ if (action === 'create' || action === 'update') {
131
+ const { data, invalid } = formDataToPrisma(formData, model);
132
+ // Refusé d'entrée : la valeur ne se convertit pas vers le type de la
133
+ // colonne (JSON illisible, nombre qui n'en est pas un), donc il n'y a même
134
+ // pas de payload à valider plus loin. Un seul champ est rapporté, comme
135
+ // partout ailleurs sur ce chemin — le formulaire re-rendu porte une erreur,
136
+ // pas une liste.
137
+ //
138
+ // Aucun conflit avec l'imposition du scope plus bas : le formulaire de
139
+ // création rend la colonne de tenant VIDE, et un vide n'est pas une valeur
140
+ // illisible (voir `formDataToPrisma`), donc rien ne devient incréable ici.
141
+ //
142
+ // Les scalaires de relation en sont exclus : la boucle FK plus bas fait sa
143
+ // propre coercion en relisant le FormData, et rattache son refus à l'arête
144
+ // (`author: invalid id`) plutôt qu'au scalaire (`authorId`). Contrairement
145
+ // au contrôle du vide, ce garde-ci n'est pas redondant avec l'ordre des
146
+ // boucles : `parseInt('abc')` produit un NaN que celle-ci verrait en
147
+ // premier, et le message perdrait le nom de la relation.
148
+ const firstInvalid = invalid.find((name) => !runtime.relationGraph?.scalarToRelation.has(name));
149
+ if (firstInvalid !== undefined) {
150
+ throw new AdminMutationError('validation', `${firstInvalid}: invalid value`, firstInvalid);
151
+ }
152
+ // Appelé tôt pour échouer vite sur un scope non injectable (`or`, opérateur
153
+ // autre que `eq`, tenant absent), avant tout travail de validation.
154
+ // Volontairement PAS appliqué ici : `data` doit conserver ce que le client
155
+ // a soumis, sinon la confrontation au scope plus bas ne verrait plus que la
156
+ // valeur déjà corrigée, et ne lèverait que pour les scalaires de relation
157
+ // — les seuls que la boucle FK réécrit.
158
+ const scopeValues = modelScopeValues(runtime, model, { locals: event.locals });
159
+ const m2mInput = {};
160
+ const targetGuards = [];
161
+ // Colonnes de texte sensibles (`password`, `passwordHash`, `apiToken`…).
162
+ // Le formulaire d'édition ne les rend pas — cf. `Form.svelte` — donc rien
163
+ // de légitime n'arrive par là ; une valeur présente vient d'un POST forgé
164
+ // ou d'un client qui invente un champ, et l'écrire remplacerait un
165
+ // credential par ce que l'appelant a choisi. La clé sort du payload, sans
166
+ // lever : ce n'est pas une donnée refusée, c'est une donnée non demandée.
167
+ //
168
+ // À la création, à l'inverse, le champ est offert. Un vide y était écrit
169
+ // comme `''`, ce qui produisait un compte au credential inutilisable avec
170
+ // un `303` d'apparence réussie. On refuse plutôt, et seulement quand la
171
+ // colonne est réellement obligatoire — une colonne optionnelle garde le
172
+ // droit d'être vide.
173
+ for (const field of model.fields) {
174
+ if (!isSensitiveStringField(field))
175
+ continue;
176
+ if (action === 'update') {
177
+ delete data[field.name];
178
+ continue;
179
+ }
180
+ const submitted = data[field.name];
181
+ const isEmpty = submitted === undefined || submitted === null || submitted === '';
182
+ if (isEmpty && field.isRequired && !field.hasDefault) {
183
+ throw new AdminMutationError('validation', `${field.name} is required`, field.name);
184
+ }
185
+ // Optionnelle et vide : ne rien écrire plutôt qu'une chaîne vide, qui
186
+ // serait indistinguable d'un secret réellement égal à ''.
187
+ if (isEmpty)
188
+ delete data[field.name];
189
+ }
190
+ /**
191
+ * Revalidation des enums. Le `<select>` rendu par `FieldInput` ne propose
192
+ * que des valeurs déclarées, mais un POST forgé n'y est pas tenu — même
193
+ * raison que la revalidation des cibles FK/m2m plus bas : ce que
194
+ * l'interface n'aurait pas offert ne doit pas passer sous prétexte qu'elle
195
+ * ne l'aurait pas offert. Sans ça, la valeur inventée part au pilote et
196
+ * ressort en message générique, sans désigner le champ fautif.
197
+ *
198
+ * `schema!` et `get(...)!` sont sûrs par construction : `isEnum` n'est posé
199
+ * qu'à partir de la table des enums du schéma (`parser.ts:220`, et
200
+ * `inspect.ts` côté Drizzle qui alimente les deux ensemble), et un modèle
201
+ * résolu implique un schéma introspecté.
202
+ *
203
+ * Le vide se décide sur `isRequired` seul, sans le `!hasDefault` de la
204
+ * boucle au-dessus : celle-ci couvre une création où le défaut de la base
205
+ * peut encore remplir la colonne, alors qu'ici « vide » vise une colonne
206
+ * qui n'accepte pas NULL — un `@default` n'y change rien.
207
+ */
208
+ const schemaEnums = runtime.schema.enums;
209
+ for (const field of model.fields) {
210
+ if (!field.isEnum || !(field.name in data))
211
+ continue;
212
+ // Le vide (ce que poste le « — aucun — » du widget) ne se décide pas ici
213
+ // mais dans le contrôle unique en fin de fonction, après l'imposition du
214
+ // scope : une colonne de tenant qui se trouve être un enum est rendue
215
+ // vide par le formulaire de création, et la refuser ici rendrait le
216
+ // modèle incréable. Cette boucle ne valide plus que le domaine.
217
+ if (data[field.name] === null)
218
+ continue;
219
+ if (!schemaEnums.get(field.type).includes(String(data[field.name]))) {
220
+ throw new AdminMutationError('validation', `${field.name}: invalid value`, field.name);
221
+ }
222
+ }
223
+ // Validation des FK owning : coercion + existence + self-ref.
224
+ // Rejoue le `where` de scoping : un ID hors du where est rejeté,
225
+ // pas seulement caché du select (IDOR par POST forgé).
226
+ if (runtime.relationGraph) {
227
+ for (const edge of runtime.relationGraph.edges.values()) {
228
+ if (edge.model !== model.name || edge.kind !== 'to-one-owning')
229
+ continue;
230
+ if (edge.unsupported)
231
+ continue;
232
+ const scalarName = edge.scalarFields[0];
233
+ // Lu directement depuis le FormData plutôt que `data` :
234
+ // `formDataToPrisma` omet la clé pour un scalaire required
235
+ // laissé vide, donc `data[scalarName]` ne suffirait pas ici.
236
+ const raw = formData.get(scalarName);
237
+ if (raw === null)
238
+ continue;
239
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
240
+ // Vide sur relation optionnelle → null (disconnect).
241
+ if (raw === '' || raw === undefined || raw === null) {
242
+ if (edge.isRequired) {
243
+ throw new AdminMutationError('validation', `${edge.field} is required`, edge.field);
244
+ }
245
+ data[scalarName] = null;
246
+ continue;
247
+ }
248
+ // Coercion vers le type de la PK cible. `targetModel` existe
249
+ // toujours : le graphe n'aurait pas produit d'arête sinon.
250
+ const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
251
+ const pkField = targetModel.fields.find((f) => f.isId);
252
+ const coerced = pkField?.type === 'Int' ? parseInt(String(raw)) : String(raw);
253
+ if (pkField?.type === 'Int' && !Number.isSafeInteger(coerced)) {
254
+ throw new AdminMutationError('validation', `${edge.field}: invalid id`, edge.field);
255
+ }
256
+ // Self-ref : la ligne courante ne peut pas être sa propre cible.
257
+ if (edge.selfReferential && route.id && String(coerced) === String(coerceId(route.id, model))) {
258
+ throw new AdminMutationError('validation', `${edge.field}: cannot reference itself`, edge.field);
259
+ }
260
+ // Existence + scoping. findFirst et non findUnique : le where
261
+ // peut porter des conditions arbitraires (scoping multi-tenant).
262
+ // Si le client ne sait pas répondre, on ne bloque pas l'écriture.
263
+ const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
264
+ const modelFilter = modelScopeFrom(runtime, targetModel, { locals: event.locals });
265
+ try {
266
+ const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: coerced };
267
+ const scopes = [scopeFilter, modelFilter].filter(Boolean);
268
+ const filter = scopes.length ? { op: 'and', clauses: [idFilter, ...scopes] } : idFilter;
269
+ targetGuards.push({ targetModel, targetPk: coerced, filter: scopes.length ? { op: 'and', clauses: scopes } : undefined });
270
+ const found = await runtime.adapter.data.findFirst(targetModel, filter);
271
+ if (!found) {
272
+ throw new AdminMutationError('validation', `${edge.field}: invalid value`, edge.field);
273
+ }
274
+ }
275
+ catch (e) {
276
+ // Déjà typée par le `if (!found)` ci-dessus : la relayer telle quelle.
277
+ // Toute autre cause (scope incompilable, champ inconnu, panne pilote)
278
+ // devient le même refus : la valeur soumise n'est pas acceptable, et on
279
+ // ne renvoie jamais au client ce que le pilote a dit.
280
+ if (e instanceof AdminMutationError)
281
+ throw e;
282
+ throw new AdminMutationError('validation', `${edge.field}: invalid value`, edge.field);
283
+ }
284
+ data[scalarName] = coerced;
285
+ }
286
+ // N-N implicite : lit `__rel__<field>` (valeurs cochées) et
287
+ // `__rel_present__<field>` (sentinelle). Sans le sentinelle,
288
+ // le champ est absent du form (readonly/exclu) → no-op.
289
+ // Avec le sentinelle mais zéro valeur cochée → vider la
290
+ // relation (`set: []` / rien à connecter en création).
291
+ for (const edge of runtime.relationGraph.edges.values()) {
292
+ if (edge.model !== model.name || edge.kind !== 'm2m')
293
+ continue;
294
+ // Pas de garde `edge.unsupported` ici : par construction du
295
+ // graphe, `unsupported` n'est jamais posé sur une arête
296
+ // m2m (seulement sur to-one-owning / groupes
297
+ // ambigus, qui retombent toujours en to-one-owning).
298
+ const present = formData.get(`__rel_present__${edge.field}`);
299
+ if (present === null)
300
+ continue;
301
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
302
+ const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
303
+ const targetPk = primaryKeyOf(targetModel);
304
+ const pkIsInt = targetModel.fields.find((f) => f.isId)?.type === 'Int';
305
+ const submitted = formData.getAll(`__rel__${edge.field}`).map(String);
306
+ const rawIds = submitted.length === 1 && submitted[0].includes(',')
307
+ ? submitted[0].split(',').map((s) => s.trim()).filter(Boolean)
308
+ : submitted;
309
+ const ids = rawIds.map((v) => pkIsInt ? parseInt(v) : v);
310
+ if (pkIsInt && ids.some((v) => !Number.isSafeInteger(v))) {
311
+ throw new AdminMutationError('validation', `${edge.field}: invalid id`, edge.field);
312
+ }
313
+ // Existence + scoping en une requête, sur l'ensemble des IDs
314
+ // soumis. Un compte différent = au moins un ID invalide ou
315
+ // hors scoping — IDOR bloqué au même titre que pour les FK.
316
+ if (ids.length > 0) {
317
+ const inFilter = { op: 'in', field: targetPk, value: ids };
318
+ const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
319
+ const modelFilter = modelScopeFrom(runtime, targetModel, { locals: event.locals });
320
+ const scopes = [scopeFilter, modelFilter].filter(Boolean);
321
+ const filter = scopes.length ? { op: 'and', clauses: [inFilter, ...scopes] } : inFilter;
322
+ try {
323
+ const found = await runtime.adapter.data.findMany(targetModel, { filter });
324
+ for (const id of [...new Map(ids.map((id) => [String(id), id])).values()]) {
325
+ targetGuards.push({ targetModel, targetPk: id, filter: scopes.length ? { op: 'and', clauses: scopes } : undefined });
326
+ }
327
+ if (found.length !== new Set(ids.map(String)).size) {
328
+ throw new AdminMutationError('validation', `${edge.field}: invalid value`, edge.field);
329
+ }
330
+ }
331
+ catch (e) {
332
+ // Déjà typée par le contrôle de cardinalité ci-dessus : la relayer
333
+ // telle quelle. Toute autre cause (scope incompilable, champ
334
+ // inconnu, panne pilote) devient le même refus : la valeur soumise
335
+ // n'est pas acceptable, et on ne renvoie jamais au client ce que le
336
+ // pilote a dit.
337
+ if (e instanceof AdminMutationError)
338
+ throw e;
339
+ throw new AdminMutationError('validation', `${edge.field}: invalid value`, edge.field);
340
+ }
341
+ }
342
+ m2mInput[edge.field] = { targetPkField: targetPk, ids };
343
+ }
344
+ }
345
+ // Imposition du scope, en dernier et volontairement après les boucles
346
+ // ci-dessus : elles réécrivent `data[scalarName]` avec la valeur soumise,
347
+ // et la colonne de tenant est presque toujours un scalaire de relation
348
+ // (`organizationId`, `authorId`…). Sans ce passage, un POST forgé créait
349
+ // dans un autre tenant, ou y déplaçait un enregistrement possédé.
350
+ //
351
+ // Une valeur soumise divergente est rejetée, pour toute colonne de scope et
352
+ // pas seulement pour les scalaires de relation : la valeur est déterminée
353
+ // par le serveur, donc une divergence est soit un POST forgé, soit un
354
+ // formulaire qui offre un choix qu'il ne devrait pas offrir. Corriger en
355
+ // silence masquerait les deux. Comparaison par `String` comme ailleurs pour
356
+ // les ids (cf. la garde self-ref), afin qu'un scope numérique et une PK
357
+ // coercée ne divergent pas sur le seul type.
358
+ //
359
+ // Seule une valeur réellement affirmée par le client est confrontée au
360
+ // scope. `formDataToPrisma` renvoie `null` pour un champ présent mais vide,
361
+ // quel que soit son type — et le formulaire de création rend justement la
362
+ // colonne de scope vide. Traiter ce vide comme un conflit rendrait toute
363
+ // création impossible dès que la colonne est visible.
364
+ // Un vide veut dire « le formulaire n'a rien fourni », pas « le client
365
+ // revendique un autre tenant » : on impose alors la valeur sans lever.
366
+ //
367
+ // L'affectation est HORS du `if` : c'est elle qui porte la garantie, pas la
368
+ // comparaison. Replier ceci en `if (…) { … } else { … }` — un nettoyage
369
+ // d'apparence anodine — réintroduirait la faille dès qu'une comparaison
370
+ // `String` coïncide par accident.
371
+ for (const [field, value] of Object.entries(scopeValues)) {
372
+ const submitted = data[field];
373
+ const asserted = field in data && submitted !== null && submitted !== undefined && submitted !== '';
374
+ if (asserted && String(submitted) !== String(value)) {
375
+ throw new AdminMutationError('authorization', `${field}: value is outside the authorization scope`, field);
376
+ }
377
+ data[field] = value;
378
+ }
379
+ /**
380
+ * Vide sur une colonne qui n'accepte pas NULL : refus, et le champ fautif
381
+ * est nommé. `formDataToPrisma` distingue déjà les deux sens du mot vide —
382
+ * une clé ABSENTE n'a pas été soumise (readonly, masquée, colonne à défaut
383
+ * que le formulaire de création n'affiche pas) et n'écrit rien ; une clé
384
+ * PRÉSENTE à `null` est une saisie vidée.
385
+ *
386
+ * Placé ici, en dernier, et pas plus haut avec les autres validations :
387
+ *
388
+ * - après l'imposition du scope, parce que le formulaire de création rend
389
+ * la colonne de tenant vide (voir le bloc juste au-dessus). À ce point la
390
+ * valeur imposée par le serveur est déjà posée, donc plus rien à refuser ;
391
+ * - après la boucle FK, qui rattache son refus à l'arête (`author is
392
+ * required`) et non au scalaire (`authorId`). C'est cet ordre qui lui en
393
+ * laisse la propriété, pas un garde ici : une arête optionnelle a un
394
+ * scalaire optionnel (Prisma lie les deux), donc le seul scalaire de
395
+ * relation qui puisse encore être `null` en arrivant ici appartient à une
396
+ * arête que la boucle FK ne gère pas — une FK composite, qu'aucun widget
397
+ * ne rend et que seul ce contrôle-ci peut alors nommer.
398
+ *
399
+ * Les champs sensibles n'y passent pas non plus : leur boucle, tout en
400
+ * haut, supprime déjà la clé d'une colonne optionnelle vide plutôt que d'y
401
+ * écrire `null` — `''` y serait indistinguable d'un secret réellement vide.
402
+ *
403
+ * `hasDefault` n'entre pas dans la décision : à la création la colonne à
404
+ * défaut n'est pas rendue, donc la clé est absente ; à l'édition la ligne a
405
+ * déjà une valeur, et la vider est une saisie, pas une absence.
406
+ */
407
+ for (const field of model.fields) {
408
+ if (!field.isRequired || !(field.name in data) || data[field.name] !== null)
409
+ continue;
410
+ throw new AdminMutationError('validation', `${field.name} is required`, field.name);
411
+ }
412
+ if (action === 'create') {
413
+ let created;
414
+ try {
415
+ created = await runtime.adapter.data.createRecord(model, {
416
+ scalars: data,
417
+ m2m: m2mInput,
418
+ targetGuards
419
+ });
420
+ }
421
+ catch (e) {
422
+ // Classé ici et non dans `handler.ts` : seul ce site connaît l'action
423
+ // réellement en cours, et `reference` / `restrict` partagent le même code
424
+ // SQLSTATE — c'est l'action qui les sépare. Un code non reconnu est relayé
425
+ // tel quel, et c'est le handler qui le masquera.
426
+ throw classifyWriteError(e, 'create') ?? e;
427
+ }
428
+ if (audit) {
429
+ await emitAudit(audit, buildAuditEvent({
430
+ event,
431
+ action: 'create',
432
+ model,
433
+ id: created[primaryKeyOf(model)],
434
+ hidden: runtime.hiddenFieldsOf(model),
435
+ values: data,
436
+ m2m: m2mInput,
437
+ after: created
438
+ }));
439
+ }
440
+ }
441
+ else if (route.id) {
442
+ const id = coerceId(route.id, model);
443
+ if (!(await scopedRecord(id)))
444
+ return null;
445
+ const before = audit
446
+ ? await readAuditSnapshot((m, recId) => runtime.adapter.data.getRecord(m, recId), model, id)
447
+ : null;
448
+ let updated;
449
+ try {
450
+ updated = await runtime.adapter.data.updateRecord(model, route.id, { scalars: data, m2m: m2mInput, targetGuards }, modelScopeFrom(runtime, model, { locals: event.locals }));
451
+ }
452
+ catch (e) {
453
+ // Classé ici et non dans `handler.ts` : seul ce site connaît l'action
454
+ // réellement en cours, et `reference` / `restrict` partagent le même code
455
+ // SQLSTATE — c'est l'action qui les sépare. Un code non reconnu est relayé
456
+ // tel quel, et c'est le handler qui le masquera.
457
+ throw classifyWriteError(e, 'update') ?? e;
458
+ }
459
+ if (audit) {
460
+ await emitAudit(audit, buildAuditEvent({
461
+ event,
462
+ action: 'update',
463
+ model,
464
+ id,
465
+ hidden: runtime.hiddenFieldsOf(model),
466
+ values: data,
467
+ m2m: m2mInput,
468
+ before,
469
+ after: updated
470
+ }));
471
+ }
472
+ }
473
+ return redirectToList(route.model);
474
+ }
475
+ return null;
476
+ }
@@ -0,0 +1,47 @@
1
+ import type { Filter } from './adapters/types.js';
2
+ import type { RelationGraph } from './introspection/relations.js';
3
+ import type { Model } from './types/schema.js';
4
+ export interface AdminPlugin {
5
+ name: string;
6
+ pages?: AdminPluginPage[];
7
+ recordActions?: AdminPluginRecordAction[];
8
+ }
9
+ export interface AdminPluginPage {
10
+ pattern: string[];
11
+ models?: string[];
12
+ render: (ctx: PluginPageContext) => PluginPageResult | Promise<PluginPageResult>;
13
+ }
14
+ export interface PluginPageResult {
15
+ html: string;
16
+ styles?: string;
17
+ scripts?: string;
18
+ }
19
+ export interface AdminPluginRecordAction {
20
+ label: string;
21
+ models?: string[];
22
+ href: (ctx: {
23
+ model: string;
24
+ id: string | number;
25
+ basePath: string;
26
+ }) => string;
27
+ }
28
+ export interface PluginPageContext {
29
+ event: any;
30
+ route: {
31
+ view: string;
32
+ model?: string;
33
+ id?: string;
34
+ };
35
+ basePath: string;
36
+ /** Set only when the page pattern captures `:id` (after a 404 skip). */
37
+ record?: Record<string, unknown>;
38
+ escapeHtml: (s: string) => string;
39
+ findModel: (name?: string) => Model | undefined;
40
+ relationGraph: RelationGraph | null;
41
+ resolveLabel: (target: Model, row: Record<string, unknown>, labelTemplate?: string) => string;
42
+ hiddenFieldsOf: (model: Model) => Set<string>;
43
+ isSensitiveFieldName: (name: string) => boolean;
44
+ loadRecord: (modelName: string, id: string | number) => Promise<Record<string, unknown> | null>;
45
+ listRecords: (modelName: string, extraFilter?: Filter) => Promise<Record<string, unknown>[]>;
46
+ getM2mSelectedIds: (modelName: string, fieldName: string, recordId: string | number) => Promise<Array<string | number>>;
47
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { PluginPageContext } from './plugin.js';
2
+ import { type AdminRuntime } from './runtime.js';
3
+ export declare function createPluginPageContext(runtime: AdminRuntime, event: any, route: {
4
+ view: string;
5
+ model?: string;
6
+ id?: string;
7
+ }, record?: Record<string, unknown>): PluginPageContext;