@vertz/server 0.2.0 → 0.2.3
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 +371 -0
- package/dist/index.d.ts +278 -235
- package/dist/index.js +1193 -100
- package/package.json +10 -9
package/dist/index.js
CHANGED
|
@@ -5,48 +5,51 @@ import {
|
|
|
5
5
|
createEnv,
|
|
6
6
|
createImmutableProxy,
|
|
7
7
|
createMiddleware,
|
|
8
|
-
|
|
9
|
-
createModuleDef,
|
|
10
|
-
createServer,
|
|
11
|
-
deepFreeze,
|
|
8
|
+
deepFreeze as deepFreeze3,
|
|
12
9
|
ForbiddenException,
|
|
13
10
|
InternalServerErrorException,
|
|
14
11
|
makeImmutable,
|
|
15
12
|
NotFoundException,
|
|
16
13
|
ServiceUnavailableException,
|
|
17
14
|
UnauthorizedException,
|
|
18
|
-
ValidationException,
|
|
19
|
-
VertzException,
|
|
15
|
+
ValidationException as ValidationException2,
|
|
16
|
+
VertzException as VertzException2,
|
|
20
17
|
vertz
|
|
21
18
|
} from "@vertz/core";
|
|
22
19
|
|
|
23
|
-
// src/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
handlers: {},
|
|
33
|
-
actions: {}
|
|
34
|
-
});
|
|
20
|
+
// src/action/action.ts
|
|
21
|
+
import { deepFreeze } from "@vertz/core";
|
|
22
|
+
var ACTION_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
23
|
+
function action(name, config) {
|
|
24
|
+
if (!name || !ACTION_NAME_PATTERN.test(name)) {
|
|
25
|
+
throw new Error(`action() name must be a non-empty lowercase string matching /^[a-z][a-z0-9-]*$/. Got: "${name}"`);
|
|
26
|
+
}
|
|
27
|
+
if (!config.actions || Object.keys(config.actions).length === 0) {
|
|
28
|
+
throw new Error("action() requires at least one action in the actions config.");
|
|
35
29
|
}
|
|
36
30
|
const def = {
|
|
31
|
+
kind: "action",
|
|
37
32
|
name,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
access: options.access || {},
|
|
42
|
-
handlers: options.handlers || {},
|
|
43
|
-
actions: options.actions || {}
|
|
33
|
+
inject: config.inject ?? {},
|
|
34
|
+
access: config.access ?? {},
|
|
35
|
+
actions: config.actions
|
|
44
36
|
};
|
|
45
|
-
return
|
|
37
|
+
return deepFreeze(def);
|
|
46
38
|
}
|
|
47
39
|
// src/auth/index.ts
|
|
48
|
-
import
|
|
40
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
import {
|
|
43
|
+
createAuthRateLimitedError,
|
|
44
|
+
createAuthValidationError,
|
|
45
|
+
createInvalidCredentialsError,
|
|
46
|
+
createSessionExpiredError,
|
|
47
|
+
createUserExistsError,
|
|
48
|
+
err,
|
|
49
|
+
ok
|
|
50
|
+
} from "@vertz/errors";
|
|
49
51
|
import bcrypt from "bcryptjs";
|
|
52
|
+
import * as jose from "jose";
|
|
50
53
|
|
|
51
54
|
// src/auth/access.ts
|
|
52
55
|
class AuthorizationError extends Error {
|
|
@@ -75,8 +78,8 @@ function createAccess(config) {
|
|
|
75
78
|
return false;
|
|
76
79
|
if (roleEnts.has(entitlement))
|
|
77
80
|
return true;
|
|
78
|
-
const [resource,
|
|
79
|
-
if (
|
|
81
|
+
const [resource, action2] = entitlement.split(":");
|
|
82
|
+
if (action2 && resource !== "*") {
|
|
80
83
|
const wildcard = `${resource}:*`;
|
|
81
84
|
if (roleEnts.has(wildcard))
|
|
82
85
|
return true;
|
|
@@ -88,17 +91,20 @@ function createAccess(config) {
|
|
|
88
91
|
return false;
|
|
89
92
|
const allowedRoles = entitlementRoles.get(entitlement);
|
|
90
93
|
if (!allowedRoles) {
|
|
91
|
-
return
|
|
94
|
+
return false;
|
|
92
95
|
}
|
|
93
96
|
return allowedRoles.has(user.role) || roleHasEntitlement(user.role, entitlement);
|
|
94
97
|
}
|
|
95
98
|
async function can(entitlement, user) {
|
|
96
99
|
return checkEntitlement(entitlement, user);
|
|
97
100
|
}
|
|
98
|
-
async function canWithResource(entitlement,
|
|
101
|
+
async function canWithResource(entitlement, resource, user) {
|
|
99
102
|
const hasEntitlement = await checkEntitlement(entitlement, user);
|
|
100
103
|
if (!hasEntitlement)
|
|
101
104
|
return false;
|
|
105
|
+
if (resource.ownerId != null && resource.ownerId !== "" && resource.ownerId !== user?.id) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
102
108
|
return true;
|
|
103
109
|
}
|
|
104
110
|
async function authorize(entitlement, user) {
|
|
@@ -226,32 +232,16 @@ async function verifyPassword(password, hash) {
|
|
|
226
232
|
function validatePassword(password, requirements) {
|
|
227
233
|
const req = { ...DEFAULT_PASSWORD_REQUIREMENTS, ...requirements };
|
|
228
234
|
if (password.length < (req.minLength ?? 8)) {
|
|
229
|
-
return {
|
|
230
|
-
code: "PASSWORD_TOO_SHORT",
|
|
231
|
-
message: `Password must be at least ${req.minLength} characters`,
|
|
232
|
-
status: 400
|
|
233
|
-
};
|
|
235
|
+
return createAuthValidationError(`Password must be at least ${req.minLength} characters`, "password", "TOO_SHORT");
|
|
234
236
|
}
|
|
235
237
|
if (req.requireUppercase && !/[A-Z]/.test(password)) {
|
|
236
|
-
return
|
|
237
|
-
code: "PASSWORD_NO_UPPERCASE",
|
|
238
|
-
message: "Password must contain at least one uppercase letter",
|
|
239
|
-
status: 400
|
|
240
|
-
};
|
|
238
|
+
return createAuthValidationError("Password must contain at least one uppercase letter", "password", "NO_UPPERCASE");
|
|
241
239
|
}
|
|
242
240
|
if (req.requireNumbers && !/\d/.test(password)) {
|
|
243
|
-
return
|
|
244
|
-
code: "PASSWORD_NO_NUMBER",
|
|
245
|
-
message: "Password must contain at least one number",
|
|
246
|
-
status: 400
|
|
247
|
-
};
|
|
241
|
+
return createAuthValidationError("Password must contain at least one number", "password", "NO_NUMBER");
|
|
248
242
|
}
|
|
249
243
|
if (req.requireSymbols && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
|
250
|
-
return
|
|
251
|
-
code: "PASSWORD_NO_SYMBOL",
|
|
252
|
-
message: "Password must contain at least one symbol",
|
|
253
|
-
status: 400
|
|
254
|
-
};
|
|
244
|
+
return createAuthValidationError("Password must contain at least one symbol", "password", "NO_SYMBOL");
|
|
255
245
|
}
|
|
256
246
|
return null;
|
|
257
247
|
}
|
|
@@ -286,7 +276,9 @@ async function createJWT(user, secret, ttl, algorithm, customClaims) {
|
|
|
286
276
|
}
|
|
287
277
|
async function verifyJWT(token, secret, algorithm) {
|
|
288
278
|
try {
|
|
289
|
-
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(secret), {
|
|
279
|
+
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(secret), {
|
|
280
|
+
algorithms: [algorithm]
|
|
281
|
+
});
|
|
290
282
|
return payload;
|
|
291
283
|
} catch {
|
|
292
284
|
return null;
|
|
@@ -302,19 +294,34 @@ function createAuth(config) {
|
|
|
302
294
|
jwtAlgorithm = "HS256",
|
|
303
295
|
claims
|
|
304
296
|
} = config;
|
|
305
|
-
const
|
|
297
|
+
const isProduction = config.isProduction ?? (typeof process === "undefined" || false);
|
|
306
298
|
let jwtSecret;
|
|
307
299
|
if (configJwtSecret) {
|
|
308
300
|
jwtSecret = configJwtSecret;
|
|
309
|
-
} else if (
|
|
310
|
-
jwtSecret
|
|
301
|
+
} else if (isProduction) {
|
|
302
|
+
throw new Error('jwtSecret is required in production. Provide it via createAuth({ jwtSecret: "..." }).');
|
|
311
303
|
} else {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
304
|
+
const secretDir = config.devSecretPath ?? join(process.cwd(), ".vertz");
|
|
305
|
+
const secretFile = join(secretDir, "jwt-secret");
|
|
306
|
+
if (existsSync(secretFile)) {
|
|
307
|
+
jwtSecret = readFileSync(secretFile, "utf-8").trim();
|
|
308
|
+
} else {
|
|
309
|
+
jwtSecret = crypto.randomUUID() + crypto.randomUUID();
|
|
310
|
+
mkdirSync(secretDir, { recursive: true });
|
|
311
|
+
writeFileSync(secretFile, jwtSecret, "utf-8");
|
|
312
|
+
console.warn(`[Auth] Auto-generated dev JWT secret at ${secretFile}. Add this path to .gitignore.`);
|
|
315
313
|
}
|
|
316
314
|
}
|
|
317
315
|
const cookieConfig = { ...DEFAULT_COOKIE_CONFIG, ...session.cookie };
|
|
316
|
+
if (cookieConfig.sameSite === "none" && cookieConfig.secure !== true) {
|
|
317
|
+
throw new Error("SameSite=None requires secure=true");
|
|
318
|
+
}
|
|
319
|
+
if (isProduction && cookieConfig.secure === false) {
|
|
320
|
+
throw new Error("Cookie 'secure' flag cannot be disabled in production");
|
|
321
|
+
}
|
|
322
|
+
if (!isProduction && cookieConfig.secure === false) {
|
|
323
|
+
console.warn("Cookie 'secure' flag is disabled. This is allowed in development but must be enabled in production.");
|
|
324
|
+
}
|
|
318
325
|
const ttlMs = parseDuration(session.ttl);
|
|
319
326
|
const signInLimiter = new RateLimiter(emailPassword?.rateLimit?.window || "15m");
|
|
320
327
|
const signUpLimiter = new RateLimiter("1h");
|
|
@@ -330,18 +337,18 @@ function createAuth(config) {
|
|
|
330
337
|
async function signUp(data) {
|
|
331
338
|
const { email, password, role = "user", ...additionalFields } = data;
|
|
332
339
|
if (!email || !email.includes("@")) {
|
|
333
|
-
return
|
|
340
|
+
return err(createAuthValidationError("Invalid email format", "email", "INVALID_FORMAT"));
|
|
334
341
|
}
|
|
335
342
|
const passwordError = validatePassword(password, emailPassword?.password);
|
|
336
343
|
if (passwordError) {
|
|
337
|
-
return
|
|
344
|
+
return err(passwordError);
|
|
338
345
|
}
|
|
339
346
|
if (users.has(email.toLowerCase())) {
|
|
340
|
-
return
|
|
347
|
+
return err(createUserExistsError("User already exists", email.toLowerCase()));
|
|
341
348
|
}
|
|
342
349
|
const signUpRateLimit = signUpLimiter.check(`signup:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 3);
|
|
343
350
|
if (!signUpRateLimit.allowed) {
|
|
344
|
-
return
|
|
351
|
+
return err(createAuthRateLimitedError("Too many sign up attempts"));
|
|
345
352
|
}
|
|
346
353
|
const passwordHash = await hashPassword(password);
|
|
347
354
|
const now = new Date;
|
|
@@ -365,24 +372,21 @@ function createAuth(config) {
|
|
|
365
372
|
claims: claims ? claims(user) : undefined
|
|
366
373
|
};
|
|
367
374
|
sessions.set(token, { userId: user.id, expiresAt });
|
|
368
|
-
return {
|
|
369
|
-
ok: true,
|
|
370
|
-
data: { user, expiresAt, payload }
|
|
371
|
-
};
|
|
375
|
+
return ok({ user, expiresAt, payload });
|
|
372
376
|
}
|
|
373
377
|
async function signIn(data) {
|
|
374
378
|
const { email, password } = data;
|
|
375
379
|
const stored = users.get(email.toLowerCase());
|
|
376
380
|
if (!stored) {
|
|
377
|
-
return
|
|
381
|
+
return err(createInvalidCredentialsError());
|
|
378
382
|
}
|
|
379
383
|
const signInRateLimit = signInLimiter.check(`signin:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 5);
|
|
380
384
|
if (!signInRateLimit.allowed) {
|
|
381
|
-
return
|
|
385
|
+
return err(createAuthRateLimitedError("Too many sign in attempts"));
|
|
382
386
|
}
|
|
383
387
|
const valid = await verifyPassword(password, stored.passwordHash);
|
|
384
388
|
if (!valid) {
|
|
385
|
-
return
|
|
389
|
+
return err(createInvalidCredentialsError());
|
|
386
390
|
}
|
|
387
391
|
const user = buildAuthUser(stored);
|
|
388
392
|
const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
@@ -396,10 +400,7 @@ function createAuth(config) {
|
|
|
396
400
|
claims: claims ? claims(user) : undefined
|
|
397
401
|
};
|
|
398
402
|
sessions.set(token, { userId: user.id, expiresAt });
|
|
399
|
-
return {
|
|
400
|
-
ok: true,
|
|
401
|
-
data: { user, expiresAt, payload }
|
|
402
|
-
};
|
|
403
|
+
return ok({ user, expiresAt, payload });
|
|
403
404
|
}
|
|
404
405
|
async function signOut(ctx) {
|
|
405
406
|
const cookieName = cookieConfig.name || "vertz.sid";
|
|
@@ -407,49 +408,46 @@ function createAuth(config) {
|
|
|
407
408
|
if (token) {
|
|
408
409
|
sessions.delete(token);
|
|
409
410
|
}
|
|
410
|
-
return
|
|
411
|
+
return ok(undefined);
|
|
411
412
|
}
|
|
412
413
|
async function getSession(headers) {
|
|
413
414
|
const cookieName = cookieConfig.name || "vertz.sid";
|
|
414
415
|
const token = headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${cookieName}=`))?.split("=")[1];
|
|
415
416
|
if (!token) {
|
|
416
|
-
return
|
|
417
|
+
return ok(null);
|
|
417
418
|
}
|
|
418
419
|
const session2 = sessions.get(token);
|
|
419
420
|
if (!session2) {
|
|
420
|
-
return
|
|
421
|
+
return ok(null);
|
|
421
422
|
}
|
|
422
423
|
if (session2.expiresAt < new Date) {
|
|
423
424
|
sessions.delete(token);
|
|
424
|
-
return
|
|
425
|
+
return ok(null);
|
|
425
426
|
}
|
|
426
427
|
const payload = await verifyJWT(token, jwtSecret, jwtAlgorithm);
|
|
427
428
|
if (!payload) {
|
|
428
429
|
sessions.delete(token);
|
|
429
|
-
return
|
|
430
|
+
return ok(null);
|
|
430
431
|
}
|
|
431
432
|
const stored = users.get(payload.email);
|
|
432
433
|
if (!stored) {
|
|
433
|
-
return
|
|
434
|
+
return ok(null);
|
|
434
435
|
}
|
|
435
436
|
const user = buildAuthUser(stored);
|
|
436
437
|
const expiresAt = new Date(payload.exp * 1000);
|
|
437
|
-
return {
|
|
438
|
-
ok: true,
|
|
439
|
-
data: { user, expiresAt, payload }
|
|
440
|
-
};
|
|
438
|
+
return ok({ user, expiresAt, payload });
|
|
441
439
|
}
|
|
442
440
|
async function refreshSession(ctx) {
|
|
443
441
|
const refreshRateLimit = refreshLimiter.check(`refresh:${ctx.headers.get("x-forwarded-ip") || "default"}`, 10);
|
|
444
442
|
if (!refreshRateLimit.allowed) {
|
|
445
|
-
return
|
|
443
|
+
return err(createAuthRateLimitedError("Too many refresh attempts"));
|
|
446
444
|
}
|
|
447
445
|
const sessionResult = await getSession(ctx.headers);
|
|
448
446
|
if (!sessionResult.ok) {
|
|
449
447
|
return sessionResult;
|
|
450
448
|
}
|
|
451
449
|
if (!sessionResult.data) {
|
|
452
|
-
return
|
|
450
|
+
return err(createSessionExpiredError("No active session"));
|
|
453
451
|
}
|
|
454
452
|
const user = sessionResult.data.user;
|
|
455
453
|
const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
@@ -463,10 +461,25 @@ function createAuth(config) {
|
|
|
463
461
|
claims: claims ? claims(user) : undefined
|
|
464
462
|
};
|
|
465
463
|
sessions.set(token, { userId: user.id, expiresAt });
|
|
466
|
-
return {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
464
|
+
return ok({ user, expiresAt, payload });
|
|
465
|
+
}
|
|
466
|
+
function authErrorToStatus(error) {
|
|
467
|
+
switch (error.code) {
|
|
468
|
+
case "AUTH_VALIDATION_ERROR":
|
|
469
|
+
return 400;
|
|
470
|
+
case "INVALID_CREDENTIALS":
|
|
471
|
+
return 401;
|
|
472
|
+
case "SESSION_EXPIRED":
|
|
473
|
+
return 401;
|
|
474
|
+
case "USER_EXISTS":
|
|
475
|
+
return 409;
|
|
476
|
+
case "RATE_LIMITED":
|
|
477
|
+
return 429;
|
|
478
|
+
case "PERMISSION_DENIED":
|
|
479
|
+
return 403;
|
|
480
|
+
default:
|
|
481
|
+
return 500;
|
|
482
|
+
}
|
|
470
483
|
}
|
|
471
484
|
async function handleAuthRequest(request) {
|
|
472
485
|
const url = new URL(request.url);
|
|
@@ -475,8 +488,38 @@ function createAuth(config) {
|
|
|
475
488
|
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
|
476
489
|
const origin = request.headers.get("origin");
|
|
477
490
|
const referer = request.headers.get("referer");
|
|
478
|
-
|
|
479
|
-
|
|
491
|
+
const expectedOrigin = new URL(request.url).origin;
|
|
492
|
+
let originValid = false;
|
|
493
|
+
if (origin) {
|
|
494
|
+
originValid = origin === expectedOrigin;
|
|
495
|
+
} else if (referer) {
|
|
496
|
+
try {
|
|
497
|
+
const refererOrigin = new URL(referer).origin;
|
|
498
|
+
originValid = refererOrigin === expectedOrigin;
|
|
499
|
+
} catch {
|
|
500
|
+
originValid = false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (!originValid) {
|
|
504
|
+
if (isProduction) {
|
|
505
|
+
return new Response(JSON.stringify({ error: "CSRF validation failed" }), {
|
|
506
|
+
status: 403,
|
|
507
|
+
headers: { "Content-Type": "application/json" }
|
|
508
|
+
});
|
|
509
|
+
} else {
|
|
510
|
+
console.warn("[Auth] CSRF warning: Origin/Referer missing or mismatched (allowed in development)");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const vtzHeader = request.headers.get("x-vtz-request");
|
|
514
|
+
if (vtzHeader !== "1") {
|
|
515
|
+
if (isProduction) {
|
|
516
|
+
return new Response(JSON.stringify({ error: "Missing required X-VTZ-Request header" }), {
|
|
517
|
+
status: 403,
|
|
518
|
+
headers: { "Content-Type": "application/json" }
|
|
519
|
+
});
|
|
520
|
+
} else {
|
|
521
|
+
console.warn("[Auth] CSRF warning: Missing X-VTZ-Request header (allowed in development)");
|
|
522
|
+
}
|
|
480
523
|
}
|
|
481
524
|
}
|
|
482
525
|
try {
|
|
@@ -485,7 +528,7 @@ function createAuth(config) {
|
|
|
485
528
|
const result = await signUp(body);
|
|
486
529
|
if (!result.ok) {
|
|
487
530
|
return new Response(JSON.stringify({ error: result.error }), {
|
|
488
|
-
status: result.error
|
|
531
|
+
status: authErrorToStatus(result.error),
|
|
489
532
|
headers: { "Content-Type": "application/json" }
|
|
490
533
|
});
|
|
491
534
|
}
|
|
@@ -503,7 +546,7 @@ function createAuth(config) {
|
|
|
503
546
|
const result = await signIn(body);
|
|
504
547
|
if (!result.ok) {
|
|
505
548
|
return new Response(JSON.stringify({ error: result.error }), {
|
|
506
|
-
status: result.error
|
|
549
|
+
status: authErrorToStatus(result.error),
|
|
507
550
|
headers: { "Content-Type": "application/json" }
|
|
508
551
|
});
|
|
509
552
|
}
|
|
@@ -543,7 +586,7 @@ function createAuth(config) {
|
|
|
543
586
|
const result = await refreshSession({ headers: request.headers });
|
|
544
587
|
if (!result.ok) {
|
|
545
588
|
return new Response(JSON.stringify({ error: result.error }), {
|
|
546
|
-
status: result.error
|
|
589
|
+
status: authErrorToStatus(result.error),
|
|
547
590
|
headers: { "Content-Type": "application/json" }
|
|
548
591
|
});
|
|
549
592
|
}
|
|
@@ -560,7 +603,7 @@ function createAuth(config) {
|
|
|
560
603
|
status: 404,
|
|
561
604
|
headers: { "Content-Type": "application/json" }
|
|
562
605
|
});
|
|
563
|
-
} catch (
|
|
606
|
+
} catch (_error) {
|
|
564
607
|
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
|
565
608
|
status: 500,
|
|
566
609
|
headers: { "Content-Type": "application/json" }
|
|
@@ -608,30 +651,1080 @@ function createAuth(config) {
|
|
|
608
651
|
initialize
|
|
609
652
|
};
|
|
610
653
|
}
|
|
654
|
+
// src/create-server.ts
|
|
655
|
+
import { createServer as coreCreateServer } from "@vertz/core";
|
|
656
|
+
import {
|
|
657
|
+
createDatabaseBridgeAdapter
|
|
658
|
+
} from "@vertz/db";
|
|
659
|
+
|
|
660
|
+
// src/entity/access-enforcer.ts
|
|
661
|
+
import { EntityForbiddenError, err as err2, ok as ok2 } from "@vertz/errors";
|
|
662
|
+
async function enforceAccess(operation, accessRules, ctx, row) {
|
|
663
|
+
const rule = accessRules[operation];
|
|
664
|
+
if (rule === undefined) {
|
|
665
|
+
return err2(new EntityForbiddenError(`Access denied: no access rule for operation "${operation}"`));
|
|
666
|
+
}
|
|
667
|
+
if (rule === false) {
|
|
668
|
+
return err2(new EntityForbiddenError(`Operation "${operation}" is disabled`));
|
|
669
|
+
}
|
|
670
|
+
const allowed = await rule(ctx, row ?? {});
|
|
671
|
+
if (!allowed) {
|
|
672
|
+
return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
|
|
673
|
+
}
|
|
674
|
+
return ok2(undefined);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// src/action/context.ts
|
|
678
|
+
function createActionContext(request, registryProxy) {
|
|
679
|
+
const userId = request.userId ?? null;
|
|
680
|
+
const roles = request.roles ?? [];
|
|
681
|
+
const tenantId = request.tenantId ?? null;
|
|
682
|
+
return {
|
|
683
|
+
userId,
|
|
684
|
+
authenticated() {
|
|
685
|
+
return userId !== null;
|
|
686
|
+
},
|
|
687
|
+
tenant() {
|
|
688
|
+
return tenantId !== null;
|
|
689
|
+
},
|
|
690
|
+
role(...rolesToCheck) {
|
|
691
|
+
return rolesToCheck.some((r) => roles.includes(r));
|
|
692
|
+
},
|
|
693
|
+
entities: registryProxy
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/action/route-generator.ts
|
|
698
|
+
function jsonResponse(data, status = 200) {
|
|
699
|
+
return new Response(JSON.stringify(data), {
|
|
700
|
+
status,
|
|
701
|
+
headers: { "content-type": "application/json" }
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
function extractRequestInfo(ctx) {
|
|
705
|
+
return {
|
|
706
|
+
userId: ctx.userId ?? null,
|
|
707
|
+
tenantId: ctx.tenantId ?? null,
|
|
708
|
+
roles: ctx.roles ?? []
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
function generateActionRoutes(def, registry, options) {
|
|
712
|
+
const prefix = options?.apiPrefix ?? "/api";
|
|
713
|
+
const inject = def.inject ?? {};
|
|
714
|
+
const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
|
|
715
|
+
const routes = [];
|
|
716
|
+
for (const [handlerName, handlerDef] of Object.entries(def.actions)) {
|
|
717
|
+
const accessRule = def.access[handlerName];
|
|
718
|
+
if (accessRule === undefined)
|
|
719
|
+
continue;
|
|
720
|
+
const method = (handlerDef.method ?? "POST").toUpperCase();
|
|
721
|
+
const handlerPath = handlerDef.path ?? `${prefix}/${def.name}/${handlerName}`;
|
|
722
|
+
const routePath = handlerDef.path ? handlerPath : `${prefix}/${def.name}/${handlerName}`;
|
|
723
|
+
if (accessRule === false) {
|
|
724
|
+
routes.push({
|
|
725
|
+
method,
|
|
726
|
+
path: routePath,
|
|
727
|
+
handler: async () => jsonResponse({
|
|
728
|
+
error: {
|
|
729
|
+
code: "MethodNotAllowed",
|
|
730
|
+
message: `Action "${handlerName}" is disabled for ${def.name}`
|
|
731
|
+
}
|
|
732
|
+
}, 405)
|
|
733
|
+
});
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
routes.push({
|
|
737
|
+
method,
|
|
738
|
+
path: routePath,
|
|
739
|
+
handler: async (ctx) => {
|
|
740
|
+
try {
|
|
741
|
+
const requestInfo = extractRequestInfo(ctx);
|
|
742
|
+
const actionCtx = createActionContext(requestInfo, registryProxy);
|
|
743
|
+
const accessResult = await enforceAccess(handlerName, def.access, actionCtx);
|
|
744
|
+
if (!accessResult.ok) {
|
|
745
|
+
return jsonResponse({ error: { code: "Forbidden", message: accessResult.error.message } }, 403);
|
|
746
|
+
}
|
|
747
|
+
const rawBody = ctx.body ?? {};
|
|
748
|
+
const parsed = handlerDef.body.parse(rawBody);
|
|
749
|
+
if (!parsed.ok) {
|
|
750
|
+
const message = parsed.error instanceof Error ? parsed.error.message : "Invalid request body";
|
|
751
|
+
return jsonResponse({ error: { code: "BadRequest", message } }, 400);
|
|
752
|
+
}
|
|
753
|
+
const result = await handlerDef.handler(parsed.data, actionCtx);
|
|
754
|
+
const responseParsed = handlerDef.response.parse(result);
|
|
755
|
+
if (!responseParsed.ok) {
|
|
756
|
+
const message = responseParsed.error instanceof Error ? responseParsed.error.message : "Response validation failed";
|
|
757
|
+
console.warn(`[vertz] Action response validation warning: ${message}`);
|
|
758
|
+
}
|
|
759
|
+
return jsonResponse(result, 200);
|
|
760
|
+
} catch (error) {
|
|
761
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
762
|
+
return jsonResponse({ error: { code: "InternalServerError", message } }, 500);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
return routes;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/entity/entity-registry.ts
|
|
771
|
+
class EntityRegistry {
|
|
772
|
+
entries = new Map;
|
|
773
|
+
register(name, ops) {
|
|
774
|
+
if (this.entries.has(name)) {
|
|
775
|
+
throw new Error(`Entity "${name}" is already registered. Each entity name must be unique.`);
|
|
776
|
+
}
|
|
777
|
+
this.entries.set(name, ops);
|
|
778
|
+
}
|
|
779
|
+
get(name) {
|
|
780
|
+
const entry = this.entries.get(name);
|
|
781
|
+
if (!entry) {
|
|
782
|
+
const available = [...this.entries.keys()].join(", ");
|
|
783
|
+
throw new Error(`Entity "${name}" is not registered. Available entities: ${available}`);
|
|
784
|
+
}
|
|
785
|
+
return entry;
|
|
786
|
+
}
|
|
787
|
+
has(name) {
|
|
788
|
+
return this.entries.has(name);
|
|
789
|
+
}
|
|
790
|
+
createProxy() {
|
|
791
|
+
return new Proxy({}, {
|
|
792
|
+
get: (_target, prop) => {
|
|
793
|
+
if (typeof prop === "symbol")
|
|
794
|
+
return;
|
|
795
|
+
return this.get(prop);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
createScopedProxy(inject) {
|
|
800
|
+
const localToEntity = new Map;
|
|
801
|
+
for (const [localName, def] of Object.entries(inject)) {
|
|
802
|
+
localToEntity.set(localName, def.name);
|
|
803
|
+
}
|
|
804
|
+
return new Proxy({}, {
|
|
805
|
+
get: (_target, prop) => {
|
|
806
|
+
if (typeof prop === "symbol")
|
|
807
|
+
return;
|
|
808
|
+
const entityName = localToEntity.get(prop);
|
|
809
|
+
if (!entityName) {
|
|
810
|
+
throw new Error(`Entity "${prop}" is not declared in inject. ` + `Injected entities: ${[...localToEntity.keys()].join(", ") || "(none)"}. ` + `Add it to the inject config to access it.`);
|
|
811
|
+
}
|
|
812
|
+
return this.get(entityName);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/entity/field-filter.ts
|
|
819
|
+
function stripHiddenFields(table, data) {
|
|
820
|
+
const hiddenKeys = new Set;
|
|
821
|
+
for (const key of Object.keys(table._columns)) {
|
|
822
|
+
const col = table._columns[key];
|
|
823
|
+
if (col?._meta._annotations.hidden) {
|
|
824
|
+
hiddenKeys.add(key);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (hiddenKeys.size === 0)
|
|
828
|
+
return data;
|
|
829
|
+
const result = {};
|
|
830
|
+
for (const [key, value] of Object.entries(data)) {
|
|
831
|
+
if (!hiddenKeys.has(key)) {
|
|
832
|
+
result[key] = value;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return result;
|
|
836
|
+
}
|
|
837
|
+
function narrowRelationFields(relationsConfig, data) {
|
|
838
|
+
const result = {};
|
|
839
|
+
for (const [key, value] of Object.entries(data)) {
|
|
840
|
+
const config = relationsConfig[key];
|
|
841
|
+
if (config === undefined || config === true) {
|
|
842
|
+
result[key] = value;
|
|
843
|
+
} else if (config === false) {} else if (typeof config === "object" && value !== null && typeof value === "object") {
|
|
844
|
+
if (Array.isArray(value)) {
|
|
845
|
+
result[key] = value.map((item) => {
|
|
846
|
+
const narrowed = {};
|
|
847
|
+
for (const field of Object.keys(config)) {
|
|
848
|
+
if (field in item) {
|
|
849
|
+
narrowed[field] = item[field];
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return narrowed;
|
|
853
|
+
});
|
|
854
|
+
} else {
|
|
855
|
+
const narrowed = {};
|
|
856
|
+
for (const field of Object.keys(config)) {
|
|
857
|
+
if (field in value) {
|
|
858
|
+
narrowed[field] = value[field];
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
result[key] = narrowed;
|
|
862
|
+
}
|
|
863
|
+
} else {
|
|
864
|
+
result[key] = value;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return result;
|
|
868
|
+
}
|
|
869
|
+
function applySelect(select, data) {
|
|
870
|
+
if (!select)
|
|
871
|
+
return data;
|
|
872
|
+
const result = {};
|
|
873
|
+
for (const key of Object.keys(select)) {
|
|
874
|
+
if (key in data) {
|
|
875
|
+
result[key] = data[key];
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return result;
|
|
879
|
+
}
|
|
880
|
+
function stripReadOnlyFields(table, data) {
|
|
881
|
+
const excludedKeys = new Set;
|
|
882
|
+
for (const key of Object.keys(table._columns)) {
|
|
883
|
+
const col = table._columns[key];
|
|
884
|
+
if (col?._meta.isReadOnly || col?._meta.primary) {
|
|
885
|
+
excludedKeys.add(key);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (excludedKeys.size === 0)
|
|
889
|
+
return data;
|
|
890
|
+
const result = {};
|
|
891
|
+
for (const [key, value] of Object.entries(data)) {
|
|
892
|
+
if (!excludedKeys.has(key)) {
|
|
893
|
+
result[key] = value;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return result;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// src/entity/action-pipeline.ts
|
|
900
|
+
import {
|
|
901
|
+
BadRequestError,
|
|
902
|
+
EntityNotFoundError,
|
|
903
|
+
err as err3,
|
|
904
|
+
ok as ok3
|
|
905
|
+
} from "@vertz/errors";
|
|
906
|
+
function createActionHandler(def, actionName, actionDef, db, hasId) {
|
|
907
|
+
return async (ctx, id, rawInput) => {
|
|
908
|
+
let row = null;
|
|
909
|
+
if (hasId) {
|
|
910
|
+
row = await db.get(id);
|
|
911
|
+
if (!row) {
|
|
912
|
+
return err3(new EntityNotFoundError(`${def.name} with id "${id}" not found`));
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const accessResult = await enforceAccess(actionName, def.access, ctx, row ?? {});
|
|
916
|
+
if (!accessResult.ok)
|
|
917
|
+
return err3(accessResult.error);
|
|
918
|
+
const parseResult = actionDef.body.parse(rawInput);
|
|
919
|
+
if (!parseResult.ok) {
|
|
920
|
+
return err3(new BadRequestError(parseResult.error.message));
|
|
921
|
+
}
|
|
922
|
+
const input = parseResult.data;
|
|
923
|
+
const rawResult = await actionDef.handler(input, ctx, row);
|
|
924
|
+
const table = def.model.table;
|
|
925
|
+
const result = rawResult && typeof rawResult === "object" && !Array.isArray(rawResult) ? stripHiddenFields(table, rawResult) : rawResult;
|
|
926
|
+
const afterHooks = def.after;
|
|
927
|
+
const afterHook = afterHooks[actionName];
|
|
928
|
+
if (afterHook) {
|
|
929
|
+
try {
|
|
930
|
+
await afterHook(result, ctx, row);
|
|
931
|
+
} catch {}
|
|
932
|
+
}
|
|
933
|
+
return ok3({ status: 200, body: result });
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// src/entity/context.ts
|
|
938
|
+
function createEntityContext(request, entityOps, registryProxy) {
|
|
939
|
+
const userId = request.userId ?? null;
|
|
940
|
+
const roles = request.roles ?? [];
|
|
941
|
+
const tenantId = request.tenantId ?? null;
|
|
942
|
+
return {
|
|
943
|
+
userId,
|
|
944
|
+
authenticated() {
|
|
945
|
+
return userId !== null;
|
|
946
|
+
},
|
|
947
|
+
tenant() {
|
|
948
|
+
return tenantId !== null;
|
|
949
|
+
},
|
|
950
|
+
role(...rolesToCheck) {
|
|
951
|
+
return rolesToCheck.some((r) => roles.includes(r));
|
|
952
|
+
},
|
|
953
|
+
entity: entityOps,
|
|
954
|
+
entities: registryProxy
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// src/entity/crud-pipeline.ts
|
|
959
|
+
import { EntityNotFoundError as EntityNotFoundError2, err as err4, ok as ok4 } from "@vertz/errors";
|
|
960
|
+
function resolvePrimaryKeyColumn(table) {
|
|
961
|
+
for (const key of Object.keys(table._columns)) {
|
|
962
|
+
const col = table._columns[key];
|
|
963
|
+
if (col?._meta.primary)
|
|
964
|
+
return key;
|
|
965
|
+
}
|
|
966
|
+
return "id";
|
|
967
|
+
}
|
|
968
|
+
function createCrudHandlers(def, db) {
|
|
969
|
+
const table = def.model.table;
|
|
970
|
+
return {
|
|
971
|
+
async list(ctx, options) {
|
|
972
|
+
const accessResult = await enforceAccess("list", def.access, ctx);
|
|
973
|
+
if (!accessResult.ok)
|
|
974
|
+
return err4(accessResult.error);
|
|
975
|
+
const rawWhere = options?.where;
|
|
976
|
+
const safeWhere = rawWhere ? stripHiddenFields(table, rawWhere) : undefined;
|
|
977
|
+
const where = safeWhere && Object.keys(safeWhere).length > 0 ? safeWhere : undefined;
|
|
978
|
+
const limit = Math.max(0, options?.limit ?? 20);
|
|
979
|
+
const after = options?.after && options.after.length <= 512 ? options.after : undefined;
|
|
980
|
+
const orderBy = options?.orderBy;
|
|
981
|
+
const { data: rows, total } = await db.list({ where, orderBy, limit, after });
|
|
982
|
+
const data = rows.map((row) => narrowRelationFields(def.relations, stripHiddenFields(table, row)));
|
|
983
|
+
const pkColumn = resolvePrimaryKeyColumn(table);
|
|
984
|
+
const lastRow = rows[rows.length - 1];
|
|
985
|
+
const nextCursor = limit > 0 && rows.length === limit && lastRow ? String(lastRow[pkColumn]) : null;
|
|
986
|
+
const hasNextPage = nextCursor !== null;
|
|
987
|
+
return ok4({ status: 200, body: { items: data, total, limit, nextCursor, hasNextPage } });
|
|
988
|
+
},
|
|
989
|
+
async get(ctx, id) {
|
|
990
|
+
const row = await db.get(id);
|
|
991
|
+
if (!row) {
|
|
992
|
+
return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
|
|
993
|
+
}
|
|
994
|
+
const accessResult = await enforceAccess("get", def.access, ctx, row);
|
|
995
|
+
if (!accessResult.ok)
|
|
996
|
+
return err4(accessResult.error);
|
|
997
|
+
return ok4({
|
|
998
|
+
status: 200,
|
|
999
|
+
body: narrowRelationFields(def.relations, stripHiddenFields(table, row))
|
|
1000
|
+
});
|
|
1001
|
+
},
|
|
1002
|
+
async create(ctx, data) {
|
|
1003
|
+
const accessResult = await enforceAccess("create", def.access, ctx);
|
|
1004
|
+
if (!accessResult.ok)
|
|
1005
|
+
return err4(accessResult.error);
|
|
1006
|
+
let input = stripReadOnlyFields(table, data);
|
|
1007
|
+
if (def.before.create) {
|
|
1008
|
+
input = await def.before.create(input, ctx);
|
|
1009
|
+
}
|
|
1010
|
+
const result = await db.create(input);
|
|
1011
|
+
const strippedResult = stripHiddenFields(table, result);
|
|
1012
|
+
if (def.after.create) {
|
|
1013
|
+
try {
|
|
1014
|
+
await def.after.create(strippedResult, ctx);
|
|
1015
|
+
} catch {}
|
|
1016
|
+
}
|
|
1017
|
+
return ok4({ status: 201, body: narrowRelationFields(def.relations, strippedResult) });
|
|
1018
|
+
},
|
|
1019
|
+
async update(ctx, id, data) {
|
|
1020
|
+
const existing = await db.get(id);
|
|
1021
|
+
if (!existing) {
|
|
1022
|
+
return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
|
|
1023
|
+
}
|
|
1024
|
+
const accessResult = await enforceAccess("update", def.access, ctx, existing);
|
|
1025
|
+
if (!accessResult.ok)
|
|
1026
|
+
return err4(accessResult.error);
|
|
1027
|
+
let input = stripReadOnlyFields(table, data);
|
|
1028
|
+
if (def.before.update) {
|
|
1029
|
+
input = await def.before.update(input, ctx);
|
|
1030
|
+
}
|
|
1031
|
+
const result = await db.update(id, input);
|
|
1032
|
+
const strippedExisting = stripHiddenFields(table, existing);
|
|
1033
|
+
const strippedResult = stripHiddenFields(table, result);
|
|
1034
|
+
if (def.after.update) {
|
|
1035
|
+
try {
|
|
1036
|
+
await def.after.update(strippedExisting, strippedResult, ctx);
|
|
1037
|
+
} catch {}
|
|
1038
|
+
}
|
|
1039
|
+
return ok4({
|
|
1040
|
+
status: 200,
|
|
1041
|
+
body: narrowRelationFields(def.relations, strippedResult)
|
|
1042
|
+
});
|
|
1043
|
+
},
|
|
1044
|
+
async delete(ctx, id) {
|
|
1045
|
+
const existing = await db.get(id);
|
|
1046
|
+
if (!existing) {
|
|
1047
|
+
return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
|
|
1048
|
+
}
|
|
1049
|
+
const accessResult = await enforceAccess("delete", def.access, ctx, existing);
|
|
1050
|
+
if (!accessResult.ok)
|
|
1051
|
+
return err4(accessResult.error);
|
|
1052
|
+
await db.delete(id);
|
|
1053
|
+
if (def.after.delete) {
|
|
1054
|
+
try {
|
|
1055
|
+
await def.after.delete(stripHiddenFields(table, existing), ctx);
|
|
1056
|
+
} catch {}
|
|
1057
|
+
}
|
|
1058
|
+
return ok4({ status: 204, body: null });
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// src/entity/error-handler.ts
|
|
1064
|
+
import { ValidationException, VertzException } from "@vertz/core";
|
|
1065
|
+
import { EntityError, isEntityValidationError } from "@vertz/errors";
|
|
1066
|
+
var ERROR_CODE_TO_STATUS = {
|
|
1067
|
+
BadRequest: 400,
|
|
1068
|
+
Unauthorized: 401,
|
|
1069
|
+
Forbidden: 403,
|
|
1070
|
+
NotFound: 404,
|
|
1071
|
+
MethodNotAllowed: 405,
|
|
1072
|
+
Conflict: 409,
|
|
1073
|
+
ValidationError: 422,
|
|
1074
|
+
InternalError: 500,
|
|
1075
|
+
ServiceUnavailable: 503
|
|
1076
|
+
};
|
|
1077
|
+
var STATUS_TO_ERROR_CODE = {
|
|
1078
|
+
400: "BadRequest",
|
|
1079
|
+
401: "Unauthorized",
|
|
1080
|
+
403: "Forbidden",
|
|
1081
|
+
404: "NotFound",
|
|
1082
|
+
405: "MethodNotAllowed",
|
|
1083
|
+
409: "Conflict",
|
|
1084
|
+
422: "ValidationError",
|
|
1085
|
+
500: "InternalError",
|
|
1086
|
+
503: "ServiceUnavailable"
|
|
1087
|
+
};
|
|
1088
|
+
function entityErrorHandler(error) {
|
|
1089
|
+
if (error instanceof EntityError) {
|
|
1090
|
+
const status = ERROR_CODE_TO_STATUS[error.code] ?? 500;
|
|
1091
|
+
const details = isEntityValidationError(error) ? error.errors : undefined;
|
|
1092
|
+
return {
|
|
1093
|
+
status,
|
|
1094
|
+
body: {
|
|
1095
|
+
error: {
|
|
1096
|
+
code: error.code,
|
|
1097
|
+
message: error.message,
|
|
1098
|
+
...details !== undefined && { details }
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
if (error instanceof VertzException) {
|
|
1104
|
+
const code = STATUS_TO_ERROR_CODE[error.statusCode] ?? "InternalError";
|
|
1105
|
+
const details = error instanceof ValidationException ? error.errors : undefined;
|
|
1106
|
+
return {
|
|
1107
|
+
status: error.statusCode,
|
|
1108
|
+
body: {
|
|
1109
|
+
error: {
|
|
1110
|
+
code,
|
|
1111
|
+
message: error.message,
|
|
1112
|
+
...details !== undefined && { details }
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
return {
|
|
1118
|
+
status: 500,
|
|
1119
|
+
body: {
|
|
1120
|
+
error: { code: "InternalError", message: "An unexpected error occurred" }
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// src/entity/vertzql-parser.ts
|
|
1126
|
+
var MAX_LIMIT = 1000;
|
|
1127
|
+
var MAX_Q_BASE64_LENGTH = 10240;
|
|
1128
|
+
var ALLOWED_Q_KEYS = new Set([
|
|
1129
|
+
"select",
|
|
1130
|
+
"include",
|
|
1131
|
+
"where",
|
|
1132
|
+
"orderBy",
|
|
1133
|
+
"limit",
|
|
1134
|
+
"offset"
|
|
1135
|
+
]);
|
|
1136
|
+
function parseVertzQL(query) {
|
|
1137
|
+
const result = {};
|
|
1138
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1139
|
+
const whereMatch = key.match(/^where\[([^\]]+)\](?:\[([^\]]+)\])?$/);
|
|
1140
|
+
if (whereMatch) {
|
|
1141
|
+
if (!result.where)
|
|
1142
|
+
result.where = {};
|
|
1143
|
+
const field = whereMatch[1];
|
|
1144
|
+
const op = whereMatch[2];
|
|
1145
|
+
const existing = result.where[field];
|
|
1146
|
+
if (op) {
|
|
1147
|
+
const base = existing && typeof existing === "object" ? existing : existing !== undefined ? { eq: existing } : {};
|
|
1148
|
+
result.where[field] = { ...base, [op]: value };
|
|
1149
|
+
} else {
|
|
1150
|
+
if (existing && typeof existing === "object") {
|
|
1151
|
+
result.where[field] = { ...existing, eq: value };
|
|
1152
|
+
} else {
|
|
1153
|
+
result.where[field] = value;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
if (key === "limit") {
|
|
1159
|
+
const parsed = Number.parseInt(value, 10);
|
|
1160
|
+
if (!Number.isNaN(parsed)) {
|
|
1161
|
+
result.limit = Math.max(0, Math.min(parsed, MAX_LIMIT));
|
|
1162
|
+
}
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
if (key === "after") {
|
|
1166
|
+
if (value) {
|
|
1167
|
+
result.after = value;
|
|
1168
|
+
}
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (key === "orderBy") {
|
|
1172
|
+
const [field, dir] = value.split(":");
|
|
1173
|
+
if (field) {
|
|
1174
|
+
if (!result.orderBy)
|
|
1175
|
+
result.orderBy = {};
|
|
1176
|
+
result.orderBy[field] = dir === "desc" ? "desc" : "asc";
|
|
1177
|
+
}
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
if (key === "q") {
|
|
1181
|
+
try {
|
|
1182
|
+
const urlDecoded = decodeURIComponent(value);
|
|
1183
|
+
if (urlDecoded.length > MAX_Q_BASE64_LENGTH) {
|
|
1184
|
+
result._qError = "q= parameter exceeds maximum allowed size";
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
const b64 = urlDecoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
1188
|
+
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
|
1189
|
+
const decoded = JSON.parse(atob(padded));
|
|
1190
|
+
for (const k of Object.keys(decoded)) {
|
|
1191
|
+
if (!ALLOWED_Q_KEYS.has(k)) {
|
|
1192
|
+
delete decoded[k];
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (decoded.select && typeof decoded.select === "object") {
|
|
1196
|
+
result.select = decoded.select;
|
|
1197
|
+
}
|
|
1198
|
+
if (decoded.include && typeof decoded.include === "object") {
|
|
1199
|
+
result.include = decoded.include;
|
|
1200
|
+
}
|
|
1201
|
+
} catch {
|
|
1202
|
+
result._qError = "Invalid q= parameter: not valid base64 or JSON";
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return result;
|
|
1207
|
+
}
|
|
1208
|
+
function getHiddenColumns(table) {
|
|
1209
|
+
const hidden = new Set;
|
|
1210
|
+
for (const key of Object.keys(table._columns)) {
|
|
1211
|
+
const col = table._columns[key];
|
|
1212
|
+
if (col?._meta._annotations.hidden) {
|
|
1213
|
+
hidden.add(key);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return hidden;
|
|
1217
|
+
}
|
|
1218
|
+
function validateVertzQL(options, table, relationsConfig) {
|
|
1219
|
+
if (options._qError) {
|
|
1220
|
+
return { ok: false, error: options._qError };
|
|
1221
|
+
}
|
|
1222
|
+
const hiddenColumns = getHiddenColumns(table);
|
|
1223
|
+
if (options.where) {
|
|
1224
|
+
for (const field of Object.keys(options.where)) {
|
|
1225
|
+
if (hiddenColumns.has(field)) {
|
|
1226
|
+
return { ok: false, error: `Field "${field}" is not filterable` };
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (options.orderBy) {
|
|
1231
|
+
for (const field of Object.keys(options.orderBy)) {
|
|
1232
|
+
if (hiddenColumns.has(field)) {
|
|
1233
|
+
return { ok: false, error: `Field "${field}" is not sortable` };
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
if (options.select) {
|
|
1238
|
+
for (const field of Object.keys(options.select)) {
|
|
1239
|
+
if (hiddenColumns.has(field)) {
|
|
1240
|
+
return { ok: false, error: `Field "${field}" is not selectable` };
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (options.include && relationsConfig) {
|
|
1245
|
+
for (const [relation, requested] of Object.entries(options.include)) {
|
|
1246
|
+
const entityConfig = relationsConfig[relation];
|
|
1247
|
+
if (entityConfig === undefined || entityConfig === false) {
|
|
1248
|
+
return { ok: false, error: `Relation "${relation}" is not exposed` };
|
|
1249
|
+
}
|
|
1250
|
+
if (typeof entityConfig === "object" && typeof requested === "object") {
|
|
1251
|
+
for (const field of Object.keys(requested)) {
|
|
1252
|
+
if (!(field in entityConfig)) {
|
|
1253
|
+
return {
|
|
1254
|
+
ok: false,
|
|
1255
|
+
error: `Field "${field}" is not exposed on relation "${relation}"`
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return { ok: true };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/entity/route-generator.ts
|
|
1266
|
+
function jsonResponse2(data, status = 200) {
|
|
1267
|
+
return new Response(JSON.stringify(data), {
|
|
1268
|
+
status,
|
|
1269
|
+
headers: { "content-type": "application/json" }
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
function emptyResponse(status) {
|
|
1273
|
+
return new Response(null, { status });
|
|
1274
|
+
}
|
|
1275
|
+
function extractRequestInfo2(ctx) {
|
|
1276
|
+
return {
|
|
1277
|
+
userId: ctx.userId ?? null,
|
|
1278
|
+
tenantId: ctx.tenantId ?? null,
|
|
1279
|
+
roles: ctx.roles ?? []
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
function getParams(ctx) {
|
|
1283
|
+
return ctx.params ?? {};
|
|
1284
|
+
}
|
|
1285
|
+
function generateEntityRoutes(def, registry, db, options) {
|
|
1286
|
+
const prefix = options?.apiPrefix ?? "/api";
|
|
1287
|
+
const basePath = `${prefix}/${def.name}`;
|
|
1288
|
+
const crudHandlers = createCrudHandlers(def, db);
|
|
1289
|
+
const inject = def.inject ?? {};
|
|
1290
|
+
const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
|
|
1291
|
+
const routes = [];
|
|
1292
|
+
function makeEntityCtx(ctx) {
|
|
1293
|
+
const requestInfo = extractRequestInfo2(ctx);
|
|
1294
|
+
const entityOps = {};
|
|
1295
|
+
return createEntityContext(requestInfo, entityOps, registryProxy);
|
|
1296
|
+
}
|
|
1297
|
+
if (def.access.list !== undefined) {
|
|
1298
|
+
if (def.access.list === false) {
|
|
1299
|
+
routes.push({
|
|
1300
|
+
method: "GET",
|
|
1301
|
+
path: basePath,
|
|
1302
|
+
handler: async () => jsonResponse2({
|
|
1303
|
+
error: {
|
|
1304
|
+
code: "MethodNotAllowed",
|
|
1305
|
+
message: `Operation "list" is disabled for ${def.name}`
|
|
1306
|
+
}
|
|
1307
|
+
}, 405)
|
|
1308
|
+
});
|
|
1309
|
+
} else {
|
|
1310
|
+
routes.push({
|
|
1311
|
+
method: "GET",
|
|
1312
|
+
path: basePath,
|
|
1313
|
+
handler: async (ctx) => {
|
|
1314
|
+
try {
|
|
1315
|
+
const entityCtx = makeEntityCtx(ctx);
|
|
1316
|
+
const query = ctx.query ?? {};
|
|
1317
|
+
const parsed = parseVertzQL(query);
|
|
1318
|
+
const relationsConfig = def.relations;
|
|
1319
|
+
const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
|
|
1320
|
+
if (!validation.ok) {
|
|
1321
|
+
return jsonResponse2({ error: { code: "BadRequest", message: validation.error } }, 400);
|
|
1322
|
+
}
|
|
1323
|
+
const options2 = {
|
|
1324
|
+
where: parsed.where ? parsed.where : undefined,
|
|
1325
|
+
orderBy: parsed.orderBy,
|
|
1326
|
+
limit: parsed.limit,
|
|
1327
|
+
after: parsed.after
|
|
1328
|
+
};
|
|
1329
|
+
const result = await crudHandlers.list(entityCtx, options2);
|
|
1330
|
+
if (!result.ok) {
|
|
1331
|
+
const { status, body } = entityErrorHandler(result.error);
|
|
1332
|
+
return jsonResponse2(body, status);
|
|
1333
|
+
}
|
|
1334
|
+
if (parsed.select && result.data.body.items) {
|
|
1335
|
+
result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
|
|
1336
|
+
}
|
|
1337
|
+
return jsonResponse2(result.data.body, result.data.status);
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
const { status, body } = entityErrorHandler(error);
|
|
1340
|
+
return jsonResponse2(body, status);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
routes.push({
|
|
1346
|
+
method: "POST",
|
|
1347
|
+
path: `${basePath}/query`,
|
|
1348
|
+
handler: async (ctx) => {
|
|
1349
|
+
try {
|
|
1350
|
+
const entityCtx = makeEntityCtx(ctx);
|
|
1351
|
+
const body = ctx.body ?? {};
|
|
1352
|
+
const parsed = {
|
|
1353
|
+
where: body.where,
|
|
1354
|
+
orderBy: body.orderBy,
|
|
1355
|
+
limit: typeof body.limit === "number" ? body.limit : undefined,
|
|
1356
|
+
after: typeof body.after === "string" ? body.after : undefined,
|
|
1357
|
+
select: body.select,
|
|
1358
|
+
include: body.include
|
|
1359
|
+
};
|
|
1360
|
+
const relationsConfig = def.relations;
|
|
1361
|
+
const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
|
|
1362
|
+
if (!validation.ok) {
|
|
1363
|
+
return jsonResponse2({ error: { code: "BadRequest", message: validation.error } }, 400);
|
|
1364
|
+
}
|
|
1365
|
+
const options2 = {
|
|
1366
|
+
where: parsed.where,
|
|
1367
|
+
orderBy: parsed.orderBy,
|
|
1368
|
+
limit: parsed.limit,
|
|
1369
|
+
after: parsed.after
|
|
1370
|
+
};
|
|
1371
|
+
const result = await crudHandlers.list(entityCtx, options2);
|
|
1372
|
+
if (!result.ok) {
|
|
1373
|
+
const { status, body: errBody } = entityErrorHandler(result.error);
|
|
1374
|
+
return jsonResponse2(errBody, status);
|
|
1375
|
+
}
|
|
1376
|
+
if (parsed.select && result.data.body.items) {
|
|
1377
|
+
result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
|
|
1378
|
+
}
|
|
1379
|
+
return jsonResponse2(result.data.body, result.data.status);
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
const { status, body: errBody } = entityErrorHandler(error);
|
|
1382
|
+
return jsonResponse2(errBody, status);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
if (def.access.get !== undefined) {
|
|
1388
|
+
if (def.access.get === false) {
|
|
1389
|
+
routes.push({
|
|
1390
|
+
method: "GET",
|
|
1391
|
+
path: `${basePath}/:id`,
|
|
1392
|
+
handler: async () => jsonResponse2({
|
|
1393
|
+
error: {
|
|
1394
|
+
code: "MethodNotAllowed",
|
|
1395
|
+
message: `Operation "get" is disabled for ${def.name}`
|
|
1396
|
+
}
|
|
1397
|
+
}, 405)
|
|
1398
|
+
});
|
|
1399
|
+
} else {
|
|
1400
|
+
routes.push({
|
|
1401
|
+
method: "GET",
|
|
1402
|
+
path: `${basePath}/:id`,
|
|
1403
|
+
handler: async (ctx) => {
|
|
1404
|
+
try {
|
|
1405
|
+
const entityCtx = makeEntityCtx(ctx);
|
|
1406
|
+
const id = getParams(ctx).id;
|
|
1407
|
+
const query = ctx.query ?? {};
|
|
1408
|
+
const parsed = parseVertzQL(query);
|
|
1409
|
+
const relationsConfig = def.relations;
|
|
1410
|
+
const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
|
|
1411
|
+
if (!validation.ok) {
|
|
1412
|
+
return jsonResponse2({ error: { code: "BadRequest", message: validation.error } }, 400);
|
|
1413
|
+
}
|
|
1414
|
+
const result = await crudHandlers.get(entityCtx, id);
|
|
1415
|
+
if (!result.ok) {
|
|
1416
|
+
const { status, body: body2 } = entityErrorHandler(result.error);
|
|
1417
|
+
return jsonResponse2(body2, status);
|
|
1418
|
+
}
|
|
1419
|
+
const body = parsed.select ? applySelect(parsed.select, result.data.body) : result.data.body;
|
|
1420
|
+
return jsonResponse2(body, result.data.status);
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
const { status, body } = entityErrorHandler(error);
|
|
1423
|
+
return jsonResponse2(body, status);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
if (def.access.create !== undefined) {
|
|
1430
|
+
if (def.access.create === false) {
|
|
1431
|
+
routes.push({
|
|
1432
|
+
method: "POST",
|
|
1433
|
+
path: basePath,
|
|
1434
|
+
handler: async () => jsonResponse2({
|
|
1435
|
+
error: {
|
|
1436
|
+
code: "MethodNotAllowed",
|
|
1437
|
+
message: `Operation "create" is disabled for ${def.name}`
|
|
1438
|
+
}
|
|
1439
|
+
}, 405)
|
|
1440
|
+
});
|
|
1441
|
+
} else {
|
|
1442
|
+
routes.push({
|
|
1443
|
+
method: "POST",
|
|
1444
|
+
path: basePath,
|
|
1445
|
+
handler: async (ctx) => {
|
|
1446
|
+
try {
|
|
1447
|
+
const entityCtx = makeEntityCtx(ctx);
|
|
1448
|
+
const data = ctx.body ?? {};
|
|
1449
|
+
const result = await crudHandlers.create(entityCtx, data);
|
|
1450
|
+
if (!result.ok) {
|
|
1451
|
+
const { status, body } = entityErrorHandler(result.error);
|
|
1452
|
+
return jsonResponse2(body, status);
|
|
1453
|
+
}
|
|
1454
|
+
return jsonResponse2(result.data.body, result.data.status);
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
const { status, body } = entityErrorHandler(error);
|
|
1457
|
+
return jsonResponse2(body, status);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
if (def.access.update !== undefined) {
|
|
1464
|
+
if (def.access.update === false) {
|
|
1465
|
+
routes.push({
|
|
1466
|
+
method: "PATCH",
|
|
1467
|
+
path: `${basePath}/:id`,
|
|
1468
|
+
handler: async () => jsonResponse2({
|
|
1469
|
+
error: {
|
|
1470
|
+
code: "MethodNotAllowed",
|
|
1471
|
+
message: `Operation "update" is disabled for ${def.name}`
|
|
1472
|
+
}
|
|
1473
|
+
}, 405)
|
|
1474
|
+
});
|
|
1475
|
+
} else {
|
|
1476
|
+
routes.push({
|
|
1477
|
+
method: "PATCH",
|
|
1478
|
+
path: `${basePath}/:id`,
|
|
1479
|
+
handler: async (ctx) => {
|
|
1480
|
+
try {
|
|
1481
|
+
const entityCtx = makeEntityCtx(ctx);
|
|
1482
|
+
const id = getParams(ctx).id;
|
|
1483
|
+
const data = ctx.body ?? {};
|
|
1484
|
+
const result = await crudHandlers.update(entityCtx, id, data);
|
|
1485
|
+
if (!result.ok) {
|
|
1486
|
+
const { status, body } = entityErrorHandler(result.error);
|
|
1487
|
+
return jsonResponse2(body, status);
|
|
1488
|
+
}
|
|
1489
|
+
return jsonResponse2(result.data.body, result.data.status);
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
const { status, body } = entityErrorHandler(error);
|
|
1492
|
+
return jsonResponse2(body, status);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
if (def.access.delete !== undefined) {
|
|
1499
|
+
if (def.access.delete === false) {
|
|
1500
|
+
routes.push({
|
|
1501
|
+
method: "DELETE",
|
|
1502
|
+
path: `${basePath}/:id`,
|
|
1503
|
+
handler: async () => jsonResponse2({
|
|
1504
|
+
error: {
|
|
1505
|
+
code: "MethodNotAllowed",
|
|
1506
|
+
message: `Operation "delete" is disabled for ${def.name}`
|
|
1507
|
+
}
|
|
1508
|
+
}, 405)
|
|
1509
|
+
});
|
|
1510
|
+
} else {
|
|
1511
|
+
routes.push({
|
|
1512
|
+
method: "DELETE",
|
|
1513
|
+
path: `${basePath}/:id`,
|
|
1514
|
+
handler: async (ctx) => {
|
|
1515
|
+
try {
|
|
1516
|
+
const entityCtx = makeEntityCtx(ctx);
|
|
1517
|
+
const id = getParams(ctx).id;
|
|
1518
|
+
const result = await crudHandlers.delete(entityCtx, id);
|
|
1519
|
+
if (!result.ok) {
|
|
1520
|
+
const { status, body } = entityErrorHandler(result.error);
|
|
1521
|
+
return jsonResponse2(body, status);
|
|
1522
|
+
}
|
|
1523
|
+
if (result.data.status === 204) {
|
|
1524
|
+
return emptyResponse(204);
|
|
1525
|
+
}
|
|
1526
|
+
return jsonResponse2(result.data.body, result.data.status);
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
const { status, body } = entityErrorHandler(error);
|
|
1529
|
+
return jsonResponse2(body, status);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
for (const [actionName, actionDef] of Object.entries(def.actions)) {
|
|
1536
|
+
if (def.access[actionName] === undefined)
|
|
1537
|
+
continue;
|
|
1538
|
+
const method = (actionDef.method ?? "POST").toUpperCase();
|
|
1539
|
+
const actionPath = actionDef.path ? `${basePath}/${actionDef.path}` : `${basePath}/:id/${actionName}`;
|
|
1540
|
+
const hasId = actionPath.includes(":id");
|
|
1541
|
+
if (def.access[actionName] === false) {
|
|
1542
|
+
routes.push({
|
|
1543
|
+
method,
|
|
1544
|
+
path: actionPath,
|
|
1545
|
+
handler: async () => jsonResponse2({
|
|
1546
|
+
error: {
|
|
1547
|
+
code: "MethodNotAllowed",
|
|
1548
|
+
message: `Action "${actionName}" is disabled for ${def.name}`
|
|
1549
|
+
}
|
|
1550
|
+
}, 405)
|
|
1551
|
+
});
|
|
1552
|
+
} else {
|
|
1553
|
+
const actionHandler = createActionHandler(def, actionName, actionDef, db, hasId);
|
|
1554
|
+
routes.push({
|
|
1555
|
+
method,
|
|
1556
|
+
path: actionPath,
|
|
1557
|
+
handler: async (ctx) => {
|
|
1558
|
+
try {
|
|
1559
|
+
const entityCtx = makeEntityCtx(ctx);
|
|
1560
|
+
const id = hasId ? getParams(ctx).id : null;
|
|
1561
|
+
const input = method === "GET" ? ctx.query ?? {} : ctx.body;
|
|
1562
|
+
const result = await actionHandler(entityCtx, id, input);
|
|
1563
|
+
if (!result.ok) {
|
|
1564
|
+
const { status, body } = entityErrorHandler(result.error);
|
|
1565
|
+
return jsonResponse2(body, status);
|
|
1566
|
+
}
|
|
1567
|
+
return jsonResponse2(result.data.body, result.data.status);
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
const { status, body } = entityErrorHandler(error);
|
|
1570
|
+
return jsonResponse2(body, status);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
return routes;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// src/create-server.ts
|
|
1580
|
+
function isDatabaseClient(db) {
|
|
1581
|
+
return db !== null && typeof db === "object" && "_internals" in db;
|
|
1582
|
+
}
|
|
1583
|
+
function createNoopDbAdapter() {
|
|
1584
|
+
return {
|
|
1585
|
+
async get() {
|
|
1586
|
+
return null;
|
|
1587
|
+
},
|
|
1588
|
+
async list() {
|
|
1589
|
+
return { data: [], total: 0 };
|
|
1590
|
+
},
|
|
1591
|
+
async create(data) {
|
|
1592
|
+
return data;
|
|
1593
|
+
},
|
|
1594
|
+
async update(_id, data) {
|
|
1595
|
+
return data;
|
|
1596
|
+
},
|
|
1597
|
+
async delete() {
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
function createEntityOps(entityDef, db) {
|
|
1603
|
+
const table = entityDef.model.table;
|
|
1604
|
+
return {
|
|
1605
|
+
async get(id) {
|
|
1606
|
+
const row = await db.get(id);
|
|
1607
|
+
if (!row)
|
|
1608
|
+
return row;
|
|
1609
|
+
return stripHiddenFields(table, row);
|
|
1610
|
+
},
|
|
1611
|
+
async list(options) {
|
|
1612
|
+
const result = await db.list(options);
|
|
1613
|
+
const items = Array.isArray(result) ? result : result.data ?? [];
|
|
1614
|
+
const total = Array.isArray(result) ? result.length : result.total ?? items.length;
|
|
1615
|
+
return {
|
|
1616
|
+
items: items.map((row) => stripHiddenFields(table, row)),
|
|
1617
|
+
total,
|
|
1618
|
+
limit: options?.limit ?? 20,
|
|
1619
|
+
nextCursor: null,
|
|
1620
|
+
hasNextPage: false
|
|
1621
|
+
};
|
|
1622
|
+
},
|
|
1623
|
+
async create(data) {
|
|
1624
|
+
const row = await db.create(data);
|
|
1625
|
+
return stripHiddenFields(table, row);
|
|
1626
|
+
},
|
|
1627
|
+
async update(id, data) {
|
|
1628
|
+
const row = await db.update(id, data);
|
|
1629
|
+
return stripHiddenFields(table, row);
|
|
1630
|
+
},
|
|
1631
|
+
async delete(id) {
|
|
1632
|
+
await db.delete(id);
|
|
1633
|
+
}
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
function createServer(config) {
|
|
1637
|
+
const allRoutes = [];
|
|
1638
|
+
const registry = new EntityRegistry;
|
|
1639
|
+
const apiPrefix = config.apiPrefix === undefined ? "/api" : config.apiPrefix;
|
|
1640
|
+
if (config.entities && config.entities.length > 0) {
|
|
1641
|
+
const { db } = config;
|
|
1642
|
+
let dbFactory;
|
|
1643
|
+
if (db && isDatabaseClient(db)) {
|
|
1644
|
+
dbFactory = (entityDef) => createDatabaseBridgeAdapter(db, entityDef.name);
|
|
1645
|
+
} else if (db) {
|
|
1646
|
+
dbFactory = () => db;
|
|
1647
|
+
} else {
|
|
1648
|
+
dbFactory = config._entityDbFactory ?? createNoopDbAdapter;
|
|
1649
|
+
}
|
|
1650
|
+
for (const entityDef of config.entities) {
|
|
1651
|
+
const entityDb = dbFactory(entityDef);
|
|
1652
|
+
const ops = createEntityOps(entityDef, entityDb);
|
|
1653
|
+
registry.register(entityDef.name, ops);
|
|
1654
|
+
}
|
|
1655
|
+
for (const entityDef of config.entities) {
|
|
1656
|
+
const entityDb = dbFactory(entityDef);
|
|
1657
|
+
const routes = generateEntityRoutes(entityDef, registry, entityDb, {
|
|
1658
|
+
apiPrefix
|
|
1659
|
+
});
|
|
1660
|
+
allRoutes.push(...routes);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if (config.actions && config.actions.length > 0) {
|
|
1664
|
+
for (const actionDef of config.actions) {
|
|
1665
|
+
const routes = generateActionRoutes(actionDef, registry, { apiPrefix });
|
|
1666
|
+
allRoutes.push(...routes);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
return coreCreateServer({
|
|
1670
|
+
...config,
|
|
1671
|
+
_entityRoutes: allRoutes.length > 0 ? allRoutes : undefined
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
// src/entity/entity.ts
|
|
1675
|
+
import { deepFreeze as deepFreeze2 } from "@vertz/core";
|
|
1676
|
+
var ENTITY_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
1677
|
+
function entity(name, config) {
|
|
1678
|
+
if (!name || !ENTITY_NAME_PATTERN.test(name)) {
|
|
1679
|
+
throw new Error(`entity() name must be a non-empty lowercase string matching /^[a-z][a-z0-9-]*$/. Got: "${name}"`);
|
|
1680
|
+
}
|
|
1681
|
+
if (!config.model) {
|
|
1682
|
+
throw new Error("entity() requires a model in the config.");
|
|
1683
|
+
}
|
|
1684
|
+
const def = {
|
|
1685
|
+
kind: "entity",
|
|
1686
|
+
name,
|
|
1687
|
+
model: config.model,
|
|
1688
|
+
inject: config.inject ?? {},
|
|
1689
|
+
access: config.access ?? {},
|
|
1690
|
+
before: config.before ?? {},
|
|
1691
|
+
after: config.after ?? {},
|
|
1692
|
+
actions: config.actions ?? {},
|
|
1693
|
+
relations: config.relations ?? {}
|
|
1694
|
+
};
|
|
1695
|
+
return deepFreeze2(def);
|
|
1696
|
+
}
|
|
611
1697
|
export {
|
|
612
1698
|
vertz,
|
|
613
1699
|
verifyPassword,
|
|
614
1700
|
validatePassword,
|
|
1701
|
+
stripReadOnlyFields,
|
|
1702
|
+
stripHiddenFields,
|
|
615
1703
|
makeImmutable,
|
|
616
1704
|
hashPassword,
|
|
617
|
-
|
|
1705
|
+
generateEntityRoutes,
|
|
1706
|
+
entityErrorHandler,
|
|
1707
|
+
entity,
|
|
1708
|
+
enforceAccess,
|
|
618
1709
|
defaultAccess,
|
|
619
|
-
deepFreeze,
|
|
1710
|
+
deepFreeze3 as deepFreeze,
|
|
620
1711
|
createServer,
|
|
621
|
-
createModuleDef,
|
|
622
|
-
createModule,
|
|
623
1712
|
createMiddleware,
|
|
624
1713
|
createImmutableProxy,
|
|
625
1714
|
createEnv,
|
|
1715
|
+
createEntityContext,
|
|
1716
|
+
createCrudHandlers,
|
|
626
1717
|
createAuth,
|
|
627
1718
|
createAccess,
|
|
628
|
-
|
|
629
|
-
|
|
1719
|
+
action,
|
|
1720
|
+
VertzException2 as VertzException,
|
|
1721
|
+
ValidationException2 as ValidationException,
|
|
630
1722
|
UnauthorizedException,
|
|
631
1723
|
ServiceUnavailableException,
|
|
632
1724
|
NotFoundException,
|
|
633
1725
|
InternalServerErrorException,
|
|
634
1726
|
ForbiddenException,
|
|
1727
|
+
EntityRegistry,
|
|
635
1728
|
ConflictException,
|
|
636
1729
|
BadRequestException,
|
|
637
1730
|
AuthorizationError
|