@vertz/server 0.2.16 → 0.2.17

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.d.ts CHANGED
@@ -1809,6 +1809,43 @@ interface RelationConfigObject<TColumnKeys extends string = string> {
1809
1809
  readonly maxLimit?: number;
1810
1810
  }
1811
1811
  type EntityRelationsConfig<TRelations extends Record<string, RelationDef2> = Record<string, RelationDef2>> = { [K in keyof TRelations]? : true | false | RelationConfigObject<RelationColumnKeys<TRelations[K]>> };
1812
+ /** Extract the target table type from a RelationDef. */
1813
+ type RelationTargetTable<R> = R extends RelationDef2<infer T> ? T : TableDef;
1814
+ /** Structured expose config for a relation. Controls field, filter, sort, and nested relation exposure. */
1815
+ interface RelationExposeConfig<R extends RelationDef2 = RelationDef2> {
1816
+ /** Which fields of the related entity to expose. */
1817
+ readonly select: { [K in PublicColumnKeys<RelationTargetTable<R>>]? : true | AccessRule };
1818
+ /** Which fields clients can filter on via VertzQL `where`. */
1819
+ readonly allowWhere?: { [K in PublicColumnKeys<RelationTargetTable<R>>]? : true | AccessRule };
1820
+ /** Which fields clients can sort on via VertzQL `orderBy`. */
1821
+ readonly allowOrderBy?: { [K in PublicColumnKeys<RelationTargetTable<R>>]? : true | AccessRule };
1822
+ /** Max items returned per parent row. Defaults to DEFAULT_RELATION_LIMIT. */
1823
+ readonly maxLimit?: number;
1824
+ /** Nested relation exposure (loosely typed — target model relations not available from RelationDef). */
1825
+ readonly include?: Record<string, true | false | RelationExposeConfig>;
1826
+ }
1827
+ /** Controls which fields, filters, sorts, and relations are exposed through the entity API. */
1828
+ interface ExposeConfig<
1829
+ TTable extends TableDef = TableDef,
1830
+ TModel extends ModelDef4 = ModelDef4
1831
+ > {
1832
+ /** Which fields of the entity to expose. Required when expose is present. */
1833
+ readonly select: { [K in PublicColumnKeys<TTable>]? : true | AccessRule };
1834
+ /** Which fields clients can filter on via VertzQL `where`. */
1835
+ readonly allowWhere?: { [K in PublicColumnKeys<TTable>]? : true | AccessRule };
1836
+ /** Which fields clients can sort on via VertzQL `orderBy`. */
1837
+ readonly allowOrderBy?: { [K in PublicColumnKeys<TTable>]? : true | AccessRule };
1838
+ /** Which relations to expose and how. */
1839
+ readonly include?: { [K in keyof TModel["relations"]]? : true | false | RelationExposeConfig<TModel["relations"][K] extends RelationDef2 ? TModel["relations"][K] : RelationDef2> };
1840
+ }
1841
+ /** Extract non-hidden column keys from a table (public fields). */
1842
+ type PublicColumnKeys<TTable extends TableDef> = TTable extends TableDef<infer TCols> ? { [K in keyof TCols & string] : TCols[K] extends {
1843
+ _meta: {
1844
+ _annotations: {
1845
+ hidden: true;
1846
+ };
1847
+ };
1848
+ } ? never : K }[keyof TCols & string] : string;
1812
1849
  interface EntityConfig<
1813
1850
  TModel extends ModelDef4 = ModelDef4,
1814
1851
  TActions extends Record<string, EntityActionDef<any, any, any, any>> = {},
@@ -1831,7 +1868,7 @@ interface EntityConfig<
1831
1868
  readonly delete?: (row: TModel["table"]["$response"], ctx: EntityContext<TModel, TInject>) => void | Promise<void>;
1832
1869
  };
1833
1870
  readonly actions?: { readonly [K in keyof TActions] : TActions[K] };
1834
- readonly relations?: EntityRelationsConfig<TModel["relations"]>;
1871
+ readonly expose?: ExposeConfig<TModel["table"], TModel>;
1835
1872
  }
