sveltekit-admin 0.6.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +61 -4
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +1 -1
  4. package/dist/server/adapters/drizzle/dataAdapter.js +121 -36
  5. package/dist/server/adapters/drizzle/index.d.ts +10 -1
  6. package/dist/server/adapters/drizzle/index.js +4 -0
  7. package/dist/server/adapters/prisma/dataAdapter.js +52 -10
  8. package/dist/server/adapters/prisma/handler.d.ts +14 -0
  9. package/dist/server/adapters/prisma/handler.js +37 -0
  10. package/dist/server/adapters/retry.d.ts +27 -0
  11. package/dist/server/adapters/retry.js +53 -0
  12. package/dist/server/adapters/types.d.ts +9 -2
  13. package/dist/server/audit.d.ts +65 -0
  14. package/dist/server/audit.js +106 -0
  15. package/dist/server/csrf.d.ts +33 -0
  16. package/dist/server/csrf.js +55 -0
  17. package/dist/server/errors.d.ts +47 -0
  18. package/dist/server/errors.js +90 -0
  19. package/dist/server/handler.d.ts +57 -26
  20. package/dist/server/handler.js +212 -561
  21. package/dist/server/mutations.d.ts +9 -0
  22. package/dist/server/mutations.js +295 -0
  23. package/dist/server/plugin.d.ts +47 -0
  24. package/dist/server/plugin.js +1 -0
  25. package/dist/server/pluginAccess.d.ts +7 -0
  26. package/dist/server/pluginAccess.js +79 -0
  27. package/dist/server/pluginRegistry.d.ts +12 -0
  28. package/dist/server/pluginRegistry.js +72 -0
  29. package/dist/server/query/listQuery.d.ts +1 -1
  30. package/dist/server/relationLoaders.d.ts +42 -0
  31. package/dist/server/relationLoaders.js +188 -0
  32. package/dist/server/router.d.ts +10 -0
  33. package/dist/server/router.js +42 -19
  34. package/dist/server/runtime.d.ts +46 -0
  35. package/dist/server/runtime.js +210 -0
  36. package/dist/server/search.d.ts +14 -0
  37. package/dist/server/search.js +78 -0
  38. package/dist/server/views/Form.svelte +26 -2
  39. package/dist/server/views/Form.svelte.d.ts +2 -1
  40. package/dist/server/views/Layout.svelte +27 -2
  41. package/dist/server/views/Layout.svelte.d.ts +2 -0
  42. package/dist/server/views/List.svelte +28 -3
  43. package/dist/server/views/List.svelte.d.ts +2 -2
  44. package/dist/server/views/types.d.ts +8 -0
  45. package/package.json +23 -21
@@ -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>>;