@vertz/server 0.2.11 → 0.2.13

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 +76 -0
  2. package/dist/index.d.ts +1001 -156
  3. package/dist/index.js +3491 -512
  4. package/package.json +6 -6
package/dist/index.d.ts CHANGED
@@ -1,167 +1,315 @@
1
1
  import { AccumulateProvides, AppBuilder as AppBuilder2, AppConfig as AppConfig2, CorsConfig, Ctx, DeepReadonly, Deps, EnvConfig, HandlerCtx, HttpMethod, HttpStatusCode, Infer, InferSchema, ListenOptions, MiddlewareDef, NamedMiddlewareDef, RawRequest, ServerAdapter, ServerHandle } from "@vertz/core";
2
2
  import { BadRequestException, ConflictException, createEnv, createImmutableProxy, createMiddleware, deepFreeze, ForbiddenException, InternalServerErrorException, makeImmutable, NotFoundException, ServiceUnavailableException, UnauthorizedException, ValidationException, VertzException, vertz } from "@vertz/core";
3
- import { ModelDef as ModelDef2, RelationDef, SchemaLike, TableDef } from "@vertz/db";
4
- import { ModelDef } from "@vertz/db";
5
- import { EntityDbAdapter, ListOptions } from "@vertz/db";
6
- import { EntityError, Result } from "@vertz/errors";
7
- import { EntityDbAdapter as EntityDbAdapter2, ListOptions as ListOptions2 } from "@vertz/db";
8
- interface ListResult<T = Record<string, unknown>> {
9
- items: T[];
10
- total: number;
11
- limit: number;
12
- nextCursor: string | null;
13
- hasNextPage: boolean;
3
+ import { ModelEntry } from "@vertz/db";
4
+ import { AuthError, Result } from "@vertz/errors";
5
+ /**
6
+ * defineAccess() Phase 6 RBAC & Access Control configuration
7
+ *
8
+ * Replaces createAccess() with hierarchical RBAC: resources form trees,
9
+ * roles inherit down the hierarchy, and entitlements map to roles/plans/flags.
10
+ */
11
+ /** Denial reasons ordered by actionability (most actionable first) */
12
+ type DenialReason = "plan_required" | "role_required" | "limit_reached" | "flag_disabled" | "hierarchy_denied" | "step_up_required" | "not_authenticated";
13
+ /** Metadata attached to denial reasons */
14
+ interface DenialMeta {
15
+ requiredPlans?: string[];
16
+ requiredRoles?: string[];
17
+ disabledFlags?: string[];
18
+ limit?: {
19
+ max: number;
20
+ consumed: number;
21
+ remaining: number;
22
+ };
23
+ fvaMaxAge?: number;
14
24
  }
15
- interface CrudResult<T = unknown> {
16
- status: number;
17
- body: T;
25
+ /** Result of a full access check (all layers evaluated) */
26
+ interface AccessCheckResult {
27
+ allowed: boolean;
28
+ reasons: DenialReason[];
29
+ reason?: DenialReason;
30
+ meta?: DenialMeta;
18
31
  }
19
- interface CrudHandlers {
20
- list(ctx: EntityContext, options?: ListOptions): Promise<Result<CrudResult<ListResult>, EntityError>>;
21
- get(ctx: EntityContext, id: string): Promise<Result<CrudResult<Record<string, unknown>>, EntityError>>;
22
- create(ctx: EntityContext, data: Record<string, unknown>): Promise<Result<CrudResult<Record<string, unknown>>, EntityError>>;
23
- update(ctx: EntityContext, id: string, data: Record<string, unknown>): Promise<Result<CrudResult<Record<string, unknown>>, EntityError>>;
24
- delete(ctx: EntityContext, id: string): Promise<Result<CrudResult<null>, EntityError>>;
32
+ /** Entitlement definition — maps to roles, optional plans and flags */
33
+ interface EntitlementDef {
34
+ roles: string[];
35
+ plans?: string[];
36
+ flags?: string[];
25
37
  }
26
- declare function createCrudHandlers(def: EntityDefinition, db: EntityDbAdapter): CrudHandlers;
38
+ /** Billing period for plan limits */
39
+ type BillingPeriod = "month" | "day" | "hour";
40
+ /** Limit definition within a plan */
41
+ interface LimitDef {
42
+ per: BillingPeriod;
43
+ max: number;
44
+ }
45
+ /** Plan definition — which entitlements are included and their usage limits */
46
+ interface PlanDef {
47
+ entitlements: readonly string[] | string[];
48
+ limits?: Record<string, LimitDef>;
49
+ }
50
+ /** The input config for defineAccess() */
51
+ interface DefineAccessInput {
52
+ hierarchy: string[];
53
+ roles: Record<string, string[]>;
54
+ inheritance?: Record<string, Record<string, string>>;
55
+ entitlements: Record<string, EntitlementDef>;
56
+ plans?: Record<string, PlanDef>;
57
+ /** Fallback plan name when an org's plan expires. Defaults to 'free'. */
58
+ defaultPlan?: string;
59
+ }
60
+ /** The frozen config returned by defineAccess() */
61
+ interface AccessDefinition {
62
+ readonly hierarchy: readonly string[];
63
+ readonly roles: Readonly<Record<string, readonly string[]>>;
64
+ readonly inheritance: Readonly<Record<string, Readonly<Record<string, string>>>>;
65
+ readonly entitlements: Readonly<Record<string, Readonly<EntitlementDef>>>;
66
+ readonly plans?: Readonly<Record<string, Readonly<PlanDef>>>;
67
+ /** Fallback plan name when an org's plan expires. Defaults to 'free'. */
68
+ readonly defaultPlan?: string;
69
+ }
70
+ declare function defineAccess(input: DefineAccessInput): AccessDefinition;
27
71
  /**
28
- * EntityOperationstyped CRUD facade for a single entity.
72
+ * InMemoryClosureStoreclosure table for resource hierarchy.
29
73
  *
30
- * When used as `ctx.entity`, TModel fills in actual column types.
31
- * When used as `ctx.entities.*`, TModel defaults to `ModelDef` (loose typing).
74
+ * Stores ancestor/descendant relationships with depth tracking.
75
+ * Self-reference rows (depth 0) are created for every resource.
76
+ * Hierarchy depth is capped at 4 levels.
32
77
  */
33
- interface EntityOperations<TModel extends ModelDef = ModelDef> {
34
- get(id: string): Promise<TModel["table"]["$response"]>;
35
- list(options?: ListOptions2): Promise<ListResult<TModel["table"]["$response"]>>;
36
- create(data: TModel["table"]["$create_input"]): Promise<TModel["table"]["$response"]>;
37
- update(id: string, data: TModel["table"]["$update_input"]): Promise<TModel["table"]["$response"]>;
38
- delete(id: string): Promise<void>;
78
+ interface ClosureRow {
79
+ ancestorType: string;
80
+ ancestorId: string;
81
+ descendantType: string;
82
+ descendantId: string;
83
+ depth: number;
84
+ }
85
+ interface ClosureEntry {
86
+ type: string;
87
+ id: string;
88
+ depth: number;
89
+ }
90
+ interface ParentRef {
91
+ parentType: string;
92
+ parentId: string;
93
+ }
94
+ interface ClosureStore {
95
+ addResource(type: string, id: string, parent?: ParentRef): Promise<void>;
96
+ removeResource(type: string, id: string): Promise<void>;
97
+ getAncestors(type: string, id: string): Promise<ClosureEntry[]>;
98
+ getDescendants(type: string, id: string): Promise<ClosureEntry[]>;
99
+ hasPath(ancestorType: string, ancestorId: string, descendantType: string, descendantId: string): Promise<boolean>;
100
+ dispose(): void;
101
+ }
102
+ declare class InMemoryClosureStore implements ClosureStore {
103
+ private rows;
104
+ addResource(type: string, id: string, parent?: ParentRef): Promise<void>;
105
+ removeResource(type: string, id: string): Promise<void>;
106
+ getAncestors(type: string, id: string): Promise<ClosureEntry[]>;
107
+ getDescendants(type: string, id: string): Promise<ClosureEntry[]>;
108
+ hasPath(ancestorType: string, ancestorId: string, descendantType: string, descendantId: string): Promise<boolean>;
109
+ dispose(): void;
110
+ }
111
+ interface RoleAssignment {
112
+ userId: string;
113
+ resourceType: string;
114
+ resourceId: string;
115
+ role: string;
116
+ }
117
+ interface RoleAssignmentStore {
118
+ assign(userId: string, resourceType: string, resourceId: string, role: string): Promise<void>;
119
+ revoke(userId: string, resourceType: string, resourceId: string, role: string): Promise<void>;
120
+ getRoles(userId: string, resourceType: string, resourceId: string): Promise<string[]>;
121
+ getRolesForUser(userId: string): Promise<RoleAssignment[]>;
122
+ getEffectiveRole(userId: string, resourceType: string, resourceId: string, accessDef: AccessDefinition, closureStore: ClosureStore): Promise<string | null>;
123
+ dispose(): void;
124
+ }
125
+ declare class InMemoryRoleAssignmentStore implements RoleAssignmentStore {
126
+ private assignments;
127
+ assign(userId: string, resourceType: string, resourceId: string, role: string): Promise<void>;
128
+ revoke(userId: string, resourceType: string, resourceId: string, role: string): Promise<void>;
129
+ getRoles(userId: string, resourceType: string, resourceId: string): Promise<string[]>;
130
+ getRolesForUser(userId: string): Promise<RoleAssignment[]>;
131
+ /**
132
+ * Compute the effective role for a user on a resource.
133
+ * Considers direct assignments + inherited roles from ancestors.
134
+ * Most permissive role wins (additive model).
135
+ */
136
+ getEffectiveRole(userId: string, resourceType: string, resourceId: string, accessDef: AccessDefinition, closureStore: ClosureStore): Promise<string | null>;
137
+ dispose(): void;
39
138
  }
40
- /** Extracts the model type from an EntityDefinition */
41
- type ExtractModel<T> = T extends EntityDefinition<infer M> ? M : ModelDef2;
42
139
  /**
43
- * Maps an inject config `{ key: EntityDefinition<TModel> }` to
44
- * `{ key: EntityOperations<TModel> }` for typed ctx.entities access.
140
+ * Feature Flag Store per-tenant boolean feature flags.
141
+ *
142
+ * Pluggable interface with in-memory default.
143
+ * Used by Layer 1 of access context to gate entitlements on feature flags.
45
144
  */
46
- type InjectToOperations<TInject extends Record<string, EntityDefinition> = {}> = { readonly [K in keyof TInject] : EntityOperations<ExtractModel<TInject[K]>> };
47
- interface BaseContext {
48
- readonly userId: string | null;
49
- authenticated(): boolean;
50
- tenant(): boolean;
51
- role(...roles: string[]): boolean;
145
+ interface FlagStore {
146
+ setFlag(orgId: string, flag: string, enabled: boolean): void;
147
+ getFlag(orgId: string, flag: string): boolean;
148
+ getFlags(orgId: string): Record<string, boolean>;
52
149
  }
53
- interface EntityContext<
54
- TModel extends ModelDef2 = ModelDef2,
55
- TInject extends Record<string, EntityDefinition> = {}
56
- > extends BaseContext {
57
- /** Typed CRUD on the current entity */
58
- readonly entity: EntityOperations<TModel>;
59
- /** Typed access to injected entities only */
60
- readonly entities: InjectToOperations<TInject>;
61
- }
62
- type AccessRule = false | ((ctx: BaseContext, row: Record<string, unknown>) => boolean | Promise<boolean>);
63
- interface EntityBeforeHooks<
64
- TCreateInput = unknown,
65
- TUpdateInput = unknown
66
- > {
67
- readonly create?: (data: TCreateInput, ctx: EntityContext) => TCreateInput | Promise<TCreateInput>;
68
- readonly update?: (data: TUpdateInput, ctx: EntityContext) => TUpdateInput | Promise<TUpdateInput>;
150
+ declare class InMemoryFlagStore implements FlagStore {
151
+ private flags;
152
+ setFlag(orgId: string, flag: string, enabled: boolean): void;
153
+ getFlag(orgId: string, flag: string): boolean;
154
+ getFlags(orgId: string): Record<string, boolean>;
69
155
  }
70
- interface EntityAfterHooks<TResponse = unknown> {
71
- readonly create?: (result: TResponse, ctx: EntityContext) => void | Promise<void>;
72
- readonly update?: (prev: TResponse, next: TResponse, ctx: EntityContext) => void | Promise<void>;
73
- readonly delete?: (row: TResponse, ctx: EntityContext) => void | Promise<void>;
156
+ /**
157
+ * PlanStore org-level plan assignments with overrides.
158
+ *
159
+ * Stores which plan an organization is on, when it started,
160
+ * optional expiration, and per-customer limit overrides.
161
+ */
162
+ /** Per-customer limit override. Only affects the cap, not the billing period. */
163
+ interface LimitOverride {
164
+ max: number;
74
165
  }
75
- interface EntityActionDef<
76
- TInput = unknown,
77
- TOutput = unknown,
78
- TResponse = unknown,
79
- TCtx extends EntityContext = EntityContext
80
- > {
81
- readonly method?: string;
82
- readonly path?: string;
83
- readonly body: SchemaLike<TInput>;
84
- readonly response: SchemaLike<TOutput>;
85
- readonly handler: (input: TInput, ctx: TCtx, row: TResponse | null) => Promise<TOutput>;
166
+ interface OrgPlan {
167
+ orgId: string;
168
+ planId: string;
169
+ startedAt: Date;
170
+ expiresAt: Date | null;
171
+ overrides: Record<string, LimitOverride>;
86
172
  }
87
- /** Extract column keys from a RelationDef's target table. */
88
- type RelationColumnKeys<R> = R extends RelationDef<infer TTarget> ? TTarget extends TableDef<infer TCols> ? Extract<keyof TCols, string> : string : string;
89
- type EntityRelationsConfig<TRelations extends Record<string, RelationDef> = Record<string, RelationDef>> = { [K in keyof TRelations]? : true | false | { [F in RelationColumnKeys<TRelations[K]>]? : true } };
90
- interface EntityConfig<
91
- TModel extends ModelDef2 = ModelDef2,
92
- TActions extends Record<string, EntityActionDef<any, any, any, any>> = {},
93
- TInject extends Record<string, EntityDefinition> = {}
94
- > {
95
- readonly model: TModel;
96
- readonly inject?: TInject;
97
- readonly access?: Partial<Record<"list" | "get" | "create" | "update" | "delete" | Extract<keyof NoInfer<TActions>, string>, AccessRule>>;
98
- readonly before?: {
99
- readonly create?: (data: TModel["table"]["$create_input"], ctx: EntityContext<TModel, TInject>) => TModel["table"]["$create_input"] | Promise<TModel["table"]["$create_input"]>;
100
- readonly update?: (data: TModel["table"]["$update_input"], ctx: EntityContext<TModel, TInject>) => TModel["table"]["$update_input"] | Promise<TModel["table"]["$update_input"]>;
101
- };
102
- readonly after?: {
103
- readonly create?: (result: TModel["table"]["$response"], ctx: EntityContext<TModel, TInject>) => void | Promise<void>;
104
- readonly update?: (prev: TModel["table"]["$response"], next: TModel["table"]["$response"], ctx: EntityContext<TModel, TInject>) => void | Promise<void>;
105
- readonly delete?: (row: TModel["table"]["$response"], ctx: EntityContext<TModel, TInject>) => void | Promise<void>;
106
- };
107
- readonly actions?: { readonly [K in keyof TActions] : TActions[K] };
108
- readonly relations?: EntityRelationsConfig<TModel["relations"]>;
173
+ interface PlanStore {
174
+ /**
175
+ * Assign a plan to an org. Resets per-customer overrides (overrides are plan-specific).
176
+ * To preserve overrides across plan changes, re-apply them after calling assignPlan().
177
+ */
178
+ assignPlan(orgId: string, planId: string, startedAt?: Date, expiresAt?: Date | null): Promise<void>;
179
+ getPlan(orgId: string): Promise<OrgPlan | null>;
180
+ updateOverrides(orgId: string, overrides: Record<string, LimitOverride>): Promise<void>;
181
+ removePlan(orgId: string): Promise<void>;
182
+ dispose(): void;
109
183
  }
110
- interface EntityDefinition<TModel extends ModelDef2 = ModelDef2> {
111
- readonly kind: "entity";
112
- readonly name: string;
113
- readonly model: TModel;
114
- readonly inject: Record<string, EntityDefinition>;
115
- readonly access: Partial<Record<string, AccessRule>>;
116
- readonly before: EntityBeforeHooks;
117
- readonly after: EntityAfterHooks;
118
- readonly actions: Record<string, EntityActionDef>;
119
- readonly relations: EntityRelationsConfig<TModel["relations"]>;
184
+ declare class InMemoryPlanStore implements PlanStore {
185
+ private plans;
186
+ assignPlan(orgId: string, planId: string, startedAt?: Date, expiresAt?: Date | null): Promise<void>;
187
+ getPlan(orgId: string): Promise<OrgPlan | null>;
188
+ updateOverrides(orgId: string, overrides: Record<string, LimitOverride>): Promise<void>;
189
+ removePlan(orgId: string): Promise<void>;
190
+ dispose(): void;
120
191
  }
121
- import { SchemaLike as SchemaLike2 } from "@vertz/db";
122
- /** Extracts the model type from an EntityDefinition */
123
- type ExtractModel2<T> = T extends EntityDefinition<infer M> ? M : never;
124
192
  /**
125
- * Maps an inject config `{ key: EntityDefinition<TModel> }` to
126
- * `{ key: EntityOperations<TModel> }` for typed ctx.entities access.
193
+ * WalletStore consumption tracking for plan-limited entitlements.
194
+ *
195
+ * Tracks per-org usage within billing periods with atomic
196
+ * check-and-increment operations.
127
197
  */
128
- type InjectToOperations2<TInject extends Record<string, EntityDefinition> = {}> = { readonly [K in keyof TInject] : EntityOperations<ExtractModel2<TInject[K]>> };
129
- interface ActionContext<TInject extends Record<string, EntityDefinition> = {}> extends BaseContext {
130
- /** Typed access to injected entities only */
131
- readonly entities: InjectToOperations2<TInject>;
198
+ interface WalletEntry {
199
+ orgId: string;
200
+ entitlement: string;
201
+ periodStart: Date;
202
+ periodEnd: Date;
203
+ consumed: number;
132
204
  }
133
- interface ActionActionDef<
134
- TInput = unknown,
135
- TOutput = unknown,
136
- TCtx extends ActionContext = ActionContext
137
- > {
138
- readonly method?: string;
139
- readonly path?: string;
140
- readonly body: SchemaLike2<TInput>;
141
- readonly response: SchemaLike2<TOutput>;
142
- readonly handler: (input: TInput, ctx: TCtx) => Promise<TOutput>;
205
+ interface ConsumeResult {
206
+ success: boolean;
207
+ consumed: number;
208
+ limit: number;
209
+ remaining: number;
143
210
  }
144
- interface ActionConfig<
145
- TActions extends Record<string, ActionActionDef<any, any, any>> = Record<string, ActionActionDef<any, any, any>>,
146
- TInject extends Record<string, EntityDefinition> = {}
147
- > {
148
- readonly inject?: TInject;
149
- readonly access?: Partial<Record<Extract<keyof NoInfer<TActions>, string>, AccessRule>>;
150
- readonly actions: { readonly [K in keyof TActions] : TActions[K] };
211
+ interface WalletStore {
212
+ consume(orgId: string, entitlement: string, periodStart: Date, periodEnd: Date, limit: number, amount?: number): Promise<ConsumeResult>;
213
+ unconsume(orgId: string, entitlement: string, periodStart: Date, periodEnd: Date, amount?: number): Promise<void>;
214
+ getConsumption(orgId: string, entitlement: string, periodStart: Date, periodEnd: Date): Promise<number>;
215
+ dispose(): void;
151
216
  }
152
- interface ActionDefinition {
153
- readonly kind: "action";
154
- readonly name: string;
155
- readonly inject: Record<string, EntityDefinition>;
156
- readonly access: Partial<Record<string, AccessRule>>;
157
- readonly actions: Record<string, ActionActionDef>;
217
+ declare class InMemoryWalletStore implements WalletStore {
218
+ private entries;
219
+ private key;
220
+ consume(orgId: string, entitlement: string, periodStart: Date, periodEnd: Date, limit: number, amount?: number): Promise<ConsumeResult>;
221
+ unconsume(orgId: string, entitlement: string, periodStart: Date, _periodEnd: Date, amount?: number): Promise<void>;
222
+ getConsumption(orgId: string, entitlement: string, periodStart: Date, _periodEnd: Date): Promise<number>;
223
+ dispose(): void;
158
224
  }
159
- declare function action<
160
- TInject extends Record<string, EntityDefinition> = {},
161
- TActions extends Record<string, ActionActionDef<any, any, any>> = Record<string, ActionActionDef<any, any, any>>
162
- >(name: string, config: ActionConfig<TActions, TInject>): ActionDefinition;
163
- import { AuthValidationError } from "@vertz/errors";
164
- import { AuthError, Result as Result2 } from "@vertz/errors";
225
+ interface ResourceRef {
226
+ type: string;
227
+ id: string;
228
+ }
229
+ interface AccessContextConfig {
230
+ userId: string | null;
231
+ accessDef: AccessDefinition;
232
+ closureStore: ClosureStore;
233
+ roleStore: RoleAssignmentStore;
234
+ /** Factor verification age — seconds since last MFA. undefined if no MFA done. */
235
+ fva?: number;
236
+ /** Flag store — required for Layer 1 feature flag checks */
237
+ flagStore?: FlagStore;
238
+ /** Plan store — required for Layer 4 plan checks */
239
+ planStore?: PlanStore;
240
+ /** Wallet store — required for Layer 5 wallet checks and canAndConsume() */
241
+ walletStore?: WalletStore;
242
+ /** Resolves an org ID from a resource. Required for plan/wallet checks. */
243
+ orgResolver?: (resource?: ResourceRef) => Promise<string | null>;
244
+ }
245
+ interface AccessContext {
246
+ can(entitlement: string, resource?: ResourceRef): Promise<boolean>;
247
+ check(entitlement: string, resource?: ResourceRef): Promise<AccessCheckResult>;
248
+ authorize(entitlement: string, resource?: ResourceRef): Promise<void>;
249
+ canAll(checks: Array<{
250
+ entitlement: string;
251
+ resource?: ResourceRef;
252
+ }>): Promise<Map<string, boolean>>;
253
+ /** Atomic check + consume. Runs full can() then increments wallet if all layers pass. */
254
+ canAndConsume(entitlement: string, resource?: ResourceRef, amount?: number): Promise<boolean>;
255
+ /** Rollback a previous canAndConsume(). Use when the operation fails after consumption. */
256
+ unconsume(entitlement: string, resource?: ResourceRef, amount?: number): Promise<void>;
257
+ }
258
+ declare function createAccessContext(config: AccessContextConfig): AccessContext;
259
+ interface AccessCheckData {
260
+ allowed: boolean;
261
+ reasons: DenialReason[];
262
+ reason?: DenialReason;
263
+ meta?: DenialMeta;
264
+ }
265
+ interface AccessSet {
266
+ entitlements: Record<string, AccessCheckData>;
267
+ flags: Record<string, boolean>;
268
+ plan: string | null;
269
+ computedAt: string;
270
+ }
271
+ interface ComputeAccessSetConfig {
272
+ userId: string | null;
273
+ accessDef: AccessDefinition;
274
+ roleStore: RoleAssignmentStore;
275
+ closureStore: ClosureStore;
276
+ plan?: string | null;
277
+ /** Flag store — for feature flag state in access set */
278
+ flagStore?: FlagStore;
279
+ /** Plan store — for limit info in access set */
280
+ planStore?: PlanStore;
281
+ /** Wallet store — for consumption info in access set */
282
+ walletStore?: WalletStore;
283
+ /** Org resolver — for plan/wallet lookups */
284
+ orgResolver?: (resource?: ResourceRef) => Promise<string | null>;
285
+ /** Org ID — direct org ID for global access set (bypass orgResolver) */
286
+ orgId?: string | null;
287
+ }
288
+ declare function computeAccessSet(config: ComputeAccessSetConfig): Promise<AccessSet>;
289
+ /** Sparse encoding for JWT. Only includes allowed + denied-with-meta entries. */
290
+ interface EncodedAccessSet {
291
+ entitlements: Record<string, EncodedAccessCheckData>;
292
+ flags: Record<string, boolean>;
293
+ plan: string | null;
294
+ computedAt: string;
295
+ }
296
+ interface EncodedAccessCheckData {
297
+ allowed: boolean;
298
+ reasons?: DenialReason[];
299
+ reason?: DenialReason;
300
+ meta?: DenialMeta;
301
+ }
302
+ /**
303
+ * Encode an AccessSet for JWT embedding.
304
+ * Sparse: only includes allowed entitlements and denied entries with meta.
305
+ * Strips requiredRoles and requiredPlans from meta (organizational info).
306
+ */
307
+ declare function encodeAccessSet(set: AccessSet): EncodedAccessSet;
308
+ /**
309
+ * Decode a sparse encoded AccessSet back to full form.
310
+ * Missing entitlements default to denied with 'role_required'.
311
+ */
312
+ declare function decodeAccessSet(encoded: EncodedAccessSet, accessDef: AccessDefinition): AccessSet;
165
313
  type SessionStrategy = "jwt" | "database" | "hybrid";
166
314
  interface CookieConfig {
167
315
  name?: string;
@@ -174,8 +322,10 @@ interface CookieConfig {
174
322
  interface SessionConfig {
175
323
  strategy: SessionStrategy;
176
324
  ttl: string | number;
325
+ refreshTtl?: string | number;
177
326
  refreshable?: boolean;
178
327
  cookie?: CookieConfig;
328
+ refreshName?: string;
179
329
  }
180
330
  interface PasswordRequirements {
181
331
  minLength?: number;
@@ -192,11 +342,168 @@ interface RateLimitConfig {
192
342
  window: string;
193
343
  maxAttempts: number;
194
344
  }
345
+ interface SessionStore {
346
+ createSessionWithId(id: string, data: {
347
+ userId: string;
348
+ refreshTokenHash: string;
349
+ ipAddress: string;
350
+ userAgent: string;
351
+ expiresAt: Date;
352
+ currentTokens?: AuthTokens;
353
+ }): Promise<StoredSession>;
354
+ findByRefreshHash(hash: string): Promise<StoredSession | null>;
355
+ findByPreviousRefreshHash(hash: string): Promise<StoredSession | null>;
356
+ revokeSession(id: string): Promise<void>;
357
+ listActiveSessions(userId: string): Promise<StoredSession[]>;
358
+ countActiveSessions(userId: string): Promise<number>;
359
+ getCurrentTokens(sessionId: string): Promise<AuthTokens | null>;
360
+ updateSession(id: string, data: {
361
+ refreshTokenHash: string;
362
+ previousRefreshHash: string;
363
+ lastActiveAt: Date;
364
+ currentTokens?: AuthTokens;
365
+ }): Promise<void>;
366
+ dispose(): void;
367
+ }
368
+ interface StoredSession {
369
+ id: string;
370
+ userId: string;
371
+ refreshTokenHash: string;
372
+ previousRefreshHash: string | null;
373
+ ipAddress: string;
374
+ userAgent: string;
375
+ createdAt: Date;
376
+ lastActiveAt: Date;
377
+ expiresAt: Date;
378
+ revokedAt: Date | null;
379
+ }
380
+ interface RateLimitStore {
381
+ check(key: string, maxAttempts: number, windowMs: number): Promise<RateLimitResult>;
382
+ dispose(): void;
383
+ }
384
+ interface UserStore {
385
+ createUser(user: AuthUser, passwordHash: string | null): Promise<void>;
386
+ findByEmail(email: string): Promise<{
387
+ user: AuthUser;
388
+ passwordHash: string | null;
389
+ } | null>;
390
+ findById(id: string): Promise<AuthUser | null>;
391
+ updatePasswordHash(userId: string, passwordHash: string): Promise<void>;
392
+ updateEmailVerified(userId: string, verified: boolean): Promise<void>;
393
+ }
394
+ interface MfaConfig {
395
+ enabled?: boolean;
396
+ issuer?: string;
397
+ backupCodeCount?: number;
398
+ }
399
+ interface MFAStore {
400
+ enableMfa(userId: string, encryptedSecret: string): Promise<void>;
401
+ disableMfa(userId: string): Promise<void>;
402
+ getSecret(userId: string): Promise<string | null>;
403
+ isMfaEnabled(userId: string): Promise<boolean>;
404
+ setBackupCodes(userId: string, hashedCodes: string[]): Promise<void>;
405
+ getBackupCodes(userId: string): Promise<string[]>;
406
+ consumeBackupCode(userId: string, hashedCode: string): Promise<void>;
407
+ dispose(): void;
408
+ }
409
+ interface MfaSetupData {
410
+ secret: string;
411
+ uri: string;
412
+ }
413
+ interface MfaChallengeData {
414
+ userId: string;
415
+ sessionId?: string;
416
+ expiresAt: number;
417
+ }
418
+ interface OAuthProviderConfig {
419
+ clientId: string;
420
+ clientSecret: string;
421
+ redirectUrl?: string;
422
+ scopes?: string[];
423
+ }
424
+ interface OAuthTokens {
425
+ accessToken: string;
426
+ refreshToken?: string;
427
+ expiresIn?: number;
428
+ idToken?: string;
429
+ }
430
+ interface OAuthUserInfo {
431
+ providerId: string;
432
+ email: string;
433
+ emailVerified: boolean;
434
+ name?: string;
435
+ avatarUrl?: string;
436
+ }
437
+ interface OAuthProvider {
438
+ id: string;
439
+ name: string;
440
+ scopes: string[];
441
+ trustEmail: boolean;
442
+ getAuthorizationUrl: (state: string, codeChallenge?: string, nonce?: string) => string;
443
+ exchangeCode: (code: string, codeVerifier?: string) => Promise<OAuthTokens>;
444
+ getUserInfo: (accessToken: string, idToken?: string, nonce?: string) => Promise<OAuthUserInfo>;
445
+ }
446
+ interface OAuthAccountStore {
447
+ linkAccount(userId: string, provider: string, providerId: string, email?: string): Promise<void>;
448
+ findByProviderAccount(provider: string, providerId: string): Promise<string | null>;
449
+ findByUserId(userId: string): Promise<{
450
+ provider: string;
451
+ providerId: string;
452
+ }[]>;
453
+ unlinkAccount(userId: string, provider: string): Promise<void>;
454
+ dispose(): void;
455
+ }
456
+ interface EmailVerificationConfig {
457
+ enabled: boolean;
458
+ tokenTtl?: string | number;
459
+ onSend: (user: AuthUser, token: string) => Promise<void>;
460
+ }
461
+ interface StoredEmailVerification {
462
+ id: string;
463
+ userId: string;
464
+ tokenHash: string;
465
+ expiresAt: Date;
466
+ createdAt: Date;
467
+ }
468
+ interface EmailVerificationStore {
469
+ createVerification(data: {
470
+ userId: string;
471
+ tokenHash: string;
472
+ expiresAt: Date;
473
+ }): Promise<StoredEmailVerification>;
474
+ findByTokenHash(tokenHash: string): Promise<StoredEmailVerification | null>;
475
+ deleteByUserId(userId: string): Promise<void>;
476
+ deleteByTokenHash(tokenHash: string): Promise<void>;
477
+ dispose(): void;
478
+ }
479
+ interface PasswordResetConfig {
480
+ enabled: boolean;
481
+ tokenTtl?: string | number;
482
+ revokeSessionsOnReset?: boolean;
483
+ onSend: (user: AuthUser, token: string) => Promise<void>;
484
+ }
485
+ interface StoredPasswordReset {
486
+ id: string;
487
+ userId: string;
488
+ tokenHash: string;
489
+ expiresAt: Date;
490
+ createdAt: Date;
491
+ }
492
+ interface PasswordResetStore {
493
+ createReset(data: {
494
+ userId: string;
495
+ tokenHash: string;
496
+ expiresAt: Date;
497
+ }): Promise<StoredPasswordReset>;
498
+ findByTokenHash(tokenHash: string): Promise<StoredPasswordReset | null>;
499
+ deleteByUserId(userId: string): Promise<void>;
500
+ dispose(): void;
501
+ }
195
502
  interface AuthConfig {
196
503
  session: SessionConfig;
197
504
  emailPassword?: EmailPasswordConfig;
198
505
  jwtSecret?: string;
199
- jwtAlgorithm?: "HS256" | "HS384" | "HS512" | "RS256";
506
+ jwtAlgorithm?: "HS256" | "HS384" | "HS512";
200
507
  /** Custom claims function for JWT payload */
201
508
  claims?: (user: AuthUser) => Record<string, unknown>;
202
509
  /**
@@ -211,12 +518,50 @@ interface AuthConfig {
211
518
  * Only used in non-production mode when jwtSecret is not provided.
212
519
  */
213
520
  devSecretPath?: string;
521
+ /** Pluggable session store — defaults to InMemorySessionStore */
522
+ sessionStore?: SessionStore;
523
+ /** Pluggable rate limit store — defaults to InMemoryRateLimitStore */
524
+ rateLimitStore?: RateLimitStore;
525
+ /** Pluggable user store — defaults to InMemoryUserStore */
526
+ userStore?: UserStore;
527
+ /** OAuth provider instances */
528
+ providers?: OAuthProvider[];
529
+ /** Pluggable OAuth account store — required when providers are configured */
530
+ oauthAccountStore?: OAuthAccountStore;
531
+ /** Encryption key for OAuth state cookies — required when providers are configured */
532
+ oauthEncryptionKey?: string;
533
+ /** Redirect URL after successful OAuth (default '/') */
534
+ oauthSuccessRedirect?: string;
535
+ /** Redirect URL on OAuth error (default '/auth/error') */
536
+ oauthErrorRedirect?: string;
537
+ /** MFA configuration */
538
+ mfa?: MfaConfig;
539
+ /** Pluggable MFA store — defaults to InMemoryMFAStore */
540
+ mfaStore?: MFAStore;
541
+ /** Email verification configuration */
542
+ emailVerification?: EmailVerificationConfig;
543
+ /** Pluggable email verification store — defaults to InMemoryEmailVerificationStore */
544
+ emailVerificationStore?: EmailVerificationStore;
545
+ /** Password reset configuration */
546
+ passwordReset?: PasswordResetConfig;
547
+ /** Pluggable password reset store — defaults to InMemoryPasswordResetStore */
548
+ passwordResetStore?: PasswordResetStore;
549
+ /** Access control configuration — enables ACL claim in JWT */
550
+ access?: AuthAccessConfig;
551
+ }
552
+ /** Access control configuration for JWT acl claim computation. */
553
+ interface AuthAccessConfig {
554
+ definition: AccessDefinition;
555
+ roleStore: RoleAssignmentStore;
556
+ closureStore: ClosureStore;
557
+ flagStore?: FlagStore;
214
558
  }
215
559
  interface AuthUser {
216
560
  id: string;
217
561
  email: string;
218
562
  role: string;
219
563
  plan?: string;
564
+ emailVerified?: boolean;
220
565
  createdAt: Date;
221
566
  updatedAt: Date;
222
567
  [key: string]: unknown;
@@ -227,12 +572,41 @@ interface SessionPayload {
227
572
  role: string;
228
573
  iat: number;
229
574
  exp: number;
575
+ jti: string;
576
+ sid: string;
230
577
  claims?: Record<string, unknown>;
578
+ fva?: number;
579
+ acl?: AclClaim;
580
+ }
581
+ /** JWT acl claim — embedded access set with overflow strategy. */
582
+ interface AclClaim {
583
+ /** Full sparse set when fits within 2KB budget */
584
+ set?: EncodedAccessSet;
585
+ /** SHA-256 hex of canonical JSON — always present */
586
+ hash: string;
587
+ /** True when set omitted due to size */
588
+ overflow: boolean;
589
+ }
590
+ interface AuthTokens {
591
+ jwt: string;
592
+ refreshToken: string;
231
593
  }
232
594
  interface Session {
233
595
  user: AuthUser;
234
596
  expiresAt: Date;
235
597
  payload: SessionPayload;
598
+ tokens?: AuthTokens;
599
+ }
600
+ interface SessionInfo {
601
+ id: string;
602
+ userId: string;
603
+ ipAddress: string;
604
+ userAgent: string;
605
+ deviceName: string;
606
+ createdAt: Date;
607
+ lastActiveAt: Date;
608
+ expiresAt: Date;
609
+ isCurrent: boolean;
236
610
  }
237
611
  interface SignUpInput {
238
612
  email: string;
@@ -245,11 +619,22 @@ interface SignInInput {
245
619
  password: string;
246
620
  }
247
621
  interface AuthApi {
248
- signUp: (data: SignUpInput) => Promise<Result2<Session, AuthError>>;
249
- signIn: (data: SignInInput) => Promise<Result2<Session, AuthError>>;
250
- signOut: (ctx: AuthContext) => Promise<Result2<void, AuthError>>;
251
- getSession: (headers: Headers) => Promise<Result2<Session | null, AuthError>>;
252
- refreshSession: (ctx: AuthContext) => Promise<Result2<Session, AuthError>>;
622
+ signUp: (data: SignUpInput, ctx?: {
623
+ headers: Headers;
624
+ }) => Promise<Result<Session, AuthError>>;
625
+ signIn: (data: SignInInput, ctx?: {
626
+ headers: Headers;
627
+ }) => Promise<Result<Session, AuthError>>;
628
+ signOut: (ctx: {
629
+ headers: Headers;
630
+ }) => Promise<Result<void, AuthError>>;
631
+ getSession: (headers: Headers) => Promise<Result<Session | null, AuthError>>;
632
+ refreshSession: (ctx: {
633
+ headers: Headers;
634
+ }) => Promise<Result<Session, AuthError>>;
635
+ listSessions: (headers: Headers) => Promise<Result<SessionInfo[], AuthError>>;
636
+ revokeSession: (sessionId: string, headers: Headers) => Promise<Result<void, AuthError>>;
637
+ revokeAllSessions: (headers: Headers) => Promise<Result<void, AuthError>>;
253
638
  }
254
639
  interface AuthInstance {
255
640
  /** HTTP handler for auth routes */
@@ -257,9 +642,11 @@ interface AuthInstance {
257
642
  /** Server-side API */
258
643
  api: AuthApi;
259
644
  /** Session middleware that injects ctx.user */
260
- middleware: () => any;
645
+ middleware: () => (ctx: Record<string, unknown>, next: () => Promise<void>) => Promise<void>;
261
646
  /** Initialize auth (create tables, etc.) */
262
647
  initialize: () => Promise<void>;
648
+ /** Dispose stores and cleanup intervals */
649
+ dispose: () => void;
263
650
  }
264
651
  interface AuthContext {
265
652
  headers: Headers;
@@ -271,6 +658,51 @@ interface RateLimitResult {
271
658
  remaining: number;
272
659
  resetAt: Date;
273
660
  }
661
+ interface UserTableEntry extends ModelEntry<any, any> {
662
+ table: {
663
+ id: {
664
+ type: string;
665
+ };
666
+ email: {
667
+ type: string;
668
+ };
669
+ passwordHash: {
670
+ type: string;
671
+ };
672
+ role: {
673
+ type: string;
674
+ };
675
+ plan?: {
676
+ type: string;
677
+ };
678
+ createdAt: {
679
+ type: Date;
680
+ };
681
+ updatedAt: {
682
+ type: Date;
683
+ };
684
+ };
685
+ }
686
+ interface RoleAssignmentTableEntry extends ModelEntry<any, any> {
687
+ table: {
688
+ id: {
689
+ type: string;
690
+ };
691
+ userId: {
692
+ type: string;
693
+ };
694
+ role: {
695
+ type: string;
696
+ };
697
+ createdAt: {
698
+ type: Date;
699
+ };
700
+ };
701
+ }
702
+ import { AuthValidationError } from "@vertz/errors";
703
+ declare function hashPassword(password: string): Promise<string>;
704
+ declare function verifyPassword(password: string, hash: string): Promise<boolean>;
705
+ declare function validatePassword(password: string, requirements?: PasswordRequirements): AuthValidationError | null;
274
706
  type Entitlement = string;
275
707
  interface RoleDefinition {
276
708
  entitlements: Entitlement[];
@@ -316,17 +748,426 @@ declare class AuthorizationError extends Error {
316
748
  }
317
749
  declare function createAccess(config: AccessConfig): AccessInstance;
318
750
  declare const defaultAccess: AccessInstance;
319
- declare function hashPassword(password: string): Promise<string>;
320
- declare function verifyPassword(password: string, hash: string): Promise<boolean>;
321
- declare function validatePassword(password: string, requirements?: PasswordRequirements): AuthValidationError | null;
751
+ type AccessEvent = {
752
+ type: "access:flag_toggled";
753
+ orgId: string;
754
+ flag: string;
755
+ enabled: boolean;
756
+ } | {
757
+ type: "access:limit_updated";
758
+ orgId: string;
759
+ entitlement: string;
760
+ consumed: number;
761
+ remaining: number;
762
+ max: number;
763
+ } | {
764
+ type: "access:role_changed";
765
+ userId: string;
766
+ } | {
767
+ type: "access:plan_changed";
768
+ orgId: string;
769
+ };
770
+ interface AccessWsData {
771
+ userId: string;
772
+ orgId: string;
773
+ }
774
+ interface AccessEventBroadcasterConfig {
775
+ jwtSecret: string;
776
+ jwtAlgorithm?: string;
777
+ /** WebSocket upgrade path. Defaults to '/api/auth/access-events'. */
778
+ path?: string;
779
+ /** Cookie name for session JWT. Defaults to 'vertz.sid'. */
780
+ cookieName?: string;
781
+ }
782
+ interface AccessEventBroadcaster {
783
+ handleUpgrade(request: Request, server: BunServer): Promise<boolean>;
784
+ websocket: {
785
+ open(ws: BunWebSocket<AccessWsData>): void;
786
+ message(ws: BunWebSocket<AccessWsData>, msg: string | Buffer): void;
787
+ close(ws: BunWebSocket<AccessWsData>): void;
788
+ };
789
+ broadcastFlagToggle(orgId: string, flag: string, enabled: boolean): void;
790
+ broadcastLimitUpdate(orgId: string, entitlement: string, consumed: number, remaining: number, max: number): void;
791
+ broadcastRoleChange(userId: string): void;
792
+ broadcastPlanChange(orgId: string): void;
793
+ getConnectionCount: number;
794
+ }
795
+ /** Minimal Bun.Server interface for WebSocket upgrade */
796
+ interface BunServer {
797
+ upgrade(request: Request, options?: {
798
+ data?: AccessWsData;
799
+ }): boolean;
800
+ }
801
+ /** Minimal Bun.ServerWebSocket interface */
802
+ interface BunWebSocket<T> {
803
+ data: T;
804
+ send(data: string): void;
805
+ close(): void;
806
+ ping(): void;
807
+ }
808
+ declare function createAccessEventBroadcaster(config: AccessEventBroadcasterConfig): AccessEventBroadcaster;
809
+ interface Period {
810
+ periodStart: Date;
811
+ periodEnd: Date;
812
+ }
813
+ /**
814
+ * Calculate the billing period that contains `now`, anchored to `startedAt`.
815
+ *
816
+ * For 'month': walks from startedAt adding 1 month at a time until we find
817
+ * the period containing now. For 'day'/'hour': uses fixed-duration periods.
818
+ */
819
+ declare function calculateBillingPeriod(startedAt: Date, per: BillingPeriod, now?: Date): Period;
820
+ declare class InMemoryEmailVerificationStore implements EmailVerificationStore {
821
+ private byId;
822
+ private byTokenHash;
823
+ private byUserId;
824
+ createVerification(data: {
825
+ userId: string;
826
+ tokenHash: string;
827
+ expiresAt: Date;
828
+ }): Promise<StoredEmailVerification>;
829
+ findByTokenHash(tokenHash: string): Promise<StoredEmailVerification | null>;
830
+ deleteByUserId(userId: string): Promise<void>;
831
+ deleteByTokenHash(tokenHash: string): Promise<void>;
832
+ dispose(): void;
833
+ }
834
+ /**
835
+ * Compute access metadata for a specific entity.
836
+ *
837
+ * @param entitlements - Entitlement names to check
838
+ * @param entity - The resource to check against
839
+ * @param accessContext - The server-side access context
840
+ * @returns Record of entitlement name to AccessCheckData (client-compatible shape)
841
+ */
842
+ declare function computeEntityAccess(entitlements: string[], entity: {
843
+ type: string;
844
+ id: string;
845
+ }, accessContext: AccessContext): Promise<Record<string, AccessCheckData>>;
846
+ /**
847
+ * Check if the session's factor verification is still fresh.
848
+ * Returns true if `fva` exists and (now - fva) < maxAgeSeconds.
849
+ */
850
+ declare function checkFva(payload: SessionPayload, maxAgeSeconds: number): boolean;
851
+ declare class InMemoryMFAStore implements MFAStore {
852
+ private secrets;
853
+ private backupCodes;
854
+ private enabled;
855
+ enableMfa(userId: string, encryptedSecret: string): Promise<void>;
856
+ disableMfa(userId: string): Promise<void>;
857
+ getSecret(userId: string): Promise<string | null>;
858
+ isMfaEnabled(userId: string): Promise<boolean>;
859
+ setBackupCodes(userId: string, hashedCodes: string[]): Promise<void>;
860
+ getBackupCodes(userId: string): Promise<string[]>;
861
+ consumeBackupCode(userId: string, hashedCode: string): Promise<void>;
862
+ dispose(): void;
863
+ }
864
+ declare class InMemoryOAuthAccountStore implements OAuthAccountStore {
865
+ private byProviderAccount;
866
+ private byUserId;
867
+ private providerKey;
868
+ linkAccount(userId: string, provider: string, providerId: string, email?: string): Promise<void>;
869
+ findByProviderAccount(provider: string, providerId: string): Promise<string | null>;
870
+ findByUserId(userId: string): Promise<{
871
+ provider: string;
872
+ providerId: string;
873
+ }[]>;
874
+ unlinkAccount(userId: string, provider: string): Promise<void>;
875
+ dispose(): void;
876
+ }
877
+ declare class InMemoryPasswordResetStore implements PasswordResetStore {
878
+ private byId;
879
+ private byTokenHash;
880
+ private byUserId;
881
+ createReset(data: {
882
+ userId: string;
883
+ tokenHash: string;
884
+ expiresAt: Date;
885
+ }): Promise<StoredPasswordReset>;
886
+ findByTokenHash(tokenHash: string): Promise<StoredPasswordReset | null>;
887
+ deleteByUserId(userId: string): Promise<void>;
888
+ dispose(): void;
889
+ }
890
+ declare function discord(config: OAuthProviderConfig): OAuthProvider;
891
+ declare function github(config: OAuthProviderConfig): OAuthProvider;
892
+ declare function google(config: OAuthProviderConfig): OAuthProvider;
893
+ declare class InMemoryRateLimitStore implements RateLimitStore {
894
+ private store;
895
+ private cleanupTimer;
896
+ constructor();
897
+ check(key: string, maxAttempts: number, windowMs: number): Promise<RateLimitResult>;
898
+ dispose(): void;
899
+ private cleanup;
900
+ }
901
+ /**
902
+ * rules.* builders — declarative access rule data structures.
903
+ *
904
+ * These are pure data structures with no evaluation logic.
905
+ * The access context evaluates them at runtime (sub-phase 4).
906
+ */
907
+ interface RoleRule {
908
+ readonly type: "role";
909
+ readonly roles: readonly string[];
910
+ }
911
+ interface EntitlementRule {
912
+ readonly type: "entitlement";
913
+ readonly entitlement: string;
914
+ }
915
+ interface WhereRule {
916
+ readonly type: "where";
917
+ readonly conditions: Record<string, unknown>;
918
+ }
919
+ interface AllRule {
920
+ readonly type: "all";
921
+ readonly rules: readonly AccessRule[];
922
+ }
923
+ interface AnyRule {
924
+ readonly type: "any";
925
+ readonly rules: readonly AccessRule[];
926
+ }
927
+ interface AuthenticatedRule {
928
+ readonly type: "authenticated";
929
+ }
930
+ interface FvaRule {
931
+ readonly type: "fva";
932
+ readonly maxAge: number;
933
+ }
934
+ type AccessRule = RoleRule | EntitlementRule | WhereRule | AllRule | AnyRule | AuthenticatedRule | FvaRule;
935
+ interface UserMarker {
936
+ readonly __marker: string;
937
+ }
938
+ declare const rules: {
939
+ /** User has at least one of the specified roles (OR) */
940
+ role(...roleNames: string[]): RoleRule;
941
+ /** User has the specified entitlement (resolves role+plan+flag from defineAccess config) */
942
+ entitlement(name: string): EntitlementRule;
943
+ /** Row-level condition — DB query syntax. Use rules.user.id for dynamic user markers */
944
+ where(conditions: Record<string, unknown>): WhereRule;
945
+ /** All sub-rules must pass (AND) */
946
+ all(...ruleList: AccessRule[]): AllRule;
947
+ /** At least one sub-rule must pass (OR) */
948
+ any(...ruleList: AccessRule[]): AnyRule;
949
+ /** User must be authenticated (no specific role required) */
950
+ authenticated(): AuthenticatedRule;
951
+ /** User must have verified MFA within maxAge seconds */
952
+ fva(maxAge: number): FvaRule;
953
+ /** Declarative user markers — resolved at evaluation time */
954
+ user: {
955
+ readonly id: UserMarker;
956
+ readonly tenantId: UserMarker;
957
+ };
958
+ };
959
+ declare class InMemorySessionStore implements SessionStore {
960
+ private sessions;
961
+ private currentTokens;
962
+ private cleanupTimer;
963
+ private maxSessionsPerUser;
964
+ constructor(maxSessionsPerUser?: number);
965
+ createSession(data: {
966
+ userId: string;
967
+ refreshTokenHash: string;
968
+ ipAddress: string;
969
+ userAgent: string;
970
+ expiresAt: Date;
971
+ }): Promise<StoredSession>;
972
+ createSessionWithId(id: string, data: {
973
+ userId: string;
974
+ refreshTokenHash: string;
975
+ ipAddress: string;
976
+ userAgent: string;
977
+ expiresAt: Date;
978
+ currentTokens?: AuthTokens;
979
+ }): Promise<StoredSession>;
980
+ findByRefreshHash(hash: string): Promise<StoredSession | null>;
981
+ findByPreviousRefreshHash(hash: string): Promise<StoredSession | null>;
982
+ revokeSession(id: string): Promise<void>;
983
+ listActiveSessions(userId: string): Promise<StoredSession[]>;
984
+ countActiveSessions(userId: string): Promise<number>;
985
+ getCurrentTokens(sessionId: string): Promise<AuthTokens | null>;
986
+ updateSession(id: string, data: {
987
+ refreshTokenHash: string;
988
+ previousRefreshHash: string;
989
+ lastActiveAt: Date;
990
+ currentTokens?: AuthTokens;
991
+ }): Promise<void>;
992
+ dispose(): void;
993
+ private cleanup;
994
+ }
995
+ declare class InMemoryUserStore implements UserStore {
996
+ private byEmail;
997
+ private byId;
998
+ createUser(user: AuthUser, passwordHash: string | null): Promise<void>;
999
+ findByEmail(email: string): Promise<{
1000
+ user: AuthUser;
1001
+ passwordHash: string | null;
1002
+ } | null>;
1003
+ findById(id: string): Promise<AuthUser | null>;
1004
+ updatePasswordHash(userId: string, passwordHash: string): Promise<void>;
1005
+ updateEmailVerified(userId: string, verified: boolean): Promise<void>;
1006
+ }
322
1007
  declare function createAuth(config: AuthConfig): AuthInstance;
323
1008
  import { AppBuilder, AppConfig } from "@vertz/core";
324
1009
  import { DatabaseClient, EntityDbAdapter as EntityDbAdapter3, ModelEntry as ModelEntry2 } from "@vertz/db";
1010
+ import { ModelDef as ModelDef2, RelationDef, SchemaLike, TableDef } from "@vertz/db";
1011
+ import { ModelDef } from "@vertz/db";
1012
+ import { EntityDbAdapter, ListOptions } from "@vertz/db";
1013
+ import { EntityError, Result as Result2 } from "@vertz/errors";
1014
+ import { EntityDbAdapter as EntityDbAdapter2, ListOptions as ListOptions2 } from "@vertz/db";
1015
+ interface ListResult<T = Record<string, unknown>> {
1016
+ items: T[];
1017
+ total: number;
1018
+ limit: number;
1019
+ nextCursor: string | null;
1020
+ hasNextPage: boolean;
1021
+ }
1022
+ interface CrudResult<T = unknown> {
1023
+ status: number;
1024
+ body: T;
1025
+ }
1026
+ interface CrudHandlers {
1027
+ list(ctx: EntityContext, options?: ListOptions): Promise<Result2<CrudResult<ListResult>, EntityError>>;
1028
+ get(ctx: EntityContext, id: string): Promise<Result2<CrudResult<Record<string, unknown>>, EntityError>>;
1029
+ create(ctx: EntityContext, data: Record<string, unknown>): Promise<Result2<CrudResult<Record<string, unknown>>, EntityError>>;
1030
+ update(ctx: EntityContext, id: string, data: Record<string, unknown>): Promise<Result2<CrudResult<Record<string, unknown>>, EntityError>>;
1031
+ delete(ctx: EntityContext, id: string): Promise<Result2<CrudResult<null>, EntityError>>;
1032
+ }
1033
+ declare function createCrudHandlers(def: EntityDefinition, db: EntityDbAdapter): CrudHandlers;
1034
+ /**
1035
+ * EntityOperations — typed CRUD facade for a single entity.
1036
+ *
1037
+ * When used as `ctx.entity`, TModel fills in actual column types.
1038
+ * When used as `ctx.entities.*`, TModel defaults to `ModelDef` (loose typing).
1039
+ */
1040
+ interface EntityOperations<TModel extends ModelDef = ModelDef> {
1041
+ get(id: string): Promise<TModel["table"]["$response"]>;
1042
+ list(options?: ListOptions2): Promise<ListResult<TModel["table"]["$response"]>>;
1043
+ create(data: TModel["table"]["$create_input"]): Promise<TModel["table"]["$response"]>;
1044
+ update(id: string, data: TModel["table"]["$update_input"]): Promise<TModel["table"]["$response"]>;
1045
+ delete(id: string): Promise<void>;
1046
+ }
1047
+ /** Extracts the model type from an EntityDefinition */
1048
+ type ExtractModel<T> = T extends EntityDefinition<infer M> ? M : ModelDef2;
1049
+ /**
1050
+ * Maps an inject config `{ key: EntityDefinition<TModel> }` to
1051
+ * `{ key: EntityOperations<TModel> }` for typed ctx.entities access.
1052
+ */
1053
+ type InjectToOperations<TInject extends Record<string, EntityDefinition> = {}> = { readonly [K in keyof TInject] : EntityOperations<ExtractModel<TInject[K]>> };
1054
+ interface BaseContext {
1055
+ readonly userId: string | null;
1056
+ authenticated(): boolean;
1057
+ tenant(): boolean;
1058
+ role(...roles: string[]): boolean;
1059
+ }
1060
+ interface EntityContext<
1061
+ TModel extends ModelDef2 = ModelDef2,
1062
+ TInject extends Record<string, EntityDefinition> = {}
1063
+ > extends BaseContext {
1064
+ /** Typed CRUD on the current entity */
1065
+ readonly entity: EntityOperations<TModel>;
1066
+ /** Typed access to injected entities only */
1067
+ readonly entities: InjectToOperations<TInject>;
1068
+ }
1069
+ type AccessRule2 = false | ((ctx: BaseContext, row: Record<string, unknown>) => boolean | Promise<boolean>);
1070
+ interface EntityBeforeHooks<
1071
+ TCreateInput = unknown,
1072
+ TUpdateInput = unknown
1073
+ > {
1074
+ readonly create?: (data: TCreateInput, ctx: EntityContext) => TCreateInput | Promise<TCreateInput>;
1075
+ readonly update?: (data: TUpdateInput, ctx: EntityContext) => TUpdateInput | Promise<TUpdateInput>;
1076
+ }
1077
+ interface EntityAfterHooks<TResponse = unknown> {
1078
+ readonly create?: (result: TResponse, ctx: EntityContext) => void | Promise<void>;
1079
+ readonly update?: (prev: TResponse, next: TResponse, ctx: EntityContext) => void | Promise<void>;
1080
+ readonly delete?: (row: TResponse, ctx: EntityContext) => void | Promise<void>;
1081
+ }
1082
+ interface EntityActionDef<
1083
+ TInput = unknown,
1084
+ TOutput = unknown,
1085
+ TResponse = unknown,
1086
+ TCtx extends EntityContext = EntityContext
1087
+ > {
1088
+ readonly method?: string;
1089
+ readonly path?: string;
1090
+ readonly body: SchemaLike<TInput>;
1091
+ readonly response: SchemaLike<TOutput>;
1092
+ readonly handler: (input: TInput, ctx: TCtx, row: TResponse | null) => Promise<TOutput>;
1093
+ }
1094
+ /** Extract column keys from a RelationDef's target table. */
1095
+ type RelationColumnKeys<R> = R extends RelationDef<infer TTarget> ? TTarget extends TableDef<infer TCols> ? Extract<keyof TCols, string> : string : string;
1096
+ type EntityRelationsConfig<TRelations extends Record<string, RelationDef> = Record<string, RelationDef>> = { [K in keyof TRelations]? : true | false | { [F in RelationColumnKeys<TRelations[K]>]? : true } };
1097
+ interface EntityConfig<
1098
+ TModel extends ModelDef2 = ModelDef2,
1099
+ TActions extends Record<string, EntityActionDef<any, any, any, any>> = {},
1100
+ TInject extends Record<string, EntityDefinition> = {}
1101
+ > {
1102
+ readonly model: TModel;
1103
+ readonly inject?: TInject;
1104
+ readonly access?: Partial<Record<"list" | "get" | "create" | "update" | "delete" | Extract<keyof NoInfer<TActions>, string>, AccessRule2>>;
1105
+ readonly before?: {
1106
+ readonly create?: (data: TModel["table"]["$create_input"], ctx: EntityContext<TModel, TInject>) => TModel["table"]["$create_input"] | Promise<TModel["table"]["$create_input"]>;
1107
+ readonly update?: (data: TModel["table"]["$update_input"], ctx: EntityContext<TModel, TInject>) => TModel["table"]["$update_input"] | Promise<TModel["table"]["$update_input"]>;
1108
+ };
1109
+ readonly after?: {
1110
+ readonly create?: (result: TModel["table"]["$response"], ctx: EntityContext<TModel, TInject>) => void | Promise<void>;
1111
+ readonly update?: (prev: TModel["table"]["$response"], next: TModel["table"]["$response"], ctx: EntityContext<TModel, TInject>) => void | Promise<void>;
1112
+ readonly delete?: (row: TModel["table"]["$response"], ctx: EntityContext<TModel, TInject>) => void | Promise<void>;
1113
+ };
1114
+ readonly actions?: { readonly [K in keyof TActions] : TActions[K] };
1115
+ readonly relations?: EntityRelationsConfig<TModel["relations"]>;
1116
+ }
1117
+ interface EntityDefinition<TModel extends ModelDef2 = ModelDef2> {
1118
+ readonly kind: "entity";
1119
+ readonly name: string;
1120
+ readonly model: TModel;
1121
+ readonly inject: Record<string, EntityDefinition>;
1122
+ readonly access: Partial<Record<string, AccessRule2>>;
1123
+ readonly before: EntityBeforeHooks;
1124
+ readonly after: EntityAfterHooks;
1125
+ readonly actions: Record<string, EntityActionDef>;
1126
+ readonly relations: EntityRelationsConfig<TModel["relations"]>;
1127
+ }
1128
+ import { SchemaLike as SchemaLike2 } from "@vertz/db";
1129
+ /** Extracts the model type from an EntityDefinition */
1130
+ type ExtractModel2<T> = T extends EntityDefinition<infer M> ? M : never;
1131
+ /**
1132
+ * Maps an inject config `{ key: EntityDefinition<TModel> }` to
1133
+ * `{ key: EntityOperations<TModel> }` for typed ctx.entities access.
1134
+ */
1135
+ type InjectToOperations2<TInject extends Record<string, EntityDefinition> = {}> = { readonly [K in keyof TInject] : EntityOperations<ExtractModel2<TInject[K]>> };
1136
+ interface ServiceContext<TInject extends Record<string, EntityDefinition> = {}> extends BaseContext {
1137
+ /** Typed access to injected entities only */
1138
+ readonly entities: InjectToOperations2<TInject>;
1139
+ }
1140
+ interface ServiceActionDef<
1141
+ TInput = unknown,
1142
+ TOutput = unknown,
1143
+ TCtx extends ServiceContext = ServiceContext
1144
+ > {
1145
+ readonly method?: string;
1146
+ readonly path?: string;
1147
+ readonly body: SchemaLike2<TInput>;
1148
+ readonly response: SchemaLike2<TOutput>;
1149
+ readonly handler: (input: TInput, ctx: TCtx) => Promise<TOutput>;
1150
+ }
1151
+ interface ServiceConfig<
1152
+ TActions extends Record<string, ServiceActionDef<any, any, any>> = Record<string, ServiceActionDef<any, any, any>>,
1153
+ TInject extends Record<string, EntityDefinition> = {}
1154
+ > {
1155
+ readonly inject?: TInject;
1156
+ readonly access?: Partial<Record<Extract<keyof NoInfer<TActions>, string>, AccessRule2>>;
1157
+ readonly actions: { readonly [K in keyof TActions] : TActions[K] };
1158
+ }
1159
+ interface ServiceDefinition {
1160
+ readonly kind: "service";
1161
+ readonly name: string;
1162
+ readonly inject: Record<string, EntityDefinition>;
1163
+ readonly access: Partial<Record<string, AccessRule2>>;
1164
+ readonly actions: Record<string, ServiceActionDef>;
1165
+ }
325
1166
  interface ServerConfig extends Omit<AppConfig, "_entityDbFactory" | "entities"> {
326
1167
  /** Entity definitions created via entity() from @vertz/server */
327
1168
  entities?: EntityDefinition[];
328
- /** Standalone action definitions created via action() from @vertz/server */
329
- actions?: ActionDefinition[];
1169
+ /** Standalone service definitions created via service() from @vertz/server */
1170
+ services?: ServiceDefinition[];
330
1171
  /**
331
1172
  * Database for entity CRUD operations.
332
1173
  * Accepts either:
@@ -347,13 +1188,13 @@ import { EntityForbiddenError, Result as Result3 } from "@vertz/errors";
347
1188
  * Evaluates an access rule for the given operation.
348
1189
  * Returns err(EntityForbiddenError) if access is denied.
349
1190
  *
350
- * Accepts BaseContext so both EntityContext and ActionContext can use it.
1191
+ * Accepts BaseContext so both EntityContext and ServiceContext can use it.
351
1192
  *
352
1193
  * - No rule defined → deny (deny by default)
353
1194
  * - Rule is false → operation is disabled
354
1195
  * - Rule is a function → evaluate and deny if returns false
355
1196
  */
356
- declare function enforceAccess(operation: string, accessRules: Partial<Record<string, AccessRule>>, ctx: BaseContext, row?: Record<string, unknown>): Promise<Result3<void, EntityForbiddenError>>;
1197
+ declare function enforceAccess(operation: string, accessRules: Partial<Record<string, AccessRule2>>, ctx: BaseContext, row?: Record<string, unknown>): Promise<Result3<void, EntityForbiddenError>>;
357
1198
  import { ModelDef as ModelDef3 } from "@vertz/db";
358
1199
  /**
359
1200
  * Request info extracted from HTTP context / auth middleware.
@@ -426,4 +1267,8 @@ interface EntityRouteOptions {
426
1267
  * Operations explicitly disabled (access: false) get a 405 handler.
427
1268
  */
428
1269
  declare function generateEntityRoutes(def: EntityDefinition, registry: EntityRegistry, db: EntityDbAdapter2, options?: EntityRouteOptions): EntityRouteEntry[];
429
- export { vertz, verifyPassword, validatePassword, stripReadOnlyFields, stripHiddenFields, makeImmutable, hashPassword, generateEntityRoutes, entityErrorHandler, entity, enforceAccess, defaultAccess, deepFreeze, createServer, createMiddleware, createImmutableProxy, createEnv, createEntityContext, createCrudHandlers, createAuth, createAccess, action, VertzException, ValidationException, UnauthorizedException, SignUpInput, SignInInput, SessionStrategy, SessionPayload, SessionConfig, Session, ServiceUnavailableException, ServerHandle, ServerConfig, ServerAdapter, Resource, RequestInfo, RawRequest, RateLimitResult, RateLimitConfig, PasswordRequirements, NotFoundException, NamedMiddlewareDef, MiddlewareDef, ListenOptions, ListResult, ListOptions2 as ListOptions, InternalServerErrorException, InferSchema, Infer, HttpStatusCode, HttpMethod, HandlerCtx, ForbiddenException, EnvConfig, EntityRouteOptions, EntityRelationsConfig, EntityRegistry, EntityOperations, EntityErrorResult, EntityDefinition, EntityDbAdapter2 as EntityDbAdapter, EntityContext, EntityConfig, EntityActionDef, EntitlementDefinition, Entitlement, EmailPasswordConfig, Deps, DeepReadonly, Ctx, CrudResult, CrudHandlers, CorsConfig, CookieConfig, ConflictException, BaseContext, BadRequestException, AuthorizationError, AuthUser, AuthInstance, AuthContext, AuthConfig, AuthApi, AppConfig2 as AppConfig, AppBuilder2 as AppBuilder, ActionDefinition, ActionContext, ActionConfig, ActionActionDef, AccumulateProvides, AccessRule, AccessInstance, AccessConfig };
1270
+ declare function service<
1271
+ TInject extends Record<string, EntityDefinition> = {},
1272
+ TActions extends Record<string, ServiceActionDef<any, any, any>> = Record<string, ServiceActionDef<any, any, any>>
1273
+ >(name: string, config: ServiceConfig<TActions, TInject>): ServiceDefinition;
1274
+ export { vertz, verifyPassword, validatePassword, stripReadOnlyFields, stripHiddenFields, service, rules, makeImmutable, hashPassword, google, github, generateEntityRoutes, entityErrorHandler, entity, enforceAccess, encodeAccessSet, discord, defineAccess, defaultAccess, deepFreeze, decodeAccessSet, createServer, createMiddleware, createImmutableProxy, createEnv, createEntityContext, createCrudHandlers, createAuth, createAccessEventBroadcaster, createAccessContext, createAccess, computeEntityAccess, computeAccessSet, checkFva, calculateBillingPeriod, WalletStore, WalletEntry, VertzException, ValidationException, UserTableEntry, UserStore, UnauthorizedException, StoredSession, StoredPasswordReset, StoredEmailVerification, SignUpInput, SignInInput, SessionStrategy, SessionStore, SessionPayload, SessionInfo, SessionConfig, Session, ServiceUnavailableException, ServiceDefinition, ServiceContext, ServiceConfig, ServiceActionDef, ServerHandle, ServerConfig, ServerAdapter, RoleAssignmentTableEntry, RoleAssignmentStore, RoleAssignment, ResourceRef, Resource, RequestInfo, RawRequest, RateLimitStore, RateLimitResult, RateLimitConfig, PlanStore, PlanDef, Period, PasswordResetStore, PasswordResetConfig, PasswordRequirements, ParentRef, OrgPlan, OAuthUserInfo, OAuthTokens, OAuthProviderConfig, OAuthProvider, OAuthAccountStore, NotFoundException, NamedMiddlewareDef, MiddlewareDef, MfaSetupData, MfaConfig, MfaChallengeData, MFAStore, ListenOptions, ListResult, ListOptions2 as ListOptions, LimitOverride, LimitDef, InternalServerErrorException, InferSchema, Infer, InMemoryWalletStore, InMemoryUserStore, InMemorySessionStore, InMemoryRoleAssignmentStore, InMemoryRateLimitStore, InMemoryPlanStore, InMemoryPasswordResetStore, InMemoryOAuthAccountStore, InMemoryMFAStore, InMemoryFlagStore, InMemoryEmailVerificationStore, InMemoryClosureStore, HttpStatusCode, HttpMethod, HandlerCtx, ForbiddenException, FlagStore, EnvConfig, EntityRouteOptions, EntityRelationsConfig, EntityRegistry, EntityOperations, EntityErrorResult, EntityDefinition, EntityDbAdapter2 as EntityDbAdapter, EntityContext, EntityConfig, EntityActionDef, EntitlementDefinition, EntitlementDef, Entitlement, EncodedAccessSet, EmailVerificationStore, EmailVerificationConfig, EmailPasswordConfig, Deps, DenialReason, DenialMeta, DefineAccessInput, DeepReadonly, Ctx, CrudResult, CrudHandlers, CorsConfig, CookieConfig, ConsumeResult, ConflictException, ComputeAccessSetConfig, ClosureStore, ClosureRow, ClosureEntry, BillingPeriod, BaseContext, BadRequestException, AuthorizationError, AuthUser, AuthTokens, AuthInstance, AuthContext, AuthConfig, AuthApi, AppConfig2 as AppConfig, AppBuilder2 as AppBuilder, AclClaim, AccumulateProvides, AccessWsData, AccessSet, AccessRule2 as AccessRule, AccessInstance, AccessEventBroadcasterConfig, AccessEventBroadcaster, AccessEvent, AccessDefinition, AccessContextConfig, AccessContext, AccessConfig, AccessCheckResult, AccessCheckData };