@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.
Files changed (4) hide show
  1. package/README.md +371 -0
  2. package/dist/index.d.ts +278 -235
  3. package/dist/index.js +1193 -100
  4. 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
- createModule,
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/domain/domain.ts
24
- function domain(name, options) {
25
- if (!name || !options) {
26
- return Object.freeze({
27
- name: name || "",
28
- type: "persisted",
29
- table: null,
30
- exposedRelations: {},
31
- access: {},
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
- type: options.type,
39
- table: options.table,
40
- exposedRelations: options.expose || {},
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 Object.freeze(def);
37
+ return deepFreeze(def);
46
38
  }
47
39
  // src/auth/index.ts
48
- import * as jose from "jose";
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, action] = entitlement.split(":");
79
- if (action && resource !== "*") {
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 true;
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, _resource, user) {
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), { algorithms: [algorithm] });
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 envJwtSecret = process.env.AUTH_JWT_SECRET;
297
+ const isProduction = config.isProduction ?? (typeof process === "undefined" || false);
306
298
  let jwtSecret;
307
299
  if (configJwtSecret) {
308
300
  jwtSecret = configJwtSecret;
309
- } else if (envJwtSecret) {
310
- jwtSecret = envJwtSecret;
301
+ } else if (isProduction) {
302
+ throw new Error('jwtSecret is required in production. Provide it via createAuth({ jwtSecret: "..." }).');
311
303
  } else {
312
- if (false) {} else {
313
- console.warn("⚠️ Using insecure default JWT secret. Set AUTH_JWT_SECRET for production.");
314
- jwtSecret = "dev-secret-change-in-production";
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 { ok: false, error: { code: "INVALID_EMAIL", message: "Invalid email format", status: 400 } };
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 { ok: false, error: passwordError };
344
+ return err(passwordError);
338
345
  }
339
346
  if (users.has(email.toLowerCase())) {
340
- return { ok: false, error: { code: "USER_EXISTS", message: "User already exists", status: 409 } };
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 { ok: false, error: { code: "RATE_LIMITED", message: "Too many sign up attempts", status: 429 } };
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 { ok: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password", status: 401 } };
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 { ok: false, error: { code: "RATE_LIMITED", message: "Too many sign in attempts", status: 429 } };
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 { ok: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password", status: 401 } };
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 { ok: true, data: undefined };
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 { ok: true, data: null };
417
+ return ok(null);
417
418
  }
418
419
  const session2 = sessions.get(token);
419
420
  if (!session2) {
420
- return { ok: true, data: null };
421
+ return ok(null);
421
422
  }
422
423
  if (session2.expiresAt < new Date) {
423
424
  sessions.delete(token);
424
- return { ok: true, data: null };
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 { ok: true, data: null };
430
+ return ok(null);
430
431
  }
431
432
  const stored = users.get(payload.email);
432
433
  if (!stored) {
433
- return { ok: true, data: null };
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 { ok: false, error: { code: "RATE_LIMITED", message: "Too many refresh attempts", status: 429 } };
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 { ok: false, error: { code: "NO_SESSION", message: "No active session", status: 401 } };
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
- ok: true,
468
- data: { user, expiresAt, payload }
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
- if (!origin && !referer) {
479
- if (false) {}
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.status,
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.status,
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.status,
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 (error) {
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
- domain,
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
- VertzException,
629
- ValidationException,
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