@vertz/server 0.2.0 → 0.2.1

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