@vertz/server 0.2.15 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +440 -201
  2. package/dist/index.js +1488 -395
  3. package/package.json +5 -4
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import {
3
- BadRequestException,
3
+ BadRequestException as BadRequestException2,
4
4
  ConflictException,
5
5
  createEnv,
6
6
  createImmutableProxy,
@@ -20,6 +20,8 @@ import {
20
20
  // src/auth/index.ts
21
21
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
22
  import { join } from "node:path";
23
+ import { BadRequestException } from "@vertz/core";
24
+ import { parseBody } from "@vertz/core/internals";
23
25
  import {
24
26
  createAuthRateLimitedError,
25
27
  createAuthValidationError as createAuthValidationError2,
@@ -86,65 +88,83 @@ function getMonthOffset(base, target) {
86
88
  return (target.getUTCFullYear() - base.getUTCFullYear()) * 12 + (target.getUTCMonth() - base.getUTCMonth());
87
89
  }
88
90
 
89
- // src/auth/plan-store.ts
90
- function resolveEffectivePlan(orgPlan, plans, defaultPlan = "free", now) {
91
- if (!orgPlan)
91
+ // src/auth/resolve-inherited-role.ts
92
+ function resolveInheritedRole(sourceType, sourceRole, targetType, accessDef) {
93
+ const hierarchy = accessDef.hierarchy;
94
+ const sourceIdx = hierarchy.indexOf(sourceType);
95
+ const targetIdx = hierarchy.indexOf(targetType);
96
+ if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
97
+ return null;
98
+ let currentRole = sourceRole;
99
+ for (let i = sourceIdx;i < targetIdx; i++) {
100
+ const currentType = hierarchy[i];
101
+ const inheritanceMap = accessDef.inheritance[currentType];
102
+ if (!inheritanceMap || !(currentRole in inheritanceMap))
103
+ return null;
104
+ currentRole = inheritanceMap[currentRole];
105
+ }
106
+ return currentRole;
107
+ }
108
+
109
+ // src/auth/subscription-store.ts
110
+ function resolveEffectivePlan(subscription, plans, defaultPlan = "free", now) {
111
+ if (!subscription)
92
112
  return null;
93
- if (orgPlan.expiresAt && orgPlan.expiresAt.getTime() < (now ?? Date.now())) {
113
+ if (subscription.expiresAt && subscription.expiresAt.getTime() < (now ?? Date.now())) {
94
114
  return plans?.[defaultPlan] ? defaultPlan : null;
95
115
  }
96
- if (!plans?.[orgPlan.planId])
116
+ if (!plans?.[subscription.planId])
97
117
  return null;
98
- return orgPlan.planId;
118
+ return subscription.planId;
99
119
  }
100
120
 
101
- class InMemoryPlanStore {
102
- plans = new Map;
121
+ class InMemorySubscriptionStore {
122
+ subscriptions = new Map;
103
123
  addOns = new Map;
104
- async assignPlan(orgId, planId, startedAt = new Date, expiresAt = null) {
105
- this.plans.set(orgId, {
106
- orgId,
124
+ async assign(tenantId, planId, startedAt = new Date, expiresAt = null) {
125
+ this.subscriptions.set(tenantId, {
126
+ tenantId,
107
127
  planId,
108
128
  startedAt,
109
129
  expiresAt,
110
130
  overrides: {}
111
131
  });
112
132
  }
113
- async getPlan(orgId) {
114
- return this.plans.get(orgId) ?? null;
133
+ async get(tenantId) {
134
+ return this.subscriptions.get(tenantId) ?? null;
115
135
  }
116
- async updateOverrides(orgId, overrides) {
117
- const plan = this.plans.get(orgId);
118
- if (!plan)
136
+ async updateOverrides(tenantId, overrides) {
137
+ const sub = this.subscriptions.get(tenantId);
138
+ if (!sub)
119
139
  return;
120
- plan.overrides = { ...plan.overrides, ...overrides };
140
+ sub.overrides = { ...sub.overrides, ...overrides };
121
141
  }
122
- async removePlan(orgId) {
123
- this.plans.delete(orgId);
142
+ async remove(tenantId) {
143
+ this.subscriptions.delete(tenantId);
124
144
  }
125
- async attachAddOn(orgId, addOnId) {
126
- if (!this.addOns.has(orgId)) {
127
- this.addOns.set(orgId, new Set);
145
+ async attachAddOn(tenantId, addOnId) {
146
+ if (!this.addOns.has(tenantId)) {
147
+ this.addOns.set(tenantId, new Set);
128
148
  }
129
- this.addOns.get(orgId).add(addOnId);
149
+ this.addOns.get(tenantId).add(addOnId);
130
150
  }
131
- async detachAddOn(orgId, addOnId) {
132
- this.addOns.get(orgId)?.delete(addOnId);
151
+ async detachAddOn(tenantId, addOnId) {
152
+ this.addOns.get(tenantId)?.delete(addOnId);
133
153
  }
134
- async getAddOns(orgId) {
135
- return [...this.addOns.get(orgId) ?? []];
154
+ async getAddOns(tenantId) {
155
+ return [...this.addOns.get(tenantId) ?? []];
136
156
  }
137
157
  async listByPlan(planId) {
138
158
  const result = [];
139
- for (const [orgId, plan] of this.plans.entries()) {
140
- if (plan.planId === planId) {
141
- result.push(orgId);
159
+ for (const [tenantId, sub] of this.subscriptions.entries()) {
160
+ if (sub.planId === planId) {
161
+ result.push(tenantId);
142
162
  }
143
163
  }
144
164
  return result;
145
165
  }
146
166
  dispose() {
147
- this.plans.clear();
167
+ this.subscriptions.clear();
148
168
  this.addOns.clear();
149
169
  }
150
170
  }
@@ -158,24 +178,6 @@ function getIncompatibleAddOns(accessDef, activeAddOnIds, targetPlanId) {
158
178
  return activeAddOnIds.filter((id) => !checkAddOnCompatibility(accessDef, id, targetPlanId));
159
179
  }
160
180
 
161
- // src/auth/resolve-inherited-role.ts
162
- function resolveInheritedRole(sourceType, sourceRole, targetType, accessDef) {
163
- const hierarchy = accessDef.hierarchy;
164
- const sourceIdx = hierarchy.indexOf(sourceType);
165
- const targetIdx = hierarchy.indexOf(targetType);
166
- if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
167
- return null;
168
- let currentRole = sourceRole;
169
- for (let i = sourceIdx;i < targetIdx; i++) {
170
- const currentType = hierarchy[i];
171
- const inheritanceMap = accessDef.inheritance[currentType];
172
- if (!inheritanceMap || !(currentRole in inheritanceMap))
173
- return null;
174
- currentRole = inheritanceMap[currentRole];
175
- }
176
- return currentRole;
177
- }
178
-
179
181
  // src/auth/access-set.ts
180
182
  async function computeAccessSet(config) {
181
183
  const {
@@ -183,14 +185,13 @@ async function computeAccessSet(config) {
183
185
  accessDef,
184
186
  roleStore,
185
187
  closureStore,
186
- plan,
187
188
  flagStore,
188
- planStore,
189
+ subscriptionStore,
189
190
  walletStore,
190
- orgId
191
+ tenantId
191
192
  } = config;
192
193
  const entitlements = {};
193
- let resolvedPlan = plan ?? null;
194
+ let resolvedPlan = null;
194
195
  if (!userId) {
195
196
  for (const name of Object.keys(accessDef.entitlements)) {
196
197
  entitlements[name] = {
@@ -202,7 +203,7 @@ async function computeAccessSet(config) {
202
203
  return {
203
204
  entitlements,
204
205
  flags: {},
205
- plan: plan ?? null,
206
+ plan: null,
206
207
  computedAt: new Date().toISOString()
207
208
  };
208
209
  }
@@ -244,14 +245,14 @@ async function computeAccessSet(config) {
244
245
  }
245
246
  }
246
247
  const resolvedFlags = {};
247
- if (flagStore && orgId) {
248
- const orgFlags = flagStore.getFlags(orgId);
248
+ if (flagStore && tenantId) {
249
+ const orgFlags = flagStore.getFlags(tenantId);
249
250
  Object.assign(resolvedFlags, orgFlags);
250
251
  for (const [name, entDef] of Object.entries(accessDef.entitlements)) {
251
252
  if (entDef.flags?.length) {
252
253
  const disabledFlags = [];
253
254
  for (const flag of entDef.flags) {
254
- if (!flagStore.getFlag(orgId, flag)) {
255
+ if (!flagStore.getFlag(tenantId, flag)) {
255
256
  disabledFlags.push(flag);
256
257
  }
257
258
  }
@@ -271,16 +272,16 @@ async function computeAccessSet(config) {
271
272
  }
272
273
  }
273
274
  }
274
- if (planStore && orgId) {
275
- const orgPlan = await planStore.getPlan(orgId);
276
- if (orgPlan) {
277
- const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
275
+ if (subscriptionStore && tenantId) {
276
+ const subscription = await subscriptionStore.get(tenantId);
277
+ if (subscription) {
278
+ const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
278
279
  resolvedPlan = effectivePlanId;
279
280
  if (effectivePlanId) {
280
281
  const planDef = accessDef.plans?.[effectivePlanId];
281
282
  if (planDef) {
282
283
  const effectiveFeatures = new Set(planDef.features ?? []);
283
- const addOns = await planStore.getAddOns?.(orgId);
284
+ const addOns = await subscriptionStore.getAddOns?.(tenantId);
284
285
  if (addOns) {
285
286
  for (const addOnId of addOns) {
286
287
  const addOnDef = accessDef.plans?.[addOnId];
@@ -326,7 +327,7 @@ async function computeAccessSet(config) {
326
327
  }
327
328
  }
328
329
  }
329
- const override = orgPlan.overrides[limitKey];
330
+ const override = subscription.overrides[limitKey];
330
331
  if (override)
331
332
  effectiveMax = Math.max(effectiveMax, override.max);
332
333
  if (effectiveMax === -1) {
@@ -339,11 +340,11 @@ async function computeAccessSet(config) {
339
340
  }
340
341
  };
341
342
  } else {
342
- const period = limitDef.per ? calculateBillingPeriod(orgPlan.startedAt, limitDef.per) : {
343
- periodStart: orgPlan.startedAt,
343
+ const period = limitDef.per ? calculateBillingPeriod(subscription.startedAt, limitDef.per) : {
344
+ periodStart: subscription.startedAt,
344
345
  periodEnd: new Date("9999-12-31T23:59:59Z")
345
346
  };
346
- const consumed = await walletStore.getConsumption(orgId, limitKey, period.periodStart, period.periodEnd);
347
+ const consumed = await walletStore.getConsumption(tenantId, limitKey, period.periodStart, period.periodEnd);
347
348
  const remaining = Math.max(0, effectiveMax - consumed);
348
349
  const entry = entitlements[name];
349
350
  if (consumed >= effectiveMax) {
@@ -895,6 +896,60 @@ class InMemoryRateLimitStore {
895
896
  }
896
897
  }
897
898
 
899
+ // src/auth/resolve-session-for-ssr.ts
900
+ function extractAccessSet(acl) {
901
+ if (acl.overflow)
902
+ return null;
903
+ if (!acl.set)
904
+ return null;
905
+ return {
906
+ entitlements: Object.fromEntries(Object.entries(acl.set.entitlements).map(([name, check]) => {
907
+ const data = {
908
+ allowed: check.allowed,
909
+ reasons: check.reasons ?? [],
910
+ ...check.reason ? { reason: check.reason } : {},
911
+ ...check.meta ? { meta: check.meta } : {}
912
+ };
913
+ return [name, data];
914
+ })),
915
+ flags: acl.set.flags,
916
+ plan: acl.set.plan,
917
+ computedAt: acl.set.computedAt
918
+ };
919
+ }
920
+ function resolveSessionForSSR(config) {
921
+ const { jwtSecret, jwtAlgorithm, cookieName } = config;
922
+ return async (request) => {
923
+ const cookieHeader = request.headers.get("cookie");
924
+ if (!cookieHeader)
925
+ return null;
926
+ const cookieEntry = cookieHeader.split(";").find((c) => c.trim().startsWith(`${cookieName}=`));
927
+ if (!cookieEntry)
928
+ return null;
929
+ const token = cookieEntry.trim().slice(`${cookieName}=`.length);
930
+ if (!token)
931
+ return null;
932
+ const payload = await verifyJWT(token, jwtSecret, jwtAlgorithm);
933
+ if (!payload)
934
+ return null;
935
+ const user = {
936
+ id: payload.sub,
937
+ email: payload.email,
938
+ role: payload.role
939
+ };
940
+ if (payload.tenantId) {
941
+ user.tenantId = payload.tenantId;
942
+ }
943
+ const session = {
944
+ user,
945
+ expiresAt: payload.exp * 1000
946
+ };
947
+ const acl = payload.acl;
948
+ const accessSet = acl !== undefined ? extractAccessSet(acl) : undefined;
949
+ return { session, accessSet };
950
+ };
951
+ }
952
+
898
953
  // src/auth/session-store.ts
899
954
  var DEFAULT_MAX_SESSIONS_PER_USER = 50;
900
955
  var CLEANUP_INTERVAL_MS2 = 60000;
@@ -969,6 +1024,13 @@ class InMemorySessionStore {
969
1024
  }
970
1025
  return null;
971
1026
  }
1027
+ async findActiveSessionById(id) {
1028
+ const session = this.sessions.get(id);
1029
+ if (!session || session.revokedAt || session.expiresAt <= new Date) {
1030
+ return null;
1031
+ }
1032
+ return session;
1033
+ }
972
1034
  async findByPreviousRefreshHash(hash) {
973
1035
  for (const session of this.sessions.values()) {
974
1036
  if (session.previousRefreshHash !== null && timingSafeEqual(session.previousRefreshHash, hash) && !session.revokedAt && session.expiresAt > new Date) {
@@ -1139,6 +1201,40 @@ function timingSafeEqual2(a, b) {
1139
1201
  return equal && a.length === b.length;
1140
1202
  }
1141
1203
 
1204
+ // src/auth/types.ts
1205
+ import {
1206
+ s
1207
+ } from "@vertz/schema";
1208
+ var authEmailFieldSchema = s.string().min(1).trim();
1209
+ var authPasswordFieldSchema = s.string().min(1);
1210
+ var signUpInputSchema = s.object({
1211
+ email: authEmailFieldSchema,
1212
+ password: authPasswordFieldSchema
1213
+ }).passthrough();
1214
+ var signInInputSchema = s.object({
1215
+ email: authEmailFieldSchema,
1216
+ password: authPasswordFieldSchema
1217
+ });
1218
+ var codeInputSchema = s.object({
1219
+ code: s.string().min(1)
1220
+ });
1221
+ var passwordInputSchema = s.object({
1222
+ password: s.string().min(1)
1223
+ });
1224
+ var tokenInputSchema = s.object({
1225
+ token: s.string().min(1).optional()
1226
+ });
1227
+ var forgotPasswordInputSchema = s.object({
1228
+ email: s.string().min(1).trim()
1229
+ });
1230
+ var resetPasswordInputSchema = s.object({
1231
+ token: s.string().min(1).optional(),
1232
+ password: s.string().min(1)
1233
+ });
1234
+ var switchTenantInputSchema = s.object({
1235
+ tenantId: s.string().min(1)
1236
+ });
1237
+
1142
1238
  // src/auth/user-store.ts
1143
1239
  class InMemoryUserStore {
1144
1240
  byEmail = new Map;
@@ -1168,6 +1264,13 @@ class InMemoryUserStore {
1168
1264
  user.emailVerified = verified;
1169
1265
  }
1170
1266
  }
1267
+ async deleteUser(id) {
1268
+ const user = this.byId.get(id);
1269
+ if (user) {
1270
+ this.byEmail.delete(user.email.toLowerCase());
1271
+ this.byId.delete(id);
1272
+ }
1273
+ }
1171
1274
  }
1172
1275
  // src/auth/access.ts
1173
1276
  class AuthorizationError extends Error {
@@ -1303,7 +1406,7 @@ function createAccessContext(config) {
1303
1406
  closureStore,
1304
1407
  roleStore,
1305
1408
  flagStore,
1306
- planStore,
1409
+ subscriptionStore,
1307
1410
  walletStore,
1308
1411
  overrideStore,
1309
1412
  orgResolver,
@@ -1332,14 +1435,14 @@ function createAccessContext(config) {
1332
1435
  } else if (entDef.roles.length > 0 && !resource) {
1333
1436
  return false;
1334
1437
  }
1335
- if (accessDef._planGatedEntitlements.has(entitlement) && planStore && orgResolver) {
1438
+ if (accessDef._planGatedEntitlements.has(entitlement) && subscriptionStore && orgResolver) {
1336
1439
  if (!resolvedOrgId)
1337
1440
  return false;
1338
- const orgPlan = await planStore.getPlan(resolvedOrgId);
1339
- const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1441
+ const subscription = await subscriptionStore.get(resolvedOrgId);
1442
+ const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
1340
1443
  if (!effectivePlanId)
1341
1444
  return false;
1342
- const hasFeature = await resolveEffectiveFeatures(resolvedOrgId, entitlement, effectivePlanId, accessDef, planStore, planVersionStore, overrides);
1445
+ const hasFeature = await resolveEffectiveFeatures(resolvedOrgId, entitlement, effectivePlanId, accessDef, subscriptionStore, planVersionStore, overrides);
1343
1446
  if (!hasFeature)
1344
1447
  return false;
1345
1448
  }
@@ -1351,8 +1454,8 @@ function createAccessContext(config) {
1351
1454
  if (!await checkLayers1to3(entitlement, resource, resolvedOrgId, overrides))
1352
1455
  return false;
1353
1456
  const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
1354
- if (limitKeys?.length && walletStore && planStore && resolvedOrgId) {
1355
- const walletStates = await resolveAllLimitStates(entitlement, resolvedOrgId, accessDef, planStore, walletStore, planVersionStore, overrides);
1457
+ if (limitKeys?.length && walletStore && subscriptionStore && resolvedOrgId) {
1458
+ const walletStates = await resolveAllLimitStates(entitlement, resolvedOrgId, accessDef, subscriptionStore, walletStore, planVersionStore, overrides);
1356
1459
  for (const ws of walletStates) {
1357
1460
  if (ws.max === 0)
1358
1461
  return false;
@@ -1422,17 +1525,17 @@ function createAccessContext(config) {
1422
1525
  meta.requiredRoles = [...entDef.roles];
1423
1526
  }
1424
1527
  }
1425
- if (accessDef._planGatedEntitlements.has(entitlement) && planStore && orgResolver) {
1528
+ if (accessDef._planGatedEntitlements.has(entitlement) && subscriptionStore && orgResolver) {
1426
1529
  let planDenied = false;
1427
1530
  if (!resolvedOrgId) {
1428
1531
  planDenied = true;
1429
1532
  } else {
1430
- const orgPlan = await planStore.getPlan(resolvedOrgId);
1431
- const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1533
+ const subscription = await subscriptionStore.get(resolvedOrgId);
1534
+ const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
1432
1535
  if (!effectivePlanId) {
1433
1536
  planDenied = true;
1434
1537
  } else {
1435
- const hasFeature = await resolveEffectiveFeatures(resolvedOrgId, entitlement, effectivePlanId, accessDef, planStore, planVersionStore, overrides);
1538
+ const hasFeature = await resolveEffectiveFeatures(resolvedOrgId, entitlement, effectivePlanId, accessDef, subscriptionStore, planVersionStore, overrides);
1436
1539
  if (!hasFeature) {
1437
1540
  planDenied = true;
1438
1541
  }
@@ -1452,8 +1555,8 @@ function createAccessContext(config) {
1452
1555
  }
1453
1556
  }
1454
1557
  const checkLimitKeys = accessDef._entitlementToLimitKeys[entitlement];
1455
- if (checkLimitKeys?.length && walletStore && planStore && resolvedOrgId) {
1456
- const walletStates = await resolveAllLimitStates(entitlement, resolvedOrgId, accessDef, planStore, walletStore, planVersionStore, overrides);
1558
+ if (checkLimitKeys?.length && walletStore && subscriptionStore && resolvedOrgId) {
1559
+ const walletStates = await resolveAllLimitStates(entitlement, resolvedOrgId, accessDef, subscriptionStore, walletStore, planVersionStore, overrides);
1457
1560
  for (const ws of walletStates) {
1458
1561
  const exceeded = ws.max === 0 || ws.max !== -1 && ws.consumed >= ws.max;
1459
1562
  if (exceeded) {
@@ -1546,20 +1649,20 @@ function createAccessContext(config) {
1546
1649
  const overrides = resolvedOrgId && overrideStore ? await overrideStore.get(resolvedOrgId) : null;
1547
1650
  if (!await checkLayers1to3(entitlement, resource, resolvedOrgId, overrides))
1548
1651
  return false;
1549
- if (!walletStore || !planStore || !orgResolver)
1652
+ if (!walletStore || !subscriptionStore || !orgResolver)
1550
1653
  return true;
1551
1654
  const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
1552
1655
  if (!limitKeys?.length)
1553
1656
  return true;
1554
1657
  if (!resolvedOrgId)
1555
1658
  return false;
1556
- const orgPlan = await planStore.getPlan(resolvedOrgId);
1557
- if (!orgPlan)
1659
+ const subscription = await subscriptionStore.get(resolvedOrgId);
1660
+ if (!subscription)
1558
1661
  return false;
1559
- const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1662
+ const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
1560
1663
  if (!effectivePlanId)
1561
1664
  return false;
1562
- const limitsToConsume = await resolveAllLimitConsumptions(resolvedOrgId, entitlement, effectivePlanId, accessDef, planStore, walletStore, orgPlan, planVersionStore, overrides);
1665
+ const limitsToConsume = await resolveAllLimitConsumptions(resolvedOrgId, entitlement, effectivePlanId, accessDef, subscriptionStore, walletStore, subscription, planVersionStore, overrides);
1563
1666
  if (!limitsToConsume.length)
1564
1667
  return true;
1565
1668
  const consumed = [];
@@ -1597,7 +1700,7 @@ function createAccessContext(config) {
1597
1700
  return true;
1598
1701
  }
1599
1702
  async function unconsume(entitlement, resource, amount = 1) {
1600
- if (!walletStore || !planStore || !orgResolver)
1703
+ if (!walletStore || !subscriptionStore || !orgResolver)
1601
1704
  return;
1602
1705
  const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
1603
1706
  if (!limitKeys?.length)
@@ -1606,13 +1709,13 @@ function createAccessContext(config) {
1606
1709
  if (!orgId)
1607
1710
  return;
1608
1711
  const overrides = overrideStore ? await overrideStore.get(orgId) : null;
1609
- const orgPlan = await planStore.getPlan(orgId);
1610
- if (!orgPlan)
1712
+ const subscription = await subscriptionStore.get(orgId);
1713
+ if (!subscription)
1611
1714
  return;
1612
- const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1715
+ const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
1613
1716
  if (!effectivePlanId)
1614
1717
  return;
1615
- const limitsToUnconsume = await resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId, accessDef, planStore, walletStore, orgPlan, planVersionStore, overrides);
1718
+ const limitsToUnconsume = await resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId, accessDef, subscriptionStore, walletStore, subscription, planVersionStore, overrides);
1616
1719
  for (const lc of limitsToUnconsume) {
1617
1720
  if (lc.effectiveMax === -1)
1618
1721
  continue;
@@ -1633,7 +1736,7 @@ var DENIAL_ORDER = [
1633
1736
  function orderDenialReasons(reasons) {
1634
1737
  return [...reasons].sort((a, b) => DENIAL_ORDER.indexOf(a) - DENIAL_ORDER.indexOf(b));
1635
1738
  }
1636
- async function resolveEffectiveFeatures(orgId, entitlement, effectivePlanId, accessDef, planStore, planVersionStore, overrides) {
1739
+ async function resolveEffectiveFeatures(orgId, entitlement, effectivePlanId, accessDef, subscriptionStore, planVersionStore, overrides) {
1637
1740
  if (planVersionStore) {
1638
1741
  const tenantVersion = await planVersionStore.getTenantVersion(orgId, effectivePlanId);
1639
1742
  if (tenantVersion !== null) {
@@ -1642,7 +1745,7 @@ async function resolveEffectiveFeatures(orgId, entitlement, effectivePlanId, acc
1642
1745
  const snapshotFeatures = versionInfo.snapshot.features;
1643
1746
  if (snapshotFeatures.includes(entitlement))
1644
1747
  return true;
1645
- const addOns2 = await planStore.getAddOns?.(orgId);
1748
+ const addOns2 = await subscriptionStore.getAddOns?.(orgId);
1646
1749
  if (addOns2) {
1647
1750
  for (const addOnId of addOns2) {
1648
1751
  const addOnDef = accessDef.plans?.[addOnId];
@@ -1659,7 +1762,7 @@ async function resolveEffectiveFeatures(orgId, entitlement, effectivePlanId, acc
1659
1762
  const planDef = accessDef.plans?.[effectivePlanId];
1660
1763
  if (planDef?.features?.includes(entitlement))
1661
1764
  return true;
1662
- const addOns = await planStore.getAddOns?.(orgId);
1765
+ const addOns = await subscriptionStore.getAddOns?.(orgId);
1663
1766
  if (addOns) {
1664
1767
  for (const addOnId of addOns) {
1665
1768
  const addOnDef = accessDef.plans?.[addOnId];
@@ -1671,10 +1774,10 @@ async function resolveEffectiveFeatures(orgId, entitlement, effectivePlanId, acc
1671
1774
  return true;
1672
1775
  return false;
1673
1776
  }
1674
- async function resolveAllLimitStates(entitlement, orgId, accessDef, planStore, walletStore, planVersionStore, overrides) {
1675
- const orgPlan = await planStore.getPlan(orgId);
1676
- const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
1677
- if (!effectivePlanId || !orgPlan)
1777
+ async function resolveAllLimitStates(entitlement, orgId, accessDef, subscriptionStore, walletStore, planVersionStore, overrides) {
1778
+ const subscription = await subscriptionStore.get(orgId);
1779
+ const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
1780
+ if (!effectivePlanId || !subscription)
1678
1781
  return [];
1679
1782
  const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
1680
1783
  if (!limitKeys?.length)
@@ -1700,7 +1803,7 @@ async function resolveAllLimitStates(entitlement, orgId, accessDef, planStore, w
1700
1803
  }
1701
1804
  if (!limitDef)
1702
1805
  continue;
1703
- const effectiveMax = computeEffectiveLimit(limitDef.max, limitKey, orgId, orgPlan, accessDef, planStore, overrides);
1806
+ const effectiveMax = computeEffectiveLimit(limitDef.max, limitKey, orgId, subscription, accessDef, subscriptionStore, overrides);
1704
1807
  const resolvedMax = await effectiveMax;
1705
1808
  const hasOverage = !!limitDef.overage;
1706
1809
  if (resolvedMax === -1) {
@@ -1708,7 +1811,7 @@ async function resolveAllLimitStates(entitlement, orgId, accessDef, planStore, w
1708
1811
  continue;
1709
1812
  }
1710
1813
  const walletKey = limitKey;
1711
- const period = limitDef.per ? calculateBillingPeriod(orgPlan.startedAt, limitDef.per) : { periodStart: orgPlan.startedAt, periodEnd: new Date("9999-12-31T23:59:59Z") };
1814
+ const period = limitDef.per ? calculateBillingPeriod(subscription.startedAt, limitDef.per) : { periodStart: subscription.startedAt, periodEnd: new Date("9999-12-31T23:59:59Z") };
1712
1815
  const consumed = await walletStore.getConsumption(orgId, walletKey, period.periodStart, period.periodEnd);
1713
1816
  states.push({
1714
1817
  key: limitKey,
@@ -1724,7 +1827,7 @@ async function resolveAllLimitStates(entitlement, orgId, accessDef, planStore, w
1724
1827
  }
1725
1828
  return states;
1726
1829
  }
1727
- async function resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId, accessDef, planStore, _walletStore, orgPlan, planVersionStore, overrides) {
1830
+ async function resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId, accessDef, subscriptionStore, _walletStore, subscription, planVersionStore, overrides) {
1728
1831
  const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
1729
1832
  if (!limitKeys?.length)
1730
1833
  return [];
@@ -1749,9 +1852,9 @@ async function resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId,
1749
1852
  }
1750
1853
  if (!limitDef)
1751
1854
  continue;
1752
- const effectiveMax = await computeEffectiveLimit(limitDef.max, limitKey, orgId, orgPlan, accessDef, planStore, overrides);
1855
+ const effectiveMax = await computeEffectiveLimit(limitDef.max, limitKey, orgId, subscription, accessDef, subscriptionStore, overrides);
1753
1856
  const walletKey = limitKey;
1754
- const period = limitDef.per ? calculateBillingPeriod(orgPlan.startedAt, limitDef.per) : { periodStart: orgPlan.startedAt, periodEnd: new Date("9999-12-31T23:59:59Z") };
1857
+ const period = limitDef.per ? calculateBillingPeriod(subscription.startedAt, limitDef.per) : { periodStart: subscription.startedAt, periodEnd: new Date("9999-12-31T23:59:59Z") };
1755
1858
  consumptions.push({
1756
1859
  walletKey,
1757
1860
  periodStart: period.periodStart,
@@ -1767,9 +1870,9 @@ async function resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId,
1767
1870
  }
1768
1871
  return consumptions;
1769
1872
  }
1770
- async function computeEffectiveLimit(basePlanMax, limitKey, orgId, orgPlan, accessDef, planStore, overrides) {
1873
+ async function computeEffectiveLimit(basePlanMax, limitKey, orgId, subscription, accessDef, subscriptionStore, overrides) {
1771
1874
  let effectiveMax = basePlanMax;
1772
- const addOns = await planStore.getAddOns?.(orgId);
1875
+ const addOns = await subscriptionStore.getAddOns?.(orgId);
1773
1876
  if (addOns) {
1774
1877
  for (const addOnId of addOns) {
1775
1878
  const addOnDef = accessDef.plans?.[addOnId];
@@ -1781,7 +1884,7 @@ async function computeEffectiveLimit(basePlanMax, limitKey, orgId, orgPlan, acce
1781
1884
  }
1782
1885
  }
1783
1886
  }
1784
- const oldOverride = orgPlan.overrides[limitKey];
1887
+ const oldOverride = subscription.overrides[limitKey];
1785
1888
  if (oldOverride) {
1786
1889
  effectiveMax = Math.max(effectiveMax, oldOverride.max);
1787
1890
  }
@@ -2091,7 +2194,6 @@ function generateAuthDDL(dialect) {
2091
2194
  email ${t.text()} NOT NULL UNIQUE,
2092
2195
  password_hash ${t.text()},
2093
2196
  role ${t.text()} NOT NULL DEFAULT 'user',
2094
- plan ${t.text()},
2095
2197
  email_verified ${t.boolean(false)},
2096
2198
  created_at ${t.timestamp()},
2097
2199
  updated_at ${t.timestamp()}
@@ -2344,7 +2446,7 @@ function extractPlanId(obj) {
2344
2446
  return null;
2345
2447
  }
2346
2448
  function createWebhookHandler(config) {
2347
- const { planStore, emitter, defaultPlan, webhookSecret } = config;
2449
+ const { subscriptionStore, emitter, defaultPlan, webhookSecret } = config;
2348
2450
  return async (request) => {
2349
2451
  const signature = request.headers.get("stripe-signature");
2350
2452
  if (signature !== webhookSecret) {
@@ -2370,7 +2472,7 @@ function createWebhookHandler(config) {
2370
2472
  const tenantId = extractTenantId(obj);
2371
2473
  const planId = extractPlanId(obj);
2372
2474
  if (tenantId && planId) {
2373
- await planStore.assignPlan(tenantId, planId);
2475
+ await subscriptionStore.assign(tenantId, planId);
2374
2476
  emitter.emit("subscription:created", { tenantId, planId });
2375
2477
  }
2376
2478
  break;
@@ -2379,7 +2481,7 @@ function createWebhookHandler(config) {
2379
2481
  const tenantId = extractTenantId(obj);
2380
2482
  const planId = extractPlanId(obj);
2381
2483
  if (tenantId && planId) {
2382
- await planStore.assignPlan(tenantId, planId);
2484
+ await subscriptionStore.assign(tenantId, planId);
2383
2485
  }
2384
2486
  break;
2385
2487
  }
@@ -2387,7 +2489,7 @@ function createWebhookHandler(config) {
2387
2489
  const tenantId = extractTenantId(obj);
2388
2490
  const planId = extractPlanId(obj);
2389
2491
  if (tenantId) {
2390
- await planStore.assignPlan(tenantId, defaultPlan);
2492
+ await subscriptionStore.assign(tenantId, defaultPlan);
2391
2493
  emitter.emit("subscription:canceled", {
2392
2494
  tenantId,
2393
2495
  planId: planId ?? defaultPlan
@@ -2414,10 +2516,10 @@ function createWebhookHandler(config) {
2414
2516
  const meta = obj.metadata;
2415
2517
  const isAddOn = meta?.isAddOn === "true";
2416
2518
  if (tenantId && planId) {
2417
- if (isAddOn && planStore.attachAddOn) {
2418
- await planStore.attachAddOn(tenantId, planId);
2519
+ if (isAddOn && subscriptionStore.attachAddOn) {
2520
+ await subscriptionStore.attachAddOn(tenantId, planId);
2419
2521
  } else {
2420
- await planStore.assignPlan(tenantId, planId);
2522
+ await subscriptionStore.assign(tenantId, planId);
2421
2523
  }
2422
2524
  }
2423
2525
  break;
@@ -2664,100 +2766,8 @@ class DbOAuthAccountStore {
2664
2766
  }
2665
2767
  dispose() {}
2666
2768
  }
2667
- // src/auth/db-plan-store.ts
2668
- import { sql as sql5 } from "@vertz/db/sql";
2669
- class DbPlanStore {
2670
- db;
2671
- constructor(db) {
2672
- this.db = db;
2673
- }
2674
- async assignPlan(orgId, planId, startedAt = new Date, expiresAt = null) {
2675
- const id = crypto.randomUUID();
2676
- const startedAtIso = startedAt.toISOString();
2677
- const expiresAtIso = expiresAt ? expiresAt.toISOString() : null;
2678
- const upsertResult = await this.db.query(sql5`INSERT INTO auth_plans (id, tenant_id, plan_id, started_at, expires_at)
2679
- VALUES (${id}, ${orgId}, ${planId}, ${startedAtIso}, ${expiresAtIso})
2680
- ON CONFLICT(tenant_id) DO UPDATE SET plan_id = ${planId}, started_at = ${startedAtIso}, expires_at = ${expiresAtIso}`);
2681
- assertWrite(upsertResult, "assignPlan/upsert");
2682
- const overrideResult = await this.db.query(sql5`DELETE FROM auth_overrides WHERE tenant_id = ${orgId}`);
2683
- assertWrite(overrideResult, "assignPlan/clearOverrides");
2684
- }
2685
- async getPlan(orgId) {
2686
- const result = await this.db.query(sql5`SELECT tenant_id, plan_id, started_at, expires_at FROM auth_plans WHERE tenant_id = ${orgId}`);
2687
- if (!result.ok)
2688
- return null;
2689
- const row = result.data.rows[0];
2690
- if (!row)
2691
- return null;
2692
- const overrides = await this.loadOverrides(orgId);
2693
- return {
2694
- orgId: row.tenant_id,
2695
- planId: row.plan_id,
2696
- startedAt: new Date(row.started_at),
2697
- expiresAt: row.expires_at ? new Date(row.expires_at) : null,
2698
- overrides
2699
- };
2700
- }
2701
- async updateOverrides(orgId, overrides) {
2702
- const planResult = await this.db.query(sql5`SELECT tenant_id FROM auth_plans WHERE tenant_id = ${orgId}`);
2703
- if (!planResult.ok || planResult.data.rows.length === 0)
2704
- return;
2705
- const existing = await this.loadOverrides(orgId);
2706
- const merged = { ...existing, ...overrides };
2707
- const overridesJson = JSON.stringify(merged);
2708
- const now = new Date().toISOString();
2709
- const overrideResult = await this.db.query(sql5`SELECT tenant_id FROM auth_overrides WHERE tenant_id = ${orgId}`);
2710
- if (overrideResult.ok && overrideResult.data.rows.length > 0) {
2711
- const updateResult = await this.db.query(sql5`UPDATE auth_overrides SET overrides = ${overridesJson}, updated_at = ${now} WHERE tenant_id = ${orgId}`);
2712
- assertWrite(updateResult, "updateOverrides/update");
2713
- } else {
2714
- const id = crypto.randomUUID();
2715
- const insertResult = await this.db.query(sql5`INSERT INTO auth_overrides (id, tenant_id, overrides, updated_at)
2716
- VALUES (${id}, ${orgId}, ${overridesJson}, ${now})`);
2717
- assertWrite(insertResult, "updateOverrides/insert");
2718
- }
2719
- }
2720
- async removePlan(orgId) {
2721
- const planResult = await this.db.query(sql5`DELETE FROM auth_plans WHERE tenant_id = ${orgId}`);
2722
- assertWrite(planResult, "removePlan/plans");
2723
- const overrideResult = await this.db.query(sql5`DELETE FROM auth_overrides WHERE tenant_id = ${orgId}`);
2724
- assertWrite(overrideResult, "removePlan/overrides");
2725
- }
2726
- async attachAddOn(orgId, addOnId) {
2727
- const id = crypto.randomUUID();
2728
- const now = new Date().toISOString();
2729
- const result = await this.db.query(sql5`INSERT INTO auth_plan_addons (id, tenant_id, addon_id, quantity, created_at)
2730
- VALUES (${id}, ${orgId}, ${addOnId}, ${1}, ${now})
2731
- ON CONFLICT(tenant_id, addon_id) DO NOTHING`);
2732
- assertWrite(result, "attachAddOn");
2733
- }
2734
- async detachAddOn(orgId, addOnId) {
2735
- const result = await this.db.query(sql5`DELETE FROM auth_plan_addons WHERE tenant_id = ${orgId} AND addon_id = ${addOnId}`);
2736
- assertWrite(result, "detachAddOn");
2737
- }
2738
- async getAddOns(orgId) {
2739
- const result = await this.db.query(sql5`SELECT addon_id FROM auth_plan_addons WHERE tenant_id = ${orgId}`);
2740
- if (!result.ok)
2741
- return [];
2742
- return result.data.rows.map((r) => r.addon_id);
2743
- }
2744
- dispose() {}
2745
- async loadOverrides(orgId) {
2746
- const result = await this.db.query(sql5`SELECT overrides FROM auth_overrides WHERE tenant_id = ${orgId}`);
2747
- if (!result.ok || result.data.rows.length === 0)
2748
- return {};
2749
- try {
2750
- const overridesStr = result.data.rows[0]?.overrides;
2751
- if (!overridesStr)
2752
- return {};
2753
- return JSON.parse(overridesStr);
2754
- } catch {
2755
- return {};
2756
- }
2757
- }
2758
- }
2759
2769
  // src/auth/db-role-assignment-store.ts
2760
- import { sql as sql6 } from "@vertz/db/sql";
2770
+ import { sql as sql5 } from "@vertz/db/sql";
2761
2771
  class DbRoleAssignmentStore {
2762
2772
  db;
2763
2773
  constructor(db) {
@@ -2766,23 +2776,23 @@ class DbRoleAssignmentStore {
2766
2776
  async assign(userId, resourceType, resourceId, role) {
2767
2777
  const id = crypto.randomUUID();
2768
2778
  const now = new Date().toISOString();
2769
- const result = await this.db.query(sql6`INSERT INTO auth_role_assignments (id, user_id, resource_type, resource_id, role, created_at)
2779
+ const result = await this.db.query(sql5`INSERT INTO auth_role_assignments (id, user_id, resource_type, resource_id, role, created_at)
2770
2780
  VALUES (${id}, ${userId}, ${resourceType}, ${resourceId}, ${role}, ${now})
2771
2781
  ON CONFLICT DO NOTHING`);
2772
2782
  assertWrite(result, "assign");
2773
2783
  }
2774
2784
  async revoke(userId, resourceType, resourceId, role) {
2775
- const result = await this.db.query(sql6`DELETE FROM auth_role_assignments WHERE user_id = ${userId} AND resource_type = ${resourceType} AND resource_id = ${resourceId} AND role = ${role}`);
2785
+ const result = await this.db.query(sql5`DELETE FROM auth_role_assignments WHERE user_id = ${userId} AND resource_type = ${resourceType} AND resource_id = ${resourceId} AND role = ${role}`);
2776
2786
  assertWrite(result, "revoke");
2777
2787
  }
2778
2788
  async getRoles(userId, resourceType, resourceId) {
2779
- const result = await this.db.query(sql6`SELECT role FROM auth_role_assignments WHERE user_id = ${userId} AND resource_type = ${resourceType} AND resource_id = ${resourceId}`);
2789
+ const result = await this.db.query(sql5`SELECT role FROM auth_role_assignments WHERE user_id = ${userId} AND resource_type = ${resourceType} AND resource_id = ${resourceId}`);
2780
2790
  if (!result.ok)
2781
2791
  return [];
2782
2792
  return result.data.rows.map((r) => r.role);
2783
2793
  }
2784
2794
  async getRolesForUser(userId) {
2785
- const result = await this.db.query(sql6`SELECT user_id, resource_type, resource_id, role FROM auth_role_assignments WHERE user_id = ${userId}`);
2795
+ const result = await this.db.query(sql5`SELECT user_id, resource_type, resource_id, role FROM auth_role_assignments WHERE user_id = ${userId}`);
2786
2796
  if (!result.ok)
2787
2797
  return [];
2788
2798
  return result.data.rows.map((r) => ({
@@ -2826,7 +2836,7 @@ class DbRoleAssignmentStore {
2826
2836
  dispose() {}
2827
2837
  }
2828
2838
  // src/auth/db-session-store.ts
2829
- import { sql as sql7 } from "@vertz/db/sql";
2839
+ import { sql as sql6 } from "@vertz/db/sql";
2830
2840
  class DbSessionStore {
2831
2841
  db;
2832
2842
  constructor(db) {
@@ -2835,7 +2845,7 @@ class DbSessionStore {
2835
2845
  async createSessionWithId(id, data) {
2836
2846
  const now = new Date;
2837
2847
  const tokensJson = data.currentTokens ? JSON.stringify(data.currentTokens) : null;
2838
- const result = await this.db.query(sql7`INSERT INTO auth_sessions (id, user_id, refresh_token_hash, previous_refresh_hash, current_tokens, ip_address, user_agent, created_at, last_active_at, expires_at, revoked_at)
2848
+ const result = await this.db.query(sql6`INSERT INTO auth_sessions (id, user_id, refresh_token_hash, previous_refresh_hash, current_tokens, ip_address, user_agent, created_at, last_active_at, expires_at, revoked_at)
2839
2849
  VALUES (${id}, ${data.userId}, ${data.refreshTokenHash}, ${null}, ${tokensJson}, ${data.ipAddress}, ${data.userAgent}, ${now.toISOString()}, ${now.toISOString()}, ${data.expiresAt.toISOString()}, ${null})`);
2840
2850
  assertWrite(result, "createSessionWithId");
2841
2851
  return {
@@ -2853,45 +2863,73 @@ class DbSessionStore {
2853
2863
  }
2854
2864
  async findByRefreshHash(hash) {
2855
2865
  const nowStr = new Date().toISOString();
2856
- const result = await this.db.query(sql7`SELECT * FROM auth_sessions WHERE refresh_token_hash = ${hash} AND revoked_at IS NULL AND expires_at > ${nowStr} LIMIT 1`);
2866
+ const result = await this.db.auth_sessions.get({
2867
+ where: {
2868
+ refreshTokenHash: hash,
2869
+ revokedAt: null,
2870
+ expiresAt: { gt: nowStr }
2871
+ }
2872
+ });
2857
2873
  if (!result.ok)
2858
2874
  return null;
2859
- const row = result.data.rows[0];
2875
+ const row = result.data;
2876
+ if (!row)
2877
+ return null;
2878
+ return this.recordToSession(row);
2879
+ }
2880
+ async findActiveSessionById(id) {
2881
+ const nowStr = new Date().toISOString();
2882
+ const result = await this.db.auth_sessions.get({
2883
+ where: {
2884
+ id,
2885
+ revokedAt: null,
2886
+ expiresAt: { gt: nowStr }
2887
+ }
2888
+ });
2889
+ if (!result.ok)
2890
+ return null;
2891
+ const row = result.data;
2860
2892
  if (!row)
2861
2893
  return null;
2862
- return this.rowToSession(row);
2894
+ return this.recordToSession(row);
2863
2895
  }
2864
2896
  async findByPreviousRefreshHash(hash) {
2865
2897
  const nowStr = new Date().toISOString();
2866
- const result = await this.db.query(sql7`SELECT * FROM auth_sessions WHERE previous_refresh_hash = ${hash} AND revoked_at IS NULL AND expires_at > ${nowStr} LIMIT 1`);
2898
+ const result = await this.db.auth_sessions.get({
2899
+ where: {
2900
+ previousRefreshHash: hash,
2901
+ revokedAt: null,
2902
+ expiresAt: { gt: nowStr }
2903
+ }
2904
+ });
2867
2905
  if (!result.ok)
2868
2906
  return null;
2869
- const row = result.data.rows[0];
2907
+ const row = result.data;
2870
2908
  if (!row)
2871
2909
  return null;
2872
- return this.rowToSession(row);
2910
+ return this.recordToSession(row);
2873
2911
  }
2874
2912
  async revokeSession(id) {
2875
2913
  const nowStr = new Date().toISOString();
2876
- const result = await this.db.query(sql7`UPDATE auth_sessions SET revoked_at = ${nowStr}, current_tokens = ${null} WHERE id = ${id}`);
2914
+ const result = await this.db.query(sql6`UPDATE auth_sessions SET revoked_at = ${nowStr}, current_tokens = ${null} WHERE id = ${id}`);
2877
2915
  assertWrite(result, "revokeSession");
2878
2916
  }
2879
2917
  async listActiveSessions(userId) {
2880
2918
  const nowStr = new Date().toISOString();
2881
- const result = await this.db.query(sql7`SELECT * FROM auth_sessions WHERE user_id = ${userId} AND revoked_at IS NULL AND expires_at > ${nowStr}`);
2919
+ const result = await this.db.query(sql6`SELECT * FROM auth_sessions WHERE user_id = ${userId} AND revoked_at IS NULL AND expires_at > ${nowStr}`);
2882
2920
  if (!result.ok)
2883
2921
  return [];
2884
2922
  return result.data.rows.map((row) => this.rowToSession(row));
2885
2923
  }
2886
2924
  async countActiveSessions(userId) {
2887
2925
  const nowStr = new Date().toISOString();
2888
- const result = await this.db.query(sql7`SELECT COUNT(*) as cnt FROM auth_sessions WHERE user_id = ${userId} AND revoked_at IS NULL AND expires_at > ${nowStr}`);
2926
+ const result = await this.db.query(sql6`SELECT COUNT(*) as cnt FROM auth_sessions WHERE user_id = ${userId} AND revoked_at IS NULL AND expires_at > ${nowStr}`);
2889
2927
  if (!result.ok)
2890
2928
  return 0;
2891
2929
  return result.data.rows[0]?.cnt ?? 0;
2892
2930
  }
2893
2931
  async getCurrentTokens(sessionId) {
2894
- const result = await this.db.query(sql7`SELECT current_tokens FROM auth_sessions WHERE id = ${sessionId} LIMIT 1`);
2932
+ const result = await this.db.query(sql6`SELECT current_tokens FROM auth_sessions WHERE id = ${sessionId} LIMIT 1`);
2895
2933
  if (!result.ok)
2896
2934
  return null;
2897
2935
  const row = result.data.rows[0];
@@ -2905,7 +2943,7 @@ class DbSessionStore {
2905
2943
  }
2906
2944
  async updateSession(id, data) {
2907
2945
  const tokensJson = data.currentTokens ? JSON.stringify(data.currentTokens) : null;
2908
- const result = await this.db.query(sql7`UPDATE auth_sessions SET refresh_token_hash = ${data.refreshTokenHash}, previous_refresh_hash = ${data.previousRefreshHash}, last_active_at = ${data.lastActiveAt.toISOString()}, current_tokens = ${tokensJson} WHERE id = ${id}`);
2946
+ const result = await this.db.query(sql6`UPDATE auth_sessions SET refresh_token_hash = ${data.refreshTokenHash}, previous_refresh_hash = ${data.previousRefreshHash}, last_active_at = ${data.lastActiveAt.toISOString()}, current_tokens = ${tokensJson} WHERE id = ${id}`);
2909
2947
  assertWrite(result, "updateSession");
2910
2948
  }
2911
2949
  dispose() {}
@@ -2923,6 +2961,118 @@ class DbSessionStore {
2923
2961
  revokedAt: row.revoked_at ? new Date(row.revoked_at) : null
2924
2962
  };
2925
2963
  }
2964
+ recordToSession(rec) {
2965
+ return {
2966
+ id: rec.id,
2967
+ userId: rec.userId,
2968
+ refreshTokenHash: rec.refreshTokenHash,
2969
+ previousRefreshHash: rec.previousRefreshHash,
2970
+ ipAddress: rec.ipAddress,
2971
+ userAgent: rec.userAgent,
2972
+ createdAt: new Date(rec.createdAt),
2973
+ lastActiveAt: new Date(rec.lastActiveAt),
2974
+ expiresAt: new Date(rec.expiresAt),
2975
+ revokedAt: rec.revokedAt ? new Date(rec.revokedAt) : null
2976
+ };
2977
+ }
2978
+ }
2979
+ // src/auth/db-subscription-store.ts
2980
+ import { sql as sql7 } from "@vertz/db/sql";
2981
+ class DbSubscriptionStore {
2982
+ db;
2983
+ constructor(db) {
2984
+ this.db = db;
2985
+ }
2986
+ async assign(tenantId, planId, startedAt = new Date, expiresAt = null) {
2987
+ await this.db.transaction(async (tx) => {
2988
+ const id = crypto.randomUUID();
2989
+ const startedAtIso = startedAt.toISOString();
2990
+ const expiresAtIso = expiresAt ? expiresAt.toISOString() : null;
2991
+ const upsertResult = await tx.query(sql7`INSERT INTO auth_plans (id, tenant_id, plan_id, started_at, expires_at)
2992
+ VALUES (${id}, ${tenantId}, ${planId}, ${startedAtIso}, ${expiresAtIso})
2993
+ ON CONFLICT(tenant_id) DO UPDATE SET plan_id = ${planId}, started_at = ${startedAtIso}, expires_at = ${expiresAtIso}`);
2994
+ assertWrite(upsertResult, "assign/upsert");
2995
+ const overrideResult = await tx.query(sql7`DELETE FROM auth_overrides WHERE tenant_id = ${tenantId}`);
2996
+ assertWrite(overrideResult, "assign/clearOverrides");
2997
+ });
2998
+ }
2999
+ async get(tenantId) {
3000
+ const result = await this.db.query(sql7`SELECT tenant_id, plan_id, started_at, expires_at FROM auth_plans WHERE tenant_id = ${tenantId}`);
3001
+ if (!result.ok)
3002
+ return null;
3003
+ const row = result.data.rows[0];
3004
+ if (!row)
3005
+ return null;
3006
+ const overrides = await this.loadOverrides(tenantId);
3007
+ return {
3008
+ tenantId: row.tenant_id,
3009
+ planId: row.plan_id,
3010
+ startedAt: new Date(row.started_at),
3011
+ expiresAt: row.expires_at ? new Date(row.expires_at) : null,
3012
+ overrides
3013
+ };
3014
+ }
3015
+ async updateOverrides(tenantId, overrides) {
3016
+ const planResult = await this.db.query(sql7`SELECT tenant_id FROM auth_plans WHERE tenant_id = ${tenantId}`);
3017
+ if (!planResult.ok || planResult.data.rows.length === 0)
3018
+ return;
3019
+ await this.db.transaction(async (tx) => {
3020
+ const existing = await this.loadOverrides(tenantId);
3021
+ const merged = { ...existing, ...overrides };
3022
+ const overridesJson = JSON.stringify(merged);
3023
+ const now = new Date().toISOString();
3024
+ const overrideResult = await tx.query(sql7`SELECT tenant_id FROM auth_overrides WHERE tenant_id = ${tenantId}`);
3025
+ if (overrideResult.ok && overrideResult.data.rows.length > 0) {
3026
+ const updateResult = await tx.query(sql7`UPDATE auth_overrides SET overrides = ${overridesJson}, updated_at = ${now} WHERE tenant_id = ${tenantId}`);
3027
+ assertWrite(updateResult, "updateOverrides/update");
3028
+ } else {
3029
+ const id = crypto.randomUUID();
3030
+ const insertResult = await tx.query(sql7`INSERT INTO auth_overrides (id, tenant_id, overrides, updated_at)
3031
+ VALUES (${id}, ${tenantId}, ${overridesJson}, ${now})`);
3032
+ assertWrite(insertResult, "updateOverrides/insert");
3033
+ }
3034
+ });
3035
+ }
3036
+ async remove(tenantId) {
3037
+ await this.db.transaction(async (tx) => {
3038
+ const planResult = await tx.query(sql7`DELETE FROM auth_plans WHERE tenant_id = ${tenantId}`);
3039
+ assertWrite(planResult, "remove/plans");
3040
+ const overrideResult = await tx.query(sql7`DELETE FROM auth_overrides WHERE tenant_id = ${tenantId}`);
3041
+ assertWrite(overrideResult, "remove/overrides");
3042
+ });
3043
+ }
3044
+ async attachAddOn(tenantId, addOnId) {
3045
+ const id = crypto.randomUUID();
3046
+ const now = new Date().toISOString();
3047
+ const result = await this.db.query(sql7`INSERT INTO auth_plan_addons (id, tenant_id, addon_id, quantity, created_at)
3048
+ VALUES (${id}, ${tenantId}, ${addOnId}, ${1}, ${now})
3049
+ ON CONFLICT(tenant_id, addon_id) DO NOTHING`);
3050
+ assertWrite(result, "attachAddOn");
3051
+ }
3052
+ async detachAddOn(tenantId, addOnId) {
3053
+ const result = await this.db.query(sql7`DELETE FROM auth_plan_addons WHERE tenant_id = ${tenantId} AND addon_id = ${addOnId}`);
3054
+ assertWrite(result, "detachAddOn");
3055
+ }
3056
+ async getAddOns(tenantId) {
3057
+ const result = await this.db.query(sql7`SELECT addon_id FROM auth_plan_addons WHERE tenant_id = ${tenantId}`);
3058
+ if (!result.ok)
3059
+ return [];
3060
+ return result.data.rows.map((r) => r.addon_id);
3061
+ }
3062
+ dispose() {}
3063
+ async loadOverrides(tenantId) {
3064
+ const result = await this.db.query(sql7`SELECT overrides FROM auth_overrides WHERE tenant_id = ${tenantId}`);
3065
+ if (!result.ok || result.data.rows.length === 0)
3066
+ return {};
3067
+ try {
3068
+ const overridesStr = result.data.rows[0]?.overrides;
3069
+ if (!overridesStr)
3070
+ return {};
3071
+ return JSON.parse(overridesStr);
3072
+ } catch {
3073
+ return {};
3074
+ }
3075
+ }
2926
3076
  }
2927
3077
  // src/auth/db-user-store.ts
2928
3078
  import { sql as sql8 } from "@vertz/db/sql";
@@ -2932,8 +3082,8 @@ class DbUserStore {
2932
3082
  this.db = db;
2933
3083
  }
2934
3084
  async createUser(user, passwordHash) {
2935
- const result = await this.db.query(sql8`INSERT INTO auth_users (id, email, password_hash, role, plan, email_verified, created_at, updated_at)
2936
- VALUES (${user.id}, ${user.email.toLowerCase()}, ${passwordHash}, ${user.role}, ${user.plan ?? null}, ${boolVal(this.db, user.emailVerified ?? false)}, ${user.createdAt.toISOString()}, ${user.updatedAt.toISOString()})`);
3085
+ const result = await this.db.query(sql8`INSERT INTO auth_users (id, email, password_hash, role, email_verified, created_at, updated_at)
3086
+ VALUES (${user.id}, ${user.email.toLowerCase()}, ${passwordHash}, ${user.role}, ${boolVal(this.db, user.emailVerified ?? false)}, ${user.createdAt.toISOString()}, ${user.updatedAt.toISOString()})`);
2937
3087
  assertWrite(result, "createUser");
2938
3088
  }
2939
3089
  async findByEmail(email) {
@@ -2966,12 +3116,15 @@ class DbUserStore {
2966
3116
  const result = await this.db.query(sql8`UPDATE auth_users SET email_verified = ${val}, updated_at = ${new Date().toISOString()} WHERE id = ${userId}`);
2967
3117
  assertWrite(result, "updateEmailVerified");
2968
3118
  }
3119
+ async deleteUser(id) {
3120
+ const result = await this.db.query(sql8`DELETE FROM auth_users WHERE id = ${id}`);
3121
+ assertWrite(result, "deleteUser");
3122
+ }
2969
3123
  rowToUser(row) {
2970
3124
  return {
2971
3125
  id: row.id,
2972
3126
  email: row.email,
2973
3127
  role: row.role,
2974
- plan: row.plan ?? undefined,
2975
3128
  emailVerified: row.email_verified === 1 || row.email_verified === true,
2976
3129
  createdAt: new Date(row.created_at),
2977
3130
  updatedAt: new Date(row.updated_at)
@@ -3295,23 +3448,23 @@ async function computeEntityAccess(entitlements, entity, accessContext) {
3295
3448
  // src/auth/flag-store.ts
3296
3449
  class InMemoryFlagStore {
3297
3450
  flags = new Map;
3298
- setFlag(orgId, flag, enabled) {
3299
- let orgFlags = this.flags.get(orgId);
3300
- if (!orgFlags) {
3301
- orgFlags = new Map;
3302
- this.flags.set(orgId, orgFlags);
3451
+ setFlag(tenantId, flag, enabled) {
3452
+ let tenantFlags = this.flags.get(tenantId);
3453
+ if (!tenantFlags) {
3454
+ tenantFlags = new Map;
3455
+ this.flags.set(tenantId, tenantFlags);
3303
3456
  }
3304
- orgFlags.set(flag, enabled);
3457
+ tenantFlags.set(flag, enabled);
3305
3458
  }
3306
- getFlag(orgId, flag) {
3307
- return this.flags.get(orgId)?.get(flag) ?? false;
3459
+ getFlag(tenantId, flag) {
3460
+ return this.flags.get(tenantId)?.get(flag) ?? false;
3308
3461
  }
3309
- getFlags(orgId) {
3310
- const orgFlags = this.flags.get(orgId);
3311
- if (!orgFlags)
3462
+ getFlags(tenantId) {
3463
+ const tenantFlags = this.flags.get(tenantId);
3464
+ if (!tenantFlags)
3312
3465
  return {};
3313
3466
  const result = {};
3314
- for (const [key, value] of orgFlags) {
3467
+ for (const [key, value] of tenantFlags) {
3315
3468
  result[key] = value;
3316
3469
  }
3317
3470
  return result;
@@ -3476,7 +3629,7 @@ function resolveGraceEnd(planDef, now) {
3476
3629
  return new Date(now.getTime() + GRACE_DURATION_MS["1m"]);
3477
3630
  }
3478
3631
  function createPlanManager(config) {
3479
- const { plans, versionStore, grandfatheringStore, planStore } = config;
3632
+ const { plans, versionStore, grandfatheringStore, subscriptionStore } = config;
3480
3633
  const clock = config.clock ?? (() => new Date);
3481
3634
  const handlers = [];
3482
3635
  function emit(event) {
@@ -3523,8 +3676,8 @@ function createPlanManager(config) {
3523
3676
  }
3524
3677
  }
3525
3678
  async function listTenantsOnPlan(planId) {
3526
- if (planStore.listByPlan) {
3527
- return planStore.listByPlan(planId);
3679
+ if (subscriptionStore.listByPlan) {
3680
+ return subscriptionStore.listByPlan(planId);
3528
3681
  }
3529
3682
  return [];
3530
3683
  }
@@ -3580,10 +3733,10 @@ function createPlanManager(config) {
3580
3733
  }
3581
3734
  }
3582
3735
  async function resolve(tenantId) {
3583
- const orgPlan = await planStore.getPlan(tenantId);
3584
- if (!orgPlan)
3736
+ const subscription = await subscriptionStore.get(tenantId);
3737
+ if (!subscription)
3585
3738
  return null;
3586
- const planId = orgPlan.planId;
3739
+ const planId = subscription.planId;
3587
3740
  const currentVersion = await versionStore.getCurrentVersion(planId);
3588
3741
  if (currentVersion === null)
3589
3742
  return null;
@@ -3761,8 +3914,7 @@ function discord(config) {
3761
3914
  providerId: data.id,
3762
3915
  email: data.email ?? "",
3763
3916
  emailVerified: data.verified ?? false,
3764
- name: data.global_name ?? data.username,
3765
- avatarUrl: data.avatar ? `https://cdn.discordapp.com/avatars/${data.id}/${data.avatar}.png` : undefined
3917
+ raw: data
3766
3918
  };
3767
3919
  }
3768
3920
  };
@@ -3804,6 +3956,9 @@ function github(config) {
3804
3956
  })
3805
3957
  });
3806
3958
  const data = await response.json();
3959
+ if (data.error) {
3960
+ throw new Error(`GitHub token exchange failed: ${data.error} — ${data.error_description}`);
3961
+ }
3807
3962
  return {
3808
3963
  accessToken: data.access_token
3809
3964
  };
@@ -3832,8 +3987,7 @@ function github(config) {
3832
3987
  providerId: String(userData.id),
3833
3988
  email: email?.email ?? "",
3834
3989
  emailVerified: email?.verified ?? false,
3835
- name: userData.name,
3836
- avatarUrl: userData.avatar_url
3990
+ raw: userData
3837
3991
  };
3838
3992
  }
3839
3993
  };
@@ -3905,8 +4059,7 @@ function google(config) {
3905
4059
  providerId: payload.sub,
3906
4060
  email: payload.email,
3907
4061
  emailVerified: payload.email_verified,
3908
- name: payload.name,
3909
- avatarUrl: payload.picture
4062
+ raw: payload
3910
4063
  };
3911
4064
  }
3912
4065
  };
@@ -3997,13 +4150,13 @@ var rules = {
3997
4150
  // src/auth/wallet-store.ts
3998
4151
  class InMemoryWalletStore {
3999
4152
  entries = new Map;
4000
- key(orgId, entitlement, periodStart) {
4001
- return `${orgId}:${entitlement}:${periodStart.getTime()}`;
4153
+ key(tenantId, entitlement, periodStart) {
4154
+ return `${tenantId}:${entitlement}:${periodStart.getTime()}`;
4002
4155
  }
4003
- async consume(orgId, entitlement, periodStart, periodEnd, limit, amount = 1) {
4004
- const k = this.key(orgId, entitlement, periodStart);
4156
+ async consume(tenantId, entitlement, periodStart, periodEnd, limit, amount = 1) {
4157
+ const k = this.key(tenantId, entitlement, periodStart);
4005
4158
  if (!this.entries.has(k)) {
4006
- this.entries.set(k, { orgId, entitlement, periodStart, periodEnd, consumed: 0 });
4159
+ this.entries.set(k, { tenantId, entitlement, periodStart, periodEnd, consumed: 0 });
4007
4160
  }
4008
4161
  const entry = this.entries.get(k);
4009
4162
  if (entry.consumed + amount > limit) {
@@ -4022,15 +4175,15 @@ class InMemoryWalletStore {
4022
4175
  remaining: limit - entry.consumed
4023
4176
  };
4024
4177
  }
4025
- async unconsume(orgId, entitlement, periodStart, _periodEnd, amount = 1) {
4026
- const k = this.key(orgId, entitlement, periodStart);
4178
+ async unconsume(tenantId, entitlement, periodStart, _periodEnd, amount = 1) {
4179
+ const k = this.key(tenantId, entitlement, periodStart);
4027
4180
  const entry = this.entries.get(k);
4028
4181
  if (!entry)
4029
4182
  return;
4030
4183
  entry.consumed = Math.max(0, entry.consumed - amount);
4031
4184
  }
4032
- async getConsumption(orgId, entitlement, periodStart, _periodEnd) {
4033
- const k = this.key(orgId, entitlement, periodStart);
4185
+ async getConsumption(tenantId, entitlement, periodStart, _periodEnd) {
4186
+ const k = this.key(tenantId, entitlement, periodStart);
4034
4187
  const entry = this.entries.get(k);
4035
4188
  return entry?.consumed ?? 0;
4036
4189
  }
@@ -4069,7 +4222,12 @@ function createAuth(config) {
4069
4222
  if (session.strategy !== "jwt") {
4070
4223
  throw new Error(`Session strategy "${session.strategy}" is not yet supported. Use "jwt".`);
4071
4224
  }
4072
- const cookieConfig = { ...DEFAULT_COOKIE_CONFIG, ...session.cookie };
4225
+ const ttlSeconds = Math.floor(parseDuration(session.ttl) / 1000);
4226
+ const cookieConfig = {
4227
+ ...DEFAULT_COOKIE_CONFIG,
4228
+ maxAge: ttlSeconds,
4229
+ ...session.cookie
4230
+ };
4073
4231
  const refreshName = session.refreshName ?? "vertz.ref";
4074
4232
  const refreshTtlMs = parseDuration(session.refreshTtl ?? "7d");
4075
4233
  const refreshMaxAge = Math.floor(refreshTtlMs / 1000);
@@ -4082,7 +4240,7 @@ function createAuth(config) {
4082
4240
  if (!isProduction && cookieConfig.secure === false) {
4083
4241
  console.warn("Cookie 'secure' flag is disabled. This is allowed in development but must be enabled in production.");
4084
4242
  }
4085
- const ttlMs = parseDuration(session.ttl);
4243
+ const ttlMs = ttlSeconds * 1000;
4086
4244
  const DUMMY_HASH = "$2a$12$000000000000000000000uGWDREoC/y2KhZ5l2QkI4j0LpDjWcaq";
4087
4245
  const sessionStore = config.sessionStore ?? new InMemorySessionStore;
4088
4246
  const userStore = config.userStore ?? new InMemoryUserStore;
@@ -4112,6 +4270,9 @@ function createAuth(config) {
4112
4270
  const passwordResetTtlMs = parseDuration(passwordResetConfig?.tokenTtl ?? "1h");
4113
4271
  const revokeSessionsOnReset = passwordResetConfig?.revokeSessionsOnReset ?? true;
4114
4272
  const passwordResetStore = config.passwordResetStore ?? (passwordResetEnabled ? new InMemoryPasswordResetStore : undefined);
4273
+ const tenantConfig = config.tenant;
4274
+ const onUserCreated = config.onUserCreated;
4275
+ const entityProxy = config._entityProxy ?? {};
4115
4276
  const rateLimitStore = config.rateLimitStore ?? new InMemoryRateLimitStore;
4116
4277
  const signInWindowMs = parseDuration(emailPassword?.rateLimit?.window || "15m");
4117
4278
  const signUpWindowMs = parseDuration("1h");
@@ -4121,6 +4282,7 @@ function createAuth(config) {
4121
4282
  const expiresAt = new Date(Date.now() + ttlMs);
4122
4283
  const userClaims = claims ? claims(user) : {};
4123
4284
  const fvaClaim = options?.fva !== undefined ? { fva: options.fva } : {};
4285
+ const tenantClaim = options?.tenantId ? { tenantId: options.tenantId } : {};
4124
4286
  let aclClaim = {};
4125
4287
  if (config.access) {
4126
4288
  const accessSet = await computeAccessSet({
@@ -4129,7 +4291,8 @@ function createAuth(config) {
4129
4291
  roleStore: config.access.roleStore,
4130
4292
  closureStore: config.access.closureStore,
4131
4293
  flagStore: config.access.flagStore,
4132
- plan: user.plan ?? null
4294
+ subscriptionStore: config.access?.subscriptionStore,
4295
+ tenantId: null
4133
4296
  });
4134
4297
  const encoded = encodeAccessSet(accessSet);
4135
4298
  const canonicalJson = JSON.stringify(encoded);
@@ -4149,6 +4312,7 @@ function createAuth(config) {
4149
4312
  const jwt = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, () => ({
4150
4313
  ...userClaims,
4151
4314
  ...fvaClaim,
4315
+ ...tenantClaim,
4152
4316
  ...aclClaim,
4153
4317
  jti,
4154
4318
  sid: sessionId
@@ -4164,16 +4328,24 @@ function createAuth(config) {
4164
4328
  sid: sessionId,
4165
4329
  claims: userClaims || undefined,
4166
4330
  ...options?.fva !== undefined ? { fva: options.fva } : {},
4331
+ ...options?.tenantId ? { tenantId: options.tenantId } : {},
4167
4332
  ...aclClaim
4168
4333
  };
4169
4334
  return { jwt, refreshToken, payload, expiresAt };
4170
4335
  }
4336
+ const FORGOT_PASSWORD_MIN_RESPONSE_MS = 15;
4171
4337
  function generateToken() {
4172
4338
  const bytes = crypto.getRandomValues(new Uint8Array(32));
4173
4339
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
4174
4340
  }
4341
+ async function waitForMinimumDuration(startedAt, minDurationMs) {
4342
+ const remaining = minDurationMs - (Date.now() - startedAt);
4343
+ if (remaining > 0) {
4344
+ await new Promise((resolve) => setTimeout(resolve, remaining));
4345
+ }
4346
+ }
4175
4347
  async function signUp(data, ctx) {
4176
- const { email, password, role = "user", ...additionalFields } = data;
4348
+ const { email, password, ...additionalFields } = data;
4177
4349
  if (!email || !email.includes("@")) {
4178
4350
  return err(createAuthValidationError2("Invalid email format", "email", "INVALID_FORMAT"));
4179
4351
  }
@@ -4195,18 +4367,37 @@ function createAuth(config) {
4195
4367
  id: _id,
4196
4368
  createdAt: _c,
4197
4369
  updatedAt: _u,
4370
+ role: _role,
4371
+ emailVerified: _emailVerified,
4198
4372
  ...safeFields
4199
4373
  } = additionalFields;
4200
4374
  const user = {
4201
- ...safeFields,
4202
4375
  id: crypto.randomUUID(),
4203
4376
  email: email.toLowerCase(),
4204
- role,
4377
+ role: "user",
4205
4378
  emailVerified: !emailVerificationEnabled,
4206
4379
  createdAt: now,
4207
4380
  updatedAt: now
4208
4381
  };
4209
4382
  await userStore.createUser(user, passwordHash);
4383
+ if (onUserCreated) {
4384
+ const callbackCtx = { entities: entityProxy };
4385
+ const payload = {
4386
+ user,
4387
+ provider: null,
4388
+ signUpData: { ...safeFields }
4389
+ };
4390
+ try {
4391
+ await onUserCreated(payload, callbackCtx);
4392
+ } catch (callbackErr) {
4393
+ try {
4394
+ await userStore.deleteUser(user.id);
4395
+ } catch (rollbackErr) {
4396
+ console.error("[Auth] Failed to rollback user after onUserCreated failure:", rollbackErr);
4397
+ }
4398
+ return err(createAuthValidationError2("User setup failed", "general", "CALLBACK_FAILED"));
4399
+ }
4400
+ }
4210
4401
  if (emailVerificationEnabled && emailVerificationStore && emailVerificationConfig?.onSend) {
4211
4402
  const token = generateToken();
4212
4403
  const tokenHash = await sha256Hex(token);
@@ -4298,6 +4489,10 @@ function createAuth(config) {
4298
4489
  if (!payload) {
4299
4490
  return ok(null);
4300
4491
  }
4492
+ const storedSession = await sessionStore.findActiveSessionById(payload.sid);
4493
+ if (!storedSession || storedSession.userId !== payload.sub) {
4494
+ return ok(null);
4495
+ }
4301
4496
  const user = await userStore.findById(payload.sub);
4302
4497
  if (!user) {
4303
4498
  return ok(null);
@@ -4380,16 +4575,16 @@ function createAuth(config) {
4380
4575
  }
4381
4576
  const currentSid = sessionResult.data.payload.sid;
4382
4577
  const sessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
4383
- const infos = sessions.map((s) => ({
4384
- id: s.id,
4385
- userId: s.userId,
4386
- ipAddress: s.ipAddress,
4387
- userAgent: s.userAgent,
4388
- deviceName: parseDeviceName(s.userAgent),
4389
- createdAt: s.createdAt,
4390
- lastActiveAt: s.lastActiveAt,
4391
- expiresAt: s.expiresAt,
4392
- isCurrent: s.id === currentSid
4578
+ const infos = sessions.map((s2) => ({
4579
+ id: s2.id,
4580
+ userId: s2.userId,
4581
+ ipAddress: s2.ipAddress,
4582
+ userAgent: s2.userAgent,
4583
+ deviceName: parseDeviceName(s2.userAgent),
4584
+ createdAt: s2.createdAt,
4585
+ lastActiveAt: s2.lastActiveAt,
4586
+ expiresAt: s2.expiresAt,
4587
+ isCurrent: s2.id === currentSid
4393
4588
  }));
4394
4589
  return ok(infos);
4395
4590
  }
@@ -4401,7 +4596,7 @@ function createAuth(config) {
4401
4596
  return err(createSessionExpiredError("Not authenticated"));
4402
4597
  }
4403
4598
  const targetSessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
4404
- const target = targetSessions.find((s) => s.id === sessionId);
4599
+ const target = targetSessions.find((s2) => s2.id === sessionId);
4405
4600
  if (!target) {
4406
4601
  return err(createSessionNotFoundError("Session not found"));
4407
4602
  }
@@ -4417,9 +4612,9 @@ function createAuth(config) {
4417
4612
  }
4418
4613
  const currentSid = sessionResult.data.payload.sid;
4419
4614
  const sessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
4420
- for (const s of sessions) {
4421
- if (s.id !== currentSid) {
4422
- await sessionStore.revokeSession(s.id);
4615
+ for (const s2 of sessions) {
4616
+ if (s2.id !== currentSid) {
4617
+ await sessionStore.revokeSession(s2.id);
4423
4618
  }
4424
4619
  }
4425
4620
  return ok(undefined);
@@ -4452,6 +4647,32 @@ function createAuth(config) {
4452
4647
  "Referrer-Policy": "strict-origin-when-cross-origin"
4453
4648
  };
4454
4649
  }
4650
+ function authValidationResponse(message) {
4651
+ return new Response(JSON.stringify({
4652
+ error: {
4653
+ code: "AUTH_VALIDATION_ERROR",
4654
+ message
4655
+ }
4656
+ }), {
4657
+ status: 400,
4658
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
4659
+ });
4660
+ }
4661
+ async function parseJsonAuthBody(request, schema) {
4662
+ try {
4663
+ const body = await parseBody(request);
4664
+ const result = schema.safeParse(body);
4665
+ if (!result.ok) {
4666
+ return { ok: false, response: authValidationResponse(result.error.message) };
4667
+ }
4668
+ return { ok: true, data: result.data };
4669
+ } catch (error) {
4670
+ if (error instanceof BadRequestException) {
4671
+ return { ok: false, response: authValidationResponse(error.message) };
4672
+ }
4673
+ throw error;
4674
+ }
4675
+ }
4455
4676
  async function handleAuthRequest(request) {
4456
4677
  const url = new URL(request.url);
4457
4678
  const path = url.pathname.replace("/api/auth", "") || "/";
@@ -4495,7 +4716,11 @@ function createAuth(config) {
4495
4716
  }
4496
4717
  try {
4497
4718
  if (method === "POST" && path === "/signup") {
4498
- const body = await request.json();
4719
+ const bodyResult = await parseJsonAuthBody(request, signUpInputSchema);
4720
+ if (!bodyResult.ok) {
4721
+ return bodyResult.response;
4722
+ }
4723
+ const body = bodyResult.data;
4499
4724
  const result = await signUp(body, { headers: request.headers });
4500
4725
  if (!result.ok) {
4501
4726
  return new Response(JSON.stringify({ error: result.error }), {
@@ -4520,7 +4745,11 @@ function createAuth(config) {
4520
4745
  });
4521
4746
  }
4522
4747
  if (method === "POST" && path === "/signin") {
4523
- const body = await request.json();
4748
+ const bodyResult = await parseJsonAuthBody(request, signInInputSchema);
4749
+ if (!bodyResult.ok) {
4750
+ return bodyResult.response;
4751
+ }
4752
+ const body = bodyResult.data;
4524
4753
  const result = await signIn(body, { headers: request.headers });
4525
4754
  if (!result.ok) {
4526
4755
  if (result.error.code === "MFA_REQUIRED" && oauthEncryptionKey) {
@@ -4609,7 +4838,8 @@ function createAuth(config) {
4609
4838
  roleStore: config.access.roleStore,
4610
4839
  closureStore: config.access.closureStore,
4611
4840
  flagStore: config.access.flagStore,
4612
- plan: sessionResult.data.user.plan ?? null
4841
+ subscriptionStore: config.access?.subscriptionStore,
4842
+ tenantId: sessionResult.data.payload?.tenantId ?? null
4613
4843
  });
4614
4844
  const encoded = encodeAccessSet(accessSet);
4615
4845
  const hashPayload = {
@@ -4625,8 +4855,9 @@ function createAuth(config) {
4625
4855
  status: 304,
4626
4856
  headers: {
4627
4857
  ETag: quotedEtag,
4858
+ Vary: "Cookie",
4628
4859
  ...securityHeaders(),
4629
- "Cache-Control": "no-cache"
4860
+ "Cache-Control": "private, no-cache"
4630
4861
  }
4631
4862
  });
4632
4863
  }
@@ -4635,8 +4866,9 @@ function createAuth(config) {
4635
4866
  headers: {
4636
4867
  "Content-Type": "application/json",
4637
4868
  ETag: quotedEtag,
4869
+ Vary: "Cookie",
4638
4870
  ...securityHeaders(),
4639
- "Cache-Control": "no-cache"
4871
+ "Cache-Control": "private, no-cache"
4640
4872
  }
4641
4873
  });
4642
4874
  }
@@ -4710,6 +4942,17 @@ function createAuth(config) {
4710
4942
  headers: { "Content-Type": "application/json", ...securityHeaders() }
4711
4943
  });
4712
4944
  }
4945
+ if (method === "GET" && path === "/providers") {
4946
+ const providerList = Array.from(providers.values()).map((p) => ({
4947
+ id: p.id,
4948
+ name: p.name,
4949
+ authUrl: `/api/auth/oauth/${p.id}`
4950
+ }));
4951
+ return new Response(JSON.stringify(providerList), {
4952
+ status: 200,
4953
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
4954
+ });
4955
+ }
4713
4956
  if (method === "GET" && path.startsWith("/oauth/") && !path.includes("/callback")) {
4714
4957
  const providerId = path.replace("/oauth/", "");
4715
4958
  const provider = providers.get(providerId);
@@ -4756,12 +4999,17 @@ function createAuth(config) {
4756
4999
  if (method === "GET" && path.includes("/oauth/") && path.endsWith("/callback")) {
4757
5000
  const providerId = path.replace("/oauth/", "").replace("/callback", "");
4758
5001
  const provider = providers.get(providerId);
4759
- const errorRedirect = oauthErrorRedirect;
5002
+ const isAbsoluteUrl = /^https?:\/\//.test(oauthErrorRedirect);
5003
+ const errorUrl = (error) => {
5004
+ const url2 = new URL(oauthErrorRedirect, "http://localhost");
5005
+ url2.searchParams.set("error", error);
5006
+ return isAbsoluteUrl ? url2.toString() : url2.pathname + url2.search + url2.hash;
5007
+ };
4760
5008
  if (!provider || !oauthEncryptionKey || !oauthAccountStore) {
4761
5009
  return new Response(null, {
4762
5010
  status: 302,
4763
5011
  headers: {
4764
- Location: `${errorRedirect}?error=provider_not_configured`,
5012
+ Location: errorUrl("provider_not_configured"),
4765
5013
  ...securityHeaders()
4766
5014
  }
4767
5015
  });
@@ -4772,7 +5020,7 @@ function createAuth(config) {
4772
5020
  return new Response(null, {
4773
5021
  status: 302,
4774
5022
  headers: {
4775
- Location: `${errorRedirect}?error=invalid_state`,
5023
+ Location: errorUrl("invalid_state"),
4776
5024
  ...securityHeaders()
4777
5025
  }
4778
5026
  });
@@ -4782,7 +5030,7 @@ function createAuth(config) {
4782
5030
  return new Response(null, {
4783
5031
  status: 302,
4784
5032
  headers: {
4785
- Location: `${errorRedirect}?error=invalid_state`,
5033
+ Location: errorUrl("invalid_state"),
4786
5034
  ...securityHeaders()
4787
5035
  }
4788
5036
  });
@@ -4793,7 +5041,7 @@ function createAuth(config) {
4793
5041
  const providerError = url.searchParams.get("error");
4794
5042
  if (providerError) {
4795
5043
  const headers = new Headers({
4796
- Location: `${errorRedirect}?error=${encodeURIComponent(providerError)}`,
5044
+ Location: errorUrl(providerError),
4797
5045
  ...securityHeaders()
4798
5046
  });
4799
5047
  headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
@@ -4801,7 +5049,7 @@ function createAuth(config) {
4801
5049
  }
4802
5050
  if (stateData.state !== queryState || stateData.provider !== providerId) {
4803
5051
  const headers = new Headers({
4804
- Location: `${errorRedirect}?error=invalid_state`,
5052
+ Location: errorUrl("invalid_state"),
4805
5053
  ...securityHeaders()
4806
5054
  });
4807
5055
  headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
@@ -4809,7 +5057,7 @@ function createAuth(config) {
4809
5057
  }
4810
5058
  if (stateData.expiresAt < Date.now()) {
4811
5059
  const headers = new Headers({
4812
- Location: `${errorRedirect}?error=invalid_state`,
5060
+ Location: errorUrl("invalid_state"),
4813
5061
  ...securityHeaders()
4814
5062
  });
4815
5063
  headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
@@ -4836,7 +5084,7 @@ function createAuth(config) {
4836
5084
  if (!userId) {
4837
5085
  if (!userInfo.email || !userInfo.email.includes("@")) {
4838
5086
  const headers = new Headers({
4839
- Location: `${errorRedirect}?error=email_required`,
5087
+ Location: errorUrl("email_required"),
4840
5088
  ...securityHeaders()
4841
5089
  });
4842
5090
  headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
@@ -4846,6 +5094,7 @@ function createAuth(config) {
4846
5094
  const newUser = {
4847
5095
  id: crypto.randomUUID(),
4848
5096
  email: userInfo.email.toLowerCase(),
5097
+ emailVerified: userInfo.emailVerified,
4849
5098
  role: "user",
4850
5099
  createdAt: now,
4851
5100
  updatedAt: now
@@ -4853,6 +5102,34 @@ function createAuth(config) {
4853
5102
  await userStore.createUser(newUser, null);
4854
5103
  userId = newUser.id;
4855
5104
  await oauthAccountStore.linkAccount(userId, provider.id, userInfo.providerId, userInfo.email);
5105
+ if (onUserCreated) {
5106
+ const callbackCtx = { entities: entityProxy };
5107
+ const payload = {
5108
+ user: newUser,
5109
+ provider: { id: provider.id, name: provider.name },
5110
+ profile: userInfo.raw
5111
+ };
5112
+ try {
5113
+ await onUserCreated(payload, callbackCtx);
5114
+ } catch {
5115
+ try {
5116
+ await oauthAccountStore.unlinkAccount(userId, provider.id);
5117
+ } catch (rollbackErr) {
5118
+ console.error("[Auth] Failed to unlink OAuth account during rollback:", rollbackErr);
5119
+ }
5120
+ try {
5121
+ await userStore.deleteUser(userId);
5122
+ } catch (rollbackErr) {
5123
+ console.error("[Auth] Failed to delete user during rollback:", rollbackErr);
5124
+ }
5125
+ const headers = new Headers({
5126
+ Location: errorUrl("user_setup_failed"),
5127
+ ...securityHeaders()
5128
+ });
5129
+ headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
5130
+ return new Response(null, { status: 302, headers });
5131
+ }
5132
+ }
4856
5133
  }
4857
5134
  }
4858
5135
  const user = await userStore.findById(userId);
@@ -4860,7 +5137,7 @@ function createAuth(config) {
4860
5137
  return new Response(null, {
4861
5138
  status: 302,
4862
5139
  headers: {
4863
- Location: `${errorRedirect}?error=user_info_failed`,
5140
+ Location: errorUrl("user_info_failed"),
4864
5141
  ...securityHeaders()
4865
5142
  }
4866
5143
  });
@@ -4881,9 +5158,10 @@ function createAuth(config) {
4881
5158
  responseHeaders.append("Set-Cookie", buildSessionCookie(sessionTokens.jwt, cookieConfig));
4882
5159
  responseHeaders.append("Set-Cookie", buildRefreshCookie(sessionTokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
4883
5160
  return new Response(null, { status: 302, headers: responseHeaders });
4884
- } catch {
5161
+ } catch (oauthErr) {
5162
+ console.error("[Auth] OAuth callback error:", oauthErr);
4885
5163
  const headers = new Headers({
4886
- Location: `${errorRedirect}?error=token_exchange_failed`,
5164
+ Location: errorUrl("token_exchange_failed"),
4887
5165
  ...securityHeaders()
4888
5166
  });
4889
5167
  headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
@@ -4933,7 +5211,11 @@ function createAuth(config) {
4933
5211
  headers: { "Content-Type": "application/json", ...securityHeaders() }
4934
5212
  });
4935
5213
  }
4936
- const body = await request.json();
5214
+ const bodyResult = await parseJsonAuthBody(request, codeInputSchema);
5215
+ if (!bodyResult.ok) {
5216
+ return bodyResult.response;
5217
+ }
5218
+ const body = bodyResult.data;
4937
5219
  const userId = challengeData.userId;
4938
5220
  const encryptedSecret = await mfaStore.getSecret(userId);
4939
5221
  if (!encryptedSecret) {
@@ -5053,7 +5335,11 @@ function createAuth(config) {
5053
5335
  headers: { "Content-Type": "application/json", ...securityHeaders() }
5054
5336
  });
5055
5337
  }
5056
- const body = await request.json();
5338
+ const bodyResult = await parseJsonAuthBody(request, codeInputSchema);
5339
+ if (!bodyResult.ok) {
5340
+ return bodyResult.response;
5341
+ }
5342
+ const body = bodyResult.data;
5057
5343
  const valid = await verifyTotpCode(pendingEntry.secret, body.code);
5058
5344
  if (!valid) {
5059
5345
  return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
@@ -5087,7 +5373,11 @@ function createAuth(config) {
5087
5373
  });
5088
5374
  }
5089
5375
  const userId = sessionResult.data.user.id;
5090
- const body = await request.json();
5376
+ const bodyResult = await parseJsonAuthBody(request, passwordInputSchema);
5377
+ if (!bodyResult.ok) {
5378
+ return bodyResult.response;
5379
+ }
5380
+ const body = bodyResult.data;
5091
5381
  const stored = await userStore.findByEmail(sessionResult.data.user.email);
5092
5382
  if (!stored || !stored.passwordHash) {
5093
5383
  return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
@@ -5122,7 +5412,11 @@ function createAuth(config) {
5122
5412
  headers: { "Content-Type": "application/json", ...securityHeaders() }
5123
5413
  });
5124
5414
  }
5125
- const body = await request.json();
5415
+ const bodyResult = await parseJsonAuthBody(request, passwordInputSchema);
5416
+ if (!bodyResult.ok) {
5417
+ return bodyResult.response;
5418
+ }
5419
+ const body = bodyResult.data;
5126
5420
  const stored = await userStore.findByEmail(sessionResult.data.user.email);
5127
5421
  if (!stored || !stored.passwordHash) {
5128
5422
  return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
@@ -5209,7 +5503,11 @@ function createAuth(config) {
5209
5503
  headers: { "Content-Type": "application/json", ...securityHeaders() }
5210
5504
  });
5211
5505
  }
5212
- const body = await request.json();
5506
+ const bodyResult = await parseJsonAuthBody(request, codeInputSchema);
5507
+ if (!bodyResult.ok) {
5508
+ return bodyResult.response;
5509
+ }
5510
+ const body = bodyResult.data;
5213
5511
  const valid = await verifyTotpCode(totpSecret, body.code);
5214
5512
  if (!valid) {
5215
5513
  return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
@@ -5253,7 +5551,11 @@ function createAuth(config) {
5253
5551
  headers: { "Content-Type": "application/json", ...securityHeaders() }
5254
5552
  });
5255
5553
  }
5256
- const body = await request.json();
5554
+ const bodyResult = await parseJsonAuthBody(request, tokenInputSchema);
5555
+ if (!bodyResult.ok) {
5556
+ return bodyResult.response;
5557
+ }
5558
+ const body = bodyResult.data;
5257
5559
  if (!body.token) {
5258
5560
  return new Response(JSON.stringify({ error: createTokenInvalidError("Token is required") }), {
5259
5561
  status: 400,
@@ -5332,25 +5634,32 @@ function createAuth(config) {
5332
5634
  headers: { "Content-Type": "application/json", ...securityHeaders() }
5333
5635
  });
5334
5636
  }
5335
- const body = await request.json();
5637
+ const bodyResult = await parseJsonAuthBody(request, forgotPasswordInputSchema);
5638
+ if (!bodyResult.ok) {
5639
+ return bodyResult.response;
5640
+ }
5641
+ const body = bodyResult.data;
5642
+ const startedAt = Date.now();
5336
5643
  const forgotRateLimit = await rateLimitStore.check(`forgot-password:${(body.email ?? "").toLowerCase()}`, 3, parseDuration("1h"));
5337
5644
  if (!forgotRateLimit.allowed) {
5645
+ await waitForMinimumDuration(startedAt, FORGOT_PASSWORD_MIN_RESPONSE_MS);
5338
5646
  return new Response(JSON.stringify({ ok: true }), {
5339
5647
  status: 200,
5340
5648
  headers: { "Content-Type": "application/json", ...securityHeaders() }
5341
5649
  });
5342
5650
  }
5651
+ const token = generateToken();
5652
+ const tokenHash = await sha256Hex(token);
5343
5653
  const stored = await userStore.findByEmail((body.email ?? "").toLowerCase());
5344
5654
  if (stored) {
5345
- const token = generateToken();
5346
- const tokenHash = await sha256Hex(token);
5347
5655
  await passwordResetStore.createReset({
5348
5656
  userId: stored.user.id,
5349
5657
  tokenHash,
5350
5658
  expiresAt: new Date(Date.now() + passwordResetTtlMs)
5351
5659
  });
5352
- await passwordResetConfig.onSend(stored.user, token);
5660
+ passwordResetConfig.onSend(stored.user, token).catch((error) => console.error("[Auth] Failed to send password reset email", error));
5353
5661
  }
5662
+ await waitForMinimumDuration(startedAt, FORGOT_PASSWORD_MIN_RESPONSE_MS);
5354
5663
  return new Response(JSON.stringify({ ok: true }), {
5355
5664
  status: 200,
5356
5665
  headers: { "Content-Type": "application/json", ...securityHeaders() }
@@ -5365,7 +5674,11 @@ function createAuth(config) {
5365
5674
  headers: { "Content-Type": "application/json", ...securityHeaders() }
5366
5675
  });
5367
5676
  }
5368
- const body = await request.json();
5677
+ const bodyResult = await parseJsonAuthBody(request, resetPasswordInputSchema);
5678
+ if (!bodyResult.ok) {
5679
+ return bodyResult.response;
5680
+ }
5681
+ const body = bodyResult.data;
5369
5682
  if (!body.token) {
5370
5683
  return new Response(JSON.stringify({ error: createTokenInvalidError("Token is required") }), {
5371
5684
  status: 400,
@@ -5399,8 +5712,8 @@ function createAuth(config) {
5399
5712
  await passwordResetStore.deleteByUserId(resetRecord.userId);
5400
5713
  if (revokeSessionsOnReset) {
5401
5714
  const activeSessions = await sessionStore.listActiveSessions(resetRecord.userId);
5402
- for (const s of activeSessions) {
5403
- await sessionStore.revokeSession(s.id);
5715
+ for (const s2 of activeSessions) {
5716
+ await sessionStore.revokeSession(s2.id);
5404
5717
  }
5405
5718
  }
5406
5719
  return new Response(JSON.stringify({ ok: true }), {
@@ -5408,6 +5721,63 @@ function createAuth(config) {
5408
5721
  headers: { "Content-Type": "application/json", ...securityHeaders() }
5409
5722
  });
5410
5723
  }
5724
+ if (method === "POST" && path === "/switch-tenant") {
5725
+ if (!tenantConfig) {
5726
+ return new Response(JSON.stringify({ error: "Not found" }), {
5727
+ status: 404,
5728
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
5729
+ });
5730
+ }
5731
+ const sessionResult = await getSession(request.headers);
5732
+ if (!sessionResult.ok || !sessionResult.data) {
5733
+ return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
5734
+ status: 401,
5735
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
5736
+ });
5737
+ }
5738
+ const bodyResult = await parseJsonAuthBody(request, switchTenantInputSchema);
5739
+ if (!bodyResult.ok) {
5740
+ return bodyResult.response;
5741
+ }
5742
+ const { tenantId } = bodyResult.data;
5743
+ const userId = sessionResult.data.user.id;
5744
+ const hasMembership = await tenantConfig.verifyMembership(userId, tenantId);
5745
+ if (!hasMembership) {
5746
+ return new Response(JSON.stringify({
5747
+ error: { code: "AUTH_FORBIDDEN", message: "Not a member of this tenant" }
5748
+ }), {
5749
+ status: 403,
5750
+ headers: { "Content-Type": "application/json", ...securityHeaders() }
5751
+ });
5752
+ }
5753
+ const currentPayload = sessionResult.data.payload;
5754
+ const tokens = await createSessionTokens(sessionResult.data.user, currentPayload.sid, {
5755
+ fva: currentPayload.fva,
5756
+ tenantId
5757
+ });
5758
+ const refreshTokenHash = await sha256Hex(tokens.refreshToken);
5759
+ const previousRefreshHash = (await sessionStore.findActiveSessionById(currentPayload.sid))?.refreshTokenHash ?? "";
5760
+ await sessionStore.updateSession(currentPayload.sid, {
5761
+ refreshTokenHash,
5762
+ previousRefreshHash,
5763
+ lastActiveAt: new Date,
5764
+ currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
5765
+ });
5766
+ const headers = new Headers({
5767
+ "Content-Type": "application/json",
5768
+ ...securityHeaders()
5769
+ });
5770
+ headers.append("Set-Cookie", buildSessionCookie(tokens.jwt, cookieConfig));
5771
+ headers.append("Set-Cookie", buildRefreshCookie(tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
5772
+ return new Response(JSON.stringify({
5773
+ tenantId,
5774
+ user: sessionResult.data.user,
5775
+ expiresAt: tokens.expiresAt.getTime()
5776
+ }), {
5777
+ status: 200,
5778
+ headers
5779
+ });
5780
+ }
5411
5781
  return new Response(JSON.stringify({ error: "Not found" }), {
5412
5782
  status: 404,
5413
5783
  headers: { "Content-Type": "application/json" }
@@ -5458,9 +5828,49 @@ function createAuth(config) {
5458
5828
  emailVerificationStore?.dispose();
5459
5829
  passwordResetStore?.dispose();
5460
5830
  pendingMfaSecrets.clear();
5831
+ },
5832
+ resolveSessionForSSR: resolveSessionForSSR({
5833
+ jwtSecret,
5834
+ jwtAlgorithm,
5835
+ cookieName: cookieConfig.name || "vertz.sid"
5836
+ })
5837
+ };
5838
+ }
5839
+ // src/content/builders.ts
5840
+ function stringDescriptor(contentType) {
5841
+ return {
5842
+ _kind: "content",
5843
+ _contentType: contentType,
5844
+ parse(value) {
5845
+ if (typeof value === "string") {
5846
+ return { ok: true, data: value };
5847
+ }
5848
+ return { ok: false, error: new Error(`Expected string, got ${typeof value}`) };
5461
5849
  }
5462
5850
  };
5463
5851
  }
5852
+ var content = {
5853
+ xml: () => stringDescriptor("application/xml"),
5854
+ html: () => stringDescriptor("text/html"),
5855
+ text: () => stringDescriptor("text/plain"),
5856
+ binary: () => ({
5857
+ _kind: "content",
5858
+ _contentType: "application/octet-stream",
5859
+ parse(value) {
5860
+ if (value instanceof Uint8Array) {
5861
+ return { ok: true, data: value };
5862
+ }
5863
+ return {
5864
+ ok: false,
5865
+ error: new Error(`Expected Uint8Array, got ${typeof value}`)
5866
+ };
5867
+ }
5868
+ })
5869
+ };
5870
+ // src/content/content-descriptor.ts
5871
+ function isContentDescriptor(value) {
5872
+ return value != null && typeof value === "object" && "_kind" in value && value._kind === "content";
5873
+ }
5464
5874
  // src/create-server.ts
5465
5875
  import { createServer as coreCreateServer } from "@vertz/core";
5466
5876
  import {
@@ -5541,10 +5951,15 @@ function narrowRelationFields(relationsConfig, data) {
5541
5951
  if (config === undefined || config === true) {
5542
5952
  result[key] = value;
5543
5953
  } else if (config === false) {} else if (typeof config === "object" && value !== null && typeof value === "object") {
5954
+ const selectMap = config.select;
5955
+ if (!selectMap) {
5956
+ result[key] = value;
5957
+ continue;
5958
+ }
5544
5959
  if (Array.isArray(value)) {
5545
5960
  result[key] = value.map((item) => {
5546
5961
  const narrowed = {};
5547
- for (const field of Object.keys(config)) {
5962
+ for (const field of Object.keys(selectMap)) {
5548
5963
  if (field in item) {
5549
5964
  narrowed[field] = item[field];
5550
5965
  }
@@ -5553,7 +5968,7 @@ function narrowRelationFields(relationsConfig, data) {
5553
5968
  });
5554
5969
  } else {
5555
5970
  const narrowed = {};
5556
- for (const field of Object.keys(config)) {
5971
+ for (const field of Object.keys(selectMap)) {
5557
5972
  if (field in value) {
5558
5973
  narrowed[field] = value[field];
5559
5974
  }
@@ -5577,6 +5992,15 @@ function applySelect(select, data) {
5577
5992
  }
5578
5993
  return result;
5579
5994
  }
5995
+ function nullGuardedFields(nulledFields, data) {
5996
+ if (nulledFields.size === 0)
5997
+ return data;
5998
+ const result = {};
5999
+ for (const [key, value] of Object.entries(data)) {
6000
+ result[key] = nulledFields.has(key) ? null : value;
6001
+ }
6002
+ return result;
6003
+ }
5580
6004
  function stripReadOnlyFields(table, data) {
5581
6005
  const excludedKeys = new Set;
5582
6006
  for (const key of Object.keys(table._columns)) {
@@ -5606,7 +6030,138 @@ import {
5606
6030
 
5607
6031
  // src/entity/access-enforcer.ts
5608
6032
  import { EntityForbiddenError, err as err2, ok as ok2 } from "@vertz/errors";
5609
- async function enforceAccess(operation, accessRules, ctx, row) {
6033
+ function deny(operation) {
6034
+ return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
6035
+ }
6036
+ function isUserMarker(value) {
6037
+ return typeof value === "object" && value !== null && "__marker" in value;
6038
+ }
6039
+ function resolveMarker(marker, ctx) {
6040
+ switch (marker.__marker) {
6041
+ case "user.id":
6042
+ return ctx.userId;
6043
+ case "user.tenantId":
6044
+ return ctx.tenantId;
6045
+ default:
6046
+ return;
6047
+ }
6048
+ }
6049
+ async function evaluateRule(rule, operation, ctx, row, options) {
6050
+ switch (rule.type) {
6051
+ case "public":
6052
+ return ok2(undefined);
6053
+ case "authenticated":
6054
+ return ctx.authenticated() ? ok2(undefined) : deny(operation);
6055
+ case "role":
6056
+ return ctx.role(...rule.roles) ? ok2(undefined) : deny(operation);
6057
+ case "entitlement": {
6058
+ if (!options.can) {
6059
+ return deny(operation);
6060
+ }
6061
+ const allowed = await options.can(rule.entitlement);
6062
+ return allowed ? ok2(undefined) : deny(operation);
6063
+ }
6064
+ case "where": {
6065
+ for (const [key, expected] of Object.entries(rule.conditions)) {
6066
+ const resolved = isUserMarker(expected) ? resolveMarker(expected, ctx) : expected;
6067
+ if (row[key] !== resolved) {
6068
+ return deny(operation);
6069
+ }
6070
+ }
6071
+ return ok2(undefined);
6072
+ }
6073
+ case "all": {
6074
+ for (const sub of rule.rules) {
6075
+ const result = await evaluateDescriptor(sub, operation, ctx, row, options);
6076
+ if (!result.ok)
6077
+ return result;
6078
+ }
6079
+ return ok2(undefined);
6080
+ }
6081
+ case "any": {
6082
+ for (const sub of rule.rules) {
6083
+ const result = await evaluateDescriptor(sub, operation, ctx, row, options);
6084
+ if (result.ok)
6085
+ return result;
6086
+ }
6087
+ return deny(operation);
6088
+ }
6089
+ case "fva": {
6090
+ if (options.fvaAge === undefined) {
6091
+ return deny(operation);
6092
+ }
6093
+ return options.fvaAge <= rule.maxAge ? ok2(undefined) : deny(operation);
6094
+ }
6095
+ }
6096
+ }
6097
+ async function evaluateDescriptor(rule, operation, ctx, row, options) {
6098
+ if (typeof rule === "function") {
6099
+ const allowed = await rule(ctx, row);
6100
+ return allowed ? ok2(undefined) : deny(operation);
6101
+ }
6102
+ return evaluateRule(rule, operation, ctx, row, options);
6103
+ }
6104
+ async function evaluateRuleSkipWhere(rule, operation, ctx, row, options) {
6105
+ if (rule.type === "where") {
6106
+ return ok2(undefined);
6107
+ }
6108
+ if (rule.type === "all") {
6109
+ for (const sub of rule.rules) {
6110
+ const result = await evaluateDescriptorSkipWhere(sub, operation, ctx, row, options);
6111
+ if (!result.ok)
6112
+ return result;
6113
+ }
6114
+ return ok2(undefined);
6115
+ }
6116
+ if (rule.type === "any") {
6117
+ for (const sub of rule.rules) {
6118
+ const result = await evaluateDescriptorSkipWhere(sub, operation, ctx, row, options);
6119
+ if (result.ok)
6120
+ return result;
6121
+ }
6122
+ return deny(operation);
6123
+ }
6124
+ return evaluateRule(rule, operation, ctx, row, options);
6125
+ }
6126
+ async function evaluateDescriptorSkipWhere(rule, operation, ctx, row, options) {
6127
+ if (typeof rule === "function") {
6128
+ const allowed = await rule(ctx, row);
6129
+ return allowed ? ok2(undefined) : deny(operation);
6130
+ }
6131
+ return evaluateRuleSkipWhere(rule, operation, ctx, row, options);
6132
+ }
6133
+ function extractFromDescriptor(rule, ctx) {
6134
+ if (typeof rule === "function")
6135
+ return null;
6136
+ switch (rule.type) {
6137
+ case "where": {
6138
+ const resolved = {};
6139
+ for (const [key, value] of Object.entries(rule.conditions)) {
6140
+ resolved[key] = isUserMarker(value) ? resolveMarker(value, ctx) : value;
6141
+ }
6142
+ return resolved;
6143
+ }
6144
+ case "all": {
6145
+ let merged = null;
6146
+ for (const sub of rule.rules) {
6147
+ const extracted = extractFromDescriptor(sub, ctx);
6148
+ if (extracted) {
6149
+ merged = { ...merged ?? {}, ...extracted };
6150
+ }
6151
+ }
6152
+ return merged;
6153
+ }
6154
+ default:
6155
+ return null;
6156
+ }
6157
+ }
6158
+ function extractWhereConditions(operation, accessRules, ctx) {
6159
+ const rule = accessRules[operation];
6160
+ if (rule === undefined || rule === false)
6161
+ return null;
6162
+ return extractFromDescriptor(rule, ctx);
6163
+ }
6164
+ async function enforceAccess(operation, accessRules, ctx, row, options) {
5610
6165
  const rule = accessRules[operation];
5611
6166
  if (rule === undefined) {
5612
6167
  return err2(new EntityForbiddenError(`Access denied: no access rule for operation "${operation}"`));
@@ -5614,14 +6169,10 @@ async function enforceAccess(operation, accessRules, ctx, row) {
5614
6169
  if (rule === false) {
5615
6170
  return err2(new EntityForbiddenError(`Operation "${operation}" is disabled`));
5616
6171
  }
5617
- if (typeof rule === "function") {
5618
- const allowed = await rule(ctx, row ?? {});
5619
- if (!allowed) {
5620
- return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
5621
- }
5622
- return ok2(undefined);
6172
+ if (options?.skipWhere) {
6173
+ return evaluateDescriptorSkipWhere(rule, operation, ctx, row ?? {}, options);
5623
6174
  }
5624
- return ok2(undefined);
6175
+ return evaluateDescriptor(rule, operation, ctx, row ?? {}, options ?? {});
5625
6176
  }
5626
6177
 
5627
6178
  // src/entity/action-pipeline.ts
@@ -5663,6 +6214,7 @@ function createEntityContext(request, entityOps, registryProxy) {
5663
6214
  const tenantId = request.tenantId ?? null;
5664
6215
  return {
5665
6216
  userId,
6217
+ tenantId,
5666
6218
  authenticated() {
5667
6219
  return userId !== null;
5668
6220
  },
@@ -5678,7 +6230,12 @@ function createEntityContext(request, entityOps, registryProxy) {
5678
6230
  }
5679
6231
 
5680
6232
  // src/entity/crud-pipeline.ts
5681
- import { EntityNotFoundError as EntityNotFoundError2, err as err4, ok as ok4 } from "@vertz/errors";
6233
+ import {
6234
+ EntityForbiddenError as EntityForbiddenError2,
6235
+ EntityNotFoundError as EntityNotFoundError2,
6236
+ err as err4,
6237
+ ok as ok4
6238
+ } from "@vertz/errors";
5682
6239
  function resolvePrimaryKeyColumn(table) {
5683
6240
  for (const key of Object.keys(table._columns)) {
5684
6241
  const col = table._columns[key];
@@ -5687,38 +6244,109 @@ function resolvePrimaryKeyColumn(table) {
5687
6244
  }
5688
6245
  return "id";
5689
6246
  }
5690
- function createCrudHandlers(def, db) {
6247
+ function createCrudHandlers(def, db, options) {
5691
6248
  const table = def.model.table;
6249
+ const isTenantScoped = def.tenantScoped;
6250
+ const tenantChain = options?.tenantChain ?? def.tenantChain ?? null;
6251
+ const isIndirectlyScoped = tenantChain !== null;
6252
+ const queryParentIds = options?.queryParentIds ?? null;
6253
+ const exposeSelect = def.expose?.select ? {
6254
+ ...def.expose.select,
6255
+ ...Object.fromEntries(Object.entries(def.expose.include ?? {}).filter(([, v]) => v !== false).map(([k]) => [k, true]))
6256
+ } : undefined;
6257
+ function notFound(id) {
6258
+ return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
6259
+ }
6260
+ function isSameTenant(ctx, row) {
6261
+ if (!isTenantScoped)
6262
+ return true;
6263
+ if (isIndirectlyScoped)
6264
+ return true;
6265
+ return row.tenantId === ctx.tenantId;
6266
+ }
6267
+ function withTenantFilter(ctx, where) {
6268
+ if (!isTenantScoped)
6269
+ return where;
6270
+ if (isIndirectlyScoped)
6271
+ return where;
6272
+ return { ...where, tenantId: ctx.tenantId };
6273
+ }
6274
+ async function resolveIndirectTenantWhere(ctx) {
6275
+ if (!isIndirectlyScoped || !tenantChain || !queryParentIds)
6276
+ return null;
6277
+ if (!ctx.tenantId) {
6278
+ return { [tenantChain.hops[0].foreignKey]: { in: [] } };
6279
+ }
6280
+ const { hops, tenantColumn } = tenantChain;
6281
+ const lastHop = hops[hops.length - 1];
6282
+ let currentIds = await queryParentIds(lastHop.tableName, { [tenantColumn]: ctx.tenantId });
6283
+ for (let i = hops.length - 2;i >= 0; i--) {
6284
+ if (currentIds.length === 0)
6285
+ break;
6286
+ const hop = hops[i];
6287
+ const nextHop = hops[i + 1];
6288
+ currentIds = await queryParentIds(hop.tableName, {
6289
+ [nextHop.foreignKey]: { in: currentIds }
6290
+ });
6291
+ }
6292
+ return { [hops[0].foreignKey]: { in: currentIds } };
6293
+ }
6294
+ async function verifyIndirectTenantOwnership(ctx, row) {
6295
+ if (!isIndirectlyScoped || !tenantChain || !queryParentIds)
6296
+ return true;
6297
+ if (!ctx.tenantId)
6298
+ return false;
6299
+ const indirectWhere = await resolveIndirectTenantWhere(ctx);
6300
+ if (!indirectWhere)
6301
+ return false;
6302
+ const firstHopFK = tenantChain.hops[0].foreignKey;
6303
+ const allowed = indirectWhere[firstHopFK];
6304
+ if (!allowed)
6305
+ return false;
6306
+ return allowed.in.includes(row[firstHopFK]);
6307
+ }
5692
6308
  return {
5693
- async list(ctx, options) {
5694
- const accessResult = await enforceAccess("list", def.access, ctx);
6309
+ async list(ctx, options2) {
6310
+ const accessWhere = extractWhereConditions("list", def.access, ctx);
6311
+ const accessResult = await enforceAccess("list", def.access, ctx, undefined, {
6312
+ skipWhere: accessWhere !== null
6313
+ });
5695
6314
  if (!accessResult.ok)
5696
6315
  return err4(accessResult.error);
5697
- const rawWhere = options?.where;
6316
+ const rawWhere = options2?.where;
5698
6317
  const safeWhere = rawWhere ? stripHiddenFields(table, rawWhere) : undefined;
5699
- const where = safeWhere && Object.keys(safeWhere).length > 0 ? safeWhere : undefined;
5700
- const limit = Math.max(0, options?.limit ?? 20);
5701
- const after = options?.after && options.after.length <= 512 ? options.after : undefined;
5702
- const orderBy = options?.orderBy;
5703
- const { data: rows, total } = await db.list({ where, orderBy, limit, after });
5704
- const data = rows.map((row) => narrowRelationFields(def.relations, stripHiddenFields(table, row)));
6318
+ const cleanWhere = safeWhere && Object.keys(safeWhere).length > 0 ? safeWhere : undefined;
6319
+ const directWhere = withTenantFilter(ctx, { ...accessWhere, ...cleanWhere });
6320
+ const indirectWhere = await resolveIndirectTenantWhere(ctx);
6321
+ const where = indirectWhere ? { ...directWhere, ...indirectWhere } : directWhere;
6322
+ const limit = Math.max(0, options2?.limit ?? 20);
6323
+ const after = options2?.after && options2.after.length <= 512 ? options2.after : undefined;
6324
+ const orderBy = options2?.orderBy;
6325
+ const include = options2?.include;
6326
+ const { data: rows, total } = await db.list({ where, orderBy, limit, after, include });
6327
+ const data = rows.map((row) => applySelect(exposeSelect, narrowRelationFields(def.expose?.include ?? {}, stripHiddenFields(table, row))));
5705
6328
  const pkColumn = resolvePrimaryKeyColumn(table);
5706
6329
  const lastRow = rows[rows.length - 1];
5707
6330
  const nextCursor = limit > 0 && rows.length === limit && lastRow ? String(lastRow[pkColumn]) : null;
5708
6331
  const hasNextPage = nextCursor !== null;
5709
6332
  return ok4({ status: 200, body: { items: data, total, limit, nextCursor, hasNextPage } });
5710
6333
  },
5711
- async get(ctx, id) {
5712
- const row = await db.get(id);
5713
- if (!row) {
5714
- return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
6334
+ async get(ctx, id, options2) {
6335
+ const getOptions = options2?.include ? { include: options2.include } : undefined;
6336
+ const row = await db.get(id, getOptions);
6337
+ if (!row)
6338
+ return notFound(id);
6339
+ if (!isSameTenant(ctx, row))
6340
+ return notFound(id);
6341
+ if (isIndirectlyScoped && !await verifyIndirectTenantOwnership(ctx, row)) {
6342
+ return notFound(id);
5715
6343
  }
5716
6344
  const accessResult = await enforceAccess("get", def.access, ctx, row);
5717
6345
  if (!accessResult.ok)
5718
6346
  return err4(accessResult.error);
5719
6347
  return ok4({
5720
6348
  status: 200,
5721
- body: narrowRelationFields(def.relations, stripHiddenFields(table, row))
6349
+ body: applySelect(exposeSelect, narrowRelationFields(def.expose?.include ?? {}, stripHiddenFields(table, row)))
5722
6350
  });
5723
6351
  },
5724
6352
  async create(ctx, data) {
@@ -5726,6 +6354,29 @@ function createCrudHandlers(def, db) {
5726
6354
  if (!accessResult.ok)
5727
6355
  return err4(accessResult.error);
5728
6356
  let input = stripReadOnlyFields(table, data);
6357
+ if (isIndirectlyScoped && tenantChain && queryParentIds && ctx.tenantId) {
6358
+ const firstHop = tenantChain.hops[0];
6359
+ const parentId = input[firstHop.foreignKey];
6360
+ if (!parentId) {
6361
+ return err4(new EntityNotFoundError2(`Referenced parent not found: ${firstHop.foreignKey} is required`));
6362
+ }
6363
+ const parentExists = await queryParentIds(firstHop.tableName, {
6364
+ [firstHop.targetColumn]: parentId
6365
+ });
6366
+ if (parentExists.length === 0) {
6367
+ return err4(new EntityNotFoundError2(`Referenced parent not found: ${firstHop.tableName} with ${firstHop.targetColumn} "${parentId}" does not exist`));
6368
+ }
6369
+ const indirectWhere = await resolveIndirectTenantWhere(ctx);
6370
+ if (indirectWhere) {
6371
+ const allowed = indirectWhere[firstHop.foreignKey];
6372
+ if (!allowed || !allowed.in.includes(parentId)) {
6373
+ return err4(new EntityForbiddenError2(`Referenced ${firstHop.tableName} does not belong to your tenant`));
6374
+ }
6375
+ }
6376
+ }
6377
+ if (isTenantScoped && !isIndirectlyScoped && ctx.tenantId) {
6378
+ input = { ...input, tenantId: ctx.tenantId };
6379
+ }
5729
6380
  if (def.before.create) {
5730
6381
  input = await def.before.create(input, ctx);
5731
6382
  }
@@ -5736,12 +6387,19 @@ function createCrudHandlers(def, db) {
5736
6387
  await def.after.create(strippedResult, ctx);
5737
6388
  } catch {}
5738
6389
  }
5739
- return ok4({ status: 201, body: narrowRelationFields(def.relations, strippedResult) });
6390
+ return ok4({
6391
+ status: 201,
6392
+ body: applySelect(exposeSelect, narrowRelationFields(def.expose?.include ?? {}, strippedResult))
6393
+ });
5740
6394
  },
5741
6395
  async update(ctx, id, data) {
5742
6396
  const existing = await db.get(id);
5743
- if (!existing) {
5744
- return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
6397
+ if (!existing)
6398
+ return notFound(id);
6399
+ if (!isSameTenant(ctx, existing))
6400
+ return notFound(id);
6401
+ if (isIndirectlyScoped && !await verifyIndirectTenantOwnership(ctx, existing)) {
6402
+ return notFound(id);
5745
6403
  }
5746
6404
  const accessResult = await enforceAccess("update", def.access, ctx, existing);
5747
6405
  if (!accessResult.ok)
@@ -5760,13 +6418,17 @@ function createCrudHandlers(def, db) {
5760
6418
  }
5761
6419
  return ok4({
5762
6420
  status: 200,
5763
- body: narrowRelationFields(def.relations, strippedResult)
6421
+ body: applySelect(exposeSelect, narrowRelationFields(def.expose?.include ?? {}, strippedResult))
5764
6422
  });
5765
6423
  },
5766
6424
  async delete(ctx, id) {
5767
6425
  const existing = await db.get(id);
5768
- if (!existing) {
5769
- return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
6426
+ if (!existing)
6427
+ return notFound(id);
6428
+ if (!isSameTenant(ctx, existing))
6429
+ return notFound(id);
6430
+ if (isIndirectlyScoped && !await verifyIndirectTenantOwnership(ctx, existing)) {
6431
+ return notFound(id);
5770
6432
  }
5771
6433
  const accessResult = await enforceAccess("delete", def.access, ctx, existing);
5772
6434
  if (!accessResult.ok)
@@ -5844,17 +6506,104 @@ function entityErrorHandler(error) {
5844
6506
  };
5845
6507
  }
5846
6508
 
6509
+ // src/entity/expose-evaluator.ts
6510
+ async function evaluateExposeRule(rule, ctx, options) {
6511
+ switch (rule.type) {
6512
+ case "public":
6513
+ return true;
6514
+ case "authenticated":
6515
+ return ctx.authenticated();
6516
+ case "role":
6517
+ return ctx.role(...rule.roles);
6518
+ case "entitlement": {
6519
+ if (!options.can)
6520
+ return false;
6521
+ return options.can(rule.entitlement);
6522
+ }
6523
+ case "where":
6524
+ return false;
6525
+ case "all": {
6526
+ for (const sub of rule.rules) {
6527
+ if (!await evaluateExposeRule(sub, ctx, options))
6528
+ return false;
6529
+ }
6530
+ return true;
6531
+ }
6532
+ case "any": {
6533
+ for (const sub of rule.rules) {
6534
+ if (await evaluateExposeRule(sub, ctx, options))
6535
+ return true;
6536
+ }
6537
+ return false;
6538
+ }
6539
+ case "fva": {
6540
+ if (options.fvaAge === undefined)
6541
+ return false;
6542
+ return options.fvaAge <= rule.maxAge;
6543
+ }
6544
+ }
6545
+ }
6546
+ async function evaluateExposeDescriptors(expose, ctx, options = {}) {
6547
+ const allowedSelectFields = new Set;
6548
+ const nulledFields = new Set;
6549
+ const allowedWhereFields = new Set;
6550
+ const allowedOrderByFields = new Set;
6551
+ for (const [field, value] of Object.entries(expose.select)) {
6552
+ if (value === true) {
6553
+ allowedSelectFields.add(field);
6554
+ } else {
6555
+ const passed = await evaluateExposeRule(value, ctx, options);
6556
+ if (passed) {
6557
+ allowedSelectFields.add(field);
6558
+ } else {
6559
+ allowedSelectFields.add(field);
6560
+ nulledFields.add(field);
6561
+ }
6562
+ }
6563
+ }
6564
+ if (expose.allowWhere) {
6565
+ for (const [field, value] of Object.entries(expose.allowWhere)) {
6566
+ if (value === true) {
6567
+ allowedWhereFields.add(field);
6568
+ } else {
6569
+ const passed = await evaluateExposeRule(value, ctx, options);
6570
+ if (passed) {
6571
+ allowedWhereFields.add(field);
6572
+ }
6573
+ }
6574
+ }
6575
+ }
6576
+ if (expose.allowOrderBy) {
6577
+ for (const [field, value] of Object.entries(expose.allowOrderBy)) {
6578
+ if (value === true) {
6579
+ allowedOrderByFields.add(field);
6580
+ } else {
6581
+ const passed = await evaluateExposeRule(value, ctx, options);
6582
+ if (passed) {
6583
+ allowedOrderByFields.add(field);
6584
+ }
6585
+ }
6586
+ }
6587
+ }
6588
+ return {
6589
+ allowedSelectFields,
6590
+ nulledFields,
6591
+ allowedWhereFields,
6592
+ allowedOrderByFields
6593
+ };
6594
+ }
6595
+
5847
6596
  // src/entity/vertzql-parser.ts
6597
+ function extractAllowKeys(allow) {
6598
+ if (!allow)
6599
+ return [];
6600
+ if (Array.isArray(allow))
6601
+ return allow;
6602
+ return Object.keys(allow);
6603
+ }
5848
6604
  var MAX_LIMIT = 1000;
5849
6605
  var MAX_Q_BASE64_LENGTH = 10240;
5850
- var ALLOWED_Q_KEYS = new Set([
5851
- "select",
5852
- "include",
5853
- "where",
5854
- "orderBy",
5855
- "limit",
5856
- "offset"
5857
- ]);
6606
+ var ALLOWED_Q_KEYS = new Set(["select", "include", "where", "orderBy", "limit", "offset"]);
5858
6607
  function parseVertzQL(query) {
5859
6608
  const result = {};
5860
6609
  for (const [key, value] of Object.entries(query)) {
@@ -5937,49 +6686,143 @@ function getHiddenColumns(table) {
5937
6686
  }
5938
6687
  return hidden;
5939
6688
  }
5940
- function validateVertzQL(options, table, relationsConfig) {
6689
+ function validateVertzQL(options, table, relationsConfig, exposeConfig, evaluatedExpose) {
5941
6690
  if (options._qError) {
5942
6691
  return { ok: false, error: options._qError };
5943
6692
  }
5944
6693
  const hiddenColumns = getHiddenColumns(table);
5945
6694
  if (options.where) {
6695
+ const allowWhereSet = evaluatedExpose ? evaluatedExpose.allowedWhereFields : null;
6696
+ const allowWhereKeys = !evaluatedExpose && exposeConfig ? extractAllowKeys(exposeConfig.allowWhere) : null;
5946
6697
  for (const field of Object.keys(options.where)) {
5947
6698
  if (hiddenColumns.has(field)) {
5948
6699
  return { ok: false, error: `Field "${field}" is not filterable` };
5949
6700
  }
6701
+ if (allowWhereSet !== null && !allowWhereSet.has(field)) {
6702
+ return { ok: false, error: `Field "${field}" is not filterable` };
6703
+ }
6704
+ if (allowWhereKeys !== null && !allowWhereKeys.includes(field)) {
6705
+ return { ok: false, error: `Field "${field}" is not filterable` };
6706
+ }
5950
6707
  }
5951
6708
  }
5952
6709
  if (options.orderBy) {
6710
+ const allowOrderBySet = evaluatedExpose ? evaluatedExpose.allowedOrderByFields : null;
6711
+ const allowOrderByKeys = !evaluatedExpose && exposeConfig ? extractAllowKeys(exposeConfig.allowOrderBy) : null;
5953
6712
  for (const field of Object.keys(options.orderBy)) {
5954
6713
  if (hiddenColumns.has(field)) {
5955
6714
  return { ok: false, error: `Field "${field}" is not sortable` };
5956
6715
  }
6716
+ if (allowOrderBySet !== null && !allowOrderBySet.has(field)) {
6717
+ return { ok: false, error: `Field "${field}" is not sortable` };
6718
+ }
6719
+ if (allowOrderByKeys !== null && !allowOrderByKeys.includes(field)) {
6720
+ return { ok: false, error: `Field "${field}" is not sortable` };
6721
+ }
5957
6722
  }
5958
6723
  }
5959
6724
  if (options.select) {
6725
+ const exposeSelectKeys = exposeConfig ? extractAllowKeys(exposeConfig.select) : null;
5960
6726
  for (const field of Object.keys(options.select)) {
5961
6727
  if (hiddenColumns.has(field)) {
5962
6728
  return { ok: false, error: `Field "${field}" is not selectable` };
5963
6729
  }
6730
+ if (exposeSelectKeys !== null && exposeSelectKeys.length > 0 && !exposeSelectKeys.includes(field)) {
6731
+ return { ok: false, error: `Field "${field}" is not selectable` };
6732
+ }
5964
6733
  }
5965
6734
  }
5966
6735
  if (options.include && relationsConfig) {
5967
- for (const [relation, requested] of Object.entries(options.include)) {
5968
- const entityConfig = relationsConfig[relation];
5969
- if (entityConfig === undefined || entityConfig === false) {
5970
- return { ok: false, error: `Relation "${relation}" is not exposed` };
5971
- }
5972
- if (typeof entityConfig === "object" && typeof requested === "object") {
5973
- for (const field of Object.keys(requested)) {
5974
- if (!(field in entityConfig)) {
5975
- return {
5976
- ok: false,
5977
- error: `Field "${field}" is not exposed on relation "${relation}"`
5978
- };
5979
- }
6736
+ const includeResult = validateInclude(options.include, relationsConfig, "");
6737
+ if (!includeResult.ok)
6738
+ return includeResult;
6739
+ }
6740
+ return { ok: true };
6741
+ }
6742
+ function validateInclude(include, relationsConfig, pathPrefix) {
6743
+ for (const [relation, requested] of Object.entries(include)) {
6744
+ const entityConfig = relationsConfig[relation];
6745
+ const relationPath = pathPrefix ? `${pathPrefix}.${relation}` : relation;
6746
+ if (entityConfig === undefined || entityConfig === false) {
6747
+ return { ok: false, error: `Relation "${relationPath}" is not exposed` };
6748
+ }
6749
+ if (requested === true)
6750
+ continue;
6751
+ const configObj = typeof entityConfig === "object" ? entityConfig : undefined;
6752
+ if (requested.where) {
6753
+ const allowWhereKeys = extractAllowKeys(configObj?.allowWhere);
6754
+ if (!configObj || allowWhereKeys.length === 0) {
6755
+ return {
6756
+ ok: false,
6757
+ error: `Filtering is not enabled on relation '${relationPath}'. ` + "Add 'allowWhere' to the entity relations config."
6758
+ };
6759
+ }
6760
+ const allowedSet = new Set(allowWhereKeys);
6761
+ for (const field of Object.keys(requested.where)) {
6762
+ if (!allowedSet.has(field)) {
6763
+ return {
6764
+ ok: false,
6765
+ error: `Field '${field}' is not filterable on relation '${relationPath}'. ` + `Allowed: ${allowWhereKeys.join(", ")}`
6766
+ };
6767
+ }
6768
+ }
6769
+ }
6770
+ if (requested.orderBy) {
6771
+ const allowOrderByKeys = extractAllowKeys(configObj?.allowOrderBy);
6772
+ if (!configObj || allowOrderByKeys.length === 0) {
6773
+ return {
6774
+ ok: false,
6775
+ error: `Sorting is not enabled on relation '${relationPath}'. ` + "Add 'allowOrderBy' to the entity relations config."
6776
+ };
6777
+ }
6778
+ const allowedSet = new Set(allowOrderByKeys);
6779
+ for (const [field, dir] of Object.entries(requested.orderBy)) {
6780
+ if (!allowedSet.has(field)) {
6781
+ return {
6782
+ ok: false,
6783
+ error: `Field '${field}' is not sortable on relation '${relationPath}'. ` + `Allowed: ${allowOrderByKeys.join(", ")}`
6784
+ };
6785
+ }
6786
+ if (dir !== "asc" && dir !== "desc") {
6787
+ return {
6788
+ ok: false,
6789
+ error: `Invalid orderBy direction '${String(dir)}' for field '${field}' on relation '${relationPath}'. Must be 'asc' or 'desc'.`
6790
+ };
6791
+ }
6792
+ }
6793
+ }
6794
+ if (requested.limit !== undefined) {
6795
+ if (typeof requested.limit !== "number" || !Number.isFinite(requested.limit)) {
6796
+ return {
6797
+ ok: false,
6798
+ error: `Invalid limit on relation '${relationPath}': must be a finite number`
6799
+ };
6800
+ }
6801
+ if (requested.limit < 0) {
6802
+ requested.limit = 0;
6803
+ }
6804
+ if (configObj?.maxLimit !== undefined && requested.limit > configObj.maxLimit) {
6805
+ requested.limit = configObj.maxLimit;
6806
+ }
6807
+ }
6808
+ if (requested.select && configObj?.select) {
6809
+ for (const field of Object.keys(requested.select)) {
6810
+ if (!(field in configObj.select)) {
6811
+ return {
6812
+ ok: false,
6813
+ error: `Field "${field}" is not exposed on relation "${relationPath}"`
6814
+ };
5980
6815
  }
5981
6816
  }
5982
6817
  }
6818
+ if (requested.include) {
6819
+ if (entityConfig === true) {
6820
+ return {
6821
+ ok: false,
6822
+ error: `Nested includes are not supported on relation '${relationPath}' ` + "without a structured relations config."
6823
+ };
6824
+ }
6825
+ }
5983
6826
  }
5984
6827
  return { ok: true };
5985
6828
  }
@@ -6004,10 +6847,31 @@ function extractRequestInfo(ctx) {
6004
6847
  function getParams(ctx) {
6005
6848
  return ctx.params ?? {};
6006
6849
  }
6850
+ function hasDescriptorValues(record) {
6851
+ if (!record)
6852
+ return false;
6853
+ return Object.values(record).some((v) => v !== true);
6854
+ }
6855
+ function hasDescriptors(expose) {
6856
+ return hasDescriptorValues(expose.select) || hasDescriptorValues(expose.allowWhere) || hasDescriptorValues(expose.allowOrderBy);
6857
+ }
6858
+ function applyNulling(data, nulledFields) {
6859
+ return nullGuardedFields(nulledFields, data);
6860
+ }
6007
6861
  function generateEntityRoutes(def, registry, db, options) {
6008
6862
  const prefix = options?.apiPrefix ?? "/api";
6009
6863
  const basePath = `${prefix}/${def.name}`;
6010
- const crudHandlers = createCrudHandlers(def, db);
6864
+ const tenantChain = options?.tenantChain ?? null;
6865
+ const exposeValidation = def.expose ? {
6866
+ select: def.expose.select,
6867
+ allowWhere: def.expose.allowWhere,
6868
+ allowOrderBy: def.expose.allowOrderBy
6869
+ } : undefined;
6870
+ const exposeEvalConfig = def.expose ? hasDescriptors(def.expose) ? def.expose : null : null;
6871
+ const crudHandlers = createCrudHandlers(def, db, {
6872
+ tenantChain,
6873
+ queryParentIds: options?.queryParentIds
6874
+ });
6011
6875
  const inject = def.inject ?? {};
6012
6876
  const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
6013
6877
  const routes = [];
@@ -6037,8 +6901,9 @@ function generateEntityRoutes(def, registry, db, options) {
6037
6901
  const entityCtx = makeEntityCtx(ctx);
6038
6902
  const query = ctx.query ?? {};
6039
6903
  const parsed = parseVertzQL(query);
6040
- const relationsConfig = def.relations;
6041
- const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
6904
+ const evaluated = exposeEvalConfig ? await evaluateExposeDescriptors(exposeEvalConfig, entityCtx) : null;
6905
+ const relationsConfig = def.expose?.include ?? {};
6906
+ const validation = validateVertzQL(parsed, def.model.table, relationsConfig, exposeValidation, evaluated ?? undefined);
6042
6907
  if (!validation.ok) {
6043
6908
  return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
6044
6909
  }
@@ -6046,13 +6911,17 @@ function generateEntityRoutes(def, registry, db, options) {
6046
6911
  where: parsed.where ? parsed.where : undefined,
6047
6912
  orderBy: parsed.orderBy,
6048
6913
  limit: parsed.limit,
6049
- after: parsed.after
6914
+ after: parsed.after,
6915
+ include: parsed.include
6050
6916
  };
6051
6917
  const result = await crudHandlers.list(entityCtx, options2);
6052
6918
  if (!result.ok) {
6053
6919
  const { status, body } = entityErrorHandler(result.error);
6054
6920
  return jsonResponse(body, status);
6055
6921
  }
6922
+ if (evaluated && evaluated.nulledFields.size > 0 && result.data.body.items) {
6923
+ result.data.body.items = result.data.body.items.map((row) => applyNulling(row, evaluated.nulledFields));
6924
+ }
6056
6925
  if (parsed.select && result.data.body.items) {
6057
6926
  result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
6058
6927
  }
@@ -6079,8 +6948,9 @@ function generateEntityRoutes(def, registry, db, options) {
6079
6948
  select: body.select,
6080
6949
  include: body.include
6081
6950
  };
6082
- const relationsConfig = def.relations;
6083
- const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
6951
+ const evaluated = exposeEvalConfig ? await evaluateExposeDescriptors(exposeEvalConfig, entityCtx) : null;
6952
+ const relationsConfig = def.expose?.include ?? {};
6953
+ const validation = validateVertzQL(parsed, def.model.table, relationsConfig, exposeValidation, evaluated ?? undefined);
6084
6954
  if (!validation.ok) {
6085
6955
  return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
6086
6956
  }
@@ -6088,13 +6958,17 @@ function generateEntityRoutes(def, registry, db, options) {
6088
6958
  where: parsed.where,
6089
6959
  orderBy: parsed.orderBy,
6090
6960
  limit: parsed.limit,
6091
- after: parsed.after
6961
+ after: parsed.after,
6962
+ include: parsed.include
6092
6963
  };
6093
6964
  const result = await crudHandlers.list(entityCtx, options2);
6094
6965
  if (!result.ok) {
6095
6966
  const { status, body: errBody } = entityErrorHandler(result.error);
6096
6967
  return jsonResponse(errBody, status);
6097
6968
  }
6969
+ if (evaluated && evaluated.nulledFields.size > 0 && result.data.body.items) {
6970
+ result.data.body.items = result.data.body.items.map((row) => applyNulling(row, evaluated.nulledFields));
6971
+ }
6098
6972
  if (parsed.select && result.data.body.items) {
6099
6973
  result.data.body.items = result.data.body.items.map((row) => applySelect(parsed.select, row));
6100
6974
  }
@@ -6128,17 +7002,23 @@ function generateEntityRoutes(def, registry, db, options) {
6128
7002
  const id = getParams(ctx).id;
6129
7003
  const query = ctx.query ?? {};
6130
7004
  const parsed = parseVertzQL(query);
6131
- const relationsConfig = def.relations;
6132
- const validation = validateVertzQL(parsed, def.model.table, relationsConfig);
7005
+ const evaluated = exposeEvalConfig ? await evaluateExposeDescriptors(exposeEvalConfig, entityCtx) : null;
7006
+ const relationsConfig = def.expose?.include ?? {};
7007
+ const validation = validateVertzQL(parsed, def.model.table, relationsConfig, exposeValidation, evaluated ?? undefined);
6133
7008
  if (!validation.ok) {
6134
7009
  return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
6135
7010
  }
6136
- const result = await crudHandlers.get(entityCtx, id);
7011
+ const getOptions = parsed.include ? { include: parsed.include } : undefined;
7012
+ const result = await crudHandlers.get(entityCtx, id, getOptions);
6137
7013
  if (!result.ok) {
6138
7014
  const { status, body: body2 } = entityErrorHandler(result.error);
6139
7015
  return jsonResponse(body2, status);
6140
7016
  }
6141
- const body = parsed.select ? applySelect(parsed.select, result.data.body) : result.data.body;
7017
+ let responseBody = result.data.body;
7018
+ if (evaluated && evaluated.nulledFields.size > 0) {
7019
+ responseBody = applyNulling(responseBody, evaluated.nulledFields);
7020
+ }
7021
+ const body = parsed.select ? applySelect(parsed.select, responseBody) : responseBody;
6142
7022
  return jsonResponse(body, result.data.status);
6143
7023
  } catch (error) {
6144
7024
  const { status, body } = entityErrorHandler(error);
@@ -6173,7 +7053,14 @@ function generateEntityRoutes(def, registry, db, options) {
6173
7053
  const { status, body } = entityErrorHandler(result.error);
6174
7054
  return jsonResponse(body, status);
6175
7055
  }
6176
- return jsonResponse(result.data.body, result.data.status);
7056
+ let responseBody = result.data.body;
7057
+ if (exposeEvalConfig) {
7058
+ const evaluated = await evaluateExposeDescriptors(exposeEvalConfig, entityCtx);
7059
+ if (evaluated.nulledFields.size > 0) {
7060
+ responseBody = applyNulling(responseBody, evaluated.nulledFields);
7061
+ }
7062
+ }
7063
+ return jsonResponse(responseBody, result.data.status);
6177
7064
  } catch (error) {
6178
7065
  const { status, body } = entityErrorHandler(error);
6179
7066
  return jsonResponse(body, status);
@@ -6208,7 +7095,14 @@ function generateEntityRoutes(def, registry, db, options) {
6208
7095
  const { status, body } = entityErrorHandler(result.error);
6209
7096
  return jsonResponse(body, status);
6210
7097
  }
6211
- return jsonResponse(result.data.body, result.data.status);
7098
+ let responseBody = result.data.body;
7099
+ if (exposeEvalConfig) {
7100
+ const evaluated = await evaluateExposeDescriptors(exposeEvalConfig, entityCtx);
7101
+ if (evaluated.nulledFields.size > 0) {
7102
+ responseBody = applyNulling(responseBody, evaluated.nulledFields);
7103
+ }
7104
+ }
7105
+ return jsonResponse(responseBody, result.data.status);
6212
7106
  } catch (error) {
6213
7107
  const { status, body } = entityErrorHandler(error);
6214
7108
  return jsonResponse(body, status);
@@ -6298,13 +7192,106 @@ function generateEntityRoutes(def, registry, db, options) {
6298
7192
  return routes;
6299
7193
  }
6300
7194
 
7195
+ // src/entity/tenant-chain.ts
7196
+ function resolveTenantChain(entityKey, tenantGraph, registry) {
7197
+ if (!tenantGraph.indirectlyScoped.includes(entityKey)) {
7198
+ return null;
7199
+ }
7200
+ if (tenantGraph.root === null) {
7201
+ return null;
7202
+ }
7203
+ const rootEntry = registry[tenantGraph.root];
7204
+ if (!rootEntry)
7205
+ return null;
7206
+ const rootTableName = rootEntry.table._name;
7207
+ const tableNameToKey = new Map;
7208
+ for (const [key, entry] of Object.entries(registry)) {
7209
+ tableNameToKey.set(entry.table._name, key);
7210
+ }
7211
+ const directlyScopedTableNames = new Set;
7212
+ for (const key of tenantGraph.directlyScoped) {
7213
+ const entry = registry[key];
7214
+ if (entry)
7215
+ directlyScopedTableNames.add(entry.table._name);
7216
+ }
7217
+ directlyScopedTableNames.add(rootTableName);
7218
+ const hops = [];
7219
+ let currentKey = entityKey;
7220
+ const visited = new Set;
7221
+ while (true) {
7222
+ if (visited.has(currentKey)) {
7223
+ return null;
7224
+ }
7225
+ visited.add(currentKey);
7226
+ const currentEntry = registry[currentKey];
7227
+ if (!currentEntry)
7228
+ return null;
7229
+ let foundHop = false;
7230
+ for (const [, rel] of Object.entries(currentEntry.relations)) {
7231
+ if (rel._type !== "one" || !rel._foreignKey)
7232
+ continue;
7233
+ const targetTableName = rel._target()._name;
7234
+ const targetKey = tableNameToKey.get(targetTableName);
7235
+ if (!targetKey)
7236
+ continue;
7237
+ const targetIsDirectlyScoped = directlyScopedTableNames.has(targetTableName);
7238
+ const targetIsIndirectlyScoped = tenantGraph.indirectlyScoped.includes(targetKey);
7239
+ if (!targetIsDirectlyScoped && !targetIsIndirectlyScoped)
7240
+ continue;
7241
+ const targetEntry = registry[targetKey];
7242
+ if (!targetEntry)
7243
+ continue;
7244
+ const targetPk = resolvePrimaryKey(targetEntry.table._columns);
7245
+ hops.push({
7246
+ tableName: targetTableName,
7247
+ foreignKey: rel._foreignKey,
7248
+ targetColumn: targetPk
7249
+ });
7250
+ if (targetIsDirectlyScoped && targetKey !== tenantGraph.root) {
7251
+ const tenantColumn = resolveTenantFk(targetKey, registry);
7252
+ if (!tenantColumn)
7253
+ return null;
7254
+ return { hops, tenantColumn };
7255
+ }
7256
+ if (targetKey === tenantGraph.root) {
7257
+ return { hops, tenantColumn: rel._foreignKey };
7258
+ }
7259
+ currentKey = targetKey;
7260
+ foundHop = true;
7261
+ break;
7262
+ }
7263
+ if (!foundHop)
7264
+ return null;
7265
+ }
7266
+ }
7267
+ function resolvePrimaryKey(columns) {
7268
+ for (const [key, col] of Object.entries(columns)) {
7269
+ if (col && typeof col === "object" && "_meta" in col) {
7270
+ const meta = col._meta;
7271
+ if (meta.primary)
7272
+ return key;
7273
+ }
7274
+ }
7275
+ return "id";
7276
+ }
7277
+ function resolveTenantFk(modelKey, registry) {
7278
+ const entry = registry[modelKey];
7279
+ if (!entry || !entry._tenant)
7280
+ return null;
7281
+ const tenantRel = entry.relations[entry._tenant];
7282
+ if (!tenantRel || !tenantRel._foreignKey)
7283
+ return null;
7284
+ return tenantRel._foreignKey;
7285
+ }
7286
+
6301
7287
  // src/service/context.ts
6302
- function createServiceContext(request, registryProxy) {
7288
+ function createServiceContext(request, registryProxy, rawRequest) {
6303
7289
  const userId = request.userId ?? null;
6304
7290
  const roles = request.roles ?? [];
6305
7291
  const tenantId = request.tenantId ?? null;
6306
7292
  return {
6307
7293
  userId,
7294
+ tenantId,
6308
7295
  authenticated() {
6309
7296
  return userId !== null;
6310
7297
  },
@@ -6314,7 +7301,8 @@ function createServiceContext(request, registryProxy) {
6314
7301
  role(...rolesToCheck) {
6315
7302
  return rolesToCheck.some((r) => roles.includes(r));
6316
7303
  },
6317
- entities: registryProxy
7304
+ entities: registryProxy,
7305
+ request: rawRequest ?? { url: "", method: "", headers: new Headers, body: undefined }
6318
7306
  };
6319
7307
  }
6320
7308
 
@@ -6363,23 +7351,56 @@ function generateServiceRoutes(def, registry, options) {
6363
7351
  handler: async (ctx) => {
6364
7352
  try {
6365
7353
  const requestInfo = extractRequestInfo2(ctx);
6366
- const serviceCtx = createServiceContext(requestInfo, registryProxy);
7354
+ const raw = ctx.raw;
7355
+ const ctxHeaders = ctx.headers;
7356
+ const rawRequest = {
7357
+ url: raw?.url ?? "",
7358
+ method: raw?.method ?? "",
7359
+ headers: new Headers(ctxHeaders ?? {}),
7360
+ body: ctx.body
7361
+ };
7362
+ const serviceCtx = createServiceContext(requestInfo, registryProxy, rawRequest);
6367
7363
  const accessResult = await enforceAccess(handlerName, def.access, serviceCtx);
6368
7364
  if (!accessResult.ok) {
6369
7365
  return jsonResponse2({ error: { code: "Forbidden", message: accessResult.error.message } }, 403);
6370
7366
  }
6371
- const rawBody = ctx.body ?? {};
6372
- const parsed = handlerDef.body.parse(rawBody);
6373
- if (!parsed.ok) {
6374
- const message = parsed.error instanceof Error ? parsed.error.message : "Invalid request body";
6375
- return jsonResponse2({ error: { code: "BadRequest", message } }, 400);
7367
+ if (handlerDef.body && isContentDescriptor(handlerDef.body)) {
7368
+ const reqContentType = rawRequest.headers.get("content-type")?.toLowerCase() ?? "";
7369
+ const expected = handlerDef.body._contentType;
7370
+ const isXml = expected === "application/xml";
7371
+ const matches = isXml ? reqContentType.includes("application/xml") || reqContentType.includes("text/xml") : reqContentType.includes(expected);
7372
+ if (reqContentType && !matches) {
7373
+ return jsonResponse2({
7374
+ error: {
7375
+ code: "UnsupportedMediaType",
7376
+ message: `Expected content-type ${expected}, got ${reqContentType}`
7377
+ }
7378
+ }, 415);
7379
+ }
6376
7380
  }
6377
- const result = await handlerDef.handler(parsed.data, serviceCtx);
7381
+ let input;
7382
+ if (handlerDef.body) {
7383
+ const rawBody = ctx.body ?? {};
7384
+ const parsed = handlerDef.body.parse(rawBody);
7385
+ if (!parsed.ok) {
7386
+ const message = parsed.error instanceof Error ? parsed.error.message : "Invalid request body";
7387
+ return jsonResponse2({ error: { code: "BadRequest", message } }, 400);
7388
+ }
7389
+ input = parsed.data;
7390
+ }
7391
+ const result = await handlerDef.handler(input, serviceCtx);
6378
7392
  const responseParsed = handlerDef.response.parse(result);
6379
7393
  if (!responseParsed.ok) {
6380
7394
  const message = responseParsed.error instanceof Error ? responseParsed.error.message : "Response validation failed";
6381
7395
  console.warn(`[vertz] Service response validation warning: ${message}`);
6382
7396
  }
7397
+ if (isContentDescriptor(handlerDef.response)) {
7398
+ const body = result instanceof Uint8Array ? result : String(result);
7399
+ return new Response(body, {
7400
+ status: 200,
7401
+ headers: { "content-type": handlerDef.response._contentType }
7402
+ });
7403
+ }
6383
7404
  return jsonResponse2(result, 200);
6384
7405
  } catch (error) {
6385
7406
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -6459,20 +7480,55 @@ function createServer(config) {
6459
7480
  }
6460
7481
  if (config.entities && config.entities.length > 0) {
6461
7482
  let dbFactory;
7483
+ const tableOf = (e) => e.table ?? e.name;
6462
7484
  if (hasDbClient) {
6463
7485
  const dbModels = db._internals.models;
6464
- const missing = config.entities.filter((e) => !(e.name in dbModels)).map((e) => `"${e.name}"`);
6465
- if (missing.length > 0) {
7486
+ const missing = config.entities.filter((e) => !(tableOf(e) in dbModels)).map((e) => `"${tableOf(e)}"`);
7487
+ const uniqueMissing = [...new Set(missing)];
7488
+ if (uniqueMissing.length > 0) {
6466
7489
  const registered = Object.keys(dbModels).map((k) => `"${k}"`).join(", ");
6467
- const plural = missing.length > 1;
6468
- 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)"}`);
7490
+ const plural = uniqueMissing.length > 1;
7491
+ throw new Error(`${plural ? "Entities" : "Entity"} ${uniqueMissing.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)"}`);
6469
7492
  }
6470
- dbFactory = (entityDef) => createDatabaseBridgeAdapter(db, entityDef.name);
7493
+ dbFactory = (entityDef) => createDatabaseBridgeAdapter(db, tableOf(entityDef));
6471
7494
  } else if (db) {
6472
7495
  dbFactory = () => db;
6473
7496
  } else {
6474
7497
  dbFactory = config._entityDbFactory ?? createNoopDbAdapter;
6475
7498
  }
7499
+ const tenantChains = new Map;
7500
+ let queryParentIds;
7501
+ if (hasDbClient) {
7502
+ const dbClient = db;
7503
+ const tenantGraph = dbClient._internals.tenantGraph;
7504
+ const dbModelsMap = dbClient._internals.models;
7505
+ for (const entityDef of config.entities) {
7506
+ const eDef = entityDef;
7507
+ if (eDef.tenantScoped === false)
7508
+ continue;
7509
+ const modelKey = tableOf(eDef);
7510
+ const chain = resolveTenantChain(modelKey, tenantGraph, dbModelsMap);
7511
+ if (chain) {
7512
+ tenantChains.set(eDef.name, chain);
7513
+ }
7514
+ }
7515
+ if (tenantChains.size > 0) {
7516
+ queryParentIds = async (tableName, where) => {
7517
+ const delegate = dbClient[tableName];
7518
+ if (!delegate)
7519
+ return [];
7520
+ const result = await delegate.list({ where });
7521
+ if (!result.ok || !result.data)
7522
+ return [];
7523
+ return result.data.map((row) => row.id);
7524
+ };
7525
+ }
7526
+ }
7527
+ if (config._tenantChains) {
7528
+ for (const [name, chain] of config._tenantChains) {
7529
+ tenantChains.set(name, chain);
7530
+ }
7531
+ }
6476
7532
  for (const entityDef of config.entities) {
6477
7533
  const entityDb = dbFactory(entityDef);
6478
7534
  const ops = createEntityOps(entityDef, entityDb);
@@ -6480,8 +7536,11 @@ function createServer(config) {
6480
7536
  }
6481
7537
  for (const entityDef of config.entities) {
6482
7538
  const entityDb = dbFactory(entityDef);
7539
+ const tenantChain = tenantChains.get(entityDef.name) ?? null;
6483
7540
  const routes = generateEntityRoutes(entityDef, registry, entityDb, {
6484
- apiPrefix
7541
+ apiPrefix,
7542
+ tenantChain,
7543
+ queryParentIds: tenantChain ? queryParentIds ?? config._queryParentIds : undefined
6485
7544
  });
6486
7545
  allRoutes.push(...routes);
6487
7546
  }
@@ -6501,15 +7560,41 @@ function createServer(config) {
6501
7560
  const authConfig = {
6502
7561
  ...config.auth,
6503
7562
  userStore: config.auth.userStore ?? new DbUserStore(dbClient),
6504
- sessionStore: config.auth.sessionStore ?? new DbSessionStore(dbClient)
7563
+ sessionStore: config.auth.sessionStore ?? new DbSessionStore(dbClient),
7564
+ oauthAccountStore: config.auth.oauthAccountStore ?? (config.auth.providers?.length ? new DbOAuthAccountStore(dbClient) : undefined),
7565
+ _entityProxy: config.auth.onUserCreated ? config.auth._entityProxy ?? registry.createProxy() : undefined
6505
7566
  };
6506
7567
  const auth = createAuth(authConfig);
7568
+ if (apiPrefix !== "/api") {
7569
+ throw new Error(`requestHandler requires apiPrefix to be '/api' (got '${apiPrefix}'). ` + "Custom API prefixes are not yet supported with auth.");
7570
+ }
7571
+ const authPrefix = `${apiPrefix}/auth`;
7572
+ const authPrefixSlash = `${authPrefix}/`;
6507
7573
  const serverInstance = app;
6508
7574
  serverInstance.auth = auth;
6509
7575
  serverInstance.initialize = async () => {
6510
7576
  await initializeAuthTables(dbClient);
6511
7577
  await auth.initialize();
6512
7578
  };
7579
+ let cachedRequestHandler = null;
7580
+ Object.defineProperty(serverInstance, "requestHandler", {
7581
+ get() {
7582
+ if (!cachedRequestHandler) {
7583
+ const entityHandler = this.handler;
7584
+ const authHandler = this.auth.handler;
7585
+ cachedRequestHandler = (request) => {
7586
+ const pathname = new URL(request.url).pathname;
7587
+ if (pathname === authPrefix || pathname.startsWith(authPrefixSlash)) {
7588
+ return authHandler(request);
7589
+ }
7590
+ return entityHandler(request);
7591
+ };
7592
+ }
7593
+ return cachedRequestHandler;
7594
+ },
7595
+ enumerable: true,
7596
+ configurable: false
7597
+ });
6513
7598
  return serverInstance;
6514
7599
  }
6515
7600
  return app;
@@ -6524,6 +7609,8 @@ function entity(name, config) {
6524
7609
  if (!config.model) {
6525
7610
  throw new Error("entity() requires a model in the config.");
6526
7611
  }
7612
+ const hasTenantIdColumn = "tenantId" in config.model.table._columns;
7613
+ const tenantScoped = config.tenantScoped ?? hasTenantIdColumn;
6527
7614
  const def = {
6528
7615
  kind: "entity",
6529
7616
  name,
@@ -6533,7 +7620,10 @@ function entity(name, config) {
6533
7620
  before: config.before ?? {},
6534
7621
  after: config.after ?? {},
6535
7622
  actions: config.actions ?? {},
6536
- relations: config.relations ?? {}
7623
+ expose: config.expose,
7624
+ table: config.table ?? name,
7625
+ tenantScoped,
7626
+ tenantChain: null
6537
7627
  };
6538
7628
  return deepFreeze(def);
6539
7629
  }
@@ -6566,7 +7656,9 @@ export {
6566
7656
  stripHiddenFields,
6567
7657
  service,
6568
7658
  rules,
7659
+ resolveTenantChain,
6569
7660
  makeImmutable,
7661
+ isContentDescriptor,
6570
7662
  initializeAuthTables,
6571
7663
  hashPassword,
6572
7664
  google,
@@ -6596,6 +7688,7 @@ export {
6596
7688
  createAccessEventBroadcaster,
6597
7689
  createAccessContext,
6598
7690
  createAccess,
7691
+ content,
6599
7692
  computePlanHash,
6600
7693
  computeOverage,
6601
7694
  computeEntityAccess,
@@ -6612,11 +7705,11 @@ export {
6612
7705
  InternalServerErrorException,
6613
7706
  InMemoryWalletStore,
6614
7707
  InMemoryUserStore,
7708
+ InMemorySubscriptionStore,
6615
7709
  InMemorySessionStore,
6616
7710
  InMemoryRoleAssignmentStore,
6617
7711
  InMemoryRateLimitStore,
6618
7712
  InMemoryPlanVersionStore,
6619
- InMemoryPlanStore,
6620
7713
  InMemoryPasswordResetStore,
6621
7714
  InMemoryOverrideStore,
6622
7715
  InMemoryOAuthAccountStore,
@@ -6628,14 +7721,14 @@ export {
6628
7721
  ForbiddenException,
6629
7722
  EntityRegistry,
6630
7723
  DbUserStore,
7724
+ DbSubscriptionStore,
6631
7725
  DbSessionStore,
6632
7726
  DbRoleAssignmentStore,
6633
- DbPlanStore,
6634
7727
  DbOAuthAccountStore,
6635
7728
  DbFlagStore,
6636
7729
  DbClosureStore,
6637
7730
  ConflictException,
6638
- BadRequestException,
7731
+ BadRequestException2 as BadRequestException,
6639
7732
  AuthorizationError,
6640
7733
  AUTH_TABLE_NAMES
6641
7734
  };