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,188 @@
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 { primaryKeyOf, coerceId } from './data.js';
8
+ import { findFkEdge } from './query/filterDetection.js';
9
+ import { scopeFrom, modelScopeFrom } from './runtime.js';
10
+ import { normalizeScope } from './adapters/filter.js';
11
+ export function combinedScope(...scopes) {
12
+ const clauses = scopes.map((scope) => normalizeScope(scope)).filter((scope) => scope !== undefined);
13
+ if (clauses.length === 0)
14
+ return undefined;
15
+ if (clauses.length === 1)
16
+ return clauses[0];
17
+ return { op: 'and', clauses };
18
+ }
19
+ export async function filterSelectedIds(runtime, targetModel, ids, ctx, relationScope) {
20
+ if (!ids)
21
+ return undefined;
22
+ const scope = combinedScope(modelScopeFrom(runtime, targetModel, ctx), relationScope);
23
+ if (!scope || ids.length === 0)
24
+ return ids;
25
+ const rows = await runtime.adapter.data.findMany(targetModel, {
26
+ filter: combinedScope(scope, { op: 'in', field: primaryKeyOf(targetModel), value: ids })
27
+ });
28
+ const allowed = new Set(rows.map((row) => String(row[primaryKeyOf(targetModel)])));
29
+ return ids.filter((id) => allowed.has(String(id)));
30
+ }
31
+ /**
32
+ * Charge les options pour toutes les arêtes to-one-owning et m2m
33
+ * d'un modèle. Une requête COUNT par relation avant le findMany : évite de
34
+ * charger 10k lignes pour découvrir qu'il y en a 10k.
35
+ */
36
+ export async function loadRelationOptions(runtime, model, ctx, currentId) {
37
+ const modelsConfig = runtime.config.models ?? {};
38
+ const edges = [...runtime.relationGraph.edges.values()].filter((edge) => {
39
+ if (edge.model !== model.name)
40
+ return false;
41
+ if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m')
42
+ return false;
43
+ if (edge.unsupported)
44
+ return false;
45
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
46
+ return relConfig?.widget !== 'hidden';
47
+ });
48
+ // Une relation ne dépend pas de l'autre : chargées en parallèle plutôt
49
+ // qu'en série (un modèle avec N relations ne doit pas payer N
50
+ // aller-retours DB empilés pour afficher un seul formulaire).
51
+ const entries = await Promise.all(edges.map(async (edge) => {
52
+ const key = `${edge.model}.${edge.field}`;
53
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
54
+ const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
55
+ const filter = combinedScope(modelScopeFrom(runtime, targetModel, ctx), scopeFrom(relConfig, ctx));
56
+ try {
57
+ const total = await runtime.adapter.data.countRecords(targetModel, filter);
58
+ if (total > runtime.selectThreshold || relConfig?.widget === 'raw-id') {
59
+ const selectedIds = edge.kind === 'm2m' && currentId
60
+ ? await filterSelectedIds(runtime, targetModel, await runtime.adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId), ctx, scopeFrom(relConfig, ctx))
61
+ : undefined;
62
+ return [key, { tooMany: true, options: [], selectedIds }];
63
+ }
64
+ const rows = await runtime.adapter.data.findMany(targetModel, { filter, orderBy: relConfig?.orderBy });
65
+ const options = rows.map((row) => ({
66
+ id: row[primaryKeyOf(targetModel)],
67
+ label: runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate)
68
+ }));
69
+ const selectedIds = edge.kind === 'm2m' && currentId
70
+ ? await filterSelectedIds(runtime, targetModel, await runtime.adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId), ctx, scopeFrom(relConfig, ctx))
71
+ : undefined;
72
+ return [key, { tooMany: false, options, selectedIds }];
73
+ }
74
+ catch {
75
+ // Cible absente de la base ou client incomplet : repli raw-id pour
76
+ // garder le champ éditable plutôt que de faire échouer tout le form.
77
+ return [key, { tooMany: true, options: [] }];
78
+ }
79
+ }));
80
+ return new Map(entries);
81
+ }
82
+ /**
83
+ * Options d'un filtre FK : charge et scope les valeurs possibles pour la
84
+ * sidebar, ET résout le label du chip actif. Doctrine IDOR (docs/design
85
+ * §6.3) : les options ET le label du chip passent par le `where` de
86
+ * scoping de la relation — un chip forgé avec un ID hors scope affiche
87
+ * l'ID brut, jamais le label (sinon c'est un oracle sur le nom d'un
88
+ * enregistrement d'un autre tenant).
89
+ */
90
+ export async function resolveFkFilterOptions(runtime, model, fkFieldName, label, ctx, activeRawValue) {
91
+ // Appelé uniquement pour un filtre `kind: 'fk'` retourné par
92
+ // resolveListFilters avec CE MÊME graphe : graphe et arête existent donc
93
+ // par construction. Garder des gardes here masquerait une incohérence
94
+ // interne et ajouterait du code mort (coverage artificielle).
95
+ const edge = findFkEdge(runtime.relationGraph, model.name, fkFieldName);
96
+ // Non-null par construction : un filtre `kind: 'fk'` ne peut exister que
97
+ // via `models[model.name].listFilter` explicite (CLAUDE.md — les filtres
98
+ // FK ne sont jamais auto-détectés) ; `runtime.config.models` est donc
99
+ // déjà renseigné pour ce modèle avant que cette fonction ne soit appelée.
100
+ const modelsConfig = runtime.config.models;
101
+ const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
102
+ // Non-null par construction : `edge` vient du graphe dérivé du même
103
+ // schéma parsé avec succès — une arête ne peut pas cibler un modèle qui
104
+ // n'existe pas dans `schema.models`.
105
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
106
+ const scope = combinedScope(modelScopeFrom(runtime, targetModel, ctx), scopeFrom(relConfig, ctx));
107
+ // Options de la sidebar (comptées puis chargées si sous le seuil) et
108
+ // label du chip actif (§6.3.b) sont deux requêtes indépendantes — l'une
109
+ // ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
110
+ // série.
111
+ const loadOptions = async () => {
112
+ try {
113
+ const total = await runtime.adapter.data.countRecords(targetModel, scope);
114
+ if (total > runtime.selectThreshold) {
115
+ return { options: [], tooMany: true };
116
+ }
117
+ const rows = await runtime.adapter.data.findMany(targetModel, { filter: scope, orderBy: relConfig?.orderBy });
118
+ const options = rows.map((row) => ({
119
+ id: row[primaryKeyOf(targetModel)],
120
+ label: runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate)
121
+ }));
122
+ return { options, tooMany: false };
123
+ }
124
+ catch {
125
+ return { options: [], tooMany: true };
126
+ }
127
+ };
128
+ // Un ID hors scope retourne null ici → le composant affiche l'ID brut,
129
+ // jamais le label (sinon c'est un oracle sur le nom d'un enregistrement
130
+ // d'un autre tenant).
131
+ const loadActiveLabel = async () => {
132
+ if (activeRawValue === undefined)
133
+ return undefined;
134
+ const activeId = coerceId(activeRawValue, targetModel);
135
+ try {
136
+ const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: activeId };
137
+ const filter = scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter;
138
+ const row = await runtime.adapter.data.findFirst(targetModel, filter);
139
+ return row ? runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ };
145
+ const [{ options, tooMany }, activeLabel] = await Promise.all([loadOptions(), loadActiveLabel()]);
146
+ return {
147
+ field: fkFieldName,
148
+ label,
149
+ relationField: edge.field,
150
+ targetModel: edge.target,
151
+ options,
152
+ mode: tooMany ? 'raw-id' : options.length <= runtime.filterLinkThreshold ? 'links' : 'select',
153
+ tooMany,
154
+ activeLabel,
155
+ // Une cible exclue/masquée n'a pas de page admin : le chip reste du
156
+ // texte, jamais un lien mort (docs/design §6.4).
157
+ activeHref: activeLabel && runtime.findModel(edge.target)
158
+ ? `${runtime.basePath}/${edge.target.toLowerCase()}/${encodeURIComponent(activeRawValue)}`
159
+ : undefined
160
+ };
161
+ }
162
+ /**
163
+ * Compte, pour chaque relation inverse (1-N, 1-1) d'un modèle, le nombre
164
+ * d'enregistrements liés côté cible. Résilient : une cible dont le client
165
+ * échoue (mock partiel, modèle absent) retombe sur 0 plutôt que de casser
166
+ * le rendu du formulaire.
167
+ */
168
+ export async function loadRelatedCounts(runtime, model, currentId, ctx) {
169
+ const edges = [...runtime.relationGraph.edges.values()].filter((edge) => edge.model === model.name && (edge.kind === 'to-many-inverse' || edge.kind === 'to-one-inverse'));
170
+ // Un count par relation inverse, indépendants entre eux : en parallèle
171
+ // plutôt qu'empilés un par un (même raisonnement que loadRelationOptions).
172
+ const entries = await Promise.all(edges.map(async (edge) => {
173
+ const owning = [...runtime.relationGraph.edges.values()].find((o) => o.model === edge.target && o.kind === 'to-one-owning' && o.relationName === edge.relationName);
174
+ if (!owning || owning.unsupported)
175
+ return undefined;
176
+ const scalarName = owning.scalarFields[0];
177
+ const key = `${edge.model}.${edge.field}`;
178
+ const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
179
+ try {
180
+ const count = await runtime.adapter.data.countRecords(targetModel, combinedScope(modelScopeFrom(runtime, targetModel, ctx), { op: 'eq', field: scalarName, value: coerceId(currentId, model) }));
181
+ return [key, count];
182
+ }
183
+ catch {
184
+ return [key, 0];
185
+ }
186
+ }));
187
+ return new Map(entries.filter((e) => e !== undefined));
188
+ }
@@ -3,4 +3,14 @@ export interface ParsedRoute {
3
3
  model?: string;
4
4
  id?: string;
5
5
  }