1836
1873
  interface EntityDefinition<TModel extends ModelDef4 = ModelDef4> {
1837
1874
  readonly kind: "entity";
@@ -1842,7 +1879,7 @@ interface EntityDefinition<TModel extends ModelDef4 = ModelDef4> {
1842
1879
  readonly before: EntityBeforeHooks;
1843
1880
  readonly after: EntityAfterHooks;
1844
1881
  readonly actions: Record<string, EntityActionDef>;
1845
- readonly relations: EntityRelationsConfig<TModel["relations"]>;
1882
+ readonly expose?: ExposeConfig<TModel["table"], TModel>;
1846
1883
  /** DB table name (defaults to entity name). */
1847
1884
  readonly table: string;
1848
1885
  /** Whether CRUD auto-filters by tenantId. */
@@ -2042,4 +2079,4 @@ declare function service<
2042
2079
  TInject extends Record<string, EntityDefinition> = {},
2043
2080
  TActions extends Record<string, ServiceActionDef<any, any, any>> = Record<string, ServiceActionDef<any, any, any>>
2044
2081
  >(name: string, config: ServiceConfig<TActions, TInject>): ServiceDefinition;
2045
- export { vertz, verifyPassword, validatePassword, validateOverrides, validateAuthModels, stripReadOnlyFields, stripHiddenFields, service, rules, resolveTenantChain, makeImmutable, isContentDescriptor, initializeAuthTables, hashPassword, google, github, getIncompatibleAddOns, generateEntityRoutes, entityErrorHandler, entity, enforceAccess, encodeAccessSet, discord, defineAccess, defaultAccess, deepFreeze, decodeAccessSet, createWebhookHandler, createStripeBillingAdapter, createServer, createPlanManager, createMiddleware, createImmutableProxy, createEnv, createEntityContext, createCrudHandlers, createBillingEventEmitter, createAuth, createAccessEventBroadcaster, createAccessContext, createAccess, content, computePlanHash, computeOverage, computeEntityAccess, computeAccessSet, checkFva, checkAddOnCompatibility, calculateBillingPeriod, authModels, WebhookHandlerConfig, WalletStore, WalletEntry, VertzException, ValidationException, UserTableEntry, UserStore, UnauthorizedException, TenantOverrides, TenantChainHop, TenantChain, SubscriptionStore, Subscription, StripeProduct, StripePrice, StripeClient, StripeBillingAdapterConfig, StoredSession, StoredPasswordReset, StoredEmailVerification, SignUpInput, SignInInput, SessionStrategy, SessionStore, SessionPayload, SessionInfo, SessionConfig, Session, ServiceUnavailableException, ServiceRequestInfo, ServiceDefinition, ServiceContext, ServiceConfig, ServiceActionDef, ServerInstance, ServerHandle, ServerConfig, ServerAdapter, RuleContext, RoleAssignmentTableEntry, RoleAssignmentStore, RoleAssignment, ResourceRef, Resource, RequestInfo, RawRequest, RateLimitStore, RateLimitResult, RateLimitConfig, PriceInterval, PlanVersionStore, PlanVersionInfo, PlanSnapshot, PlanPrice, PlanHashInput, PlanDef, Period, PasswordResetStore, PasswordResetConfig, PasswordRequirements, ParentRef, OverrideStore, OverageInput, OverageConfig, OnUserCreatedPayload, OAuthUserInfo, OAuthTokens, OAuthProviderConfig, OAuthProvider, OAuthAccountStore, NotFoundException, NamedMiddlewareDef, MiddlewareDef, MfaSetupData, MfaConfig, MfaChallengeData, MFAStore, ListenOptions, ListResult, ListOptions2 as ListOptions, LimitOverrideDef, LimitOverride, LimitDef, InternalServerErrorException, InferSchema, Infer2 as Infer, InMemoryWalletStore, InMemoryUserStore, InMemorySubscriptionStore, InMemorySessionStore, InMemoryRoleAssignmentStore, InMemoryRateLimitStore, InMemoryPlanVersionStore, InMemoryPasswordResetStore, InMemoryOverrideStore, InMemoryOAuthAccountStore, InMemoryMFAStore, InMemoryGrandfatheringStore, InMemoryFlagStore, InMemoryEmailVerificationStore, InMemoryClosureStore, HttpStatusCode, HttpMethod, HandlerCtx, GrandfatheringStore, GrandfatheringState, ForbiddenException, FlagStore, EnvConfig, EntityRouteOptions, EntityRelationsConfig, EntityRegistry, EntityOperations, EntityErrorResult, EntityDefinition, EntityDef, EntityDbAdapter2 as EntityDbAdapter, EntityContext, EntityConfig, EntityActionDef, EntitlementValue, EntitlementRegistry, EntitlementDefinition, EntitlementDef, Entitlement, EncodedAccessSet, EmailVerificationStore, EmailVerificationConfig, EmailPasswordConfig, Deps, DenialReason, DenialMeta, DefineAccessInput, DeepReadonly, DbUserStore, DbSubscriptionStore, DbSessionStore, DbRoleAssignmentStore, DbOAuthAccountStore, DbFlagStore, DbDialectName, DbClosureStore, Ctx, CrudResult, CrudHandlers, CorsConfig, CookieConfig, ContentDescriptor, ConsumeResult, ConflictException, ComputeAccessSetConfig, ClosureStore, ClosureRow, ClosureEntry, BillingPeriod, BillingEventType, BillingEventHandler, BillingEventEmitter, BillingEvent, BillingAdapter, BaseContext, BadRequestException, AuthorizationError, AuthUser, AuthTokens, AuthInstance, AuthEntityProxy, AuthDbClient, AuthContext, AuthConfig, AuthCallbackContext, AuthApi, AppConfig2 as AppConfig, AppBuilder2 as AppBuilder, AddOnRequires, AclClaim, AccumulateProvides, AccessWsData, AccessSet, AccessRule2 as AccessRule, AccessInstance, AccessEventBroadcasterConfig, AccessEventBroadcaster, AccessEvent, AccessDefinition, AccessContextConfig, AccessContext, AccessConfig, AccessCheckResult, AccessCheckData, AUTH_TABLE_NAMES };
2082
+ export { vertz, verifyPassword, validatePassword, validateOverrides, validateAuthModels, stripReadOnlyFields, stripHiddenFields, service, rules, resolveTenantChain, makeImmutable, isContentDescriptor, initializeAuthTables, hashPassword, google, github, getIncompatibleAddOns, generateEntityRoutes, entityErrorHandler, entity, enforceAccess, encodeAccessSet, discord, defineAccess, defaultAccess, deepFreeze, decodeAccessSet, createWebhookHandler, createStripeBillingAdapter, createServer, createPlanManager, createMiddleware, createImmutableProxy, createEnv, createEntityContext, createCrudHandlers, createBillingEventEmitter, createAuth, createAccessEventBroadcaster, createAccessContext, createAccess, content, computePlanHash, computeOverage, computeEntityAccess, computeAccessSet, checkFva, checkAddOnCompatibility, calculateBillingPeriod, authModels, WebhookHandlerConfig, WalletStore, WalletEntry, VertzException, ValidationException, UserTableEntry, UserStore, UnauthorizedException, TenantOverrides, TenantChainHop, TenantChain, SubscriptionStore, Subscription, StripeProduct, StripePrice, StripeClient, StripeBillingAdapterConfig, StoredSession, StoredPasswordReset, StoredEmailVerification, SignUpInput, SignInInput, SessionStrategy, SessionStore, SessionPayload, SessionInfo, SessionConfig, Session, ServiceUnavailableException, ServiceRequestInfo, ServiceDefinition, ServiceContext, ServiceConfig, ServiceActionDef, ServerInstance, ServerHandle, ServerConfig, ServerAdapter, RuleContext, RoleAssignmentTableEntry, RoleAssignmentStore, RoleAssignment, ResourceRef, Resource, RequestInfo, RelationExposeConfig, RawRequest, RateLimitStore, RateLimitResult, RateLimitConfig, PriceInterval, PlanVersionStore, PlanVersionInfo, PlanSnapshot, PlanPrice, PlanHashInput, PlanDef, Period, PasswordResetStore, PasswordResetConfig, PasswordRequirements, ParentRef, OverrideStore, OverageInput, OverageConfig, OnUserCreatedPayload, OAuthUserInfo, OAuthTokens, OAuthProviderConfig, OAuthProvider, OAuthAccountStore, NotFoundException, NamedMiddlewareDef, MiddlewareDef, MfaSetupData, MfaConfig, MfaChallengeData, MFAStore, ListenOptions, ListResult, ListOptions2 as ListOptions, LimitOverrideDef, LimitOverride, LimitDef, InternalServerErrorException, InferSchema, Infer2 as Infer, InMemoryWalletStore, InMemoryUserStore, InMemorySubscriptionStore, InMemorySessionStore, InMemoryRoleAssignmentStore, InMemoryRateLimitStore, InMemoryPlanVersionStore, InMemoryPasswordResetStore, InMemoryOverrideStore, InMemoryOAuthAccountStore, InMemoryMFAStore, InMemoryGrandfatheringStore, InMemoryFlagStore, InMemoryEmailVerificationStore, InMemoryClosureStore, HttpStatusCode, HttpMethod, HandlerCtx, GrandfatheringStore, GrandfatheringState, ForbiddenException, FlagStore, ExposeConfig, EnvConfig, EntityRouteOptions, EntityRelationsConfig, EntityRegistry, EntityOperations, EntityErrorResult, EntityDefinition, EntityDef, EntityDbAdapter2 as EntityDbAdapter, EntityContext, EntityConfig, EntityActionDef, EntitlementValue, EntitlementRegistry, EntitlementDefinition, EntitlementDef, Entitlement, EncodedAccessSet, EmailVerificationStore, EmailVerificationConfig, EmailPasswordConfig, Deps, DenialReason, DenialMeta, DefineAccessInput, DeepReadonly, DbUserStore, DbSubscriptionStore, DbSessionStore, DbRoleAssignmentStore, DbOAuthAccountStore, DbFlagStore, DbDialectName, DbClosureStore, Ctx, CrudResult, CrudHandlers, CorsConfig, CookieConfig, ContentDescriptor, ConsumeResult, ConflictException, ComputeAccessSetConfig, ClosureStore, ClosureRow, ClosureEntry, BillingPeriod, BillingEventType, BillingEventHandler, BillingEventEmitter, BillingEvent, BillingAdapter, BaseContext, BadRequestException, AuthorizationError, AuthUser, AuthTokens, AuthInstance, AuthEntityProxy, AuthDbClient, AuthContext, AuthConfig, AuthCallbackContext, AuthApi, AppConfig2 as AppConfig, AppBuilder2 as AppBuilder, AddOnRequires, AclClaim, AccumulateProvides, AccessWsData, AccessSet, AccessRule2 as AccessRule, AccessInstance, AccessEventBroadcasterConfig, AccessEventBroadcaster, AccessEvent, AccessDefinition, AccessContextConfig, AccessContext, AccessConfig, AccessCheckResult, AccessCheckData, AUTH_TABLE_NAMES };
package/dist/index.js CHANGED
@@ -5992,6 +5992,15 @@ function applySelect(select, data) {
5992
5992
  }
5993
5993
  return result;
5994
5994
  }
5995
+ function nullGuardedFields(nulledFields, data) {
5996
+ if (nulledFields.size === 0)
5997
+ return data;
5998
+ const result = {};
5999
+ for (const [key, value] of Object.entries(data)) {
6000
+ result[key] = nulledFields.has(key) ? null : value;
6001
+ }
6002
+ return result;
6003
+ }
5995
6004
  function stripReadOnlyFields(table, data) {
5996
6005
  const excludedKeys = new Set;
5997
6006
  for (const key of Object.keys(table._columns)) {
@@ -6241,6 +6250,10 @@ function createCrudHandlers(def, db, options) {
6241
6250
  const tenantChain = options?.tenantChain ?? def.tenantChain ?? null;
6242
6251
  const isIndirectlyScoped = tenantChain !== null;
6243
6252
  const queryParentIds = options?.queryParentIds ?? null;
6253
+ const exposeSelect = def.expose?.select ? {
6254
+ ...def.expose.select,
6255
+ ...Object.fromEntries(Object.entries(def.expose.include ?? {}).filter(([, v]) => v !== false).map(([k]) => [k, true]))
6256
+ } : undefined;
6244
6257
  function notFound(id) {
6245
6258
  return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
6246
6259
  }
@@ -6311,7 +6324,7 @@ function createCrudHandlers(def, db, options) {
6311
6324
  const orderBy = options2?.orderBy;
6312
6325
  const include = options2?.include;
6313
6326
  const { data: rows, total } = await db.list({ where, orderBy, limit, after, include });
6314
- const data = rows.map((row) => narrowRelationFields(def.relations, stripHiddenFields(table, row)));
6327
+ const data = rows.map((row) => applySelect(exposeSelect, narrowRelationFields(def.expose?.include ?? {}, stripHiddenFields(table, row))));
6315
6328
  const pkColumn = resolvePrimaryKeyColumn(table);
6316
6329
  const lastRow = rows[rows.length - 1];
6317
6330
  const nextCursor = limit > 0 && rows.length === limit && lastRow ? String(lastRow[pkColumn]) : null;
@@ -6333,7 +6346,7 @@ function createCrudHandlers(def, db, options) {
6333
6346
  return err4(accessResult.error);
6334
6347
  return ok4({
6335
6348
  status: 200,
6336
- body: narrowRelationFields(def.relations, stripHiddenFields(table, row))
6349
+ body: applySelect(exposeSelect, narrowRelationFields(def.expose?.include ?? {}, stripHiddenFields(table, row)))
6337
6350
  });
6338
6351
  },
6339
6352
  async create(ctx, data) {
@@ -6376,7 +6389,7 @@ function createCrudHandlers(def, db, options) {
6376
6389
  }
6377
6390
  return ok4({
6378
6391
  status: 201,
6379
- body: narrowRelationFields(def.relations, strippedResult)
6392
+ body: applySelect(exposeSelect, narrowRelationFields(def.expose?.include ?? {}, strippedResult))
6380
6393
  });
6381
6394
  },
6382
6395
  async update(ctx, id, data) {
@@ -6405,7 +6418,7 @@ function createCrudHandlers(def, db, options) {
6405
6418
  }
6406
6419
  return ok4({
6407
6420
  status: 200,
6408
- body: narrowRelationFields(def.relations, strippedResult)
6421
+ body: applySelect(exposeSelect, narrowRelationFields(def.expose?.include ?? {}, strippedResult))
6409
6422
  });
6410
6423
  },
6411
6424
  async delete(ctx, id) {
@@ -6493,7 +6506,101 @@ function entityErrorHandler(error) {
6493
6506
  };
6494
6507
  }
6495
6508
 
6509
+ // src/entity/expose-evaluator.ts
6510
+ async function evaluateExposeRule(rule, ctx, options) {
6511
+ switch (rule.type) {
6512
+ case "public":
6513
+ return true;
6514
+ case "authenticated":
6515
+ return ctx.authenticated();
6516
+ case "role":
6517
+ return ctx.role(...rule.roles);
6518
+ case "entitlement": {
6519
+ if (!options.can)
6520
+ return false;
6521
+ return options.can(rule.entitlement);
6522
+ }
6523
+ case "where":
6524
+ return false;
6525
+ case "all": {
6526
+ for (const sub of rule.rules) {
6527
+ if (!await evaluateExposeRule(sub, ctx, options))
6528
+ return false;
6529
+ }
6530
+ return true;
6531
+ }
6532
+ case "any": {
6533
+ for (const sub of rule.rules) {
6534
+ if (await evaluateExposeRule(sub, ctx, options))
6535
+ return true;
6536
+ }
6537
+ return false;
6538
+ }
6539
+ case "fva": {
6540
+ if (options.fvaAge === undefined)
6541
+ return false;
6542
+ return options.fvaAge <= rule.maxAge;
6543
+ }
6544
+ }
6545
+ }
6546
+ async function evaluateExposeDescriptors(expose, ctx, options = {}) {
6547
+ const allowedSelectFields = new Set;
6548
+ const nulledFields = new Set;
6549
+ const allowedWhereFields = new Set;
6550
+ const allowedOrderByFields = new Set;
6551
+ for (const [field, value] of Object.entries(expose.select)) {
6552
+ if (value === true) {
6553
+ allowedSelectFields.add(field);
6554
+ } else {
6555
+ const passed = await evaluateExposeRule(value, ctx, options);
6556
+ if (passed) {
6557
+ allowedSelectFields.add(field);
6558
+ } else {
6559
+ allowedSelectFields.add(field);
6560
+ nulledFields.add(field);
6561
+ }
6562
+ }
6563
+ }
6564
+ if (expose.allowWhere) {
6565
+ for (const [field, value] of Object.entries(expose.allowWhere)) {
6566
+ if (value === true) {
6567
+ allowedWhereFields.add(field);
6568
+ } else {
6569
+ const passed = await evaluateExposeRule(value, ctx, options);
6570
+ if (passed) {
6571
+ allowedWhereFields.add(field);
6572
+ }
6573
+ }
6574
+ }
6575
+ }
6576
+ if (expose.allowOrderBy) {
6577
+ for (const [field, value] of Object.entries(expose.allowOrderBy)) {
6578
+ if (value === true) {
6579
+ allowedOrderByFields.add(field);
6580
+ } else {
6581
+ const passed = await evaluateExposeRule(value, ctx, options);
6582
+ if (passed) {
6583
+ allowedOrderByFields.add(field);
6584
+ }
6585
+ }
6586
+ }
6587
+ }
6588
+ return {
6589
+ allowedSelectFields,
6590
+ nulledFields,
6591
+ allowedWhereFields,
6592
+ allowedOrderByFields
6593
+ };
6594
+ }
6595
+
6496
6596
  // src/entity/vertzql-parser.ts
6597
+ function extractAllowKeys(allow) {
6598
+ if (!allow)
6599
+ return [];
6600
+ if (Array.isArray(allow))
6601
+ return allow;
6602
+ return Object.keys(allow);
6603
+ }
6497
6604
  var MAX_LIMIT = 1000;
6498
6605
  var MAX_Q_BASE64_LENGTH = 10240;
6499
6606
  var ALLOWED_Q_KEYS = new Set(["select", "include", "where", "orderBy", "limit", "offset"]);
@@ -6579,30 +6686,50 @@ function getHiddenColumns(table) {
6579
6686
  }
6580
6687
  return hidden;
6581
6688
  }
6582
- function validateVertzQL(options, table, relationsConfig) {
6689
+ function validateVertzQL(options, table, relationsConfig, exposeConfig, evaluatedExpose) {
6583
6690
  if (options._qError) {
6584
6691
  return { ok: false, error: options._qError };
6585
6692
  }
6586
6693
  const hiddenColumns = getHiddenColumns(table);
6587
6694
  if (options.where) {
6695
+ const allowWhereSet = evaluatedExpose ? evaluatedExpose.allowedWhereFields : null;
6696
+ const allowWhereKeys = !evaluatedExpose && exposeConfig ? extractAllowKeys(exposeConfig.allowWhere) : null;
6588
6697
  for (const field of Object.keys(options.where)) {
6589
6698
  if (hiddenColumns.has(field)) {
6590
6699
  return { ok: false, error: `Field "${field}" is not filterable` };
6591
6700
  }
6701
+ if (allowWhereSet !== null && !allowWhereSet.has(field)) {
6702
+ return { ok: false, error: `Field "${field}" is not filterable` };
6703
+ }
6704
+ if (allowWhereKeys !== null && !allowWhereKeys.includes(field)) {
6705
+ return { ok: false, error: `Field "${field}" is not filterable` };
6706
+ }
6592
6707
  }
6593
6708
  }
6594
6709
  if (options.orderBy) {
6710
+ const allowOrderBySet = evaluatedExpose ? evaluatedExpose.allowedOrderByFields : null;
6711
+ const allowOrderByKeys = !evaluatedExpose && exposeConfig ? extractAllowKeys(exposeConfig.allowOrderBy) : null;
6595
6712
  for (const field of Object.keys(options.orderBy)) {
6596
6713
  if (hiddenColumns.has(field)) {
6597
6714
  return { ok: false, error: `Field "${field}" is not sortable` };
6598
6715
  }
6716
+ if (allowOrderBySet !== null && !allowOrderBySet.has(field)) {
6717
+ return { ok: false, error: `Field "${field}" is not sortable` };
6718
+ }
6719
+ if (allowOrderByKeys !== null && !allowOrderByKeys.includes(field)) {
6720
+ return { ok: false, error: `Field "${field}" is not sortable` };
6721
+ }
6599
6722
  }
6600
6723
  }
6601
6724
  if (options.select) {
6725
+ const exposeSelectKeys = exposeConfig ? extractAllowKeys(exposeConfig.select) : null;
6602
6726
  for (const field of Object.keys(options.select)) {
6603
6727
  if (hiddenColumns.has(field)) {
6604
6728
  return { ok: false, error: `Field "${field}" is not selectable` };
6605
6729
  }
6730
+ if (exposeSelectKeys !== null && exposeSelectKeys.length > 0 && !exposeSelectKeys.includes(field)) {
6731
+ return { ok: false, error: `Field "${field}" is not selectable` };
6732
+ }
6606
6733
  }
6607
6734
  }
6608
6735
  if (options.include && relationsConfig) {
@@ -6623,35 +6750,37 @@ function validateInclude(include, relationsConfig, pathPrefix) {
6623
6750
  continue;
6624
6751
  const configObj = typeof entityConfig === "object" ? entityConfig : undefined;
6625
6752
  if (requested.where) {
6626
- if (!configObj || !configObj.allowWhere || configObj.allowWhere.length === 0) {
6753
+ const allowWhereKeys = extractAllowKeys(configObj?.allowWhere);
6754
+ if (!configObj || allowWhereKeys.length === 0) {
6627
6755
  return {
6628
6756
  ok: false,
6629
6757
  error: `Filtering is not enabled on relation '${relationPath}'. ` + "Add 'allowWhere' to the entity relations config."
6630
6758
  };
6631
6759
  }
6632
- const allowedSet = new Set(configObj.allowWhere);
6760
+ const allowedSet = new Set(allowWhereKeys);
6633
6761
  for (const field of Object.keys(requested.where)) {
6634
6762
  if (!allowedSet.has(field)) {
6635
6763
  return {
6636
6764
  ok: false,
6637
- error: `Field '${field}' is not filterable on relation '${relationPath}'. ` + `Allowed: ${configObj.allowWhere.join(", ")}`
6765
+ error: `Field '${field}' is not filterable on relation '${relationPath}'. ` + `Allowed: ${allowWhereKeys.join(", ")}`
6638
6766
  };
6639
6767
  }
6640
6768
  }
6641
6769
  }
6642
6770
  if (requested.orderBy) {
6643
- if (!configObj || !configObj.allowOrderBy || configObj.allowOrderBy.length === 0) {
6771
+ const allowOrderByKeys = extractAllowKeys(configObj?.allowOrderBy);
6772
+ if (!configObj || allowOrderByKeys.length === 0) {
6644
6773
  return {
6645
6774
  ok: false,
6646
6775
  error: `Sorting is not enabled on relation '${relationPath}'. ` + "Add 'allowOrderBy' to the entity relations config."
6647
6776
  };
6648
6777
  }
6649
- const allowedSet = new Set(configObj.allowOrderBy);
6778
+ const allowedSet = new Set(allowOrderByKeys);
6650
6779
  for (const [field, dir] of Object.entries(requested.orderBy)) {
6651
6780
  if (!allowedSet.has(field)) {
6652
6781
  return {
6653
6782
  ok: false,
6654
- error: `Field '${field}' is not sortable on relation '${relationPath}'. ` + `Allowed: ${configObj.allowOrderBy.join(", ")}`
6783
+ error: `Field '${field}' is not sortable on relation '${relationPath}'. ` + `Allowed: ${allowOrderByKeys.join(", ")}`
6655
6784
  };
6656
6785
  }
6657
6786
  if (dir !== "asc" && dir !== "desc") {
@@ -6718,10 +6847,27 @@ function extractRequestInfo(ctx) {
6718
6847
  function getParams(ctx) {
6719
6848
  return ctx.params ?? {};
6720
6849
  }
6850
+ function hasDescriptorValues(record) {
6851
+ if (!record)
6852
+ return false;
6853
+ return Object.values(record).some((v) => v !== true);
6854
+ }
6855
+ function hasDescriptors(expose) {
6856
+ return hasDescriptorValues(expose.select) || hasDescriptorValues(expose.allowWhere) || hasDescriptorValues(expose.allowOrderBy);
6857
+ }
6858
+ function applyNulling(data, nulledFields) {
6859
+ return nullGuardedFields(nulledFields, data);
6860
+ }
6721
6861
  function generateEntityRoutes(def, registry, db, options) {
6722
6862
  const prefix = options?.apiPrefix ?? "/api";
6723
6863
  const basePath = `${prefix}/${def.name}`;
6724
6864
  const tenantChain = options?.tenantChain ?? null;
6865
+ const exposeValidation = def.expose ? {
6866
+ select: def.expose.select,
6867
+ allowWhere: def.expose.allowWhere,
6868
+ allowOrderBy: def.expose.allowOrderBy
6869
+ } : undefined;
6870
+ const exposeEvalConfig = def.expose ? hasDescriptors(def.expose) ? def.expose : null : null;
6725
6871
  const crudHandlers = createCrudHandlers(def, db, {
6726
6872
  tenantChain,
6727
6873
  queryParentIds: options?.queryParentIds
@@ -6755,8 +6901,9 @@ function generateEntityRoutes(def, registry, db, options) {
6755
6901
  const entityCtx = makeEntityCtx(ctx);
6756
6902
  const query = ctx.query ?? {};
6757
6903
  const parsed = parseVertzQL(query);
6758
- const relationsConfig = def.relations;
6759
- const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
6904
+ const evaluated = exposeEvalConfig ? await evaluateExposeDescriptors(exposeEvalConfig, entityCtx) : null;
6905
+ const relationsConfig = def.expose?.include ?? {};
6906
+ const validation = validateVertzQL(parsed, def.model.table, relationsConfig, exposeValidation, evaluated ?? undefined);
6760
6907
  if (!validation.ok) {
6761
6908
  return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
6762
6909
  }
@@ -6772,6 +6919,9 @@ function generateEntityRoutes(def, registry, db, options) {
6772
6919
  const { status, body } = entityErrorHandler(result.error);
6773
6920
  return jsonResponse(body, status);
6774
6921
  }
6922
+ if (evaluated && evaluated.nulledFields.size > 0 && result.data.body.items) {
6923
+ result.data.body.items = result.data.body.items.map((row) => applyNulling(row, evaluated.nulledFields));
6924
+ }
6775
6925
  if (parsed.select && result.data.body.items) {
6776
6926
  result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
6777
6927
  }
@@ -6798,8 +6948,9 @@ function generateEntityRoutes(def, registry, db, options) {
6798
6948
  select: body.select,
6799
6949
  include: body.include
6800
6950
  };
6801
- const relationsConfig = def.relations;
6802
- const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
6951
+ const evaluated = exposeEvalConfig ? await evaluateExposeDescriptors(exposeEvalConfig, entityCtx) : null;
6952
+ const relationsConfig = def.expose?.include ?? {};
6953
+ const validation = validateVertzQL(parsed, def.model.table, relationsConfig, exposeValidation, evaluated ?? undefined);
6803
6954
  if (!validation.ok) {
6804
6955
  return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
6805
6956
  }
@@ -6815,6 +6966,9 @@ function generateEntityRoutes(def, registry, db, options) {
6815
6966
  const { status, body: errBody } = entityErrorHandler(result.error);
6816
6967
  return jsonResponse(errBody, status);
6817
6968
  }
6969
+ if (evaluated && evaluated.nulledFields.size > 0 && result.data.body.items) {
6970
+ result.data.body.items = result.data.body.items.map((row) => applyNulling(row, evaluated.nulledFields));
6971
+ }
6818
6972
  if (parsed.select && result.data.body.items) {
6819
6973
  result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
6820
6974
  }
@@ -6848,8 +7002,9 @@ function generateEntityRoutes(def, registry, db, options) {
6848
7002
  const id = getParams(ctx).id;
6849
7003
  const query = ctx.query ?? {};
6850
7004
  const parsed = parseVertzQL(query);
6851
- const relationsConfig = def.relations;
6852
- const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
7005
+ const evaluated = exposeEvalConfig ? await evaluateExposeDescriptors(exposeEvalConfig, entityCtx) : null;
7006
+ const relationsConfig = def.expose?.include ?? {};
7007
+ const validation = validateVertzQL(parsed, def.model.table, relationsConfig, exposeValidation, evaluated ?? undefined);
6853
7008
  if (!validation.ok) {
6854
7009
  return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
6855
7010
  }
@@ -6859,7 +7014,11 @@ function generateEntityRoutes(def, registry, db, options) {
6859
7014
  const { status, body: body2 } = entityErrorHandler(result.error);
6860
7015
  return jsonResponse(body2, status);
6861
7016
  }
6862
- const body = parsed.select ? applySelect(parsed.select, result.data.body) : result.data.body;
7017
+ let responseBody = result.data.body;
7018
+ if (evaluated && evaluated.nulledFields.size > 0) {
7019
+ responseBody = applyNulling(responseBody, evaluated.nulledFields);
7020
+ }
7021
+ const body = parsed.select ? applySelect(parsed.select, responseBody) : responseBody;
6863
7022
  return jsonResponse(body, result.data.status);
6864
7023
  } catch (error) {
6865
7024
  const { status, body } = entityErrorHandler(error);
@@ -6894,7 +7053,14 @@ function generateEntityRoutes(def, registry, db, options) {
6894
7053
  const { status, body } = entityErrorHandler(result.error);
6895
7054
  return jsonResponse(body, status);
6896
7055
  }
6897
- return jsonResponse(result.data.body, result.data.status);
7056
+ let responseBody = result.data.body;
7057
+ if (exposeEvalConfig) {
7058
+ const evaluated = await evaluateExposeDescriptors(exposeEvalConfig, entityCtx);
7059
+ if (evaluated.nulledFields.size > 0) {
7060
+ responseBody = applyNulling(responseBody, evaluated.nulledFields);
7061
+ }
7062
+ }
7063
+ return jsonResponse(responseBody, result.data.status);
6898
7064
  } catch (error) {
6899
7065
  const { status, body } = entityErrorHandler(error);
6900
7066
  return jsonResponse(body, status);
@@ -6929,7 +7095,14 @@ function generateEntityRoutes(def, registry, db, options) {
6929
7095
  const { status, body } = entityErrorHandler(result.error);
6930
7096
  return jsonResponse(body, status);
6931
7097
  }
6932
- return jsonResponse(result.data.body, result.data.status);
7098
+ let responseBody = result.data.body;
7099
+ if (exposeEvalConfig) {
7100
+ const evaluated = await evaluateExposeDescriptors(exposeEvalConfig, entityCtx);
7101
+ if (evaluated.nulledFields.size > 0) {
7102
+ responseBody = applyNulling(responseBody, evaluated.nulledFields);
7103
+ }
7104
+ }
7105
+ return jsonResponse(responseBody, result.data.status);
6933
7106
  } catch (error) {
6934
7107
  const { status, body } = entityErrorHandler(error);
6935
7108
  return jsonResponse(body, status);
@@ -7447,7 +7620,7 @@ function entity(name, config) {
7447
7620
  before: config.before ?? {},
7448
7621
  after: config.after ?? {},
7449
7622
  actions: config.actions ?? {},
7450
- relations: config.relations ?? {},
7623
+ expose: config.expose,
7451
7624
  table: config.table ?? name,
7452
7625
  tenantScoped,
7453
7626
  tenantChain: null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertz/server",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Vertz server runtime — modules, routing, and auth",
@@ -31,10 +31,10 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@vertz/core": "^0.2.15",
35
- "@vertz/db": "^0.2.15",
36
- "@vertz/errors": "^0.2.15",
37
- "@vertz/schema": "^0.2.15",
34
+ "@vertz/core": "^0.2.16",
35
+ "@vertz/db": "^0.2.16",
36
+ "@vertz/errors": "^0.2.16",
37
+ "@vertz/schema": "^0.2.16",
38
38
  "bcryptjs": "^3.0.3",
39
39
  "jose": "^6.0.11"
40
40
  },