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
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@ See [CHANGELOG.md](./CHANGELOG.md) for release notes and breaking changes, and
|
|
|
16
16
|
- ⚡ **Zero routes** - everything handled via a single hook
|
|
17
17
|
- 🪶 **3 lines of code** to setup
|
|
18
18
|
- 🔧 **Customizable** - hide fields, set readonly, custom labels
|
|
19
|
+
- 📋 **Audit log** - optional callback after every successful write
|
|
20
|
+
- 🧩 **Plugins** - optional extra pages and record actions (`plugins: []`)
|
|
19
21
|
- 🔌 **Drizzle adapter** (optional subpath export)
|
|
20
22
|
|
|
21
23
|
## Installation
|
|
@@ -46,12 +48,12 @@ That's it! Navigate to `/admin` and you'll see:
|
|
|
46
48
|
|
|
47
49
|
## Drizzle
|
|
48
50
|
|
|
49
|
-
Prisma stays the default
|
|
50
|
-
|
|
51
|
+
Prisma stays the default on the root entry (`createAdminHandler({ prisma })`).
|
|
52
|
+
For Drizzle, import **both** the handler and the adapter from the subpath
|
|
53
|
+
so a Drizzle-only app never evaluates the Prisma adapter modules:
|
|
51
54
|
|
|
52
55
|
```typescript
|
|
53
|
-
import { createAdminHandler } from 'sveltekit-admin';
|
|
54
|
-
import { createDrizzleAdapter } from 'sveltekit-admin/adapters/drizzle';
|
|
56
|
+
import { createAdminHandler, createDrizzleAdapter } from 'sveltekit-admin/adapters/drizzle';
|
|
55
57
|
import { db } from './db';
|
|
56
58
|
import * as schema from './db/schema';
|
|
57
59
|
|
|
@@ -61,6 +63,10 @@ export const handle = createAdminHandler({
|
|
|
61
63
|
});
|
|
62
64
|
```
|
|
63
65
|
|
|
66
|
+
Importing `createAdminHandler` from `sveltekit-admin` and the adapter from
|
|
67
|
+
the subpath still works, but it loads the Prisma adapter JavaScript (it does
|
|
68
|
+
not require installing `@prisma/client`).
|
|
69
|
+
|
|
64
70
|
Pass the same `schema` object you already export (tables + `relations()`).
|
|
65
71
|
Model names in `config.models` are the JS export keys (`users`, not `User`).
|
|
66
72
|
`config.search.mode` only applies to `createAdminHandler({ prisma })`;
|
|
@@ -163,6 +169,37 @@ prefetch would allow), and this route is checked *before* `authCheck`, so
|
|
|
163
169
|
a user whose session already expired can still use it to clean up
|
|
164
170
|
client-side state instead of being stuck behind a 401 with no way back.
|
|
165
171
|
|
|
172
|
+
### Audit log
|
|
173
|
+
|
|
174
|
+
Same philosophy as `authCheck` / `logout`: the library has no log table of
|
|
175
|
+
its own. Provide an `audit` callback and it is called after every
|
|
176
|
+
**successful** create, update, or delete with a redacted `AuditEvent`.
|
|
177
|
+
The actor is whatever you already put on `event.locals`. Sensitive and
|
|
178
|
+
`hidden` fields are stripped. If the callback throws, the mutation still
|
|
179
|
+
redirects.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
const adminHandle = createAdminHandler({
|
|
183
|
+
prisma,
|
|
184
|
+
authCheck: (event) => event.locals.session?.user?.role === 'admin',
|
|
185
|
+
audit: async (entry) => {
|
|
186
|
+
await prisma.auditLog.create({
|
|
187
|
+
data: {
|
|
188
|
+
at: entry.at,
|
|
189
|
+
actorId: entry.event.locals.session?.user?.id,
|
|
190
|
+
action: entry.action,
|
|
191
|
+
model: entry.model,
|
|
192
|
+
recordId: String(entry.id),
|
|
193
|
+
changes: entry.action === 'update' ? entry.changes : undefined
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
No callback means no behaviour change. Persist to your own model if you
|
|
201
|
+
want the log to appear in the admin like any other table.
|
|
202
|
+
|
|
166
203
|
## How It Works
|
|
167
204
|
|
|
168
205
|
The admin handler intercepts all requests to `/admin/*` and:
|
|
@@ -177,6 +214,26 @@ Routes handled:
|
|
|
177
214
|
- `/admin/user/new` → Create user form
|
|
178
215
|
- `/admin/user/123` → Edit user form
|
|
179
216
|
|
|
217
|
+
## Plugins
|
|
218
|
+
|
|
219
|
+
Pass `plugins` to register extra admin pages (SSR HTML + inline CSS/JS)
|
|
220
|
+
and links on edit screens and list rows. See the exported `AdminPlugin`
|
|
221
|
+
type and the documentation site's Plugins page.
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
createAdminHandler({
|
|
225
|
+
prisma,
|
|
226
|
+
plugins: [
|
|
227
|
+
{
|
|
228
|
+
name: 'hello',
|
|
229
|
+
pages: [{ pattern: ['hello'], render: () => ({ html: '<p>Hello</p>' }) }]
|
|
230
|
+
}
|
|
231
|
+
]
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Omit `plugins` and the admin is unchanged.
|
|
236
|
+
|
|
180
237
|
## Model Configuration
|
|
181
238
|
|
|
182
239
|
```typescript
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* SvelteKit Admin
|
|
3
3
|
* Django-like admin panel for SvelteKit + Prisma
|
|
4
4
|
*/
|
|
5
|
-
export { createAdminHandler, type AdminHandlerConfig } from './server/handler.js';
|
|
5
|
+
export { createAdminHandler, type AdminHandlerConfig } from './server/adapters/prisma/handler.js';
|
|
6
6
|
export { defaultAdminCheck } from './server/auth.js';
|
|
7
|
+
export type { AuditAction, AuditEvent } from './server/audit.js';
|
|
7
8
|
export { parsePrismaSchema, parseSchemaContent, type PrismaSchema, type PrismaModel, type PrismaField } from './server/introspection/parser.js';
|
|
8
9
|
export type { Schema, Model, Field } from './server/types/schema.js';
|
|
9
10
|
export { createPrismaAdapter } from './server/adapters/prisma/index.js';
|
|
10
11
|
export type { DataAdapter, SchemaIntrospector, Filter } from './server/adapters/types.js';
|
|
12
|
+
export type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction, PluginPageContext, PluginPageResult } from './server/plugin.js';
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* SvelteKit Admin
|
|
3
3
|
* Django-like admin panel for SvelteKit + Prisma
|
|
4
4
|
*/
|
|
5
|
-
export { createAdminHandler } from './server/handler.js';
|
|
5
|
+
export { createAdminHandler } from './server/adapters/prisma/handler.js';
|
|
6
6
|
export { defaultAdminCheck } from './server/auth.js';
|
|
7
7
|
export { parsePrismaSchema, parseSchemaContent } from './server/introspection/parser.js';
|
|
8
8
|
export { createPrismaAdapter } from './server/adapters/prisma/index.js';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { asc, count, desc, eq, getTableColumns } from "drizzle-orm";
|
|
1
|
+
import { and, asc, count, desc, eq, getTableColumns } from "drizzle-orm";
|
|
2
2
|
import { coerceId, primaryKeyOf } from "../../data.js";
|
|
3
|
+
import { withWriteRetry } from "../retry.js";
|
|
3
4
|
import { compileFilterToDrizzle } from "./filterCompiler.js";
|
|
4
5
|
function tableFor(ctx, model) {
|
|
5
6
|
const table = ctx.tables[model.name];
|
|
@@ -11,11 +12,58 @@ function tableFor(ctx, model) {
|
|
|
11
12
|
function primaryKeyColumn(table, model) {
|
|
12
13
|
return getTableColumns(table)[primaryKeyOf(model)];
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Ordre de verrouillage déterministe. Les guards m2m sont empilés dans l'ordre
|
|
17
|
+
* des ids soumis par le formulaire, donc sous contrôle du client : deux
|
|
18
|
+
* requêtes concurrentes envoyant [1,2] et [2,1] verrouilleraient les mêmes
|
|
19
|
+
* lignes en sens inverse et se deadlockeraient (PostgreSQL 40P01), ce qui est
|
|
20
|
+
* bien plus facile à déclencher que la course qu'on cherche à empêcher.
|
|
21
|
+
* Trier sur (modèle, pk) donne un ordre total stable et supprime le cycle.
|
|
22
|
+
*/
|
|
23
|
+
function orderedGuards(guards) {
|
|
24
|
+
return [...guards].sort((a, b) => a.targetModel.name.localeCompare(b.targetModel.name) ||
|
|
25
|
+
String(a.targetPk).localeCompare(String(b.targetPk)));
|
|
26
|
+
}
|
|
27
|
+
async function validateTargetGuards(ctx, tx, guards, compile) {
|
|
28
|
+
for (const guard of orderedGuards(guards)) {
|
|
29
|
+
const table = tableFor(ctx, guard.targetModel);
|
|
30
|
+
const where = and(eq(primaryKeyColumn(table, guard.targetModel), guard.targetPk), compile(table, guard.filter));
|
|
31
|
+
const query = tx.select().from(table).where(where).limit(1);
|
|
32
|
+
// Verrou partagé tenu jusqu'au commit, PostgreSQL uniquement.
|
|
33
|
+
//
|
|
34
|
+
// Mesuré (PG 16, les 4 combinaisons) : SERIALIZABLE seul n'annule PAS la
|
|
35
|
+
// séquence « lire le guard -> un tiers sort la cible du scope -> écrire ».
|
|
36
|
+
// SSI n'y voit aucun cycle de dépendances, donc cet ordre reste
|
|
37
|
+
// sérialisable et les deux transactions committent. Seul le verrou de
|
|
38
|
+
// ligne ferme la fenêtre. Ne pas le retirer en pensant que le niveau
|
|
39
|
+
// d'isolation suffit : c'est faux, et ça a été vérifié.
|
|
40
|
+
//
|
|
41
|
+
// MySQL est exclu volontairement : SERIALIZABLE y transforme déjà les
|
|
42
|
+
// SELECT en lectures verrouillantes (mesuré : le writer concurrent est
|
|
43
|
+
// bloqué), donc le verrou n'apporterait rien — et `for share` est une
|
|
44
|
+
// syntaxe 8.0+, l'émettre casserait les schémas encore en 5.7.
|
|
45
|
+
const rows = await (ctx.dialect === "postgresql" ? query.for("share") : query);
|
|
46
|
+
if (rows.length === 0)
|
|
47
|
+
throw new Error("relation target is outside the authorization scope");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function validateTargetGuardsSqlite(ctx, tx, guards, compile) {
|
|
51
|
+
// Pas de clause de verrou en SQLite (absente de sqlite-core, et inutile :
|
|
52
|
+
// l'écriture concurrente échoue fermée, cf. le commentaire ci-dessus).
|
|
53
|
+
for (const guard of orderedGuards(guards)) {
|
|
54
|
+
const table = tableFor(ctx, guard.targetModel);
|
|
55
|
+
const where = and(eq(primaryKeyColumn(table, guard.targetModel), guard.targetPk), compile(table, guard.filter));
|
|
56
|
+
const row = tx.select().from(table).where(where).limit(1).get();
|
|
57
|
+
if (!row)
|
|
58
|
+
throw new Error("relation target is outside the authorization scope");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function selectByPrimaryKey(db, table, model, id, authorizationFilter) {
|
|
62
|
+
const primaryKeyWhere = eq(primaryKeyColumn(table, model), coerceId(String(id), model));
|
|
15
63
|
const rows = await db
|
|
16
64
|
.select()
|
|
17
65
|
.from(table)
|
|
18
|
-
.where(
|
|
66
|
+
.where(authorizationFilter ? and(primaryKeyWhere, authorizationFilter) : primaryKeyWhere)
|
|
19
67
|
.limit(1);
|
|
20
68
|
return rows[0] ?? null;
|
|
21
69
|
}
|
|
@@ -32,15 +80,23 @@ async function insertAndReturn(db, table, model, scalars, dialect) {
|
|
|
32
80
|
const rows = await db.insert(table).values(scalars).returning();
|
|
33
81
|
return rows[0];
|
|
34
82
|
}
|
|
35
|
-
async function updateAndReturn(db, table, model, id, scalars, dialect) {
|
|
83
|
+
async function updateAndReturn(db, table, model, id, scalars, dialect, authorizationFilter) {
|
|
36
84
|
const coercedId = coerceId(String(id), model);
|
|
37
|
-
const where =
|
|
85
|
+
const where = authorizationFilter
|
|
86
|
+
? and(eq(primaryKeyColumn(table, model), coercedId), authorizationFilter)
|
|
87
|
+
: eq(primaryKeyColumn(table, model), coercedId);
|
|
38
88
|
if (dialect === "mysql") {
|
|
39
89
|
await db.update(table).set(scalars).where(where);
|
|
40
|
-
|
|
90
|
+
const row = await selectByPrimaryKey(db, table, model, coercedId, authorizationFilter);
|
|
91
|
+
if (!row)
|
|
92
|
+
throw new Error("record is outside the authorization scope");
|
|
93
|
+
return row;
|
|
41
94
|
}
|
|
42
95
|
const rows = await db.update(table).set(scalars).where(where).returning();
|
|
43
|
-
|
|
96
|
+
const row = rows[0];
|
|
97
|
+
if (!row)
|
|
98
|
+
throw new Error("record is outside the authorization scope");
|
|
99
|
+
return row;
|
|
44
100
|
}
|
|
45
101
|
async function insertM2mRows(db, link, parentId, ids) {
|
|
46
102
|
if (ids.length === 0)
|
|
@@ -53,13 +109,16 @@ async function insertM2mRows(db, link, parentId, ids) {
|
|
|
53
109
|
function insertAndReturnSqlite(db, table, scalars) {
|
|
54
110
|
return db.insert(table).values(scalars).returning().get();
|
|
55
111
|
}
|
|
56
|
-
function updateAndReturnSqlite(db, table, model, id, scalars) {
|
|
57
|
-
|
|
112
|
+
function updateAndReturnSqlite(db, table, model, id, scalars, authorizationFilter) {
|
|
113
|
+
const row = db
|
|
58
114
|
.update(table)
|
|
59
115
|
.set(scalars)
|
|
60
|
-
.where(eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
|
|
116
|
+
.where(authorizationFilter ? and(eq(primaryKeyColumn(table, model), coerceId(String(id), model)), authorizationFilter) : eq(primaryKeyColumn(table, model), coerceId(String(id), model)))
|
|
61
117
|
.returning()
|
|
62
118
|
.get();
|
|
119
|
+
if (!row)
|
|
120
|
+
throw new Error("record is outside the authorization scope");
|
|
121
|
+
return row;
|
|
63
122
|
}
|
|
64
123
|
function insertM2mRowsSqlite(db, link, parentId, ids) {
|
|
65
124
|
if (ids.length === 0)
|
|
@@ -138,11 +197,13 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
138
197
|
async createRecord(model, input) {
|
|
139
198
|
const table = tableFor(ctx, model);
|
|
140
199
|
const m2mFields = Object.entries(input.m2m ?? {});
|
|
141
|
-
|
|
200
|
+
const guards = input.targetGuards ?? [];
|
|
201
|
+
if (m2mFields.length === 0 && guards.length === 0) {
|
|
142
202
|
return insertAndReturn(db, table, model, input.scalars, ctx.dialect);
|
|
143
203
|
}
|
|
144
204
|
if (ctx.dialect === "sqlite") {
|
|
145
205
|
return db.transaction((tx) => {
|
|
206
|
+
validateTargetGuardsSqlite(ctx, tx, guards, compileHere);
|
|
146
207
|
const parent = insertAndReturnSqlite(tx, table, input.scalars);
|
|
147
208
|
const parentId = parent[primaryKeyOf(model)];
|
|
148
209
|
for (const [field, relation] of m2mFields) {
|
|
@@ -152,9 +213,10 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
152
213
|
insertM2mRowsSqlite(tx, link, parentId, relation.ids);
|
|
153
214
|
}
|
|
154
215
|
return parent;
|
|
155
|
-
});
|
|
216
|
+
}, { behavior: "immediate" });
|
|
156
217
|
}
|
|
157
|
-
return db.transaction(async (tx) => {
|
|
218
|
+
return withWriteRetry(() => db.transaction(async (tx) => {
|
|
219
|
+
await validateTargetGuards(ctx, tx, guards, compileHere);
|
|
158
220
|
const parent = await insertAndReturn(tx, table, model, input.scalars, ctx.dialect);
|
|
159
221
|
const parentId = parent[primaryKeyOf(model)];
|
|
160
222
|
for (const [field, relation] of m2mFields) {
|
|
@@ -164,17 +226,19 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
164
226
|
await insertM2mRows(tx, link, parentId, relation.ids);
|
|
165
227
|
}
|
|
166
228
|
return parent;
|
|
167
|
-
});
|
|
229
|
+
}, { isolationLevel: "serializable" }));
|
|
168
230
|
},
|
|
169
|
-
async updateRecord(model, id, input) {
|
|
231
|
+
async updateRecord(model, id, input, authorizationFilter) {
|
|
170
232
|
const table = tableFor(ctx, model);
|
|
171
233
|
const m2mFields = Object.entries(input.m2m ?? {});
|
|
172
|
-
|
|
173
|
-
|
|
234
|
+
const guards = input.targetGuards ?? [];
|
|
235
|
+
if (m2mFields.length === 0 && guards.length === 0) {
|
|
236
|
+
return updateAndReturn(db, table, model, id, input.scalars, ctx.dialect, compileHere(table, authorizationFilter));
|
|
174
237
|
}
|
|
175
238
|
if (ctx.dialect === "sqlite") {
|
|
176
239
|
return db.transaction((tx) => {
|
|
177
|
-
|
|
240
|
+
validateTargetGuardsSqlite(ctx, tx, guards, compileHere);
|
|
241
|
+
const parent = updateAndReturnSqlite(tx, table, model, id, input.scalars, compileHere(table, authorizationFilter));
|
|
178
242
|
const parentId = coerceId(String(id), model);
|
|
179
243
|
for (const [field, relation] of m2mFields) {
|
|
180
244
|
const link = ctx.m2m.get(`${model.name}.${field}`);
|
|
@@ -184,10 +248,11 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
184
248
|
insertM2mRowsSqlite(tx, link, parentId, relation.ids);
|
|
185
249
|
}
|
|
186
250
|
return parent;
|
|
187
|
-
});
|
|
251
|
+
}, { behavior: "immediate" });
|
|
188
252
|
}
|
|
189
|
-
return db.transaction(async (tx) => {
|
|
190
|
-
|
|
253
|
+
return withWriteRetry(() => db.transaction(async (tx) => {
|
|
254
|
+
await validateTargetGuards(ctx, tx, guards, compileHere);
|
|
255
|
+
const parent = await updateAndReturn(tx, table, model, id, input.scalars, ctx.dialect, compileHere(table, authorizationFilter));
|
|
191
256
|
const parentId = coerceId(String(id), model);
|
|
192
257
|
for (const [field, relation] of m2mFields) {
|
|
193
258
|
const link = ctx.m2m.get(`${model.name}.${field}`);
|
|
@@ -197,37 +262,57 @@ export function createDrizzleDataAdapter(db, ctx) {
|
|
|
197
262
|
await insertM2mRows(tx, link, parentId, relation.ids);
|
|
198
263
|
}
|
|
199
264
|
return parent;
|
|
200
|
-
});
|
|
265
|
+
}, { isolationLevel: "serializable" }));
|
|
201
266
|
},
|
|
202
|
-
async deleteRecord(model, id) {
|
|
267
|
+
async deleteRecord(model, id, authorizationFilter) {
|
|
203
268
|
const table = tableFor(ctx, model);
|
|
204
269
|
const coercedId = coerceId(String(id), model);
|
|
205
270
|
const links = [...ctx.m2m.entries()]
|
|
206
271
|
.filter(([key]) => key.startsWith(`${model.name}.`))
|
|
207
272
|
.map(([, link]) => link);
|
|
273
|
+
const parentWhere = and(eq(primaryKeyColumn(table, model), coercedId), compileHere(table, authorizationFilter));
|
|
208
274
|
if (links.length === 0) {
|
|
209
|
-
|
|
210
|
-
.delete(table)
|
|
211
|
-
.
|
|
275
|
+
if (ctx.dialect === "sqlite") {
|
|
276
|
+
const result = db.delete(table).where(parentWhere).run();
|
|
277
|
+
if (result.changes !== 1)
|
|
278
|
+
throw new Error("record is outside the authorization scope");
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const result = await db.delete(table).where(parentWhere);
|
|
282
|
+
if (Number(result?.affectedRows ?? 0) !== 1)
|
|
283
|
+
throw new Error("record is outside the authorization scope");
|
|
284
|
+
}
|
|
212
285
|
return;
|
|
213
286
|
}
|
|
287
|
+
// Les pivots partent avant le parent, l'ordre qu'imposent les FK. Le DELETE
|
|
288
|
+
// scopé du parent sert lui-même de garde : zéro ligne touchée => throw =>
|
|
289
|
+
// rollback des pivots. Pas de SELECT de vérification préalable, donc aucune
|
|
290
|
+
// fenêtre TOCTOU entre la lecture du scope et la suppression, et aucune
|
|
291
|
+
// branche défensive inatteignable.
|
|
214
292
|
if (ctx.dialect === "sqlite") {
|
|
215
293
|
db.transaction((tx) => {
|
|
216
|
-
for (const link of links)
|
|
294
|
+
for (const link of links)
|
|
217
295
|
tx.delete(link.pivot).where(eq(link.selfColumn, coercedId)).run();
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
});
|
|
296
|
+
const result = tx.delete(table).where(parentWhere).run();
|
|
297
|
+
if (result.changes !== 1)
|
|
298
|
+
throw new Error("record is outside the authorization scope");
|
|
299
|
+
}, { behavior: "immediate" });
|
|
223
300
|
return;
|
|
224
301
|
}
|
|
225
|
-
await db.transaction(async (tx) => {
|
|
226
|
-
for (const link of links)
|
|
302
|
+
await withWriteRetry(() => db.transaction(async (tx) => {
|
|
303
|
+
for (const link of links)
|
|
227
304
|
await tx.delete(link.pivot).where(eq(link.selfColumn, coercedId));
|
|
305
|
+
if (ctx.dialect === "postgresql") {
|
|
306
|
+
const deleted = await tx.delete(table).where(parentWhere).returning({ id: primaryKeyColumn(table, model) });
|
|
307
|
+
if (deleted.length !== 1)
|
|
308
|
+
throw new Error("record is outside the authorization scope");
|
|
228
309
|
}
|
|
229
|
-
|
|
230
|
-
|
|
310
|
+
else {
|
|
311
|
+
const result = await tx.delete(table).where(parentWhere);
|
|
312
|
+
if (Number(result?.affectedRows ?? 0) !== 1)
|
|
313
|
+
throw new Error("record is outside the authorization scope");
|
|
314
|
+
}
|
|
315
|
+
}, { isolationLevel: "serializable" }));
|
|
231
316
|
},
|
|
232
317
|
async getM2mSelectedIds(model, edge, _targetModel, recordId) {
|
|
233
318
|
const link = ctx.m2m.get(`${model.name}.${edge.field}`);
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { defaultAdminCheck } from "../../auth.js";
|
|
2
|
+
import { createAdminHandler, type AdminHandlerConfig } from "../../handler.js";
|
|
3
|
+
import type { Schema, Model, Field } from "../../types/schema.js";
|
|
4
|
+
import type { DataAdapter, Filter, SchemaIntrospector } from "../types.js";
|
|
2
5
|
import type { DrizzleDialect } from "./inspect.js";
|
|
6
|
+
export { createAdminHandler, type AdminHandlerConfig };
|
|
7
|
+
export { defaultAdminCheck };
|
|
8
|
+
export type { AuditAction, AuditEvent } from "../../audit.js";
|
|
9
|
+
export type { Schema, Model, Field };
|
|
10
|
+
export type { DataAdapter, SchemaIntrospector, Filter };
|
|
3
11
|
export type { DrizzleDialect };
|
|
12
|
+
export type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction, PluginPageContext, PluginPageResult, } from "../../plugin.js";
|
|
4
13
|
export declare function resolveCaseInsensitiveSearch(dialect: DrizzleDialect, searchMode?: "auto" | "insensitive" | "default"): boolean;
|
|
5
14
|
export declare function createDrizzleAdapter(opts: {
|
|
6
15
|
db: any;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { defaultAdminCheck } from "../../auth.js";
|
|
2
|
+
import { createAdminHandler } from "../../handler.js";
|
|
1
3
|
import { createDrizzleDataAdapter } from "./dataAdapter.js";
|
|
2
4
|
import { inspectDrizzleSchema } from "./inspect.js";
|
|
3
5
|
import { createDrizzleIntrospector } from "./introspector.js";
|
|
6
|
+
export { createAdminHandler };
|
|
7
|
+
export { defaultAdminCheck };
|
|
4
8
|
export function resolveCaseInsensitiveSearch(dialect, searchMode = "auto") {
|
|
5
9
|
if (searchMode === "insensitive")
|
|
6
10
|
return true;
|
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
import { toPrismaModel, primaryKeyOf, coerceId } from '../../data.js';
|
|
2
2
|
import { compileFilterToPrismaWhere } from './filterCompiler.js';
|
|
3
|
+
import { withWriteRetry } from '../retry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Revalidation des cibles de relation à l'intérieur de la transaction d'écriture.
|
|
6
|
+
*
|
|
7
|
+
* Fenêtre résiduelle, assumée : cette lecture ne pose aucun verrou de ligne.
|
|
8
|
+
* Sur PostgreSQL, `Serializable` ne l'empêche PAS — mesuré sur PG 16 : SSI ne
|
|
9
|
+
* voit aucun cycle de dépendances dans « lire le guard -> un tiers sort la
|
|
10
|
+
* cible du scope -> écrire », donc les deux transactions committent. L'adapter
|
|
11
|
+
* Drizzle ferme cette fenêtre avec un `FOR SHARE` ; Prisma n'expose aucune API
|
|
12
|
+
* de verrou, et l'émettre demanderait du `$queryRaw` par dialecte, donc les
|
|
13
|
+
* noms physiques de tables et colonnes (les `@@map`/`@map` ne sont pas parsés)
|
|
14
|
+
* et un second compilateur de filtres à garder en phase avec
|
|
15
|
+
* `compileFilterToPrismaWhere` — exactement le duplicata divergent que ce
|
|
16
|
+
* codebase a déjà payé une fois.
|
|
17
|
+
*
|
|
18
|
+
* L'exposition reste bornée : `mutations.ts` a déjà revalidé chaque FK et m2m
|
|
19
|
+
* par un `findFirst` scopé avant d'appeler l'adapter, et gagner cette course
|
|
20
|
+
* ne produit qu'une référence orpheline inter-tenant — aucune lecture des
|
|
21
|
+
* données de l'autre tenant, le scoping des dropdowns et la « chip » anti-oracle
|
|
22
|
+
* tenant par ailleurs. C'est un défaut d'intégrité, pas une divulgation.
|
|
23
|
+
*/
|
|
24
|
+
async function validateTargetGuards(tx, guards, compile) {
|
|
25
|
+
for (const guard of guards) {
|
|
26
|
+
const key = toPrismaModel(guard.targetModel.name);
|
|
27
|
+
const pk = primaryKeyOf(guard.targetModel);
|
|
28
|
+
const where = guard.filter
|
|
29
|
+
? { [pk]: guard.targetPk, AND: [compile(guard.filter)] }
|
|
30
|
+
: { [pk]: guard.targetPk };
|
|
31
|
+
if (!(await tx[key].findFirst({ where }))) {
|
|
32
|
+
throw new Error('relation target is outside the authorization scope');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
3
36
|
/**
|
|
4
37
|
* Prisma implementation of `DataAdapter`. `caseInsensitiveSearch` is fixed
|
|
5
38
|
* at construction time (Task 5's boot block resolves it from the schema's
|
|
@@ -44,38 +77,47 @@ export function createPrismaDataAdapter(prisma, opts = {}) {
|
|
|
44
77
|
async createRecord(model, input) {
|
|
45
78
|
const key = toPrismaModel(model.name);
|
|
46
79
|
const m2mFields = Object.keys(input.m2m ?? {});
|
|
47
|
-
|
|
80
|
+
const guards = input.targetGuards ?? [];
|
|
81
|
+
if (m2mFields.length === 0 && guards.length === 0) {
|
|
48
82
|
return prisma[key].create({ data: input.scalars });
|
|
49
83
|
}
|
|
50
|
-
return prisma.$transaction(async (tx) => {
|
|
84
|
+
return withWriteRetry(() => prisma.$transaction(async (tx) => {
|
|
85
|
+
await validateTargetGuards(tx, guards, compileHere);
|
|
51
86
|
const data = { ...input.scalars };
|
|
52
87
|
for (const field of m2mFields) {
|
|
53
88
|
const { targetPkField, ids } = input.m2m[field];
|
|
54
89
|
data[field] = { connect: ids.map((id) => ({ [targetPkField]: id })) };
|
|
55
90
|
}
|
|
56
91
|
return tx[key].create({ data });
|
|
57
|
-
});
|
|
92
|
+
}, { isolationLevel: "Serializable" }));
|
|
58
93
|
},
|
|
59
|
-
async updateRecord(model, id, input) {
|
|
94
|
+
async updateRecord(model, id, input, authorizationFilter) {
|
|
60
95
|
const key = toPrismaModel(model.name);
|
|
61
96
|
const primaryKey = primaryKeyOf(model);
|
|
62
|
-
const where =
|
|
97
|
+
const where = authorizationFilter
|
|
98
|
+
? { [primaryKey]: coerceId(String(id), model), AND: [compileHere(authorizationFilter)] }
|
|
99
|
+
: { [primaryKey]: coerceId(String(id), model) };
|
|
63
100
|
const m2mFields = Object.keys(input.m2m ?? {});
|
|
64
|
-
|
|
101
|
+
const guards = input.targetGuards ?? [];
|
|
102
|
+
if (m2mFields.length === 0 && guards.length === 0) {
|
|
65
103
|
return prisma[key].update({ where, data: input.scalars });
|
|
66
104
|
}
|
|
67
|
-
return prisma.$transaction(async (tx) => {
|
|
105
|
+
return withWriteRetry(() => prisma.$transaction(async (tx) => {
|
|
106
|
+
await validateTargetGuards(tx, guards, compileHere);
|
|
68
107
|
const data = { ...input.scalars };
|
|
69
108
|
for (const field of m2mFields) {
|
|
70
109
|
const { targetPkField, ids } = input.m2m[field];
|
|
71
110
|
data[field] = { set: ids.map((id) => ({ [targetPkField]: id })) };
|
|
72
111
|
}
|
|
73
112
|
return tx[key].update({ where, data });
|
|
74
|
-
});
|
|
113
|
+
}, { isolationLevel: "Serializable" }));
|
|
75
114
|
},
|
|
76
|
-
async deleteRecord(model, id) {
|
|
115
|
+
async deleteRecord(model, id, authorizationFilter) {
|
|
77
116
|
const primaryKey = primaryKeyOf(model);
|
|
78
|
-
|
|
117
|
+
const where = authorizationFilter
|
|
118
|
+
? { [primaryKey]: coerceId(String(id), model), AND: [compileHere(authorizationFilter)] }
|
|
119
|
+
: { [primaryKey]: coerceId(String(id), model) };
|
|
120
|
+
await prisma[toPrismaModel(model.name)].delete({ where });
|
|
79
121
|
},
|
|
80
122
|
async getM2mSelectedIds(model, edge, targetModel, recordId) {
|
|
81
123
|
try {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AdminHandlerConfig as CoreAdminHandlerConfig } from '../../handler.js';
|
|
2
|
+
export interface AdminHandlerConfig extends Omit<CoreAdminHandlerConfig, 'adapter' | 'prisma' | 'prismaSchemaPath' | 'search'> {
|
|
3
|
+
prisma?: any;
|
|
4
|
+
prismaSchemaPath?: string;
|
|
5
|
+
adapter?: CoreAdminHandlerConfig['adapter'];
|
|
6
|
+
search?: {
|
|
7
|
+
mode?: 'auto' | 'insensitive' | 'default';
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function createAdminHandler(config: AdminHandlerConfig): ({ event, resolve }: {
|
|
11
|
+
event: any;
|
|
12
|
+
resolve: (event: any) => Response | Promise<Response>;
|
|
13
|
+
}) => Promise<Response>;
|
|
14
|
+
export type { AdminPlugin, AdminPluginPage, AdminPluginRecordAction, PluginPageContext, PluginPageResult } from '../../plugin.js';
|
|
@@ -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
|
+
}
|