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.
- package/README.md +61 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/server/adapters/drizzle/dataAdapter.js +196 -42
- package/dist/server/adapters/drizzle/index.d.ts +10 -1
- package/dist/server/adapters/drizzle/index.js +4 -0
- package/dist/server/adapters/prisma/dataAdapter.js +71 -11
- package/dist/server/adapters/prisma/handler.d.ts +14 -0
- package/dist/server/adapters/prisma/handler.js +37 -0
- package/dist/server/adapters/retry.d.ts +27 -0
- package/dist/server/adapters/retry.js +53 -0
- package/dist/server/adapters/types.d.ts +42 -3
- package/dist/server/audit.d.ts +65 -0
- package/dist/server/audit.js +106 -0
- package/dist/server/csrf.d.ts +33 -0
- package/dist/server/csrf.js +55 -0
- package/dist/server/data.d.ts +18 -2
- package/dist/server/data.js +39 -8
- package/dist/server/errors.d.ts +47 -0
- package/dist/server/errors.js +90 -0
- package/dist/server/handler.d.ts +92 -26
- package/dist/server/handler.js +263 -560
- package/dist/server/introspection/parser.d.ts +15 -0
- package/dist/server/introspection/parser.js +17 -0
- package/dist/server/mutations.d.ts +13 -0
- package/dist/server/mutations.js +476 -0
- package/dist/server/plugin.d.ts +47 -0
- package/dist/server/plugin.js +1 -0
- package/dist/server/pluginAccess.d.ts +7 -0
- package/dist/server/pluginAccess.js +79 -0
- package/dist/server/pluginRegistry.d.ts +12 -0
- package/dist/server/pluginRegistry.js +72 -0
- package/dist/server/query/listColumns.d.ts +19 -0
- package/dist/server/query/listColumns.js +43 -0
- package/dist/server/query/listQuery.d.ts +1 -1
- package/dist/server/query/pageSize.d.ts +19 -0
- package/dist/server/query/pageSize.js +26 -0
- package/dist/server/query/sortQuery.d.ts +31 -0
- package/dist/server/query/sortQuery.js +33 -0
- package/dist/server/query/urls.js +9 -1
- package/dist/server/relationLoaders.d.ts +42 -0
- package/dist/server/relationLoaders.js +188 -0
- package/dist/server/router.d.ts +10 -0
- package/dist/server/router.js +42 -19
- package/dist/server/runtime.d.ts +51 -0
- package/dist/server/runtime.js +263 -0
- package/dist/server/search.d.ts +14 -0
- package/dist/server/search.js +78 -0
- package/dist/server/submitted.d.ts +20 -0
- package/dist/server/submitted.js +54 -0
- package/dist/server/views/FieldInput.svelte +114 -8
- package/dist/server/views/FieldInput.svelte.d.ts +7 -0
- package/dist/server/views/Form.svelte +96 -5
- package/dist/server/views/Form.svelte.d.ts +19 -1
- package/dist/server/views/Layout.svelte +32 -4
- package/dist/server/views/Layout.svelte.d.ts +2 -0
- package/dist/server/views/List.svelte +156 -26
- package/dist/server/views/List.svelte.d.ts +7 -2
- package/dist/server/views/RelationCheckboxes.svelte +26 -5
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +14 -3
- package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
- package/dist/server/views/html.d.ts +19 -0
- package/dist/server/views/html.js +25 -0
- package/dist/server/views/pagination.d.ts +9 -0
- package/dist/server/views/pagination.js +31 -0
- package/dist/server/views/theme.js +147 -3
- package/dist/server/views/types.d.ts +15 -0
- 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;
|