sveltekit-admin 0.6.0 → 0.9.0
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 +196 -42
- 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 +71 -11
- 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 +42 -3
- 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/data.d.ts +18 -2
- package/dist/server/data.js +39 -8
- package/dist/server/errors.d.ts +47 -0
- package/dist/server/errors.js +90 -0
- package/dist/server/handler.d.ts +92 -26
- package/dist/server/handler.js +263 -560
- package/dist/server/introspection/parser.d.ts +15 -0
- package/dist/server/introspection/parser.js +17 -0
- package/dist/server/mutations.d.ts +13 -0
- package/dist/server/mutations.js +476 -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/listColumns.d.ts +19 -0
- package/dist/server/query/listColumns.js +43 -0
- package/dist/server/query/listQuery.d.ts +1 -1
- package/dist/server/query/pageSize.d.ts +19 -0
- package/dist/server/query/pageSize.js +26 -0
- package/dist/server/query/sortQuery.d.ts +31 -0
- package/dist/server/query/sortQuery.js +33 -0
- package/dist/server/query/urls.js +9 -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 +51 -0
- package/dist/server/runtime.js +263 -0
- package/dist/server/search.d.ts +14 -0
- package/dist/server/search.js +78 -0
- package/dist/server/submitted.d.ts +20 -0
- package/dist/server/submitted.js +54 -0
- package/dist/server/views/FieldInput.svelte +114 -8
- package/dist/server/views/FieldInput.svelte.d.ts +7 -0
- package/dist/server/views/Form.svelte +96 -5
- package/dist/server/views/Form.svelte.d.ts +19 -1
- package/dist/server/views/Layout.svelte +32 -4
- package/dist/server/views/Layout.svelte.d.ts +2 -0
- package/dist/server/views/List.svelte +156 -26
- package/dist/server/views/List.svelte.d.ts +7 -2
- package/dist/server/views/RelationCheckboxes.svelte +26 -5
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +14 -3
- package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
- package/dist/server/views/html.d.ts +19 -0
- package/dist/server/views/html.js +25 -0
- package/dist/server/views/pagination.d.ts +9 -0
- package/dist/server/views/pagination.js +31 -0
- package/dist/server/views/theme.js +147 -3
- package/dist/server/views/types.d.ts +15 -0
- package/package.json +24 -21
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createAdminHandler as createCoreHandler } from '../../handler.js';
|
|
2
|
+
import { createPrismaDataAdapter } from './dataAdapter.js';
|
|
3
|
+
import { createPrismaIntrospector } from './introspector.js';
|
|
4
|
+
import { resolveCaseInsensitiveSearch } from './index.js';
|
|
5
|
+
function omitPrismaShortcutFields(config) {
|
|
6
|
+
const { prisma: _prisma, prismaSchemaPath: _path, search: _search, adapter: _adapter, ...rest } = config;
|
|
7
|
+
return rest;
|
|
8
|
+
}
|
|
9
|
+
function buildPrismaAdapter(config) {
|
|
10
|
+
const schemaPath = config.prismaSchemaPath ?? './prisma/schema.prisma';
|
|
11
|
+
const introspector = createPrismaIntrospector({ schemaPath });
|
|
12
|
+
let schema = null;
|
|
13
|
+
try {
|
|
14
|
+
schema = introspector.introspect();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
schema = null;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
introspector: schema ? { introspect: () => schema } : introspector,
|
|
21
|
+
data: createPrismaDataAdapter(config.prisma, {
|
|
22
|
+
caseInsensitiveSearch: resolveCaseInsensitiveSearch(schema, config.search?.mode)
|
|
23
|
+
})
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function createAdminHandler(config) {
|
|
27
|
+
if (config.adapter) {
|
|
28
|
+
return createCoreHandler({ ...omitPrismaShortcutFields(config), adapter: config.adapter });
|
|
29
|
+
}
|
|
30
|
+
if (!config.prisma) {
|
|
31
|
+
throw new Error('[sveltekit-admin] createAdminHandler requires either `prisma` (with optional `prismaSchemaPath`) or `adapter` — neither was provided.');
|
|
32
|
+
}
|
|
33
|
+
return createCoreHandler({
|
|
34
|
+
...omitPrismaShortcutFields(config),
|
|
35
|
+
adapter: buildPrismaAdapter(config)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reprise bornée des écritures transactionnelles.
|
|
3
|
+
*
|
|
4
|
+
* Une transaction `SERIALIZABLE` peut échouer sur un conflit que le moteur ne
|
|
5
|
+
* sait pas ordonner (PostgreSQL 40001) ou sur un deadlock (PostgreSQL 40P01,
|
|
6
|
+
* MySQL 1213). Ces échecs sont transitoires par construction : la transaction
|
|
7
|
+
* a été annulée entièrement, elle n'a donc rien écrit, et rejouer le même
|
|
8
|
+
* travail sur un instantané neuf aboutit presque toujours. Sans reprise ils
|
|
9
|
+
* remontent en 500 alors que rien n'est cassé.
|
|
10
|
+
*
|
|
11
|
+
* On ne rejoue QUE ces codes. Un refus de scope (« outside the authorization
|
|
12
|
+
* scope ») ou une FK invalide ne sont pas transitoires : les rejouer ne ferait
|
|
13
|
+
* que répéter le même refus, et masquerait un refus légitime derrière une
|
|
14
|
+
* latence. Le défaut est donc de laisser remonter.
|
|
15
|
+
*
|
|
16
|
+
* Pas de temporisation entre les tentatives : au moment où le moteur signale
|
|
17
|
+
* le conflit, la transaction concurrente est déjà terminée (committée ou
|
|
18
|
+
* annulée), donc attendre ne change rien à la probabilité de succès. Cela
|
|
19
|
+
* évite aussi d'introduire des minuteurs dans un chemin d'écriture.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isRetryableWriteError(error: unknown): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Exécute `run`, en le rejouant tant que l'échec est un conflit de concurrence
|
|
24
|
+
* et que le budget de tentatives n'est pas épuisé. `attempts` compte la
|
|
25
|
+
* tentative initiale : 3 signifie « un essai puis deux reprises ».
|
|
26
|
+
*/
|
|
27
|
+
export declare function withWriteRetry<T>(run: () => Promise<T>, attempts?: number): Promise<T>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reprise bornée des écritures transactionnelles.
|
|
3
|
+
*
|
|
4
|
+
* Une transaction `SERIALIZABLE` peut échouer sur un conflit que le moteur ne
|
|
5
|
+
* sait pas ordonner (PostgreSQL 40001) ou sur un deadlock (PostgreSQL 40P01,
|
|
6
|
+
* MySQL 1213). Ces échecs sont transitoires par construction : la transaction
|
|
7
|
+
* a été annulée entièrement, elle n'a donc rien écrit, et rejouer le même
|
|
8
|
+
* travail sur un instantané neuf aboutit presque toujours. Sans reprise ils
|
|
9
|
+
* remontent en 500 alors que rien n'est cassé.
|
|
10
|
+
*
|
|
11
|
+
* On ne rejoue QUE ces codes. Un refus de scope (« outside the authorization
|
|
12
|
+
* scope ») ou une FK invalide ne sont pas transitoires : les rejouer ne ferait
|
|
13
|
+
* que répéter le même refus, et masquerait un refus légitime derrière une
|
|
14
|
+
* latence. Le défaut est donc de laisser remonter.
|
|
15
|
+
*
|
|
16
|
+
* Pas de temporisation entre les tentatives : au moment où le moteur signale
|
|
17
|
+
* le conflit, la transaction concurrente est déjà terminée (committée ou
|
|
18
|
+
* annulée), donc attendre ne change rien à la probabilité de succès. Cela
|
|
19
|
+
* évite aussi d'introduire des minuteurs dans un chemin d'écriture.
|
|
20
|
+
*/
|
|
21
|
+
import { codeOf } from '../errors.js';
|
|
22
|
+
/** Codes que le moteur n'émet que pour un conflit de concurrence annulable. */
|
|
23
|
+
const RETRYABLE_CODES = new Set([
|
|
24
|
+
'40001', // PostgreSQL / CockroachDB — serialization_failure
|
|
25
|
+
'40P01', // PostgreSQL — deadlock_detected
|
|
26
|
+
'ER_LOCK_DEADLOCK', // MySQL 1213
|
|
27
|
+
'ER_LOCK_WAIT_TIMEOUT' // MySQL 1205
|
|
28
|
+
]);
|
|
29
|
+
export function isRetryableWriteError(error) {
|
|
30
|
+
const code = codeOf(error);
|
|
31
|
+
return code !== undefined && RETRYABLE_CODES.has(code);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Exécute `run`, en le rejouant tant que l'échec est un conflit de concurrence
|
|
35
|
+
* et que le budget de tentatives n'est pas épuisé. `attempts` compte la
|
|
36
|
+
* tentative initiale : 3 signifie « un essai puis deux reprises ».
|
|
37
|
+
*/
|
|
38
|
+
export async function withWriteRetry(run, attempts = 3) {
|
|
39
|
+
// La boucle ne couvre que les reprises ; la dernière tentative est le `run`
|
|
40
|
+
// final, dont l'échec remonte tel quel. Écrit ainsi plutôt qu'en boucle
|
|
41
|
+
// infinie avec un `throw` de sortie : celle-ci n'aurait aucune sortie
|
|
42
|
+
// normale, donc une branche inatteignable et non testable.
|
|
43
|
+
for (let remaining = attempts - 1; remaining > 0; remaining -= 1) {
|
|
44
|
+
try {
|
|
45
|
+
return await run();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (!isRetryableWriteError(error))
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return run();
|
|
53
|
+
}
|
|
@@ -21,17 +21,41 @@ 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
|
+
}
|
|
29
|
+
/** Tri demandé par `?sort=`, déjà validé contre les colonnes que la vue rend. */
|
|
30
|
+
export interface ListOrder {
|
|
31
|
+
field: string;
|
|
32
|
+
dir: 'asc' | 'desc';
|
|
33
|
+
}
|
|
24
34
|
/**
|
|
25
35
|
* Per-request CRUD + relation-read surface `handler.ts` talks to instead of
|
|
26
36
|
* a raw ORM client. See docs/superpowers/specs/2026-08-13-db-adapter-abstraction-design.md
|
|
27
37
|
* for the rationale behind each method's shape.
|
|
28
38
|
*/
|
|
29
39
|
export interface DataAdapter {
|
|
30
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Vue liste paginée : toujours count + fetch ensemble.
|
|
42
|
+
*
|
|
43
|
+
* `orderBy` absent = clé primaire décroissante, l'ordre historique. Présent,
|
|
44
|
+
* il est TOUJOURS départagé par la clé primaire décroissante, sauf quand
|
|
45
|
+
* c'est elle qu'on trie : sans ce départage, deux lignes de même valeur
|
|
46
|
+
* peuvent changer de page d'une requête à l'autre, et une fenêtre
|
|
47
|
+
* `skip`/`take` posée par-dessus perd son sens (une ligne vue deux fois, une
|
|
48
|
+
* autre jamais). C'est à l'adapter de le composer — lui seul sait nommer la
|
|
49
|
+
* clé primaire dans le langage de son moteur.
|
|
50
|
+
*
|
|
51
|
+
* `field` n'est jamais une chaîne libre : `sortQuery.ts` ne le laisse sortir
|
|
52
|
+
* que s'il appartient aux colonnes réellement rendues par la liste.
|
|
53
|
+
*/
|
|
31
54
|
listRecords(model: Model, opts: {
|
|
32
55
|
filter?: Filter;
|
|
33
56
|
skip: number;
|
|
34
57
|
take: number;
|
|
58
|
+
orderBy?: ListOrder;
|
|
35
59
|
}): Promise<{
|
|
36
60
|
rows: Record<string, unknown>[];
|
|
37
61
|
total: number;
|
|
@@ -65,6 +89,7 @@ export interface DataAdapter {
|
|
|
65
89
|
targetPkField: string;
|
|
66
90
|
ids: Array<string | number>;
|
|
67
91
|
}>;
|
|
92
|
+
targetGuards?: TargetGuard[];
|
|
68
93
|
}): Promise<Record<string, unknown>>;
|
|
69
94
|
updateRecord(model: Model, id: string | number, input: {
|
|
70
95
|
scalars: Record<string, unknown>;
|
|
@@ -72,8 +97,22 @@ export interface DataAdapter {
|
|
|
72
97
|
targetPkField: string;
|
|
73
98
|
ids: Array<string | number>;
|
|
74
99
|
}>;
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
targetGuards?: TargetGuard[];
|
|
101
|
+
}, authorizationFilter?: Filter): Promise<Record<string, unknown>>;
|
|
102
|
+
deleteRecord(model: Model, id: string | number, authorizationFilter?: Filter): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Suppression en masse, en UNE opération et non une boucle de
|
|
105
|
+
* `deleteRecord` : une boucle qui casse au septième id sur une contrainte de
|
|
106
|
+
* clé étrangère laisse six lignes supprimées et rien pour revenir en
|
|
107
|
+
* arrière. Ici les deux seules réponses possibles sont « tout est supprimé »
|
|
108
|
+
* et « rien ne l'est, parce que telle ligne est encore référencée ».
|
|
109
|
+
*
|
|
110
|
+
* `authorizationFilter` est composé avec les ids DANS le `where` plutôt que
|
|
111
|
+
* vérifié à part : un id hors portée ne matche simplement pas, sans erreur,
|
|
112
|
+
* donc rien ne distingue « n'existe pas » de « appartient à un autre
|
|
113
|
+
* tenant ». Le compte renvoyé est celui des lignes réellement supprimées.
|
|
114
|
+
*/
|
|
115
|
+
deleteMany(model: Model, ids: Array<string | number>, authorizationFilter?: Filter): Promise<number>;
|
|
77
116
|
/** `targetModel` est fourni par l'appelant : chaque site d'appel actuel l'a déjà résolu. */
|
|
78
117
|
getM2mSelectedIds(model: Model, edge: RelationEdge, targetModel: Model, recordId: string | number): Promise<Array<string | number>>;
|
|
79
118
|
}
|
|
@@ -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
|
+
}
|
package/dist/server/data.d.ts
CHANGED
|
@@ -16,8 +16,24 @@ export declare function primaryKeyOf(model: PrismaModel): string;
|
|
|
16
16
|
* côté intégration.
|
|
17
17
|
*/
|
|
18
18
|
export declare function coerceId(id: string, model: PrismaModel): string | number;
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Convertit un FormData en payload Prisma, en ignorant les champs auto-gérés.
|
|
21
|
+
*
|
|
22
|
+
* Renvoie aussi la liste des champs dont la valeur soumise ne se convertit pas
|
|
23
|
+
* vers le type de la colonne : un JSON illisible, un nombre qui n'en est pas
|
|
24
|
+
* un. Leur clé est laissée HORS du payload, et c'est `mutations.ts` — le seul
|
|
25
|
+
* producteur de refus côté bibliothèque, cf. `errors.ts` — qui décide quoi en
|
|
26
|
+
* faire. Cette fonction ne lève pas : elle convertit et rapporte.
|
|
27
|
+
*
|
|
28
|
+
* Ces valeurs étaient auparavant écrites en `null` sans un mot. Sur une colonne
|
|
29
|
+
* nullable, la saisie et la valeur déjà stockée disparaissaient ensemble
|
|
30
|
+
* derrière un `303` d'apparence réussie ; sur une colonne obligatoire, le
|
|
31
|
+
* pilote répondait par un message générique ne nommant aucun champ.
|
|
32
|
+
*/
|
|
33
|
+
export declare function formDataToPrisma(formData: FormData, model: PrismaModel): {
|
|
34
|
+
data: Record<string, unknown>;
|
|
35
|
+
invalid: string[];
|
|
36
|
+
};
|
|
21
37
|
/**
|
|
22
38
|
* Traduit le paramètre `?page=` en fenêtre Prisma. Toute entrée qui n'est pas un
|
|
23
39
|
* entier sûr >= 1 retombe sur la première page : sans ce garde-fou, `?page=abc`
|
package/dist/server/data.js
CHANGED
|
@@ -22,9 +22,23 @@ export function coerceId(id, model) {
|
|
|
22
22
|
const pkField = model.fields.find((f) => f.name === primaryKeyOf(model));
|
|
23
23
|
return pkField?.type === 'Int' ? parseInt(id) : id;
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Convertit un FormData en payload Prisma, en ignorant les champs auto-gérés.
|
|
27
|
+
*
|
|
28
|
+
* Renvoie aussi la liste des champs dont la valeur soumise ne se convertit pas
|
|
29
|
+
* vers le type de la colonne : un JSON illisible, un nombre qui n'en est pas
|
|
30
|
+
* un. Leur clé est laissée HORS du payload, et c'est `mutations.ts` — le seul
|
|
31
|
+
* producteur de refus côté bibliothèque, cf. `errors.ts` — qui décide quoi en
|
|
32
|
+
* faire. Cette fonction ne lève pas : elle convertit et rapporte.
|
|
33
|
+
*
|
|
34
|
+
* Ces valeurs étaient auparavant écrites en `null` sans un mot. Sur une colonne
|
|
35
|
+
* nullable, la saisie et la valeur déjà stockée disparaissaient ensemble
|
|
36
|
+
* derrière un `303` d'apparence réussie ; sur une colonne obligatoire, le
|
|
37
|
+
* pilote répondait par un message générique ne nommant aucun champ.
|
|
38
|
+
*/
|
|
26
39
|
export function formDataToPrisma(formData, model) {
|
|
27
40
|
const data = {};
|
|
41
|
+
const invalid = [];
|
|
28
42
|
for (const field of model.fields) {
|
|
29
43
|
if (field.isId || field.isUpdatedAt || field.isCreatedAt || field.relation)
|
|
30
44
|
continue;
|
|
@@ -36,13 +50,23 @@ export function formDataToPrisma(formData, model) {
|
|
|
36
50
|
}
|
|
37
51
|
switch (field.type) {
|
|
38
52
|
case 'Int':
|
|
39
|
-
case 'BigInt':
|
|
40
|
-
|
|
53
|
+
case 'BigInt': {
|
|
54
|
+
const parsed = value ? parseInt(value.toString()) : null;
|
|
55
|
+
if (Number.isNaN(parsed))
|
|
56
|
+
invalid.push(field.name);
|
|
57
|
+
else
|
|
58
|
+
data[field.name] = parsed;
|
|
41
59
|
break;
|
|
60
|
+
}
|
|
42
61
|
case 'Float':
|
|
43
|
-
case 'Decimal':
|
|
44
|
-
|
|
62
|
+
case 'Decimal': {
|
|
63
|
+
const parsed = value ? parseFloat(value.toString()) : null;
|
|
64
|
+
if (Number.isNaN(parsed))
|
|
65
|
+
invalid.push(field.name);
|
|
66
|
+
else
|
|
67
|
+
data[field.name] = parsed;
|
|
45
68
|
break;
|
|
69
|
+
}
|
|
46
70
|
case 'Boolean':
|
|
47
71
|
data[field.name] = value === 'on' || value === 'true' || value === '1';
|
|
48
72
|
break;
|
|
@@ -54,14 +78,21 @@ export function formDataToPrisma(formData, model) {
|
|
|
54
78
|
data[field.name] = value ? JSON.parse(value.toString()) : null;
|
|
55
79
|
}
|
|
56
80
|
catch {
|
|
57
|
-
|
|
81
|
+
invalid.push(field.name);
|
|
58
82
|
}
|
|
59
83
|
break;
|
|
60
84
|
default:
|
|
61
|
-
|
|
85
|
+
// Le vide vaut `null`, comme pour les types au-dessus. Écrire `''`
|
|
86
|
+
// rendait une chaîne vide indistinguable d'une valeur voulue, violait
|
|
87
|
+
// une colonne `String? @unique` dès la deuxième ligne vidée, et
|
|
88
|
+
// n'était de toute façon pas une valeur qu'un type enum déclare.
|
|
89
|
+
// Un champ ABSENT du formulaire ne passe pas par ici (`continue`
|
|
90
|
+
// au-dessus) : c'est cette distinction que `mutations.ts` exploite
|
|
91
|
+
// pour séparer « non soumis » de « vidé ».
|
|
92
|
+
data[field.name] = value.toString() || null;
|
|
62
93
|
}
|
|
63
94
|
}
|
|
64
|
-
return data;
|
|
95
|
+
return { data, invalid };
|
|
65
96
|
}
|
|
66
97
|
/**
|
|
67
98
|
* Traduit le paramètre `?page=` en fenêtre Prisma. Toute entrée qui n'est pas un
|
|
@@ -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;
|