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
|
@@ -21,6 +21,11 @@ import type { RelationEdge } from '../introspection/relations.js';
|
|
|
21
21
|
export interface SchemaIntrospector {
|
|
22
22
|
introspect(): Schema | Promise<Schema>;
|
|
23
23
|
}
|
|
24
|
+
export interface TargetGuard {
|
|
25
|
+
targetModel: Model;
|
|
26
|
+
targetPk: string | number;
|
|
27
|
+
filter?: Filter;
|
|
28
|
+
}
|
|
24
29
|
/**
|
|
25
30
|
* Per-request CRUD + relation-read surface `handler.ts` talks to instead of
|
|
26
31
|
* a raw ORM client. See docs/superpowers/specs/2026-08-13-db-adapter-abstraction-design.md
|
|
@@ -65,6 +70,7 @@ export interface DataAdapter {
|
|
|
65
70
|
targetPkField: string;
|
|
66
71
|
ids: Array<string | number>;
|
|
67
72
|
}>;
|
|
73
|
+
targetGuards?: TargetGuard[];
|
|
68
74
|
}): Promise<Record<string, unknown>>;
|
|
69
75
|
updateRecord(model: Model, id: string | number, input: {
|
|
70
76
|
scalars: Record<string, unknown>;
|
|
@@ -72,8 +78,9 @@ export interface DataAdapter {
|
|
|
72
78
|
targetPkField: string;
|
|
73
79
|
ids: Array<string | number>;
|
|
74
80
|
}>;
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
targetGuards?: TargetGuard[];
|
|
82
|
+
}, authorizationFilter?: Filter): Promise<Record<string, unknown>>;
|
|
83
|
+
deleteRecord(model: Model, id: string | number, authorizationFilter?: Filter): Promise<void>;
|
|
77
84
|
/** `targetModel` est fourni par l'appelant : chaque site d'appel actuel l'a déjà résolu. */
|
|
78
85
|
getM2mSelectedIds(model: Model, edge: RelationEdge, targetModel: Model, recordId: string | number): Promise<Array<string | number>>;
|
|
79
86
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit-log helpers for successful admin writes.
|
|
3
|
+
*
|
|
4
|
+
* The package has no session store and no first-party AuditLog table — same
|
|
5
|
+
* bring-your-own philosophy as `authCheck` / `logout`. This module builds a
|
|
6
|
+
* redacted `AuditEvent` and emits it to the consumer's callback. Sensitive
|
|
7
|
+
* names (`isSensitiveFieldName`) and config `hidden` fields are stripped from
|
|
8
|
+
* every snapshot so the audit sink cannot become a second oracle for secrets.
|
|
9
|
+
*/
|
|
10
|
+
import type { Model } from './types/schema.js';
|
|
11
|
+
export type AuditAction = 'create' | 'update' | 'delete';
|
|
12
|
+
export type AuditEvent = {
|
|
13
|
+
event: any;
|
|
14
|
+
at: Date;
|
|
15
|
+
action: 'create';
|
|
16
|
+
model: string;
|
|
17
|
+
id: string | number;
|
|
18
|
+
values: Record<string, unknown>;
|
|
19
|
+
after: Record<string, unknown>;
|
|
20
|
+
m2m?: Record<string, Array<string | number>>;
|
|
21
|
+
} | {
|
|
22
|
+
event: any;
|
|
23
|
+
at: Date;
|
|
24
|
+
action: 'update';
|
|
25
|
+
model: string;
|
|
26
|
+
id: string | number;
|
|
27
|
+
values: Record<string, unknown>;
|
|
28
|
+
before: Record<string, unknown> | null;
|
|
29
|
+
after: Record<string, unknown>;
|
|
30
|
+
changes: Record<string, {
|
|
31
|
+
from: unknown;
|
|
32
|
+
to: unknown;
|
|
33
|
+
}>;
|
|
34
|
+
m2m?: Record<string, Array<string | number>>;
|
|
35
|
+
} | {
|
|
36
|
+
event: any;
|
|
37
|
+
at: Date;
|
|
38
|
+
action: 'delete';
|
|
39
|
+
model: string;
|
|
40
|
+
id: string | number;
|
|
41
|
+
before: Record<string, unknown> | null;
|
|
42
|
+
};
|
|
43
|
+
export declare function redactForAudit(record: Record<string, unknown>, model: Model, hidden: ReadonlySet<string>): Record<string, unknown>;
|
|
44
|
+
export declare function diffRecords(before: Record<string, unknown>, after: Record<string, unknown>): Record<string, {
|
|
45
|
+
from: unknown;
|
|
46
|
+
to: unknown;
|
|
47
|
+
}>;
|
|
48
|
+
export interface BuildAuditEventInput {
|
|
49
|
+
event: any;
|
|
50
|
+
at?: Date;
|
|
51
|
+
action: AuditAction;
|
|
52
|
+
model: Model;
|
|
53
|
+
id: string | number;
|
|
54
|
+
hidden: ReadonlySet<string>;
|
|
55
|
+
values?: Record<string, unknown>;
|
|
56
|
+
m2m?: Record<string, {
|
|
57
|
+
targetPkField: string;
|
|
58
|
+
ids: Array<string | number>;
|
|
59
|
+
}>;
|
|
60
|
+
before?: Record<string, unknown> | null;
|
|
61
|
+
after?: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
export declare function buildAuditEvent(input: BuildAuditEventInput): AuditEvent;
|
|
64
|
+
export declare function readAuditSnapshot(getRecord: (model: Model, id: string | number) => Promise<Record<string, unknown> | null>, model: Model, id: string | number): Promise<Record<string, unknown> | null>;
|
|
65
|
+
export declare function emitAudit(audit: ((entry: AuditEvent) => void | Promise<void>) | undefined, entry: AuditEvent): Promise<void>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit-log helpers for successful admin writes.
|
|
3
|
+
*
|
|
4
|
+
* The package has no session store and no first-party AuditLog table — same
|
|
5
|
+
* bring-your-own philosophy as `authCheck` / `logout`. This module builds a
|
|
6
|
+
* redacted `AuditEvent` and emits it to the consumer's callback. Sensitive
|
|
7
|
+
* names (`isSensitiveFieldName`) and config `hidden` fields are stripped from
|
|
8
|
+
* every snapshot so the audit sink cannot become a second oracle for secrets.
|
|
9
|
+
*/
|
|
10
|
+
import { isSensitiveFieldName } from './introspection/parser.js';
|
|
11
|
+
export function redactForAudit(record, model, hidden) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const field of model.fields) {
|
|
14
|
+
if (field.relation || field.isList)
|
|
15
|
+
continue;
|
|
16
|
+
if (isSensitiveFieldName(field.name) || hidden.has(field.name))
|
|
17
|
+
continue;
|
|
18
|
+
if (!(field.name in record))
|
|
19
|
+
continue;
|
|
20
|
+
out[field.name] = record[field.name];
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function auditValuesEqual(a, b) {
|
|
25
|
+
if (Object.is(a, b))
|
|
26
|
+
return true;
|
|
27
|
+
if (a instanceof Date && b instanceof Date)
|
|
28
|
+
return a.getTime() === b.getTime();
|
|
29
|
+
if (typeof a === 'bigint' && typeof b === 'bigint')
|
|
30
|
+
return a === b;
|
|
31
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
export function diffRecords(before, after) {
|
|
42
|
+
const changes = {};
|
|
43
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
44
|
+
for (const key of keys) {
|
|
45
|
+
const from = before[key];
|
|
46
|
+
const to = after[key];
|
|
47
|
+
if (!auditValuesEqual(from, to)) {
|
|
48
|
+
changes[key] = { from, to };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return changes;
|
|
52
|
+
}
|
|
53
|
+
function compactM2m(m2m) {
|
|
54
|
+
if (!m2m)
|
|
55
|
+
return undefined;
|
|
56
|
+
const keys = Object.keys(m2m);
|
|
57
|
+
if (keys.length === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const key of keys) {
|
|
61
|
+
out[key] = m2m[key].ids;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
export function buildAuditEvent(input) {
|
|
66
|
+
const at = input.at ?? new Date();
|
|
67
|
+
const hidden = input.hidden;
|
|
68
|
+
const m2m = compactM2m(input.m2m);
|
|
69
|
+
const base = { event: input.event, at, model: input.model.name, id: input.id };
|
|
70
|
+
if (input.action === 'delete') {
|
|
71
|
+
const before = input.before ? redactForAudit(input.before, input.model, hidden) : null;
|
|
72
|
+
return { ...base, action: 'delete', before };
|
|
73
|
+
}
|
|
74
|
+
const values = redactForAudit(input.values ?? {}, input.model, hidden);
|
|
75
|
+
if (input.action === 'create') {
|
|
76
|
+
const after = redactForAudit(input.after ?? {}, input.model, hidden);
|
|
77
|
+
return m2m
|
|
78
|
+
? { ...base, action: 'create', values, after, m2m }
|
|
79
|
+
: { ...base, action: 'create', values, after };
|
|
80
|
+
}
|
|
81
|
+
const afterRaw = { ...(input.before ?? {}), ...(input.after ?? {}) };
|
|
82
|
+
const after = redactForAudit(afterRaw, input.model, hidden);
|
|
83
|
+
const before = input.before ? redactForAudit(input.before, input.model, hidden) : null;
|
|
84
|
+
const changes = before ? diffRecords(before, after) : {};
|
|
85
|
+
return m2m
|
|
86
|
+
? { ...base, action: 'update', values, before, after, changes, m2m }
|
|
87
|
+
: { ...base, action: 'update', values, before, after, changes };
|
|
88
|
+
}
|
|
89
|
+
export async function readAuditSnapshot(getRecord, model, id) {
|
|
90
|
+
try {
|
|
91
|
+
return await getRecord(model, id);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function emitAudit(audit, entry) {
|
|
98
|
+
if (!audit)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
await audit(entry);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
console.error('[sveltekit-admin] audit callback failed:', e);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vérification d'origine des requêtes mutantes servies sous `basePath`.
|
|
3
|
+
*
|
|
4
|
+
* SvelteKit fait le même contrôle (`runtime/server/respond.js`) mais ne peut
|
|
5
|
+
* pas porter la garantie ici : il tourne avant le hook `handle` (invisible
|
|
6
|
+
* pour cette lib), un `kit.csrf.checkOrigin: false` posé pour une route sans
|
|
7
|
+
* rapport le désactive partout, et il est court-circuité en dev. Revérifié
|
|
8
|
+
* ici, dev compris : un proxy qui strippe `Origin` doit casser sur
|
|
9
|
+
* `pnpm run dev`, pas en production.
|
|
10
|
+
*/
|
|
11
|
+
export type CsrfConfig = false | {
|
|
12
|
+
/**
|
|
13
|
+
* Origines acceptées en plus de celle de la requête. Normalisées en
|
|
14
|
+
* origine, donc `https://ops.example/` et `https://ops.example` sont
|
|
15
|
+
* la même entrée.
|
|
16
|
+
*/
|
|
17
|
+
trustedOrigins?: string[];
|
|
18
|
+
};
|
|
19
|
+
export interface ResolvedCsrf {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
trustedOrigins: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
/** Résolue au boot : une entrée illisible doit faire échouer `createAdminHandler`. */
|
|
24
|
+
export declare function resolveCsrfConfig(csrf: CsrfConfig | undefined): ResolvedCsrf;
|
|
25
|
+
export declare function verifyOrigin(csrf: ResolvedCsrf, event: {
|
|
26
|
+
url: URL;
|
|
27
|
+
request: {
|
|
28
|
+
method: string;
|
|
29
|
+
headers: {
|
|
30
|
+
get(name: string): string | null;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
}): Response | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vérification d'origine des requêtes mutantes servies sous `basePath`.
|
|
3
|
+
*
|
|
4
|
+
* SvelteKit fait le même contrôle (`runtime/server/respond.js`) mais ne peut
|
|
5
|
+
* pas porter la garantie ici : il tourne avant le hook `handle` (invisible
|
|
6
|
+
* pour cette lib), un `kit.csrf.checkOrigin: false` posé pour une route sans
|
|
7
|
+
* rapport le désactive partout, et il est court-circuité en dev. Revérifié
|
|
8
|
+
* ici, dev compris : un proxy qui strippe `Origin` doit casser sur
|
|
9
|
+
* `pnpm run dev`, pas en production.
|
|
10
|
+
*/
|
|
11
|
+
/** Sans effet de bord : jamais un vecteur CSRF, jamais inspectées. */
|
|
12
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
13
|
+
/** Résolue au boot : une entrée illisible doit faire échouer `createAdminHandler`. */
|
|
14
|
+
export function resolveCsrfConfig(csrf) {
|
|
15
|
+
if (csrf === false)
|
|
16
|
+
return { enabled: false, trustedOrigins: new Set() };
|
|
17
|
+
const trustedOrigins = new Set();
|
|
18
|
+
for (const entry of csrf?.trustedOrigins ?? []) {
|
|
19
|
+
let origin;
|
|
20
|
+
try {
|
|
21
|
+
origin = new URL(entry).origin;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`[sveltekit-admin] csrf.trustedOrigins contains "${entry}", which is not an absolute ` +
|
|
25
|
+
'URL. Use a full origin such as "https://admin.example.com".');
|
|
26
|
+
}
|
|
27
|
+
// `javascript:`, `data:`, `file:`… normalisent en "null", ce qu'envoie
|
|
28
|
+
// aussi une iframe sandboxée : les accepter ouvrirait à tout contexte opaque.
|
|
29
|
+
if (origin === 'null') {
|
|
30
|
+
throw new Error(`[sveltekit-admin] csrf.trustedOrigins contains "${entry}", whose origin is opaque ` +
|
|
31
|
+
'("null"). Only http(s) origins can be trusted.');
|
|
32
|
+
}
|
|
33
|
+
trustedOrigins.add(origin);
|
|
34
|
+
}
|
|
35
|
+
return { enabled: true, trustedOrigins };
|
|
36
|
+
}
|
|
37
|
+
export function verifyOrigin(csrf, event) {
|
|
38
|
+
if (!csrf.enabled)
|
|
39
|
+
return null;
|
|
40
|
+
if (SAFE_METHODS.has(event.request.method))
|
|
41
|
+
return null;
|
|
42
|
+
const origin = event.request.headers.get('origin');
|
|
43
|
+
if (origin === event.url.origin)
|
|
44
|
+
return null;
|
|
45
|
+
// `trustedOrigins` ne contient jamais `null` (rejeté au boot) : un en-tête
|
|
46
|
+
// absent ne peut donc pas y correspondre.
|
|
47
|
+
if (origin !== null && csrf.trustedOrigins.has(origin))
|
|
48
|
+
return null;
|
|
49
|
+
// Corps statique : ne jamais réfléchir l'`Origin` reçu ni énumérer les
|
|
50
|
+
// origines acceptées.
|
|
51
|
+
return new Response('[sveltekit-admin] Cross-site request forbidden', {
|
|
52
|
+
status: 403,
|
|
53
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forme unique des échecs de mutation admin.
|
|
3
|
+
*
|
|
4
|
+
* Deux producteurs, un seul type : `mutations.ts` pour les refus que la
|
|
5
|
+
* bibliothèque décide elle-même (validation, scope), `classifyWriteError`
|
|
6
|
+
* pour ceux que le moteur signale. Un seul consommateur : le site d'appel
|
|
7
|
+
* de `handleMutation` dans `handler.ts`, qui ne rend QUE le message d'une
|
|
8
|
+
* `AdminMutationError` — jamais celui d'une erreur pilote brute.
|
|
9
|
+
*
|
|
10
|
+
* La classification se fait par code, jamais par texte : les messages des
|
|
11
|
+
* pilotes changent entre versions, les codes non.
|
|
12
|
+
*/
|
|
13
|
+
export type MutationErrorKind = 'validation' | 'conflict' | 'reference' | 'restrict' | 'authorization' | 'notFound' | 'unknown';
|
|
14
|
+
export declare class AdminMutationError extends Error {
|
|
15
|
+
readonly kind: MutationErrorKind;
|
|
16
|
+
readonly field?: string;
|
|
17
|
+
constructor(kind: MutationErrorKind, message: string, field?: string);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Erreur de configuration de la bibliothèque elle-même (scope non injectable,
|
|
21
|
+
* tenant absent…), destinée au développeur intégrateur et non à l'utilisateur
|
|
22
|
+
* de l'admin. Distincte d'`AdminMutationError` : elle ne décrit pas un refus
|
|
23
|
+
* de la donnée soumise, mais un montage incorrect côté consommateur, et c'est
|
|
24
|
+
* la SEULE erreur non typée que le chemin de mutation relaie telle quelle.
|
|
25
|
+
*/
|
|
26
|
+
export declare class AdminConfigError extends Error {
|
|
27
|
+
constructor(message: string);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Les pilotes exposent le code SQLSTATE à des endroits différents : `code` sur
|
|
31
|
+
* `pg`, `mysql2` et `better-sqlite3`, `meta.code` sur une
|
|
32
|
+
* `PrismaClientKnownRequestError` issue d'une transaction interactive.
|
|
33
|
+
*
|
|
34
|
+
* Vit ici plutôt que dans `retry.ts` : deux modules classent désormais les
|
|
35
|
+
* erreurs pilote, et un second exemplaire de ce helper dériverait du premier.
|
|
36
|
+
*/
|
|
37
|
+
export declare function codeOf(error: unknown): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Traduit un échec d'écriture en `AdminMutationError`, ou `null` si le code
|
|
40
|
+
* n'est pas reconnu — l'appelant rend alors un message générique.
|
|
41
|
+
*
|
|
42
|
+
* `reference` et `restrict` partagent le même code SQLSTATE (PostgreSQL 23503,
|
|
43
|
+
* SQLite SQLITE_CONSTRAINT_FOREIGNKEY) : c'est l'action en cours qui les
|
|
44
|
+
* sépare, pas le message. Sur create/update une cible soumise est invalide ;
|
|
45
|
+
* sur delete la ligne est référencée ailleurs.
|
|
46
|
+
*/
|
|
47
|
+
export declare function classifyWriteError(error: unknown, action: 'create' | 'update' | 'delete'): AdminMutationError | null;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forme unique des échecs de mutation admin.
|
|
3
|
+
*
|
|
4
|
+
* Deux producteurs, un seul type : `mutations.ts` pour les refus que la
|
|
5
|
+
* bibliothèque décide elle-même (validation, scope), `classifyWriteError`
|
|
6
|
+
* pour ceux que le moteur signale. Un seul consommateur : le site d'appel
|
|
7
|
+
* de `handleMutation` dans `handler.ts`, qui ne rend QUE le message d'une
|
|
8
|
+
* `AdminMutationError` — jamais celui d'une erreur pilote brute.
|
|
9
|
+
*
|
|
10
|
+
* La classification se fait par code, jamais par texte : les messages des
|
|
11
|
+
* pilotes changent entre versions, les codes non.
|
|
12
|
+
*/
|
|
13
|
+
export class AdminMutationError extends Error {
|
|
14
|
+
kind;
|
|
15
|
+
field;
|
|
16
|
+
constructor(kind, message, field) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'AdminMutationError';
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
this.field = field;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Erreur de configuration de la bibliothèque elle-même (scope non injectable,
|
|
25
|
+
* tenant absent…), destinée au développeur intégrateur et non à l'utilisateur
|
|
26
|
+
* de l'admin. Distincte d'`AdminMutationError` : elle ne décrit pas un refus
|
|
27
|
+
* de la donnée soumise, mais un montage incorrect côté consommateur, et c'est
|
|
28
|
+
* la SEULE erreur non typée que le chemin de mutation relaie telle quelle.
|
|
29
|
+
*/
|
|
30
|
+
export class AdminConfigError extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'AdminConfigError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Les pilotes exposent le code SQLSTATE à des endroits différents : `code` sur
|
|
38
|
+
* `pg`, `mysql2` et `better-sqlite3`, `meta.code` sur une
|
|
39
|
+
* `PrismaClientKnownRequestError` issue d'une transaction interactive.
|
|
40
|
+
*
|
|
41
|
+
* Vit ici plutôt que dans `retry.ts` : deux modules classent désormais les
|
|
42
|
+
* erreurs pilote, et un second exemplaire de ce helper dériverait du premier.
|
|
43
|
+
*/
|
|
44
|
+
export function codeOf(error) {
|
|
45
|
+
const candidate = error;
|
|
46
|
+
const raw = candidate?.code ?? candidate?.meta?.code;
|
|
47
|
+
return typeof raw === 'string' ? raw : undefined;
|
|
48
|
+
}
|
|
49
|
+
const UNIQUE_CODES = new Set([
|
|
50
|
+
'P2002', // Prisma
|
|
51
|
+
'23505', // PostgreSQL — unique_violation
|
|
52
|
+
'ER_DUP_ENTRY', // MySQL 1062
|
|
53
|
+
'SQLITE_CONSTRAINT_UNIQUE'
|
|
54
|
+
]);
|
|
55
|
+
const FOREIGN_KEY_CODES = new Set([
|
|
56
|
+
'P2003', // Prisma
|
|
57
|
+
'23503', // PostgreSQL — foreign_key_violation
|
|
58
|
+
'ER_NO_REFERENCED_ROW_2', // MySQL 1452 — la cible soumise n'existe pas
|
|
59
|
+
'ER_ROW_IS_REFERENCED_2', // MySQL 1451 — la ligne est référencée ailleurs
|
|
60
|
+
'SQLITE_CONSTRAINT_FOREIGNKEY'
|
|
61
|
+
]);
|
|
62
|
+
const NOT_FOUND_CODES = new Set(['P2025']);
|
|
63
|
+
/**
|
|
64
|
+
* Traduit un échec d'écriture en `AdminMutationError`, ou `null` si le code
|
|
65
|
+
* n'est pas reconnu — l'appelant rend alors un message générique.
|
|
66
|
+
*
|
|
67
|
+
* `reference` et `restrict` partagent le même code SQLSTATE (PostgreSQL 23503,
|
|
68
|
+
* SQLite SQLITE_CONSTRAINT_FOREIGNKEY) : c'est l'action en cours qui les
|
|
69
|
+
* sépare, pas le message. Sur create/update une cible soumise est invalide ;
|
|
70
|
+
* sur delete la ligne est référencée ailleurs.
|
|
71
|
+
*/
|
|
72
|
+
export function classifyWriteError(error, action) {
|
|
73
|
+
if (error instanceof AdminMutationError)
|
|
74
|
+
return error;
|
|
75
|
+
const code = codeOf(error);
|
|
76
|
+
if (code === undefined)
|
|
77
|
+
return null;
|
|
78
|
+
if (UNIQUE_CODES.has(code)) {
|
|
79
|
+
return new AdminMutationError('conflict', 'A record with these values already exists.');
|
|
80
|
+
}
|
|
81
|
+
if (FOREIGN_KEY_CODES.has(code)) {
|
|
82
|
+
return action === 'delete'
|
|
83
|
+
? new AdminMutationError('restrict', 'This record is referenced by other records.')
|
|
84
|
+
: new AdminMutationError('reference', 'A referenced record no longer exists.');
|
|
85
|
+
}
|
|
86
|
+
if (NOT_FOUND_CODES.has(code)) {
|
|
87
|
+
return new AdminMutationError('notFound', 'This record no longer exists.');
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
package/dist/server/handler.d.ts
CHANGED
|
@@ -3,23 +3,17 @@
|
|
|
3
3
|
* Zero files needed in routes - everything handled via hook
|
|
4
4
|
*/
|
|
5
5
|
import type { DataAdapter, SchemaIntrospector } from './adapters/types.js';
|
|
6
|
+
import type { AuditEvent } from './audit.js';
|
|
7
|
+
import { type CsrfConfig } from './csrf.js';
|
|
8
|
+
import type { AdminPlugin } from './plugin.js';
|
|
6
9
|
export interface AdminHandlerConfig {
|
|
7
10
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* Explicit `{ introspector, data }` pair, from `createPrismaAdapter`,
|
|
12
|
+
* `createDrizzleAdapter`, or a custom implementation. The `{ prisma,
|
|
13
|
+
* prismaSchemaPath }` shortcut lives on the Prisma wrapper exported by
|
|
14
|
+
* the package root, not here.
|
|
12
15
|
*/
|
|
13
|
-
|
|
14
|
-
/** Path to Prisma schema file */
|
|
15
|
-
prismaSchemaPath?: string;
|
|
16
|
-
/**
|
|
17
|
-
* Explicit adapter, built via `createPrismaAdapter(...)` (or, in a future
|
|
18
|
-
* release, a Drizzle/other adapter). Takes priority over `prisma`/
|
|
19
|
-
* `prismaSchemaPath` when both are somehow set. Most consumers never touch
|
|
20
|
-
* this — passing `prisma`/`prismaSchemaPath` builds one internally.
|
|
21
|
-
*/
|
|
22
|
-
adapter?: {
|
|
16
|
+
adapter: {
|
|
23
17
|
introspector: SchemaIntrospector;
|
|
24
18
|
data: DataAdapter;
|
|
25
19
|
};
|
|
@@ -48,12 +42,50 @@ export interface AdminHandlerConfig {
|
|
|
48
42
|
logout?: (event: any) => void | Promise<void>;
|
|
49
43
|
/** Where to redirect after logout (default: '/') */
|
|
50
44
|
logoutRedirectTo?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Cross-site protection for every state-changing admin request (create /
|
|
47
|
+
* update / delete, `_logout`, `_search`). On by default; a missing `Origin`
|
|
48
|
+
* is rejected, as SvelteKit does. `trustedOrigins` allows a second
|
|
49
|
+
* legitimate origin, `csrf: false` opts out entirely.
|
|
50
|
+
*
|
|
51
|
+
* Why this isn't left to `kit.csrf.checkOrigin`, and the same-origin threat
|
|
52
|
+
* it does not cover: see `csrf.ts` and /docs/csrf.
|
|
53
|
+
*/
|
|
54
|
+
csrf?: CsrfConfig;
|
|
55
|
+
/**
|
|
56
|
+
* Audit sink — same "bring your own" philosophy as `authCheck` / `logout`.
|
|
57
|
+
* The library has no log table and no session of its own, so it cannot
|
|
58
|
+
* know where to persist "admin X changed row Y" (your `AuditLog` model,
|
|
59
|
+
* a logger, an HTTP sink…). You provide the side effect; the handler
|
|
60
|
+
* calls it **after a successful create / update / delete** with a
|
|
61
|
+
* redacted `AuditEvent`. No callback means no behaviour change: no
|
|
62
|
+
* extra reads, no calls.
|
|
63
|
+
*
|
|
64
|
+
* The actor is whatever you already put on `event.locals` (the same
|
|
65
|
+
* object `authCheck` sees). Sensitive field names (`password` / `hash` /
|
|
66
|
+
* `secret` / `token`) and per-model `hidden` fields are stripped from
|
|
67
|
+
* `values` / `before` / `after` / `changes` so the sink cannot become a
|
|
68
|
+
* second oracle for secrets. Reads (GET), logout, and `_search` are
|
|
69
|
+
* not audited.
|
|
70
|
+
*
|
|
71
|
+
* Awaited before the 303 so a `prisma.auditLog.create(...)` inside the
|
|
72
|
+
* callback commits before the redirect. If the callback throws, the
|
|
73
|
+
* mutation still redirects — the write is the source of truth, the log
|
|
74
|
+
* is a sidecar (`console.error` with prefix
|
|
75
|
+
* `[sveltekit-admin] audit callback failed:`). There is no way to wrap
|
|
76
|
+
* the adapter write and your sink in one transaction without owning
|
|
77
|
+
* both stores.
|
|
78
|
+
*/
|
|
79
|
+
audit?: (entry: AuditEvent) => void | Promise<void>;
|
|
51
80
|
/** Per-model configuration */
|
|
52
81
|
models?: Record<string, {
|
|
53
82
|
hidden?: string[];
|
|
54
83
|
readonly?: string[];
|
|
55
84
|
listFields?: string[];
|
|
56
85
|
label?: string;
|
|
86
|
+
scope?: (ctx: {
|
|
87
|
+
locals?: any;
|
|
88
|
+
}) => Record<string, unknown> | import('./adapters/types.js').Filter;
|
|
57
89
|
/**
|
|
58
90
|
* Scoping `where` applied to the LIST VIEW ONLY of this model
|
|
59
91
|
* (search, sidebar filters, FK filter, pagination count) — composed
|
|
@@ -145,23 +177,22 @@ export interface AdminHandlerConfig {
|
|
|
145
177
|
linkThreshold?: number;
|
|
146
178
|
autoDetect?: boolean;
|
|
147
179
|
};
|
|
148
|
-
/**
|
|
149
|
-
* Recherche texte libre : configuration globale.
|
|
150
|
-
* `mode`: 'auto' détecte le provider du schéma et n'émet `mode: 'insensitive'`
|
|
151
|
-
* que sur postgresql/cockroachdb/mongodb (les seuls où Prisma le supporte —
|
|
152
|
-
* l'émettre sur sqlite/mysql/sqlserver lève une erreur Prisma). 'insensitive'
|
|
153
|
-
* et 'default' forcent le comportement, pour un provider non détectable
|
|
154
|
-
* (`provider = env(...)`) ou un besoin spécifique (index `citext`, etc.).
|
|
155
|
-
* Voir docs/design/list-search-filters.md §2.5.
|
|
156
|
-
*/
|
|
157
|
-
search?: {
|
|
158
|
-
mode?: 'auto' | 'insensitive' | 'default';
|
|
159
|
-
};
|
|
160
180
|
/** Custom branding */
|
|
161
181
|
branding?: {
|
|
162
182
|
title?: string;
|
|
163
183
|
primaryColor?: string;
|
|
164
184
|
};
|
|
185
|
+
/**
|
|
186
|
+
* Optional admin plugins (new pages + record actions). Omitted or `[]`
|
|
187
|
+
* keeps every builtin view byte-identical to a build without plugins.
|
|
188
|
+
* Plugin routes are matched before builtins, so a registered pattern
|
|
189
|
+
* with a literal token in a `:model`/`:id` position can take over a
|
|
190
|
+
* builtin path when it matches first (e.g. `['user']` shadows the User
|
|
191
|
+
* list); only an identical, token-for-token overlay throws at boot.
|
|
192
|
+
* See `AdminPlugin`. Options like graph `depth` belong on the author's
|
|
193
|
+
* factory, not here.
|
|
194
|
+
*/
|
|
195
|
+
plugins?: AdminPlugin[];
|
|
165
196
|
}
|
|
166
197
|
export declare function createAdminHandler(config: AdminHandlerConfig): ({ event, resolve }: {
|
|
167
198
|
event: any;
|