/** * SvelteKit Admin - Standalone Handler * Zero files needed in routes - everything handled via hook */ import type { DataAdapter, SchemaIntrospector } from './adapters/types.js'; import type { AuditEvent } from './audit.js'; import { type CsrfConfig } from './csrf.js'; import type { AdminPlugin } from './plugin.js'; export interface AdminHandlerConfig { /** * Explicit `{ introspector, data }` pair, from `createPrismaAdapter`, * `createDrizzleAdapter`, or a custom implementation. The `{ prisma, * prismaSchemaPath }` shortcut lives on the Prisma wrapper exported by * the package root, not here. */ adapter: { introspector: SchemaIntrospector; data: DataAdapter; }; /** Base path for admin routes (default: /admin) */ basePath?: string; /** Authentication check - return true if user can access admin */ authCheck?: (event: any) => boolean | Promise; /** * Logout logic — same "bring your own auth" philosophy as `authCheck`: * this library has no session system of its own, so it can't know how * to clear yours (a cookie, a Lucia/Better-Auth/Auth.js call, whatever). * You provide the side effect (clear the cookie, invalidate the * session...); the handler wires it to a POST-only route and a sidebar * button. No button is rendered at all if this isn't set — an admin * with no `logout` configured looks exactly as it did before this * option existed. * * POST-only, never a bare link: logging out must never be triggerable * by a GET (a crawler, a link prefetch, ``), which a plain * `` would allow. Runs BEFORE `authCheck` — a * user whose session already expired (so `authCheck` would now reject * them) must still be able to hit the logout route to clean up * client-side state (e.g. clear a stale cookie) without being stuck * behind a 401 first. */ logout?: (event: any) => void | Promise; /** Where to redirect after logout (default: '/') */ logoutRedirectTo?: string; /** * Cross-site protection for every state-changing admin request (create / * update / delete, `_logout`, `_search`). On by default; a missing `Origin` * is rejected, as SvelteKit does. `trustedOrigins` allows a second * legitimate origin, `csrf: false` opts out entirely. * * Why this isn't left to `kit.csrf.checkOrigin`, and the same-origin threat * it does not cover: see `csrf.ts` and /docs/csrf. */ csrf?: CsrfConfig; /** * Audit sink — same "bring your own" philosophy as `authCheck` / `logout`. * The library has no log table and no session of its own, so it cannot * know where to persist "admin X changed row Y" (your `AuditLog` model, * a logger, an HTTP sink…). You provide the side effect; the handler * calls it **after a successful create / update / delete** with a * redacted `AuditEvent`. No callback means no behaviour change: no * extra reads, no calls. * * The actor is whatever you already put on `event.locals` (the same * object `authCheck` sees). Sensitive field names (`password` / `hash` / * `secret` / `token`) and per-model `hidden` fields are stripped from * `values` / `before` / `after` / `changes` so the sink cannot become a * second oracle for secrets. Reads (GET), logout, and `_search` are * not audited. * * Awaited before the 303 so a `prisma.auditLog.create(...)` inside the * callback commits before the redirect. If the callback throws, the * mutation still redirects — the write is the source of truth, the log * is a sidecar (`console.error` with prefix * `[sveltekit-admin] audit callback failed:`). There is no way to wrap * the adapter write and your sink in one transaction without owning * both stores. */ audit?: (entry: AuditEvent) => void | Promise; /** Per-model configuration */ models?: Record Record | import('./adapters/types.js').Filter; /** * Scoping `where` applied to the LIST VIEW ONLY of this model * (search, sidebar filters, FK filter, pagination count) — composed * via `AND` with active filters, never a spread (docs/design * §0.c/§5.2). Deliberately named `listWhere`, not `where`: a bare * `where` invites a developer to believe it scopes every operation * on the model (detail view, edit, delete, dashboard counts), which * it does NOT — those have no equivalent scoping in this version and * remain fully open regardless of this config (a real risk found in * review: a multi-tenant app that relies on `listWhere` alone gets a * false sense of safety while `getRecord`/`updateRecord`/ * `deleteRecord` stay unscoped for anyone who obtains a row's ID * through another channel — a referrer, a log line, or straight * enumeration on a model with an Int primary key). * * Without this scope, the FK filter this feature adds makes * cross-tenant row discovery through the list trivial: `?f.authorId= * 1..N` used to return another tenant's row (its label stayed * protected via `relations[x].where`, §6.3.b, but the row itself did * not). This config closes that specific hole for the list — nothing * more. If you need every view scoped, you currently have to wire * `relations[x].where` for the active-FK-label lookup separately * (they are NOT the same function and are NOT automatically kept in * sync), and there is no scoping hook at all yet for * detail/edit/delete/dashboard — track that as a real gap, not an * oversight to work around silently. * * A scope function that returns `{}` (e.g. because `locals.userId` * was undefined after a session expired) is NOT treated as "no * scope" — `{}` would silently fail open (an intersection with an * empty clause matches everything) exactly when a caller most needs * protection. It throws instead: fail loud on a misbehaving scope * function, never fail open on a data leak. */ listWhere?: (ctx: { locals?: any; }) => Record; relations?: Record; where?: (ctx: { locals?: any; }) => Record; nullLabel?: string; }>; /** * Champs interrogés par la barre de recherche texte libre. Sans * config, une heuristique conservatrice reprend `relationDefaults.labelFields` * parmi les champs String non sensibles (voir docs/design/list-search-filters.md §2.1). * Une config explicite gagne toujours et n'est jamais tronquée par * l'heuristique — un champ non filtrable au sens de `isFilterableFieldType` * (relation, liste, Json, Bytes) ou sensible (password/hash/secret/token) * y est silencieusement ignoré. */ searchFields?: string[]; /** * Champs filtrables via la sidebar de la liste. Config explicite * (forme courte `'published'` ou objet `{ field, label }`) — sinon * une heuristique auto-détecte les champs Boolean et enum uniquement * (domaine de valeurs connu statiquement, zéro requête pour rendre la * sidebar ; voir docs/design/list-search-filters.md §3.5). Une config * invalide (champ inexistant, sensible, relation, type non supporté) * lève une erreur au démarrage — c'est une erreur de développeur, elle * doit échouer fort plutôt que produire un filtre silencieusement mort. */ listFilter?: import('./query/filterDetection.js').ListFilterConfigEntry[]; /** * Ordre d'arrivée sur la liste, avant tout `?sort=` dans l'URL. `dir` vaut * `'asc'` par défaut. * * Volontairement explicite plutôt qu'auto-détecté : deviner « trie par * `name` s'il y en a un » changerait l'ordre de toutes les listes * existantes sans que personne l'ait demandé, et l'heuristique dériverait * de ce que la vue affiche réellement. * * `field` doit être une colonne que la liste AFFICHE — sinon aucun en-tête * ne peut annoncer le tri ni permettre d'en sortir. Validé au démarrage : * une colonne inexistante ou non affichée lève, plutôt que de produire un * tri mort à chaque rendu. */ defaultSort?: { field: string; dir?: 'asc' | 'desc'; }; }>; /** * Lignes par page de la vue liste (défaut : 20). Entier de 1 à 200 : au-delà * ce n'est plus une page, c'est un export — et une requête qui tient la * connexion sur une table volumineuse. Validé au démarrage. */ perPage?: number; /** * Tailles de page qu'un visiteur peut choisir (défaut : `[10, 20, 50, 100]`). * `perPage` y est ajouté d'office s'il n'y figure pas, sinon la taille active * n'apparaîtrait pas dans le sélecteur. * * Un `?perPage=` n'est honoré que s'il appartient à cette liste : sans cette * règle, `?perPage=100000` est un `take` non borné, donc un déni de service à * un paramètre près. `[]` désactive entièrement le mécanisme — aucun * sélecteur rendu, `?perPage=` sans effet. */ pageSizeOptions?: number[]; /** Models to exclude from admin */ exclude?: string[]; /** Hide pivot/junction tables automatically (default: true) */ hidePivotTables?: boolean; /** Relation defaults */ relationDefaults?: { /** Au-delà de ce nombre d'options, une FK est rendue en raw-id (default: 200) */ selectThreshold?: number; /** Champs candidats pour le label, dans l'ordre de préférence */ labelFields?: string[]; }; /** * Défauts pour la sidebar de filtres (listFilter). * `linkThreshold`: en dessous ou égal à ce nombre d'options, un filtre FK * est rendu en liens dans la sidebar ; au-dessus (et ≤ relationDefaults.selectThreshold), * en `