@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.js CHANGED
@@ -17,275 +17,2145 @@ import {
17
17
  vertz
18
18
  } from "@vertz/core";
19
19
 
20
- // src/action/action.ts
21
- import { deepFreeze } from "@vertz/core";
22
- var ACTION_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
23
- function action(name, config) {
24
- if (!name || !ACTION_NAME_PATTERN.test(name)) {
25
- throw new Error(`action() name must be a non-empty lowercase string matching /^[a-z][a-z0-9-]*$/. Got: "${name}"`);
26
- }
27
- if (!config.actions || Object.keys(config.actions).length === 0) {
28
- throw new Error("action() requires at least one action in the actions config.");
29
- }
30
- const def = {
31
- kind: "action",
32
- name,
33
- inject: config.inject ?? {},
34
- access: config.access ?? {},
35
- actions: config.actions
36
- };
37
- return deepFreeze(def);
38
- }
39
20
  // src/auth/index.ts
40
21
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
41
22
  import { join } from "node:path";
42
23
  import {
43
24
  createAuthRateLimitedError,
44
- createAuthValidationError,
25
+ createAuthValidationError as createAuthValidationError2,
45
26
  createInvalidCredentialsError,
27
+ createMfaAlreadyEnabledError,
28
+ createMfaInvalidCodeError,
29
+ createMfaNotEnabledError,
30
+ createMfaRequiredError,
46
31
  createSessionExpiredError,
32
+ createSessionNotFoundError,
33
+ createTokenExpiredError,
34
+ createTokenInvalidError,
47
35
  createUserExistsError,
48
36
  err,
49
37
  ok
50
38
  } from "@vertz/errors";
51
- import bcrypt from "bcryptjs";
52
- import * as jose from "jose";
53
39
 