6
+ export interface RouteEntry {
7
+ pattern: string[];
8
+ view: string;
9
+ }
10
+ export declare const BUILTIN_ROUTES: RouteEntry[];
11
+ export declare function matchRoute(pathname: string, basePath: string, routes: RouteEntry[]): {
12
+ view: string;
13
+ model?: string;
14
+ id?: string;
15
+ };
6
16
  export declare function parseRoute(pathname: string, basePath: string): ParsedRoute;
@@ -1,27 +1,50 @@
1
- export function parseRoute(pathname, basePath) {
1
+ export const BUILTIN_ROUTES = [
2
+ { pattern: [], view: 'dashboard' },
3
+ { pattern: ['_search'], view: 'search' },
4
+ { pattern: ['_logout'], view: 'logout' },
5
+ { pattern: [':model', 'new'], view: 'create' },
6
+ { pattern: [':model', ':id'], view: 'edit' },
7
+ { pattern: [':model'], view: 'list' }
8
+ ];
9
+ function relativeSegments(pathname, basePath) {
2
10
  // Le `replace` n'est PAS redondant avec le `filter(Boolean)` plus bas : il est ce
3
11
  // qui fait que `/admin/` et `/admin///` donnent un `path` vide, donc le dashboard.
4
12
  // Sans lui, `path` vaudrait '/' — truthy — et le chemin tomberait sur `notFound`.
5
13
  const path = pathname.slice(basePath.length).replace(/^\/+|\/+$/g, '');
6
- if (!path) {
7
- return { view: 'dashboard' };
8
- }
9
- const segments = path.split('/').filter(Boolean);
10
- if (segments.length === 1) {
11
- if (segments[0] === '_search') {
12
- return { view: 'search' };
13
- }
14
- if (segments[0] === '_logout') {
15
- return { view: 'logout' };
16
- }
17
- return { view: 'list', model: segments[0] };
18
- }
19
- if (segments.length === 2) {
20
- if (segments[1] === 'new') {
21
- return { view: 'create', model: segments[0] };
14
+ if (!path)
15
+ return [];
16
+ return path.split('/').filter(Boolean);
17
+ }
18
+ export function matchRoute(pathname, basePath, routes) {
19
+ const segments = relativeSegments(pathname, basePath);
20
+ for (const route of routes) {
21
+ if (route.pattern.length !== segments.length)
22
+ continue;
23
+ const captured = {};
24
+ let ok = true;
25
+ for (let i = 0; i < route.pattern.length; i++) {
26
+ const token = route.pattern[i];
27
+ const seg = segments[i];
28
+ if (token === ':model') {
29
+ captured.model = seg;
30
+ }
31
+ else if (token === ':id') {
32
+ captured.id = seg;
33
+ }
34
+ else if (token !== seg) {
35
+ ok = false;
36
+ break;
37
+ }
22
38
  }
23
- return { view: 'edit', model: segments[0], id: segments[1] };
39
+ if (ok)
40
+ return { view: route.view, ...captured };
24
41
  }
25
- // 3 segments ou plus : aucune vue ne correspond.
26
42
  return { view: 'notFound' };
27
43
  }
44
+ // Builtins-only helper — NOT the handler's dispatch path. `handler.ts` calls
45
+ // `matchRoute` directly with `[...pluginRoutes, ...BUILTIN_ROUTES]` so plugin
46
+ // patterns are considered first; this function stays around for tests and
47
+ // any caller that only cares about the builtin route table.
48
+ export function parseRoute(pathname, basePath) {
49
+ return matchRoute(pathname, basePath, BUILTIN_ROUTES);
50
+ }
@@ -0,0 +1,46 @@
1
+ import type { Schema, Model } from './types/schema.js';
2
+ import { type RelationGraph } from './introspection/relations.js';
3
+ import type { ViewModel } from './views/types.js';
4
+ import type { DataAdapter, SchemaIntrospector, Filter } from './adapters/types.js';
5
+ import type { AdminHandlerConfig } from './handler.js';
6
+ export declare function scopeFrom(relConfig: {
7
+ where?: (ctx: any) => any;
8
+ } | undefined, ctx: {
9
+ locals?: any;
10
+ }): any;
11
+ export declare function listScopeFrom(runtime: AdminRuntime, model: Model, ctx: {
12
+ locals?: any;
13
+ }): Record<string, unknown> | undefined;
14
+ export declare function modelScopeFrom(runtime: AdminRuntime, model: Model, ctx: {
15
+ locals?: any;
16
+ }): Filter | undefined;
17
+ /** Extract equality predicates so create can force tenant-owned columns. */
18
+ export declare function modelScopeValues(runtime: AdminRuntime, model: Model, ctx: {
19
+ locals?: any;
20
+ }): Record<string, unknown>;
21
+ export interface AdminRuntime {
22
+ adapter: {
23
+ introspector: SchemaIntrospector;
24
+ data: DataAdapter;
25
+ };
26
+ schema: Schema | null;
27
+ relationGraph: RelationGraph | null;
28
+ models: Model[];
29
+ modelList: Array<{
30
+ name: string;
31
+ label: string;
32
+ }>;
33
+ config: AdminHandlerConfig;
34
+ basePath: string;
35
+ perPage: number;
36
+ selectThreshold: number;
37
+ filterLinkThreshold: number;
38
+ labelFieldCandidates: string[];
39
+ findModel(name?: string): Model | undefined;
40
+ labelOf(model: Model): string;
41
+ hiddenFieldsOf(model: Model): Set<string>;
42
+ viewModel(model: Model): ViewModel;
43
+ resolveLabel(target: Model, row: Record<string, unknown>, labelTemplate?: string): string;
44
+ resolveFilterableFields(model: Model): Set<string>;
45
+ }
46
+ export declare function createAdminRuntime(config: AdminHandlerConfig): AdminRuntime;
@@ -0,0 +1,210 @@
1
+ import { isSensitiveFieldName } from './introspection/parser.js';
2
+ import { buildRelationGraph } from './introspection/relations.js';
3
+ import { primaryKeyOf } from './data.js';
4
+ import { validateListFilterConfig } from './query/filterDetection.js';
5
+ import { isCompositeFilter, isLeafFilter, normalizeScope } from './adapters/filter.js';
6
+ import { toLabel } from './views/html.js';
7
+ import { AdminConfigError } from './errors.js';
8
+ export function scopeFrom(relConfig, ctx) {
9
+ return relConfig?.where ? normalizeScope(relConfig.where(ctx)) : undefined;
10
+ }
11
+ export function listScopeFrom(runtime, model, ctx) {
12
+ const listScope = runtime.config.models?.[model.name]?.listWhere?.(ctx);
13
+ // A scope function returning `{}` (falsy-looking but truthy as
14
+ // an object) would otherwise silently fail OPEN — `{}` composed
15
+ // into an AND matches every row, exactly the opposite of what a
16
+ // caller configuring listWhere expects (real gap found in
17
+ // review: `locals.userId` undefined after a session expires is
18
+ // a realistic way to hit this). Fail loud instead: a scope
19
+ // function is either omitted entirely, or must return at least
20
+ // one condition every time it runs.
21
+ if (listScope && Object.keys(listScope).length === 0) {
22
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.listWhere returned an empty object ({}), ` +
23
+ `which would silently disable list scoping (fail-open). Return undefined/omit the ` +
24
+ `scope entirely if there is genuinely nothing to scope by for this request, or a ` +
25
+ `condition that actually restricts rows otherwise.`);
26
+ }
27
+ return listScope;
28
+ }
29
+ export function modelScopeFrom(runtime, model, ctx) {
30
+ const scope = runtime.config.models?.[model.name]?.scope;
31
+ if (!scope)
32
+ return undefined;
33
+ const raw = scope(ctx);
34
+ if (!raw || (typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length === 0)) {
35
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope must return a non-empty condition; ` +
36
+ 'refusing to fail open.');
37
+ }
38
+ const normalized = normalizeScope(raw);
39
+ const valid = (node) => {
40
+ if (isLeafFilter(node))
41
+ return node.value !== undefined;
42
+ return isCompositeFilter(node) && node.clauses.length > 0 && node.clauses.every(valid);
43
+ };
44
+ if (!valid(normalized)) {
45
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope returned an invalid condition; refusing to fail open.`);
46
+ }
47
+ return normalized;
48
+ }
49
+ /** Extract equality predicates so create can force tenant-owned columns. */
50
+ export function modelScopeValues(runtime, model, ctx) {
51
+ const normalized = modelScopeFrom(runtime, model, ctx);
52
+ if (!normalized)
53
+ return {};
54
+ const values = {};
55
+ const visit = (node) => {
56
+ if (isLeafFilter(node)) {
57
+ if (node.op !== 'eq')
58
+ return false;
59
+ if (node.field in values && values[node.field] !== node.value)
60
+ return false;
61
+ values[node.field] = node.value;
62
+ return true;
63
+ }
64
+ if (!isCompositeFilter(node) || node.op === 'or')
65
+ return false;
66
+ return node.clauses.every(visit);
67
+ };
68
+ if (!visit(normalized) || Object.keys(values).length === 0) {
69
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope must contain only equality conditions for creation`);
70
+ }
71
+ return values;
72
+ }
73
+ export function createAdminRuntime(config) {
74
+ const { basePath = '/admin', exclude = [], hidePivotTables = true, models: modelsConfig = {} } = config;
75
+ const adapter = config.adapter;
76
+ const introspector = adapter.introspector;
77
+ // Introspect the schema once at startup — same failure handling as before:
78
+ // a broken/missing schema source degrades to "no models known" rather than
79
+ // throwing out of `createAdminHandler` itself.
80
+ let schema = null;
81
+ let relationGraph = null;
82
+ try {
83
+ const introspected = introspector.introspect();
84
+ if (introspected instanceof Promise) {
85
+ throw new Error('[sveltekit-admin] SchemaIntrospector.introspect() returned a Promise — ' +
86
+ 'createAdminHandler only supports synchronous introspection today.');
87
+ }
88
+ schema = introspected;
89
+ relationGraph = buildRelationGraph(schema);
90
+ for (const d of relationGraph.diagnostics) {
91
+ console.warn(`[sveltekit-admin] ${d}`);
92
+ }
93
+ }
94
+ catch (e) {
95
+ console.warn('[sveltekit-admin] Could not introspect schema:', e);
96
+ }
97
+ const models = schema?.models.filter((m) => {
98
+ // Exclude explicitly excluded models
99
+ if (exclude.includes(m.name))
100
+ return false;
101
+ // Exclude pivot tables if option is enabled
102
+ if (hidePivotTables && m.isPivotTable)
103
+ return false;
104
+ return true;
105
+ }) || [];
106
+ // Valider `listFilter` au démarrage : une config invalide (champ
107
+ // inexistant, sensible, relation, type non supporté) doit échouer fort
108
+ // ici plutôt que produire silencieusement un filtre mort à chaque rendu
109
+ // de liste (docs/design §8, même politique que le groupe ambigu de
110
+ // relations.ts).
111
+ const hiddenFieldsOf = (m) => new Set(modelsConfig[m.name]?.hidden ?? []);
112
+ for (const m of models) {
113
+ const entries = modelsConfig[m.name]?.listFilter;
114
+ // Non-null par construction : `models` n'existe que si le schéma
115
+ // a été parsé, et le graphe est construit dans la même branche de boot.
116
+ if (entries)
117
+ validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
118
+ }
119
+ const labelOf = (m) => {
120
+ const configured = modelsConfig[m.name]?.label;
121
+ if (configured)
122
+ return configured;
123
+ const label = toLabel(m.name);
124
+ return label.charAt(0).toUpperCase() + label.slice(1);
125
+ };
126
+ const modelList = models.map((m) => ({ name: m.name, label: labelOf(m) }));
127
+ const findModel = (name) => models.find((m) => m.name.toLowerCase() === name?.toLowerCase());
128
+ const viewModel = (m) => ({
129
+ name: m.name,
130
+ label: labelOf(m),
131
+ fields: m.fields,
132
+ primaryKey: primaryKeyOf(m),
133
+ // Non-null par construction : `m` vient toujours de `models`,
134
+ // dérivé du schéma qu'on vient de parser avec succès.
135
+ relationGraph: relationGraph
136
+ });
137
+ const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
138
+ const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
139
+ const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
140
+ 'name', 'title', 'label', 'email', 'username', 'slug'
141
+ ];
142
+ /**
143
+ * Champs qu'un `?f.<field>=` est autorisé à cibler pour ce modèle : tout
144
+ * champ scalaire non-liste, non-relation, de type filtrable
145
+ * (String/Int/Float/Decimal/BigInt/Boolean/DateTime/enum — donc pas
146
+ * Json/Bytes), non sensible, et non listé dans `hidden` pour ce modèle.
147
+ * Sans ce dernier point, `hidden: ['internalNotes']` ne fait que masquer
148
+ * l'affichage : le champ reste un oracle de confirmation de valeur via
149
+ * `?f.internalNotes=...contains...`, exactement la faille §0.a fermée
150
+ * ailleurs pour les champs sensibles par nom — `hidden` et le prédicat
151
+ * de sensibilité sont deux sources distinctes, toutes deux doivent
152
+ * fermer l'oracle (docs/design §10, "deux sources, un seul prédicat
153
+ * partagé, sinon divergence garantie"). Défense en profondeur :
154
+ * `listQuery.ts` revérifie lui-même la sensibilité par nom, ce set est
155
+ * la première passe et la seule à connaître la config `hidden`.
156
+ */
157
+ const resolveFilterableFields = (model) => {
158
+ const hidden = hiddenFieldsOf(model);
159
+ const out = new Set();
160
+ for (const f of model.fields) {
161
+ if (f.relation || f.isList)
162
+ continue;
163
+ if (['Json', 'Bytes'].includes(f.type))
164
+ continue;
165
+ if (isSensitiveFieldName(f.name))
166
+ continue;
167
+ if (hidden.has(f.name))
168
+ continue;
169
+ out.add(f.name);
170
+ }
171
+ return out;
172
+ };
173
+ /**
174
+ * Résout le label BRUT (non échappé) d'une ligne : premier champ String
175
+ * candidat présent, sinon template `{a} {b}` si configuré, sinon la PK.
176
+ * Déterministe. Svelte échappe automatiquement à l'interpolation dans les
177
+ * composants — pas besoin d'échapper ici.
178
+ */
179
+ const resolveLabel = (targetModel, row, labelTemplate) => {
180
+ if (labelTemplate) {
181
+ return labelTemplate.replace(/\{(\w+)\}/g, (_, k) => String(row[k] ?? ''));
182
+ }
183
+ for (const candidate of labelFieldCandidates) {
184
+ const field = targetModel.fields.find((f) => f.name === candidate);
185
+ if (field && field.type === 'String' && row[candidate] != null) {
186
+ return String(row[candidate]);
187
+ }
188
+ }
189
+ return String(row[primaryKeyOf(targetModel)]);
190
+ };
191
+ return {
192
+ adapter,
193
+ schema,
194
+ relationGraph,
195
+ models,
196
+ modelList,
197
+ config,
198
+ basePath,
199
+ perPage: 20,
200
+ selectThreshold,
201
+ filterLinkThreshold,
202
+ labelFieldCandidates,
203
+ findModel,
204
+ labelOf,
205
+ hiddenFieldsOf,
206
+ viewModel,
207
+ resolveLabel,
208
+ resolveFilterableFields
209
+ };
210
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `_search` JSON endpoint — split out of `handler.ts`, pure orchestration
3
+ * over `AdminRuntime`.
4
+ */
5
+ import { type AdminRuntime } from './runtime.js';
6
+ /**
7
+ * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
8
+ * Sert les options d'une relation to-one-owning ou m2m en JSON
9
+ * paginé — la voie prévue pour un futur widget autocomplete côté client
10
+ * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
11
+ * de scoping configuré sur la relation, comme le select et la validation
12
+ * POST : même garantie anti-IDOR sur les trois chemins.
13
+ */
14
+ export declare function handleSearch(runtime: AdminRuntime, event: any): Promise<Response>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `_search` JSON endpoint — split out of `handler.ts`, pure orchestration
3
+ * over `AdminRuntime`.
4
+ */
5
+ import { primaryKeyOf, paginate } from './data.js';
6
+ import { scopeFrom, modelScopeFrom } from './runtime.js';
7
+ /**
8
+ * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
9
+ * Sert les options d'une relation to-one-owning ou m2m en JSON
10
+ * paginé — la voie prévue pour un futur widget autocomplete côté client
11
+ * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
12
+ * de scoping configuré sur la relation, comme le select et la validation
13
+ * POST : même garantie anti-IDOR sur les trois chemins.
14
+ */
15
+ export async function handleSearch(runtime, event) {
16
+ const modelsConfig = runtime.config.models ?? {};
17
+ const relParam = event.url.searchParams.get('rel') ?? '';
18
+ const [modelName, fieldName] = relParam.split('.');
19
+ const q = event.url.searchParams.get('q') ?? '';
20
+ const { page } = paginate(event.url.searchParams.get('page'), runtime.perPage);
21
+ const model = runtime.findModel(modelName);
22
+ const edge = model && runtime.relationGraph
23
+ ? runtime.relationGraph.edges.get(`${model.name}.${fieldName}`)
24
+ : undefined;
25
+ if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m') || edge.unsupported) {
26
+ return new Response(JSON.stringify({ error: 'unknown relation' }), {
27
+ status: 404,
28
+ headers: { 'Content-Type': 'application/json' }
29
+ });
30
+ }
31
+ const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
32
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
33
+ const configWhere = scopeFrom(relConfig, { locals: event.locals });
34
+ const modelWhere = modelScopeFrom(runtime, targetModel, { locals: event.locals });
35
+ // Recherche sur le premier champ String candidat du modèle cible — le
36
+ // même champ que celui utilisé pour construire le label par défaut.
37
+ const searchField = runtime.labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
38
+ // `_search` must stay case-sensitive on every adapter/provider. A
39
+ // `{ op: 'contains' }` leaf would pick up the adapter-wide
40
+ // `caseInsensitiveSearch` flag (Prisma `mode: 'insensitive'`, Drizzle
41
+ // `ilike`). `containsExact` compiles to `{ contains }` / `LIKE` with
42
+ // no case-folding — same observable Prisma behavior as the previous
43
+ // opaque `{ [field]: { contains: q } }` pass-through.
44
+ const containsFilter = q && searchField
45
+ ? { op: 'containsExact', field: searchField, value: q }
46
+ : undefined;
47
+ const searchClauses = [modelWhere, configWhere, containsFilter].filter(Boolean);
48
+ const searchFilter = searchClauses.length > 1
49
+ ? { op: 'and', clauses: searchClauses }
50
+ : searchClauses[0];
51
+ try {
52
+ // Count + fetch are independent reads — run them in parallel (as this
53
+ // endpoint always has, pre-refactor) rather than doubling latency with
54
+ // two sequential awaits.
55
+ const [total, rows] = await Promise.all([
56
+ runtime.adapter.data.countRecords(targetModel, searchFilter),
57
+ runtime.adapter.data.findMany(targetModel, {
58
+ filter: searchFilter,
59
+ orderBy: relConfig?.orderBy,
60
+ skip: (page - 1) * runtime.perPage,
61
+ take: runtime.perPage
62
+ })
63
+ ]);
64
+ const options = rows.map((row) => ({
65
+ id: row[primaryKeyOf(targetModel)],
66
+ label: runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate)
67
+ }));
68
+ return new Response(JSON.stringify({ options, total, page }), {
69
+ headers: { 'Content-Type': 'application/json' }
70
+ });
71
+ }
72
+ catch {
73
+ return new Response(JSON.stringify({ error: 'search failed' }), {
74
+ status: 500,
75
+ headers: { 'Content-Type': 'application/json' }
76
+ });
77
+ }
78
+ }