sveltekit-admin 0.6.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +121 -36
- 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 +52 -10
- 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 +9 -2
- 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/errors.d.ts +47 -0
- package/dist/server/errors.js +90 -0
- package/dist/server/handler.d.ts +57 -26
- package/dist/server/handler.js +212 -561
- package/dist/server/mutations.d.ts +9 -0
- package/dist/server/mutations.js +295 -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/listQuery.d.ts +1 -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 +46 -0
- package/dist/server/runtime.js +210 -0
- package/dist/server/search.d.ts +14 -0
- package/dist/server/search.js +78 -0
- package/dist/server/views/Form.svelte +26 -2
- package/dist/server/views/Form.svelte.d.ts +2 -1
- package/dist/server/views/Layout.svelte +27 -2
- package/dist/server/views/Layout.svelte.d.ts +2 -0
- package/dist/server/views/List.svelte +28 -3
- package/dist/server/views/List.svelte.d.ts +2 -2
- package/dist/server/views/types.d.ts +8 -0
- package/package.json +23 -21
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST create/update/delete handling — split out of `handler.ts`, pure
|
|
3
|
+
* orchestration over `AdminRuntime`. Reads `formData` unconditionally: the
|
|
4
|
+
* handler only calls this on `event.request.method === 'POST'`, so the
|
|
5
|
+
* request body is never consumed on GET.
|
|
6
|
+
*/
|
|
7
|
+
import type { ParsedRoute } from './router.js';
|
|
8
|
+
import { type AdminRuntime } from './runtime.js';
|
|
9
|
+
export declare function handleMutation(runtime: AdminRuntime, event: any, route: ParsedRoute): Promise<Response | null>;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST create/update/delete handling — split out of `handler.ts`, pure
|
|
3
|
+
* orchestration over `AdminRuntime`. Reads `formData` unconditionally: the
|
|
4
|
+
* handler only calls this on `event.request.method === 'POST'`, so the
|
|
5
|
+
* request body is never consumed on GET.
|
|
6
|
+
*/
|
|
7
|
+
import { primaryKeyOf, coerceId, formDataToPrisma } from './data.js';
|
|
8
|
+
import { AdminMutationError, classifyWriteError } from './errors.js';
|
|
9
|
+
import { buildAuditEvent, emitAudit, readAuditSnapshot } from './audit.js';
|
|
10
|
+
import { scopeFrom, modelScopeFrom, modelScopeValues } from './runtime.js';
|
|
11
|
+
export async function handleMutation(runtime, event, route) {
|
|
12
|
+
const modelsConfig = runtime.config.models ?? {};
|
|
13
|
+
const audit = runtime.config.audit;
|
|
14
|
+
const formData = await event.request.formData();
|
|
15
|
+
const action = formData.get('_action');
|
|
16
|
+
if (!route.model)
|
|
17
|
+
return null;
|
|
18
|
+
const model = runtime.findModel(route.model);
|
|
19
|
+
if (!model) {
|
|
20
|
+
throw new AdminMutationError('notFound', `Model "${route.model}" not found`);
|
|
21
|
+
}
|
|
22
|
+
const redirectToList = (modelName) => new Response(null, {
|
|
23
|
+
status: 303,
|
|
24
|
+
headers: { Location: `${runtime.basePath}/${modelName.toLowerCase()}` }
|
|
25
|
+
});
|
|
26
|
+
const scopedRecord = async (id) => {
|
|
27
|
+
const modelScope = modelScopeFrom(runtime, model, { locals: event.locals });
|
|
28
|
+
if (!modelScope)
|
|
29
|
+
return true;
|
|
30
|
+
return runtime.adapter.data.findFirst(model, {
|
|
31
|
+
op: 'and',
|
|
32
|
+
clauses: [{ op: 'eq', field: primaryKeyOf(model), value: coerceId(String(id), model) }, modelScope]
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
if (action === 'delete' && route.id) {
|
|
36
|
+
const id = coerceId(route.id, model);
|
|
37
|
+
if (!(await scopedRecord(id)))
|
|
38
|
+
return null;
|
|
39
|
+
const before = audit
|
|
40
|
+
? await readAuditSnapshot((m, recId) => runtime.adapter.data.getRecord(m, recId), model, id)
|
|
41
|
+
: null;
|
|
42
|
+
try {
|
|
43
|
+
await runtime.adapter.data.deleteRecord(model, route.id, modelScopeFrom(runtime, model, { locals: event.locals }));
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action réelle
|
|
47
|
+
// (`handleMutation` a déjà consommé le corps de la requête, donc le handler
|
|
48
|
+
// ne peut plus lire `_action`). Un code non reconnu est relayé tel quel, et
|
|
49
|
+
// c'est le handler qui le masquera.
|
|
50
|
+
throw classifyWriteError(e, 'delete') ?? e;
|
|
51
|
+
}
|
|
52
|
+
if (audit) {
|
|
53
|
+
await emitAudit(audit, buildAuditEvent({
|
|
54
|
+
event,
|
|
55
|
+
action: 'delete',
|
|
56
|
+
model,
|
|
57
|
+
id,
|
|
58
|
+
hidden: runtime.hiddenFieldsOf(model),
|
|
59
|
+
before
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
return redirectToList(route.model);
|
|
63
|
+
}
|
|
64
|
+
if (action === 'create' || action === 'update') {
|
|
65
|
+
const data = formDataToPrisma(formData, model);
|
|
66
|
+
// Appelé tôt pour échouer vite sur un scope non injectable (`or`, opérateur
|
|
67
|
+
// autre que `eq`, tenant absent), avant tout travail de validation.
|
|
68
|
+
// Volontairement PAS appliqué ici : `data` doit conserver ce que le client
|
|
69
|
+
// a soumis, sinon la confrontation au scope plus bas ne verrait plus que la
|
|
70
|
+
// valeur déjà corrigée, et ne lèverait que pour les scalaires de relation
|
|
71
|
+
// — les seuls que la boucle FK réécrit.
|
|
72
|
+
const scopeValues = modelScopeValues(runtime, model, { locals: event.locals });
|
|
73
|
+
const m2mInput = {};
|
|
74
|
+
const targetGuards = [];
|
|
75
|
+
// Validation des FK owning : coercion + existence + self-ref.
|
|
76
|
+
// Rejoue le `where` de scoping : un ID hors du where est rejeté,
|
|
77
|
+
// pas seulement caché du select (IDOR par POST forgé).
|
|
78
|
+
if (runtime.relationGraph) {
|
|
79
|
+
for (const edge of runtime.relationGraph.edges.values()) {
|
|
80
|
+
if (edge.model !== model.name || edge.kind !== 'to-one-owning')
|
|
81
|
+
continue;
|
|
82
|
+
if (edge.unsupported)
|
|
83
|
+
continue;
|
|
84
|
+
const scalarName = edge.scalarFields[0];
|
|
85
|
+
// Lu directement depuis le FormData plutôt que `data` :
|
|
86
|
+
// `formDataToPrisma` omet la clé pour un scalaire required
|
|
87
|
+
// laissé vide, donc `data[scalarName]` ne suffirait pas ici.
|
|
88
|
+
const raw = formData.get(scalarName);
|
|
89
|
+
if (raw === null)
|
|
90
|
+
continue;
|
|
91
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
92
|
+
// Vide sur relation optionnelle → null (disconnect).
|
|
93
|
+
if (raw === '' || raw === undefined || raw === null) {
|
|
94
|
+
if (edge.isRequired) {
|
|
95
|
+
throw new AdminMutationError('validation', `${edge.field} is required`, edge.field);
|
|
96
|
+
}
|
|
97
|
+
data[scalarName] = null;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Coercion vers le type de la PK cible. `targetModel` existe
|
|
101
|
+
// toujours : le graphe n'aurait pas produit d'arête sinon.
|
|
102
|
+
const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
|
|
103
|
+
const pkField = targetModel.fields.find((f) => f.isId);
|
|
104
|
+
const coerced = pkField?.type === 'Int' ? parseInt(String(raw)) : String(raw);
|
|
105
|
+
if (pkField?.type === 'Int' && !Number.isSafeInteger(coerced)) {
|
|
106
|
+
throw new AdminMutationError('validation', `${edge.field}: invalid id`, edge.field);
|
|
107
|
+
}
|
|
108
|
+
// Self-ref : la ligne courante ne peut pas être sa propre cible.
|
|
109
|
+
if (edge.selfReferential && route.id && String(coerced) === String(coerceId(route.id, model))) {
|
|
110
|
+
throw new AdminMutationError('validation', `${edge.field}: cannot reference itself`, edge.field);
|
|
111
|
+
}
|
|
112
|
+
// Existence + scoping. findFirst et non findUnique : le where
|
|
113
|
+
// peut porter des conditions arbitraires (scoping multi-tenant).
|
|
114
|
+
// Si le client ne sait pas répondre, on ne bloque pas l'écriture.
|
|
115
|
+
const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
|
|
116
|
+
const modelFilter = modelScopeFrom(runtime, targetModel, { locals: event.locals });
|
|
117
|
+
try {
|
|
118
|
+
const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: coerced };
|
|
119
|
+
const scopes = [scopeFilter, modelFilter].filter(Boolean);
|
|
120
|
+
const filter = scopes.length ? { op: 'and', clauses: [idFilter, ...scopes] } : idFilter;
|
|
121
|
+
targetGuards.push({ targetModel, targetPk: coerced, filter: scopes.length ? { op: 'and', clauses: scopes } : undefined });
|
|
122
|
+
const found = await runtime.adapter.data.findFirst(targetModel, filter);
|
|
123
|
+
if (!found) {
|
|
124
|
+
throw new AdminMutationError('validation', `${edge.field}: invalid value`, edge.field);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// Déjà typée par le `if (!found)` ci-dessus : la relayer telle quelle.
|
|
129
|
+
// Toute autre cause (scope incompilable, champ inconnu, panne pilote)
|
|
130
|
+
// devient le même refus : la valeur soumise n'est pas acceptable, et on
|
|
131
|
+
// ne renvoie jamais au client ce que le pilote a dit.
|
|
132
|
+
if (e instanceof AdminMutationError)
|
|
133
|
+
throw e;
|
|
134
|
+
throw new AdminMutationError('validation', `${edge.field}: invalid value`, edge.field);
|
|
135
|
+
}
|
|
136
|
+
data[scalarName] = coerced;
|
|
137
|
+
}
|
|
138
|
+
// N-N implicite : lit `__rel__<field>` (valeurs cochées) et
|
|
139
|
+
// `__rel_present__<field>` (sentinelle). Sans le sentinelle,
|
|
140
|
+
// le champ est absent du form (readonly/exclu) → no-op.
|
|
141
|
+
// Avec le sentinelle mais zéro valeur cochée → vider la
|
|
142
|
+
// relation (`set: []` / rien à connecter en création).
|
|
143
|
+
for (const edge of runtime.relationGraph.edges.values()) {
|
|
144
|
+
if (edge.model !== model.name || edge.kind !== 'm2m')
|
|
145
|
+
continue;
|
|
146
|
+
// Pas de garde `edge.unsupported` ici : par construction du
|
|
147
|
+
// graphe, `unsupported` n'est jamais posé sur une arête
|
|
148
|
+
// m2m (seulement sur to-one-owning / groupes
|
|
149
|
+
// ambigus, qui retombent toujours en to-one-owning).
|
|
150
|
+
const present = formData.get(`__rel_present__${edge.field}`);
|
|
151
|
+
if (present === null)
|
|
152
|
+
continue;
|
|
153
|
+
const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
|
|
154
|
+
const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
|
|
155
|
+
const targetPk = primaryKeyOf(targetModel);
|
|
156
|
+
const pkIsInt = targetModel.fields.find((f) => f.isId)?.type === 'Int';
|
|
157
|
+
const submitted = formData.getAll(`__rel__${edge.field}`).map(String);
|
|
158
|
+
const rawIds = submitted.length === 1 && submitted[0].includes(',')
|
|
159
|
+
? submitted[0].split(',').map((s) => s.trim()).filter(Boolean)
|
|
160
|
+
: submitted;
|
|
161
|
+
const ids = rawIds.map((v) => pkIsInt ? parseInt(v) : v);
|
|
162
|
+
if (pkIsInt && ids.some((v) => !Number.isSafeInteger(v))) {
|
|
163
|
+
throw new AdminMutationError('validation', `${edge.field}: invalid id`, edge.field);
|
|
164
|
+
}
|
|
165
|
+
// Existence + scoping en une requête, sur l'ensemble des IDs
|
|
166
|
+
// soumis. Un compte différent = au moins un ID invalide ou
|
|
167
|
+
// hors scoping — IDOR bloqué au même titre que pour les FK.
|
|
168
|
+
if (ids.length > 0) {
|
|
169
|
+
const inFilter = { op: 'in', field: targetPk, value: ids };
|
|
170
|
+
const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
|
|
171
|
+
const modelFilter = modelScopeFrom(runtime, targetModel, { locals: event.locals });
|
|
172
|
+
const scopes = [scopeFilter, modelFilter].filter(Boolean);
|
|
173
|
+
const filter = scopes.length ? { op: 'and', clauses: [inFilter, ...scopes] } : inFilter;
|
|
174
|
+
try {
|
|
175
|
+
const found = await runtime.adapter.data.findMany(targetModel, { filter });
|
|
176
|
+
for (const id of [...new Map(ids.map((id) => [String(id), id])).values()]) {
|
|
177
|
+
targetGuards.push({ targetModel, targetPk: id, filter: scopes.length ? { op: 'and', clauses: scopes } : undefined });
|
|
178
|
+
}
|
|
179
|
+
if (found.length !== new Set(ids.map(String)).size) {
|
|
180
|
+
throw new AdminMutationError('validation', `${edge.field}: invalid value`, edge.field);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
// Déjà typée par le contrôle de cardinalité ci-dessus : la relayer
|
|
185
|
+
// telle quelle. Toute autre cause (scope incompilable, champ
|
|
186
|
+
// inconnu, panne pilote) devient le même refus : la valeur soumise
|
|
187
|
+
// n'est pas acceptable, et on ne renvoie jamais au client ce que le
|
|
188
|
+
// pilote a dit.
|
|
189
|
+
if (e instanceof AdminMutationError)
|
|
190
|
+
throw e;
|
|
191
|
+
throw new AdminMutationError('validation', `${edge.field}: invalid value`, edge.field);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
m2mInput[edge.field] = { targetPkField: targetPk, ids };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Imposition du scope, en dernier et volontairement après les boucles
|
|
198
|
+
// ci-dessus : elles réécrivent `data[scalarName]` avec la valeur soumise,
|
|
199
|
+
// et la colonne de tenant est presque toujours un scalaire de relation
|
|
200
|
+
// (`organizationId`, `authorId`…). Sans ce passage, un POST forgé créait
|
|
201
|
+
// dans un autre tenant, ou y déplaçait un enregistrement possédé.
|
|
202
|
+
//
|
|
203
|
+
// Une valeur soumise divergente est rejetée, pour toute colonne de scope et
|
|
204
|
+
// pas seulement pour les scalaires de relation : la valeur est déterminée
|
|
205
|
+
// par le serveur, donc une divergence est soit un POST forgé, soit un
|
|
206
|
+
// formulaire qui offre un choix qu'il ne devrait pas offrir. Corriger en
|
|
207
|
+
// silence masquerait les deux. Comparaison par `String` comme ailleurs pour
|
|
208
|
+
// les ids (cf. la garde self-ref), afin qu'un scope numérique et une PK
|
|
209
|
+
// coercée ne divergent pas sur le seul type.
|
|
210
|
+
//
|
|
211
|
+
// Seule une valeur réellement affirmée par le client est confrontée au
|
|
212
|
+
// scope. `formDataToPrisma` renvoie `''` (String) ou `null` (Int, Float,
|
|
213
|
+
// DateTime) pour un champ présent mais vide — et le formulaire de création
|
|
214
|
+
// rend justement la colonne de scope vide. Traiter ce vide comme un conflit
|
|
215
|
+
// rendrait toute création impossible dès que la colonne est visible.
|
|
216
|
+
// Un vide veut dire « le formulaire n'a rien fourni », pas « le client
|
|
217
|
+
// revendique un autre tenant » : on impose alors la valeur sans lever.
|
|
218
|
+
//
|
|
219
|
+
// L'affectation est HORS du `if` : c'est elle qui porte la garantie, pas la
|
|
220
|
+
// comparaison. Replier ceci en `if (…) { … } else { … }` — un nettoyage
|
|
221
|
+
// d'apparence anodine — réintroduirait la faille dès qu'une comparaison
|
|
222
|
+
// `String` coïncide par accident.
|
|
223
|
+
for (const [field, value] of Object.entries(scopeValues)) {
|
|
224
|
+
const submitted = data[field];
|
|
225
|
+
const asserted = field in data && submitted !== null && submitted !== undefined && submitted !== '';
|
|
226
|
+
if (asserted && String(submitted) !== String(value)) {
|
|
227
|
+
throw new AdminMutationError('authorization', `${field}: value is outside the authorization scope`, field);
|
|
228
|
+
}
|
|
229
|
+
data[field] = value;
|
|
230
|
+
}
|
|
231
|
+
if (action === 'create') {
|
|
232
|
+
let created;
|
|
233
|
+
try {
|
|
234
|
+
created = await runtime.adapter.data.createRecord(model, {
|
|
235
|
+
scalars: data,
|
|
236
|
+
m2m: m2mInput,
|
|
237
|
+
targetGuards
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action réelle
|
|
242
|
+
// (`handleMutation` a déjà consommé le corps de la requête, donc le handler
|
|
243
|
+
// ne peut plus lire `_action`). Un code non reconnu est relayé tel quel, et
|
|
244
|
+
// c'est le handler qui le masquera.
|
|
245
|
+
throw classifyWriteError(e, 'create') ?? e;
|
|
246
|
+
}
|
|
247
|
+
if (audit) {
|
|
248
|
+
await emitAudit(audit, buildAuditEvent({
|
|
249
|
+
event,
|
|
250
|
+
action: 'create',
|
|
251
|
+
model,
|
|
252
|
+
id: created[primaryKeyOf(model)],
|
|
253
|
+
hidden: runtime.hiddenFieldsOf(model),
|
|
254
|
+
values: data,
|
|
255
|
+
m2m: m2mInput,
|
|
256
|
+
after: created
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else if (route.id) {
|
|
261
|
+
const id = coerceId(route.id, model);
|
|
262
|
+
if (!(await scopedRecord(id)))
|
|
263
|
+
return null;
|
|
264
|
+
const before = audit
|
|
265
|
+
? await readAuditSnapshot((m, recId) => runtime.adapter.data.getRecord(m, recId), model, id)
|
|
266
|
+
: null;
|
|
267
|
+
let updated;
|
|
268
|
+
try {
|
|
269
|
+
updated = await runtime.adapter.data.updateRecord(model, route.id, { scalars: data, m2m: m2mInput, targetGuards }, modelScopeFrom(runtime, model, { locals: event.locals }));
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
// Classé ici et non dans `handler.ts` : seul ce site connaît l'action réelle
|
|
273
|
+
// (`handleMutation` a déjà consommé le corps de la requête, donc le handler
|
|
274
|
+
// ne peut plus lire `_action`). Un code non reconnu est relayé tel quel, et
|
|
275
|
+
// c'est le handler qui le masquera.
|
|
276
|
+
throw classifyWriteError(e, 'update') ?? e;
|
|
277
|
+
}
|
|
278
|
+
if (audit) {
|
|
279
|
+
await emitAudit(audit, buildAuditEvent({
|
|
280
|
+
event,
|
|
281
|
+
action: 'update',
|
|
282
|
+
model,
|
|
283
|
+
id,
|
|
284
|
+
hidden: runtime.hiddenFieldsOf(model),
|
|
285
|
+
values: data,
|
|
286
|
+
m2m: m2mInput,
|
|
287
|
+
before,
|
|
288
|
+
after: updated
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return redirectToList(route.model);
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { normalizeScope } from './adapters/filter.js';
|
|
2
|
+
import { redactForAudit } from './audit.js';
|
|
3
|
+
import { coerceId, primaryKeyOf } from './data.js';
|
|
4
|
+
import { isSensitiveFieldName } from './introspection/parser.js';
|
|
5
|
+
import { listScopeFrom, modelScopeFrom, scopeFrom } from './runtime.js';
|
|
6
|
+
import { filterSelectedIds } from './relationLoaders.js';
|
|
7
|
+
import { escapeHtml } from './views/html.js';
|
|
8
|
+
function andFilters(...parts) {
|
|
9
|
+
const clauses = parts.filter((p) => p !== undefined);
|
|
10
|
+
if (clauses.length === 0)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (clauses.length === 1)
|
|
13
|
+
return clauses[0];
|
|
14
|
+
return { op: 'and', clauses: clauses };
|
|
15
|
+
}
|
|
16
|
+
function redactRow(runtime, model, row) {
|
|
17
|
+
return redactForAudit(row, model, runtime.hiddenFieldsOf(model));
|
|
18
|
+
}
|
|
19
|
+
export function createPluginPageContext(runtime, event, route, record) {
|
|
20
|
+
const loadRecord = async (modelName, id) => {
|
|
21
|
+
const model = runtime.findModel(modelName);
|
|
22
|
+
if (!model)
|
|
23
|
+
return null;
|
|
24
|
+
const scope = andFilters(modelScopeFrom(runtime, model, { locals: event.locals }), normalizeScope(listScopeFrom(runtime, model, { locals: event.locals })));
|
|
25
|
+
const idFilter = {
|
|
26
|
+
op: 'eq',
|
|
27
|
+
field: primaryKeyOf(model),
|
|
28
|
+
value: coerceId(String(id), model)
|
|
29
|
+
};
|
|
30
|
+
const filter = (scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter);
|
|
31
|
+
const row = await runtime.adapter.data.findFirst(model, filter);
|
|
32
|
+
return row ? redactRow(runtime, model, row) : null;
|
|
33
|
+
};
|
|
34
|
+
const listRecords = async (modelName, extraFilter) => {
|
|
35
|
+
const model = runtime.findModel(modelName);
|
|
36
|
+
if (!model)
|
|
37
|
+
return [];
|
|
38
|
+
const scope = andFilters(modelScopeFrom(runtime, model, { locals: event.locals }), normalizeScope(listScopeFrom(runtime, model, { locals: event.locals })));
|
|
39
|
+
const filter = andFilters(scope, extraFilter);
|
|
40
|
+
const rows = await runtime.adapter.data.findMany(model, { filter: filter });
|
|
41
|
+
return rows.map((row) => redactRow(runtime, model, row));
|
|
42
|
+
};
|
|
43
|
+
const getM2mSelectedIds = async (modelName, fieldName, recordId) => {
|
|
44
|
+
const model = runtime.findModel(modelName);
|
|
45
|
+
if (!model) {
|
|
46
|
+
throw new Error(`[sveltekit-admin] getM2mSelectedIds: unknown model "${modelName}"`);
|
|
47
|
+
}
|
|
48
|
+
// Non-null par construction : `model` vient toujours de `runtime.models`,
|
|
49
|
+
// dérivé du schéma qu'on a parsé avec succès (même convention que
|
|
50
|
+
// `runtime.ts` `validateListFilterConfig` / `viewModel`).
|
|
51
|
+
const edge = runtime.relationGraph.edges.get(`${model.name}.${fieldName}`);
|
|
52
|
+
if (!edge || edge.kind !== 'm2m') {
|
|
53
|
+
throw new Error(`[sveltekit-admin] getM2mSelectedIds: "${model.name}.${fieldName}" is not an m2m relation`);
|
|
54
|
+
}
|
|
55
|
+
const target = runtime.findModel(edge.target);
|
|
56
|
+
if (!target) {
|
|
57
|
+
throw new Error(`[sveltekit-admin] getM2mSelectedIds: target model "${edge.target}" is not visible`);
|
|
58
|
+
}
|
|
59
|
+
if (!(await loadRecord(model.name, recordId)))
|
|
60
|
+
return [];
|
|
61
|
+
const selected = await runtime.adapter.data.getM2mSelectedIds(model, edge, target, recordId);
|
|
62
|
+
return (await filterSelectedIds(runtime, target, selected, { locals: event.locals }, scopeFrom(runtime.config.models?.[model.name]?.relations?.[fieldName], { locals: event.locals }))) ?? [];
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
event,
|
|
66
|
+
route,
|
|
67
|
+
basePath: runtime.basePath,
|
|
68
|
+
record,
|
|
69
|
+
escapeHtml,
|
|
70
|
+
findModel: runtime.findModel,
|
|
71
|
+
relationGraph: runtime.relationGraph,
|
|
72
|
+
resolveLabel: runtime.resolveLabel,
|
|
73
|
+
hiddenFieldsOf: runtime.hiddenFieldsOf,
|
|
74
|
+
isSensitiveFieldName,
|
|
75
|
+
loadRecord,
|
|
76
|
+
listRecords,
|
|
77
|
+
getM2mSelectedIds
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RouteEntry } from './router.js';
|
|
2
|
+
import type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction } from './plugin.js';
|
|
3
|
+
export interface PluginRegistry {
|
|
4
|
+
routes: RouteEntry[];
|
|
5
|
+
pagesByView: Map<string, AdminPluginPage>;
|
|
6
|
+
recordActions: AdminPluginRecordAction[];
|
|
7
|
+
}
|
|
8
|
+
export declare function pluginViewId(pluginName: string, pattern: string[]): string;
|
|
9
|
+
export declare function resolvePluginRegistry(plugins: AdminPlugin[], builtinRoutes: RouteEntry[], visibleModels: Array<{
|
|
10
|
+
name: string;
|
|
11
|
+
}>): PluginRegistry;
|
|
12
|
+
export declare function actionsForModel(registry: PluginRegistry, modelName: string): AdminPluginRecordAction[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export function pluginViewId(pluginName, pattern) {
|
|
2
|
+
return `plugin/${pluginName}/${pattern.join('/')}`;
|
|
3
|
+
}
|
|
4
|
+
function patternKey(pattern) {
|
|
5
|
+
return pattern.join('\0');
|
|
6
|
+
}
|
|
7
|
+
function modelVisible(visibleModels, entry) {
|
|
8
|
+
return visibleModels.some((m) => m.name.toLowerCase() === entry.toLowerCase());
|
|
9
|
+
}
|
|
10
|
+
function assertKnownModels(entries, visibleModels, pluginName) {
|
|
11
|
+
if (!entries)
|
|
12
|
+
return;
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (!modelVisible(visibleModels, entry)) {
|
|
15
|
+
throw new Error(`[sveltekit-admin] plugin "${pluginName}" models[] includes unknown model "${entry}"`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function resolvePluginRegistry(plugins, builtinRoutes, visibleModels) {
|
|
20
|
+
const builtinKeys = new Map(builtinRoutes.map((r) => [patternKey(r.pattern), r.view]));
|
|
21
|
+
const taken = new Map();
|
|
22
|
+
const names = new Set();
|
|
23
|
+
const routes = [];
|
|
24
|
+
const pagesByView = new Map();
|
|
25
|
+
const recordActions = [];
|
|
26
|
+
for (const plugin of plugins) {
|
|
27
|
+
if (!plugin.name) {
|
|
28
|
+
throw new Error('[sveltekit-admin] plugin name must be a non-empty string');
|
|
29
|
+
}
|
|
30
|
+
if (names.has(plugin.name)) {
|
|
31
|
+
throw new Error(`[sveltekit-admin] duplicate plugin name "${plugin.name}"`);
|
|
32
|
+
}
|
|
33
|
+
names.add(plugin.name);
|
|
34
|
+
for (const page of plugin.pages ?? []) {
|
|
35
|
+
for (const token of page.pattern) {
|
|
36
|
+
if (token.startsWith(':') && token !== ':model' && token !== ':id') {
|
|
37
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" pattern token "${token}" is not :model or :id`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const hasModel = page.pattern.includes(':model');
|
|
41
|
+
const hasId = page.pattern.includes(':id');
|
|
42
|
+
if (page.models && !hasModel) {
|
|
43
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" page ${JSON.stringify(page.pattern)} sets models[] but pattern has no :model`);
|
|
44
|
+
}
|
|
45
|
+
if (hasId && !hasModel) {
|
|
46
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" page ${JSON.stringify(page.pattern)} has :id but pattern has no :model`);
|
|
47
|
+
}
|
|
48
|
+
assertKnownModels(page.models, visibleModels, plugin.name);
|
|
49
|
+
const key = patternKey(page.pattern);
|
|
50
|
+
const builtinView = builtinKeys.get(key);
|
|
51
|
+
if (builtinView !== undefined) {
|
|
52
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" pattern ${JSON.stringify(page.pattern)} overlays builtin route "${builtinView}"`);
|
|
53
|
+
}
|
|
54
|
+
const other = taken.get(key);
|
|
55
|
+
if (other !== undefined) {
|
|
56
|
+
throw new Error(`[sveltekit-admin] plugin "${plugin.name}" pattern ${JSON.stringify(page.pattern)} collides with plugin "${other}"`);
|
|
57
|
+
}
|
|
58
|
+
taken.set(key, plugin.name);
|
|
59
|
+
const view = pluginViewId(plugin.name, page.pattern);
|
|
60
|
+
routes.push({ pattern: page.pattern, view });
|
|
61
|
+
pagesByView.set(view, page);
|
|
62
|
+
}
|
|
63
|
+
for (const action of plugin.recordActions ?? []) {
|
|
64
|
+
assertKnownModels(action.models, visibleModels, plugin.name);
|
|
65
|
+
recordActions.push(action);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { routes, pagesByView, recordActions };
|
|
69
|
+
}
|
|
70
|
+
export function actionsForModel(registry, modelName) {
|
|
71
|
+
return registry.recordActions.filter((action) => !action.models || action.models.some((n) => n.toLowerCase() === modelName.toLowerCase()));
|
|
72
|
+
}
|
|
@@ -83,4 +83,4 @@ export declare function parseListQuery(searchParams: URLSearchParams, model: Pri
|
|
|
83
83
|
* `normalizeScope`; nested Prisma where objects stay opaque for the Prisma
|
|
84
84
|
* compiler. Drizzle's compiler throws on those opaques.
|
|
85
85
|
*/
|
|
86
|
-
export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): Filter | Record<string, unknown> | undefined;
|
|
86
|
+
export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | Filter | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): Filter | Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation option loaders shared by the create/edit Form and the list-view
|
|
3
|
+
* FK filter sidebar. Split out of `handler.ts` — pure orchestration over
|
|
4
|
+
* `AdminRuntime` (schema/relationGraph/adapter already resolved at boot),
|
|
5
|
+
* no boot logic lives here.
|
|
6
|
+
*/
|
|
7
|
+
import type { PrismaModel } from './introspection/parser.js';
|
|
8
|
+
import type { RelationMeta, FkFilterMeta } from './views/types.js';
|
|
9
|
+
import { type AdminRuntime } from './runtime.js';
|
|
10
|
+
import type { Filter } from './adapters/types.js';
|
|
11
|
+
export declare function combinedScope(...scopes: Array<Filter | Record<string, unknown> | undefined>): Filter | undefined;
|
|
12
|
+
export declare function filterSelectedIds(runtime: AdminRuntime, targetModel: PrismaModel, ids: Array<string | number> | undefined, ctx: {
|
|
13
|
+
locals?: any;
|
|
14
|
+
}, relationScope?: Filter | Record<string, unknown>): Promise<Array<string | number> | undefined>;
|
|
15
|
+
/**
|
|
16
|
+
* Charge les options pour toutes les arêtes to-one-owning et m2m
|
|
17
|
+
* d'un modèle. Une requête COUNT par relation avant le findMany : évite de
|
|
18
|
+
* charger 10k lignes pour découvrir qu'il y en a 10k.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadRelationOptions(runtime: AdminRuntime, model: PrismaModel, ctx: {
|
|
21
|
+
locals?: any;
|
|
22
|
+
}, currentId?: string): Promise<Map<string, RelationMeta>>;
|
|
23
|
+
/**
|
|
24
|
+
* Options d'un filtre FK : charge et scope les valeurs possibles pour la
|
|
25
|
+
* sidebar, ET résout le label du chip actif. Doctrine IDOR (docs/design
|
|
26
|
+
* §6.3) : les options ET le label du chip passent par le `where` de
|
|
27
|
+
* scoping de la relation — un chip forgé avec un ID hors scope affiche
|
|
28
|
+
* l'ID brut, jamais le label (sinon c'est un oracle sur le nom d'un
|
|
29
|
+
* enregistrement d'un autre tenant).
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveFkFilterOptions(runtime: AdminRuntime, model: PrismaModel, fkFieldName: string, label: string, ctx: {
|
|
32
|
+
locals?: any;
|
|
33
|
+
}, activeRawValue: string | undefined): Promise<FkFilterMeta>;
|
|
34
|
+
/**
|
|
35
|
+
* Compte, pour chaque relation inverse (1-N, 1-1) d'un modèle, le nombre
|
|
36
|
+
* d'enregistrements liés côté cible. Résilient : une cible dont le client
|
|
37
|
+
* échoue (mock partiel, modèle absent) retombe sur 0 plutôt que de casser
|
|
38
|
+
* le rendu du formulaire.
|
|
39
|
+
*/
|
|
40
|
+
export declare function loadRelatedCounts(runtime: AdminRuntime, model: PrismaModel, currentId: string, ctx: {
|
|
41
|
+
locals?: any;
|
|
42
|
+
}): Promise<Map<string, number>>;
|