54
- // src/auth/access.ts
55
- class AuthorizationError extends Error {
56
- entitlement;
57
- userId;
58
- constructor(message, entitlement, userId) {
59
- super(message);
60
- this.entitlement = entitlement;
61
- this.userId = userId;
62
- this.name = "AuthorizationError";
40
+ // src/auth/billing-period.ts
41
+ function calculateBillingPeriod(startedAt, per, now = new Date) {
42
+ if (per === "month") {
43
+ return calculateMonthlyPeriod(startedAt, now);
63
44
  }
45
+ const durationMs = per === "day" ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
46
+ const elapsed = now.getTime() - startedAt.getTime();
47
+ const periodsElapsed = Math.floor(elapsed / durationMs);
48
+ const periodStart = new Date(startedAt.getTime() + periodsElapsed * durationMs);
49
+ const periodEnd = new Date(periodStart.getTime() + durationMs);
50
+ return { periodStart, periodEnd };
64
51
  }
65
- function createAccess(config) {
66
- const { roles, entitlements } = config;
67
- const roleEntitlements = new Map;
68
- for (const [roleName, roleDef] of Object.entries(roles)) {
69
- roleEntitlements.set(roleName, new Set(roleDef.entitlements));
52
+ function calculateMonthlyPeriod(startedAt, now) {
53
+ if (now.getTime() < startedAt.getTime()) {
54
+ return { periodStart: new Date(startedAt), periodEnd: addMonths(startedAt, 1) };
70
55
  }
71
- const entitlementRoles = new Map;
72
- for (const [entName, entDef] of Object.entries(entitlements)) {
73
- entitlementRoles.set(entName, new Set(entDef.roles));
56
+ let periodStart = new Date(startedAt);
57
+ const approxMonths = (now.getUTCFullYear() - startedAt.getUTCFullYear()) * 12 + (now.getUTCMonth() - startedAt.getUTCMonth());
58
+ if (approxMonths > 0) {
59
+ periodStart = addMonths(startedAt, approxMonths);
60
+ if (periodStart.getTime() > now.getTime()) {
61
+ periodStart = addMonths(startedAt, approxMonths - 1);
62
+ }
74
63
  }
75
- function roleHasEntitlement(role, entitlement) {
76
- const roleEnts = roleEntitlements.get(role);
77
- if (!roleEnts)
78
- return false;
79
- if (roleEnts.has(entitlement))
80
- return true;
81
- const [resource, action2] = entitlement.split(":");
82
- if (action2 && resource !== "*") {
83
- const wildcard = `${resource}:*`;
84
- if (roleEnts.has(wildcard))
85
- return true;
64
+ const periodEnd = addMonths(startedAt, getMonthOffset(startedAt, periodStart) + 1);
65
+ return { periodStart, periodEnd };
66
+ }
67
+ function addMonths(base, months) {
68
+ const result = new Date(base);
69
+ const targetMonth = result.getUTCMonth() + months;
70
+ result.setUTCMonth(targetMonth);
71
+ const expectedMonth = ((base.getUTCMonth() + months) % 12 + 12) % 12;
72
+ if (result.getUTCMonth() !== expectedMonth) {
73
+ result.setUTCDate(0);
74
+ }
75
+ return result;
76
+ }
77
+ function getMonthOffset(base, target) {
78
+ return (target.getUTCFullYear() - base.getUTCFullYear()) * 12 + (target.getUTCMonth() - base.getUTCMonth());
79
+ }
80
+
81
+ // src/auth/plan-store.ts
82
+ function resolveEffectivePlan(orgPlan, plans, defaultPlan = "free", now) {
83
+ if (!orgPlan)
84
+ return null;
85
+ if (orgPlan.expiresAt && orgPlan.expiresAt.getTime() < (now ?? Date.now())) {
86
+ return plans?.[defaultPlan] ? defaultPlan : null;
87
+ }
88
+ if (!plans?.[orgPlan.planId])
89
+ return null;
90
+ return orgPlan.planId;
91
+ }
92
+
93
+ class InMemoryPlanStore {
94
+ plans = new Map;
95
+ async assignPlan(orgId, planId, startedAt = new Date, expiresAt = null) {
96
+ this.plans.set(orgId, {
97
+ orgId,
98
+ planId,
99
+ startedAt,
100
+ expiresAt,
101
+ overrides: {}
102
+ });
103
+ }
104
+ async getPlan(orgId) {
105
+ return this.plans.get(orgId) ?? null;
106
+ }
107
+ async updateOverrides(orgId, overrides) {
108
+ const plan = this.plans.get(orgId);
109
+ if (!plan)
110
+ return;
111
+ plan.overrides = { ...plan.overrides, ...overrides };
112
+ }
113
+ async removePlan(orgId) {
114
+ this.plans.delete(orgId);
115
+ }
116
+ dispose() {
117
+ this.plans.clear();
118
+ }
119
+ }
120
+
121
+ // src/auth/access-set.ts
122
+ async function computeAccessSet(config) {
123
+ const {
124
+ userId,
125
+ accessDef,
126
+ roleStore,
127
+ closureStore,
128
+ plan,
129
+ flagStore,
130
+ planStore,
131
+ walletStore,
132
+ orgId
133
+ } = config;
134
+ const entitlements = {};
135
+ let resolvedPlan = plan ?? null;
136
+ if (!userId) {
137
+ for (const name of Object.keys(accessDef.entitlements)) {
138
+ entitlements[name] = {
139
+ allowed: false,
140
+ reasons: ["not_authenticated"],
141
+ reason: "not_authenticated"
142
+ };
86
143
  }
144
+ return {
145
+ entitlements,
146
+ flags: {},
147
+ plan: plan ?? null,
148
+ computedAt: new Date().toISOString()
149
+ };
150
+ }
151
+ const assignments = await roleStore.getRolesForUser(userId);
152
+ const effectiveRolesByType = new Map;
153
+ for (const assignment of assignments) {
154
+ addRole(effectiveRolesByType, assignment.resourceType, assignment.role);
155
+ const descendants = await closureStore.getDescendants(assignment.resourceType, assignment.resourceId);
156
+ for (const desc of descendants) {
157
+ if (desc.depth === 0)
158
+ continue;
159
+ const inheritedRole = resolveInheritedRole(assignment.resourceType, assignment.role, desc.type, accessDef);
160
+ if (inheritedRole) {
161
+ addRole(effectiveRolesByType, desc.type, inheritedRole);
162
+ }
163
+ }
164
+ }
165
+ const allRoles = new Set;
166
+ for (const roles of effectiveRolesByType.values()) {
167
+ for (const role of roles) {
168
+ allRoles.add(role);
169
+ }
170
+ }
171
+ for (const [name, entDef] of Object.entries(accessDef.entitlements)) {
172
+ if (entDef.roles.length === 0) {
173
+ entitlements[name] = { allowed: true, reasons: [] };
174
+ continue;
175
+ }
176
+ const hasRequiredRole = entDef.roles.some((r) => allRoles.has(r));
177
+ if (hasRequiredRole) {
178
+ entitlements[name] = { allowed: true, reasons: [] };
179
+ } else {
180
+ entitlements[name] = {
181
+ allowed: false,
182
+ reasons: ["role_required"],
183
+ reason: "role_required",
184
+ meta: { requiredRoles: [...entDef.roles] }
185
+ };
186
+ }
187
+ }
188
+ const resolvedFlags = {};
189
+ if (flagStore && orgId) {
190
+ const orgFlags = flagStore.getFlags(orgId);
191
+ Object.assign(resolvedFlags, orgFlags);
192
+ for (const [name, entDef] of Object.entries(accessDef.entitlements)) {
193
+ if (entDef.flags?.length) {
194
+ const disabledFlags = [];
195
+ for (const flag of entDef.flags) {
196
+ if (!flagStore.getFlag(orgId, flag)) {
197
+ disabledFlags.push(flag);
198
+ }
199
+ }
200
+ if (disabledFlags.length > 0) {
201
+ const entry = entitlements[name];
202
+ const reasons = [...entry.reasons];
203
+ if (!reasons.includes("flag_disabled"))
204
+ reasons.push("flag_disabled");
205
+ entitlements[name] = {
206
+ ...entry,
207
+ allowed: false,
208
+ reasons,
209
+ reason: reasons[0],
210
+ meta: { ...entry.meta, disabledFlags }
211
+ };
212
+ }
213
+ }
214
+ }
215
+ }
216
+ if (planStore && orgId) {
217
+ const orgPlan = await planStore.getPlan(orgId);
218
+ if (orgPlan) {
219
+ const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
220
+ resolvedPlan = effectivePlanId;
221
+ if (effectivePlanId) {
222
+ const planDef = accessDef.plans?.[effectivePlanId];
223
+ if (planDef) {
224
+ for (const [name, entDef] of Object.entries(accessDef.entitlements)) {
225
+ if (entDef.plans?.length && !planDef.entitlements.includes(name)) {
226
+ const entry = entitlements[name];
227
+ const reasons = [...entry.reasons];
228
+ if (!reasons.includes("plan_required"))
229
+ reasons.push("plan_required");
230
+ entitlements[name] = {
231
+ ...entry,
232
+ allowed: false,
233
+ reasons,
234
+ reason: reasons[0],
235
+ meta: { ...entry.meta, requiredPlans: [...entDef.plans] }
236
+ };
237
+ }
238
+ if (walletStore && planDef.limits?.[name]) {
239
+ const limitDef = planDef.limits[name];
240
+ const override = orgPlan.overrides[name];
241
+ const effectiveLimit = override ? Math.max(override.max, limitDef.max) : limitDef.max;
242
+ const { periodStart, periodEnd } = calculateBillingPeriod(orgPlan.startedAt, limitDef.per);
243
+ const consumed = await walletStore.getConsumption(orgId, name, periodStart, periodEnd);
244
+ const remaining = Math.max(0, effectiveLimit - consumed);
245
+ const entry = entitlements[name];
246
+ if (consumed >= effectiveLimit) {
247
+ const reasons = [...entry.reasons];
248
+ if (!reasons.includes("limit_reached"))
249
+ reasons.push("limit_reached");
250
+ entitlements[name] = {
251
+ ...entry,
252
+ allowed: false,
253
+ reasons,
254
+ reason: reasons[0],
255
+ meta: {
256
+ ...entry.meta,
257
+ limit: { max: effectiveLimit, consumed, remaining }
258
+ }
259
+ };
260
+ } else {
261
+ entitlements[name] = {
262
+ ...entry,
263
+ meta: {
264
+ ...entry.meta,
265
+ limit: { max: effectiveLimit, consumed, remaining }
266
+ }
267
+ };
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+ return {
276
+ entitlements,
277
+ flags: resolvedFlags,
278
+ plan: resolvedPlan,
279
+ computedAt: new Date().toISOString()
280
+ };
281
+ }
282
+ function encodeAccessSet(set) {
283
+ const entitlements = {};
284
+ for (const [name, check] of Object.entries(set.entitlements)) {
285
+ if (check.allowed) {
286
+ const entry = { allowed: true };
287
+ if (check.meta?.limit) {
288
+ entry.meta = { limit: { ...check.meta.limit } };
289
+ }
290
+ entitlements[name] = entry;
291
+ } else if (check.meta && Object.keys(check.meta).length > 0) {
292
+ const strippedMeta = { ...check.meta };
293
+ delete strippedMeta.requiredRoles;
294
+ delete strippedMeta.requiredPlans;
295
+ const hasRemainingMeta = Object.keys(strippedMeta).length > 0;
296
+ entitlements[name] = {
297
+ allowed: false,
298
+ ...check.reasons.length > 0 ? { reasons: [...check.reasons] } : {},
299
+ ...check.reason ? { reason: check.reason } : {},
300
+ ...hasRemainingMeta ? { meta: strippedMeta } : {}
301
+ };
302
+ }
303
+ }
304
+ return {
305
+ entitlements,
306
+ flags: { ...set.flags },
307
+ plan: set.plan,
308
+ computedAt: set.computedAt
309
+ };
310
+ }
311
+ function decodeAccessSet(encoded, accessDef) {
312
+ const entitlements = {};
313
+ for (const [name, check] of Object.entries(encoded.entitlements)) {
314
+ entitlements[name] = {
315
+ allowed: check.allowed,
316
+ reasons: check.reasons ?? [],
317
+ ...check.reason ? { reason: check.reason } : {},
318
+ ...check.meta ? { meta: check.meta } : {}
319
+ };
320
+ }
321
+ for (const name of Object.keys(accessDef.entitlements)) {
322
+ if (!(name in entitlements)) {
323
+ entitlements[name] = {
324
+ allowed: false,
325
+ reasons: ["role_required"],
326
+ reason: "role_required"
327
+ };
328
+ }
329
+ }
330
+ return {
331
+ entitlements,
332
+ flags: { ...encoded.flags },
333
+ plan: encoded.plan,
334
+ computedAt: encoded.computedAt
335
+ };
336
+ }
337
+ function addRole(map, resourceType, role) {
338
+ let roles = map.get(resourceType);
339
+ if (!roles) {
340
+ roles = new Set;
341
+ map.set(resourceType, roles);
342
+ }
343
+ roles.add(role);
344
+ }
345
+ function resolveInheritedRole(sourceType, sourceRole, targetType, accessDef) {
346
+ const hierarchy = accessDef.hierarchy;
347
+ const sourceIdx = hierarchy.indexOf(sourceType);
348
+ const targetIdx = hierarchy.indexOf(targetType);
349
+ if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
350
+ return null;
351
+ let currentRole = sourceRole;
352
+ for (let i = sourceIdx;i < targetIdx; i++) {
353
+ const currentType = hierarchy[i];
354
+ const inheritanceMap = accessDef.inheritance[currentType];
355
+ if (!inheritanceMap || !(currentRole in inheritanceMap))
356
+ return null;
357
+ currentRole = inheritanceMap[currentRole];
358
+ }
359
+ return currentRole;
360
+ }
361
+
362
+ // src/auth/cookies.ts
363
+ var DEFAULT_COOKIE_CONFIG = {
364
+ name: "vertz.sid",
365
+ httpOnly: true,
366
+ secure: true,
367
+ sameSite: "lax",
368
+ path: "/",
369
+ maxAge: 60
370
+ };
371
+ function buildSessionCookie(value, cookieConfig, clear = false) {
372
+ const name = cookieConfig.name || "vertz.sid";
373
+ const maxAge = cookieConfig.maxAge ?? 60;
374
+ const path = cookieConfig.path || "/";
375
+ const sameSite = cookieConfig.sameSite || "lax";
376
+ const secure = cookieConfig.secure ?? true;
377
+ if (clear) {
378
+ return `${name}=; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=0`;
379
+ }
380
+ return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=${maxAge}`;
381
+ }
382
+ function buildRefreshCookie(value, cookieConfig, refreshName, refreshMaxAge, clear = false) {
383
+ const name = refreshName;
384
+ const sameSite = cookieConfig.sameSite || "lax";
385
+ const secure = cookieConfig.secure ?? true;
386
+ const path = "/api/auth/refresh";
387
+ if (clear) {
388
+ return `${name}=; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=0`;
389
+ }
390
+ return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=${refreshMaxAge}`;
391
+ }
392
+ function buildMfaChallengeCookie(value, cookieConfig, clear = false) {
393
+ const name = "vertz.mfa";
394
+ const path = "/api/auth/mfa";
395
+ const sameSite = cookieConfig.sameSite || "lax";
396
+ const secure = cookieConfig.secure ?? true;
397
+ if (clear) {
398
+ return `${name}=; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=0`;
399
+ }
400
+ return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=300`;
401
+ }
402
+ function buildOAuthStateCookie(value, cookieConfig, clear = false) {
403
+ const name = "vertz.oauth";
404
+ const path = "/api/auth/oauth";
405
+ const sameSite = cookieConfig.sameSite || "lax";
406
+ const secure = cookieConfig.secure ?? true;
407
+ if (clear) {
408
+ return `${name}=; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=0`;
409
+ }
410
+ return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=300`;
411
+ }
412
+
413
+ // src/auth/crypto.ts
414
+ function timingSafeEqual(a, b) {
415
+ if (a.length !== b.length)
87
416
  return false;
417
+ const encoder = new TextEncoder;
418
+ const bufA = encoder.encode(a);
419
+ const bufB = encoder.encode(b);
420
+ let result = 0;
421
+ for (let i = 0;i < bufA.length; i++) {
422
+ result |= bufA[i] ^ bufB[i];
88
423
  }
89
- async function checkEntitlement(entitlement, user) {
90
- if (!user)
91
- return false;
92
- const allowedRoles = entitlementRoles.get(entitlement);
93
- if (!allowedRoles) {
94
- return false;
424
+ return result === 0;
425
+ }
426
+ async function sha256Hex(input) {
427
+ const data = new TextEncoder().encode(input);
428
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
429
+ const hashArray = new Uint8Array(hashBuffer);
430
+ return Array.from(hashArray).map((b) => b.toString(16).padStart(2, "0")).join("");
431
+ }
432
+ function toBase64Url(buffer) {
433
+ return btoa(String.fromCharCode(...buffer)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
434
+ }
435
+ function fromBase64Url(str) {
436
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
437
+ const binary = atob(padded);
438
+ const bytes = new Uint8Array(binary.length);
439
+ for (let i = 0;i < binary.length; i++) {
440
+ bytes[i] = binary.charCodeAt(i);
441
+ }
442
+ return bytes;
443
+ }
444
+ async function deriveKey(passphrase) {
445
+ const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "HKDF", false, ["deriveKey"]);
446
+ return crypto.subtle.deriveKey({
447
+ name: "HKDF",
448
+ hash: "SHA-256",
449
+ salt: new TextEncoder().encode("vertz-oauth-state"),
450
+ info: new TextEncoder().encode("aes-256-gcm")
451
+ }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
452
+ }
453
+ async function encrypt(plaintext, key) {
454
+ const aesKey = await deriveKey(key);
455
+ const iv = crypto.getRandomValues(new Uint8Array(12));
456
+ const encoded = new TextEncoder().encode(plaintext);
457
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded);
458
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
459
+ combined.set(iv, 0);
460
+ combined.set(new Uint8Array(ciphertext), iv.length);
461
+ return toBase64Url(combined);
462
+ }
463
+ async function decrypt(ciphertext, key) {
464
+ try {
465
+ const aesKey = await deriveKey(key);
466
+ const combined = fromBase64Url(ciphertext);
467
+ const iv = combined.slice(0, 12);
468
+ const data = combined.slice(12);
469
+ const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, data);
470
+ return new TextDecoder().decode(decrypted);
471
+ } catch {
472
+ return null;
473
+ }
474
+ }
475
+ function generateCodeVerifier() {
476
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
477
+ return toBase64Url(bytes);
478
+ }
479
+ async function generateCodeChallenge(verifier) {
480
+ const data = new TextEncoder().encode(verifier);
481
+ const hash = await crypto.subtle.digest("SHA-256", data);
482
+ return toBase64Url(new Uint8Array(hash));
483
+ }
484
+ function generateNonce() {
485
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
486
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
487
+ }
488
+
489
+ // src/auth/device-name.ts
490
+ function parseDeviceName(userAgent) {
491
+ if (!userAgent)
492
+ return "Unknown device";
493
+ let browser = "";
494
+ if (/Edg\//.test(userAgent))
495
+ browser = "Edge";
496
+ else if (/Chrome\//.test(userAgent) && !/Chromium\//.test(userAgent))
497
+ browser = "Chrome";
498
+ else if (/Firefox\//.test(userAgent))
499
+ browser = "Firefox";
500
+ else if (/Safari\//.test(userAgent) && !/Chrome\//.test(userAgent))
501
+ browser = "Safari";
502
+ let os = "";
503
+ if (/iPhone/.test(userAgent))
504
+ os = "iPhone";
505
+ else if (/iPad/.test(userAgent))
506
+ os = "iPad";
507
+ else if (/Mac OS X/.test(userAgent))
508
+ os = "macOS";
509
+ else if (/Windows/.test(userAgent))
510
+ os = "Windows";
511
+ else if (/Linux/.test(userAgent))
512
+ os = "Linux";
513
+ else if (/Android/.test(userAgent))
514
+ os = "Android";
515
+ if (browser && os)
516
+ return `${browser} on ${os}`;
517
+ if (browser)
518
+ return browser;
519
+ if (os)
520
+ return os;
521
+ return "Unknown device";
522
+ }
523
+
524
+ // src/auth/email-verification-store.ts
525
+ class InMemoryEmailVerificationStore {
526
+ byId = new Map;
527
+ byTokenHash = new Map;
528
+ byUserId = new Map;
529
+ async createVerification(data) {
530
+ const verification = {
531
+ id: crypto.randomUUID(),
532
+ userId: data.userId,
533
+ tokenHash: data.tokenHash,
534
+ expiresAt: data.expiresAt,
535
+ createdAt: new Date
536
+ };
537
+ this.byId.set(verification.id, verification);
538
+ this.byTokenHash.set(data.tokenHash, verification);
539
+ const userSet = this.byUserId.get(data.userId) ?? new Set;
540
+ userSet.add(verification.id);
541
+ this.byUserId.set(data.userId, userSet);
542
+ return verification;
543
+ }
544
+ async findByTokenHash(tokenHash) {
545
+ return this.byTokenHash.get(tokenHash) ?? null;
546
+ }
547
+ async deleteByUserId(userId) {
548
+ const ids = this.byUserId.get(userId);
549
+ if (ids) {
550
+ for (const id of ids) {
551
+ const verification = this.byId.get(id);
552
+ if (verification) {
553
+ this.byTokenHash.delete(verification.tokenHash);
554
+ this.byId.delete(id);
555
+ }
556
+ }
557
+ this.byUserId.delete(userId);
558
+ }
559
+ }
560
+ async deleteByTokenHash(tokenHash) {
561
+ const verification = this.byTokenHash.get(tokenHash);
562
+ if (verification) {
563
+ this.byTokenHash.delete(tokenHash);
564
+ this.byId.delete(verification.id);
565
+ const userSet = this.byUserId.get(verification.userId);
566
+ if (userSet) {
567
+ userSet.delete(verification.id);
568
+ if (userSet.size === 0) {
569
+ this.byUserId.delete(verification.userId);
570
+ }
571
+ }
572
+ }
573
+ }
574
+ dispose() {
575
+ this.byId.clear();
576
+ this.byTokenHash.clear();
577
+ this.byUserId.clear();
578
+ }
579
+ }
580
+
581
+ // src/auth/jwt.ts
582
+ import * as jose from "jose";
583
+ function parseDuration(duration) {
584
+ if (typeof duration === "number")
585
+ return duration;
586
+ const match = duration.match(/^(\d+)([smhd])$/);
587
+ if (!match) {
588
+ throw new Error(`Invalid duration: "${duration}". Expected format: <number><unit> where unit is s (seconds), m (minutes), h (hours), or d (days). Examples: "60s", "15m", "7d".`);
589
+ }
590
+ const value = parseInt(match[1], 10);
591
+ const unit = match[2];
592
+ const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
593
+ return value * multipliers[unit] * 1000;
594
+ }
595
+ async function createJWT(user, secret, ttl, algorithm, customClaims) {
596
+ const claims = customClaims ? customClaims(user) : {};
597
+ const jwt = await new jose.SignJWT({
598
+ sub: user.id,
599
+ email: user.email,
600
+ role: user.role,
601
+ ...claims
602
+ }).setProtectedHeader({ alg: algorithm }).setIssuedAt().setExpirationTime(`${Math.floor(ttl / 1000)}s`).sign(new TextEncoder().encode(secret));
603
+ return jwt;
604
+ }
605
+ async function verifyJWT(token, secret, algorithm) {
606
+ try {
607
+ const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(secret), {
608
+ algorithms: [algorithm]
609
+ });
610
+ if (typeof payload.sub !== "string" || typeof payload.email !== "string" || typeof payload.role !== "string" || typeof payload.jti !== "string" || typeof payload.sid !== "string") {
611
+ return null;
612
+ }
613
+ return payload;
614
+ } catch {
615
+ return null;
616
+ }
617
+ }
618
+
619
+ // src/auth/mfa-store.ts
620
+ class InMemoryMFAStore {
621
+ secrets = new Map;
622
+ backupCodes = new Map;
623
+ enabled = new Set;
624
+ async enableMfa(userId, encryptedSecret) {
625
+ this.secrets.set(userId, encryptedSecret);
626
+ this.enabled.add(userId);
627
+ }
628
+ async disableMfa(userId) {
629
+ this.secrets.delete(userId);
630
+ this.backupCodes.delete(userId);
631
+ this.enabled.delete(userId);
632
+ }
633
+ async getSecret(userId) {
634
+ return this.secrets.get(userId) ?? null;
635
+ }
636
+ async isMfaEnabled(userId) {
637
+ return this.enabled.has(userId);
638
+ }
639
+ async setBackupCodes(userId, hashedCodes) {
640
+ this.backupCodes.set(userId, [...hashedCodes]);
641
+ }
642
+ async getBackupCodes(userId) {
643
+ return this.backupCodes.get(userId) ?? [];
644
+ }
645
+ async consumeBackupCode(userId, hashedCode) {
646
+ const codes = this.backupCodes.get(userId);
647
+ if (codes) {
648
+ this.backupCodes.set(userId, codes.filter((c) => c !== hashedCode));
649
+ }
650
+ }
651
+ dispose() {
652
+ this.secrets.clear();
653
+ this.backupCodes.clear();
654
+ this.enabled.clear();
655
+ }
656
+ }
657
+
658
+ // src/auth/oauth-account-store.ts
659
+ class InMemoryOAuthAccountStore {
660
+ byProviderAccount = new Map;
661
+ byUserId = new Map;
662
+ providerKey(provider, providerId) {
663
+ return `${provider}:${providerId}`;
664
+ }
665
+ async linkAccount(userId, provider, providerId, email) {
666
+ const link = { userId, provider, providerId, email };
667
+ this.byProviderAccount.set(this.providerKey(provider, providerId), link);
668
+ const userLinks = this.byUserId.get(userId) ?? [];
669
+ const exists = userLinks.some((l) => l.provider === provider && l.providerId === providerId);
670
+ if (!exists) {
671
+ userLinks.push(link);
672
+ this.byUserId.set(userId, userLinks);
673
+ }
674
+ }
675
+ async findByProviderAccount(provider, providerId) {
676
+ const link = this.byProviderAccount.get(this.providerKey(provider, providerId));
677
+ return link?.userId ?? null;
678
+ }
679
+ async findByUserId(userId) {
680
+ const links = this.byUserId.get(userId) ?? [];
681
+ return links.map(({ provider, providerId }) => ({ provider, providerId }));
682
+ }
683
+ async unlinkAccount(userId, provider) {
684
+ const userLinks = this.byUserId.get(userId) ?? [];
685
+ const linkToRemove = userLinks.find((l) => l.provider === provider);
686
+ if (linkToRemove) {
687
+ this.byProviderAccount.delete(this.providerKey(provider, linkToRemove.providerId));
688
+ this.byUserId.set(userId, userLinks.filter((l) => l.provider !== provider));
689
+ }
690
+ }
691
+ dispose() {
692
+ this.byProviderAccount.clear();
693
+ this.byUserId.clear();
694
+ }
695
+ }
696
+
697
+ // src/auth/password.ts
698
+ import { createAuthValidationError } from "@vertz/errors";
699
+ import bcrypt from "bcryptjs";
700
+ var DEFAULT_PASSWORD_REQUIREMENTS = {
701
+ minLength: 8,
702
+ requireUppercase: false,
703
+ requireNumbers: false,
704
+ requireSymbols: false
705
+ };
706
+ var BCRYPT_ROUNDS = 12;
707
+ async function hashPassword(password) {
708
+ return bcrypt.hash(password, BCRYPT_ROUNDS);
709
+ }
710
+ async function verifyPassword(password, hash) {
711
+ return bcrypt.compare(password, hash);
712
+ }
713
+ function validatePassword(password, requirements) {
714
+ const req = { ...DEFAULT_PASSWORD_REQUIREMENTS, ...requirements };
715
+ if (password.length < (req.minLength ?? 8)) {
716
+ return createAuthValidationError(`Password must be at least ${req.minLength} characters`, "password", "TOO_SHORT");
717
+ }
718
+ if (req.requireUppercase && !/[A-Z]/.test(password)) {
719
+ return createAuthValidationError("Password must contain at least one uppercase letter", "password", "NO_UPPERCASE");
720
+ }
721
+ if (req.requireNumbers && !/\d/.test(password)) {
722
+ return createAuthValidationError("Password must contain at least one number", "password", "NO_NUMBER");
723
+ }
724
+ if (req.requireSymbols && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
725
+ return createAuthValidationError("Password must contain at least one symbol", "password", "NO_SYMBOL");
726
+ }
727
+ return null;
728
+ }
729
+
730
+ // src/auth/password-reset-store.ts
731
+ class InMemoryPasswordResetStore {
732
+ byId = new Map;
733
+ byTokenHash = new Map;
734
+ byUserId = new Map;
735
+ async createReset(data) {
736
+ const reset = {
737
+ id: crypto.randomUUID(),
738
+ userId: data.userId,
739
+ tokenHash: data.tokenHash,
740
+ expiresAt: data.expiresAt,
741
+ createdAt: new Date
742
+ };
743
+ this.byId.set(reset.id, reset);
744
+ this.byTokenHash.set(data.tokenHash, reset);
745
+ const userSet = this.byUserId.get(data.userId) ?? new Set;
746
+ userSet.add(reset.id);
747
+ this.byUserId.set(data.userId, userSet);
748
+ return reset;
749
+ }
750
+ async findByTokenHash(tokenHash) {
751
+ return this.byTokenHash.get(tokenHash) ?? null;
752
+ }
753
+ async deleteByUserId(userId) {
754
+ const ids = this.byUserId.get(userId);
755
+ if (ids) {
756
+ for (const id of ids) {
757
+ const reset = this.byId.get(id);
758
+ if (reset) {
759
+ this.byTokenHash.delete(reset.tokenHash);
760
+ this.byId.delete(id);
761
+ }
762
+ }
763
+ this.byUserId.delete(userId);
764
+ }
765
+ }
766
+ dispose() {
767
+ this.byId.clear();
768
+ this.byTokenHash.clear();
769
+ this.byUserId.clear();
770
+ }
771
+ }
772
+
773
+ // src/auth/rate-limit-store.ts
774
+ var CLEANUP_INTERVAL_MS = 60000;
775
+
776
+ class InMemoryRateLimitStore {
777
+ store = new Map;
778
+ cleanupTimer;
779
+ constructor() {
780
+ this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
781
+ }
782
+ async check(key, maxAttempts, windowMs) {
783
+ const now = new Date;
784
+ const entry = this.store.get(key);
785
+ if (!entry || entry.resetAt < now) {
786
+ const resetAt = new Date(now.getTime() + windowMs);
787
+ this.store.set(key, { count: 1, resetAt });
788
+ return { allowed: true, remaining: maxAttempts - 1, resetAt };
789
+ }
790
+ if (entry.count >= maxAttempts) {
791
+ return { allowed: false, remaining: 0, resetAt: entry.resetAt };
792
+ }
793
+ entry.count++;
794
+ return { allowed: true, remaining: maxAttempts - entry.count, resetAt: entry.resetAt };
795
+ }
796
+ dispose() {
797
+ clearInterval(this.cleanupTimer);
798
+ }
799
+ cleanup() {
800
+ const now = new Date;
801
+ for (const [key, entry] of this.store) {
802
+ if (entry.resetAt < now) {
803
+ this.store.delete(key);
804
+ }
805
+ }
806
+ }
807
+ }
808
+
809
+ // src/auth/session-store.ts
810
+ var DEFAULT_MAX_SESSIONS_PER_USER = 50;
811
+ var CLEANUP_INTERVAL_MS2 = 60000;
812
+
813
+ class InMemorySessionStore {
814
+ sessions = new Map;
815
+ currentTokens = new Map;
816
+ cleanupTimer;
817
+ maxSessionsPerUser;
818
+ constructor(maxSessionsPerUser = DEFAULT_MAX_SESSIONS_PER_USER) {
819
+ this.maxSessionsPerUser = maxSessionsPerUser;
820
+ this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS2);
821
+ }
822
+ async createSession(data) {
823
+ const activeSessions = await this.listActiveSessions(data.userId);
824
+ if (activeSessions.length >= this.maxSessionsPerUser) {
825
+ const sorted = activeSessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
826
+ const toRevoke = sorted.slice(0, activeSessions.length - this.maxSessionsPerUser + 1);
827
+ for (const s of toRevoke) {
828
+ await this.revokeSession(s.id);
829
+ }
830
+ }
831
+ const now = new Date;
832
+ const session = {
833
+ id: crypto.randomUUID(),
834
+ userId: data.userId,
835
+ refreshTokenHash: data.refreshTokenHash,
836
+ previousRefreshHash: null,
837
+ ipAddress: data.ipAddress,
838
+ userAgent: data.userAgent,
839
+ createdAt: now,
840
+ lastActiveAt: now,
841
+ expiresAt: data.expiresAt,
842
+ revokedAt: null
843
+ };
844
+ this.sessions.set(session.id, session);
845
+ return session;
846
+ }
847
+ async createSessionWithId(id, data) {
848
+ const activeSessions = await this.listActiveSessions(data.userId);
849
+ if (activeSessions.length >= this.maxSessionsPerUser) {
850
+ const sorted = activeSessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
851
+ const toRevoke = sorted.slice(0, activeSessions.length - this.maxSessionsPerUser + 1);
852
+ for (const s of toRevoke) {
853
+ await this.revokeSession(s.id);
854
+ }
855
+ }
856
+ const now = new Date;
857
+ const session = {
858
+ id,
859
+ userId: data.userId,
860
+ refreshTokenHash: data.refreshTokenHash,
861
+ previousRefreshHash: null,
862
+ ipAddress: data.ipAddress,
863
+ userAgent: data.userAgent,
864
+ createdAt: now,
865
+ lastActiveAt: now,
866
+ expiresAt: data.expiresAt,
867
+ revokedAt: null
868
+ };
869
+ this.sessions.set(session.id, session);
870
+ if (data.currentTokens) {
871
+ this.currentTokens.set(id, data.currentTokens);
872
+ }
873
+ return session;
874
+ }
875
+ async findByRefreshHash(hash) {
876
+ for (const session of this.sessions.values()) {
877
+ if (timingSafeEqual(session.refreshTokenHash, hash) && !session.revokedAt && session.expiresAt > new Date) {
878
+ return session;
879
+ }
880
+ }
881
+ return null;
882
+ }
883
+ async findByPreviousRefreshHash(hash) {
884
+ for (const session of this.sessions.values()) {
885
+ if (session.previousRefreshHash !== null && timingSafeEqual(session.previousRefreshHash, hash) && !session.revokedAt && session.expiresAt > new Date) {
886
+ return session;
887
+ }
888
+ }
889
+ return null;
890
+ }
891
+ async revokeSession(id) {
892
+ const session = this.sessions.get(id);
893
+ if (session) {
894
+ session.revokedAt = new Date;
895
+ this.currentTokens.delete(id);
896
+ }
897
+ }
898
+ async listActiveSessions(userId) {
899
+ const now = new Date;
900
+ const result = [];
901
+ for (const session of this.sessions.values()) {
902
+ if (session.userId === userId && !session.revokedAt && session.expiresAt > now) {
903
+ result.push(session);
904
+ }
905
+ }
906
+ return result;
907
+ }
908
+ async countActiveSessions(userId) {
909
+ const sessions = await this.listActiveSessions(userId);
910
+ return sessions.length;
911
+ }
912
+ async getCurrentTokens(sessionId) {
913
+ return this.currentTokens.get(sessionId) ?? null;
914
+ }
915
+ async updateSession(id, data) {
916
+ const session = this.sessions.get(id);
917
+ if (session) {
918
+ session.refreshTokenHash = data.refreshTokenHash;
919
+ session.previousRefreshHash = data.previousRefreshHash;
920
+ session.lastActiveAt = data.lastActiveAt;
921
+ if (data.currentTokens) {
922
+ this.currentTokens.set(id, data.currentTokens);
923
+ }
924
+ }
925
+ }
926
+ dispose() {
927
+ clearInterval(this.cleanupTimer);
928
+ }
929
+ cleanup() {
930
+ const now = new Date;
931
+ for (const [id, session] of this.sessions) {
932
+ if (session.expiresAt < now || session.revokedAt) {
933
+ this.sessions.delete(id);
934
+ this.currentTokens.delete(id);
935
+ }
936
+ }
937
+ }
938
+ }
939
+
940
+ // src/auth/totp.ts
941
+ import { timingSafeEqual as cryptoTimingSafeEqual } from "node:crypto";
942
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
943
+ function base32Encode(buffer) {
944
+ let bits = 0;
945
+ let value = 0;
946
+ let result = "";
947
+ for (let i = 0;i < buffer.length; i++) {
948
+ value = value << 8 | buffer[i];
949
+ bits += 8;
950
+ while (bits >= 5) {
951
+ result += BASE32_ALPHABET[value >>> bits - 5 & 31];
952
+ bits -= 5;
953
+ }
954
+ }
955
+ if (bits > 0) {
956
+ result += BASE32_ALPHABET[value << 5 - bits & 31];
957
+ }
958
+ return result;
959
+ }
960
+ function base32Decode(encoded) {
961
+ const str = encoded.replace(/=+$/, "").toUpperCase();
962
+ const output = [];
963
+ let bits = 0;
964
+ let value = 0;
965
+ for (let i = 0;i < str.length; i++) {
966
+ const idx = BASE32_ALPHABET.indexOf(str[i]);
967
+ if (idx === -1)
968
+ continue;
969
+ value = value << 5 | idx;
970
+ bits += 5;
971
+ if (bits >= 8) {
972
+ output.push(value >>> bits - 8 & 255);
973
+ bits -= 8;
974
+ }
975
+ }
976
+ return new Uint8Array(output);
977
+ }
978
+ var TOTP_PERIOD = 30;
979
+ var TOTP_DIGITS = 6;
980
+ function generateTotpSecret() {
981
+ const bytes = crypto.getRandomValues(new Uint8Array(20));
982
+ return base32Encode(bytes);
983
+ }
984
+ async function verifyTotpCode(secret, code, timestamp, drift = 1) {
985
+ const time = timestamp ?? Date.now();
986
+ const counter = Math.floor(time / 1000 / TOTP_PERIOD);
987
+ for (let i = -drift;i <= drift; i++) {
988
+ const expected = await hmacOtp(secret, counter + i);
989
+ if (timingSafeEqual2(code, expected)) {
990
+ return true;
991
+ }
992
+ }
993
+ return false;
994
+ }
995
+ function generateTotpUri(secret, email, issuer) {
996
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(email)}`;
997
+ const params = new URLSearchParams({
998
+ secret,
999
+ issuer,
1000
+ algorithm: "SHA1",
1001
+ digits: String(TOTP_DIGITS),
1002
+ period: String(TOTP_PERIOD)
1003
+ });
1004
+ return `otpauth://totp/${label}?${params.toString()}`;
1005
+ }
1006
+ var BACKUP_CODE_LENGTH = 8;
1007
+ var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
1008
+ function generateBackupCodes(count = 10) {
1009
+ const codes = [];
1010
+ const seen = new Set;
1011
+ while (codes.length < count) {
1012
+ const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH));
1013
+ let code = "";
1014
+ for (let i = 0;i < BACKUP_CODE_LENGTH; i++) {
1015
+ code += BACKUP_CODE_CHARS[bytes[i] % BACKUP_CODE_CHARS.length];
1016
+ }
1017
+ if (!seen.has(code)) {
1018
+ seen.add(code);
1019
+ codes.push(code);
1020
+ }
1021
+ }
1022
+ return codes;
1023
+ }
1024
+ async function hashBackupCode(code) {
1025
+ return hashPassword(code);
1026
+ }
1027
+ async function verifyBackupCode(code, hash) {
1028
+ return verifyPassword(code, hash);
1029
+ }
1030
+ async function hmacOtp(secret, counter) {
1031
+ const keyBytes = base32Decode(secret);
1032
+ const key = await crypto.subtle.importKey("raw", keyBytes.buffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
1033
+ const counterBuffer = new ArrayBuffer(8);
1034
+ const view = new DataView(counterBuffer);
1035
+ view.setUint32(4, counter, false);
1036
+ const hmac = await crypto.subtle.sign("HMAC", key, counterBuffer);
1037
+ const hmacBytes = new Uint8Array(hmac);
1038
+ const offset = hmacBytes[hmacBytes.length - 1] & 15;
1039
+ const binary = (hmacBytes[offset] & 127) << 24 | (hmacBytes[offset + 1] & 255) << 16 | (hmacBytes[offset + 2] & 255) << 8 | hmacBytes[offset + 3] & 255;
1040
+ const otp = binary % 10 ** TOTP_DIGITS;
1041
+ return otp.toString().padStart(TOTP_DIGITS, "0");
1042
+ }
1043
+ function timingSafeEqual2(a, b) {
1044
+ const maxLen = Math.max(a.length, b.length);
1045
+ const bufA = Buffer.alloc(maxLen);
1046
+ const bufB = Buffer.alloc(maxLen);
1047
+ bufA.write(a);
1048
+ bufB.write(b);
1049
+ const equal = cryptoTimingSafeEqual(bufA, bufB);
1050
+ return equal && a.length === b.length;
1051
+ }
1052
+
1053
+ // src/auth/user-store.ts
1054
+ class InMemoryUserStore {
1055
+ byEmail = new Map;
1056
+ byId = new Map;
1057
+ async createUser(user, passwordHash) {
1058
+ this.byEmail.set(user.email.toLowerCase(), { user, passwordHash });
1059
+ this.byId.set(user.id, user);
1060
+ }
1061
+ async findByEmail(email) {
1062
+ return this.byEmail.get(email.toLowerCase()) ?? null;
1063
+ }
1064
+ async findById(id) {
1065
+ return this.byId.get(id) ?? null;
1066
+ }
1067
+ async updatePasswordHash(userId, passwordHash) {
1068
+ const user = this.byId.get(userId);
1069
+ if (user) {
1070
+ const entry = this.byEmail.get(user.email.toLowerCase());
1071
+ if (entry) {
1072
+ entry.passwordHash = passwordHash;
1073
+ }
1074
+ }
1075
+ }
1076
+ async updateEmailVerified(userId, verified) {
1077
+ const user = this.byId.get(userId);
1078
+ if (user) {
1079
+ user.emailVerified = verified;
1080
+ }
1081
+ }
1082
+ }
1083
+ // src/auth/access.ts
1084
+ class AuthorizationError extends Error {
1085
+ entitlement;
1086
+ userId;
1087
+ constructor(message, entitlement, userId) {
1088
+ super(message);
1089
+ this.entitlement = entitlement;
1090
+ this.userId = userId;
1091
+ this.name = "AuthorizationError";
1092
+ }
1093
+ }
1094
+ function createAccess(config) {
1095
+ const { roles, entitlements } = config;
1096
+ const roleEntitlements = new Map;
1097
+ for (const [roleName, roleDef] of Object.entries(roles)) {
1098
+ roleEntitlements.set(roleName, new Set(roleDef.entitlements));
1099
+ }
1100
+ const entitlementRoles = new Map;
1101
+ for (const [entName, entDef] of Object.entries(entitlements)) {
1102
+ entitlementRoles.set(entName, new Set(entDef.roles));
1103
+ }
1104
+ function roleHasEntitlement(role, entitlement) {
1105
+ const roleEnts = roleEntitlements.get(role);
1106
+ if (!roleEnts)
1107
+ return false;
1108
+ if (roleEnts.has(entitlement))
1109
+ return true;
1110
+ const [resource, action] = entitlement.split(":");
1111
+ if (action && resource !== "*") {
1112
+ const wildcard = `${resource}:*`;
1113
+ if (roleEnts.has(wildcard))
1114
+ return true;
1115
+ }
1116
+ return false;
1117
+ }
1118
+ async function checkEntitlement(entitlement, user) {
1119
+ if (!user)
1120
+ return false;
1121
+ const allowedRoles = entitlementRoles.get(entitlement);
1122
+ if (!allowedRoles) {
1123
+ return false;
1124
+ }
1125
+ return allowedRoles.has(user.role) || roleHasEntitlement(user.role, entitlement);
1126
+ }
1127
+ async function can(entitlement, user) {
1128
+ return checkEntitlement(entitlement, user);
1129
+ }
1130
+ async function canWithResource(entitlement, resource, user) {
1131
+ const hasEntitlement = await checkEntitlement(entitlement, user);
1132
+ if (!hasEntitlement)
1133
+ return false;
1134
+ if (resource.ownerId != null && resource.ownerId !== "" && resource.ownerId !== user?.id) {
1135
+ return false;
1136
+ }
1137
+ return true;
1138
+ }
1139
+ async function authorize(entitlement, user) {
1140
+ const allowed = await can(entitlement, user);
1141
+ if (!allowed) {
1142
+ throw new AuthorizationError(`Not authorized to perform this action: ${entitlement}`, entitlement, user?.id);
1143
+ }
1144
+ }
1145
+ async function authorizeWithResource(entitlement, resource, user) {
1146
+ const allowed = await canWithResource(entitlement, resource, user);
1147
+ if (!allowed) {
1148
+ throw new AuthorizationError(`Not authorized to perform this action on this resource: ${entitlement}`, entitlement, user?.id);
1149
+ }
1150
+ }
1151
+ async function canAll(checks, user) {
1152
+ const results = new Map;
1153
+ for (const { entitlement, resource } of checks) {
1154
+ const key = resource ? `${entitlement}:${resource.id}` : entitlement;
1155
+ const allowed = resource ? await canWithResource(entitlement, resource, user) : await can(entitlement, user);
1156
+ results.set(key, allowed);
1157
+ }
1158
+ return results;
1159
+ }
1160
+ function getEntitlementsForRole(role) {
1161
+ const roleEnts = roleEntitlements.get(role);
1162
+ return roleEnts ? Array.from(roleEnts) : [];
1163
+ }
1164
+ function createMiddleware() {
1165
+ return async (ctx, next) => {
1166
+ ctx.can = async (entitlement, resource) => {
1167
+ if (resource) {
1168
+ return canWithResource(entitlement, resource, ctx.user ?? null);
1169
+ }
1170
+ return can(entitlement, ctx.user ?? null);
1171
+ };
1172
+ ctx.authorize = async (entitlement, resource) => {
1173
+ if (resource) {
1174
+ return authorizeWithResource(entitlement, resource, ctx.user ?? null);
1175
+ }
1176
+ return authorize(entitlement, ctx.user ?? null);
1177
+ };
1178
+ await next();
1179
+ };
1180
+ }
1181
+ return {
1182
+ can,
1183
+ canWithResource,
1184
+ authorize,
1185
+ authorizeWithResource,
1186
+ canAll,
1187
+ getEntitlementsForRole,
1188
+ middleware: createMiddleware
1189
+ };
1190
+ }
1191
+ var defaultAccess = createAccess({
1192
+ roles: {
1193
+ user: { entitlements: ["read", "create"] },
1194
+ editor: { entitlements: ["read", "create", "update"] },
1195
+ admin: { entitlements: ["read", "create", "update", "delete"] }
1196
+ },
1197
+ entitlements: {
1198
+ "user:read": { roles: ["user", "editor", "admin"] },
1199
+ "user:create": { roles: ["user", "editor", "admin"] },
1200
+ "user:update": { roles: ["editor", "admin"] },
1201
+ "user:delete": { roles: ["admin"] },
1202
+ read: { roles: ["user", "editor", "admin"] },
1203
+ create: { roles: ["user", "editor", "admin"] },
1204
+ update: { roles: ["editor", "admin"] },
1205
+ delete: { roles: ["admin"] }
1206
+ }
1207
+ });
1208
+ // src/auth/access-context.ts
1209
+ var MAX_BULK_CHECKS = 100;
1210
+ function createAccessContext(config) {
1211
+ const {
1212
+ userId,
1213
+ accessDef,
1214
+ closureStore,
1215
+ roleStore,
1216
+ flagStore,
1217
+ planStore,
1218
+ walletStore,
1219
+ orgResolver
1220
+ } = config;
1221
+ async function checkLayers1to4(entitlement, resource, resolvedOrgId) {
1222
+ if (!userId)
1223
+ return false;
1224
+ const entDef = accessDef.entitlements[entitlement];
1225
+ if (!entDef)
1226
+ return false;
1227
+ if (entDef.flags?.length && flagStore && orgResolver) {
1228
+ if (!resolvedOrgId)
1229
+ return false;
1230
+ for (const flag of entDef.flags) {
1231
+ if (!flagStore.getFlag(resolvedOrgId, flag)) {
1232
+ return false;
1233
+ }
1234
+ }
1235
+ }
1236
+ if (entDef.roles.length > 0 && resource) {
1237
+ const effectiveRole = await roleStore.getEffectiveRole(userId, resource.type, resource.id, accessDef, closureStore);
1238
+ if (!effectiveRole || !entDef.roles.includes(effectiveRole)) {
1239
+ return false;
1240
+ }
1241
+ } else if (entDef.roles.length > 0 && !resource) {
1242
+ return false;
1243
+ }
1244
+ if (entDef.plans?.length && planStore && orgResolver) {
1245
+ if (!resolvedOrgId)
1246
+ return false;
1247
+ const orgPlan = await planStore.getPlan(resolvedOrgId);
1248
+ const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1249
+ if (!effectivePlanId)
1250
+ return false;
1251
+ const planDef = accessDef.plans?.[effectivePlanId];
1252
+ if (!planDef || !planDef.entitlements.includes(entitlement)) {
1253
+ return false;
1254
+ }
1255
+ }
1256
+ return true;
1257
+ }
1258
+ async function can(entitlement, resource) {
1259
+ const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
1260
+ if (!await checkLayers1to4(entitlement, resource, resolvedOrgId))
1261
+ return false;
1262
+ const entDef = accessDef.entitlements[entitlement];
1263
+ if (entDef?.plans?.length && walletStore && planStore && resolvedOrgId) {
1264
+ const walletState = await resolveWalletStateFromOrgId(entitlement, resolvedOrgId, accessDef, planStore, walletStore);
1265
+ if (walletState && walletState.consumed >= walletState.limit) {
1266
+ return false;
1267
+ }
1268
+ }
1269
+ return true;
1270
+ }
1271
+ async function check(entitlement, resource) {
1272
+ const reasons = [];
1273
+ const meta = {};
1274
+ if (!userId) {
1275
+ return {
1276
+ allowed: false,
1277
+ reasons: ["not_authenticated"],
1278
+ reason: "not_authenticated"
1279
+ };
1280
+ }
1281
+ const entDef = accessDef.entitlements[entitlement];
1282
+ if (!entDef) {
1283
+ return {
1284
+ allowed: false,
1285
+ reasons: ["role_required"],
1286
+ reason: "role_required"
1287
+ };
1288
+ }
1289
+ const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
1290
+ if (entDef.flags?.length && flagStore && orgResolver) {
1291
+ if (resolvedOrgId) {
1292
+ const disabledFlags = [];
1293
+ for (const flag of entDef.flags) {
1294
+ if (!flagStore.getFlag(resolvedOrgId, flag)) {
1295
+ disabledFlags.push(flag);
1296
+ }
1297
+ }
1298
+ if (disabledFlags.length > 0) {
1299
+ reasons.push("flag_disabled");
1300
+ meta.disabledFlags = disabledFlags;
1301
+ }
1302
+ } else {
1303
+ reasons.push("flag_disabled");
1304
+ meta.disabledFlags = [...entDef.flags];
1305
+ }
1306
+ }
1307
+ if (entDef.roles.length > 0) {
1308
+ let hasRole = false;
1309
+ if (resource) {
1310
+ const effectiveRole = await roleStore.getEffectiveRole(userId, resource.type, resource.id, accessDef, closureStore);
1311
+ hasRole = !!effectiveRole && entDef.roles.includes(effectiveRole);
1312
+ }
1313
+ if (!hasRole) {
1314
+ reasons.push("role_required");
1315
+ meta.requiredRoles = [...entDef.roles];
1316
+ }
1317
+ }
1318
+ if (entDef.plans?.length && planStore && orgResolver) {
1319
+ let planDenied = false;
1320
+ if (!resolvedOrgId) {
1321
+ planDenied = true;
1322
+ } else {
1323
+ const orgPlan = await planStore.getPlan(resolvedOrgId);
1324
+ const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1325
+ if (!effectivePlanId) {
1326
+ planDenied = true;
1327
+ } else {
1328
+ const planDef = accessDef.plans?.[effectivePlanId];
1329
+ if (!planDef || !planDef.entitlements.includes(entitlement)) {
1330
+ planDenied = true;
1331
+ }
1332
+ }
1333
+ }
1334
+ if (planDenied) {
1335
+ reasons.push("plan_required");
1336
+ meta.requiredPlans = [...entDef.plans];
1337
+ }
1338
+ }
1339
+ if (entDef.plans?.length && walletStore && planStore && resolvedOrgId) {
1340
+ const walletState = await resolveWalletStateFromOrgId(entitlement, resolvedOrgId, accessDef, planStore, walletStore);
1341
+ if (walletState) {
1342
+ meta.limit = {
1343
+ max: walletState.limit,
1344
+ consumed: walletState.consumed,
1345
+ remaining: Math.max(0, walletState.limit - walletState.consumed)
1346
+ };
1347
+ if (walletState.consumed >= walletState.limit) {
1348
+ reasons.push("limit_reached");
1349
+ }
1350
+ }
1351
+ }
1352
+ const orderedReasons = orderDenialReasons(reasons);
1353
+ return {
1354
+ allowed: orderedReasons.length === 0,
1355
+ reasons: orderedReasons,
1356
+ reason: orderedReasons[0],
1357
+ meta: Object.keys(meta).length > 0 ? meta : undefined
1358
+ };
1359
+ }
1360
+ async function authorize(entitlement, resource) {
1361
+ const result = await can(entitlement, resource);
1362
+ if (!result) {
1363
+ throw new AuthorizationError(`Not authorized: ${entitlement}`, entitlement, userId ?? undefined);
1364
+ }
1365
+ }
1366
+ async function canAll(checks) {
1367
+ if (checks.length > MAX_BULK_CHECKS) {
1368
+ throw new Error(`canAll() is limited to ${MAX_BULK_CHECKS} checks per call`);
1369
+ }
1370
+ const results = new Map;
1371
+ for (const { entitlement, resource } of checks) {
1372
+ const key = resource ? `${entitlement}:${resource.id}` : entitlement;
1373
+ const allowed = await can(entitlement, resource);
1374
+ results.set(key, allowed);
1375
+ }
1376
+ return results;
1377
+ }
1378
+ async function canAndConsume(entitlement, resource, amount = 1) {
1379
+ const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
1380
+ if (!await checkLayers1to4(entitlement, resource, resolvedOrgId))
1381
+ return false;
1382
+ if (!walletStore || !planStore || !orgResolver)
1383
+ return true;
1384
+ const entDef = accessDef.entitlements[entitlement];
1385
+ if (!entDef?.plans?.length)
1386
+ return true;
1387
+ if (!resolvedOrgId)
1388
+ return false;
1389
+ const orgPlan = await planStore.getPlan(resolvedOrgId);
1390
+ if (!orgPlan)
1391
+ return false;
1392
+ const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1393
+ if (!effectivePlanId)
1394
+ return false;
1395
+ const planDef = accessDef.plans?.[effectivePlanId];
1396
+ if (!planDef)
1397
+ return false;
1398
+ const limitDef = planDef.limits?.[entitlement];
1399
+ if (!limitDef)
1400
+ return true;
1401
+ const effectiveLimit = resolveEffectiveLimit(orgPlan, entitlement, limitDef);
1402
+ const { periodStart, periodEnd } = calculateBillingPeriod(orgPlan.startedAt, limitDef.per);
1403
+ const result = await walletStore.consume(resolvedOrgId, entitlement, periodStart, periodEnd, effectiveLimit, amount);
1404
+ return result.success;
1405
+ }
1406
+ async function unconsume(entitlement, resource, amount = 1) {
1407
+ if (!walletStore || !planStore || !orgResolver)
1408
+ return;
1409
+ const entDef = accessDef.entitlements[entitlement];
1410
+ if (!entDef?.plans?.length)
1411
+ return;
1412
+ const orgId = await orgResolver(resource);
1413
+ if (!orgId)
1414
+ return;
1415
+ const orgPlan = await planStore.getPlan(orgId);
1416
+ if (!orgPlan)
1417
+ return;
1418
+ const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1419
+ if (!effectivePlanId)
1420
+ return;
1421
+ const planDef = accessDef.plans?.[effectivePlanId];
1422
+ const limitDef = planDef?.limits?.[entitlement];
1423
+ if (!limitDef)
1424
+ return;
1425
+ const { periodStart, periodEnd } = calculateBillingPeriod(orgPlan.startedAt, limitDef.per);
1426
+ await walletStore.unconsume(orgId, entitlement, periodStart, periodEnd, amount);
1427
+ }
1428
+ return { can, check, authorize, canAll, canAndConsume, unconsume };
1429
+ }
1430
+ var DENIAL_ORDER = [
1431
+ "plan_required",
1432
+ "role_required",
1433
+ "limit_reached",
1434
+ "flag_disabled",
1435
+ "hierarchy_denied",
1436
+ "step_up_required",
1437
+ "not_authenticated"
1438
+ ];
1439
+ function orderDenialReasons(reasons) {
1440
+ return [...reasons].sort((a, b) => DENIAL_ORDER.indexOf(a) - DENIAL_ORDER.indexOf(b));
1441
+ }
1442
+ async function resolveWalletStateFromOrgId(entitlement, orgId, accessDef, planStore, walletStore) {
1443
+ const orgPlan = await planStore.getPlan(orgId);
1444
+ const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1445
+ if (!effectivePlanId || !orgPlan)
1446
+ return null;
1447
+ const planDef = accessDef.plans?.[effectivePlanId];
1448
+ const limitDef = planDef?.limits?.[entitlement];
1449
+ if (!limitDef)
1450
+ return null;
1451
+ const effectiveLimit = resolveEffectiveLimit(orgPlan, entitlement, limitDef);
1452
+ const { periodStart, periodEnd } = calculateBillingPeriod(orgPlan.startedAt, limitDef.per);
1453
+ const consumed = await walletStore.getConsumption(orgId, entitlement, periodStart, periodEnd);
1454
+ return { limit: effectiveLimit, consumed };
1455
+ }
1456
+ function resolveEffectiveLimit(orgPlan, entitlement, planLimit) {
1457
+ const override = orgPlan.overrides[entitlement];
1458
+ if (!override)
1459
+ return planLimit.max;
1460
+ return Math.max(override.max, planLimit.max);
1461
+ }
1462
+ // src/auth/access-event-broadcaster.ts
1463
+ function createAccessEventBroadcaster(config) {
1464
+ const {
1465
+ jwtSecret,
1466
+ jwtAlgorithm = "HS256",
1467
+ path = "/api/auth/access-events",
1468
+ cookieName = "vertz.sid"
1469
+ } = config;
1470
+ const connectionsByOrg = new Map;
1471
+ const connectionsByUser = new Map;
1472
+ let connectionCount = 0;
1473
+ const allConnections = new Set;
1474
+ const PING_INTERVAL_MS = 30000;
1475
+ const pingTimer = setInterval(() => {
1476
+ for (const ws of allConnections) {
1477
+ try {
1478
+ ws.ping();
1479
+ } catch {}
1480
+ }
1481
+ }, PING_INTERVAL_MS);
1482
+ if (typeof pingTimer === "object" && "unref" in pingTimer) {
1483
+ pingTimer.unref();
1484
+ }
1485
+ function addConnection(ws) {
1486
+ const { orgId, userId } = ws.data;
1487
+ let orgSet = connectionsByOrg.get(orgId);
1488
+ if (!orgSet) {
1489
+ orgSet = new Set;
1490
+ connectionsByOrg.set(orgId, orgSet);
1491
+ }
1492
+ orgSet.add(ws);
1493
+ let userSet = connectionsByUser.get(userId);
1494
+ if (!userSet) {
1495
+ userSet = new Set;
1496
+ connectionsByUser.set(userId, userSet);
1497
+ }
1498
+ userSet.add(ws);
1499
+ allConnections.add(ws);
1500
+ connectionCount++;
1501
+ }
1502
+ function removeConnection(ws) {
1503
+ const { orgId, userId } = ws.data;
1504
+ const orgSet = connectionsByOrg.get(orgId);
1505
+ if (orgSet) {
1506
+ orgSet.delete(ws);
1507
+ if (orgSet.size === 0)
1508
+ connectionsByOrg.delete(orgId);
1509
+ }
1510
+ const userSet = connectionsByUser.get(userId);
1511
+ if (userSet) {
1512
+ userSet.delete(ws);
1513
+ if (userSet.size === 0)
1514
+ connectionsByUser.delete(userId);
1515
+ }
1516
+ allConnections.delete(ws);
1517
+ connectionCount--;
1518
+ }
1519
+ function broadcastToOrg(orgId, message) {
1520
+ const orgSet = connectionsByOrg.get(orgId);
1521
+ if (!orgSet)
1522
+ return;
1523
+ for (const ws of orgSet) {
1524
+ ws.send(message);
1525
+ }
1526
+ }
1527
+ function broadcastToUser(userId, message) {
1528
+ const userSet = connectionsByUser.get(userId);
1529
+ if (!userSet)
1530
+ return;
1531
+ for (const ws of userSet) {
1532
+ ws.send(message);
1533
+ }
1534
+ }
1535
+ function parseCookie(request) {
1536
+ const cookieHeader = request.headers.get("cookie");
1537
+ if (!cookieHeader)
1538
+ return null;
1539
+ const cookies = cookieHeader.split(";").map((c) => c.trim());
1540
+ for (const cookie of cookies) {
1541
+ const [name, ...rest] = cookie.split("=");
1542
+ if (name === cookieName) {
1543
+ return rest.join("=");
1544
+ }
1545
+ }
1546
+ return null;
1547
+ }
1548
+ async function handleUpgrade(request, server) {
1549
+ const url = new URL(request.url);
1550
+ if (url.pathname !== path)
1551
+ return false;
1552
+ const token = parseCookie(request);
1553
+ if (!token)
1554
+ return false;
1555
+ try {
1556
+ const payload = await verifyJWT(token, jwtSecret, jwtAlgorithm);
1557
+ if (!payload || !payload.sub)
1558
+ return false;
1559
+ const claims = payload.claims;
1560
+ const orgId = claims?.orgId ?? "";
1561
+ return server.upgrade(request, {
1562
+ data: {
1563
+ userId: payload.sub,
1564
+ orgId
1565
+ }
1566
+ });
1567
+ } catch {
1568
+ return false;
1569
+ }
1570
+ }
1571
+ const websocket = {
1572
+ open(ws) {
1573
+ addConnection(ws);
1574
+ },
1575
+ message(_ws, msg) {
1576
+ const msgStr = typeof msg === "string" ? msg : msg.toString();
1577
+ if (msgStr === "pong") {
1578
+ return;
1579
+ }
1580
+ },
1581
+ close(ws) {
1582
+ removeConnection(ws);
1583
+ }
1584
+ };
1585
+ function broadcastFlagToggle(orgId, flag, enabled) {
1586
+ const event = { type: "access:flag_toggled", orgId, flag, enabled };
1587
+ broadcastToOrg(orgId, JSON.stringify(event));
1588
+ }
1589
+ function broadcastLimitUpdate(orgId, entitlement, consumed, remaining, max) {
1590
+ const event = {
1591
+ type: "access:limit_updated",
1592
+ orgId,
1593
+ entitlement,
1594
+ consumed,
1595
+ remaining,
1596
+ max
1597
+ };
1598
+ broadcastToOrg(orgId, JSON.stringify(event));
1599
+ }
1600
+ function broadcastRoleChange(userId) {
1601
+ const event = { type: "access:role_changed", userId };
1602
+ broadcastToUser(userId, JSON.stringify(event));
1603
+ }
1604
+ function broadcastPlanChange(orgId) {
1605
+ const event = { type: "access:plan_changed", orgId };
1606
+ broadcastToOrg(orgId, JSON.stringify(event));
1607
+ }
1608
+ return {
1609
+ handleUpgrade,
1610
+ websocket,
1611
+ broadcastFlagToggle,
1612
+ broadcastLimitUpdate,
1613
+ broadcastRoleChange,
1614
+ broadcastPlanChange,
1615
+ get getConnectionCount() {
1616
+ return connectionCount;
1617
+ }
1618
+ };
1619
+ }
1620
+ // src/auth/closure-store.ts
1621
+ var MAX_DEPTH = 4;
1622
+
1623
+ class InMemoryClosureStore {
1624
+ rows = [];
1625
+ async addResource(type, id, parent) {
1626
+ this.rows.push({
1627
+ ancestorType: type,
1628
+ ancestorId: id,
1629
+ descendantType: type,
1630
+ descendantId: id,
1631
+ depth: 0
1632
+ });
1633
+ if (parent) {
1634
+ const parentAncestors = await this.getAncestors(parent.parentType, parent.parentId);
1635
+ const maxParentDepth = Math.max(...parentAncestors.map((a) => a.depth), 0);
1636
+ if (maxParentDepth + 1 >= MAX_DEPTH) {
1637
+ this.rows.pop();
1638
+ throw new Error("Hierarchy depth exceeds maximum of 4 levels");
1639
+ }
1640
+ for (const ancestor of parentAncestors) {
1641
+ this.rows.push({
1642
+ ancestorType: ancestor.type,
1643
+ ancestorId: ancestor.id,
1644
+ descendantType: type,
1645
+ descendantId: id,
1646
+ depth: ancestor.depth + 1
1647
+ });
1648
+ }
1649
+ }
1650
+ }
1651
+ async removeResource(type, id) {
1652
+ const descendants = await this.getDescendants(type, id);
1653
+ const descendantKeys = new Set(descendants.map((d) => `${d.type}:${d.id}`));
1654
+ this.rows = this.rows.filter((row) => {
1655
+ const ancestorKey = `${row.ancestorType}:${row.ancestorId}`;
1656
+ const descendantKey = `${row.descendantType}:${row.descendantId}`;
1657
+ return !descendantKeys.has(ancestorKey) && !descendantKeys.has(descendantKey);
1658
+ });
1659
+ }
1660
+ async getAncestors(type, id) {
1661
+ return this.rows.filter((row) => row.descendantType === type && row.descendantId === id).map((row) => ({
1662
+ type: row.ancestorType,
1663
+ id: row.ancestorId,
1664
+ depth: row.depth
1665
+ }));
1666
+ }
1667
+ async getDescendants(type, id) {
1668
+ return this.rows.filter((row) => row.ancestorType === type && row.ancestorId === id).map((row) => ({
1669
+ type: row.descendantType,
1670
+ id: row.descendantId,
1671
+ depth: row.depth
1672
+ }));
1673
+ }
1674
+ async hasPath(ancestorType, ancestorId, descendantType, descendantId) {
1675
+ return this.rows.some((row) => row.ancestorType === ancestorType && row.ancestorId === ancestorId && row.descendantType === descendantType && row.descendantId === descendantId);
1676
+ }
1677
+ dispose() {
1678
+ this.rows = [];
1679
+ }
1680
+ }
1681
+ // src/auth/define-access.ts
1682
+ function defineAccess(input) {
1683
+ if (input.hierarchy.length === 0) {
1684
+ throw new Error("Hierarchy must have at least one resource type");
1685
+ }
1686
+ if (input.hierarchy.length > 4) {
1687
+ throw new Error("Hierarchy depth must not exceed 4 levels");
1688
+ }
1689
+ const hierarchySet = new Set(input.hierarchy);
1690
+ for (const resourceType of Object.keys(input.roles)) {
1691
+ if (!hierarchySet.has(resourceType)) {
1692
+ throw new Error(`Roles reference unknown resource type: ${resourceType}`);
1693
+ }
1694
+ }
1695
+ const inheritance = input.inheritance ?? {};
1696
+ for (const [resourceType, mapping] of Object.entries(inheritance)) {
1697
+ if (!hierarchySet.has(resourceType)) {
1698
+ throw new Error(`Inheritance references unknown resource type: ${resourceType}`);
1699
+ }
1700
+ const parentRoles = new Set(input.roles[resourceType] ?? []);
1701
+ const hierarchyIdx = input.hierarchy.indexOf(resourceType);
1702
+ const childType = input.hierarchy[hierarchyIdx + 1];
1703
+ const childRoles = childType ? new Set(input.roles[childType] ?? []) : new Set;
1704
+ for (const [parentRole, childRole] of Object.entries(mapping)) {
1705
+ if (!parentRoles.has(parentRole)) {
1706
+ throw new Error(`Inheritance for ${resourceType} references undefined role: ${parentRole}`);
1707
+ }
1708
+ if (!childRoles.has(childRole)) {
1709
+ throw new Error(`Inheritance for ${resourceType} maps to undefined child role: ${childRole}`);
1710
+ }
1711
+ }
1712
+ }
1713
+ const planNameSet = new Set(Object.keys(input.plans ?? {}));
1714
+ for (const [entName, entDef] of Object.entries(input.entitlements)) {
1715
+ if (entDef.plans) {
1716
+ for (const planRef of entDef.plans) {
1717
+ if (!planNameSet.has(planRef)) {
1718
+ throw new Error(`Entitlement "${entName}" references unknown plan: ${planRef}`);
1719
+ }
1720
+ }
1721
+ }
1722
+ }
1723
+ const entitlementSet = new Set(Object.keys(input.entitlements));
1724
+ if (input.plans) {
1725
+ for (const [planName, planDef] of Object.entries(input.plans)) {
1726
+ const planEntitlementSet = new Set(planDef.entitlements);
1727
+ for (const ent of planDef.entitlements) {
1728
+ if (!entitlementSet.has(ent)) {
1729
+ throw new Error(`Plan "${planName}" references unknown entitlement: ${ent}`);
1730
+ }
1731
+ }
1732
+ if (planDef.limits) {
1733
+ for (const limitKey of Object.keys(planDef.limits)) {
1734
+ if (!planEntitlementSet.has(limitKey)) {
1735
+ throw new Error(`Plan "${planName}" has limit for "${limitKey}" which is not in the plan's entitlements`);
1736
+ }
1737
+ }
1738
+ }
1739
+ }
1740
+ }
1741
+ const config = {
1742
+ hierarchy: Object.freeze([...input.hierarchy]),
1743
+ roles: Object.freeze(Object.fromEntries(Object.entries(input.roles).map(([k, v]) => [k, Object.freeze([...v])]))),
1744
+ inheritance: Object.freeze(Object.fromEntries(Object.entries(input.inheritance ?? {}).map(([k, v]) => [k, Object.freeze({ ...v })]))),
1745
+ entitlements: Object.freeze(Object.fromEntries(Object.entries(input.entitlements).map(([k, v]) => [k, Object.freeze({ ...v })]))),
1746
+ ...input.defaultPlan ? { defaultPlan: input.defaultPlan } : {},
1747
+ ...input.plans ? {
1748
+ plans: Object.freeze(Object.fromEntries(Object.entries(input.plans).map(([planName, planDef]) => [
1749
+ planName,
1750
+ Object.freeze({
1751
+ entitlements: Object.freeze([...planDef.entitlements]),
1752
+ ...planDef.limits ? {
1753
+ limits: Object.freeze(Object.fromEntries(Object.entries(planDef.limits).map(([k, v]) => [
1754
+ k,
1755
+ Object.freeze({ ...v })
1756
+ ])))
1757
+ } : {}
1758
+ })
1759
+ ])))
1760
+ } : {}
1761
+ };
1762
+ return Object.freeze(config);
1763
+ }
1764
+ // src/auth/entity-access.ts
1765
+ async function computeEntityAccess(entitlements, entity, accessContext) {
1766
+ const results = {};
1767
+ for (const entitlement of entitlements) {
1768
+ results[entitlement] = await accessContext.check(entitlement, {
1769
+ type: entity.type,
1770
+ id: entity.id
1771
+ });
1772
+ }
1773
+ return results;
1774
+ }
1775
+ // src/auth/flag-store.ts
1776
+ class InMemoryFlagStore {
1777
+ flags = new Map;
1778
+ setFlag(orgId, flag, enabled) {
1779
+ let orgFlags = this.flags.get(orgId);
1780
+ if (!orgFlags) {
1781
+ orgFlags = new Map;
1782
+ this.flags.set(orgId, orgFlags);
1783
+ }
1784
+ orgFlags.set(flag, enabled);
1785
+ }
1786
+ getFlag(orgId, flag) {
1787
+ return this.flags.get(orgId)?.get(flag) ?? false;
1788
+ }
1789
+ getFlags(orgId) {
1790
+ const orgFlags = this.flags.get(orgId);
1791
+ if (!orgFlags)
1792
+ return {};
1793
+ const result = {};
1794
+ for (const [key, value] of orgFlags) {
1795
+ result[key] = value;
1796
+ }
1797
+ return result;
1798
+ }
1799
+ }
1800
+ // src/auth/fva.ts
1801
+ function checkFva(payload, maxAgeSeconds) {
1802
+ if (payload.fva === undefined)
1803
+ return false;
1804
+ const now = Math.floor(Date.now() / 1000);
1805
+ return now - payload.fva < maxAgeSeconds;
1806
+ }
1807
+ // src/auth/providers/discord.ts
1808
+ var AUTHORIZATION_URL = "https://discord.com/api/oauth2/authorize";
1809
+ var TOKEN_URL = "https://discord.com/api/oauth2/token";
1810
+ var USER_URL = "https://discord.com/api/users/@me";
1811
+ var DEFAULT_SCOPES = ["identify", "email"];
1812
+ function discord(config) {
1813
+ const scopes = config.scopes ?? DEFAULT_SCOPES;
1814
+ return {
1815
+ id: "discord",
1816
+ name: "Discord",
1817
+ scopes,
1818
+ trustEmail: false,
1819
+ getAuthorizationUrl(state, codeChallenge) {
1820
+ const params = new URLSearchParams({
1821
+ client_id: config.clientId,
1822
+ redirect_uri: config.redirectUrl ?? "",
1823
+ response_type: "code",
1824
+ scope: scopes.join(" "),
1825
+ state
1826
+ });
1827
+ if (codeChallenge) {
1828
+ params.set("code_challenge", codeChallenge);
1829
+ params.set("code_challenge_method", "S256");
1830
+ }
1831
+ return `${AUTHORIZATION_URL}?${params.toString()}`;
1832
+ },
1833
+ async exchangeCode(code, codeVerifier) {
1834
+ const body = new URLSearchParams({
1835
+ client_id: config.clientId,
1836
+ client_secret: config.clientSecret,
1837
+ code,
1838
+ grant_type: "authorization_code",
1839
+ redirect_uri: config.redirectUrl ?? ""
1840
+ });
1841
+ if (codeVerifier) {
1842
+ body.set("code_verifier", codeVerifier);
1843
+ }
1844
+ const response = await fetch(TOKEN_URL, {
1845
+ method: "POST",
1846
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1847
+ body: body.toString()
1848
+ });
1849
+ const data = await response.json();
1850
+ return {
1851
+ accessToken: data.access_token,
1852
+ refreshToken: data.refresh_token,
1853
+ expiresIn: data.expires_in
1854
+ };
1855
+ },
1856
+ async getUserInfo(accessToken) {
1857
+ const response = await fetch(USER_URL, {
1858
+ headers: { Authorization: `Bearer ${accessToken}` }
1859
+ });
1860
+ const data = await response.json();
1861
+ return {
1862
+ providerId: data.id,
1863
+ email: data.email ?? "",
1864
+ emailVerified: data.verified ?? false,
1865
+ name: data.global_name ?? data.username,
1866
+ avatarUrl: data.avatar ? `https://cdn.discordapp.com/avatars/${data.id}/${data.avatar}.png` : undefined
1867
+ };
1868
+ }
1869
+ };
1870
+ }
1871
+ // src/auth/providers/github.ts
1872
+ var AUTHORIZATION_URL2 = "https://github.com/login/oauth/authorize";
1873
+ var TOKEN_URL2 = "https://github.com/login/oauth/access_token";
1874
+ var USER_URL2 = "https://api.github.com/user";
1875
+ var EMAILS_URL = "https://api.github.com/user/emails";
1876
+ var DEFAULT_SCOPES2 = ["read:user", "user:email"];
1877
+ function github(config) {
1878
+ const scopes = config.scopes ?? DEFAULT_SCOPES2;
1879
+ return {
1880
+ id: "github",
1881
+ name: "GitHub",
1882
+ scopes,
1883
+ trustEmail: false,
1884
+ getAuthorizationUrl(state) {
1885
+ const params = new URLSearchParams({
1886
+ client_id: config.clientId,
1887
+ redirect_uri: config.redirectUrl ?? "",
1888
+ scope: scopes.join(" "),
1889
+ state
1890
+ });
1891
+ return `${AUTHORIZATION_URL2}?${params.toString()}`;
1892
+ },
1893
+ async exchangeCode(code) {
1894
+ const response = await fetch(TOKEN_URL2, {
1895
+ method: "POST",
1896
+ headers: {
1897
+ Accept: "application/json",
1898
+ "Content-Type": "application/json"
1899
+ },
1900
+ body: JSON.stringify({
1901
+ client_id: config.clientId,
1902
+ client_secret: config.clientSecret,
1903
+ code,
1904
+ redirect_uri: config.redirectUrl ?? ""
1905
+ })
1906
+ });
1907
+ const data = await response.json();
1908
+ return {
1909
+ accessToken: data.access_token
1910
+ };
1911
+ },
1912
+ async getUserInfo(accessToken) {
1913
+ const [userResponse, emailsResponse] = await Promise.all([
1914
+ fetch(USER_URL2, {
1915
+ headers: {
1916
+ Authorization: `Bearer ${accessToken}`,
1917
+ "User-Agent": "vertz-auth/1.0"
1918
+ }
1919
+ }),
1920
+ fetch(EMAILS_URL, {
1921
+ headers: {
1922
+ Authorization: `Bearer ${accessToken}`,
1923
+ "User-Agent": "vertz-auth/1.0"
1924
+ }
1925
+ })
1926
+ ]);
1927
+ const userData = await userResponse.json();
1928
+ const emails = await emailsResponse.json();
1929
+ const primaryEmail = emails.find((e) => e.primary && e.verified);
1930
+ const fallbackEmail = emails.find((e) => e.verified);
1931
+ const email = primaryEmail ?? fallbackEmail;
1932
+ return {
1933
+ providerId: String(userData.id),
1934
+ email: email?.email ?? "",
1935
+ emailVerified: email?.verified ?? false,
1936
+ name: userData.name,
1937
+ avatarUrl: userData.avatar_url
1938
+ };
1939
+ }
1940
+ };
1941
+ }
1942
+ // src/auth/providers/google.ts
1943
+ var AUTHORIZATION_URL3 = "https://accounts.google.com/o/oauth2/v2/auth";
1944
+ var TOKEN_URL3 = "https://oauth2.googleapis.com/token";
1945
+ var DEFAULT_SCOPES3 = ["openid", "email", "profile"];
1946
+ function google(config) {
1947
+ const scopes = config.scopes ?? DEFAULT_SCOPES3;
1948
+ return {
1949
+ id: "google",
1950
+ name: "Google",
1951
+ scopes,
1952
+ trustEmail: true,
1953
+ getAuthorizationUrl(state, codeChallenge, nonce) {
1954
+ const params = new URLSearchParams({
1955
+ client_id: config.clientId,
1956
+ redirect_uri: config.redirectUrl ?? "",
1957
+ response_type: "code",
1958
+ scope: scopes.join(" "),
1959
+ state,
1960
+ access_type: "offline",
1961
+ prompt: "consent"
1962
+ });
1963
+ if (codeChallenge) {
1964
+ params.set("code_challenge", codeChallenge);
1965
+ params.set("code_challenge_method", "S256");
1966
+ }
1967
+ if (nonce) {
1968
+ params.set("nonce", nonce);
1969
+ }
1970
+ return `${AUTHORIZATION_URL3}?${params.toString()}`;
1971
+ },
1972
+ async exchangeCode(code, codeVerifier) {
1973
+ const body = new URLSearchParams({
1974
+ client_id: config.clientId,
1975
+ client_secret: config.clientSecret,
1976
+ code,
1977
+ grant_type: "authorization_code",
1978
+ redirect_uri: config.redirectUrl ?? ""
1979
+ });
1980
+ if (codeVerifier) {
1981
+ body.set("code_verifier", codeVerifier);
1982
+ }
1983
+ const response = await fetch(TOKEN_URL3, {
1984
+ method: "POST",
1985
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1986
+ body: body.toString()
1987
+ });
1988
+ const data = await response.json();
1989
+ return {
1990
+ accessToken: data.access_token,
1991
+ refreshToken: data.refresh_token,
1992
+ expiresIn: data.expires_in,
1993
+ idToken: data.id_token
1994
+ };
1995
+ },
1996
+ async getUserInfo(_accessToken, idToken, nonce) {
1997
+ if (!idToken) {
1998
+ throw new Error("Google provider requires an ID token");
1999
+ }
2000
+ const parts = idToken.split(".");
2001
+ const payload = JSON.parse(atob(parts[1]));
2002
+ if (nonce && payload.nonce !== nonce) {
2003
+ throw new Error("ID token nonce mismatch");
2004
+ }
2005
+ return {
2006
+ providerId: payload.sub,
2007
+ email: payload.email,
2008
+ emailVerified: payload.email_verified,
2009
+ name: payload.name,
2010
+ avatarUrl: payload.picture
2011
+ };
2012
+ }
2013
+ };
2014
+ }
2015
+ // src/auth/role-assignment-store.ts
2016
+ class InMemoryRoleAssignmentStore {
2017
+ assignments = [];
2018
+ async assign(userId, resourceType, resourceId, role) {
2019
+ const exists = this.assignments.some((a) => a.userId === userId && a.resourceType === resourceType && a.resourceId === resourceId && a.role === role);
2020
+ if (!exists) {
2021
+ this.assignments.push({ userId, resourceType, resourceId, role });
95
2022
  }
96
- return allowedRoles.has(user.role) || roleHasEntitlement(user.role, entitlement);
97
2023
  }
98
- async function can(entitlement, user) {
99
- return checkEntitlement(entitlement, user);
2024
+ async revoke(userId, resourceType, resourceId, role) {
2025
+ this.assignments = this.assignments.filter((a) => !(a.userId === userId && a.resourceType === resourceType && a.resourceId === resourceId && a.role === role));
100
2026
  }
101
- async function canWithResource(entitlement, resource, user) {
102
- const hasEntitlement = await checkEntitlement(entitlement, user);
103
- if (!hasEntitlement)
104
- return false;
105
- if (resource.ownerId != null && resource.ownerId !== "" && resource.ownerId !== user?.id) {
106
- return false;
107
- }
108
- return true;
2027
+ async getRoles(userId, resourceType, resourceId) {
2028
+ return this.assignments.filter((a) => a.userId === userId && a.resourceType === resourceType && a.resourceId === resourceId).map((a) => a.role);
109
2029
  }
110
- async function authorize(entitlement, user) {
111
- const allowed = await can(entitlement, user);
112
- if (!allowed) {
113
- throw new AuthorizationError(`Not authorized to perform this action: ${entitlement}`, entitlement, user?.id);
114
- }
2030
+ async getRolesForUser(userId) {
2031
+ return this.assignments.filter((a) => a.userId === userId);
115
2032
  }
116
- async function authorizeWithResource(entitlement, resource, user) {
117
- const allowed = await canWithResource(entitlement, resource, user);
118
- if (!allowed) {
119
- throw new AuthorizationError(`Not authorized to perform this action on this resource: ${entitlement}`, entitlement, user?.id);
2033
+ async getEffectiveRole(userId, resourceType, resourceId, accessDef, closureStore) {
2034
+ const rolesForType = accessDef.roles[resourceType];
2035
+ if (!rolesForType || rolesForType.length === 0)
2036
+ return null;
2037
+ const candidateRoles = [];
2038
+ const directRoles = await this.getRoles(userId, resourceType, resourceId);
2039
+ candidateRoles.push(...directRoles);
2040
+ const ancestors = await closureStore.getAncestors(resourceType, resourceId);
2041
+ for (const ancestor of ancestors) {
2042
+ if (ancestor.depth === 0)
2043
+ continue;
2044
+ const ancestorRoles = await this.getRoles(userId, ancestor.type, ancestor.id);
2045
+ for (const ancestorRole of ancestorRoles) {
2046
+ const inheritedRole = resolveInheritedRole2(ancestor.type, ancestorRole, resourceType, accessDef);
2047
+ if (inheritedRole) {
2048
+ candidateRoles.push(inheritedRole);
2049
+ }
2050
+ }
120
2051
  }
121
- }
122
- async function canAll(checks, user) {
123
- const results = new Map;
124
- for (const { entitlement, resource } of checks) {
125
- const key = resource ? `${entitlement}:${resource.id}` : entitlement;
126
- const allowed = resource ? await canWithResource(entitlement, resource, user) : await can(entitlement, user);
127
- results.set(key, allowed);
2052
+ if (candidateRoles.length === 0)
2053
+ return null;
2054
+ const roleOrder = [...rolesForType];
2055
+ let bestIndex = roleOrder.length;
2056
+ for (const role of candidateRoles) {
2057
+ const idx = roleOrder.indexOf(role);
2058
+ if (idx !== -1 && idx < bestIndex) {
2059
+ bestIndex = idx;
2060
+ }
128
2061
  }
129
- return results;
2062
+ return bestIndex < roleOrder.length ? roleOrder[bestIndex] : null;
130
2063
  }
131
- function getEntitlementsForRole(role) {
132
- const roleEnts = roleEntitlements.get(role);
133
- return roleEnts ? Array.from(roleEnts) : [];
2064
+ dispose() {
2065
+ this.assignments = [];
134
2066
  }
135
- function createMiddleware() {
136
- return async (ctx, next) => {
137
- ctx.can = async (entitlement, resource) => {
138
- if (resource) {
139
- return canWithResource(entitlement, resource, ctx.user ?? null);
140
- }
141
- return can(entitlement, ctx.user ?? null);
142
- };
143
- ctx.authorize = async (entitlement, resource) => {
144
- if (resource) {
145
- return authorizeWithResource(entitlement, resource, ctx.user ?? null);
146
- }
147
- return authorize(entitlement, ctx.user ?? null);
148
- };
149
- await next();
150
- };
2067
+ }
2068
+ function resolveInheritedRole2(sourceType, sourceRole, targetType, accessDef) {
2069
+ const hierarchy = accessDef.hierarchy;
2070
+ const sourceIdx = hierarchy.indexOf(sourceType);
2071
+ const targetIdx = hierarchy.indexOf(targetType);
2072
+ if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
2073
+ return null;
2074
+ let currentRole = sourceRole;
2075
+ for (let i = sourceIdx;i < targetIdx; i++) {
2076
+ const currentType = hierarchy[i];
2077
+ const inheritanceMap = accessDef.inheritance[currentType];
2078
+ if (!inheritanceMap || !(currentRole in inheritanceMap))
2079
+ return null;
2080
+ currentRole = inheritanceMap[currentRole];
151
2081
  }
152
- return {
153
- can,
154
- canWithResource,
155
- authorize,
156
- authorizeWithResource,
157
- canAll,
158
- getEntitlementsForRole,
159
- middleware: createMiddleware
160
- };
2082
+ return currentRole;
161
2083
  }
162
- var defaultAccess = createAccess({
163
- roles: {
164
- user: { entitlements: ["read", "create"] },
165
- editor: { entitlements: ["read", "create", "update"] },
166
- admin: { entitlements: ["read", "create", "update", "delete"] }
2084
+ // src/auth/rules.ts
2085
+ var userMarkers = {
2086
+ id: Object.freeze({ __marker: "user.id" }),
2087
+ tenantId: Object.freeze({ __marker: "user.tenantId" })
2088
+ };
2089
+ var rules = {
2090
+ role(...roleNames) {
2091
+ return { type: "role", roles: Object.freeze([...roleNames]) };
167
2092
  },
168
- entitlements: {
169
- "user:read": { roles: ["user", "editor", "admin"] },
170
- "user:create": { roles: ["user", "editor", "admin"] },
171
- "user:update": { roles: ["editor", "admin"] },
172
- "user:delete": { roles: ["admin"] },
173
- read: { roles: ["user", "editor", "admin"] },
174
- create: { roles: ["user", "editor", "admin"] },
175
- update: { roles: ["editor", "admin"] },
176
- delete: { roles: ["admin"] }
2093
+ entitlement(name) {
2094
+ return { type: "entitlement", entitlement: name };
2095
+ },
2096
+ where(conditions) {
2097
+ return { type: "where", conditions: Object.freeze({ ...conditions }) };
2098
+ },
2099
+ all(...ruleList) {
2100
+ return { type: "all", rules: Object.freeze([...ruleList]) };
2101
+ },
2102
+ any(...ruleList) {
2103
+ return { type: "any", rules: Object.freeze([...ruleList]) };
2104
+ },
2105
+ authenticated() {
2106
+ return { type: "authenticated" };
2107
+ },
2108
+ fva(maxAge) {
2109
+ return { type: "fva", maxAge };
2110
+ },
2111
+ user: userMarkers
2112
+ };
2113
+ // src/auth/wallet-store.ts
2114
+ class InMemoryWalletStore {
2115
+ entries = new Map;
2116
+ key(orgId, entitlement, periodStart) {
2117
+ return `${orgId}:${entitlement}:${periodStart.getTime()}`;
177
2118
  }
178
- });
179
-
180
- // src/auth/index.ts
181
- class RateLimiter {
182
- store = new Map;
183
- windowMs;
184
- constructor(window) {
185
- this.windowMs = this.parseDuration(window);
186
- }
187
- parseDuration(duration) {
188
- const match = duration.match(/^(\d+)([smh])$/);
189
- if (!match)
190
- throw new Error(`Invalid duration: ${duration}`);
191
- const value = parseInt(match[1], 10);
192
- const unit = match[2];
193
- const multipliers = { s: 1000, m: 60000, h: 3600000 };
194
- return value * multipliers[unit];
195
- }
196
- check(key, maxAttempts) {
197
- const now = new Date;
198
- const entry = this.store.get(key);
199
- if (!entry || entry.resetAt < now) {
200
- const resetAt = new Date(now.getTime() + this.windowMs);
201
- this.store.set(key, { count: 1, resetAt });
202
- return { allowed: true, remaining: maxAttempts - 1, resetAt };
203
- }
204
- if (entry.count >= maxAttempts) {
205
- return { allowed: false, remaining: 0, resetAt: entry.resetAt };
2119
+ async consume(orgId, entitlement, periodStart, periodEnd, limit, amount = 1) {
2120
+ const k = this.key(orgId, entitlement, periodStart);
2121
+ if (!this.entries.has(k)) {
2122
+ this.entries.set(k, { orgId, entitlement, periodStart, periodEnd, consumed: 0 });
206
2123
  }
207
- entry.count++;
208
- return { allowed: true, remaining: maxAttempts - entry.count, resetAt: entry.resetAt };
209
- }
210
- cleanup() {
211
- const now = new Date;
212
- for (const [key, entry] of this.store) {
213
- if (entry.resetAt < now) {
214
- this.store.delete(key);
215
- }
2124
+ const entry = this.entries.get(k);
2125
+ if (entry.consumed + amount > limit) {
2126
+ return {
2127
+ success: false,
2128
+ consumed: entry.consumed,
2129
+ limit,
2130
+ remaining: Math.max(0, limit - entry.consumed)
2131
+ };
216
2132
  }
2133
+ entry.consumed += amount;
2134
+ return {
2135
+ success: true,
2136
+ consumed: entry.consumed,
2137
+ limit,
2138
+ remaining: limit - entry.consumed
2139
+ };
217
2140
  }
218
- }
219
- var DEFAULT_PASSWORD_REQUIREMENTS = {
220
- minLength: 8,
221
- requireUppercase: false,
222
- requireNumbers: false,
223
- requireSymbols: false
224
- };
225
- var BCRYPT_ROUNDS = 12;
226
- async function hashPassword(password) {
227
- return bcrypt.hash(password, BCRYPT_ROUNDS);
228
- }
229
- async function verifyPassword(password, hash) {
230
- return bcrypt.compare(password, hash);
231
- }
232
- function validatePassword(password, requirements) {
233
- const req = { ...DEFAULT_PASSWORD_REQUIREMENTS, ...requirements };
234
- if (password.length < (req.minLength ?? 8)) {
235
- return createAuthValidationError(`Password must be at least ${req.minLength} characters`, "password", "TOO_SHORT");
236
- }
237
- if (req.requireUppercase && !/[A-Z]/.test(password)) {
238
- return createAuthValidationError("Password must contain at least one uppercase letter", "password", "NO_UPPERCASE");
239
- }
240
- if (req.requireNumbers && !/\d/.test(password)) {
241
- return createAuthValidationError("Password must contain at least one number", "password", "NO_NUMBER");
2141
+ async unconsume(orgId, entitlement, periodStart, _periodEnd, amount = 1) {
2142
+ const k = this.key(orgId, entitlement, periodStart);
2143
+ const entry = this.entries.get(k);
2144
+ if (!entry)
2145
+ return;
2146
+ entry.consumed = Math.max(0, entry.consumed - amount);
242
2147
  }
243
- if (req.requireSymbols && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
244
- return createAuthValidationError("Password must contain at least one symbol", "password", "NO_SYMBOL");
2148
+ async getConsumption(orgId, entitlement, periodStart, _periodEnd) {
2149
+ const k = this.key(orgId, entitlement, periodStart);
2150
+ const entry = this.entries.get(k);
2151
+ return entry?.consumed ?? 0;
245
2152
  }
246
- return null;
247
- }
248
- var DEFAULT_COOKIE_CONFIG = {
249
- name: "vertz.sid",
250
- httpOnly: true,
251
- secure: true,
252
- sameSite: "lax",
253
- path: "/",
254
- maxAge: 60 * 60 * 24 * 7
255
- };
256
- function parseDuration(duration) {
257
- if (typeof duration === "number")
258
- return duration;
259
- const match = duration.match(/^(\d+)([smhd])$/);
260
- if (!match)
261
- throw new Error(`Invalid duration: ${duration}`);
262
- const value = parseInt(match[1], 10);
263
- const unit = match[2];
264
- const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
265
- return value * multipliers[unit] * 1000;
266
- }
267
- async function createJWT(user, secret, ttl, algorithm, customClaims) {
268
- const claims = customClaims ? customClaims(user) : {};
269
- const jwt = await new jose.SignJWT({
270
- sub: user.id,
271
- email: user.email,
272
- role: user.role,
273
- ...claims
274
- }).setProtectedHeader({ alg: algorithm }).setIssuedAt().setExpirationTime(Math.floor(ttl / 1000)).sign(new TextEncoder().encode(secret));
275
- return jwt;
276
- }
277
- async function verifyJWT(token, secret, algorithm) {
278
- try {
279
- const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(secret), {
280
- algorithms: [algorithm]
281
- });
282
- return payload;
283
- } catch {
284
- return null;
2153
+ dispose() {
2154
+ this.entries.clear();
285
2155
  }
286
2156
  }
287
- var users = new Map;
288
- var sessions = new Map;
2157
+
2158
+ // src/auth/index.ts
289
2159
  function createAuth(config) {
290
2160
  const {
291
2161
  session,
@@ -312,7 +2182,13 @@ function createAuth(config) {
312
2182
  console.warn(`[Auth] Auto-generated dev JWT secret at ${secretFile}. Add this path to .gitignore.`);
313
2183
  }
314
2184
  }
2185
+ if (session.strategy !== "jwt") {
2186
+ throw new Error(`Session strategy "${session.strategy}" is not yet supported. Use "jwt".`);
2187
+ }
315
2188
  const cookieConfig = { ...DEFAULT_COOKIE_CONFIG, ...session.cookie };
2189
+ const refreshName = session.refreshName ?? "vertz.ref";
2190
+ const refreshTtlMs = parseDuration(session.refreshTtl ?? "7d");
2191
+ const refreshMaxAge = Math.floor(refreshTtlMs / 1000);
316
2192
  if (cookieConfig.sameSite === "none" && cookieConfig.secure !== true) {
317
2193
  throw new Error("SameSite=None requires secure=true");
318
2194
  }
@@ -323,145 +2199,346 @@ function createAuth(config) {
323
2199
  console.warn("Cookie 'secure' flag is disabled. This is allowed in development but must be enabled in production.");
324
2200
  }
325
2201
  const ttlMs = parseDuration(session.ttl);
326
- const signInLimiter = new RateLimiter(emailPassword?.rateLimit?.window || "15m");
327
- const signUpLimiter = new RateLimiter("1h");
328
- const refreshLimiter = new RateLimiter("1m");
329
- setInterval(() => {
330
- signInLimiter.cleanup();
331
- signUpLimiter.cleanup();
332
- refreshLimiter.cleanup();
333
- }, 60000);
334
- function buildAuthUser(stored) {
335
- return stored.user;
336
- }
337
- async function signUp(data) {
2202
+ const DUMMY_HASH = "$2a$12$000000000000000000000uGWDREoC/y2KhZ5l2QkI4j0LpDjWcaq";
2203
+ const sessionStore = config.sessionStore ?? new InMemorySessionStore;
2204
+ const userStore = config.userStore ?? new InMemoryUserStore;
2205
+ const providers = new Map;
2206
+ if (config.providers) {
2207
+ for (const provider of config.providers) {
2208
+ providers.set(provider.id, provider);
2209
+ }
2210
+ }
2211
+ const oauthAccountStore = config.oauthAccountStore ?? (providers.size > 0 ? new InMemoryOAuthAccountStore : undefined);
2212
+ const oauthEncryptionKey = config.oauthEncryptionKey;
2213
+ const oauthSuccessRedirect = config.oauthSuccessRedirect ?? "/";
2214
+ const oauthErrorRedirect = config.oauthErrorRedirect ?? "/auth/error";
2215
+ const mfaConfig = config.mfa;
2216
+ const mfaEnabled = mfaConfig?.enabled ?? false;
2217
+ const mfaIssuer = mfaConfig?.issuer ?? "Vertz";
2218
+ const mfaBackupCodeCount = mfaConfig?.backupCodeCount ?? 10;
2219
+ const mfaStore = config.mfaStore ?? (mfaEnabled ? new InMemoryMFAStore : undefined);
2220
+ const PENDING_MFA_TTL = 10 * 60 * 1000;
2221
+ const pendingMfaSecrets = new Map;
2222
+ const emailVerificationConfig = config.emailVerification;
2223
+ const emailVerificationEnabled = emailVerificationConfig?.enabled ?? false;
2224
+ const emailVerificationTtlMs = parseDuration(emailVerificationConfig?.tokenTtl ?? "24h");
2225
+ const emailVerificationStore = config.emailVerificationStore ?? (emailVerificationEnabled ? new InMemoryEmailVerificationStore : undefined);
2226
+ const passwordResetConfig = config.passwordReset;
2227
+ const passwordResetEnabled = passwordResetConfig?.enabled ?? false;
2228
+ const passwordResetTtlMs = parseDuration(passwordResetConfig?.tokenTtl ?? "1h");
2229
+ const revokeSessionsOnReset = passwordResetConfig?.revokeSessionsOnReset ?? true;
2230
+ const passwordResetStore = config.passwordResetStore ?? (passwordResetEnabled ? new InMemoryPasswordResetStore : undefined);
2231
+ const rateLimitStore = config.rateLimitStore ?? new InMemoryRateLimitStore;
2232
+ const signInWindowMs = parseDuration(emailPassword?.rateLimit?.window || "15m");
2233
+ const signUpWindowMs = parseDuration("1h");
2234
+ const refreshWindowMs = parseDuration("1m");
2235
+ async function createSessionTokens(user, sessionId, options) {
2236
+ const jti = crypto.randomUUID();
2237
+ const expiresAt = new Date(Date.now() + ttlMs);
2238
+ const userClaims = claims ? claims(user) : {};
2239
+ const fvaClaim = options?.fva !== undefined ? { fva: options.fva } : {};
2240
+ let aclClaim = {};
2241
+ if (config.access) {
2242
+ const accessSet = await computeAccessSet({
2243
+ userId: user.id,
2244
+ accessDef: config.access.definition,
2245
+ roleStore: config.access.roleStore,
2246
+ closureStore: config.access.closureStore,
2247
+ flagStore: config.access.flagStore,
2248
+ plan: user.plan ?? null
2249
+ });
2250
+ const encoded = encodeAccessSet(accessSet);
2251
+ const canonicalJson = JSON.stringify(encoded);
2252
+ const stablePayload = {
2253
+ entitlements: encoded.entitlements,
2254
+ flags: encoded.flags,
2255
+ plan: encoded.plan
2256
+ };
2257
+ const hash = await sha256Hex(JSON.stringify(stablePayload));
2258
+ const byteLength = new TextEncoder().encode(canonicalJson).length;
2259
+ if (byteLength <= 2048) {
2260
+ aclClaim = { acl: { set: encoded, hash, overflow: false } };
2261
+ } else {
2262
+ aclClaim = { acl: { hash, overflow: true } };
2263
+ }
2264
+ }
2265
+ const jwt = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, () => ({
2266
+ ...userClaims,
2267
+ ...fvaClaim,
2268
+ ...aclClaim,
2269
+ jti,
2270
+ sid: sessionId
2271
+ }));
2272
+ const refreshToken = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
2273
+ const payload = {
2274
+ sub: user.id,
2275
+ email: user.email,
2276
+ role: user.role,
2277
+ iat: Math.floor(Date.now() / 1000),
2278
+ exp: Math.floor(expiresAt.getTime() / 1000),
2279
+ jti,
2280
+ sid: sessionId,
2281
+ claims: userClaims || undefined,
2282
+ ...options?.fva !== undefined ? { fva: options.fva } : {},
2283
+ ...aclClaim
2284
+ };
2285
+ return { jwt, refreshToken, payload, expiresAt };
2286
+ }
2287
+ function generateToken() {
2288
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
2289
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
2290
+ }
2291
+ async function signUp(data, ctx) {
338
2292
  const { email, password, role = "user", ...additionalFields } = data;
339
2293
  if (!email || !email.includes("@")) {
340
- return err(createAuthValidationError("Invalid email format", "email", "INVALID_FORMAT"));
2294
+ return err(createAuthValidationError2("Invalid email format", "email", "INVALID_FORMAT"));
341
2295
  }
342
2296
  const passwordError = validatePassword(password, emailPassword?.password);
343
2297
  if (passwordError) {
344
2298
  return err(passwordError);
345
2299
  }
346
- if (users.has(email.toLowerCase())) {
347
- return err(createUserExistsError("User already exists", email.toLowerCase()));
348
- }
349
- const signUpRateLimit = signUpLimiter.check(`signup:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 3);
2300
+ const signUpRateLimit = await rateLimitStore.check(`signup:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 3, signUpWindowMs);
350
2301
  if (!signUpRateLimit.allowed) {
351
2302
  return err(createAuthRateLimitedError("Too many sign up attempts"));
352
2303
  }
2304
+ const existing = await userStore.findByEmail(email.toLowerCase());
2305
+ if (existing) {
2306
+ return err(createUserExistsError("User already exists", email.toLowerCase()));
2307
+ }
353
2308
  const passwordHash = await hashPassword(password);
354
2309
  const now = new Date;
2310
+ const {
2311
+ id: _id,
2312
+ createdAt: _c,
2313
+ updatedAt: _u,
2314
+ ...safeFields
2315
+ } = additionalFields;
355
2316
  const user = {
2317
+ ...safeFields,
356
2318
  id: crypto.randomUUID(),
357
2319
  email: email.toLowerCase(),
358
2320
  role,
2321
+ emailVerified: !emailVerificationEnabled,
359
2322
  createdAt: now,
360
- updatedAt: now,
361
- ...additionalFields
362
- };
363
- users.set(email.toLowerCase(), { user, passwordHash });
364
- const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
365
- const expiresAt = new Date(Date.now() + ttlMs);
366
- const payload = {
367
- sub: user.id,
368
- email: user.email,
369
- role: user.role,
370
- iat: Math.floor(Date.now() / 1000),
371
- exp: Math.floor(expiresAt.getTime() / 1000),
372
- claims: claims ? claims(user) : undefined
2323
+ updatedAt: now
373
2324
  };
374
- sessions.set(token, { userId: user.id, expiresAt });
375
- return ok({ user, expiresAt, payload });
2325
+ await userStore.createUser(user, passwordHash);
2326
+ if (emailVerificationEnabled && emailVerificationStore && emailVerificationConfig?.onSend) {
2327
+ const token = generateToken();
2328
+ const tokenHash = await sha256Hex(token);
2329
+ await emailVerificationStore.createVerification({
2330
+ userId: user.id,
2331
+ tokenHash,
2332
+ expiresAt: new Date(Date.now() + emailVerificationTtlMs)
2333
+ });
2334
+ await emailVerificationConfig.onSend(user, token);
2335
+ }
2336
+ const sessionId = crypto.randomUUID();
2337
+ const tokens = await createSessionTokens(user, sessionId);
2338
+ const refreshTokenHash = await sha256Hex(tokens.refreshToken);
2339
+ const ipAddress = ctx?.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? ctx?.headers.get("x-real-ip") ?? "";
2340
+ const userAgent = ctx?.headers.get("user-agent") ?? "";
2341
+ await sessionStore.createSessionWithId(sessionId, {
2342
+ userId: user.id,
2343
+ refreshTokenHash,
2344
+ ipAddress,
2345
+ userAgent,
2346
+ expiresAt: new Date(Date.now() + refreshTtlMs),
2347
+ currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
2348
+ });
2349
+ return ok({
2350
+ user,
2351
+ expiresAt: tokens.expiresAt,
2352
+ payload: tokens.payload,
2353
+ tokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
2354
+ });
376
2355
  }
377
- async function signIn(data) {
2356
+ async function signIn(data, ctx) {
378
2357
  const { email, password } = data;
379
- const stored = users.get(email.toLowerCase());
2358
+ const signInRateLimit = await rateLimitStore.check(`signin:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 5, signInWindowMs);
2359
+ if (!signInRateLimit.allowed) {
2360
+ return err(createAuthRateLimitedError("Too many sign in attempts"));
2361
+ }
2362
+ const stored = await userStore.findByEmail(email.toLowerCase());
380
2363
  if (!stored) {
2364
+ await verifyPassword(password, DUMMY_HASH);
381
2365
  return err(createInvalidCredentialsError());
382
2366
  }
383
- const signInRateLimit = signInLimiter.check(`signin:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 5);
384
- if (!signInRateLimit.allowed) {
385
- return err(createAuthRateLimitedError("Too many sign in attempts"));
2367
+ if (stored.passwordHash === null) {
2368
+ await verifyPassword(password, DUMMY_HASH);
2369
+ return err(createInvalidCredentialsError());
386
2370
  }
387
2371
  const valid = await verifyPassword(password, stored.passwordHash);
388
2372
  if (!valid) {
389
2373
  return err(createInvalidCredentialsError());
390
2374
  }
391
- const user = buildAuthUser(stored);
392
- const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
393
- const expiresAt = new Date(Date.now() + ttlMs);
394
- const payload = {
395
- sub: user.id,
396
- email: user.email,
397
- role: user.role,
398
- iat: Math.floor(Date.now() / 1000),
399
- exp: Math.floor(expiresAt.getTime() / 1000),
400
- claims: claims ? claims(user) : undefined
401
- };
402
- sessions.set(token, { userId: user.id, expiresAt });
403
- return ok({ user, expiresAt, payload });
2375
+ const user = stored.user;
2376
+ if (mfaStore && await mfaStore.isMfaEnabled(user.id)) {
2377
+ return err(createMfaRequiredError("MFA verification required"));
2378
+ }
2379
+ const sessionId = crypto.randomUUID();
2380
+ const tokens = await createSessionTokens(user, sessionId);
2381
+ const refreshTokenHash = await sha256Hex(tokens.refreshToken);
2382
+ const ipAddress = ctx?.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? ctx?.headers.get("x-real-ip") ?? "";
2383
+ const userAgent = ctx?.headers.get("user-agent") ?? "";
2384
+ await sessionStore.createSessionWithId(sessionId, {
2385
+ userId: user.id,
2386
+ refreshTokenHash,
2387
+ ipAddress,
2388
+ userAgent,
2389
+ expiresAt: new Date(Date.now() + refreshTtlMs),
2390
+ currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
2391
+ });
2392
+ return ok({
2393
+ user,
2394
+ expiresAt: tokens.expiresAt,
2395
+ payload: tokens.payload,
2396
+ tokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
2397
+ });
404
2398
  }
405
2399
  async function signOut(ctx) {
406
- const cookieName = cookieConfig.name || "vertz.sid";
407
- const token = ctx.headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${cookieName}=`))?.split("=")[1];
408
- if (token) {
409
- sessions.delete(token);
2400
+ const sessionResult = await getSession(ctx.headers);
2401
+ if (sessionResult.ok && sessionResult.data) {
2402
+ await sessionStore.revokeSession(sessionResult.data.payload.sid);
410
2403
  }
411
2404
  return ok(undefined);
412
2405
  }
413
2406
  async function getSession(headers) {
414
2407
  const cookieName = cookieConfig.name || "vertz.sid";
415
- const token = headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${cookieName}=`))?.split("=")[1];
2408
+ const cookieEntry = headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${cookieName}=`));
2409
+ const token = cookieEntry ? cookieEntry.trim().slice(`${cookieName}=`.length) : undefined;
416
2410
  if (!token) {
417
2411
  return ok(null);
418
2412
  }
419
- const session2 = sessions.get(token);
420
- if (!session2) {
421
- return ok(null);
422
- }
423
- if (session2.expiresAt < new Date) {
424
- sessions.delete(token);
425
- return ok(null);
426
- }
427
2413
  const payload = await verifyJWT(token, jwtSecret, jwtAlgorithm);
428
2414
  if (!payload) {
429
- sessions.delete(token);
430
2415
  return ok(null);
431
2416
  }
432
- const stored = users.get(payload.email);
433
- if (!stored) {
2417
+ const user = await userStore.findById(payload.sub);
2418
+ if (!user) {
434
2419
  return ok(null);
435
2420
  }
436
- const user = buildAuthUser(stored);
437
2421
  const expiresAt = new Date(payload.exp * 1000);
438
2422
  return ok({ user, expiresAt, payload });
439
2423
  }
440
2424
  async function refreshSession(ctx) {
441
- const refreshRateLimit = refreshLimiter.check(`refresh:${ctx.headers.get("x-forwarded-ip") || "default"}`, 10);
2425
+ const refreshRateLimit = await rateLimitStore.check(`refresh:${ctx.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "default"}`, 10, refreshWindowMs);
442
2426
  if (!refreshRateLimit.allowed) {
443
2427
  return err(createAuthRateLimitedError("Too many refresh attempts"));
444
2428
  }
445
- const sessionResult = await getSession(ctx.headers);
446
- if (!sessionResult.ok) {
2429
+ const refreshEntry = ctx.headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${refreshName}=`));
2430
+ const refreshToken = refreshEntry ? refreshEntry.trim().slice(`${refreshName}=`.length) : undefined;
2431
+ if (!refreshToken) {
2432
+ return err(createSessionExpiredError("No refresh token"));
2433
+ }
2434
+ const refreshHash = await sha256Hex(refreshToken);
2435
+ let storedSession = await sessionStore.findByRefreshHash(refreshHash);
2436
+ let isGracePeriod = false;
2437
+ if (!storedSession) {
2438
+ storedSession = await sessionStore.findByPreviousRefreshHash(refreshHash);
2439
+ if (storedSession && storedSession.lastActiveAt.getTime() + 1e4 > Date.now()) {
2440
+ isGracePeriod = true;
2441
+ } else {
2442
+ return err(createSessionExpiredError("Invalid refresh token"));
2443
+ }
2444
+ }
2445
+ const user = await userStore.findById(storedSession.userId);
2446
+ if (!user) {
2447
+ return err(createSessionExpiredError("User not found"));
2448
+ }
2449
+ if (isGracePeriod) {
2450
+ const currentTokens = await sessionStore.getCurrentTokens(storedSession.id);
2451
+ if (currentTokens) {
2452
+ const payload = await verifyJWT(currentTokens.jwt, jwtSecret, jwtAlgorithm);
2453
+ return ok({
2454
+ user,
2455
+ expiresAt: payload ? new Date(payload.exp * 1000) : new Date(Date.now() + ttlMs),
2456
+ payload: payload ?? {
2457
+ sub: user.id,
2458
+ email: user.email,
2459
+ role: user.role,
2460
+ iat: Math.floor(Date.now() / 1000),
2461
+ exp: Math.floor((Date.now() + ttlMs) / 1000),
2462
+ jti: "",
2463
+ sid: storedSession.id
2464
+ },
2465
+ tokens: currentTokens
2466
+ });
2467
+ }
2468
+ }
2469
+ let existingFva;
2470
+ const oldTokens = await sessionStore.getCurrentTokens(storedSession.id);
2471
+ if (oldTokens) {
2472
+ const oldPayload = await verifyJWT(oldTokens.jwt, jwtSecret, jwtAlgorithm);
2473
+ existingFva = oldPayload?.fva;
2474
+ }
2475
+ const newTokens = await createSessionTokens(user, storedSession.id, existingFva !== undefined ? { fva: existingFva } : undefined);
2476
+ const newRefreshHash = await sha256Hex(newTokens.refreshToken);
2477
+ await sessionStore.updateSession(storedSession.id, {
2478
+ refreshTokenHash: newRefreshHash,
2479
+ previousRefreshHash: storedSession.refreshTokenHash,
2480
+ lastActiveAt: new Date,
2481
+ currentTokens: { jwt: newTokens.jwt, refreshToken: newTokens.refreshToken }
2482
+ });
2483
+ return ok({
2484
+ user,
2485
+ expiresAt: newTokens.expiresAt,
2486
+ payload: newTokens.payload,
2487
+ tokens: { jwt: newTokens.jwt, refreshToken: newTokens.refreshToken }
2488
+ });
2489
+ }
2490
+ async function listSessions(headers) {
2491
+ const sessionResult = await getSession(headers);
2492
+ if (!sessionResult.ok)
2493
+ return sessionResult;
2494
+ if (!sessionResult.data) {
2495
+ return err(createSessionExpiredError("Not authenticated"));
2496
+ }
2497
+ const currentSid = sessionResult.data.payload.sid;
2498
+ const sessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
2499
+ const infos = sessions.map((s) => ({
2500
+ id: s.id,
2501
+ userId: s.userId,
2502
+ ipAddress: s.ipAddress,
2503
+ userAgent: s.userAgent,
2504
+ deviceName: parseDeviceName(s.userAgent),
2505
+ createdAt: s.createdAt,
2506
+ lastActiveAt: s.lastActiveAt,
2507
+ expiresAt: s.expiresAt,
2508
+ isCurrent: s.id === currentSid
2509
+ }));
2510
+ return ok(infos);
2511
+ }
2512
+ async function revokeSessionById(sessionId, headers) {
2513
+ const sessionResult = await getSession(headers);
2514
+ if (!sessionResult.ok)
2515
+ return sessionResult;
2516
+ if (!sessionResult.data) {
2517
+ return err(createSessionExpiredError("Not authenticated"));
2518
+ }
2519
+ const targetSessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
2520
+ const target = targetSessions.find((s) => s.id === sessionId);
2521
+ if (!target) {
2522
+ return err(createSessionNotFoundError("Session not found"));
2523
+ }
2524
+ await sessionStore.revokeSession(sessionId);
2525
+ return ok(undefined);
2526
+ }
2527
+ async function revokeAllSessions(headers) {
2528
+ const sessionResult = await getSession(headers);
2529
+ if (!sessionResult.ok)
447
2530
  return sessionResult;
448
- }
449
2531
  if (!sessionResult.data) {
450
- return err(createSessionExpiredError("No active session"));
2532
+ return err(createSessionExpiredError("Not authenticated"));
451
2533
  }
452
- const user = sessionResult.data.user;
453
- const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
454
- const expiresAt = new Date(Date.now() + ttlMs);
455
- const payload = {
456
- sub: user.id,
457
- email: user.email,
458
- role: user.role,
459
- iat: Math.floor(Date.now() / 1000),
460
- exp: Math.floor(expiresAt.getTime() / 1000),
461
- claims: claims ? claims(user) : undefined
462
- };
463
- sessions.set(token, { userId: user.id, expiresAt });
464
- return ok({ user, expiresAt, payload });
2534
+ const currentSid = sessionResult.data.payload.sid;
2535
+ const sessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
2536
+ for (const s of sessions) {
2537
+ if (s.id !== currentSid) {
2538
+ await sessionStore.revokeSession(s.id);
2539
+ }
2540
+ }
2541
+ return ok(undefined);
465
2542
  }
466
2543
  function authErrorToStatus(error) {
467
2544
  switch (error.code) {
@@ -471,6 +2548,8 @@ function createAuth(config) {
471
2548
  return 401;
472
2549
  case "SESSION_EXPIRED":
473
2550
  return 401;
2551
+ case "SESSION_NOT_FOUND":
2552
+ return 404;
474
2553
  case "USER_EXISTS":
475
2554
  return 409;
476
2555
  case "RATE_LIMITED":
@@ -481,6 +2560,14 @@ function createAuth(config) {
481
2560
  return 500;
482
2561
  }
483
2562
  }
2563
+ function securityHeaders() {
2564
+ return {
2565
+ "Cache-Control": "no-store, no-cache, must-revalidate",
2566
+ Pragma: "no-cache",
2567
+ "X-Content-Type-Options": "nosniff",
2568
+ "Referrer-Policy": "strict-origin-when-cross-origin"
2569
+ };
2570
+ }
484
2571
  async function handleAuthRequest(request) {
485
2572
  const url = new URL(request.url);
486
2573
  const path = url.pathname.replace("/api/auth", "") || "/";
@@ -504,7 +2591,7 @@ function createAuth(config) {
504
2591
  if (isProduction) {
505
2592
  return new Response(JSON.stringify({ error: "CSRF validation failed" }), {
506
2593
  status: 403,
507
- headers: { "Content-Type": "application/json" }
2594
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
508
2595
  });
509
2596
  } else {
510
2597
  console.warn("[Auth] CSRF warning: Origin/Referer missing or mismatched (allowed in development)");
@@ -515,7 +2602,7 @@ function createAuth(config) {
515
2602
  if (isProduction) {
516
2603
  return new Response(JSON.stringify({ error: "Missing required X-VTZ-Request header" }), {
517
2604
  status: 403,
518
- headers: { "Content-Type": "application/json" }
2605
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
519
2606
  });
520
2607
  } else {
521
2608
  console.warn("[Auth] CSRF warning: Missing X-VTZ-Request header (allowed in development)");
@@ -525,78 +2612,916 @@ function createAuth(config) {
525
2612
  try {
526
2613
  if (method === "POST" && path === "/signup") {
527
2614
  const body = await request.json();
528
- const result = await signUp(body);
2615
+ const result = await signUp(body, { headers: request.headers });
529
2616
  if (!result.ok) {
530
2617
  return new Response(JSON.stringify({ error: result.error }), {
531
2618
  status: authErrorToStatus(result.error),
532
- headers: { "Content-Type": "application/json" }
2619
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
533
2620
  });
534
2621
  }
535
- const cookieValue = await createJWT(result.data.user, jwtSecret, ttlMs, jwtAlgorithm, claims);
536
- return new Response(JSON.stringify({ user: result.data.user }), {
2622
+ const headers = new Headers({
2623
+ "Content-Type": "application/json",
2624
+ ...securityHeaders()
2625
+ });
2626
+ if (result.data.tokens) {
2627
+ headers.append("Set-Cookie", buildSessionCookie(result.data.tokens.jwt, cookieConfig));
2628
+ headers.append("Set-Cookie", buildRefreshCookie(result.data.tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
2629
+ }
2630
+ return new Response(JSON.stringify({
2631
+ user: result.data.user,
2632
+ expiresAt: result.data.expiresAt.getTime()
2633
+ }), {
537
2634
  status: 201,
538
- headers: {
539
- "Content-Type": "application/json",
540
- "Set-Cookie": buildCookie(cookieValue)
541
- }
2635
+ headers
542
2636
  });
543
2637
  }
544
2638
  if (method === "POST" && path === "/signin") {
545
2639
  const body = await request.json();
546
- const result = await signIn(body);
2640
+ const result = await signIn(body, { headers: request.headers });
547
2641
  if (!result.ok) {
2642
+ if (result.error.code === "MFA_REQUIRED" && oauthEncryptionKey) {
2643
+ const stored = await userStore.findByEmail(body.email.toLowerCase());
2644
+ if (stored) {
2645
+ const challengeData = JSON.stringify({
2646
+ userId: stored.user.id,
2647
+ expiresAt: Date.now() + 300000
2648
+ });
2649
+ const encryptedChallenge = await encrypt(challengeData, oauthEncryptionKey);
2650
+ const headers2 = new Headers({
2651
+ "Content-Type": "application/json",
2652
+ ...securityHeaders()
2653
+ });
2654
+ headers2.append("Set-Cookie", buildMfaChallengeCookie(encryptedChallenge, cookieConfig));
2655
+ return new Response(JSON.stringify({ error: result.error }), {
2656
+ status: 403,
2657
+ headers: headers2
2658
+ });
2659
+ }
2660
+ }
548
2661
  return new Response(JSON.stringify({ error: result.error }), {
549
2662
  status: authErrorToStatus(result.error),
550
- headers: { "Content-Type": "application/json" }
2663
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
551
2664
  });
552
2665
  }
553
- const cookieValue = await createJWT(result.data.user, jwtSecret, ttlMs, jwtAlgorithm, claims);
554
- return new Response(JSON.stringify({ user: result.data.user }), {
2666
+ const headers = new Headers({
2667
+ "Content-Type": "application/json",
2668
+ ...securityHeaders()
2669
+ });
2670
+ if (result.data.tokens) {
2671
+ headers.append("Set-Cookie", buildSessionCookie(result.data.tokens.jwt, cookieConfig));
2672
+ headers.append("Set-Cookie", buildRefreshCookie(result.data.tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
2673
+ }
2674
+ return new Response(JSON.stringify({
2675
+ user: result.data.user,
2676
+ expiresAt: result.data.expiresAt.getTime()
2677
+ }), {
555
2678
  status: 200,
556
- headers: {
557
- "Content-Type": "application/json",
558
- "Set-Cookie": buildCookie(cookieValue)
559
- }
2679
+ headers
560
2680
  });
561
2681
  }
562
2682
  if (method === "POST" && path === "/signout") {
563
2683
  await signOut({ headers: request.headers });
2684
+ const headers = new Headers({
2685
+ "Content-Type": "application/json",
2686
+ ...securityHeaders()
2687
+ });
2688
+ headers.append("Set-Cookie", buildSessionCookie("", cookieConfig, true));
2689
+ headers.append("Set-Cookie", buildRefreshCookie("", cookieConfig, refreshName, refreshMaxAge, true));
564
2690
  return new Response(JSON.stringify({ ok: true }), {
2691
+ status: 200,
2692
+ headers
2693
+ });
2694
+ }
2695
+ if (method === "GET" && path === "/session") {
2696
+ const result = await getSession(request.headers);
2697
+ if (!result.ok) {
2698
+ return new Response(JSON.stringify({ error: result.error }), {
2699
+ status: 500,
2700
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2701
+ });
2702
+ }
2703
+ return new Response(JSON.stringify({ session: result.data }), {
2704
+ status: 200,
2705
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2706
+ });
2707
+ }
2708
+ if (method === "GET" && path === "/access-set") {
2709
+ if (!config.access) {
2710
+ return new Response(JSON.stringify({ error: "Access control not configured" }), {
2711
+ status: 404,
2712
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2713
+ });
2714
+ }
2715
+ const sessionResult = await getSession(request.headers);
2716
+ if (!sessionResult.ok || !sessionResult.data) {
2717
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
2718
+ status: 401,
2719
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2720
+ });
2721
+ }
2722
+ const accessSet = await computeAccessSet({
2723
+ userId: sessionResult.data.user.id,
2724
+ accessDef: config.access.definition,
2725
+ roleStore: config.access.roleStore,
2726
+ closureStore: config.access.closureStore,
2727
+ flagStore: config.access.flagStore,
2728
+ plan: sessionResult.data.user.plan ?? null
2729
+ });
2730
+ const encoded = encodeAccessSet(accessSet);
2731
+ const hashPayload = {
2732
+ entitlements: encoded.entitlements,
2733
+ flags: encoded.flags,
2734
+ plan: encoded.plan
2735
+ };
2736
+ const hash = await sha256Hex(JSON.stringify(hashPayload));
2737
+ const quotedEtag = `"${hash}"`;
2738
+ const ifNoneMatch = request.headers.get("If-None-Match");
2739
+ if (ifNoneMatch && ifNoneMatch.replace(/"/g, "") === hash) {
2740
+ return new Response(null, {
2741
+ status: 304,
2742
+ headers: {
2743
+ ETag: quotedEtag,
2744
+ ...securityHeaders(),
2745
+ "Cache-Control": "no-cache"
2746
+ }
2747
+ });
2748
+ }
2749
+ return new Response(JSON.stringify({ accessSet }), {
565
2750
  status: 200,
566
2751
  headers: {
567
2752
  "Content-Type": "application/json",
568
- "Set-Cookie": buildCookie("", true)
2753
+ ETag: quotedEtag,
2754
+ ...securityHeaders(),
2755
+ "Cache-Control": "no-cache"
569
2756
  }
570
2757
  });
571
2758
  }
572
- if (method === "GET" && path === "/session") {
573
- const result = await getSession(request.headers);
2759
+ if (method === "POST" && path === "/refresh") {
2760
+ const result = await refreshSession({ headers: request.headers });
2761
+ if (!result.ok) {
2762
+ const headers2 = new Headers({
2763
+ "Content-Type": "application/json",
2764
+ ...securityHeaders()
2765
+ });
2766
+ headers2.append("Set-Cookie", buildSessionCookie("", cookieConfig, true));
2767
+ headers2.append("Set-Cookie", buildRefreshCookie("", cookieConfig, refreshName, refreshMaxAge, true));
2768
+ return new Response(JSON.stringify({ error: result.error }), {
2769
+ status: authErrorToStatus(result.error),
2770
+ headers: headers2
2771
+ });
2772
+ }
2773
+ const headers = new Headers({
2774
+ "Content-Type": "application/json",
2775
+ ...securityHeaders()
2776
+ });
2777
+ if (result.data.tokens) {
2778
+ headers.append("Set-Cookie", buildSessionCookie(result.data.tokens.jwt, cookieConfig));
2779
+ headers.append("Set-Cookie", buildRefreshCookie(result.data.tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
2780
+ }
2781
+ return new Response(JSON.stringify({
2782
+ user: result.data.user,
2783
+ expiresAt: result.data.expiresAt.getTime()
2784
+ }), {
2785
+ status: 200,
2786
+ headers
2787
+ });
2788
+ }
2789
+ if (method === "GET" && path === "/sessions") {
2790
+ const result = await listSessions(request.headers);
2791
+ if (!result.ok) {
2792
+ return new Response(JSON.stringify({ error: result.error }), {
2793
+ status: authErrorToStatus(result.error),
2794
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2795
+ });
2796
+ }
2797
+ return new Response(JSON.stringify({ sessions: result.data }), {
2798
+ status: 200,
2799
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2800
+ });
2801
+ }
2802
+ if (method === "DELETE" && path.startsWith("/sessions/")) {
2803
+ const sessionId = path.replace("/sessions/", "");
2804
+ const result = await revokeSessionById(sessionId, request.headers);
2805
+ if (!result.ok) {
2806
+ return new Response(JSON.stringify({ error: result.error }), {
2807
+ status: authErrorToStatus(result.error),
2808
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2809
+ });
2810
+ }
2811
+ return new Response(JSON.stringify({ ok: true }), {
2812
+ status: 200,
2813
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2814
+ });
2815
+ }
2816
+ if (method === "DELETE" && path === "/sessions") {
2817
+ const result = await revokeAllSessions(request.headers);
574
2818
  if (!result.ok) {
575
2819
  return new Response(JSON.stringify({ error: result.error }), {
2820
+ status: authErrorToStatus(result.error),
2821
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2822
+ });
2823
+ }
2824
+ return new Response(JSON.stringify({ ok: true }), {
2825
+ status: 200,
2826
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2827
+ });
2828
+ }
2829
+ if (method === "GET" && path.startsWith("/oauth/") && !path.includes("/callback")) {
2830
+ const providerId = path.replace("/oauth/", "");
2831
+ const provider = providers.get(providerId);
2832
+ if (!provider) {
2833
+ return new Response(JSON.stringify({ error: "Provider not found" }), {
2834
+ status: 404,
2835
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2836
+ });
2837
+ }
2838
+ const oauthIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "default";
2839
+ const oauthRateLimit = await rateLimitStore.check(`oauth:${oauthIp}`, 10, 5 * 60 * 1000);
2840
+ if (!oauthRateLimit.allowed) {
2841
+ return new Response(JSON.stringify({ error: "Too many requests" }), {
2842
+ status: 429,
2843
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2844
+ });
2845
+ }
2846
+ if (!oauthEncryptionKey) {
2847
+ return new Response(JSON.stringify({ error: "OAuth not configured" }), {
2848
+ status: 500,
2849
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
2850
+ });
2851
+ }
2852
+ const codeVerifier = generateCodeVerifier();
2853
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
2854
+ const state = generateNonce();
2855
+ const nonce = generateNonce();
2856
+ const stateData = {
2857
+ provider: providerId,
2858
+ state,
2859
+ codeVerifier,
2860
+ nonce,
2861
+ expiresAt: Date.now() + 300000
2862
+ };
2863
+ const encryptedState = await encrypt(JSON.stringify(stateData), oauthEncryptionKey);
2864
+ const authUrl = provider.getAuthorizationUrl(state, codeChallenge, nonce);
2865
+ const headers = new Headers({
2866
+ Location: authUrl,
2867
+ ...securityHeaders()
2868
+ });
2869
+ headers.append("Set-Cookie", buildOAuthStateCookie(encryptedState, cookieConfig));
2870
+ return new Response(null, { status: 302, headers });
2871
+ }
2872
+ if (method === "GET" && path.includes("/oauth/") && path.endsWith("/callback")) {
2873
+ const providerId = path.replace("/oauth/", "").replace("/callback", "");
2874
+ const provider = providers.get(providerId);
2875
+ const errorRedirect = oauthErrorRedirect;
2876
+ if (!provider || !oauthEncryptionKey || !oauthAccountStore) {
2877
+ return new Response(null, {
2878
+ status: 302,
2879
+ headers: {
2880
+ Location: `${errorRedirect}?error=provider_not_configured`,
2881
+ ...securityHeaders()
2882
+ }
2883
+ });
2884
+ }
2885
+ const oauthCookieEntry = request.headers.get("cookie")?.split(";").find((c) => c.trim().startsWith("vertz.oauth="));
2886
+ const encryptedState = oauthCookieEntry ? oauthCookieEntry.trim().slice("vertz.oauth=".length) : undefined;
2887
+ if (!encryptedState) {
2888
+ return new Response(null, {
2889
+ status: 302,
2890
+ headers: {
2891
+ Location: `${errorRedirect}?error=invalid_state`,
2892
+ ...securityHeaders()
2893
+ }
2894
+ });
2895
+ }
2896
+ const decryptedState = await decrypt(encryptedState, oauthEncryptionKey);
2897
+ if (!decryptedState) {
2898
+ return new Response(null, {
2899
+ status: 302,
2900
+ headers: {
2901
+ Location: `${errorRedirect}?error=invalid_state`,
2902
+ ...securityHeaders()
2903
+ }
2904
+ });
2905
+ }
2906
+ const stateData = JSON.parse(decryptedState);
2907
+ const queryState = url.searchParams.get("state");
2908
+ const code = url.searchParams.get("code");
2909
+ const providerError = url.searchParams.get("error");
2910
+ if (providerError) {
2911
+ const headers = new Headers({
2912
+ Location: `${errorRedirect}?error=${encodeURIComponent(providerError)}`,
2913
+ ...securityHeaders()
2914
+ });
2915
+ headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
2916
+ return new Response(null, { status: 302, headers });
2917
+ }
2918
+ if (stateData.state !== queryState || stateData.provider !== providerId) {
2919
+ const headers = new Headers({
2920
+ Location: `${errorRedirect}?error=invalid_state`,
2921
+ ...securityHeaders()
2922
+ });
2923
+ headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
2924
+ return new Response(null, { status: 302, headers });
2925
+ }
2926
+ if (stateData.expiresAt < Date.now()) {
2927
+ const headers = new Headers({
2928
+ Location: `${errorRedirect}?error=invalid_state`,
2929
+ ...securityHeaders()
2930
+ });
2931
+ headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
2932
+ return new Response(null, { status: 302, headers });
2933
+ }
2934
+ try {
2935
+ const tokens = await provider.exchangeCode(code ?? "", stateData.codeVerifier);
2936
+ const userInfo = await provider.getUserInfo(tokens.accessToken, tokens.idToken, stateData.nonce);
2937
+ const responseHeaders = new Headers({
2938
+ Location: oauthSuccessRedirect,
2939
+ ...securityHeaders()
2940
+ });
2941
+ responseHeaders.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
2942
+ let userId = null;
2943
+ userId = await oauthAccountStore.findByProviderAccount(provider.id, userInfo.providerId);
2944
+ if (!userId) {
2945
+ if (provider.trustEmail && userInfo.emailVerified) {
2946
+ const existingUser = await userStore.findByEmail(userInfo.email.toLowerCase());
2947
+ if (existingUser) {
2948
+ userId = existingUser.user.id;
2949
+ await oauthAccountStore.linkAccount(userId, provider.id, userInfo.providerId, userInfo.email);
2950
+ }
2951
+ }
2952
+ if (!userId) {
2953
+ if (!userInfo.email || !userInfo.email.includes("@")) {
2954
+ const headers = new Headers({
2955
+ Location: `${errorRedirect}?error=email_required`,
2956
+ ...securityHeaders()
2957
+ });
2958
+ headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
2959
+ return new Response(null, { status: 302, headers });
2960
+ }
2961
+ const now = new Date;
2962
+ const newUser = {
2963
+ id: crypto.randomUUID(),
2964
+ email: userInfo.email.toLowerCase(),
2965
+ role: "user",
2966
+ createdAt: now,
2967
+ updatedAt: now
2968
+ };
2969
+ await userStore.createUser(newUser, null);
2970
+ userId = newUser.id;
2971
+ await oauthAccountStore.linkAccount(userId, provider.id, userInfo.providerId, userInfo.email);
2972
+ }
2973
+ }
2974
+ const user = await userStore.findById(userId);
2975
+ if (!user) {
2976
+ return new Response(null, {
2977
+ status: 302,
2978
+ headers: {
2979
+ Location: `${errorRedirect}?error=user_info_failed`,
2980
+ ...securityHeaders()
2981
+ }
2982
+ });
2983
+ }
2984
+ const sessionId = crypto.randomUUID();
2985
+ const sessionTokens = await createSessionTokens(user, sessionId);
2986
+ const refreshTokenHash = await sha256Hex(sessionTokens.refreshToken);
2987
+ const ipAddress = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "";
2988
+ const userAgent = request.headers.get("user-agent") ?? "";
2989
+ await sessionStore.createSessionWithId(sessionId, {
2990
+ userId: user.id,
2991
+ refreshTokenHash,
2992
+ ipAddress,
2993
+ userAgent,
2994
+ expiresAt: new Date(Date.now() + refreshTtlMs),
2995
+ currentTokens: { jwt: sessionTokens.jwt, refreshToken: sessionTokens.refreshToken }
2996
+ });
2997
+ responseHeaders.append("Set-Cookie", buildSessionCookie(sessionTokens.jwt, cookieConfig));
2998
+ responseHeaders.append("Set-Cookie", buildRefreshCookie(sessionTokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
2999
+ return new Response(null, { status: 302, headers: responseHeaders });
3000
+ } catch {
3001
+ const headers = new Headers({
3002
+ Location: `${errorRedirect}?error=token_exchange_failed`,
3003
+ ...securityHeaders()
3004
+ });
3005
+ headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
3006
+ return new Response(null, { status: 302, headers });
3007
+ }
3008
+ }
3009
+ if (method === "POST" && path === "/mfa/challenge") {
3010
+ if (!mfaStore || !oauthEncryptionKey) {
3011
+ return new Response(JSON.stringify({ error: "MFA not configured" }), {
3012
+ status: 400,
3013
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3014
+ });
3015
+ }
3016
+ const challengeIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "default";
3017
+ const challengeRateLimit = await rateLimitStore.check(`mfa-challenge:${challengeIp}`, 5, signInWindowMs);
3018
+ if (!challengeRateLimit.allowed) {
3019
+ return new Response(JSON.stringify({ error: createAuthRateLimitedError("Too many MFA attempts") }), {
3020
+ status: 429,
3021
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3022
+ });
3023
+ }
3024
+ const mfaCookieEntry = request.headers.get("cookie")?.split(";").find((c) => c.trim().startsWith("vertz.mfa="));
3025
+ const mfaToken = mfaCookieEntry ? mfaCookieEntry.trim().slice("vertz.mfa=".length) : undefined;
3026
+ if (!mfaToken) {
3027
+ return new Response(JSON.stringify({
3028
+ error: { code: "SESSION_EXPIRED", message: "No MFA challenge token" }
3029
+ }), {
3030
+ status: 401,
3031
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3032
+ });
3033
+ }
3034
+ const decrypted = await decrypt(mfaToken, oauthEncryptionKey);
3035
+ if (!decrypted) {
3036
+ return new Response(JSON.stringify({
3037
+ error: { code: "SESSION_EXPIRED", message: "Invalid MFA challenge token" }
3038
+ }), {
3039
+ status: 401,
3040
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3041
+ });
3042
+ }
3043
+ const challengeData = JSON.parse(decrypted);
3044
+ if (challengeData.expiresAt < Date.now()) {
3045
+ return new Response(JSON.stringify({
3046
+ error: { code: "SESSION_EXPIRED", message: "MFA challenge expired" }
3047
+ }), {
3048
+ status: 401,
3049
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3050
+ });
3051
+ }
3052
+ const body = await request.json();
3053
+ const userId = challengeData.userId;
3054
+ const encryptedSecret = await mfaStore.getSecret(userId);
3055
+ if (!encryptedSecret) {
3056
+ return new Response(JSON.stringify({ error: createMfaNotEnabledError() }), {
3057
+ status: 400,
3058
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3059
+ });
3060
+ }
3061
+ const totpSecret = await decrypt(encryptedSecret, oauthEncryptionKey);
3062
+ if (!totpSecret) {
3063
+ return new Response(JSON.stringify({ error: "Internal error" }), {
3064
+ status: 500,
3065
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3066
+ });
3067
+ }
3068
+ let codeValid = await verifyTotpCode(totpSecret, body.code);
3069
+ if (!codeValid) {
3070
+ const hashedCodes = await mfaStore.getBackupCodes(userId);
3071
+ for (const hashed of hashedCodes) {
3072
+ if (await verifyBackupCode(body.code, hashed)) {
3073
+ await mfaStore.consumeBackupCode(userId, hashed);
3074
+ codeValid = true;
3075
+ break;
3076
+ }
3077
+ }
3078
+ }
3079
+ if (!codeValid) {
3080
+ return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
3081
+ status: 400,
3082
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3083
+ });
3084
+ }
3085
+ const user = await userStore.findById(userId);
3086
+ if (!user) {
3087
+ return new Response(JSON.stringify({ error: "User not found" }), {
3088
+ status: 500,
3089
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3090
+ });
3091
+ }
3092
+ const sessionId = crypto.randomUUID();
3093
+ const fvaTimestamp = Math.floor(Date.now() / 1000);
3094
+ const tokens = await createSessionTokens(user, sessionId, { fva: fvaTimestamp });
3095
+ const refreshTokenHash = await sha256Hex(tokens.refreshToken);
3096
+ const ipAddress = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? "";
3097
+ const userAgent = request.headers.get("user-agent") ?? "";
3098
+ await sessionStore.createSessionWithId(sessionId, {
3099
+ userId: user.id,
3100
+ refreshTokenHash,
3101
+ ipAddress,
3102
+ userAgent,
3103
+ expiresAt: new Date(Date.now() + refreshTtlMs),
3104
+ currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
3105
+ });
3106
+ const headers = new Headers({
3107
+ "Content-Type": "application/json",
3108
+ ...securityHeaders()
3109
+ });
3110
+ headers.append("Set-Cookie", buildSessionCookie(tokens.jwt, cookieConfig));
3111
+ headers.append("Set-Cookie", buildRefreshCookie(tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
3112
+ headers.append("Set-Cookie", buildMfaChallengeCookie("", cookieConfig, true));
3113
+ return new Response(JSON.stringify({ user }), {
3114
+ status: 200,
3115
+ headers
3116
+ });
3117
+ }
3118
+ if (method === "POST" && path === "/mfa/setup") {
3119
+ if (!mfaStore) {
3120
+ return new Response(JSON.stringify({ error: "MFA not configured" }), {
3121
+ status: 400,
3122
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3123
+ });
3124
+ }
3125
+ const sessionResult = await getSession(request.headers);
3126
+ if (!sessionResult.ok || !sessionResult.data) {
3127
+ return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
3128
+ status: 401,
3129
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3130
+ });
3131
+ }
3132
+ const userId = sessionResult.data.user.id;
3133
+ const alreadyEnabled = await mfaStore.isMfaEnabled(userId);
3134
+ if (alreadyEnabled) {
3135
+ return new Response(JSON.stringify({ error: createMfaAlreadyEnabledError() }), {
3136
+ status: 409,
3137
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3138
+ });
3139
+ }
3140
+ const secret = generateTotpSecret();
3141
+ const uri = generateTotpUri(secret, sessionResult.data.user.email, mfaIssuer);
3142
+ pendingMfaSecrets.set(userId, { secret, createdAt: Date.now() });
3143
+ return new Response(JSON.stringify({ secret, uri }), {
3144
+ status: 200,
3145
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3146
+ });
3147
+ }
3148
+ if (method === "POST" && path === "/mfa/verify-setup") {
3149
+ if (!mfaStore || !oauthEncryptionKey) {
3150
+ return new Response(JSON.stringify({ error: "MFA not configured" }), {
3151
+ status: 400,
3152
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3153
+ });
3154
+ }
3155
+ const sessionResult = await getSession(request.headers);
3156
+ if (!sessionResult.ok || !sessionResult.data) {
3157
+ return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
3158
+ status: 401,
3159
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3160
+ });
3161
+ }
3162
+ const userId = sessionResult.data.user.id;
3163
+ const pendingEntry = pendingMfaSecrets.get(userId);
3164
+ if (!pendingEntry || Date.now() - pendingEntry.createdAt > PENDING_MFA_TTL) {
3165
+ if (pendingEntry)
3166
+ pendingMfaSecrets.delete(userId);
3167
+ return new Response(JSON.stringify({ error: createMfaNotEnabledError("No pending MFA setup") }), {
3168
+ status: 400,
3169
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3170
+ });
3171
+ }
3172
+ const body = await request.json();
3173
+ const valid = await verifyTotpCode(pendingEntry.secret, body.code);
3174
+ if (!valid) {
3175
+ return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
3176
+ status: 400,
3177
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3178
+ });
3179
+ }
3180
+ const encryptedSecret = await encrypt(pendingEntry.secret, oauthEncryptionKey);
3181
+ await mfaStore.enableMfa(userId, encryptedSecret);
3182
+ pendingMfaSecrets.delete(userId);
3183
+ const backupCodes = generateBackupCodes(mfaBackupCodeCount);
3184
+ const hashedCodes = await Promise.all(backupCodes.map((c) => hashBackupCode(c)));
3185
+ await mfaStore.setBackupCodes(userId, hashedCodes);
3186
+ return new Response(JSON.stringify({ backupCodes }), {
3187
+ status: 200,
3188
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3189
+ });
3190
+ }
3191
+ if (method === "POST" && path === "/mfa/disable") {
3192
+ if (!mfaStore) {
3193
+ return new Response(JSON.stringify({ error: "MFA not configured" }), {
3194
+ status: 400,
3195
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3196
+ });
3197
+ }
3198
+ const sessionResult = await getSession(request.headers);
3199
+ if (!sessionResult.ok || !sessionResult.data) {
3200
+ return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
3201
+ status: 401,
3202
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3203
+ });
3204
+ }
3205
+ const userId = sessionResult.data.user.id;
3206
+ const body = await request.json();
3207
+ const stored = await userStore.findByEmail(sessionResult.data.user.email);
3208
+ if (!stored || !stored.passwordHash) {
3209
+ return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
3210
+ status: 401,
3211
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3212
+ });
3213
+ }
3214
+ const valid = await verifyPassword(body.password, stored.passwordHash);
3215
+ if (!valid) {
3216
+ return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
3217
+ status: 401,
3218
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3219
+ });
3220
+ }
3221
+ await mfaStore.disableMfa(userId);
3222
+ return new Response(JSON.stringify({ ok: true }), {
3223
+ status: 200,
3224
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3225
+ });
3226
+ }
3227
+ if (method === "POST" && path === "/mfa/backup-codes") {
3228
+ if (!mfaStore) {
3229
+ return new Response(JSON.stringify({ error: "MFA not configured" }), {
3230
+ status: 400,
3231
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3232
+ });
3233
+ }
3234
+ const sessionResult = await getSession(request.headers);
3235
+ if (!sessionResult.ok || !sessionResult.data) {
3236
+ return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
3237
+ status: 401,
3238
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3239
+ });
3240
+ }
3241
+ const body = await request.json();
3242
+ const stored = await userStore.findByEmail(sessionResult.data.user.email);
3243
+ if (!stored || !stored.passwordHash) {
3244
+ return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
3245
+ status: 401,
3246
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3247
+ });
3248
+ }
3249
+ const valid = await verifyPassword(body.password, stored.passwordHash);
3250
+ if (!valid) {
3251
+ return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
3252
+ status: 401,
3253
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3254
+ });
3255
+ }
3256
+ const userId = sessionResult.data.user.id;
3257
+ const backupCodes = generateBackupCodes(mfaBackupCodeCount);
3258
+ const hashedCodes = await Promise.all(backupCodes.map((c) => hashBackupCode(c)));
3259
+ await mfaStore.setBackupCodes(userId, hashedCodes);
3260
+ return new Response(JSON.stringify({ backupCodes }), {
3261
+ status: 200,
3262
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3263
+ });
3264
+ }
3265
+ if (method === "GET" && path === "/mfa/status") {
3266
+ if (!mfaStore) {
3267
+ return new Response(JSON.stringify({ error: "MFA not configured" }), {
3268
+ status: 400,
3269
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3270
+ });
3271
+ }
3272
+ const sessionResult = await getSession(request.headers);
3273
+ if (!sessionResult.ok || !sessionResult.data) {
3274
+ return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
3275
+ status: 401,
3276
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3277
+ });
3278
+ }
3279
+ const userId = sessionResult.data.user.id;
3280
+ const enabled = await mfaStore.isMfaEnabled(userId);
3281
+ const codes = await mfaStore.getBackupCodes(userId);
3282
+ return new Response(JSON.stringify({
3283
+ enabled,
3284
+ hasBackupCodes: codes.length > 0,
3285
+ backupCodesRemaining: codes.length
3286
+ }), {
3287
+ status: 200,
3288
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3289
+ });
3290
+ }
3291
+ if (method === "POST" && path === "/mfa/step-up") {
3292
+ if (!mfaStore || !oauthEncryptionKey) {
3293
+ return new Response(JSON.stringify({ error: "MFA not configured" }), {
3294
+ status: 400,
3295
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3296
+ });
3297
+ }
3298
+ const stepUpIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "default";
3299
+ const stepUpRateLimit = await rateLimitStore.check(`mfa-stepup:${stepUpIp}`, 5, signInWindowMs);
3300
+ if (!stepUpRateLimit.allowed) {
3301
+ return new Response(JSON.stringify({ error: createAuthRateLimitedError("Too many step-up attempts") }), {
3302
+ status: 429,
3303
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3304
+ });
3305
+ }
3306
+ const sessionResult = await getSession(request.headers);
3307
+ if (!sessionResult.ok || !sessionResult.data) {
3308
+ return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
3309
+ status: 401,
3310
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3311
+ });
3312
+ }
3313
+ const userId = sessionResult.data.user.id;
3314
+ const encryptedSecret = await mfaStore.getSecret(userId);
3315
+ if (!encryptedSecret) {
3316
+ return new Response(JSON.stringify({ error: createMfaNotEnabledError() }), {
3317
+ status: 400,
3318
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3319
+ });
3320
+ }
3321
+ const totpSecret = await decrypt(encryptedSecret, oauthEncryptionKey);
3322
+ if (!totpSecret) {
3323
+ return new Response(JSON.stringify({ error: "Internal error" }), {
576
3324
  status: 500,
577
- headers: { "Content-Type": "application/json" }
3325
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3326
+ });
3327
+ }
3328
+ const body = await request.json();
3329
+ const valid = await verifyTotpCode(totpSecret, body.code);
3330
+ if (!valid) {
3331
+ return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
3332
+ status: 400,
3333
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3334
+ });
3335
+ }
3336
+ const user = sessionResult.data.user;
3337
+ const currentSid = sessionResult.data.payload.sid;
3338
+ const fvaTimestamp = Math.floor(Date.now() / 1000);
3339
+ const tokens = await createSessionTokens(user, currentSid, { fva: fvaTimestamp });
3340
+ const newRefreshHash = await sha256Hex(tokens.refreshToken);
3341
+ const oldTokens = await sessionStore.getCurrentTokens(currentSid);
3342
+ const previousRefreshHash = oldTokens ? await sha256Hex(oldTokens.refreshToken) : newRefreshHash;
3343
+ await sessionStore.updateSession(currentSid, {
3344
+ refreshTokenHash: newRefreshHash,
3345
+ previousRefreshHash,
3346
+ lastActiveAt: new Date,
3347
+ currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
3348
+ });
3349
+ const headers = new Headers({
3350
+ "Content-Type": "application/json",
3351
+ ...securityHeaders()
3352
+ });
3353
+ headers.append("Set-Cookie", buildSessionCookie(tokens.jwt, cookieConfig));
3354
+ headers.append("Set-Cookie", buildRefreshCookie(tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
3355
+ return new Response(JSON.stringify({ user }), {
3356
+ status: 200,
3357
+ headers
3358
+ });
3359
+ }
3360
+ if (method === "POST" && path === "/verify-email") {
3361
+ if (!emailVerificationStore || !emailVerificationEnabled) {
3362
+ return new Response(JSON.stringify({
3363
+ error: {
3364
+ code: "AUTH_VALIDATION_ERROR",
3365
+ message: "Email verification not configured"
3366
+ }
3367
+ }), {
3368
+ status: 400,
3369
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3370
+ });
3371
+ }
3372
+ const body = await request.json();
3373
+ if (!body.token) {
3374
+ return new Response(JSON.stringify({ error: createTokenInvalidError("Token is required") }), {
3375
+ status: 400,
3376
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3377
+ });
3378
+ }
3379
+ const tokenHash = await sha256Hex(body.token);
3380
+ const verification = await emailVerificationStore.findByTokenHash(tokenHash);
3381
+ if (!verification) {
3382
+ return new Response(JSON.stringify({ error: createTokenInvalidError() }), {
3383
+ status: 400,
3384
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3385
+ });
3386
+ }
3387
+ if (verification.expiresAt < new Date) {
3388
+ await emailVerificationStore.deleteByTokenHash(tokenHash);
3389
+ return new Response(JSON.stringify({ error: createTokenExpiredError() }), {
3390
+ status: 400,
3391
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3392
+ });
3393
+ }
3394
+ await userStore.updateEmailVerified(verification.userId, true);
3395
+ await emailVerificationStore.deleteByUserId(verification.userId);
3396
+ return new Response(JSON.stringify({ ok: true }), {
3397
+ status: 200,
3398
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3399
+ });
3400
+ }
3401
+ if (method === "POST" && path === "/resend-verification") {
3402
+ if (!emailVerificationStore || !emailVerificationEnabled || !emailVerificationConfig?.onSend) {
3403
+ return new Response(JSON.stringify({
3404
+ error: {
3405
+ code: "AUTH_VALIDATION_ERROR",
3406
+ message: "Email verification not configured"
3407
+ }
3408
+ }), {
3409
+ status: 400,
3410
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3411
+ });
3412
+ }
3413
+ const sessionResult = await getSession(request.headers);
3414
+ if (!sessionResult.ok || !sessionResult.data) {
3415
+ return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
3416
+ status: 401,
3417
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3418
+ });
3419
+ }
3420
+ const userId = sessionResult.data.user.id;
3421
+ const resendRateLimit = await rateLimitStore.check(`resend-verification:${userId}`, 3, parseDuration("1h"));
3422
+ if (!resendRateLimit.allowed) {
3423
+ return new Response(JSON.stringify({ error: createAuthRateLimitedError("Too many resend attempts") }), {
3424
+ status: 429,
3425
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3426
+ });
3427
+ }
3428
+ await emailVerificationStore.deleteByUserId(userId);
3429
+ const token = generateToken();
3430
+ const tokenHash = await sha256Hex(token);
3431
+ await emailVerificationStore.createVerification({
3432
+ userId,
3433
+ tokenHash,
3434
+ expiresAt: new Date(Date.now() + emailVerificationTtlMs)
3435
+ });
3436
+ await emailVerificationConfig.onSend(sessionResult.data.user, token);
3437
+ return new Response(JSON.stringify({ ok: true }), {
3438
+ status: 200,
3439
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3440
+ });
3441
+ }
3442
+ if (method === "POST" && path === "/forgot-password") {
3443
+ if (!passwordResetStore || !passwordResetEnabled || !passwordResetConfig?.onSend) {
3444
+ return new Response(JSON.stringify({
3445
+ error: { code: "AUTH_VALIDATION_ERROR", message: "Password reset not configured" }
3446
+ }), {
3447
+ status: 400,
3448
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3449
+ });
3450
+ }
3451
+ const body = await request.json();
3452
+ const forgotRateLimit = await rateLimitStore.check(`forgot-password:${(body.email ?? "").toLowerCase()}`, 3, parseDuration("1h"));
3453
+ if (!forgotRateLimit.allowed) {
3454
+ return new Response(JSON.stringify({ ok: true }), {
3455
+ status: 200,
3456
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
578
3457
  });
579
3458
  }
580
- return new Response(JSON.stringify({ session: result.data }), {
3459
+ const stored = await userStore.findByEmail((body.email ?? "").toLowerCase());
3460
+ if (stored) {
3461
+ const token = generateToken();
3462
+ const tokenHash = await sha256Hex(token);
3463
+ await passwordResetStore.createReset({
3464
+ userId: stored.user.id,
3465
+ tokenHash,
3466
+ expiresAt: new Date(Date.now() + passwordResetTtlMs)
3467
+ });
3468
+ await passwordResetConfig.onSend(stored.user, token);
3469
+ }
3470
+ return new Response(JSON.stringify({ ok: true }), {
581
3471
  status: 200,
582
- headers: { "Content-Type": "application/json" }
3472
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
583
3473
  });
584
3474
  }
585
- if (method === "POST" && path === "/refresh") {
586
- const result = await refreshSession({ headers: request.headers });
587
- if (!result.ok) {
588
- return new Response(JSON.stringify({ error: result.error }), {
589
- status: authErrorToStatus(result.error),
590
- headers: { "Content-Type": "application/json" }
3475
+ if (method === "POST" && path === "/reset-password") {
3476
+ if (!passwordResetStore || !passwordResetEnabled) {
3477
+ return new Response(JSON.stringify({
3478
+ error: { code: "AUTH_VALIDATION_ERROR", message: "Password reset not configured" }
3479
+ }), {
3480
+ status: 400,
3481
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
591
3482
  });
592
3483
  }
593
- const cookieValue = await createJWT(result.data.user, jwtSecret, ttlMs, jwtAlgorithm, claims);
594
- return new Response(JSON.stringify({ user: result.data.user }), {
595
- status: 200,
596
- headers: {
597
- "Content-Type": "application/json",
598
- "Set-Cookie": buildCookie(cookieValue)
3484
+ const body = await request.json();
3485
+ if (!body.token) {
3486
+ return new Response(JSON.stringify({ error: createTokenInvalidError("Token is required") }), {
3487
+ status: 400,
3488
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3489
+ });
3490
+ }
3491
+ const passwordError = validatePassword(body.password, emailPassword?.password);
3492
+ if (passwordError) {
3493
+ return new Response(JSON.stringify({ error: passwordError }), {
3494
+ status: 400,
3495
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3496
+ });
3497
+ }
3498
+ const tokenHash = await sha256Hex(body.token);
3499
+ const resetRecord = await passwordResetStore.findByTokenHash(tokenHash);
3500
+ if (!resetRecord) {
3501
+ return new Response(JSON.stringify({ error: createTokenInvalidError() }), {
3502
+ status: 400,
3503
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3504
+ });
3505
+ }
3506
+ if (resetRecord.expiresAt < new Date) {
3507
+ await passwordResetStore.deleteByUserId(resetRecord.userId);
3508
+ return new Response(JSON.stringify({ error: createTokenExpiredError() }), {
3509
+ status: 400,
3510
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
3511
+ });
3512
+ }
3513
+ const newPasswordHash = await hashPassword(body.password);
3514
+ await userStore.updatePasswordHash(resetRecord.userId, newPasswordHash);
3515
+ await passwordResetStore.deleteByUserId(resetRecord.userId);
3516
+ if (revokeSessionsOnReset) {
3517
+ const activeSessions = await sessionStore.listActiveSessions(resetRecord.userId);
3518
+ for (const s of activeSessions) {
3519
+ await sessionStore.revokeSession(s.id);
599
3520
  }
3521
+ }
3522
+ return new Response(JSON.stringify({ ok: true }), {
3523
+ status: 200,
3524
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
600
3525
  });
601
3526
  }
602
3527
  return new Response(JSON.stringify({ error: "Not found" }), {
@@ -610,17 +3535,6 @@ function createAuth(config) {
610
3535
  });
611
3536
  }
612
3537
  }
613
- function buildCookie(value, clear = false) {
614
- const name = cookieConfig.name || "vertz.sid";
615
- const maxAge = cookieConfig.maxAge ?? 60 * 60 * 24 * 7;
616
- const path = cookieConfig.path || "/";
617
- const sameSite = cookieConfig.sameSite || "lax";
618
- const secure = cookieConfig.secure ?? true;
619
- if (clear) {
620
- return `${name}=; Path=${path}; HttpOnly; SameSite=${sameSite}; Max-Age=0`;
621
- }
622
- return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=${maxAge}`;
623
- }
624
3538
  function createMiddleware() {
625
3539
  return async (ctx, next) => {
626
3540
  const sessionResult = await getSession(ctx.headers);
@@ -642,13 +3556,25 @@ function createAuth(config) {
642
3556
  signIn,
643
3557
  signOut,
644
3558
  getSession,
645
- refreshSession
3559
+ refreshSession,
3560
+ listSessions,
3561
+ revokeSession: revokeSessionById,
3562
+ revokeAllSessions
646
3563
  };
647
3564
  return {
648
3565
  handler: handleAuthRequest,
649
3566
  api,
650
3567
  middleware: createMiddleware,
651
- initialize
3568
+ initialize,
3569
+ dispose() {
3570
+ sessionStore.dispose();
3571
+ rateLimitStore.dispose();
3572
+ oauthAccountStore?.dispose();
3573
+ mfaStore?.dispose();
3574
+ emailVerificationStore?.dispose();
3575
+ passwordResetStore?.dispose();
3576
+ pendingMfaSecrets.clear();
3577
+ }
652
3578
  };
653
3579
  }
654
3580
  // src/create-server.ts
@@ -657,116 +3583,6 @@ import {
657
3583
  createDatabaseBridgeAdapter
658
3584
  } from "@vertz/db";
659
3585
 
660
- // src/entity/access-enforcer.ts
661
- import { EntityForbiddenError, err as err2, ok as ok2 } from "@vertz/errors";
662
- async function enforceAccess(operation, accessRules, ctx, row) {
663
- const rule = accessRules[operation];
664
- if (rule === undefined) {
665
- return err2(new EntityForbiddenError(`Access denied: no access rule for operation "${operation}"`));
666
- }
667
- if (rule === false) {
668
- return err2(new EntityForbiddenError(`Operation "${operation}" is disabled`));
669
- }
670
- const allowed = await rule(ctx, row ?? {});
671
- if (!allowed) {
672
- return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
673
- }
674
- return ok2(undefined);
675
- }
676
-
677
- // src/action/context.ts
678
- function createActionContext(request, registryProxy) {
679
- const userId = request.userId ?? null;
680
- const roles = request.roles ?? [];
681
- const tenantId = request.tenantId ?? null;
682
- return {
683
- userId,
684
- authenticated() {
685
- return userId !== null;
686
- },
687
- tenant() {
688
- return tenantId !== null;
689
- },
690
- role(...rolesToCheck) {
691
- return rolesToCheck.some((r) => roles.includes(r));
692
- },
693
- entities: registryProxy
694
- };
695
- }
696
-
697
- // src/action/route-generator.ts
698
- function jsonResponse(data, status = 200) {
699
- return new Response(JSON.stringify(data), {
700
- status,
701
- headers: { "content-type": "application/json" }
702
- });
703
- }
704
- function extractRequestInfo(ctx) {
705
- return {
706
- userId: ctx.userId ?? null,
707
- tenantId: ctx.tenantId ?? null,
708
- roles: ctx.roles ?? []
709
- };
710
- }
711
- function generateActionRoutes(def, registry, options) {
712
- const prefix = options?.apiPrefix ?? "/api";
713
- const inject = def.inject ?? {};
714
- const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
715
- const routes = [];
716
- for (const [handlerName, handlerDef] of Object.entries(def.actions)) {
717
- const accessRule = def.access[handlerName];
718
- if (accessRule === undefined)
719
- continue;
720
- const method = (handlerDef.method ?? "POST").toUpperCase();
721
- const handlerPath = handlerDef.path ?? `${prefix}/${def.name}/${handlerName}`;
722
- const routePath = handlerDef.path ? handlerPath : `${prefix}/${def.name}/${handlerName}`;
723
- if (accessRule === false) {
724
- routes.push({
725
- method,
726
- path: routePath,
727
- handler: async () => jsonResponse({
728
- error: {
729
- code: "MethodNotAllowed",
730
- message: `Action "${handlerName}" is disabled for ${def.name}`
731
- }
732
- }, 405)
733
- });
734
- continue;
735
- }
736
- routes.push({
737
- method,
738
- path: routePath,
739
- handler: async (ctx) => {
740
- try {
741
- const requestInfo = extractRequestInfo(ctx);
742
- const actionCtx = createActionContext(requestInfo, registryProxy);
743
- const accessResult = await enforceAccess(handlerName, def.access, actionCtx);
744
- if (!accessResult.ok) {
745
- return jsonResponse({ error: { code: "Forbidden", message: accessResult.error.message } }, 403);
746
- }
747
- const rawBody = ctx.body ?? {};
748
- const parsed = handlerDef.body.parse(rawBody);
749
- if (!parsed.ok) {
750
- const message = parsed.error instanceof Error ? parsed.error.message : "Invalid request body";
751
- return jsonResponse({ error: { code: "BadRequest", message } }, 400);
752
- }
753
- const result = await handlerDef.handler(parsed.data, actionCtx);
754
- const responseParsed = handlerDef.response.parse(result);
755
- if (!responseParsed.ok) {
756
- const message = responseParsed.error instanceof Error ? responseParsed.error.message : "Response validation failed";
757
- console.warn(`[vertz] Action response validation warning: ${message}`);
758
- }
759
- return jsonResponse(result, 200);
760
- } catch (error) {
761
- const message = error instanceof Error ? error.message : "Internal server error";
762
- return jsonResponse({ error: { code: "InternalServerError", message } }, 500);
763
- }
764
- }
765
- });
766
- }
767
- return routes;
768
- }
769
-
770
3586
  // src/entity/entity-registry.ts
771
3587
  class EntityRegistry {
772
3588
  entries = new Map;
@@ -903,6 +3719,25 @@ import {
903
3719
  err as err3,
904
3720
  ok as ok3
905
3721
  } from "@vertz/errors";
3722
+
3723
+ // src/entity/access-enforcer.ts
3724
+ import { EntityForbiddenError, err as err2, ok as ok2 } from "@vertz/errors";
3725
+ async function enforceAccess(operation, accessRules, ctx, row) {
3726
+ const rule = accessRules[operation];
3727
+ if (rule === undefined) {
3728
+ return err2(new EntityForbiddenError(`Access denied: no access rule for operation "${operation}"`));
3729
+ }
3730
+ if (rule === false) {
3731
+ return err2(new EntityForbiddenError(`Operation "${operation}" is disabled`));
3732
+ }
3733
+ const allowed = await rule(ctx, row ?? {});
3734
+ if (!allowed) {
3735
+ return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
3736
+ }
3737
+ return ok2(undefined);
3738
+ }
3739
+
3740
+ // src/entity/action-pipeline.ts
906
3741
  function createActionHandler(def, actionName, actionDef, db, hasId) {
907
3742
  return async (ctx, id, rawInput) => {
908
3743
  let row = null;
@@ -1263,7 +4098,7 @@ function validateVertzQL(options, table, relationsConfig) {
1263
4098
  }
1264
4099
 
1265
4100
  // src/entity/route-generator.ts
1266
- function jsonResponse2(data, status = 200) {
4101
+ function jsonResponse(data, status = 200) {
1267
4102
  return new Response(JSON.stringify(data), {
1268
4103
  status,
1269
4104
  headers: { "content-type": "application/json" }
@@ -1272,7 +4107,7 @@ function jsonResponse2(data, status = 200) {
1272
4107
  function emptyResponse(status) {
1273
4108
  return new Response(null, { status });
1274
4109
  }
1275
- function extractRequestInfo2(ctx) {
4110
+ function extractRequestInfo(ctx) {
1276
4111
  return {
1277
4112
  userId: ctx.userId ?? null,
1278
4113
  tenantId: ctx.tenantId ?? null,
@@ -1290,7 +4125,7 @@ function generateEntityRoutes(def, registry, db, options) {
1290
4125
  const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
1291
4126
  const routes = [];
1292
4127
  function makeEntityCtx(ctx) {
1293
- const requestInfo = extractRequestInfo2(ctx);
4128
+ const requestInfo = extractRequestInfo(ctx);
1294
4129
  const entityOps = {};
1295
4130
  return createEntityContext(requestInfo, entityOps, registryProxy);
1296
4131
  }
@@ -1299,7 +4134,7 @@ function generateEntityRoutes(def, registry, db, options) {
1299
4134
  routes.push({
1300
4135
  method: "GET",
1301
4136
  path: basePath,
1302
- handler: async () => jsonResponse2({
4137
+ handler: async () => jsonResponse({
1303
4138
  error: {
1304
4139
  code: "MethodNotAllowed",
1305
4140
  message: `Operation "list" is disabled for ${def.name}`
@@ -1318,7 +4153,7 @@ function generateEntityRoutes(def, registry, db, options) {
1318
4153
  const relationsConfig = def.relations;
1319
4154
  const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
1320
4155
  if (!validation.ok) {
1321
- return jsonResponse2({ error: { code: "BadRequest", message: validation.error } }, 400);
4156
+ return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
1322
4157
  }
1323
4158
  const options2 = {
1324
4159
  where: parsed.where ? parsed.where : undefined,
@@ -1329,15 +4164,15 @@ function generateEntityRoutes(def, registry, db, options) {
1329
4164
  const result = await crudHandlers.list(entityCtx, options2);
1330
4165
  if (!result.ok) {
1331
4166
  const { status, body } = entityErrorHandler(result.error);
1332
- return jsonResponse2(body, status);
4167
+ return jsonResponse(body, status);
1333
4168
  }
1334
4169
  if (parsed.select && result.data.body.items) {
1335
4170
  result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
1336
4171
  }
1337
- return jsonResponse2(result.data.body, result.data.status);
4172
+ return jsonResponse(result.data.body, result.data.status);
1338
4173
  } catch (error) {
1339
4174
  const { status, body } = entityErrorHandler(error);
1340
- return jsonResponse2(body, status);
4175
+ return jsonResponse(body, status);
1341
4176
  }
1342
4177
  }
1343
4178
  });
@@ -1360,7 +4195,7 @@ function generateEntityRoutes(def, registry, db, options) {
1360
4195
  const relationsConfig = def.relations;
1361
4196
  const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
1362
4197
  if (!validation.ok) {
1363
- return jsonResponse2({ error: { code: "BadRequest", message: validation.error } }, 400);
4198
+ return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
1364
4199
  }
1365
4200
  const options2 = {
1366
4201
  where: parsed.where,
@@ -1371,15 +4206,15 @@ function generateEntityRoutes(def, registry, db, options) {
1371
4206
  const result = await crudHandlers.list(entityCtx, options2);
1372
4207
  if (!result.ok) {
1373
4208
  const { status, body: errBody } = entityErrorHandler(result.error);
1374
- return jsonResponse2(errBody, status);
4209
+ return jsonResponse(errBody, status);
1375
4210
  }
1376
4211
  if (parsed.select && result.data.body.items) {
1377
4212
  result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
1378
4213
  }
1379
- return jsonResponse2(result.data.body, result.data.status);
4214
+ return jsonResponse(result.data.body, result.data.status);
1380
4215
  } catch (error) {
1381
4216
  const { status, body: errBody } = entityErrorHandler(error);
1382
- return jsonResponse2(errBody, status);
4217
+ return jsonResponse(errBody, status);
1383
4218
  }
1384
4219
  }
1385
4220
  });
@@ -1389,7 +4224,7 @@ function generateEntityRoutes(def, registry, db, options) {
1389
4224
  routes.push({
1390
4225
  method: "GET",
1391
4226
  path: `${basePath}/:id`,
1392
- handler: async () => jsonResponse2({
4227
+ handler: async () => jsonResponse({
1393
4228
  error: {
1394
4229
  code: "MethodNotAllowed",
1395
4230
  message: `Operation "get" is disabled for ${def.name}`
@@ -1409,18 +4244,18 @@ function generateEntityRoutes(def, registry, db, options) {
1409
4244
  const relationsConfig = def.relations;
1410
4245
  const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
1411
4246
  if (!validation.ok) {
1412
- return jsonResponse2({ error: { code: "BadRequest", message: validation.error } }, 400);
4247
+ return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
1413
4248
  }
1414
4249
  const result = await crudHandlers.get(entityCtx, id);
1415
4250
  if (!result.ok) {
1416
4251
  const { status, body: body2 } = entityErrorHandler(result.error);
1417
- return jsonResponse2(body2, status);
4252
+ return jsonResponse(body2, status);
1418
4253
  }
1419
4254
  const body = parsed.select ? applySelect(parsed.select, result.data.body) : result.data.body;
1420
- return jsonResponse2(body, result.data.status);
4255
+ return jsonResponse(body, result.data.status);
1421
4256
  } catch (error) {
1422
4257
  const { status, body } = entityErrorHandler(error);
1423
- return jsonResponse2(body, status);
4258
+ return jsonResponse(body, status);
1424
4259
  }
1425
4260
  }
1426
4261
  });
@@ -1431,7 +4266,7 @@ function generateEntityRoutes(def, registry, db, options) {
1431
4266
  routes.push({
1432
4267
  method: "POST",
1433
4268
  path: basePath,
1434
- handler: async () => jsonResponse2({
4269
+ handler: async () => jsonResponse({
1435
4270
  error: {
1436
4271
  code: "MethodNotAllowed",
1437
4272
  message: `Operation "create" is disabled for ${def.name}`
@@ -1449,12 +4284,12 @@ function generateEntityRoutes(def, registry, db, options) {
1449
4284
  const result = await crudHandlers.create(entityCtx, data);
1450
4285
  if (!result.ok) {
1451
4286
  const { status, body } = entityErrorHandler(result.error);
1452
- return jsonResponse2(body, status);
4287
+ return jsonResponse(body, status);
1453
4288
  }
1454
- return jsonResponse2(result.data.body, result.data.status);
4289
+ return jsonResponse(result.data.body, result.data.status);
1455
4290
  } catch (error) {
1456
4291
  const { status, body } = entityErrorHandler(error);
1457
- return jsonResponse2(body, status);
4292
+ return jsonResponse(body, status);
1458
4293
  }
1459
4294
  }
1460
4295
  });
@@ -1465,7 +4300,7 @@ function generateEntityRoutes(def, registry, db, options) {
1465
4300
  routes.push({
1466
4301
  method: "PATCH",
1467
4302
  path: `${basePath}/:id`,
1468
- handler: async () => jsonResponse2({
4303
+ handler: async () => jsonResponse({
1469
4304
  error: {
1470
4305
  code: "MethodNotAllowed",
1471
4306
  message: `Operation "update" is disabled for ${def.name}`
@@ -1484,12 +4319,12 @@ function generateEntityRoutes(def, registry, db, options) {
1484
4319
  const result = await crudHandlers.update(entityCtx, id, data);
1485
4320
  if (!result.ok) {
1486
4321
  const { status, body } = entityErrorHandler(result.error);
1487
- return jsonResponse2(body, status);
4322
+ return jsonResponse(body, status);
1488
4323
  }
1489
- return jsonResponse2(result.data.body, result.data.status);
4324
+ return jsonResponse(result.data.body, result.data.status);
1490
4325
  } catch (error) {
1491
4326
  const { status, body } = entityErrorHandler(error);
1492
- return jsonResponse2(body, status);
4327
+ return jsonResponse(body, status);
1493
4328
  }
1494
4329
  }
1495
4330
  });
@@ -1500,7 +4335,7 @@ function generateEntityRoutes(def, registry, db, options) {
1500
4335
  routes.push({
1501
4336
  method: "DELETE",
1502
4337
  path: `${basePath}/:id`,
1503
- handler: async () => jsonResponse2({
4338
+ handler: async () => jsonResponse({
1504
4339
  error: {
1505
4340
  code: "MethodNotAllowed",
1506
4341
  message: `Operation "delete" is disabled for ${def.name}`
@@ -1518,15 +4353,15 @@ function generateEntityRoutes(def, registry, db, options) {
1518
4353
  const result = await crudHandlers.delete(entityCtx, id);
1519
4354
  if (!result.ok) {
1520
4355
  const { status, body } = entityErrorHandler(result.error);
1521
- return jsonResponse2(body, status);
4356
+ return jsonResponse(body, status);
1522
4357
  }
1523
4358
  if (result.data.status === 204) {
1524
4359
  return emptyResponse(204);
1525
4360
  }
1526
- return jsonResponse2(result.data.body, result.data.status);
4361
+ return jsonResponse(result.data.body, result.data.status);
1527
4362
  } catch (error) {
1528
4363
  const { status, body } = entityErrorHandler(error);
1529
- return jsonResponse2(body, status);
4364
+ return jsonResponse(body, status);
1530
4365
  }
1531
4366
  }
1532
4367
  });
@@ -1542,7 +4377,7 @@ function generateEntityRoutes(def, registry, db, options) {
1542
4377
  routes.push({
1543
4378
  method,
1544
4379
  path: actionPath,
1545
- handler: async () => jsonResponse2({
4380
+ handler: async () => jsonResponse({
1546
4381
  error: {
1547
4382
  code: "MethodNotAllowed",
1548
4383
  message: `Action "${actionName}" is disabled for ${def.name}`
@@ -1562,12 +4397,12 @@ function generateEntityRoutes(def, registry, db, options) {
1562
4397
  const result = await actionHandler(entityCtx, id, input);
1563
4398
  if (!result.ok) {
1564
4399
  const { status, body } = entityErrorHandler(result.error);
1565
- return jsonResponse2(body, status);
4400
+ return jsonResponse(body, status);
1566
4401
  }
1567
- return jsonResponse2(result.data.body, result.data.status);
4402
+ return jsonResponse(result.data.body, result.data.status);
1568
4403
  } catch (error) {
1569
4404
  const { status, body } = entityErrorHandler(error);
1570
- return jsonResponse2(body, status);
4405
+ return jsonResponse(body, status);
1571
4406
  }
1572
4407
  }
1573
4408
  });
@@ -1576,6 +4411,99 @@ function generateEntityRoutes(def, registry, db, options) {
1576
4411
  return routes;
1577
4412
  }
1578
4413
 
4414
+ // src/service/context.ts
4415
+ function createServiceContext(request, registryProxy) {
4416
+ const userId = request.userId ?? null;
4417
+ const roles = request.roles ?? [];
4418
+ const tenantId = request.tenantId ?? null;
4419
+ return {
4420
+ userId,
4421
+ authenticated() {
4422
+ return userId !== null;
4423
+ },
4424
+ tenant() {
4425
+ return tenantId !== null;
4426
+ },
4427
+ role(...rolesToCheck) {
4428
+ return rolesToCheck.some((r) => roles.includes(r));
4429
+ },
4430
+ entities: registryProxy
4431
+ };
4432
+ }
4433
+
4434
+ // src/service/route-generator.ts
4435
+ function jsonResponse2(data, status = 200) {
4436
+ return new Response(JSON.stringify(data), {
4437
+ status,
4438
+ headers: { "content-type": "application/json" }
4439
+ });
4440
+ }
4441
+ function extractRequestInfo2(ctx) {
4442
+ return {
4443
+ userId: ctx.userId ?? null,
4444
+ tenantId: ctx.tenantId ?? null,
4445
+ roles: ctx.roles ?? []
4446
+ };
4447
+ }
4448
+ function generateServiceRoutes(def, registry, options) {
4449
+ const prefix = options?.apiPrefix ?? "/api";
4450
+ const inject = def.inject ?? {};
4451
+ const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
4452
+ const routes = [];
4453
+ for (const [handlerName, handlerDef] of Object.entries(def.actions)) {
4454
+ const accessRule = def.access[handlerName];
4455
+ if (accessRule === undefined)
4456
+ continue;
4457
+ const method = (handlerDef.method ?? "POST").toUpperCase();
4458
+ const handlerPath = handlerDef.path ?? `${prefix}/${def.name}/${handlerName}`;
4459
+ const routePath = handlerDef.path ? handlerPath : `${prefix}/${def.name}/${handlerName}`;
4460
+ if (accessRule === false) {
4461
+ routes.push({
4462
+ method,
4463
+ path: routePath,
4464
+ handler: async () => jsonResponse2({
4465
+ error: {
4466
+ code: "MethodNotAllowed",
4467
+ message: `Action "${handlerName}" is disabled for ${def.name}`
4468
+ }
4469
+ }, 405)
4470
+ });
4471
+ continue;
4472
+ }
4473
+ routes.push({
4474
+ method,
4475
+ path: routePath,
4476
+ handler: async (ctx) => {
4477
+ try {
4478
+ const requestInfo = extractRequestInfo2(ctx);
4479
+ const serviceCtx = createServiceContext(requestInfo, registryProxy);
4480
+ const accessResult = await enforceAccess(handlerName, def.access, serviceCtx);
4481
+ if (!accessResult.ok) {
4482
+ return jsonResponse2({ error: { code: "Forbidden", message: accessResult.error.message } }, 403);
4483
+ }
4484
+ const rawBody = ctx.body ?? {};
4485
+ const parsed = handlerDef.body.parse(rawBody);
4486
+ if (!parsed.ok) {
4487
+ const message = parsed.error instanceof Error ? parsed.error.message : "Invalid request body";
4488
+ return jsonResponse2({ error: { code: "BadRequest", message } }, 400);
4489
+ }
4490
+ const result = await handlerDef.handler(parsed.data, serviceCtx);
4491
+ const responseParsed = handlerDef.response.parse(result);
4492
+ if (!responseParsed.ok) {
4493
+ const message = responseParsed.error instanceof Error ? responseParsed.error.message : "Response validation failed";
4494
+ console.warn(`[vertz] Service response validation warning: ${message}`);
4495
+ }
4496
+ return jsonResponse2(result, 200);
4497
+ } catch (error) {
4498
+ const message = error instanceof Error ? error.message : "Internal server error";
4499
+ return jsonResponse2({ error: { code: "InternalServerError", message } }, 500);
4500
+ }
4501
+ }
4502
+ });
4503
+ }
4504
+ return routes;
4505
+ }
4506
+
1579
4507
  // src/create-server.ts
1580
4508
  function isDatabaseClient(db) {
1581
4509
  return db !== null && typeof db === "object" && "_internals" in db;
@@ -1641,6 +4569,13 @@ function createServer(config) {
1641
4569
  const { db } = config;
1642
4570
  let dbFactory;
1643
4571
  if (db && isDatabaseClient(db)) {
4572
+ const dbModels = db._internals.models;
4573
+ const missing = config.entities.filter((e) => !(e.name in dbModels)).map((e) => `"${e.name}"`);
4574
+ if (missing.length > 0) {
4575
+ const registered = Object.keys(dbModels).map((k) => `"${k}"`).join(", ");
4576
+ const plural = missing.length > 1;
4577
+ throw new Error(`${plural ? "Entities" : "Entity"} ${missing.join(", ")} ${plural ? "are" : "is"} not registered in createDb(). ` + `Add the missing model${plural ? "s" : ""} to the models object in your createDb() call. ` + `Registered models: ${registered || "(none)"}`);
4578
+ }
1644
4579
  dbFactory = (entityDef) => createDatabaseBridgeAdapter(db, entityDef.name);
1645
4580
  } else if (db) {
1646
4581
  dbFactory = () => db;
@@ -1660,9 +4595,9 @@ function createServer(config) {
1660
4595
  allRoutes.push(...routes);
1661
4596
  }
1662
4597
  }
1663
- if (config.actions && config.actions.length > 0) {
1664
- for (const actionDef of config.actions) {
1665
- const routes = generateActionRoutes(actionDef, registry, { apiPrefix });
4598
+ if (config.services && config.services.length > 0) {
4599
+ for (const serviceDef of config.services) {
4600
+ const routes = generateServiceRoutes(serviceDef, registry, { apiPrefix });
1666
4601
  allRoutes.push(...routes);
1667
4602
  }
1668
4603
  }
@@ -1672,7 +4607,7 @@ function createServer(config) {
1672
4607
  });
1673
4608
  }
1674
4609
  // src/entity/entity.ts
1675
- import { deepFreeze as deepFreeze2 } from "@vertz/core";
4610
+ import { deepFreeze } from "@vertz/core";
1676
4611
  var ENTITY_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
1677
4612
  function entity(name, config) {
1678
4613
  if (!name || !ENTITY_NAME_PATTERN.test(name)) {
@@ -1692,6 +4627,25 @@ function entity(name, config) {
1692
4627
  actions: config.actions ?? {},
1693
4628
  relations: config.relations ?? {}
1694
4629
  };
4630
+ return deepFreeze(def);
4631
+ }
4632
+ // src/service/service.ts
4633
+ import { deepFreeze as deepFreeze2 } from "@vertz/core";
4634
+ var SERVICE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
4635
+ function service(name, config) {
4636
+ if (!name || !SERVICE_NAME_PATTERN.test(name)) {
4637
+ throw new Error(`service() name must be a non-empty lowercase string matching /^[a-z][a-z0-9-]*$/. Got: "${name}"`);
4638
+ }
4639
+ if (!config.actions || Object.keys(config.actions).length === 0) {
4640
+ throw new Error("service() requires at least one action in the actions config.");
4641
+ }
4642
+ const def = {
4643
+ kind: "service",
4644
+ name,
4645
+ inject: config.inject ?? {},
4646
+ access: config.access ?? {},
4647
+ actions: config.actions
4648
+ };
1695
4649
  return deepFreeze2(def);
1696
4650
  }
1697
4651
  export {
@@ -1700,14 +4654,22 @@ export {
1700
4654
  validatePassword,
1701
4655
  stripReadOnlyFields,
1702
4656
  stripHiddenFields,
4657
+ service,
4658
+ rules,
1703
4659
  makeImmutable,
1704
4660
  hashPassword,
4661
+ google,
4662
+ github,
1705
4663
  generateEntityRoutes,
1706
4664
  entityErrorHandler,
1707
4665
  entity,
1708
4666
  enforceAccess,
4667
+ encodeAccessSet,
4668
+ discord,
4669
+ defineAccess,
1709
4670
  defaultAccess,
1710
4671
  deepFreeze3 as deepFreeze,
4672
+ decodeAccessSet,
1711
4673
  createServer,
1712
4674
  createMiddleware,
1713
4675
  createImmutableProxy,
@@ -1715,14 +4677,31 @@ export {
1715
4677
  createEntityContext,
1716
4678
  createCrudHandlers,
1717
4679
  createAuth,
4680
+ createAccessEventBroadcaster,
4681
+ createAccessContext,
1718
4682
  createAccess,
1719
- action,
4683
+ computeEntityAccess,
4684
+ computeAccessSet,
4685
+ checkFva,
4686
+ calculateBillingPeriod,
1720
4687
  VertzException2 as VertzException,
1721
4688
  ValidationException2 as ValidationException,
1722
4689
  UnauthorizedException,
1723
4690
  ServiceUnavailableException,
1724
4691
  NotFoundException,
1725
4692
  InternalServerErrorException,
4693
+ InMemoryWalletStore,
4694
+ InMemoryUserStore,
4695
+ InMemorySessionStore,
4696
+ InMemoryRoleAssignmentStore,
4697
+ InMemoryRateLimitStore,
4698
+ InMemoryPlanStore,
4699
+ InMemoryPasswordResetStore,
4700
+ InMemoryOAuthAccountStore,
4701
+ InMemoryMFAStore,
4702
+ InMemoryFlagStore,
4703
+ InMemoryEmailVerificationStore,
4704
+ InMemoryClosureStore,
1726
4705
  ForbiddenException,
1727
4706
  EntityRegistry,
1728
4707
  ConflictException,