@vertz/server 0.2.13 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +671 -102
- package/dist/index.js +2139 -208
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -40,7 +40,13 @@ import {
|
|
|
40
40
|
// src/auth/billing-period.ts
|
|
41
41
|
function calculateBillingPeriod(startedAt, per, now = new Date) {
|
|
42
42
|
if (per === "month") {
|
|
43
|
-
return
|
|
43
|
+
return calculateMultiMonthPeriod(startedAt, now, 1);
|
|
44
|
+
}
|
|
45
|
+
if (per === "quarter") {
|
|
46
|
+
return calculateMultiMonthPeriod(startedAt, now, 3);
|
|
47
|
+
}
|
|
48
|
+
if (per === "year") {
|
|
49
|
+
return calculateMultiMonthPeriod(startedAt, now, 12);
|
|
44
50
|
}
|
|
45
51
|
const durationMs = per === "day" ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
|
46
52
|
const elapsed = now.getTime() - startedAt.getTime();
|
|
@@ -49,19 +55,21 @@ function calculateBillingPeriod(startedAt, per, now = new Date) {
|
|
|
49
55
|
const periodEnd = new Date(periodStart.getTime() + durationMs);
|
|
50
56
|
return { periodStart, periodEnd };
|
|
51
57
|
}
|
|
52
|
-
function
|
|
58
|
+
function calculateMultiMonthPeriod(startedAt, now, monthInterval) {
|
|
53
59
|
if (now.getTime() < startedAt.getTime()) {
|
|
54
|
-
return { periodStart: new Date(startedAt), periodEnd: addMonths(startedAt,
|
|
60
|
+
return { periodStart: new Date(startedAt), periodEnd: addMonths(startedAt, monthInterval) };
|
|
55
61
|
}
|
|
56
|
-
let periodStart = new Date(startedAt);
|
|
57
62
|
const approxMonths = (now.getUTCFullYear() - startedAt.getUTCFullYear()) * 12 + (now.getUTCMonth() - startedAt.getUTCMonth());
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
const periodsElapsed = Math.floor(approxMonths / monthInterval);
|
|
64
|
+
let periodStart = addMonths(startedAt, periodsElapsed * monthInterval);
|
|
65
|
+
if (periodStart.getTime() > now.getTime()) {
|
|
66
|
+
periodStart = addMonths(startedAt, (periodsElapsed - 1) * monthInterval);
|
|
67
|
+
}
|
|
68
|
+
const nextStart = addMonths(startedAt, (periodsElapsed + 1) * monthInterval);
|
|
69
|
+
if (nextStart.getTime() <= now.getTime()) {
|
|
70
|
+
periodStart = nextStart;
|
|
63
71
|
}
|
|
64
|
-
const periodEnd = addMonths(startedAt, getMonthOffset(startedAt, periodStart) +
|
|
72
|
+
const periodEnd = addMonths(startedAt, getMonthOffset(startedAt, periodStart) + monthInterval);
|
|
65
73
|
return { periodStart, periodEnd };
|
|
66
74
|
}
|
|
67
75
|
function addMonths(base, months) {
|
|
@@ -92,6 +100,7 @@ function resolveEffectivePlan(orgPlan, plans, defaultPlan = "free", now) {
|
|
|
92
100
|
|
|
93
101
|
class InMemoryPlanStore {
|
|
94
102
|
plans = new Map;
|
|
103
|
+
addOns = new Map;
|
|
95
104
|
async assignPlan(orgId, planId, startedAt = new Date, expiresAt = null) {
|
|
96
105
|
this.plans.set(orgId, {
|
|
97
106
|
orgId,
|
|
@@ -113,9 +122,58 @@ class InMemoryPlanStore {
|
|
|
113
122
|
async removePlan(orgId) {
|
|
114
123
|
this.plans.delete(orgId);
|
|
115
124
|
}
|
|
125
|
+
async attachAddOn(orgId, addOnId) {
|
|
126
|
+
if (!this.addOns.has(orgId)) {
|
|
127
|
+
this.addOns.set(orgId, new Set);
|
|
128
|
+
}
|
|
129
|
+
this.addOns.get(orgId).add(addOnId);
|
|
130
|
+
}
|
|
131
|
+
async detachAddOn(orgId, addOnId) {
|
|
132
|
+
this.addOns.get(orgId)?.delete(addOnId);
|
|
133
|
+
}
|
|
134
|
+
async getAddOns(orgId) {
|
|
135
|
+
return [...this.addOns.get(orgId) ?? []];
|
|
136
|
+
}
|
|
137
|
+
async listByPlan(planId) {
|
|
138
|
+
const result = [];
|
|
139
|
+
for (const [orgId, plan] of this.plans.entries()) {
|
|
140
|
+
if (plan.planId === planId) {
|
|
141
|
+
result.push(orgId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
116
146
|
dispose() {
|
|
117
147
|
this.plans.clear();
|
|
148
|
+
this.addOns.clear();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function checkAddOnCompatibility(accessDef, addOnId, currentPlanId) {
|
|
152
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
153
|
+
if (!addOnDef?.requires)
|
|
154
|
+
return true;
|
|
155
|
+
return addOnDef.requires.plans.includes(currentPlanId);
|
|
156
|
+
}
|
|
157
|
+
function getIncompatibleAddOns(accessDef, activeAddOnIds, targetPlanId) {
|
|
158
|
+
return activeAddOnIds.filter((id) => !checkAddOnCompatibility(accessDef, id, targetPlanId));
|
|
159
|
+
}
|
|
160
|
+
|
|
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];
|
|
118
175
|
}
|
|
176
|
+
return currentRole;
|
|
119
177
|
}
|
|
120
178
|
|
|
121
179
|
// src/auth/access-set.ts
|
|
@@ -221,50 +279,97 @@ async function computeAccessSet(config) {
|
|
|
221
279
|
if (effectivePlanId) {
|
|
222
280
|
const planDef = accessDef.plans?.[effectivePlanId];
|
|
223
281
|
if (planDef) {
|
|
224
|
-
|
|
225
|
-
|
|
282
|
+
const effectiveFeatures = new Set(planDef.features ?? []);
|
|
283
|
+
const addOns = await planStore.getAddOns?.(orgId);
|
|
284
|
+
if (addOns) {
|
|
285
|
+
for (const addOnId of addOns) {
|
|
286
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
287
|
+
if (addOnDef?.features) {
|
|
288
|
+
for (const f of addOnDef.features)
|
|
289
|
+
effectiveFeatures.add(f);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const name of Object.keys(accessDef.entitlements)) {
|
|
294
|
+
if (accessDef._planGatedEntitlements.has(name) && !effectiveFeatures.has(name)) {
|
|
226
295
|
const entry = entitlements[name];
|
|
227
296
|
const reasons = [...entry.reasons];
|
|
228
297
|
if (!reasons.includes("plan_required"))
|
|
229
298
|
reasons.push("plan_required");
|
|
299
|
+
const requiredPlans = [];
|
|
300
|
+
for (const [pName, pDef] of Object.entries(accessDef.plans ?? {})) {
|
|
301
|
+
if (pDef.features?.includes(name))
|
|
302
|
+
requiredPlans.push(pName);
|
|
303
|
+
}
|
|
230
304
|
entitlements[name] = {
|
|
231
305
|
...entry,
|
|
232
306
|
allowed: false,
|
|
233
307
|
reasons,
|
|
234
308
|
reason: reasons[0],
|
|
235
|
-
meta: { ...entry.meta, requiredPlans
|
|
309
|
+
meta: { ...entry.meta, requiredPlans }
|
|
236
310
|
};
|
|
237
311
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
reasons,
|
|
254
|
-
reason: reasons[0],
|
|
255
|
-
meta: {
|
|
256
|
-
...entry.meta,
|
|
257
|
-
limit: { max: effectiveLimit, consumed, remaining }
|
|
312
|
+
const limitKeys = accessDef._entitlementToLimitKeys[name];
|
|
313
|
+
if (walletStore && limitKeys?.length) {
|
|
314
|
+
const limitKey = limitKeys[0];
|
|
315
|
+
const limitDef = planDef.limits?.[limitKey];
|
|
316
|
+
if (limitDef) {
|
|
317
|
+
let effectiveMax = limitDef.max;
|
|
318
|
+
if (addOns) {
|
|
319
|
+
for (const addOnId of addOns) {
|
|
320
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
321
|
+
const addOnLimit = addOnDef?.limits?.[limitKey];
|
|
322
|
+
if (addOnLimit) {
|
|
323
|
+
if (effectiveMax === -1)
|
|
324
|
+
break;
|
|
325
|
+
effectiveMax += addOnLimit.max;
|
|
326
|
+
}
|
|
258
327
|
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
328
|
+
}
|
|
329
|
+
const override = orgPlan.overrides[limitKey];
|
|
330
|
+
if (override)
|
|
331
|
+
effectiveMax = Math.max(effectiveMax, override.max);
|
|
332
|
+
if (effectiveMax === -1) {
|
|
333
|
+
const entry = entitlements[name];
|
|
334
|
+
entitlements[name] = {
|
|
335
|
+
...entry,
|
|
336
|
+
meta: {
|
|
337
|
+
...entry.meta,
|
|
338
|
+
limit: { key: limitKey, max: -1, consumed: 0, remaining: -1 }
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
} else {
|
|
342
|
+
const period = limitDef.per ? calculateBillingPeriod(orgPlan.startedAt, limitDef.per) : {
|
|
343
|
+
periodStart: orgPlan.startedAt,
|
|
344
|
+
periodEnd: new Date("9999-12-31T23:59:59Z")
|
|
345
|
+
};
|
|
346
|
+
const consumed = await walletStore.getConsumption(orgId, limitKey, period.periodStart, period.periodEnd);
|
|
347
|
+
const remaining = Math.max(0, effectiveMax - consumed);
|
|
348
|
+
const entry = entitlements[name];
|
|
349
|
+
if (consumed >= effectiveMax) {
|
|
350
|
+
const reasons = [...entry.reasons];
|
|
351
|
+
if (!reasons.includes("limit_reached"))
|
|
352
|
+
reasons.push("limit_reached");
|
|
353
|
+
entitlements[name] = {
|
|
354
|
+
...entry,
|
|
355
|
+
allowed: false,
|
|
356
|
+
reasons,
|
|
357
|
+
reason: reasons[0],
|
|
358
|
+
meta: {
|
|
359
|
+
...entry.meta,
|
|
360
|
+
limit: { key: limitKey, max: effectiveMax, consumed, remaining }
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
} else {
|
|
364
|
+
entitlements[name] = {
|
|
365
|
+
...entry,
|
|
366
|
+
meta: {
|
|
367
|
+
...entry.meta,
|
|
368
|
+
limit: { key: limitKey, max: effectiveMax, consumed, remaining }
|
|
369
|
+
}
|
|
370
|
+
};
|
|
266
371
|
}
|
|
267
|
-
}
|
|
372
|
+
}
|
|
268
373
|
}
|
|
269
374
|
}
|
|
270
375
|
}
|
|
@@ -342,22 +447,6 @@ function addRole(map, resourceType, role) {
|
|
|
342
447
|
}
|
|
343
448
|
roles.add(role);
|
|
344
449
|
}
|
|
345
|
-
function resolveInheritedRole(sourceType, sourceRole, targetType, accessDef) {
|
|
346
|
-
const hierarchy = accessDef.hierarchy;
|
|
347
|
-
const sourceIdx = hierarchy.indexOf(sourceType);
|
|
348
|
-
const targetIdx = hierarchy.indexOf(targetType);
|
|
349
|
-
if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
|
|
350
|
-
return null;
|
|
351
|
-
let currentRole = sourceRole;
|
|
352
|
-
for (let i = sourceIdx;i < targetIdx; i++) {
|
|
353
|
-
const currentType = hierarchy[i];
|
|
354
|
-
const inheritanceMap = accessDef.inheritance[currentType];
|
|
355
|
-
if (!inheritanceMap || !(currentRole in inheritanceMap))
|
|
356
|
-
return null;
|
|
357
|
-
currentRole = inheritanceMap[currentRole];
|
|
358
|
-
}
|
|
359
|
-
return currentRole;
|
|
360
|
-
}
|
|
361
450
|
|
|
362
451
|
// src/auth/cookies.ts
|
|
363
452
|
var DEFAULT_COOKIE_CONFIG = {
|
|
@@ -703,7 +792,7 @@ var DEFAULT_PASSWORD_REQUIREMENTS = {
|
|
|
703
792
|
requireNumbers: false,
|
|
704
793
|
requireSymbols: false
|
|
705
794
|
};
|
|
706
|
-
var BCRYPT_ROUNDS = 12;
|
|
795
|
+
var BCRYPT_ROUNDS = process.env.VERTZ_TEST === "1" ? 4 : 12;
|
|
707
796
|
async function hashPassword(password) {
|
|
708
797
|
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
709
798
|
}
|
|
@@ -1216,9 +1305,11 @@ function createAccessContext(config) {
|
|
|
1216
1305
|
flagStore,
|
|
1217
1306
|
planStore,
|
|
1218
1307
|
walletStore,
|
|
1219
|
-
|
|
1308
|
+
overrideStore,
|
|
1309
|
+
orgResolver,
|
|
1310
|
+
planVersionStore
|
|
1220
1311
|
} = config;
|
|
1221
|
-
async function
|
|
1312
|
+
async function checkLayers1to3(entitlement, resource, resolvedOrgId, overrides) {
|
|
1222
1313
|
if (!userId)
|
|
1223
1314
|
return false;
|
|
1224
1315
|
const entDef = accessDef.entitlements[entitlement];
|
|
@@ -1241,29 +1332,44 @@ function createAccessContext(config) {
|
|
|
1241
1332
|
} else if (entDef.roles.length > 0 && !resource) {
|
|
1242
1333
|
return false;
|
|
1243
1334
|
}
|
|
1244
|
-
if (
|
|
1335
|
+
if (accessDef._planGatedEntitlements.has(entitlement) && planStore && orgResolver) {
|
|
1245
1336
|
if (!resolvedOrgId)
|
|
1246
1337
|
return false;
|
|
1247
1338
|
const orgPlan = await planStore.getPlan(resolvedOrgId);
|
|
1248
1339
|
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1249
1340
|
if (!effectivePlanId)
|
|
1250
1341
|
return false;
|
|
1251
|
-
const
|
|
1252
|
-
if (!
|
|
1342
|
+
const hasFeature = await resolveEffectiveFeatures(resolvedOrgId, entitlement, effectivePlanId, accessDef, planStore, planVersionStore, overrides);
|
|
1343
|
+
if (!hasFeature)
|
|
1253
1344
|
return false;
|
|
1254
|
-
}
|
|
1255
1345
|
}
|
|
1256
1346
|
return true;
|
|
1257
1347
|
}
|
|
1258
1348
|
async function can(entitlement, resource) {
|
|
1259
1349
|
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1260
|
-
|
|
1350
|
+
const overrides = resolvedOrgId && overrideStore ? await overrideStore.get(resolvedOrgId) : null;
|
|
1351
|
+
if (!await checkLayers1to3(entitlement, resource, resolvedOrgId, overrides))
|
|
1261
1352
|
return false;
|
|
1262
|
-
const
|
|
1263
|
-
if (
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1353
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1354
|
+
if (limitKeys?.length && walletStore && planStore && resolvedOrgId) {
|
|
1355
|
+
const walletStates = await resolveAllLimitStates(entitlement, resolvedOrgId, accessDef, planStore, walletStore, planVersionStore, overrides);
|
|
1356
|
+
for (const ws of walletStates) {
|
|
1357
|
+
if (ws.max === 0)
|
|
1358
|
+
return false;
|
|
1359
|
+
if (ws.max === -1)
|
|
1360
|
+
continue;
|
|
1361
|
+
if (ws.consumed >= ws.max) {
|
|
1362
|
+
if (ws.hasOverage) {
|
|
1363
|
+
if (ws.overageCap !== undefined) {
|
|
1364
|
+
const overageUnits = ws.consumed - ws.max;
|
|
1365
|
+
const overageCost = overageUnits * (ws.overageAmount ?? 0) / (ws.overagePer ?? 1);
|
|
1366
|
+
if (overageCost >= ws.overageCap)
|
|
1367
|
+
return false;
|
|
1368
|
+
}
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1267
1373
|
}
|
|
1268
1374
|
}
|
|
1269
1375
|
return true;
|
|
@@ -1287,6 +1393,7 @@ function createAccessContext(config) {
|
|
|
1287
1393
|
};
|
|
1288
1394
|
}
|
|
1289
1395
|
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1396
|
+
const overrides = resolvedOrgId && overrideStore ? await overrideStore.get(resolvedOrgId) : null;
|
|
1290
1397
|
if (entDef.flags?.length && flagStore && orgResolver) {
|
|
1291
1398
|
if (resolvedOrgId) {
|
|
1292
1399
|
const disabledFlags = [];
|
|
@@ -1315,7 +1422,7 @@ function createAccessContext(config) {
|
|
|
1315
1422
|
meta.requiredRoles = [...entDef.roles];
|
|
1316
1423
|
}
|
|
1317
1424
|
}
|
|
1318
|
-
if (
|
|
1425
|
+
if (accessDef._planGatedEntitlements.has(entitlement) && planStore && orgResolver) {
|
|
1319
1426
|
let planDenied = false;
|
|
1320
1427
|
if (!resolvedOrgId) {
|
|
1321
1428
|
planDenied = true;
|
|
@@ -1325,27 +1432,75 @@ function createAccessContext(config) {
|
|
|
1325
1432
|
if (!effectivePlanId) {
|
|
1326
1433
|
planDenied = true;
|
|
1327
1434
|
} else {
|
|
1328
|
-
const
|
|
1329
|
-
if (!
|
|
1435
|
+
const hasFeature = await resolveEffectiveFeatures(resolvedOrgId, entitlement, effectivePlanId, accessDef, planStore, planVersionStore, overrides);
|
|
1436
|
+
if (!hasFeature) {
|
|
1330
1437
|
planDenied = true;
|
|
1331
1438
|
}
|
|
1332
1439
|
}
|
|
1333
1440
|
}
|
|
1334
1441
|
if (planDenied) {
|
|
1335
1442
|
reasons.push("plan_required");
|
|
1336
|
-
|
|
1443
|
+
const plansWithFeature = [];
|
|
1444
|
+
if (accessDef.plans) {
|
|
1445
|
+
for (const [pName, pDef] of Object.entries(accessDef.plans)) {
|
|
1446
|
+
if (pDef.features?.includes(entitlement)) {
|
|
1447
|
+
plansWithFeature.push(pName);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
meta.requiredPlans = plansWithFeature;
|
|
1337
1452
|
}
|
|
1338
1453
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1454
|
+
const checkLimitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1455
|
+
if (checkLimitKeys?.length && walletStore && planStore && resolvedOrgId) {
|
|
1456
|
+
const walletStates = await resolveAllLimitStates(entitlement, resolvedOrgId, accessDef, planStore, walletStore, planVersionStore, overrides);
|
|
1457
|
+
for (const ws of walletStates) {
|
|
1458
|
+
const exceeded = ws.max === 0 || ws.max !== -1 && ws.consumed >= ws.max;
|
|
1459
|
+
if (exceeded) {
|
|
1460
|
+
if (ws.hasOverage && ws.max !== 0) {
|
|
1461
|
+
let capHit = false;
|
|
1462
|
+
if (ws.overageCap !== undefined) {
|
|
1463
|
+
const overageUnits = ws.consumed - ws.max;
|
|
1464
|
+
const overageCost = overageUnits * (ws.overageAmount ?? 0) / (ws.overagePer ?? 1);
|
|
1465
|
+
if (overageCost >= ws.overageCap)
|
|
1466
|
+
capHit = true;
|
|
1467
|
+
}
|
|
1468
|
+
if (capHit) {
|
|
1469
|
+
reasons.push("limit_reached");
|
|
1470
|
+
meta.limit = {
|
|
1471
|
+
key: ws.key,
|
|
1472
|
+
max: ws.max,
|
|
1473
|
+
consumed: ws.consumed,
|
|
1474
|
+
remaining: 0,
|
|
1475
|
+
overage: true
|
|
1476
|
+
};
|
|
1477
|
+
break;
|
|
1478
|
+
}
|
|
1479
|
+
meta.limit = {
|
|
1480
|
+
key: ws.key,
|
|
1481
|
+
max: ws.max,
|
|
1482
|
+
consumed: ws.consumed,
|
|
1483
|
+
remaining: 0,
|
|
1484
|
+
overage: true
|
|
1485
|
+
};
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1348
1488
|
reasons.push("limit_reached");
|
|
1489
|
+
meta.limit = {
|
|
1490
|
+
key: ws.key,
|
|
1491
|
+
max: ws.max,
|
|
1492
|
+
consumed: ws.consumed,
|
|
1493
|
+
remaining: Math.max(0, ws.max === -1 ? Infinity : ws.max - ws.consumed)
|
|
1494
|
+
};
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
if (!meta.limit) {
|
|
1498
|
+
meta.limit = {
|
|
1499
|
+
key: ws.key,
|
|
1500
|
+
max: ws.max,
|
|
1501
|
+
consumed: ws.consumed,
|
|
1502
|
+
remaining: ws.max === -1 ? Infinity : Math.max(0, ws.max - ws.consumed)
|
|
1503
|
+
};
|
|
1349
1504
|
}
|
|
1350
1505
|
}
|
|
1351
1506
|
}
|
|
@@ -1375,14 +1530,26 @@ function createAccessContext(config) {
|
|
|
1375
1530
|
}
|
|
1376
1531
|
return results;
|
|
1377
1532
|
}
|
|
1533
|
+
async function canBatch(entitlement, resources) {
|
|
1534
|
+
if (resources.length > MAX_BULK_CHECKS) {
|
|
1535
|
+
throw new Error(`canBatch() is limited to ${MAX_BULK_CHECKS} resources per call`);
|
|
1536
|
+
}
|
|
1537
|
+
const results = new Map;
|
|
1538
|
+
for (const resource of resources) {
|
|
1539
|
+
const allowed = await can(entitlement, resource);
|
|
1540
|
+
results.set(resource.id, allowed);
|
|
1541
|
+
}
|
|
1542
|
+
return results;
|
|
1543
|
+
}
|
|
1378
1544
|
async function canAndConsume(entitlement, resource, amount = 1) {
|
|
1379
1545
|
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1380
|
-
|
|
1546
|
+
const overrides = resolvedOrgId && overrideStore ? await overrideStore.get(resolvedOrgId) : null;
|
|
1547
|
+
if (!await checkLayers1to3(entitlement, resource, resolvedOrgId, overrides))
|
|
1381
1548
|
return false;
|
|
1382
1549
|
if (!walletStore || !planStore || !orgResolver)
|
|
1383
1550
|
return true;
|
|
1384
|
-
const
|
|
1385
|
-
if (!
|
|
1551
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1552
|
+
if (!limitKeys?.length)
|
|
1386
1553
|
return true;
|
|
1387
1554
|
if (!resolvedOrgId)
|
|
1388
1555
|
return false;
|
|
@@ -1392,40 +1559,67 @@ function createAccessContext(config) {
|
|
|
1392
1559
|
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1393
1560
|
if (!effectivePlanId)
|
|
1394
1561
|
return false;
|
|
1395
|
-
const
|
|
1396
|
-
if (!
|
|
1397
|
-
return false;
|
|
1398
|
-
const limitDef = planDef.limits?.[entitlement];
|
|
1399
|
-
if (!limitDef)
|
|
1562
|
+
const limitsToConsume = await resolveAllLimitConsumptions(resolvedOrgId, entitlement, effectivePlanId, accessDef, planStore, walletStore, orgPlan, planVersionStore, overrides);
|
|
1563
|
+
if (!limitsToConsume.length)
|
|
1400
1564
|
return true;
|
|
1401
|
-
const
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1565
|
+
const consumed = [];
|
|
1566
|
+
for (const lc of limitsToConsume) {
|
|
1567
|
+
if (lc.effectiveMax === -1)
|
|
1568
|
+
continue;
|
|
1569
|
+
if (lc.effectiveMax === 0) {
|
|
1570
|
+
await rollbackConsumptions(resolvedOrgId, consumed, walletStore, amount);
|
|
1571
|
+
return false;
|
|
1572
|
+
}
|
|
1573
|
+
let consumeMax = lc.effectiveMax;
|
|
1574
|
+
if (lc.hasOverage) {
|
|
1575
|
+
if (lc.overageCap !== undefined) {
|
|
1576
|
+
const currentConsumed = await walletStore.getConsumption(resolvedOrgId, lc.walletKey, lc.periodStart, lc.periodEnd);
|
|
1577
|
+
const overageUnits = Math.max(0, currentConsumed + amount - lc.effectiveMax);
|
|
1578
|
+
const overageCost = overageUnits * (lc.overageAmount ?? 0) / (lc.overagePer ?? 1);
|
|
1579
|
+
if (overageCost > lc.overageCap) {
|
|
1580
|
+
await rollbackConsumptions(resolvedOrgId, consumed, walletStore, amount);
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
consumeMax = Number.MAX_SAFE_INTEGER;
|
|
1585
|
+
}
|
|
1586
|
+
const result = await walletStore.consume(resolvedOrgId, lc.walletKey, lc.periodStart, lc.periodEnd, consumeMax, amount);
|
|
1587
|
+
if (!result.success) {
|
|
1588
|
+
await rollbackConsumptions(resolvedOrgId, consumed, walletStore, amount);
|
|
1589
|
+
return false;
|
|
1590
|
+
}
|
|
1591
|
+
consumed.push({
|
|
1592
|
+
key: lc.walletKey,
|
|
1593
|
+
periodStart: lc.periodStart,
|
|
1594
|
+
periodEnd: lc.periodEnd
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
return true;
|
|
1405
1598
|
}
|
|
1406
1599
|
async function unconsume(entitlement, resource, amount = 1) {
|
|
1407
1600
|
if (!walletStore || !planStore || !orgResolver)
|
|
1408
1601
|
return;
|
|
1409
|
-
const
|
|
1410
|
-
if (!
|
|
1602
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1603
|
+
if (!limitKeys?.length)
|
|
1411
1604
|
return;
|
|
1412
1605
|
const orgId = await orgResolver(resource);
|
|
1413
1606
|
if (!orgId)
|
|
1414
1607
|
return;
|
|
1608
|
+
const overrides = overrideStore ? await overrideStore.get(orgId) : null;
|
|
1415
1609
|
const orgPlan = await planStore.getPlan(orgId);
|
|
1416
1610
|
if (!orgPlan)
|
|
1417
1611
|
return;
|
|
1418
1612
|
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1419
1613
|
if (!effectivePlanId)
|
|
1420
1614
|
return;
|
|
1421
|
-
const
|
|
1422
|
-
const
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1615
|
+
const limitsToUnconsume = await resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId, accessDef, planStore, walletStore, orgPlan, planVersionStore, overrides);
|
|
1616
|
+
for (const lc of limitsToUnconsume) {
|
|
1617
|
+
if (lc.effectiveMax === -1)
|
|
1618
|
+
continue;
|
|
1619
|
+
await walletStore.unconsume(orgId, lc.walletKey, lc.periodStart, lc.periodEnd, amount);
|
|
1620
|
+
}
|
|
1427
1621
|
}
|
|
1428
|
-
return { can, check, authorize, canAll, canAndConsume, unconsume };
|
|
1622
|
+
return { can, check, authorize, canAll, canBatch, canAndConsume, unconsume };
|
|
1429
1623
|
}
|
|
1430
1624
|
var DENIAL_ORDER = [
|
|
1431
1625
|
"plan_required",
|
|
@@ -1439,25 +1633,174 @@ var DENIAL_ORDER = [
|
|
|
1439
1633
|
function orderDenialReasons(reasons) {
|
|
1440
1634
|
return [...reasons].sort((a, b) => DENIAL_ORDER.indexOf(a) - DENIAL_ORDER.indexOf(b));
|
|
1441
1635
|
}
|
|
1442
|
-
async function
|
|
1636
|
+
async function resolveEffectiveFeatures(orgId, entitlement, effectivePlanId, accessDef, planStore, planVersionStore, overrides) {
|
|
1637
|
+
if (planVersionStore) {
|
|
1638
|
+
const tenantVersion = await planVersionStore.getTenantVersion(orgId, effectivePlanId);
|
|
1639
|
+
if (tenantVersion !== null) {
|
|
1640
|
+
const versionInfo = await planVersionStore.getVersion(effectivePlanId, tenantVersion);
|
|
1641
|
+
if (versionInfo) {
|
|
1642
|
+
const snapshotFeatures = versionInfo.snapshot.features;
|
|
1643
|
+
if (snapshotFeatures.includes(entitlement))
|
|
1644
|
+
return true;
|
|
1645
|
+
const addOns2 = await planStore.getAddOns?.(orgId);
|
|
1646
|
+
if (addOns2) {
|
|
1647
|
+
for (const addOnId of addOns2) {
|
|
1648
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
1649
|
+
if (addOnDef?.features?.includes(entitlement))
|
|
1650
|
+
return true;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
if (overrides?.features?.includes(entitlement))
|
|
1654
|
+
return true;
|
|
1655
|
+
return false;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1660
|
+
if (planDef?.features?.includes(entitlement))
|
|
1661
|
+
return true;
|
|
1662
|
+
const addOns = await planStore.getAddOns?.(orgId);
|
|
1663
|
+
if (addOns) {
|
|
1664
|
+
for (const addOnId of addOns) {
|
|
1665
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
1666
|
+
if (addOnDef?.features?.includes(entitlement))
|
|
1667
|
+
return true;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
if (overrides?.features?.includes(entitlement))
|
|
1671
|
+
return true;
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
async function resolveAllLimitStates(entitlement, orgId, accessDef, planStore, walletStore, planVersionStore, overrides) {
|
|
1443
1675
|
const orgPlan = await planStore.getPlan(orgId);
|
|
1444
1676
|
const effectivePlanId = resolveEffectivePlan(orgPlan, accessDef.plans, accessDef.defaultPlan);
|
|
1445
1677
|
if (!effectivePlanId || !orgPlan)
|
|
1446
|
-
return
|
|
1447
|
-
const
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1678
|
+
return [];
|
|
1679
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1680
|
+
if (!limitKeys?.length)
|
|
1681
|
+
return [];
|
|
1682
|
+
let versionedLimits = null;
|
|
1683
|
+
if (planVersionStore) {
|
|
1684
|
+
const tenantVersion = await planVersionStore.getTenantVersion(orgId, effectivePlanId);
|
|
1685
|
+
if (tenantVersion !== null) {
|
|
1686
|
+
const versionInfo = await planVersionStore.getVersion(effectivePlanId, tenantVersion);
|
|
1687
|
+
if (versionInfo) {
|
|
1688
|
+
versionedLimits = versionInfo.snapshot.limits;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
const states = [];
|
|
1693
|
+
for (const limitKey of limitKeys) {
|
|
1694
|
+
let limitDef;
|
|
1695
|
+
if (versionedLimits && limitKey in versionedLimits) {
|
|
1696
|
+
limitDef = versionedLimits[limitKey];
|
|
1697
|
+
} else {
|
|
1698
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1699
|
+
limitDef = planDef?.limits?.[limitKey];
|
|
1700
|
+
}
|
|
1701
|
+
if (!limitDef)
|
|
1702
|
+
continue;
|
|
1703
|
+
const effectiveMax = computeEffectiveLimit(limitDef.max, limitKey, orgId, orgPlan, accessDef, planStore, overrides);
|
|
1704
|
+
const resolvedMax = await effectiveMax;
|
|
1705
|
+
const hasOverage = !!limitDef.overage;
|
|
1706
|
+
if (resolvedMax === -1) {
|
|
1707
|
+
states.push({ key: limitKey, max: -1, consumed: 0, hasOverage });
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
const walletKey = limitKey;
|
|
1711
|
+
const period = limitDef.per ? calculateBillingPeriod(orgPlan.startedAt, limitDef.per) : { periodStart: orgPlan.startedAt, periodEnd: new Date("9999-12-31T23:59:59Z") };
|
|
1712
|
+
const consumed = await walletStore.getConsumption(orgId, walletKey, period.periodStart, period.periodEnd);
|
|
1713
|
+
states.push({
|
|
1714
|
+
key: limitKey,
|
|
1715
|
+
max: resolvedMax,
|
|
1716
|
+
consumed,
|
|
1717
|
+
hasOverage,
|
|
1718
|
+
...limitDef.overage ? {
|
|
1719
|
+
overageCap: limitDef.overage.cap,
|
|
1720
|
+
overageAmount: limitDef.overage.amount,
|
|
1721
|
+
overagePer: limitDef.overage.per
|
|
1722
|
+
} : {}
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
return states;
|
|
1726
|
+
}
|
|
1727
|
+
async function resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId, accessDef, planStore, _walletStore, orgPlan, planVersionStore, overrides) {
|
|
1728
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1729
|
+
if (!limitKeys?.length)
|
|
1730
|
+
return [];
|
|
1731
|
+
let versionedLimits = null;
|
|
1732
|
+
if (planVersionStore) {
|
|
1733
|
+
const tenantVersion = await planVersionStore.getTenantVersion(orgId, effectivePlanId);
|
|
1734
|
+
if (tenantVersion !== null) {
|
|
1735
|
+
const versionInfo = await planVersionStore.getVersion(effectivePlanId, tenantVersion);
|
|
1736
|
+
if (versionInfo) {
|
|
1737
|
+
versionedLimits = versionInfo.snapshot.limits;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
const consumptions = [];
|
|
1742
|
+
for (const limitKey of limitKeys) {
|
|
1743
|
+
let limitDef;
|
|
1744
|
+
if (versionedLimits && limitKey in versionedLimits) {
|
|
1745
|
+
limitDef = versionedLimits[limitKey];
|
|
1746
|
+
} else {
|
|
1747
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1748
|
+
limitDef = planDef?.limits?.[limitKey];
|
|
1749
|
+
}
|
|
1750
|
+
if (!limitDef)
|
|
1751
|
+
continue;
|
|
1752
|
+
const effectiveMax = await computeEffectiveLimit(limitDef.max, limitKey, orgId, orgPlan, accessDef, planStore, overrides);
|
|
1753
|
+
const walletKey = limitKey;
|
|
1754
|
+
const period = limitDef.per ? calculateBillingPeriod(orgPlan.startedAt, limitDef.per) : { periodStart: orgPlan.startedAt, periodEnd: new Date("9999-12-31T23:59:59Z") };
|
|
1755
|
+
consumptions.push({
|
|
1756
|
+
walletKey,
|
|
1757
|
+
periodStart: period.periodStart,
|
|
1758
|
+
periodEnd: period.periodEnd,
|
|
1759
|
+
effectiveMax,
|
|
1760
|
+
hasOverage: !!limitDef.overage,
|
|
1761
|
+
...limitDef.overage ? {
|
|
1762
|
+
overageCap: limitDef.overage.cap,
|
|
1763
|
+
overageAmount: limitDef.overage.amount,
|
|
1764
|
+
overagePer: limitDef.overage.per
|
|
1765
|
+
} : {}
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
return consumptions;
|
|
1769
|
+
}
|
|
1770
|
+
async function computeEffectiveLimit(basePlanMax, limitKey, orgId, orgPlan, accessDef, planStore, overrides) {
|
|
1771
|
+
let effectiveMax = basePlanMax;
|
|
1772
|
+
const addOns = await planStore.getAddOns?.(orgId);
|
|
1773
|
+
if (addOns) {
|
|
1774
|
+
for (const addOnId of addOns) {
|
|
1775
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
1776
|
+
const addOnLimit = addOnDef?.limits?.[limitKey];
|
|
1777
|
+
if (addOnLimit) {
|
|
1778
|
+
if (effectiveMax === -1)
|
|
1779
|
+
break;
|
|
1780
|
+
effectiveMax += addOnLimit.max;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
const oldOverride = orgPlan.overrides[limitKey];
|
|
1785
|
+
if (oldOverride) {
|
|
1786
|
+
effectiveMax = Math.max(effectiveMax, oldOverride.max);
|
|
1787
|
+
}
|
|
1788
|
+
const limitOverride = overrides?.limits?.[limitKey];
|
|
1789
|
+
if (limitOverride) {
|
|
1790
|
+
if (limitOverride.max !== undefined) {
|
|
1791
|
+
effectiveMax = limitOverride.max;
|
|
1792
|
+
} else if (limitOverride.add !== undefined) {
|
|
1793
|
+
if (effectiveMax !== -1) {
|
|
1794
|
+
effectiveMax = Math.max(0, effectiveMax + limitOverride.add);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
return effectiveMax;
|
|
1455
1799
|
}
|
|
1456
|
-
function
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
return Math.max(override.max, planLimit.max);
|
|
1800
|
+
async function rollbackConsumptions(orgId, consumed, walletStore, amount) {
|
|
1801
|
+
for (const c of consumed) {
|
|
1802
|
+
await walletStore.unconsume(orgId, c.key, c.periodStart, c.periodEnd, amount);
|
|
1803
|
+
}
|
|
1461
1804
|
}
|
|
1462
1805
|
// src/auth/access-event-broadcaster.ts
|
|
1463
1806
|
function createAccessEventBroadcaster(config) {
|
|
@@ -1605,6 +1948,22 @@ function createAccessEventBroadcaster(config) {
|
|
|
1605
1948
|
const event = { type: "access:plan_changed", orgId };
|
|
1606
1949
|
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1607
1950
|
}
|
|
1951
|
+
function broadcastPlanAssigned(orgId, planId) {
|
|
1952
|
+
const event = { type: "access:plan_assigned", orgId, planId };
|
|
1953
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1954
|
+
}
|
|
1955
|
+
function broadcastAddonAttached(orgId, addonId) {
|
|
1956
|
+
const event = { type: "access:addon_attached", orgId, addonId };
|
|
1957
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1958
|
+
}
|
|
1959
|
+
function broadcastAddonDetached(orgId, addonId) {
|
|
1960
|
+
const event = { type: "access:addon_detached", orgId, addonId };
|
|
1961
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1962
|
+
}
|
|
1963
|
+
function broadcastLimitReset(orgId, entitlement, max) {
|
|
1964
|
+
const event = { type: "access:limit_reset", orgId, entitlement, max };
|
|
1965
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1966
|
+
}
|
|
1608
1967
|
return {
|
|
1609
1968
|
handleUpgrade,
|
|
1610
1969
|
websocket,
|
|
@@ -1612,11 +1971,472 @@ function createAccessEventBroadcaster(config) {
|
|
|
1612
1971
|
broadcastLimitUpdate,
|
|
1613
1972
|
broadcastRoleChange,
|
|
1614
1973
|
broadcastPlanChange,
|
|
1974
|
+
broadcastPlanAssigned,
|
|
1975
|
+
broadcastAddonAttached,
|
|
1976
|
+
broadcastAddonDetached,
|
|
1977
|
+
broadcastLimitReset,
|
|
1615
1978
|
get getConnectionCount() {
|
|
1616
1979
|
return connectionCount;
|
|
1617
1980
|
}
|
|
1618
1981
|
};
|
|
1619
1982
|
}
|
|
1983
|
+
// src/auth/auth-models.ts
|
|
1984
|
+
import { d } from "@vertz/db";
|
|
1985
|
+
var authUsersTable = d.table("auth_users", {
|
|
1986
|
+
id: d.text().primary(),
|
|
1987
|
+
email: d.text(),
|
|
1988
|
+
passwordHash: d.text().nullable(),
|
|
1989
|
+
role: d.text().default("user"),
|
|
1990
|
+
plan: d.text().nullable(),
|
|
1991
|
+
emailVerified: d.boolean().default(false),
|
|
1992
|
+
createdAt: d.timestamp().default("now"),
|
|
1993
|
+
updatedAt: d.timestamp().default("now")
|
|
1994
|
+
});
|
|
1995
|
+
var authSessionsTable = d.table("auth_sessions", {
|
|
1996
|
+
id: d.text().primary(),
|
|
1997
|
+
userId: d.text(),
|
|
1998
|
+
refreshTokenHash: d.text(),
|
|
1999
|
+
previousRefreshHash: d.text().nullable(),
|
|
2000
|
+
currentTokens: d.text().nullable(),
|
|
2001
|
+
ipAddress: d.text(),
|
|
2002
|
+
userAgent: d.text(),
|
|
2003
|
+
createdAt: d.timestamp().default("now"),
|
|
2004
|
+
lastActiveAt: d.timestamp().default("now"),
|
|
2005
|
+
expiresAt: d.timestamp(),
|
|
2006
|
+
revokedAt: d.timestamp().nullable()
|
|
2007
|
+
});
|
|
2008
|
+
var authOauthAccountsTable = d.table("auth_oauth_accounts", {
|
|
2009
|
+
id: d.text().primary(),
|
|
2010
|
+
userId: d.text(),
|
|
2011
|
+
provider: d.text(),
|
|
2012
|
+
providerId: d.text(),
|
|
2013
|
+
email: d.text().nullable(),
|
|
2014
|
+
createdAt: d.timestamp().default("now")
|
|
2015
|
+
});
|
|
2016
|
+
var authRoleAssignmentsTable = d.table("auth_role_assignments", {
|
|
2017
|
+
id: d.text().primary(),
|
|
2018
|
+
userId: d.text(),
|
|
2019
|
+
resourceType: d.text(),
|
|
2020
|
+
resourceId: d.text(),
|
|
2021
|
+
role: d.text(),
|
|
2022
|
+
createdAt: d.timestamp().default("now")
|
|
2023
|
+
});
|
|
2024
|
+
var authClosureTable = d.table("auth_closure", {
|
|
2025
|
+
id: d.text().primary(),
|
|
2026
|
+
ancestorType: d.text(),
|
|
2027
|
+
ancestorId: d.text(),
|
|
2028
|
+
descendantType: d.text(),
|
|
2029
|
+
descendantId: d.text(),
|
|
2030
|
+
depth: d.integer()
|
|
2031
|
+
});
|
|
2032
|
+
var authPlansTable = d.table("auth_plans", {
|
|
2033
|
+
id: d.text().primary(),
|
|
2034
|
+
tenantId: d.text(),
|
|
2035
|
+
planId: d.text(),
|
|
2036
|
+
startedAt: d.timestamp().default("now"),
|
|
2037
|
+
expiresAt: d.timestamp().nullable()
|
|
2038
|
+
});
|
|
2039
|
+
var authPlanAddonsTable = d.table("auth_plan_addons", {
|
|
2040
|
+
id: d.text().primary(),
|
|
2041
|
+
tenantId: d.text(),
|
|
2042
|
+
addonId: d.text(),
|
|
2043
|
+
isOneOff: d.boolean().default(false),
|
|
2044
|
+
quantity: d.integer().default(1),
|
|
2045
|
+
createdAt: d.timestamp().default("now")
|
|
2046
|
+
});
|
|
2047
|
+
var authFlagsTable = d.table("auth_flags", {
|
|
2048
|
+
id: d.text().primary(),
|
|
2049
|
+
tenantId: d.text(),
|
|
2050
|
+
flag: d.text(),
|
|
2051
|
+
enabled: d.boolean().default(false)
|
|
2052
|
+
});
|
|
2053
|
+
var authOverridesTable = d.table("auth_overrides", {
|
|
2054
|
+
id: d.text().primary(),
|
|
2055
|
+
tenantId: d.text(),
|
|
2056
|
+
overrides: d.text(),
|
|
2057
|
+
updatedAt: d.timestamp().default("now")
|
|
2058
|
+
});
|
|
2059
|
+
var authModels = {
|
|
2060
|
+
auth_users: d.model(authUsersTable),
|
|
2061
|
+
auth_sessions: d.model(authSessionsTable),
|
|
2062
|
+
auth_oauth_accounts: d.model(authOauthAccountsTable),
|
|
2063
|
+
auth_role_assignments: d.model(authRoleAssignmentsTable),
|
|
2064
|
+
auth_closure: d.model(authClosureTable),
|
|
2065
|
+
auth_plans: d.model(authPlansTable),
|
|
2066
|
+
auth_plan_addons: d.model(authPlanAddonsTable),
|
|
2067
|
+
auth_flags: d.model(authFlagsTable),
|
|
2068
|
+
auth_overrides: d.model(authOverridesTable)
|
|
2069
|
+
};
|
|
2070
|
+
// src/auth/auth-tables.ts
|
|
2071
|
+
import { sql } from "@vertz/db/sql";
|
|
2072
|
+
|
|
2073
|
+
// src/auth/dialect-ddl.ts
|
|
2074
|
+
function dialectDDL(dialect) {
|
|
2075
|
+
return {
|
|
2076
|
+
boolean: (def) => dialect === "sqlite" ? `INTEGER NOT NULL DEFAULT ${def ? 1 : 0}` : `BOOLEAN NOT NULL DEFAULT ${def}`,
|
|
2077
|
+
timestamp: () => dialect === "sqlite" ? "TEXT NOT NULL" : "TIMESTAMPTZ NOT NULL",
|
|
2078
|
+
timestampNullable: () => dialect === "sqlite" ? "TEXT" : "TIMESTAMPTZ",
|
|
2079
|
+
text: () => "TEXT",
|
|
2080
|
+
textPrimary: () => "TEXT PRIMARY KEY",
|
|
2081
|
+
integer: () => "INTEGER"
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// src/auth/auth-tables.ts
|
|
2086
|
+
function generateAuthDDL(dialect) {
|
|
2087
|
+
const t = dialectDDL(dialect);
|
|
2088
|
+
const statements = [];
|
|
2089
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_users (
|
|
2090
|
+
id ${t.textPrimary()},
|
|
2091
|
+
email ${t.text()} NOT NULL UNIQUE,
|
|
2092
|
+
password_hash ${t.text()},
|
|
2093
|
+
role ${t.text()} NOT NULL DEFAULT 'user',
|
|
2094
|
+
plan ${t.text()},
|
|
2095
|
+
email_verified ${t.boolean(false)},
|
|
2096
|
+
created_at ${t.timestamp()},
|
|
2097
|
+
updated_at ${t.timestamp()}
|
|
2098
|
+
)`);
|
|
2099
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
2100
|
+
id ${t.textPrimary()},
|
|
2101
|
+
user_id ${t.text()} NOT NULL,
|
|
2102
|
+
refresh_token_hash ${t.text()} NOT NULL,
|
|
2103
|
+
previous_refresh_hash ${t.text()},
|
|
2104
|
+
current_tokens ${t.text()},
|
|
2105
|
+
ip_address ${t.text()} NOT NULL,
|
|
2106
|
+
user_agent ${t.text()} NOT NULL,
|
|
2107
|
+
created_at ${t.timestamp()},
|
|
2108
|
+
last_active_at ${t.timestamp()},
|
|
2109
|
+
expires_at ${t.timestamp()},
|
|
2110
|
+
revoked_at ${t.timestampNullable()}
|
|
2111
|
+
)`);
|
|
2112
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_oauth_accounts (
|
|
2113
|
+
id ${t.textPrimary()},
|
|
2114
|
+
user_id ${t.text()} NOT NULL,
|
|
2115
|
+
provider ${t.text()} NOT NULL,
|
|
2116
|
+
provider_id ${t.text()} NOT NULL,
|
|
2117
|
+
email ${t.text()},
|
|
2118
|
+
created_at ${t.timestamp()},
|
|
2119
|
+
UNIQUE(provider, provider_id)
|
|
2120
|
+
)`);
|
|
2121
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_role_assignments (
|
|
2122
|
+
id ${t.textPrimary()},
|
|
2123
|
+
user_id ${t.text()} NOT NULL,
|
|
2124
|
+
resource_type ${t.text()} NOT NULL,
|
|
2125
|
+
resource_id ${t.text()} NOT NULL,
|
|
2126
|
+
role ${t.text()} NOT NULL,
|
|
2127
|
+
created_at ${t.timestamp()},
|
|
2128
|
+
UNIQUE(user_id, resource_type, resource_id, role)
|
|
2129
|
+
)`);
|
|
2130
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_closure (
|
|
2131
|
+
id ${t.textPrimary()},
|
|
2132
|
+
ancestor_type ${t.text()} NOT NULL,
|
|
2133
|
+
ancestor_id ${t.text()} NOT NULL,
|
|
2134
|
+
descendant_type ${t.text()} NOT NULL,
|
|
2135
|
+
descendant_id ${t.text()} NOT NULL,
|
|
2136
|
+
depth ${t.integer()} NOT NULL,
|
|
2137
|
+
UNIQUE(ancestor_type, ancestor_id, descendant_type, descendant_id)
|
|
2138
|
+
)`);
|
|
2139
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_plans (
|
|
2140
|
+
id ${t.textPrimary()},
|
|
2141
|
+
tenant_id ${t.text()} NOT NULL UNIQUE,
|
|
2142
|
+
plan_id ${t.text()} NOT NULL,
|
|
2143
|
+
started_at ${t.timestamp()},
|
|
2144
|
+
expires_at ${t.timestampNullable()}
|
|
2145
|
+
)`);
|
|
2146
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_plan_addons (
|
|
2147
|
+
id ${t.textPrimary()},
|
|
2148
|
+
tenant_id ${t.text()} NOT NULL,
|
|
2149
|
+
addon_id ${t.text()} NOT NULL,
|
|
2150
|
+
is_one_off ${t.boolean(false)},
|
|
2151
|
+
quantity ${t.integer()} NOT NULL DEFAULT 1,
|
|
2152
|
+
created_at ${t.timestamp()},
|
|
2153
|
+
UNIQUE(tenant_id, addon_id)
|
|
2154
|
+
)`);
|
|
2155
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_flags (
|
|
2156
|
+
id ${t.textPrimary()},
|
|
2157
|
+
tenant_id ${t.text()} NOT NULL,
|
|
2158
|
+
flag ${t.text()} NOT NULL,
|
|
2159
|
+
enabled ${t.boolean(false)},
|
|
2160
|
+
UNIQUE(tenant_id, flag)
|
|
2161
|
+
)`);
|
|
2162
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_overrides (
|
|
2163
|
+
id ${t.textPrimary()},
|
|
2164
|
+
tenant_id ${t.text()} NOT NULL UNIQUE,
|
|
2165
|
+
overrides ${t.text()} NOT NULL,
|
|
2166
|
+
updated_at ${t.timestamp()}
|
|
2167
|
+
)`);
|
|
2168
|
+
return statements;
|
|
2169
|
+
}
|
|
2170
|
+
var AUTH_TABLE_NAMES = [
|
|
2171
|
+
"auth_users",
|
|
2172
|
+
"auth_sessions",
|
|
2173
|
+
"auth_oauth_accounts",
|
|
2174
|
+
"auth_role_assignments",
|
|
2175
|
+
"auth_closure",
|
|
2176
|
+
"auth_plans",
|
|
2177
|
+
"auth_plan_addons",
|
|
2178
|
+
"auth_flags",
|
|
2179
|
+
"auth_overrides"
|
|
2180
|
+
];
|
|
2181
|
+
function validateAuthModels(db) {
|
|
2182
|
+
const dbModels = db._internals.models;
|
|
2183
|
+
const missing = AUTH_TABLE_NAMES.filter((m) => !(m in dbModels));
|
|
2184
|
+
if (missing.length > 0) {
|
|
2185
|
+
throw new Error(`Auth requires models ${missing.map((m) => `"${m}"`).join(", ")} in createDb(). ` + "Add authModels to your createDb() call: createDb({ models: { ...authModels, ...yourModels } })");
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
async function initializeAuthTables(db) {
|
|
2189
|
+
const dialectName = db._internals.dialect.name;
|
|
2190
|
+
const statements = generateAuthDDL(dialectName);
|
|
2191
|
+
for (const ddl of statements) {
|
|
2192
|
+
await db.query(sql.raw(ddl));
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
// src/auth/billing/event-emitter.ts
|
|
2196
|
+
function createBillingEventEmitter() {
|
|
2197
|
+
const handlers = new Map;
|
|
2198
|
+
return {
|
|
2199
|
+
on(eventType, handler) {
|
|
2200
|
+
const list = handlers.get(eventType) ?? [];
|
|
2201
|
+
list.push(handler);
|
|
2202
|
+
handlers.set(eventType, list);
|
|
2203
|
+
},
|
|
2204
|
+
off(eventType, handler) {
|
|
2205
|
+
const list = handlers.get(eventType);
|
|
2206
|
+
if (!list)
|
|
2207
|
+
return;
|
|
2208
|
+
const idx = list.indexOf(handler);
|
|
2209
|
+
if (idx >= 0)
|
|
2210
|
+
list.splice(idx, 1);
|
|
2211
|
+
},
|
|
2212
|
+
emit(eventType, event) {
|
|
2213
|
+
const list = handlers.get(eventType);
|
|
2214
|
+
if (!list)
|
|
2215
|
+
return;
|
|
2216
|
+
for (const handler of list) {
|
|
2217
|
+
try {
|
|
2218
|
+
handler(event);
|
|
2219
|
+
} catch {}
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
// src/auth/billing/overage.ts
|
|
2225
|
+
function computeOverage(input) {
|
|
2226
|
+
const { consumed, max, rate } = input;
|
|
2227
|
+
if (max === -1)
|
|
2228
|
+
return 0;
|
|
2229
|
+
if (consumed <= max)
|
|
2230
|
+
return 0;
|
|
2231
|
+
const overageUnits = consumed - max;
|
|
2232
|
+
const charge = overageUnits * rate;
|
|
2233
|
+
if (input.cap !== undefined && charge > input.cap) {
|
|
2234
|
+
return input.cap;
|
|
2235
|
+
}
|
|
2236
|
+
return charge;
|
|
2237
|
+
}
|
|
2238
|
+
// src/auth/billing/stripe-adapter.ts
|
|
2239
|
+
var INTERVAL_MAP = {
|
|
2240
|
+
month: "month",
|
|
2241
|
+
quarter: "month",
|
|
2242
|
+
year: "year"
|
|
2243
|
+
};
|
|
2244
|
+
function mapInterval(interval) {
|
|
2245
|
+
if (interval === "quarter") {
|
|
2246
|
+
return { interval: "month", interval_count: 3 };
|
|
2247
|
+
}
|
|
2248
|
+
return { interval: INTERVAL_MAP[interval] ?? "month" };
|
|
2249
|
+
}
|
|
2250
|
+
function createStripeBillingAdapter(config) {
|
|
2251
|
+
const { stripe, currency = "usd" } = config;
|
|
2252
|
+
async function syncPlans(plans) {
|
|
2253
|
+
const existing = await stripe.products.list();
|
|
2254
|
+
const existingByPlanId = new Map;
|
|
2255
|
+
for (const product of existing.data) {
|
|
2256
|
+
if (product.metadata.vertzPlanId) {
|
|
2257
|
+
existingByPlanId.set(product.metadata.vertzPlanId, product);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
for (const [planId, planDef] of Object.entries(plans)) {
|
|
2261
|
+
const metadata = {
|
|
2262
|
+
vertzPlanId: planId
|
|
2263
|
+
};
|
|
2264
|
+
if (planDef.group)
|
|
2265
|
+
metadata.group = planDef.group;
|
|
2266
|
+
if (planDef.addOn)
|
|
2267
|
+
metadata.addOn = "true";
|
|
2268
|
+
let product = existingByPlanId.get(planId);
|
|
2269
|
+
if (!product) {
|
|
2270
|
+
product = await stripe.products.create({
|
|
2271
|
+
name: planDef.title ?? planId,
|
|
2272
|
+
metadata,
|
|
2273
|
+
description: planDef.description
|
|
2274
|
+
});
|
|
2275
|
+
} else {
|
|
2276
|
+
await stripe.products.update(product.id, {
|
|
2277
|
+
name: planDef.title ?? planId,
|
|
2278
|
+
metadata
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
const amount = planDef.price?.amount ?? 0;
|
|
2282
|
+
const interval = planDef.price?.interval;
|
|
2283
|
+
const unitAmount = Math.round(amount * 100);
|
|
2284
|
+
const existingPrices = await stripe.prices.list({ product: product.id });
|
|
2285
|
+
const activePrices = existingPrices.data.filter((p) => p.active);
|
|
2286
|
+
const matchingPrice = activePrices.find((p) => p.unit_amount === unitAmount);
|
|
2287
|
+
if (!matchingPrice) {
|
|
2288
|
+
for (const oldPrice of activePrices) {
|
|
2289
|
+
await stripe.prices.update(oldPrice.id, { active: false });
|
|
2290
|
+
}
|
|
2291
|
+
const priceParams = {
|
|
2292
|
+
product: product.id,
|
|
2293
|
+
unit_amount: unitAmount,
|
|
2294
|
+
currency,
|
|
2295
|
+
metadata: { vertzPlanId: planId }
|
|
2296
|
+
};
|
|
2297
|
+
if (interval && interval !== "one_off") {
|
|
2298
|
+
const mapped = mapInterval(interval);
|
|
2299
|
+
priceParams.recurring = { interval: mapped.interval };
|
|
2300
|
+
}
|
|
2301
|
+
await stripe.prices.create(priceParams);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
async function createSubscription(_tenantId, _planId) {
|
|
2306
|
+
return "sub_placeholder";
|
|
2307
|
+
}
|
|
2308
|
+
async function cancelSubscription(_tenantId) {}
|
|
2309
|
+
async function attachAddOn(_tenantId, _addOnId) {}
|
|
2310
|
+
async function reportOverage(_tenantId, _limitKey, _amount) {}
|
|
2311
|
+
async function getPortalUrl(_tenantId) {
|
|
2312
|
+
return "https://billing.stripe.com/portal/placeholder";
|
|
2313
|
+
}
|
|
2314
|
+
return {
|
|
2315
|
+
syncPlans,
|
|
2316
|
+
createSubscription,
|
|
2317
|
+
cancelSubscription,
|
|
2318
|
+
attachAddOn,
|
|
2319
|
+
reportOverage,
|
|
2320
|
+
getPortalUrl
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
// src/auth/billing/webhook-handler.ts
|
|
2324
|
+
function extractTenantId(obj) {
|
|
2325
|
+
const meta = obj.metadata;
|
|
2326
|
+
if (meta?.tenantId)
|
|
2327
|
+
return meta.tenantId;
|
|
2328
|
+
const subDetails = obj.subscription_details;
|
|
2329
|
+
if (subDetails?.metadata) {
|
|
2330
|
+
const subMeta = subDetails.metadata;
|
|
2331
|
+
if (subMeta.tenantId)
|
|
2332
|
+
return subMeta.tenantId;
|
|
2333
|
+
}
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
function extractPlanId(obj) {
|
|
2337
|
+
const meta = obj.metadata;
|
|
2338
|
+
if (meta?.vertzPlanId)
|
|
2339
|
+
return meta.vertzPlanId;
|
|
2340
|
+
const items = obj.items;
|
|
2341
|
+
if (items?.data?.[0]?.price?.metadata?.vertzPlanId) {
|
|
2342
|
+
return items.data[0].price.metadata.vertzPlanId;
|
|
2343
|
+
}
|
|
2344
|
+
return null;
|
|
2345
|
+
}
|
|
2346
|
+
function createWebhookHandler(config) {
|
|
2347
|
+
const { planStore, emitter, defaultPlan, webhookSecret } = config;
|
|
2348
|
+
return async (request) => {
|
|
2349
|
+
const signature = request.headers.get("stripe-signature");
|
|
2350
|
+
if (signature !== webhookSecret) {
|
|
2351
|
+
return new Response(JSON.stringify({ error: "Invalid signature" }), {
|
|
2352
|
+
status: 400,
|
|
2353
|
+
headers: { "content-type": "application/json" }
|
|
2354
|
+
});
|
|
2355
|
+
}
|
|
2356
|
+
let event;
|
|
2357
|
+
try {
|
|
2358
|
+
const body = await request.text();
|
|
2359
|
+
event = JSON.parse(body);
|
|
2360
|
+
} catch {
|
|
2361
|
+
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
|
|
2362
|
+
status: 400,
|
|
2363
|
+
headers: { "content-type": "application/json" }
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
const obj = event.data.object;
|
|
2367
|
+
try {
|
|
2368
|
+
switch (event.type) {
|
|
2369
|
+
case "customer.subscription.created": {
|
|
2370
|
+
const tenantId = extractTenantId(obj);
|
|
2371
|
+
const planId = extractPlanId(obj);
|
|
2372
|
+
if (tenantId && planId) {
|
|
2373
|
+
await planStore.assignPlan(tenantId, planId);
|
|
2374
|
+
emitter.emit("subscription:created", { tenantId, planId });
|
|
2375
|
+
}
|
|
2376
|
+
break;
|
|
2377
|
+
}
|
|
2378
|
+
case "customer.subscription.updated": {
|
|
2379
|
+
const tenantId = extractTenantId(obj);
|
|
2380
|
+
const planId = extractPlanId(obj);
|
|
2381
|
+
if (tenantId && planId) {
|
|
2382
|
+
await planStore.assignPlan(tenantId, planId);
|
|
2383
|
+
}
|
|
2384
|
+
break;
|
|
2385
|
+
}
|
|
2386
|
+
case "customer.subscription.deleted": {
|
|
2387
|
+
const tenantId = extractTenantId(obj);
|
|
2388
|
+
const planId = extractPlanId(obj);
|
|
2389
|
+
if (tenantId) {
|
|
2390
|
+
await planStore.assignPlan(tenantId, defaultPlan);
|
|
2391
|
+
emitter.emit("subscription:canceled", {
|
|
2392
|
+
tenantId,
|
|
2393
|
+
planId: planId ?? defaultPlan
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
break;
|
|
2397
|
+
}
|
|
2398
|
+
case "invoice.payment_failed": {
|
|
2399
|
+
const tenantId = extractTenantId(obj);
|
|
2400
|
+
const planId = extractPlanId(obj);
|
|
2401
|
+
const attemptCount = obj.attempt_count ?? 1;
|
|
2402
|
+
if (tenantId) {
|
|
2403
|
+
emitter.emit("billing:payment_failed", {
|
|
2404
|
+
tenantId,
|
|
2405
|
+
planId: planId ?? "",
|
|
2406
|
+
attempt: attemptCount
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
break;
|
|
2410
|
+
}
|
|
2411
|
+
case "checkout.session.completed": {
|
|
2412
|
+
const tenantId = extractTenantId(obj);
|
|
2413
|
+
const planId = extractPlanId(obj);
|
|
2414
|
+
const meta = obj.metadata;
|
|
2415
|
+
const isAddOn = meta?.isAddOn === "true";
|
|
2416
|
+
if (tenantId && planId) {
|
|
2417
|
+
if (isAddOn && planStore.attachAddOn) {
|
|
2418
|
+
await planStore.attachAddOn(tenantId, planId);
|
|
2419
|
+
} else {
|
|
2420
|
+
await planStore.assignPlan(tenantId, planId);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
break;
|
|
2424
|
+
}
|
|
2425
|
+
default:
|
|
2426
|
+
break;
|
|
2427
|
+
}
|
|
2428
|
+
} catch {
|
|
2429
|
+
return new Response(JSON.stringify({ error: "Processing failed" }), {
|
|
2430
|
+
status: 500,
|
|
2431
|
+
headers: { "content-type": "application/json" }
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
return new Response(JSON.stringify({ received: true }), {
|
|
2435
|
+
status: 200,
|
|
2436
|
+
headers: { "content-type": "application/json" }
|
|
2437
|
+
});
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
1620
2440
|
// src/auth/closure-store.ts
|
|
1621
2441
|
var MAX_DEPTH = 4;
|
|
1622
2442
|
|
|
@@ -1648,112 +2468,739 @@ class InMemoryClosureStore {
|
|
|
1648
2468
|
}
|
|
1649
2469
|
}
|
|
1650
2470
|
}
|
|
1651
|
-
async removeResource(type, id) {
|
|
1652
|
-
const descendants = await this.getDescendants(type, id);
|
|
1653
|
-
const descendantKeys = new Set(descendants.map((
|
|
1654
|
-
this.rows = this.rows.filter((row) => {
|
|
1655
|
-
const ancestorKey = `${row.ancestorType}:${row.ancestorId}`;
|
|
1656
|
-
const descendantKey = `${row.descendantType}:${row.descendantId}`;
|
|
1657
|
-
return !descendantKeys.has(ancestorKey) && !descendantKeys.has(descendantKey);
|
|
1658
|
-
});
|
|
2471
|
+
async removeResource(type, id) {
|
|
2472
|
+
const descendants = await this.getDescendants(type, id);
|
|
2473
|
+
const descendantKeys = new Set(descendants.map((d2) => `${d2.type}:${d2.id}`));
|
|
2474
|
+
this.rows = this.rows.filter((row) => {
|
|
2475
|
+
const ancestorKey = `${row.ancestorType}:${row.ancestorId}`;
|
|
2476
|
+
const descendantKey = `${row.descendantType}:${row.descendantId}`;
|
|
2477
|
+
return !descendantKeys.has(ancestorKey) && !descendantKeys.has(descendantKey);
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
async getAncestors(type, id) {
|
|
2481
|
+
return this.rows.filter((row) => row.descendantType === type && row.descendantId === id).map((row) => ({
|
|
2482
|
+
type: row.ancestorType,
|
|
2483
|
+
id: row.ancestorId,
|
|
2484
|
+
depth: row.depth
|
|
2485
|
+
}));
|
|
2486
|
+
}
|
|
2487
|
+
async getDescendants(type, id) {
|
|
2488
|
+
return this.rows.filter((row) => row.ancestorType === type && row.ancestorId === id).map((row) => ({
|
|
2489
|
+
type: row.descendantType,
|
|
2490
|
+
id: row.descendantId,
|
|
2491
|
+
depth: row.depth
|
|
2492
|
+
}));
|
|
2493
|
+
}
|
|
2494
|
+
async hasPath(ancestorType, ancestorId, descendantType, descendantId) {
|
|
2495
|
+
return this.rows.some((row) => row.ancestorType === ancestorType && row.ancestorId === ancestorId && row.descendantType === descendantType && row.descendantId === descendantId);
|
|
2496
|
+
}
|
|
2497
|
+
dispose() {
|
|
2498
|
+
this.rows = [];
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
// src/auth/db-closure-store.ts
|
|
2502
|
+
import { sql as sql2 } from "@vertz/db/sql";
|
|
2503
|
+
|
|
2504
|
+
// src/auth/db-types.ts
|
|
2505
|
+
function boolVal(db, value) {
|
|
2506
|
+
return db._internals.dialect.name === "sqlite" ? value ? 1 : 0 : value;
|
|
2507
|
+
}
|
|
2508
|
+
function assertWrite(result, context) {
|
|
2509
|
+
if (!result.ok) {
|
|
2510
|
+
throw new Error(`Auth DB write failed (${context}): ${result.error.message}`);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// src/auth/db-closure-store.ts
|
|
2515
|
+
var MAX_DEPTH2 = 4;
|
|
2516
|
+
|
|
2517
|
+
class DbClosureStore {
|
|
2518
|
+
db;
|
|
2519
|
+
constructor(db) {
|
|
2520
|
+
this.db = db;
|
|
2521
|
+
}
|
|
2522
|
+
async addResource(type, id, parent) {
|
|
2523
|
+
const selfId = crypto.randomUUID();
|
|
2524
|
+
const selfResult = await this.db.query(sql2`INSERT INTO auth_closure (id, ancestor_type, ancestor_id, descendant_type, descendant_id, depth)
|
|
2525
|
+
VALUES (${selfId}, ${type}, ${id}, ${type}, ${id}, ${0})
|
|
2526
|
+
ON CONFLICT DO NOTHING`);
|
|
2527
|
+
assertWrite(selfResult, "addResource/self");
|
|
2528
|
+
if (parent) {
|
|
2529
|
+
const parentAncestors = await this.getAncestors(parent.parentType, parent.parentId);
|
|
2530
|
+
const maxParentDepth = Math.max(...parentAncestors.map((a) => a.depth), 0);
|
|
2531
|
+
if (maxParentDepth + 1 >= MAX_DEPTH2) {
|
|
2532
|
+
throw new Error("Hierarchy depth exceeds maximum of 4 levels");
|
|
2533
|
+
}
|
|
2534
|
+
for (const ancestor of parentAncestors) {
|
|
2535
|
+
const rowId = crypto.randomUUID();
|
|
2536
|
+
const ancestorResult = await this.db.query(sql2`INSERT INTO auth_closure (id, ancestor_type, ancestor_id, descendant_type, descendant_id, depth)
|
|
2537
|
+
VALUES (${rowId}, ${ancestor.type}, ${ancestor.id}, ${type}, ${id}, ${ancestor.depth + 1})
|
|
2538
|
+
ON CONFLICT DO NOTHING`);
|
|
2539
|
+
assertWrite(ancestorResult, "addResource/ancestor");
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
async removeResource(type, id) {
|
|
2544
|
+
const descendants = await this.getDescendants(type, id);
|
|
2545
|
+
for (const desc of descendants) {
|
|
2546
|
+
const delResult = await this.db.query(sql2`DELETE FROM auth_closure WHERE (descendant_type = ${desc.type} AND descendant_id = ${desc.id}) OR (ancestor_type = ${desc.type} AND ancestor_id = ${desc.id})`);
|
|
2547
|
+
assertWrite(delResult, "removeResource");
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
async getAncestors(type, id) {
|
|
2551
|
+
const result = await this.db.query(sql2`SELECT ancestor_type, ancestor_id, depth FROM auth_closure WHERE descendant_type = ${type} AND descendant_id = ${id}`);
|
|
2552
|
+
if (!result.ok)
|
|
2553
|
+
return [];
|
|
2554
|
+
return result.data.rows.map((r) => ({
|
|
2555
|
+
type: r.ancestor_type,
|
|
2556
|
+
id: r.ancestor_id,
|
|
2557
|
+
depth: r.depth
|
|
2558
|
+
}));
|
|
2559
|
+
}
|
|
2560
|
+
async getDescendants(type, id) {
|
|
2561
|
+
const result = await this.db.query(sql2`SELECT descendant_type, descendant_id, depth FROM auth_closure WHERE ancestor_type = ${type} AND ancestor_id = ${id}`);
|
|
2562
|
+
if (!result.ok)
|
|
2563
|
+
return [];
|
|
2564
|
+
return result.data.rows.map((r) => ({
|
|
2565
|
+
type: r.descendant_type,
|
|
2566
|
+
id: r.descendant_id,
|
|
2567
|
+
depth: r.depth
|
|
2568
|
+
}));
|
|
2569
|
+
}
|
|
2570
|
+
async hasPath(ancestorType, ancestorId, descendantType, descendantId) {
|
|
2571
|
+
const result = await this.db.query(sql2`SELECT COUNT(*) as cnt FROM auth_closure WHERE ancestor_type = ${ancestorType} AND ancestor_id = ${ancestorId} AND descendant_type = ${descendantType} AND descendant_id = ${descendantId}`);
|
|
2572
|
+
if (!result.ok)
|
|
2573
|
+
return false;
|
|
2574
|
+
return (result.data.rows[0]?.cnt ?? 0) > 0;
|
|
2575
|
+
}
|
|
2576
|
+
dispose() {}
|
|
2577
|
+
}
|
|
2578
|
+
// src/auth/db-flag-store.ts
|
|
2579
|
+
import { sql as sql3 } from "@vertz/db/sql";
|
|
2580
|
+
class DbFlagStore {
|
|
2581
|
+
db;
|
|
2582
|
+
cache = new Map;
|
|
2583
|
+
constructor(db) {
|
|
2584
|
+
this.db = db;
|
|
2585
|
+
}
|
|
2586
|
+
async loadFlags() {
|
|
2587
|
+
const result = await this.db.query(sql3`SELECT tenant_id, flag, enabled FROM auth_flags`);
|
|
2588
|
+
if (!result.ok)
|
|
2589
|
+
return;
|
|
2590
|
+
this.cache.clear();
|
|
2591
|
+
for (const row of result.data.rows) {
|
|
2592
|
+
let orgFlags = this.cache.get(row.tenant_id);
|
|
2593
|
+
if (!orgFlags) {
|
|
2594
|
+
orgFlags = new Map;
|
|
2595
|
+
this.cache.set(row.tenant_id, orgFlags);
|
|
2596
|
+
}
|
|
2597
|
+
orgFlags.set(row.flag, row.enabled === 1 || row.enabled === true);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
setFlag(orgId, flag, enabled) {
|
|
2601
|
+
let orgFlags = this.cache.get(orgId);
|
|
2602
|
+
if (!orgFlags) {
|
|
2603
|
+
orgFlags = new Map;
|
|
2604
|
+
this.cache.set(orgId, orgFlags);
|
|
2605
|
+
}
|
|
2606
|
+
orgFlags.set(flag, enabled);
|
|
2607
|
+
const id = crypto.randomUUID();
|
|
2608
|
+
const val = boolVal(this.db, enabled);
|
|
2609
|
+
this.db.query(sql3`INSERT INTO auth_flags (id, tenant_id, flag, enabled) VALUES (${id}, ${orgId}, ${flag}, ${val})
|
|
2610
|
+
ON CONFLICT(tenant_id, flag) DO UPDATE SET enabled = ${val}`).then((result) => {
|
|
2611
|
+
if (!result.ok) {
|
|
2612
|
+
console.error("[DbFlagStore] Failed to persist flag:", result.error);
|
|
2613
|
+
}
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
getFlag(orgId, flag) {
|
|
2617
|
+
return this.cache.get(orgId)?.get(flag) ?? false;
|
|
2618
|
+
}
|
|
2619
|
+
getFlags(orgId) {
|
|
2620
|
+
const orgFlags = this.cache.get(orgId);
|
|
2621
|
+
if (!orgFlags)
|
|
2622
|
+
return {};
|
|
2623
|
+
const result = {};
|
|
2624
|
+
for (const [key, value] of orgFlags) {
|
|
2625
|
+
result[key] = value;
|
|
2626
|
+
}
|
|
2627
|
+
return result;
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
// src/auth/db-oauth-account-store.ts
|
|
2631
|
+
import { sql as sql4 } from "@vertz/db/sql";
|
|
2632
|
+
class DbOAuthAccountStore {
|
|
2633
|
+
db;
|
|
2634
|
+
constructor(db) {
|
|
2635
|
+
this.db = db;
|
|
2636
|
+
}
|
|
2637
|
+
async linkAccount(userId, provider, providerId, email) {
|
|
2638
|
+
const id = crypto.randomUUID();
|
|
2639
|
+
const now = new Date().toISOString();
|
|
2640
|
+
const emailValue = email ?? null;
|
|
2641
|
+
const result = await this.db.query(sql4`INSERT INTO auth_oauth_accounts (id, user_id, provider, provider_id, email, created_at)
|
|
2642
|
+
VALUES (${id}, ${userId}, ${provider}, ${providerId}, ${emailValue}, ${now})
|
|
2643
|
+
ON CONFLICT DO NOTHING`);
|
|
2644
|
+
assertWrite(result, "linkAccount");
|
|
2645
|
+
}
|
|
2646
|
+
async findByProviderAccount(provider, providerId) {
|
|
2647
|
+
const result = await this.db.query(sql4`SELECT user_id FROM auth_oauth_accounts WHERE provider = ${provider} AND provider_id = ${providerId}`);
|
|
2648
|
+
if (!result.ok || result.data.rows.length === 0)
|
|
2649
|
+
return null;
|
|
2650
|
+
return result.data.rows[0]?.user_id ?? null;
|
|
2651
|
+
}
|
|
2652
|
+
async findByUserId(userId) {
|
|
2653
|
+
const result = await this.db.query(sql4`SELECT provider, provider_id FROM auth_oauth_accounts WHERE user_id = ${userId}`);
|
|
2654
|
+
if (!result.ok)
|
|
2655
|
+
return [];
|
|
2656
|
+
return result.data.rows.map((r) => ({
|
|
2657
|
+
provider: r.provider,
|
|
2658
|
+
providerId: r.provider_id
|
|
2659
|
+
}));
|
|
2660
|
+
}
|
|
2661
|
+
async unlinkAccount(userId, provider) {
|
|
2662
|
+
const result = await this.db.query(sql4`DELETE FROM auth_oauth_accounts WHERE user_id = ${userId} AND provider = ${provider}`);
|
|
2663
|
+
assertWrite(result, "unlinkAccount");
|
|
2664
|
+
}
|
|
2665
|
+
dispose() {}
|
|
2666
|
+
}
|
|
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
|
+
// src/auth/db-role-assignment-store.ts
|
|
2760
|
+
import { sql as sql6 } from "@vertz/db/sql";
|
|
2761
|
+
class DbRoleAssignmentStore {
|
|
2762
|
+
db;
|
|
2763
|
+
constructor(db) {
|
|
2764
|
+
this.db = db;
|
|
2765
|
+
}
|
|
2766
|
+
async assign(userId, resourceType, resourceId, role) {
|
|
2767
|
+
const id = crypto.randomUUID();
|
|
2768
|
+
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)
|
|
2770
|
+
VALUES (${id}, ${userId}, ${resourceType}, ${resourceId}, ${role}, ${now})
|
|
2771
|
+
ON CONFLICT DO NOTHING`);
|
|
2772
|
+
assertWrite(result, "assign");
|
|
2773
|
+
}
|
|
2774
|
+
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}`);
|
|
2776
|
+
assertWrite(result, "revoke");
|
|
2777
|
+
}
|
|
2778
|
+
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}`);
|
|
2780
|
+
if (!result.ok)
|
|
2781
|
+
return [];
|
|
2782
|
+
return result.data.rows.map((r) => r.role);
|
|
2783
|
+
}
|
|
2784
|
+
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}`);
|
|
2786
|
+
if (!result.ok)
|
|
2787
|
+
return [];
|
|
2788
|
+
return result.data.rows.map((r) => ({
|
|
2789
|
+
userId: r.user_id,
|
|
2790
|
+
resourceType: r.resource_type,
|
|
2791
|
+
resourceId: r.resource_id,
|
|
2792
|
+
role: r.role
|
|
2793
|
+
}));
|
|
2794
|
+
}
|
|
2795
|
+
async getEffectiveRole(userId, resourceType, resourceId, accessDef, closureStore) {
|
|
2796
|
+
const rolesForType = accessDef.roles[resourceType];
|
|
2797
|
+
if (!rolesForType || rolesForType.length === 0)
|
|
2798
|
+
return null;
|
|
2799
|
+
const candidateRoles = [];
|
|
2800
|
+
const directRoles = await this.getRoles(userId, resourceType, resourceId);
|
|
2801
|
+
candidateRoles.push(...directRoles);
|
|
2802
|
+
const ancestors = await closureStore.getAncestors(resourceType, resourceId);
|
|
2803
|
+
for (const ancestor of ancestors) {
|
|
2804
|
+
if (ancestor.depth === 0)
|
|
2805
|
+
continue;
|
|
2806
|
+
const ancestorRoles = await this.getRoles(userId, ancestor.type, ancestor.id);
|
|
2807
|
+
for (const ancestorRole of ancestorRoles) {
|
|
2808
|
+
const inheritedRole = resolveInheritedRole(ancestor.type, ancestorRole, resourceType, accessDef);
|
|
2809
|
+
if (inheritedRole) {
|
|
2810
|
+
candidateRoles.push(inheritedRole);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
if (candidateRoles.length === 0)
|
|
2815
|
+
return null;
|
|
2816
|
+
const roleOrder = [...rolesForType];
|
|
2817
|
+
let bestIndex = roleOrder.length;
|
|
2818
|
+
for (const role of candidateRoles) {
|
|
2819
|
+
const idx = roleOrder.indexOf(role);
|
|
2820
|
+
if (idx !== -1 && idx < bestIndex) {
|
|
2821
|
+
bestIndex = idx;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
return bestIndex < roleOrder.length ? roleOrder[bestIndex] : null;
|
|
2825
|
+
}
|
|
2826
|
+
dispose() {}
|
|
2827
|
+
}
|
|
2828
|
+
// src/auth/db-session-store.ts
|
|
2829
|
+
import { sql as sql7 } from "@vertz/db/sql";
|
|
2830
|
+
class DbSessionStore {
|
|
2831
|
+
db;
|
|
2832
|
+
constructor(db) {
|
|
2833
|
+
this.db = db;
|
|
2834
|
+
}
|
|
2835
|
+
async createSessionWithId(id, data) {
|
|
2836
|
+
const now = new Date;
|
|
2837
|
+
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)
|
|
2839
|
+
VALUES (${id}, ${data.userId}, ${data.refreshTokenHash}, ${null}, ${tokensJson}, ${data.ipAddress}, ${data.userAgent}, ${now.toISOString()}, ${now.toISOString()}, ${data.expiresAt.toISOString()}, ${null})`);
|
|
2840
|
+
assertWrite(result, "createSessionWithId");
|
|
2841
|
+
return {
|
|
2842
|
+
id,
|
|
2843
|
+
userId: data.userId,
|
|
2844
|
+
refreshTokenHash: data.refreshTokenHash,
|
|
2845
|
+
previousRefreshHash: null,
|
|
2846
|
+
ipAddress: data.ipAddress,
|
|
2847
|
+
userAgent: data.userAgent,
|
|
2848
|
+
createdAt: now,
|
|
2849
|
+
lastActiveAt: now,
|
|
2850
|
+
expiresAt: data.expiresAt,
|
|
2851
|
+
revokedAt: null
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
async findByRefreshHash(hash) {
|
|
2855
|
+
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`);
|
|
2857
|
+
if (!result.ok)
|
|
2858
|
+
return null;
|
|
2859
|
+
const row = result.data.rows[0];
|
|
2860
|
+
if (!row)
|
|
2861
|
+
return null;
|
|
2862
|
+
return this.rowToSession(row);
|
|
2863
|
+
}
|
|
2864
|
+
async findByPreviousRefreshHash(hash) {
|
|
2865
|
+
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`);
|
|
2867
|
+
if (!result.ok)
|
|
2868
|
+
return null;
|
|
2869
|
+
const row = result.data.rows[0];
|
|
2870
|
+
if (!row)
|
|
2871
|
+
return null;
|
|
2872
|
+
return this.rowToSession(row);
|
|
2873
|
+
}
|
|
2874
|
+
async revokeSession(id) {
|
|
2875
|
+
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}`);
|
|
2877
|
+
assertWrite(result, "revokeSession");
|
|
2878
|
+
}
|
|
2879
|
+
async listActiveSessions(userId) {
|
|
2880
|
+
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}`);
|
|
2882
|
+
if (!result.ok)
|
|
2883
|
+
return [];
|
|
2884
|
+
return result.data.rows.map((row) => this.rowToSession(row));
|
|
2885
|
+
}
|
|
2886
|
+
async countActiveSessions(userId) {
|
|
2887
|
+
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}`);
|
|
2889
|
+
if (!result.ok)
|
|
2890
|
+
return 0;
|
|
2891
|
+
return result.data.rows[0]?.cnt ?? 0;
|
|
2892
|
+
}
|
|
2893
|
+
async getCurrentTokens(sessionId) {
|
|
2894
|
+
const result = await this.db.query(sql7`SELECT current_tokens FROM auth_sessions WHERE id = ${sessionId} LIMIT 1`);
|
|
2895
|
+
if (!result.ok)
|
|
2896
|
+
return null;
|
|
2897
|
+
const row = result.data.rows[0];
|
|
2898
|
+
if (!row?.current_tokens)
|
|
2899
|
+
return null;
|
|
2900
|
+
try {
|
|
2901
|
+
return JSON.parse(row.current_tokens);
|
|
2902
|
+
} catch {
|
|
2903
|
+
return null;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
async updateSession(id, data) {
|
|
2907
|
+
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}`);
|
|
2909
|
+
assertWrite(result, "updateSession");
|
|
2910
|
+
}
|
|
2911
|
+
dispose() {}
|
|
2912
|
+
rowToSession(row) {
|
|
2913
|
+
return {
|
|
2914
|
+
id: row.id,
|
|
2915
|
+
userId: row.user_id,
|
|
2916
|
+
refreshTokenHash: row.refresh_token_hash,
|
|
2917
|
+
previousRefreshHash: row.previous_refresh_hash,
|
|
2918
|
+
ipAddress: row.ip_address,
|
|
2919
|
+
userAgent: row.user_agent,
|
|
2920
|
+
createdAt: new Date(row.created_at),
|
|
2921
|
+
lastActiveAt: new Date(row.last_active_at),
|
|
2922
|
+
expiresAt: new Date(row.expires_at),
|
|
2923
|
+
revokedAt: row.revoked_at ? new Date(row.revoked_at) : null
|
|
2924
|
+
};
|
|
1659
2925
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2926
|
+
}
|
|
2927
|
+
// src/auth/db-user-store.ts
|
|
2928
|
+
import { sql as sql8 } from "@vertz/db/sql";
|
|
2929
|
+
class DbUserStore {
|
|
2930
|
+
db;
|
|
2931
|
+
constructor(db) {
|
|
2932
|
+
this.db = db;
|
|
1666
2933
|
}
|
|
1667
|
-
async
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
depth: row.depth
|
|
1672
|
-
}));
|
|
2934
|
+
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()})`);
|
|
2937
|
+
assertWrite(result, "createUser");
|
|
1673
2938
|
}
|
|
1674
|
-
async
|
|
1675
|
-
|
|
2939
|
+
async findByEmail(email) {
|
|
2940
|
+
const result = await this.db.query(sql8`SELECT * FROM auth_users WHERE email = ${email.toLowerCase()} LIMIT 1`);
|
|
2941
|
+
if (!result.ok)
|
|
2942
|
+
return null;
|
|
2943
|
+
const row = result.data.rows[0];
|
|
2944
|
+
if (!row)
|
|
2945
|
+
return null;
|
|
2946
|
+
return {
|
|
2947
|
+
user: this.rowToUser(row),
|
|
2948
|
+
passwordHash: row.password_hash
|
|
2949
|
+
};
|
|
1676
2950
|
}
|
|
1677
|
-
|
|
1678
|
-
this.
|
|
2951
|
+
async findById(id) {
|
|
2952
|
+
const result = await this.db.query(sql8`SELECT * FROM auth_users WHERE id = ${id} LIMIT 1`);
|
|
2953
|
+
if (!result.ok)
|
|
2954
|
+
return null;
|
|
2955
|
+
const row = result.data.rows[0];
|
|
2956
|
+
if (!row)
|
|
2957
|
+
return null;
|
|
2958
|
+
return this.rowToUser(row);
|
|
2959
|
+
}
|
|
2960
|
+
async updatePasswordHash(userId, passwordHash) {
|
|
2961
|
+
const result = await this.db.query(sql8`UPDATE auth_users SET password_hash = ${passwordHash}, updated_at = ${new Date().toISOString()} WHERE id = ${userId}`);
|
|
2962
|
+
assertWrite(result, "updatePasswordHash");
|
|
2963
|
+
}
|
|
2964
|
+
async updateEmailVerified(userId, verified) {
|
|
2965
|
+
const val = boolVal(this.db, verified);
|
|
2966
|
+
const result = await this.db.query(sql8`UPDATE auth_users SET email_verified = ${val}, updated_at = ${new Date().toISOString()} WHERE id = ${userId}`);
|
|
2967
|
+
assertWrite(result, "updateEmailVerified");
|
|
2968
|
+
}
|
|
2969
|
+
rowToUser(row) {
|
|
2970
|
+
return {
|
|
2971
|
+
id: row.id,
|
|
2972
|
+
email: row.email,
|
|
2973
|
+
role: row.role,
|
|
2974
|
+
plan: row.plan ?? undefined,
|
|
2975
|
+
emailVerified: row.email_verified === 1 || row.email_verified === true,
|
|
2976
|
+
createdAt: new Date(row.created_at),
|
|
2977
|
+
updatedAt: new Date(row.updated_at)
|
|
2978
|
+
};
|
|
1679
2979
|
}
|
|
1680
2980
|
}
|
|
1681
2981
|
// src/auth/define-access.ts
|
|
1682
2982
|
function defineAccess(input) {
|
|
1683
|
-
|
|
1684
|
-
|
|
2983
|
+
const { entities, entitlements: rawEntitlements } = input;
|
|
2984
|
+
for (const [entityName, entityDef] of Object.entries(entities)) {
|
|
2985
|
+
const roleSet = new Set;
|
|
2986
|
+
for (const role of entityDef.roles) {
|
|
2987
|
+
if (roleSet.has(role)) {
|
|
2988
|
+
throw new Error(`Duplicate role '${role}' in entity '${entityName}'`);
|
|
2989
|
+
}
|
|
2990
|
+
roleSet.add(role);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
const parentToChildren = new Map;
|
|
2994
|
+
const childToParent = new Map;
|
|
2995
|
+
const inheritanceMap = {};
|
|
2996
|
+
for (const [entityName, entityDef] of Object.entries(entities)) {
|
|
2997
|
+
if (!entityDef.inherits)
|
|
2998
|
+
continue;
|
|
2999
|
+
const parentEntities = new Set;
|
|
3000
|
+
for (const [inheritKey, localRole] of Object.entries(entityDef.inherits)) {
|
|
3001
|
+
const colonIdx = inheritKey.indexOf(":");
|
|
3002
|
+
if (colonIdx === -1) {
|
|
3003
|
+
throw new Error(`Invalid inherits key '${inheritKey}' in entity '${entityName}' — expected format 'entity:role'`);
|
|
3004
|
+
}
|
|
3005
|
+
const parentEntityName2 = inheritKey.substring(0, colonIdx);
|
|
3006
|
+
const parentRoleName = inheritKey.substring(colonIdx + 1);
|
|
3007
|
+
if (parentEntityName2 === entityName) {
|
|
3008
|
+
throw new Error(`Entity '${entityName}' cannot inherit from itself`);
|
|
3009
|
+
}
|
|
3010
|
+
if (!(parentEntityName2 in entities)) {
|
|
3011
|
+
throw new Error(`Entity '${parentEntityName2}' in ${entityName}.inherits is not defined`);
|
|
3012
|
+
}
|
|
3013
|
+
const parentEntity = entities[parentEntityName2];
|
|
3014
|
+
if (!parentEntity.roles.includes(parentRoleName)) {
|
|
3015
|
+
throw new Error(`Role '${parentRoleName}' does not exist on entity '${parentEntityName2}'`);
|
|
3016
|
+
}
|
|
3017
|
+
if (!entityDef.roles.includes(localRole)) {
|
|
3018
|
+
throw new Error(`Role '${localRole}' does not exist on entity '${entityName}'`);
|
|
3019
|
+
}
|
|
3020
|
+
parentEntities.add(parentEntityName2);
|
|
3021
|
+
if (!inheritanceMap[parentEntityName2]) {
|
|
3022
|
+
inheritanceMap[parentEntityName2] = {};
|
|
3023
|
+
}
|
|
3024
|
+
inheritanceMap[parentEntityName2][parentRoleName] = localRole;
|
|
3025
|
+
}
|
|
3026
|
+
if (parentEntities.size > 1) {
|
|
3027
|
+
throw new Error(`Entity '${entityName}' inherits from multiple parents (${[...parentEntities].join(", ")}). Each entity can only inherit from one parent.`);
|
|
3028
|
+
}
|
|
3029
|
+
const parentEntityName = [...parentEntities][0];
|
|
3030
|
+
if (parentEntityName) {
|
|
3031
|
+
if (!parentToChildren.has(parentEntityName)) {
|
|
3032
|
+
parentToChildren.set(parentEntityName, new Set);
|
|
3033
|
+
}
|
|
3034
|
+
parentToChildren.get(parentEntityName).add(entityName);
|
|
3035
|
+
if (childToParent.has(entityName)) {
|
|
3036
|
+
throw new Error(`Entity '${entityName}' inherits from multiple parents`);
|
|
3037
|
+
}
|
|
3038
|
+
childToParent.set(entityName, parentEntityName);
|
|
3039
|
+
}
|
|
1685
3040
|
}
|
|
1686
|
-
|
|
3041
|
+
detectCycles(childToParent);
|
|
3042
|
+
const hierarchy = inferHierarchy(entities, childToParent, parentToChildren);
|
|
3043
|
+
if (hierarchy.length > 4) {
|
|
1687
3044
|
throw new Error("Hierarchy depth must not exceed 4 levels");
|
|
1688
3045
|
}
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
3046
|
+
const resolvedEntitlements = {};
|
|
3047
|
+
const ruleContext = createRuleContext();
|
|
3048
|
+
for (const [entName, entValue] of Object.entries(rawEntitlements)) {
|
|
3049
|
+
const colonIdx = entName.indexOf(":");
|
|
3050
|
+
if (colonIdx === -1) {
|
|
3051
|
+
throw new Error(`Entitlement '${entName}' must use format 'entity:action'`);
|
|
3052
|
+
}
|
|
3053
|
+
const entityPrefix = entName.substring(0, colonIdx);
|
|
3054
|
+
if (!(entityPrefix in entities)) {
|
|
3055
|
+
throw new Error(`Entitlement '${entName}' references undefined entity '${entityPrefix}'`);
|
|
3056
|
+
}
|
|
3057
|
+
let entDef;
|
|
3058
|
+
if (typeof entValue === "function") {
|
|
3059
|
+
entDef = entValue(ruleContext);
|
|
3060
|
+
} else {
|
|
3061
|
+
entDef = entValue;
|
|
1693
3062
|
}
|
|
3063
|
+
const entityRoles = entities[entityPrefix].roles;
|
|
3064
|
+
for (const role of entDef.roles) {
|
|
3065
|
+
if (!entityRoles.includes(role)) {
|
|
3066
|
+
throw new Error(`Role '${role}' in '${entName}' does not exist on entity '${entityPrefix}'`);
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
resolvedEntitlements[entName] = entDef;
|
|
1694
3070
|
}
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
3071
|
+
const entitlementSet = new Set(Object.keys(resolvedEntitlements));
|
|
3072
|
+
const entityNameSet = new Set(Object.keys(entities));
|
|
3073
|
+
if (input.plans) {
|
|
3074
|
+
for (const [planName, planDef] of Object.entries(input.plans)) {
|
|
3075
|
+
if (planDef.features) {
|
|
3076
|
+
for (const feature of planDef.features) {
|
|
3077
|
+
if (!entitlementSet.has(feature)) {
|
|
3078
|
+
throw new Error(`Plan '${planName}' feature '${feature}' is not a defined entitlement`);
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
if (planDef.limits) {
|
|
3083
|
+
for (const [limitKey, limitDef] of Object.entries(planDef.limits)) {
|
|
3084
|
+
if (!entitlementSet.has(limitDef.gates)) {
|
|
3085
|
+
throw new Error(`Limit '${limitKey}' gates '${limitDef.gates}' which is not defined`);
|
|
3086
|
+
}
|
|
3087
|
+
if (limitDef.scope && !entityNameSet.has(limitDef.scope)) {
|
|
3088
|
+
throw new Error(`Limit '${limitKey}' scope '${limitDef.scope}' is not a defined entity`);
|
|
3089
|
+
}
|
|
3090
|
+
if (limitDef.max < -1 || limitDef.max < 0 && limitDef.max !== -1) {
|
|
3091
|
+
throw new Error(`Limit '${limitKey}' max must be -1 (unlimited), 0 (disabled), or a positive integer, got ${limitDef.max}`);
|
|
3092
|
+
}
|
|
3093
|
+
if (!Number.isInteger(limitDef.max)) {
|
|
3094
|
+
throw new Error(`Limit '${limitKey}' max must be -1 (unlimited), 0 (disabled), or a positive integer, got ${limitDef.max}`);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
if (planDef.addOn) {
|
|
3099
|
+
if (planDef.group) {
|
|
3100
|
+
throw new Error(`Add-on '${planName}' must not have a group`);
|
|
3101
|
+
}
|
|
3102
|
+
} else {
|
|
3103
|
+
if (!planDef.group) {
|
|
3104
|
+
throw new Error(`Base plan '${planName}' must have a group`);
|
|
3105
|
+
}
|
|
3106
|
+
if (planDef.requires) {
|
|
3107
|
+
throw new Error(`Plan '${planName}': 'requires' is only valid on add-on plans`);
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
if (planDef.addOn && planDef.requires) {
|
|
3111
|
+
for (const reqPlan of planDef.requires.plans) {
|
|
3112
|
+
if (!input.plans[reqPlan]) {
|
|
3113
|
+
throw new Error(`Add-on '${planName}' requires plan '${reqPlan}' is not defined`);
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
1699
3117
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
for (const [parentRole, childRole] of Object.entries(mapping)) {
|
|
1705
|
-
if (!parentRoles.has(parentRole)) {
|
|
1706
|
-
throw new Error(`Inheritance for ${resourceType} references undefined role: ${parentRole}`);
|
|
3118
|
+
if (input.defaultPlan) {
|
|
3119
|
+
const defaultPlanDef = input.plans[input.defaultPlan];
|
|
3120
|
+
if (defaultPlanDef?.addOn) {
|
|
3121
|
+
throw new Error(`defaultPlan '${input.defaultPlan}' is an add-on, not a base plan`);
|
|
1707
3122
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
3123
|
+
}
|
|
3124
|
+
const basePlanLimitKeys = new Set;
|
|
3125
|
+
for (const [, planDef] of Object.entries(input.plans)) {
|
|
3126
|
+
if (!planDef.addOn && planDef.limits) {
|
|
3127
|
+
for (const limitKey of Object.keys(planDef.limits)) {
|
|
3128
|
+
basePlanLimitKeys.add(limitKey);
|
|
3129
|
+
}
|
|
1710
3130
|
}
|
|
1711
3131
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
throw new Error(`Entitlement "${entName}" references unknown plan: ${planRef}`);
|
|
3132
|
+
for (const [, planDef] of Object.entries(input.plans)) {
|
|
3133
|
+
if (planDef.addOn && planDef.limits) {
|
|
3134
|
+
for (const limitKey of Object.keys(planDef.limits)) {
|
|
3135
|
+
if (!basePlanLimitKeys.has(limitKey)) {
|
|
3136
|
+
throw new Error(`Add-on limit '${limitKey}' not defined in any base plan`);
|
|
3137
|
+
}
|
|
1719
3138
|
}
|
|
1720
3139
|
}
|
|
1721
3140
|
}
|
|
1722
3141
|
}
|
|
1723
|
-
const
|
|
3142
|
+
const planGatedEntitlements = new Set;
|
|
3143
|
+
const entitlementToLimitKeys = {};
|
|
1724
3144
|
if (input.plans) {
|
|
1725
|
-
for (const [
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
throw new Error(`Plan "${planName}" references unknown entitlement: ${ent}`);
|
|
3145
|
+
for (const [, planDef] of Object.entries(input.plans)) {
|
|
3146
|
+
if (planDef.features) {
|
|
3147
|
+
for (const feature of planDef.features) {
|
|
3148
|
+
planGatedEntitlements.add(feature);
|
|
1730
3149
|
}
|
|
1731
3150
|
}
|
|
1732
3151
|
if (planDef.limits) {
|
|
1733
|
-
for (const limitKey of Object.
|
|
1734
|
-
if (!
|
|
1735
|
-
|
|
3152
|
+
for (const [limitKey, limitDef] of Object.entries(planDef.limits)) {
|
|
3153
|
+
if (!entitlementToLimitKeys[limitDef.gates]) {
|
|
3154
|
+
entitlementToLimitKeys[limitDef.gates] = [];
|
|
3155
|
+
}
|
|
3156
|
+
if (!entitlementToLimitKeys[limitDef.gates].includes(limitKey)) {
|
|
3157
|
+
entitlementToLimitKeys[limitDef.gates].push(limitKey);
|
|
1736
3158
|
}
|
|
1737
3159
|
}
|
|
1738
3160
|
}
|
|
1739
3161
|
}
|
|
1740
3162
|
}
|
|
3163
|
+
const rolesMap = {};
|
|
3164
|
+
for (const [entityName, entityDef] of Object.entries(entities)) {
|
|
3165
|
+
rolesMap[entityName] = Object.freeze([...entityDef.roles]);
|
|
3166
|
+
}
|
|
1741
3167
|
const config = {
|
|
1742
|
-
hierarchy: Object.freeze([...
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
3168
|
+
hierarchy: Object.freeze([...hierarchy]),
|
|
3169
|
+
entities: Object.freeze(Object.fromEntries(Object.entries(entities).map(([k, v]) => [
|
|
3170
|
+
k,
|
|
3171
|
+
Object.freeze({
|
|
3172
|
+
roles: Object.freeze([...v.roles]),
|
|
3173
|
+
...v.inherits ? { inherits: Object.freeze({ ...v.inherits }) } : {}
|
|
3174
|
+
})
|
|
3175
|
+
]))),
|
|
3176
|
+
roles: Object.freeze(rolesMap),
|
|
3177
|
+
inheritance: Object.freeze(Object.fromEntries(Object.entries(inheritanceMap).map(([k, v]) => [k, Object.freeze({ ...v })]))),
|
|
3178
|
+
entitlements: Object.freeze(Object.fromEntries(Object.entries(resolvedEntitlements).map(([k, v]) => [k, Object.freeze({ ...v })]))),
|
|
3179
|
+
_planGatedEntitlements: Object.freeze(planGatedEntitlements),
|
|
3180
|
+
_entitlementToLimitKeys: Object.freeze(Object.fromEntries(Object.entries(entitlementToLimitKeys).map(([k, v]) => [k, Object.freeze([...v])]))),
|
|
1746
3181
|
...input.defaultPlan ? { defaultPlan: input.defaultPlan } : {},
|
|
1747
3182
|
...input.plans ? {
|
|
1748
3183
|
plans: Object.freeze(Object.fromEntries(Object.entries(input.plans).map(([planName, planDef]) => [
|
|
1749
3184
|
planName,
|
|
1750
3185
|
Object.freeze({
|
|
1751
|
-
|
|
3186
|
+
...planDef.title ? { title: planDef.title } : {},
|
|
3187
|
+
...planDef.description ? { description: planDef.description } : {},
|
|
3188
|
+
...planDef.group ? { group: planDef.group } : {},
|
|
3189
|
+
...planDef.addOn ? { addOn: planDef.addOn } : {},
|
|
3190
|
+
...planDef.price ? { price: Object.freeze({ ...planDef.price }) } : {},
|
|
3191
|
+
...planDef.features ? { features: Object.freeze([...planDef.features]) } : {},
|
|
1752
3192
|
...planDef.limits ? {
|
|
1753
3193
|
limits: Object.freeze(Object.fromEntries(Object.entries(planDef.limits).map(([k, v]) => [
|
|
1754
3194
|
k,
|
|
1755
3195
|
Object.freeze({ ...v })
|
|
1756
3196
|
])))
|
|
3197
|
+
} : {},
|
|
3198
|
+
...planDef.grandfathering ? { grandfathering: Object.freeze({ ...planDef.grandfathering }) } : {},
|
|
3199
|
+
...planDef.requires ? {
|
|
3200
|
+
requires: Object.freeze({
|
|
3201
|
+
group: planDef.requires.group,
|
|
3202
|
+
plans: Object.freeze([...planDef.requires.plans])
|
|
3203
|
+
})
|
|
1757
3204
|
} : {}
|
|
1758
3205
|
})
|
|
1759
3206
|
])))
|
|
@@ -1761,6 +3208,79 @@ function defineAccess(input) {
|
|
|
1761
3208
|
};
|
|
1762
3209
|
return Object.freeze(config);
|
|
1763
3210
|
}
|
|
3211
|
+
function inferHierarchy(entities, childToParent, parentToChildren) {
|
|
3212
|
+
const inInheritance = new Set;
|
|
3213
|
+
for (const [child, parent] of childToParent.entries()) {
|
|
3214
|
+
inInheritance.add(child);
|
|
3215
|
+
inInheritance.add(parent);
|
|
3216
|
+
}
|
|
3217
|
+
if (inInheritance.size === 0) {
|
|
3218
|
+
return Object.keys(entities);
|
|
3219
|
+
}
|
|
3220
|
+
const roots = [];
|
|
3221
|
+
for (const entity of inInheritance) {
|
|
3222
|
+
if (!childToParent.has(entity)) {
|
|
3223
|
+
roots.push(entity);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
const hierarchy = [];
|
|
3227
|
+
const visited = new Set;
|
|
3228
|
+
function walkChain(entity) {
|
|
3229
|
+
if (visited.has(entity))
|
|
3230
|
+
return;
|
|
3231
|
+
visited.add(entity);
|
|
3232
|
+
hierarchy.push(entity);
|
|
3233
|
+
const children = parentToChildren.get(entity);
|
|
3234
|
+
if (children) {
|
|
3235
|
+
for (const child of children) {
|
|
3236
|
+
walkChain(child);
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
for (const root of roots) {
|
|
3241
|
+
walkChain(root);
|
|
3242
|
+
}
|
|
3243
|
+
for (const entityName of Object.keys(entities)) {
|
|
3244
|
+
if (!visited.has(entityName)) {
|
|
3245
|
+
hierarchy.push(entityName);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
return hierarchy;
|
|
3249
|
+
}
|
|
3250
|
+
function detectCycles(childToParent) {
|
|
3251
|
+
const visited = new Set;
|
|
3252
|
+
const inPath = new Set;
|
|
3253
|
+
for (const entity of childToParent.keys()) {
|
|
3254
|
+
if (!visited.has(entity)) {
|
|
3255
|
+
walkForCycles(entity, childToParent, visited, inPath);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
function walkForCycles(entity, childToParent, visited, inPath) {
|
|
3260
|
+
if (inPath.has(entity)) {
|
|
3261
|
+
throw new Error("Circular inheritance detected");
|
|
3262
|
+
}
|
|
3263
|
+
if (visited.has(entity))
|
|
3264
|
+
return;
|
|
3265
|
+
inPath.add(entity);
|
|
3266
|
+
const parent = childToParent.get(entity);
|
|
3267
|
+
if (parent) {
|
|
3268
|
+
walkForCycles(parent, childToParent, visited, inPath);
|
|
3269
|
+
}
|
|
3270
|
+
inPath.delete(entity);
|
|
3271
|
+
visited.add(entity);
|
|
3272
|
+
}
|
|
3273
|
+
function createRuleContext() {
|
|
3274
|
+
return {
|
|
3275
|
+
where(conditions) {
|
|
3276
|
+
return { type: "where", conditions: Object.freeze({ ...conditions }) };
|
|
3277
|
+
},
|
|
3278
|
+
user: {
|
|
3279
|
+
id: Object.freeze({ __marker: "user.id" }),
|
|
3280
|
+
tenantId: Object.freeze({ __marker: "user.tenantId" })
|
|
3281
|
+
}
|
|
3282
|
+
};
|
|
3283
|
+
}
|
|
1764
3284
|
// src/auth/entity-access.ts
|
|
1765
3285
|
async function computeEntityAccess(entitlements, entity, accessContext) {
|
|
1766
3286
|
const results = {};
|
|
@@ -1804,6 +3324,385 @@ function checkFva(payload, maxAgeSeconds) {
|
|
|
1804
3324
|
const now = Math.floor(Date.now() / 1000);
|
|
1805
3325
|
return now - payload.fva < maxAgeSeconds;
|
|
1806
3326
|
}
|
|
3327
|
+
// src/auth/grandfathering-store.ts
|
|
3328
|
+
class InMemoryGrandfatheringStore {
|
|
3329
|
+
states = new Map;
|
|
3330
|
+
async setGrandfathered(tenantId, planId, version, graceEnds) {
|
|
3331
|
+
this.states.set(`${tenantId}:${planId}`, { tenantId, planId, version, graceEnds });
|
|
3332
|
+
}
|
|
3333
|
+
async getGrandfathered(tenantId, planId) {
|
|
3334
|
+
return this.states.get(`${tenantId}:${planId}`) ?? null;
|
|
3335
|
+
}
|
|
3336
|
+
async listGrandfathered(planId) {
|
|
3337
|
+
const result = [];
|
|
3338
|
+
for (const state of this.states.values()) {
|
|
3339
|
+
if (state.planId === planId) {
|
|
3340
|
+
result.push(state);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
return result;
|
|
3344
|
+
}
|
|
3345
|
+
async removeGrandfathered(tenantId, planId) {
|
|
3346
|
+
this.states.delete(`${tenantId}:${planId}`);
|
|
3347
|
+
}
|
|
3348
|
+
dispose() {
|
|
3349
|
+
this.states.clear();
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
// src/auth/override-store.ts
|
|
3353
|
+
class InMemoryOverrideStore {
|
|
3354
|
+
overrides = new Map;
|
|
3355
|
+
async set(tenantId, overrides) {
|
|
3356
|
+
const existing = this.overrides.get(tenantId) ?? {};
|
|
3357
|
+
if (overrides.features) {
|
|
3358
|
+
const featureSet = new Set(existing.features ?? []);
|
|
3359
|
+
for (const f of overrides.features) {
|
|
3360
|
+
featureSet.add(f);
|
|
3361
|
+
}
|
|
3362
|
+
existing.features = [...featureSet];
|
|
3363
|
+
}
|
|
3364
|
+
if (overrides.limits) {
|
|
3365
|
+
existing.limits = { ...existing.limits, ...overrides.limits };
|
|
3366
|
+
}
|
|
3367
|
+
this.overrides.set(tenantId, existing);
|
|
3368
|
+
}
|
|
3369
|
+
async remove(tenantId, keys) {
|
|
3370
|
+
const existing = this.overrides.get(tenantId);
|
|
3371
|
+
if (!existing)
|
|
3372
|
+
return;
|
|
3373
|
+
if (keys.features && existing.features) {
|
|
3374
|
+
const removeSet = new Set(keys.features);
|
|
3375
|
+
existing.features = existing.features.filter((f) => !removeSet.has(f));
|
|
3376
|
+
if (existing.features.length === 0) {
|
|
3377
|
+
delete existing.features;
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
if (keys.limits && existing.limits) {
|
|
3381
|
+
for (const key of keys.limits) {
|
|
3382
|
+
delete existing.limits[key];
|
|
3383
|
+
}
|
|
3384
|
+
if (Object.keys(existing.limits).length === 0) {
|
|
3385
|
+
delete existing.limits;
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
if (!existing.features && !existing.limits) {
|
|
3389
|
+
this.overrides.delete(tenantId);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
async get(tenantId) {
|
|
3393
|
+
return this.overrides.get(tenantId) ?? null;
|
|
3394
|
+
}
|
|
3395
|
+
dispose() {
|
|
3396
|
+
this.overrides.clear();
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
function validateOverrides(accessDef, overrides) {
|
|
3400
|
+
const allLimitKeys = new Set;
|
|
3401
|
+
if (accessDef.plans) {
|
|
3402
|
+
for (const planDef of Object.values(accessDef.plans)) {
|
|
3403
|
+
if (planDef.limits) {
|
|
3404
|
+
for (const key of Object.keys(planDef.limits)) {
|
|
3405
|
+
allLimitKeys.add(key);
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
if (overrides.limits) {
|
|
3411
|
+
for (const [key, limitDef] of Object.entries(overrides.limits)) {
|
|
3412
|
+
if (!allLimitKeys.has(key)) {
|
|
3413
|
+
throw new Error(`Override limit key '${key}' is not defined in any plan`);
|
|
3414
|
+
}
|
|
3415
|
+
if (limitDef.max !== undefined) {
|
|
3416
|
+
if (limitDef.max < -1) {
|
|
3417
|
+
throw new Error(`Override limit '${key}' max must be -1 (unlimited), 0 (disabled), or a positive integer, got ${limitDef.max}`);
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
if (limitDef.add !== undefined) {
|
|
3421
|
+
if (!Number.isFinite(limitDef.add)) {
|
|
3422
|
+
throw new Error(`Override limit '${key}' add must be a finite integer, got ${limitDef.add}`);
|
|
3423
|
+
}
|
|
3424
|
+
if (!Number.isInteger(limitDef.add)) {
|
|
3425
|
+
throw new Error(`Override limit '${key}' add must be an integer, got ${limitDef.add}`);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
if (overrides.features) {
|
|
3431
|
+
for (const feature of overrides.features) {
|
|
3432
|
+
if (!accessDef.entitlements[feature]) {
|
|
3433
|
+
throw new Error(`Override feature '${feature}' is not a defined entitlement`);
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
// src/auth/plan-hash.ts
|
|
3439
|
+
async function computePlanHash(config) {
|
|
3440
|
+
const canonical = {
|
|
3441
|
+
features: config.features ?? [],
|
|
3442
|
+
limits: config.limits ?? {},
|
|
3443
|
+
price: config.price ?? null
|
|
3444
|
+
};
|
|
3445
|
+
const json = JSON.stringify(canonical, sortedReplacer);
|
|
3446
|
+
return sha256Hex(json);
|
|
3447
|
+
}
|
|
3448
|
+
function sortedReplacer(_key, value) {
|
|
3449
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
3450
|
+
const sorted = {};
|
|
3451
|
+
for (const k of Object.keys(value).sort()) {
|
|
3452
|
+
sorted[k] = value[k];
|
|
3453
|
+
}
|
|
3454
|
+
return sorted;
|
|
3455
|
+
}
|
|
3456
|
+
return value;
|
|
3457
|
+
}
|
|
3458
|
+
// src/auth/plan-manager.ts
|
|
3459
|
+
var GRACE_DURATION_MS = {
|
|
3460
|
+
"1m": 30 * 24 * 60 * 60 * 1000,
|
|
3461
|
+
"3m": 90 * 24 * 60 * 60 * 1000,
|
|
3462
|
+
"6m": 180 * 24 * 60 * 60 * 1000,
|
|
3463
|
+
"12m": 365 * 24 * 60 * 60 * 1000
|
|
3464
|
+
};
|
|
3465
|
+
function resolveGraceEnd(planDef, now) {
|
|
3466
|
+
const grace = planDef.grandfathering?.grace;
|
|
3467
|
+
if (grace === "indefinite")
|
|
3468
|
+
return null;
|
|
3469
|
+
if (grace && GRACE_DURATION_MS[grace]) {
|
|
3470
|
+
return new Date(now.getTime() + GRACE_DURATION_MS[grace]);
|
|
3471
|
+
}
|
|
3472
|
+
const interval = planDef.price?.interval;
|
|
3473
|
+
if (interval === "year") {
|
|
3474
|
+
return new Date(now.getTime() + GRACE_DURATION_MS["3m"]);
|
|
3475
|
+
}
|
|
3476
|
+
return new Date(now.getTime() + GRACE_DURATION_MS["1m"]);
|
|
3477
|
+
}
|
|
3478
|
+
function createPlanManager(config) {
|
|
3479
|
+
const { plans, versionStore, grandfatheringStore, planStore } = config;
|
|
3480
|
+
const clock = config.clock ?? (() => new Date);
|
|
3481
|
+
const handlers = [];
|
|
3482
|
+
function emit(event) {
|
|
3483
|
+
for (const handler of handlers) {
|
|
3484
|
+
handler(event);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
function extractSnapshot(planDef) {
|
|
3488
|
+
return {
|
|
3489
|
+
features: planDef.features ? [...planDef.features] : [],
|
|
3490
|
+
limits: planDef.limits ? { ...planDef.limits } : {},
|
|
3491
|
+
price: planDef.price ? { ...planDef.price } : null
|
|
3492
|
+
};
|
|
3493
|
+
}
|
|
3494
|
+
async function initialize() {
|
|
3495
|
+
const now = clock();
|
|
3496
|
+
for (const [planId, planDef] of Object.entries(plans)) {
|
|
3497
|
+
if (planDef.addOn)
|
|
3498
|
+
continue;
|
|
3499
|
+
const snapshot = extractSnapshot(planDef);
|
|
3500
|
+
const hash = await computePlanHash(snapshot);
|
|
3501
|
+
const currentHash = await versionStore.getCurrentHash(planId);
|
|
3502
|
+
if (currentHash === hash)
|
|
3503
|
+
continue;
|
|
3504
|
+
const version = await versionStore.createVersion(planId, hash, snapshot);
|
|
3505
|
+
const currentVersion = version;
|
|
3506
|
+
emit({
|
|
3507
|
+
type: "plan:version_created",
|
|
3508
|
+
planId,
|
|
3509
|
+
version,
|
|
3510
|
+
currentVersion,
|
|
3511
|
+
timestamp: now
|
|
3512
|
+
});
|
|
3513
|
+
if (version > 1) {
|
|
3514
|
+
const grandfatheredTenants = await listTenantsOnPlan(planId);
|
|
3515
|
+
const graceEnds = resolveGraceEnd(planDef, now);
|
|
3516
|
+
for (const tenantId of grandfatheredTenants) {
|
|
3517
|
+
const tenantVersion = await versionStore.getTenantVersion(tenantId, planId);
|
|
3518
|
+
if (tenantVersion !== null && tenantVersion < version) {
|
|
3519
|
+
await grandfatheringStore.setGrandfathered(tenantId, planId, tenantVersion, graceEnds);
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
async function listTenantsOnPlan(planId) {
|
|
3526
|
+
if (planStore.listByPlan) {
|
|
3527
|
+
return planStore.listByPlan(planId);
|
|
3528
|
+
}
|
|
3529
|
+
return [];
|
|
3530
|
+
}
|
|
3531
|
+
async function migrate(planId, opts) {
|
|
3532
|
+
const now = clock();
|
|
3533
|
+
const currentVersion = await versionStore.getCurrentVersion(planId);
|
|
3534
|
+
if (currentVersion === null)
|
|
3535
|
+
return;
|
|
3536
|
+
if (opts?.tenantId) {
|
|
3537
|
+
await migrateTenant(opts.tenantId, planId, currentVersion, now);
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
const grandfatheredList = await grandfatheringStore.listGrandfathered(planId);
|
|
3541
|
+
for (const state of grandfatheredList) {
|
|
3542
|
+
if (state.graceEnds === null)
|
|
3543
|
+
continue;
|
|
3544
|
+
if (state.graceEnds.getTime() <= now.getTime()) {
|
|
3545
|
+
await migrateTenant(state.tenantId, planId, currentVersion, now);
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
async function migrateTenant(tenantId, planId, targetVersion, now) {
|
|
3550
|
+
const previousVersion = await versionStore.getTenantVersion(tenantId, planId);
|
|
3551
|
+
if (previousVersion !== null) {
|
|
3552
|
+
const prevInfo = await versionStore.getVersion(planId, previousVersion);
|
|
3553
|
+
const targetInfo = await versionStore.getVersion(planId, targetVersion);
|
|
3554
|
+
if (prevInfo && targetInfo) {
|
|
3555
|
+
const prevFeatures = new Set(prevInfo.snapshot.features);
|
|
3556
|
+
const targetFeatures = new Set(targetInfo.snapshot.features);
|
|
3557
|
+
for (const f of prevFeatures) {
|
|
3558
|
+
if (!targetFeatures.has(f)) {
|
|
3559
|
+
break;
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
await versionStore.setTenantVersion(tenantId, planId, targetVersion);
|
|
3565
|
+
await grandfatheringStore.removeGrandfathered(tenantId, planId);
|
|
3566
|
+
emit({
|
|
3567
|
+
type: "plan:migrated",
|
|
3568
|
+
planId,
|
|
3569
|
+
tenantId,
|
|
3570
|
+
version: targetVersion,
|
|
3571
|
+
previousVersion: previousVersion ?? undefined,
|
|
3572
|
+
timestamp: now
|
|
3573
|
+
});
|
|
3574
|
+
}
|
|
3575
|
+
async function schedule(planId, opts) {
|
|
3576
|
+
const at = typeof opts.at === "string" ? new Date(opts.at) : opts.at;
|
|
3577
|
+
const grandfatheredList = await grandfatheringStore.listGrandfathered(planId);
|
|
3578
|
+
for (const state of grandfatheredList) {
|
|
3579
|
+
await grandfatheringStore.setGrandfathered(state.tenantId, planId, state.version, at);
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
async function resolve(tenantId) {
|
|
3583
|
+
const orgPlan = await planStore.getPlan(tenantId);
|
|
3584
|
+
if (!orgPlan)
|
|
3585
|
+
return null;
|
|
3586
|
+
const planId = orgPlan.planId;
|
|
3587
|
+
const currentVersion = await versionStore.getCurrentVersion(planId);
|
|
3588
|
+
if (currentVersion === null)
|
|
3589
|
+
return null;
|
|
3590
|
+
const tenantVersion = await versionStore.getTenantVersion(tenantId, planId);
|
|
3591
|
+
const effectiveVersion = tenantVersion ?? currentVersion;
|
|
3592
|
+
const grandfatheringState = await grandfatheringStore.getGrandfathered(tenantId, planId);
|
|
3593
|
+
const versionInfo = await versionStore.getVersion(planId, effectiveVersion);
|
|
3594
|
+
if (!versionInfo)
|
|
3595
|
+
return null;
|
|
3596
|
+
return {
|
|
3597
|
+
planId,
|
|
3598
|
+
version: effectiveVersion,
|
|
3599
|
+
currentVersion,
|
|
3600
|
+
grandfathered: grandfatheringState !== null,
|
|
3601
|
+
graceEnds: grandfatheringState?.graceEnds ?? null,
|
|
3602
|
+
snapshot: versionInfo.snapshot
|
|
3603
|
+
};
|
|
3604
|
+
}
|
|
3605
|
+
async function grandfatheredFn(planId) {
|
|
3606
|
+
return grandfatheringStore.listGrandfathered(planId);
|
|
3607
|
+
}
|
|
3608
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
3609
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
3610
|
+
async function checkGraceEvents() {
|
|
3611
|
+
const now = clock();
|
|
3612
|
+
for (const planId of Object.keys(plans)) {
|
|
3613
|
+
const grandfatheredList = await grandfatheringStore.listGrandfathered(planId);
|
|
3614
|
+
for (const state of grandfatheredList) {
|
|
3615
|
+
if (state.graceEnds === null)
|
|
3616
|
+
continue;
|
|
3617
|
+
const timeUntilGraceEnd = state.graceEnds.getTime() - now.getTime();
|
|
3618
|
+
if (timeUntilGraceEnd <= SEVEN_DAYS_MS && timeUntilGraceEnd > 0) {
|
|
3619
|
+
emit({
|
|
3620
|
+
type: "plan:grace_expiring",
|
|
3621
|
+
planId,
|
|
3622
|
+
tenantId: state.tenantId,
|
|
3623
|
+
version: state.version,
|
|
3624
|
+
graceEnds: state.graceEnds,
|
|
3625
|
+
timestamp: now
|
|
3626
|
+
});
|
|
3627
|
+
} else if (timeUntilGraceEnd <= THIRTY_DAYS_MS && timeUntilGraceEnd > SEVEN_DAYS_MS) {
|
|
3628
|
+
emit({
|
|
3629
|
+
type: "plan:grace_approaching",
|
|
3630
|
+
planId,
|
|
3631
|
+
tenantId: state.tenantId,
|
|
3632
|
+
version: state.version,
|
|
3633
|
+
graceEnds: state.graceEnds,
|
|
3634
|
+
timestamp: now
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
function on(handler) {
|
|
3641
|
+
handlers.push(handler);
|
|
3642
|
+
}
|
|
3643
|
+
function off(handler) {
|
|
3644
|
+
const idx = handlers.indexOf(handler);
|
|
3645
|
+
if (idx >= 0)
|
|
3646
|
+
handlers.splice(idx, 1);
|
|
3647
|
+
}
|
|
3648
|
+
return {
|
|
3649
|
+
initialize,
|
|
3650
|
+
migrate,
|
|
3651
|
+
schedule,
|
|
3652
|
+
resolve,
|
|
3653
|
+
grandfathered: grandfatheredFn,
|
|
3654
|
+
checkGraceEvents,
|
|
3655
|
+
on,
|
|
3656
|
+
off
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
// src/auth/plan-version-store.ts
|
|
3660
|
+
class InMemoryPlanVersionStore {
|
|
3661
|
+
versions = new Map;
|
|
3662
|
+
tenantVersions = new Map;
|
|
3663
|
+
async createVersion(planId, hash, snapshot) {
|
|
3664
|
+
const planVersions = this.versions.get(planId) ?? [];
|
|
3665
|
+
const version = planVersions.length + 1;
|
|
3666
|
+
const info = {
|
|
3667
|
+
planId,
|
|
3668
|
+
version,
|
|
3669
|
+
hash,
|
|
3670
|
+
snapshot,
|
|
3671
|
+
createdAt: new Date
|
|
3672
|
+
};
|
|
3673
|
+
planVersions.push(info);
|
|
3674
|
+
this.versions.set(planId, planVersions);
|
|
3675
|
+
return version;
|
|
3676
|
+
}
|
|
3677
|
+
async getCurrentVersion(planId) {
|
|
3678
|
+
const planVersions = this.versions.get(planId);
|
|
3679
|
+
if (!planVersions || planVersions.length === 0)
|
|
3680
|
+
return null;
|
|
3681
|
+
return planVersions.length;
|
|
3682
|
+
}
|
|
3683
|
+
async getVersion(planId, version) {
|
|
3684
|
+
const planVersions = this.versions.get(planId);
|
|
3685
|
+
if (!planVersions)
|
|
3686
|
+
return null;
|
|
3687
|
+
return planVersions[version - 1] ?? null;
|
|
3688
|
+
}
|
|
3689
|
+
async getTenantVersion(tenantId, planId) {
|
|
3690
|
+
return this.tenantVersions.get(`${tenantId}:${planId}`) ?? null;
|
|
3691
|
+
}
|
|
3692
|
+
async setTenantVersion(tenantId, planId, version) {
|
|
3693
|
+
this.tenantVersions.set(`${tenantId}:${planId}`, version);
|
|
3694
|
+
}
|
|
3695
|
+
async getCurrentHash(planId) {
|
|
3696
|
+
const planVersions = this.versions.get(planId);
|
|
3697
|
+
if (!planVersions || planVersions.length === 0)
|
|
3698
|
+
return null;
|
|
3699
|
+
return planVersions[planVersions.length - 1].hash;
|
|
3700
|
+
}
|
|
3701
|
+
dispose() {
|
|
3702
|
+
this.versions.clear();
|
|
3703
|
+
this.tenantVersions.clear();
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
1807
3706
|
// src/auth/providers/discord.ts
|
|
1808
3707
|
var AUTHORIZATION_URL = "https://discord.com/api/oauth2/authorize";
|
|
1809
3708
|
var TOKEN_URL = "https://discord.com/api/oauth2/token";
|
|
@@ -2043,7 +3942,7 @@ class InMemoryRoleAssignmentStore {
|
|
|
2043
3942
|
continue;
|
|
2044
3943
|
const ancestorRoles = await this.getRoles(userId, ancestor.type, ancestor.id);
|
|
2045
3944
|
for (const ancestorRole of ancestorRoles) {
|
|
2046
|
-
const inheritedRole =
|
|
3945
|
+
const inheritedRole = resolveInheritedRole(ancestor.type, ancestorRole, resourceType, accessDef);
|
|
2047
3946
|
if (inheritedRole) {
|
|
2048
3947
|
candidateRoles.push(inheritedRole);
|
|
2049
3948
|
}
|
|
@@ -2065,28 +3964,13 @@ class InMemoryRoleAssignmentStore {
|
|
|
2065
3964
|
this.assignments = [];
|
|
2066
3965
|
}
|
|
2067
3966
|
}
|
|
2068
|
-
function resolveInheritedRole2(sourceType, sourceRole, targetType, accessDef) {
|
|
2069
|
-
const hierarchy = accessDef.hierarchy;
|
|
2070
|
-
const sourceIdx = hierarchy.indexOf(sourceType);
|
|
2071
|
-
const targetIdx = hierarchy.indexOf(targetType);
|
|
2072
|
-
if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
|
|
2073
|
-
return null;
|
|
2074
|
-
let currentRole = sourceRole;
|
|
2075
|
-
for (let i = sourceIdx;i < targetIdx; i++) {
|
|
2076
|
-
const currentType = hierarchy[i];
|
|
2077
|
-
const inheritanceMap = accessDef.inheritance[currentType];
|
|
2078
|
-
if (!inheritanceMap || !(currentRole in inheritanceMap))
|
|
2079
|
-
return null;
|
|
2080
|
-
currentRole = inheritanceMap[currentRole];
|
|
2081
|
-
}
|
|
2082
|
-
return currentRole;
|
|
2083
|
-
}
|
|
2084
3967
|
// src/auth/rules.ts
|
|
2085
3968
|
var userMarkers = {
|
|
2086
3969
|
id: Object.freeze({ __marker: "user.id" }),
|
|
2087
3970
|
tenantId: Object.freeze({ __marker: "user.tenantId" })
|
|
2088
3971
|
};
|
|
2089
3972
|
var rules = {
|
|
3973
|
+
public: Object.freeze({ type: "public" }),
|
|
2090
3974
|
role(...roleNames) {
|
|
2091
3975
|
return { type: "role", roles: Object.freeze([...roleNames]) };
|
|
2092
3976
|
},
|
|
@@ -3730,9 +5614,12 @@ async function enforceAccess(operation, accessRules, ctx, row) {
|
|
|
3730
5614
|
if (rule === false) {
|
|
3731
5615
|
return err2(new EntityForbiddenError(`Operation "${operation}" is disabled`));
|
|
3732
5616
|
}
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
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);
|
|
3736
5623
|
}
|
|
3737
5624
|
return ok2(undefined);
|
|
3738
5625
|
}
|
|
@@ -4565,10 +6452,14 @@ function createServer(config) {
|
|
|
4565
6452
|
const allRoutes = [];
|
|
4566
6453
|
const registry = new EntityRegistry;
|
|
4567
6454
|
const apiPrefix = config.apiPrefix === undefined ? "/api" : config.apiPrefix;
|
|
6455
|
+
const { db } = config;
|
|
6456
|
+
const hasDbClient = db && isDatabaseClient(db);
|
|
6457
|
+
if (hasDbClient && config.auth) {
|
|
6458
|
+
validateAuthModels(db);
|
|
6459
|
+
}
|
|
4568
6460
|
if (config.entities && config.entities.length > 0) {
|
|
4569
|
-
const { db } = config;
|
|
4570
6461
|
let dbFactory;
|
|
4571
|
-
if (
|
|
6462
|
+
if (hasDbClient) {
|
|
4572
6463
|
const dbModels = db._internals.models;
|
|
4573
6464
|
const missing = config.entities.filter((e) => !(e.name in dbModels)).map((e) => `"${e.name}"`);
|
|
4574
6465
|
if (missing.length > 0) {
|
|
@@ -4601,10 +6492,27 @@ function createServer(config) {
|
|
|
4601
6492
|
allRoutes.push(...routes);
|
|
4602
6493
|
}
|
|
4603
6494
|
}
|
|
4604
|
-
|
|
6495
|
+
const app = coreCreateServer({
|
|
4605
6496
|
...config,
|
|
4606
6497
|
_entityRoutes: allRoutes.length > 0 ? allRoutes : undefined
|
|
4607
6498
|
});
|
|
6499
|
+
if (hasDbClient && config.auth) {
|
|
6500
|
+
const dbClient = db;
|
|
6501
|
+
const authConfig = {
|
|
6502
|
+
...config.auth,
|
|
6503
|
+
userStore: config.auth.userStore ?? new DbUserStore(dbClient),
|
|
6504
|
+
sessionStore: config.auth.sessionStore ?? new DbSessionStore(dbClient)
|
|
6505
|
+
};
|
|
6506
|
+
const auth = createAuth(authConfig);
|
|
6507
|
+
const serverInstance = app;
|
|
6508
|
+
serverInstance.auth = auth;
|
|
6509
|
+
serverInstance.initialize = async () => {
|
|
6510
|
+
await initializeAuthTables(dbClient);
|
|
6511
|
+
await auth.initialize();
|
|
6512
|
+
};
|
|
6513
|
+
return serverInstance;
|
|
6514
|
+
}
|
|
6515
|
+
return app;
|
|
4608
6516
|
}
|
|
4609
6517
|
// src/entity/entity.ts
|
|
4610
6518
|
import { deepFreeze } from "@vertz/core";
|
|
@@ -4652,14 +6560,18 @@ export {
|
|
|
4652
6560
|
vertz,
|
|
4653
6561
|
verifyPassword,
|
|
4654
6562
|
validatePassword,
|
|
6563
|
+
validateOverrides,
|
|
6564
|
+
validateAuthModels,
|
|
4655
6565
|
stripReadOnlyFields,
|
|
4656
6566
|
stripHiddenFields,
|
|
4657
6567
|
service,
|
|
4658
6568
|
rules,
|
|
4659
6569
|
makeImmutable,
|
|
6570
|
+
initializeAuthTables,
|
|
4660
6571
|
hashPassword,
|
|
4661
6572
|
google,
|
|
4662
6573
|
github,
|
|
6574
|
+
getIncompatibleAddOns,
|
|
4663
6575
|
generateEntityRoutes,
|
|
4664
6576
|
entityErrorHandler,
|
|
4665
6577
|
entity,
|
|
@@ -4670,20 +6582,28 @@ export {
|
|
|
4670
6582
|
defaultAccess,
|
|
4671
6583
|
deepFreeze3 as deepFreeze,
|
|
4672
6584
|
decodeAccessSet,
|
|
6585
|
+
createWebhookHandler,
|
|
6586
|
+
createStripeBillingAdapter,
|
|
4673
6587
|
createServer,
|
|
6588
|
+
createPlanManager,
|
|
4674
6589
|
createMiddleware,
|
|
4675
6590
|
createImmutableProxy,
|
|
4676
6591
|
createEnv,
|
|
4677
6592
|
createEntityContext,
|
|
4678
6593
|
createCrudHandlers,
|
|
6594
|
+
createBillingEventEmitter,
|
|
4679
6595
|
createAuth,
|
|
4680
6596
|
createAccessEventBroadcaster,
|
|
4681
6597
|
createAccessContext,
|
|
4682
6598
|
createAccess,
|
|
6599
|
+
computePlanHash,
|
|
6600
|
+
computeOverage,
|
|
4683
6601
|
computeEntityAccess,
|
|
4684
6602
|
computeAccessSet,
|
|
4685
6603
|
checkFva,
|
|
6604
|
+
checkAddOnCompatibility,
|
|
4686
6605
|
calculateBillingPeriod,
|
|
6606
|
+
authModels,
|
|
4687
6607
|
VertzException2 as VertzException,
|
|
4688
6608
|
ValidationException2 as ValidationException,
|
|
4689
6609
|
UnauthorizedException,
|
|
@@ -4695,16 +6615,27 @@ export {
|
|
|
4695
6615
|
InMemorySessionStore,
|
|
4696
6616
|
InMemoryRoleAssignmentStore,
|
|
4697
6617
|
InMemoryRateLimitStore,
|
|
6618
|
+
InMemoryPlanVersionStore,
|
|
4698
6619
|
InMemoryPlanStore,
|
|
4699
6620
|
InMemoryPasswordResetStore,
|
|
6621
|
+
InMemoryOverrideStore,
|
|
4700
6622
|
InMemoryOAuthAccountStore,
|
|
4701
6623
|
InMemoryMFAStore,
|
|
6624
|
+
InMemoryGrandfatheringStore,
|
|
4702
6625
|
InMemoryFlagStore,
|
|
4703
6626
|
InMemoryEmailVerificationStore,
|
|
4704
6627
|
InMemoryClosureStore,
|
|
4705
6628
|
ForbiddenException,
|
|
4706
6629
|
EntityRegistry,
|
|
6630
|
+
DbUserStore,
|
|
6631
|
+
DbSessionStore,
|
|
6632
|
+
DbRoleAssignmentStore,
|
|
6633
|
+
DbPlanStore,
|
|
6634
|
+
DbOAuthAccountStore,
|
|
6635
|
+
DbFlagStore,
|
|
6636
|
+
DbClosureStore,
|
|
4707
6637
|
ConflictException,
|
|
4708
6638
|
BadRequestException,
|
|
4709
|
-
AuthorizationError
|
|
6639
|
+
AuthorizationError,
|
|
6640
|
+
AUTH_TABLE_NAMES
|
|
4710
6641
|
};
|