@vertz/server 0.2.14 → 0.2.16
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 +962 -191
- package/dist/index.js +3273 -422
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import {
|
|
3
|
-
BadRequestException,
|
|
3
|
+
BadRequestException as BadRequestException2,
|
|
4
4
|
ConflictException,
|
|
5
5
|
createEnv,
|
|
6
6
|
createImmutableProxy,
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
// src/auth/index.ts
|
|
21
21
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
+
import { BadRequestException } from "@vertz/core";
|
|
24
|
+
import { parseBody } from "@vertz/core/internals";
|
|
23
25
|
import {
|
|
24
26
|
createAuthRateLimitedError,
|
|
25
27
|
createAuthValidationError as createAuthValidationError2,
|
|
@@ -40,7 +42,13 @@ import {
|
|
|
40
42
|
// src/auth/billing-period.ts
|
|
41
43
|
function calculateBillingPeriod(startedAt, per, now = new Date) {
|
|
42
44
|
if (per === "month") {
|
|
43
|
-
return
|
|
45
|
+
return calculateMultiMonthPeriod(startedAt, now, 1);
|
|
46
|
+
}
|
|
47
|
+
if (per === "quarter") {
|
|
48
|
+
return calculateMultiMonthPeriod(startedAt, now, 3);
|
|
49
|
+
}
|
|
50
|
+
if (per === "year") {
|
|
51
|
+
return calculateMultiMonthPeriod(startedAt, now, 12);
|
|
44
52
|
}
|
|
45
53
|
const durationMs = per === "day" ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
|
46
54
|
const elapsed = now.getTime() - startedAt.getTime();
|
|
@@ -49,19 +57,21 @@ function calculateBillingPeriod(startedAt, per, now = new Date) {
|
|
|
49
57
|
const periodEnd = new Date(periodStart.getTime() + durationMs);
|
|
50
58
|
return { periodStart, periodEnd };
|
|
51
59
|
}
|
|
52
|
-
function
|
|
60
|
+
function calculateMultiMonthPeriod(startedAt, now, monthInterval) {
|
|
53
61
|
if (now.getTime() < startedAt.getTime()) {
|
|
54
|
-
return { periodStart: new Date(startedAt), periodEnd: addMonths(startedAt,
|
|
62
|
+
return { periodStart: new Date(startedAt), periodEnd: addMonths(startedAt, monthInterval) };
|
|
55
63
|
}
|
|
56
|
-
let periodStart = new Date(startedAt);
|
|
57
64
|
const approxMonths = (now.getUTCFullYear() - startedAt.getUTCFullYear()) * 12 + (now.getUTCMonth() - startedAt.getUTCMonth());
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
const periodsElapsed = Math.floor(approxMonths / monthInterval);
|
|
66
|
+
let periodStart = addMonths(startedAt, periodsElapsed * monthInterval);
|
|
67
|
+
if (periodStart.getTime() > now.getTime()) {
|
|
68
|
+
periodStart = addMonths(startedAt, (periodsElapsed - 1) * monthInterval);
|
|
69
|
+
}
|
|
70
|
+
const nextStart = addMonths(startedAt, (periodsElapsed + 1) * monthInterval);
|
|
71
|
+
if (nextStart.getTime() <= now.getTime()) {
|
|
72
|
+
periodStart = nextStart;
|
|
63
73
|
}
|
|
64
|
-
const periodEnd = addMonths(startedAt, getMonthOffset(startedAt, periodStart) +
|
|
74
|
+
const periodEnd = addMonths(startedAt, getMonthOffset(startedAt, periodStart) + monthInterval);
|
|
65
75
|
return { periodStart, periodEnd };
|
|
66
76
|
}
|
|
67
77
|
function addMonths(base, months) {
|
|
@@ -78,45 +88,95 @@ function getMonthOffset(base, target) {
|
|
|
78
88
|
return (target.getUTCFullYear() - base.getUTCFullYear()) * 12 + (target.getUTCMonth() - base.getUTCMonth());
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
// src/auth/
|
|
82
|
-
function
|
|
83
|
-
|
|
91
|
+
// src/auth/resolve-inherited-role.ts
|
|
92
|
+
function resolveInheritedRole(sourceType, sourceRole, targetType, accessDef) {
|
|
93
|
+
const hierarchy = accessDef.hierarchy;
|
|
94
|
+
const sourceIdx = hierarchy.indexOf(sourceType);
|
|
95
|
+
const targetIdx = hierarchy.indexOf(targetType);
|
|
96
|
+
if (sourceIdx === -1 || targetIdx === -1 || sourceIdx >= targetIdx)
|
|
97
|
+
return null;
|
|
98
|
+
let currentRole = sourceRole;
|
|
99
|
+
for (let i = sourceIdx;i < targetIdx; i++) {
|
|
100
|
+
const currentType = hierarchy[i];
|
|
101
|
+
const inheritanceMap = accessDef.inheritance[currentType];
|
|
102
|
+
if (!inheritanceMap || !(currentRole in inheritanceMap))
|
|
103
|
+
return null;
|
|
104
|
+
currentRole = inheritanceMap[currentRole];
|
|
105
|
+
}
|
|
106
|
+
return currentRole;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/auth/subscription-store.ts
|
|
110
|
+
function resolveEffectivePlan(subscription, plans, defaultPlan = "free", now) {
|
|
111
|
+
if (!subscription)
|
|
84
112
|
return null;
|
|
85
|
-
if (
|
|
113
|
+
if (subscription.expiresAt && subscription.expiresAt.getTime() < (now ?? Date.now())) {
|
|
86
114
|
return plans?.[defaultPlan] ? defaultPlan : null;
|
|
87
115
|
}
|
|
88
|
-
if (!plans?.[
|
|
116
|
+
if (!plans?.[subscription.planId])
|
|
89
117
|
return null;
|
|
90
|
-
return
|
|
118
|
+
return subscription.planId;
|
|
91
119
|
}
|
|
92
120
|
|
|
93
|
-
class
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
121
|
+
class InMemorySubscriptionStore {
|
|
122
|
+
subscriptions = new Map;
|
|
123
|
+
addOns = new Map;
|
|
124
|
+
async assign(tenantId, planId, startedAt = new Date, expiresAt = null) {
|
|
125
|
+
this.subscriptions.set(tenantId, {
|
|
126
|
+
tenantId,
|
|
98
127
|
planId,
|
|
99
128
|
startedAt,
|
|
100
129
|
expiresAt,
|
|
101
130
|
overrides: {}
|
|
102
131
|
});
|
|
103
132
|
}
|
|
104
|
-
async
|
|
105
|
-
return this.
|
|
133
|
+
async get(tenantId) {
|
|
134
|
+
return this.subscriptions.get(tenantId) ?? null;
|
|
106
135
|
}
|
|
107
|
-
async updateOverrides(
|
|
108
|
-
const
|
|
109
|
-
if (!
|
|
136
|
+
async updateOverrides(tenantId, overrides) {
|
|
137
|
+
const sub = this.subscriptions.get(tenantId);
|
|
138
|
+
if (!sub)
|
|
110
139
|
return;
|
|
111
|
-
|
|
140
|
+
sub.overrides = { ...sub.overrides, ...overrides };
|
|
141
|
+
}
|
|
142
|
+
async remove(tenantId) {
|
|
143
|
+
this.subscriptions.delete(tenantId);
|
|
144
|
+
}
|
|
145
|
+
async attachAddOn(tenantId, addOnId) {
|
|
146
|
+
if (!this.addOns.has(tenantId)) {
|
|
147
|
+
this.addOns.set(tenantId, new Set);
|
|
148
|
+
}
|
|
149
|
+
this.addOns.get(tenantId).add(addOnId);
|
|
150
|
+
}
|
|
151
|
+
async detachAddOn(tenantId, addOnId) {
|
|
152
|
+
this.addOns.get(tenantId)?.delete(addOnId);
|
|
112
153
|
}
|
|
113
|
-
async
|
|
114
|
-
this.
|
|
154
|
+
async getAddOns(tenantId) {
|
|
155
|
+
return [...this.addOns.get(tenantId) ?? []];
|
|
156
|
+
}
|
|
157
|
+
async listByPlan(planId) {
|
|
158
|
+
const result = [];
|
|
159
|
+
for (const [tenantId, sub] of this.subscriptions.entries()) {
|
|
160
|
+
if (sub.planId === planId) {
|
|
161
|
+
result.push(tenantId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
115
165
|
}
|
|
116
166
|
dispose() {
|
|
117
|
-
this.
|
|
167
|
+
this.subscriptions.clear();
|
|
168
|
+
this.addOns.clear();
|
|
118
169
|
}
|
|
119
170
|
}
|
|
171
|
+
function checkAddOnCompatibility(accessDef, addOnId, currentPlanId) {
|
|
172
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
173
|
+
if (!addOnDef?.requires)
|
|
174
|
+
return true;
|
|
175
|
+
return addOnDef.requires.plans.includes(currentPlanId);
|
|
176
|
+
}
|
|
177
|
+
function getIncompatibleAddOns(accessDef, activeAddOnIds, targetPlanId) {
|
|
178
|
+
return activeAddOnIds.filter((id) => !checkAddOnCompatibility(accessDef, id, targetPlanId));
|
|
179
|
+
}
|
|
120
180
|
|
|
121
181
|
// src/auth/access-set.ts
|
|
122
182
|
async function computeAccessSet(config) {
|
|
@@ -125,14 +185,13 @@ async function computeAccessSet(config) {
|
|
|
125
185
|
accessDef,
|
|
126
186
|
roleStore,
|
|
127
187
|
closureStore,
|
|
128
|
-
plan,
|
|
129
188
|
flagStore,
|
|
130
|
-
|
|
189
|
+
subscriptionStore,
|
|
131
190
|
walletStore,
|
|
132
|
-
|
|
191
|
+
tenantId
|
|
133
192
|
} = config;
|
|
134
193
|
const entitlements = {};
|
|
135
|
-
let resolvedPlan =
|
|
194
|
+
let resolvedPlan = null;
|
|
136
195
|
if (!userId) {
|
|
137
196
|
for (const name of Object.keys(accessDef.entitlements)) {
|
|
138
197
|
entitlements[name] = {
|
|
@@ -144,7 +203,7 @@ async function computeAccessSet(config) {
|
|
|
144
203
|
return {
|
|
145
204
|
entitlements,
|
|
146
205
|
flags: {},
|
|
147
|
-
plan:
|
|
206
|
+
plan: null,
|
|
148
207
|
computedAt: new Date().toISOString()
|
|
149
208
|
};
|
|
150
209
|
}
|
|
@@ -186,14 +245,14 @@ async function computeAccessSet(config) {
|
|
|
186
245
|
}
|
|
187
246
|
}
|
|
188
247
|
const resolvedFlags = {};
|
|
189
|
-
if (flagStore &&
|
|
190
|
-
const orgFlags = flagStore.getFlags(
|
|
248
|
+
if (flagStore && tenantId) {
|
|
249
|
+
const orgFlags = flagStore.getFlags(tenantId);
|
|
191
250
|
Object.assign(resolvedFlags, orgFlags);
|
|
192
251
|
for (const [name, entDef] of Object.entries(accessDef.entitlements)) {
|
|
193
252
|
if (entDef.flags?.length) {
|
|
194
253
|
const disabledFlags = [];
|
|
195
254
|
for (const flag of entDef.flags) {
|
|
196
|
-
if (!flagStore.getFlag(
|
|
255
|
+
if (!flagStore.getFlag(tenantId, flag)) {
|
|
197
256
|
disabledFlags.push(flag);
|
|
198
257
|
}
|
|
199
258
|
}
|
|
@@ -213,58 +272,105 @@ async function computeAccessSet(config) {
|
|
|
213
272
|
}
|
|
214
273
|
}
|
|
215
274
|
}
|
|
216
|
-
if (
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
const effectivePlanId = resolveEffectivePlan(
|
|
275
|
+
if (subscriptionStore && tenantId) {
|
|
276
|
+
const subscription = await subscriptionStore.get(tenantId);
|
|
277
|
+
if (subscription) {
|
|
278
|
+
const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
|
|
220
279
|
resolvedPlan = effectivePlanId;
|
|
221
280
|
if (effectivePlanId) {
|
|
222
281
|
const planDef = accessDef.plans?.[effectivePlanId];
|
|
223
282
|
if (planDef) {
|
|
224
|
-
|
|
225
|
-
|
|
283
|
+
const effectiveFeatures = new Set(planDef.features ?? []);
|
|
284
|
+
const addOns = await subscriptionStore.getAddOns?.(tenantId);
|
|
285
|
+
if (addOns) {
|
|
286
|
+
for (const addOnId of addOns) {
|
|
287
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
288
|
+
if (addOnDef?.features) {
|
|
289
|
+
for (const f of addOnDef.features)
|
|
290
|
+
effectiveFeatures.add(f);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const name of Object.keys(accessDef.entitlements)) {
|
|
295
|
+
if (accessDef._planGatedEntitlements.has(name) && !effectiveFeatures.has(name)) {
|
|
226
296
|
const entry = entitlements[name];
|
|
227
297
|
const reasons = [...entry.reasons];
|
|
228
298
|
if (!reasons.includes("plan_required"))
|
|
229
299
|
reasons.push("plan_required");
|
|
300
|
+
const requiredPlans = [];
|
|
301
|
+
for (const [pName, pDef] of Object.entries(accessDef.plans ?? {})) {
|
|
302
|
+
if (pDef.features?.includes(name))
|
|
303
|
+
requiredPlans.push(pName);
|
|
304
|
+
}
|
|
230
305
|
entitlements[name] = {
|
|
231
306
|
...entry,
|
|
232
307
|
allowed: false,
|
|
233
308
|
reasons,
|
|
234
309
|
reason: reasons[0],
|
|
235
|
-
meta: { ...entry.meta, requiredPlans
|
|
310
|
+
meta: { ...entry.meta, requiredPlans }
|
|
236
311
|
};
|
|
237
312
|
}
|
|
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 }
|
|
313
|
+
const limitKeys = accessDef._entitlementToLimitKeys[name];
|
|
314
|
+
if (walletStore && limitKeys?.length) {
|
|
315
|
+
const limitKey = limitKeys[0];
|
|
316
|
+
const limitDef = planDef.limits?.[limitKey];
|
|
317
|
+
if (limitDef) {
|
|
318
|
+
let effectiveMax = limitDef.max;
|
|
319
|
+
if (addOns) {
|
|
320
|
+
for (const addOnId of addOns) {
|
|
321
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
322
|
+
const addOnLimit = addOnDef?.limits?.[limitKey];
|
|
323
|
+
if (addOnLimit) {
|
|
324
|
+
if (effectiveMax === -1)
|
|
325
|
+
break;
|
|
326
|
+
effectiveMax += addOnLimit.max;
|
|
327
|
+
}
|
|
258
328
|
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
329
|
+
}
|
|
330
|
+
const override = subscription.overrides[limitKey];
|
|
331
|
+
if (override)
|
|
332
|
+
effectiveMax = Math.max(effectiveMax, override.max);
|
|
333
|
+
if (effectiveMax === -1) {
|
|
334
|
+
const entry = entitlements[name];
|
|
335
|
+
entitlements[name] = {
|
|
336
|
+
...entry,
|
|
337
|
+
meta: {
|
|
338
|
+
...entry.meta,
|
|
339
|
+
limit: { key: limitKey, max: -1, consumed: 0, remaining: -1 }
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
} else {
|
|
343
|
+
const period = limitDef.per ? calculateBillingPeriod(subscription.startedAt, limitDef.per) : {
|
|
344
|
+
periodStart: subscription.startedAt,
|
|
345
|
+
periodEnd: new Date("9999-12-31T23:59:59Z")
|
|
346
|
+
};
|
|
347
|
+
const consumed = await walletStore.getConsumption(tenantId, limitKey, period.periodStart, period.periodEnd);
|
|
348
|
+
const remaining = Math.max(0, effectiveMax - consumed);
|
|
349
|
+
const entry = entitlements[name];
|
|
350
|
+
if (consumed >= effectiveMax) {
|
|
351
|
+
const reasons = [...entry.reasons];
|
|
352
|
+
if (!reasons.includes("limit_reached"))
|
|
353
|
+
reasons.push("limit_reached");
|
|
354
|
+
entitlements[name] = {
|
|
355
|
+
...entry,
|
|
356
|
+
allowed: false,
|
|
357
|
+
reasons,
|
|
358
|
+
reason: reasons[0],
|
|
359
|
+
meta: {
|
|
360
|
+
...entry.meta,
|
|
361
|
+
limit: { key: limitKey, max: effectiveMax, consumed, remaining }
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
} else {
|
|
365
|
+
entitlements[name] = {
|
|
366
|
+
...entry,
|
|
367
|
+
meta: {
|
|
368
|
+
...entry.meta,
|
|
369
|
+
limit: { key: limitKey, max: effectiveMax, consumed, remaining }
|
|
370
|
+
}
|
|
371
|
+
};
|
|
266
372
|
}
|
|
267
|
-
}
|
|
373
|
+
}
|
|
268
374
|
}
|
|
269
375
|
}
|
|
270
376
|
}
|
|
@@ -342,22 +448,6 @@ function addRole(map, resourceType, role) {
|
|
|
342
448
|
}
|
|
343
449
|
roles.add(role);
|
|
344
450
|
}
|
|
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
451
|
|
|
362
452
|
// src/auth/cookies.ts
|
|
363
453
|
var DEFAULT_COOKIE_CONFIG = {
|
|
@@ -703,7 +793,7 @@ var DEFAULT_PASSWORD_REQUIREMENTS = {
|
|
|
703
793
|
requireNumbers: false,
|
|
704
794
|
requireSymbols: false
|
|
705
795
|
};
|
|
706
|
-
var BCRYPT_ROUNDS = 12;
|
|
796
|
+
var BCRYPT_ROUNDS = process.env.VERTZ_TEST === "1" ? 4 : 12;
|
|
707
797
|
async function hashPassword(password) {
|
|
708
798
|
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
709
799
|
}
|
|
@@ -806,6 +896,60 @@ class InMemoryRateLimitStore {
|
|
|
806
896
|
}
|
|
807
897
|
}
|
|
808
898
|
|
|
899
|
+
// src/auth/resolve-session-for-ssr.ts
|
|
900
|
+
function extractAccessSet(acl) {
|
|
901
|
+
if (acl.overflow)
|
|
902
|
+
return null;
|
|
903
|
+
if (!acl.set)
|
|
904
|
+
return null;
|
|
905
|
+
return {
|
|
906
|
+
entitlements: Object.fromEntries(Object.entries(acl.set.entitlements).map(([name, check]) => {
|
|
907
|
+
const data = {
|
|
908
|
+
allowed: check.allowed,
|
|
909
|
+
reasons: check.reasons ?? [],
|
|
910
|
+
...check.reason ? { reason: check.reason } : {},
|
|
911
|
+
...check.meta ? { meta: check.meta } : {}
|
|
912
|
+
};
|
|
913
|
+
return [name, data];
|
|
914
|
+
})),
|
|
915
|
+
flags: acl.set.flags,
|
|
916
|
+
plan: acl.set.plan,
|
|
917
|
+
computedAt: acl.set.computedAt
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function resolveSessionForSSR(config) {
|
|
921
|
+
const { jwtSecret, jwtAlgorithm, cookieName } = config;
|
|
922
|
+
return async (request) => {
|
|
923
|
+
const cookieHeader = request.headers.get("cookie");
|
|
924
|
+
if (!cookieHeader)
|
|
925
|
+
return null;
|
|
926
|
+
const cookieEntry = cookieHeader.split(";").find((c) => c.trim().startsWith(`${cookieName}=`));
|
|
927
|
+
if (!cookieEntry)
|
|
928
|
+
return null;
|
|
929
|
+
const token = cookieEntry.trim().slice(`${cookieName}=`.length);
|
|
930
|
+
if (!token)
|
|
931
|
+
return null;
|
|
932
|
+
const payload = await verifyJWT(token, jwtSecret, jwtAlgorithm);
|
|
933
|
+
if (!payload)
|
|
934
|
+
return null;
|
|
935
|
+
const user = {
|
|
936
|
+
id: payload.sub,
|
|
937
|
+
email: payload.email,
|
|
938
|
+
role: payload.role
|
|
939
|
+
};
|
|
940
|
+
if (payload.tenantId) {
|
|
941
|
+
user.tenantId = payload.tenantId;
|
|
942
|
+
}
|
|
943
|
+
const session = {
|
|
944
|
+
user,
|
|
945
|
+
expiresAt: payload.exp * 1000
|
|
946
|
+
};
|
|
947
|
+
const acl = payload.acl;
|
|
948
|
+
const accessSet = acl !== undefined ? extractAccessSet(acl) : undefined;
|
|
949
|
+
return { session, accessSet };
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
809
953
|
// src/auth/session-store.ts
|
|
810
954
|
var DEFAULT_MAX_SESSIONS_PER_USER = 50;
|
|
811
955
|
var CLEANUP_INTERVAL_MS2 = 60000;
|
|
@@ -880,6 +1024,13 @@ class InMemorySessionStore {
|
|
|
880
1024
|
}
|
|
881
1025
|
return null;
|
|
882
1026
|
}
|
|
1027
|
+
async findActiveSessionById(id) {
|
|
1028
|
+
const session = this.sessions.get(id);
|
|
1029
|
+
if (!session || session.revokedAt || session.expiresAt <= new Date) {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
return session;
|
|
1033
|
+
}
|
|
883
1034
|
async findByPreviousRefreshHash(hash) {
|
|
884
1035
|
for (const session of this.sessions.values()) {
|
|
885
1036
|
if (session.previousRefreshHash !== null && timingSafeEqual(session.previousRefreshHash, hash) && !session.revokedAt && session.expiresAt > new Date) {
|
|
@@ -1050,6 +1201,40 @@ function timingSafeEqual2(a, b) {
|
|
|
1050
1201
|
return equal && a.length === b.length;
|
|
1051
1202
|
}
|
|
1052
1203
|
|
|
1204
|
+
// src/auth/types.ts
|
|
1205
|
+
import {
|
|
1206
|
+
s
|
|
1207
|
+
} from "@vertz/schema";
|
|
1208
|
+
var authEmailFieldSchema = s.string().min(1).trim();
|
|
1209
|
+
var authPasswordFieldSchema = s.string().min(1);
|
|
1210
|
+
var signUpInputSchema = s.object({
|
|
1211
|
+
email: authEmailFieldSchema,
|
|
1212
|
+
password: authPasswordFieldSchema
|
|
1213
|
+
}).passthrough();
|
|
1214
|
+
var signInInputSchema = s.object({
|
|
1215
|
+
email: authEmailFieldSchema,
|
|
1216
|
+
password: authPasswordFieldSchema
|
|
1217
|
+
});
|
|
1218
|
+
var codeInputSchema = s.object({
|
|
1219
|
+
code: s.string().min(1)
|
|
1220
|
+
});
|
|
1221
|
+
var passwordInputSchema = s.object({
|
|
1222
|
+
password: s.string().min(1)
|
|
1223
|
+
});
|
|
1224
|
+
var tokenInputSchema = s.object({
|
|
1225
|
+
token: s.string().min(1).optional()
|
|
1226
|
+
});
|
|
1227
|
+
var forgotPasswordInputSchema = s.object({
|
|
1228
|
+
email: s.string().min(1).trim()
|
|
1229
|
+
});
|
|
1230
|
+
var resetPasswordInputSchema = s.object({
|
|
1231
|
+
token: s.string().min(1).optional(),
|
|
1232
|
+
password: s.string().min(1)
|
|
1233
|
+
});
|
|
1234
|
+
var switchTenantInputSchema = s.object({
|
|
1235
|
+
tenantId: s.string().min(1)
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1053
1238
|
// src/auth/user-store.ts
|
|
1054
1239
|
class InMemoryUserStore {
|
|
1055
1240
|
byEmail = new Map;
|
|
@@ -1079,6 +1264,13 @@ class InMemoryUserStore {
|
|
|
1079
1264
|
user.emailVerified = verified;
|
|
1080
1265
|
}
|
|
1081
1266
|
}
|
|
1267
|
+
async deleteUser(id) {
|
|
1268
|
+
const user = this.byId.get(id);
|
|
1269
|
+
if (user) {
|
|
1270
|
+
this.byEmail.delete(user.email.toLowerCase());
|
|
1271
|
+
this.byId.delete(id);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1082
1274
|
}
|
|
1083
1275
|
// src/auth/access.ts
|
|
1084
1276
|
class AuthorizationError extends Error {
|
|
@@ -1214,11 +1406,13 @@ function createAccessContext(config) {
|
|
|
1214
1406
|
closureStore,
|
|
1215
1407
|
roleStore,
|
|
1216
1408
|
flagStore,
|
|
1217
|
-
|
|
1409
|
+
subscriptionStore,
|
|
1218
1410
|
walletStore,
|
|
1219
|
-
|
|
1411
|
+
overrideStore,
|
|
1412
|
+
orgResolver,
|
|
1413
|
+
planVersionStore
|
|
1220
1414
|
} = config;
|
|
1221
|
-
async function
|
|
1415
|
+
async function checkLayers1to3(entitlement, resource, resolvedOrgId, overrides) {
|
|
1222
1416
|
if (!userId)
|
|
1223
1417
|
return false;
|
|
1224
1418
|
const entDef = accessDef.entitlements[entitlement];
|
|
@@ -1241,29 +1435,44 @@ function createAccessContext(config) {
|
|
|
1241
1435
|
} else if (entDef.roles.length > 0 && !resource) {
|
|
1242
1436
|
return false;
|
|
1243
1437
|
}
|
|
1244
|
-
if (
|
|
1438
|
+
if (accessDef._planGatedEntitlements.has(entitlement) && subscriptionStore && orgResolver) {
|
|
1245
1439
|
if (!resolvedOrgId)
|
|
1246
1440
|
return false;
|
|
1247
|
-
const
|
|
1248
|
-
const effectivePlanId = resolveEffectivePlan(
|
|
1441
|
+
const subscription = await subscriptionStore.get(resolvedOrgId);
|
|
1442
|
+
const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
|
|
1249
1443
|
if (!effectivePlanId)
|
|
1250
1444
|
return false;
|
|
1251
|
-
const
|
|
1252
|
-
if (!
|
|
1445
|
+
const hasFeature = await resolveEffectiveFeatures(resolvedOrgId, entitlement, effectivePlanId, accessDef, subscriptionStore, planVersionStore, overrides);
|
|
1446
|
+
if (!hasFeature)
|
|
1253
1447
|
return false;
|
|
1254
|
-
}
|
|
1255
1448
|
}
|
|
1256
1449
|
return true;
|
|
1257
1450
|
}
|
|
1258
1451
|
async function can(entitlement, resource) {
|
|
1259
1452
|
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1260
|
-
|
|
1453
|
+
const overrides = resolvedOrgId && overrideStore ? await overrideStore.get(resolvedOrgId) : null;
|
|
1454
|
+
if (!await checkLayers1to3(entitlement, resource, resolvedOrgId, overrides))
|
|
1261
1455
|
return false;
|
|
1262
|
-
const
|
|
1263
|
-
if (
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1456
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1457
|
+
if (limitKeys?.length && walletStore && subscriptionStore && resolvedOrgId) {
|
|
1458
|
+
const walletStates = await resolveAllLimitStates(entitlement, resolvedOrgId, accessDef, subscriptionStore, walletStore, planVersionStore, overrides);
|
|
1459
|
+
for (const ws of walletStates) {
|
|
1460
|
+
if (ws.max === 0)
|
|
1461
|
+
return false;
|
|
1462
|
+
if (ws.max === -1)
|
|
1463
|
+
continue;
|
|
1464
|
+
if (ws.consumed >= ws.max) {
|
|
1465
|
+
if (ws.hasOverage) {
|
|
1466
|
+
if (ws.overageCap !== undefined) {
|
|
1467
|
+
const overageUnits = ws.consumed - ws.max;
|
|
1468
|
+
const overageCost = overageUnits * (ws.overageAmount ?? 0) / (ws.overagePer ?? 1);
|
|
1469
|
+
if (overageCost >= ws.overageCap)
|
|
1470
|
+
return false;
|
|
1471
|
+
}
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
return false;
|
|
1475
|
+
}
|
|
1267
1476
|
}
|
|
1268
1477
|
}
|
|
1269
1478
|
return true;
|
|
@@ -1287,6 +1496,7 @@ function createAccessContext(config) {
|
|
|
1287
1496
|
};
|
|
1288
1497
|
}
|
|
1289
1498
|
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1499
|
+
const overrides = resolvedOrgId && overrideStore ? await overrideStore.get(resolvedOrgId) : null;
|
|
1290
1500
|
if (entDef.flags?.length && flagStore && orgResolver) {
|
|
1291
1501
|
if (resolvedOrgId) {
|
|
1292
1502
|
const disabledFlags = [];
|
|
@@ -1315,37 +1525,85 @@ function createAccessContext(config) {
|
|
|
1315
1525
|
meta.requiredRoles = [...entDef.roles];
|
|
1316
1526
|
}
|
|
1317
1527
|
}
|
|
1318
|
-
if (
|
|
1528
|
+
if (accessDef._planGatedEntitlements.has(entitlement) && subscriptionStore && orgResolver) {
|
|
1319
1529
|
let planDenied = false;
|
|
1320
1530
|
if (!resolvedOrgId) {
|
|
1321
1531
|
planDenied = true;
|
|
1322
1532
|
} else {
|
|
1323
|
-
const
|
|
1324
|
-
const effectivePlanId = resolveEffectivePlan(
|
|
1533
|
+
const subscription = await subscriptionStore.get(resolvedOrgId);
|
|
1534
|
+
const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
|
|
1325
1535
|
if (!effectivePlanId) {
|
|
1326
1536
|
planDenied = true;
|
|
1327
1537
|
} else {
|
|
1328
|
-
const
|
|
1329
|
-
if (!
|
|
1538
|
+
const hasFeature = await resolveEffectiveFeatures(resolvedOrgId, entitlement, effectivePlanId, accessDef, subscriptionStore, planVersionStore, overrides);
|
|
1539
|
+
if (!hasFeature) {
|
|
1330
1540
|
planDenied = true;
|
|
1331
1541
|
}
|
|
1332
1542
|
}
|
|
1333
1543
|
}
|
|
1334
1544
|
if (planDenied) {
|
|
1335
1545
|
reasons.push("plan_required");
|
|
1336
|
-
|
|
1546
|
+
const plansWithFeature = [];
|
|
1547
|
+
if (accessDef.plans) {
|
|
1548
|
+
for (const [pName, pDef] of Object.entries(accessDef.plans)) {
|
|
1549
|
+
if (pDef.features?.includes(entitlement)) {
|
|
1550
|
+
plansWithFeature.push(pName);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
meta.requiredPlans = plansWithFeature;
|
|
1337
1555
|
}
|
|
1338
1556
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1557
|
+
const checkLimitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1558
|
+
if (checkLimitKeys?.length && walletStore && subscriptionStore && resolvedOrgId) {
|
|
1559
|
+
const walletStates = await resolveAllLimitStates(entitlement, resolvedOrgId, accessDef, subscriptionStore, walletStore, planVersionStore, overrides);
|
|
1560
|
+
for (const ws of walletStates) {
|
|
1561
|
+
const exceeded = ws.max === 0 || ws.max !== -1 && ws.consumed >= ws.max;
|
|
1562
|
+
if (exceeded) {
|
|
1563
|
+
if (ws.hasOverage && ws.max !== 0) {
|
|
1564
|
+
let capHit = false;
|
|
1565
|
+
if (ws.overageCap !== undefined) {
|
|
1566
|
+
const overageUnits = ws.consumed - ws.max;
|
|
1567
|
+
const overageCost = overageUnits * (ws.overageAmount ?? 0) / (ws.overagePer ?? 1);
|
|
1568
|
+
if (overageCost >= ws.overageCap)
|
|
1569
|
+
capHit = true;
|
|
1570
|
+
}
|
|
1571
|
+
if (capHit) {
|
|
1572
|
+
reasons.push("limit_reached");
|
|
1573
|
+
meta.limit = {
|
|
1574
|
+
key: ws.key,
|
|
1575
|
+
max: ws.max,
|
|
1576
|
+
consumed: ws.consumed,
|
|
1577
|
+
remaining: 0,
|
|
1578
|
+
overage: true
|
|
1579
|
+
};
|
|
1580
|
+
break;
|
|
1581
|
+
}
|
|
1582
|
+
meta.limit = {
|
|
1583
|
+
key: ws.key,
|
|
1584
|
+
max: ws.max,
|
|
1585
|
+
consumed: ws.consumed,
|
|
1586
|
+
remaining: 0,
|
|
1587
|
+
overage: true
|
|
1588
|
+
};
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1348
1591
|
reasons.push("limit_reached");
|
|
1592
|
+
meta.limit = {
|
|
1593
|
+
key: ws.key,
|
|
1594
|
+
max: ws.max,
|
|
1595
|
+
consumed: ws.consumed,
|
|
1596
|
+
remaining: Math.max(0, ws.max === -1 ? Infinity : ws.max - ws.consumed)
|
|
1597
|
+
};
|
|
1598
|
+
break;
|
|
1599
|
+
}
|
|
1600
|
+
if (!meta.limit) {
|
|
1601
|
+
meta.limit = {
|
|
1602
|
+
key: ws.key,
|
|
1603
|
+
max: ws.max,
|
|
1604
|
+
consumed: ws.consumed,
|
|
1605
|
+
remaining: ws.max === -1 ? Infinity : Math.max(0, ws.max - ws.consumed)
|
|
1606
|
+
};
|
|
1349
1607
|
}
|
|
1350
1608
|
}
|
|
1351
1609
|
}
|
|
@@ -1375,57 +1633,96 @@ function createAccessContext(config) {
|
|
|
1375
1633
|
}
|
|
1376
1634
|
return results;
|
|
1377
1635
|
}
|
|
1636
|
+
async function canBatch(entitlement, resources) {
|
|
1637
|
+
if (resources.length > MAX_BULK_CHECKS) {
|
|
1638
|
+
throw new Error(`canBatch() is limited to ${MAX_BULK_CHECKS} resources per call`);
|
|
1639
|
+
}
|
|
1640
|
+
const results = new Map;
|
|
1641
|
+
for (const resource of resources) {
|
|
1642
|
+
const allowed = await can(entitlement, resource);
|
|
1643
|
+
results.set(resource.id, allowed);
|
|
1644
|
+
}
|
|
1645
|
+
return results;
|
|
1646
|
+
}
|
|
1378
1647
|
async function canAndConsume(entitlement, resource, amount = 1) {
|
|
1379
1648
|
const resolvedOrgId = orgResolver ? await orgResolver(resource) : null;
|
|
1380
|
-
|
|
1649
|
+
const overrides = resolvedOrgId && overrideStore ? await overrideStore.get(resolvedOrgId) : null;
|
|
1650
|
+
if (!await checkLayers1to3(entitlement, resource, resolvedOrgId, overrides))
|
|
1381
1651
|
return false;
|
|
1382
|
-
if (!walletStore || !
|
|
1652
|
+
if (!walletStore || !subscriptionStore || !orgResolver)
|
|
1383
1653
|
return true;
|
|
1384
|
-
const
|
|
1385
|
-
if (!
|
|
1654
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1655
|
+
if (!limitKeys?.length)
|
|
1386
1656
|
return true;
|
|
1387
1657
|
if (!resolvedOrgId)
|
|
1388
1658
|
return false;
|
|
1389
|
-
const
|
|
1390
|
-
if (!
|
|
1659
|
+
const subscription = await subscriptionStore.get(resolvedOrgId);
|
|
1660
|
+
if (!subscription)
|
|
1391
1661
|
return false;
|
|
1392
|
-
const effectivePlanId = resolveEffectivePlan(
|
|
1662
|
+
const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
|
|
1393
1663
|
if (!effectivePlanId)
|
|
1394
1664
|
return false;
|
|
1395
|
-
const
|
|
1396
|
-
if (!
|
|
1397
|
-
return false;
|
|
1398
|
-
const limitDef = planDef.limits?.[entitlement];
|
|
1399
|
-
if (!limitDef)
|
|
1665
|
+
const limitsToConsume = await resolveAllLimitConsumptions(resolvedOrgId, entitlement, effectivePlanId, accessDef, subscriptionStore, walletStore, subscription, planVersionStore, overrides);
|
|
1666
|
+
if (!limitsToConsume.length)
|
|
1400
1667
|
return true;
|
|
1401
|
-
const
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1668
|
+
const consumed = [];
|
|
1669
|
+
for (const lc of limitsToConsume) {
|
|
1670
|
+
if (lc.effectiveMax === -1)
|
|
1671
|
+
continue;
|
|
1672
|
+
if (lc.effectiveMax === 0) {
|
|
1673
|
+
await rollbackConsumptions(resolvedOrgId, consumed, walletStore, amount);
|
|
1674
|
+
return false;
|
|
1675
|
+
}
|
|
1676
|
+
let consumeMax = lc.effectiveMax;
|
|
1677
|
+
if (lc.hasOverage) {
|
|
1678
|
+
if (lc.overageCap !== undefined) {
|
|
1679
|
+
const currentConsumed = await walletStore.getConsumption(resolvedOrgId, lc.walletKey, lc.periodStart, lc.periodEnd);
|
|
1680
|
+
const overageUnits = Math.max(0, currentConsumed + amount - lc.effectiveMax);
|
|
1681
|
+
const overageCost = overageUnits * (lc.overageAmount ?? 0) / (lc.overagePer ?? 1);
|
|
1682
|
+
if (overageCost > lc.overageCap) {
|
|
1683
|
+
await rollbackConsumptions(resolvedOrgId, consumed, walletStore, amount);
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
consumeMax = Number.MAX_SAFE_INTEGER;
|
|
1688
|
+
}
|
|
1689
|
+
const result = await walletStore.consume(resolvedOrgId, lc.walletKey, lc.periodStart, lc.periodEnd, consumeMax, amount);
|
|
1690
|
+
if (!result.success) {
|
|
1691
|
+
await rollbackConsumptions(resolvedOrgId, consumed, walletStore, amount);
|
|
1692
|
+
return false;
|
|
1693
|
+
}
|
|
1694
|
+
consumed.push({
|
|
1695
|
+
key: lc.walletKey,
|
|
1696
|
+
periodStart: lc.periodStart,
|
|
1697
|
+
periodEnd: lc.periodEnd
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
return true;
|
|
1405
1701
|
}
|
|
1406
1702
|
async function unconsume(entitlement, resource, amount = 1) {
|
|
1407
|
-
if (!walletStore || !
|
|
1703
|
+
if (!walletStore || !subscriptionStore || !orgResolver)
|
|
1408
1704
|
return;
|
|
1409
|
-
const
|
|
1410
|
-
if (!
|
|
1705
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1706
|
+
if (!limitKeys?.length)
|
|
1411
1707
|
return;
|
|
1412
1708
|
const orgId = await orgResolver(resource);
|
|
1413
1709
|
if (!orgId)
|
|
1414
1710
|
return;
|
|
1415
|
-
const
|
|
1416
|
-
|
|
1711
|
+
const overrides = overrideStore ? await overrideStore.get(orgId) : null;
|
|
1712
|
+
const subscription = await subscriptionStore.get(orgId);
|
|
1713
|
+
if (!subscription)
|
|
1417
1714
|
return;
|
|
1418
|
-
const effectivePlanId = resolveEffectivePlan(
|
|
1715
|
+
const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
|
|
1419
1716
|
if (!effectivePlanId)
|
|
1420
1717
|
return;
|
|
1421
|
-
const
|
|
1422
|
-
const
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1718
|
+
const limitsToUnconsume = await resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId, accessDef, subscriptionStore, walletStore, subscription, planVersionStore, overrides);
|
|
1719
|
+
for (const lc of limitsToUnconsume) {
|
|
1720
|
+
if (lc.effectiveMax === -1)
|
|
1721
|
+
continue;
|
|
1722
|
+
await walletStore.unconsume(orgId, lc.walletKey, lc.periodStart, lc.periodEnd, amount);
|
|
1723
|
+
}
|
|
1427
1724
|
}
|
|
1428
|
-
return { can, check, authorize, canAll, canAndConsume, unconsume };
|
|
1725
|
+
return { can, check, authorize, canAll, canBatch, canAndConsume, unconsume };
|
|
1429
1726
|
}
|
|
1430
1727
|
var DENIAL_ORDER = [
|
|
1431
1728
|
"plan_required",
|
|
@@ -1439,25 +1736,174 @@ var DENIAL_ORDER = [
|
|
|
1439
1736
|
function orderDenialReasons(reasons) {
|
|
1440
1737
|
return [...reasons].sort((a, b) => DENIAL_ORDER.indexOf(a) - DENIAL_ORDER.indexOf(b));
|
|
1441
1738
|
}
|
|
1442
|
-
async function
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1739
|
+
async function resolveEffectiveFeatures(orgId, entitlement, effectivePlanId, accessDef, subscriptionStore, planVersionStore, overrides) {
|
|
1740
|
+
if (planVersionStore) {
|
|
1741
|
+
const tenantVersion = await planVersionStore.getTenantVersion(orgId, effectivePlanId);
|
|
1742
|
+
if (tenantVersion !== null) {
|
|
1743
|
+
const versionInfo = await planVersionStore.getVersion(effectivePlanId, tenantVersion);
|
|
1744
|
+
if (versionInfo) {
|
|
1745
|
+
const snapshotFeatures = versionInfo.snapshot.features;
|
|
1746
|
+
if (snapshotFeatures.includes(entitlement))
|
|
1747
|
+
return true;
|
|
1748
|
+
const addOns2 = await subscriptionStore.getAddOns?.(orgId);
|
|
1749
|
+
if (addOns2) {
|
|
1750
|
+
for (const addOnId of addOns2) {
|
|
1751
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
1752
|
+
if (addOnDef?.features?.includes(entitlement))
|
|
1753
|
+
return true;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
if (overrides?.features?.includes(entitlement))
|
|
1757
|
+
return true;
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1447
1762
|
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1763
|
+
if (planDef?.features?.includes(entitlement))
|
|
1764
|
+
return true;
|
|
1765
|
+
const addOns = await subscriptionStore.getAddOns?.(orgId);
|
|
1766
|
+
if (addOns) {
|
|
1767
|
+
for (const addOnId of addOns) {
|
|
1768
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
1769
|
+
if (addOnDef?.features?.includes(entitlement))
|
|
1770
|
+
return true;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (overrides?.features?.includes(entitlement))
|
|
1774
|
+
return true;
|
|
1775
|
+
return false;
|
|
1776
|
+
}
|
|
1777
|
+
async function resolveAllLimitStates(entitlement, orgId, accessDef, subscriptionStore, walletStore, planVersionStore, overrides) {
|
|
1778
|
+
const subscription = await subscriptionStore.get(orgId);
|
|
1779
|
+
const effectivePlanId = resolveEffectivePlan(subscription, accessDef.plans, accessDef.defaultPlan);
|
|
1780
|
+
if (!effectivePlanId || !subscription)
|
|
1781
|
+
return [];
|
|
1782
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1783
|
+
if (!limitKeys?.length)
|
|
1784
|
+
return [];
|
|
1785
|
+
let versionedLimits = null;
|
|
1786
|
+
if (planVersionStore) {
|
|
1787
|
+
const tenantVersion = await planVersionStore.getTenantVersion(orgId, effectivePlanId);
|
|
1788
|
+
if (tenantVersion !== null) {
|
|
1789
|
+
const versionInfo = await planVersionStore.getVersion(effectivePlanId, tenantVersion);
|
|
1790
|
+
if (versionInfo) {
|
|
1791
|
+
versionedLimits = versionInfo.snapshot.limits;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
const states = [];
|
|
1796
|
+
for (const limitKey of limitKeys) {
|
|
1797
|
+
let limitDef;
|
|
1798
|
+
if (versionedLimits && limitKey in versionedLimits) {
|
|
1799
|
+
limitDef = versionedLimits[limitKey];
|
|
1800
|
+
} else {
|
|
1801
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1802
|
+
limitDef = planDef?.limits?.[limitKey];
|
|
1803
|
+
}
|
|
1804
|
+
if (!limitDef)
|
|
1805
|
+
continue;
|
|
1806
|
+
const effectiveMax = computeEffectiveLimit(limitDef.max, limitKey, orgId, subscription, accessDef, subscriptionStore, overrides);
|
|
1807
|
+
const resolvedMax = await effectiveMax;
|
|
1808
|
+
const hasOverage = !!limitDef.overage;
|
|
1809
|
+
if (resolvedMax === -1) {
|
|
1810
|
+
states.push({ key: limitKey, max: -1, consumed: 0, hasOverage });
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
const walletKey = limitKey;
|
|
1814
|
+
const period = limitDef.per ? calculateBillingPeriod(subscription.startedAt, limitDef.per) : { periodStart: subscription.startedAt, periodEnd: new Date("9999-12-31T23:59:59Z") };
|
|
1815
|
+
const consumed = await walletStore.getConsumption(orgId, walletKey, period.periodStart, period.periodEnd);
|
|
1816
|
+
states.push({
|
|
1817
|
+
key: limitKey,
|
|
1818
|
+
max: resolvedMax,
|
|
1819
|
+
consumed,
|
|
1820
|
+
hasOverage,
|
|
1821
|
+
...limitDef.overage ? {
|
|
1822
|
+
overageCap: limitDef.overage.cap,
|
|
1823
|
+
overageAmount: limitDef.overage.amount,
|
|
1824
|
+
overagePer: limitDef.overage.per
|
|
1825
|
+
} : {}
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
return states;
|
|
1829
|
+
}
|
|
1830
|
+
async function resolveAllLimitConsumptions(orgId, entitlement, effectivePlanId, accessDef, subscriptionStore, _walletStore, subscription, planVersionStore, overrides) {
|
|
1831
|
+
const limitKeys = accessDef._entitlementToLimitKeys[entitlement];
|
|
1832
|
+
if (!limitKeys?.length)
|
|
1833
|
+
return [];
|
|
1834
|
+
let versionedLimits = null;
|
|
1835
|
+
if (planVersionStore) {
|
|
1836
|
+
const tenantVersion = await planVersionStore.getTenantVersion(orgId, effectivePlanId);
|
|
1837
|
+
if (tenantVersion !== null) {
|
|
1838
|
+
const versionInfo = await planVersionStore.getVersion(effectivePlanId, tenantVersion);
|
|
1839
|
+
if (versionInfo) {
|
|
1840
|
+
versionedLimits = versionInfo.snapshot.limits;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
const consumptions = [];
|
|
1845
|
+
for (const limitKey of limitKeys) {
|
|
1846
|
+
let limitDef;
|
|
1847
|
+
if (versionedLimits && limitKey in versionedLimits) {
|
|
1848
|
+
limitDef = versionedLimits[limitKey];
|
|
1849
|
+
} else {
|
|
1850
|
+
const planDef = accessDef.plans?.[effectivePlanId];
|
|
1851
|
+
limitDef = planDef?.limits?.[limitKey];
|
|
1852
|
+
}
|
|
1853
|
+
if (!limitDef)
|
|
1854
|
+
continue;
|
|
1855
|
+
const effectiveMax = await computeEffectiveLimit(limitDef.max, limitKey, orgId, subscription, accessDef, subscriptionStore, overrides);
|
|
1856
|
+
const walletKey = limitKey;
|
|
1857
|
+
const period = limitDef.per ? calculateBillingPeriod(subscription.startedAt, limitDef.per) : { periodStart: subscription.startedAt, periodEnd: new Date("9999-12-31T23:59:59Z") };
|
|
1858
|
+
consumptions.push({
|
|
1859
|
+
walletKey,
|
|
1860
|
+
periodStart: period.periodStart,
|
|
1861
|
+
periodEnd: period.periodEnd,
|
|
1862
|
+
effectiveMax,
|
|
1863
|
+
hasOverage: !!limitDef.overage,
|
|
1864
|
+
...limitDef.overage ? {
|
|
1865
|
+
overageCap: limitDef.overage.cap,
|
|
1866
|
+
overageAmount: limitDef.overage.amount,
|
|
1867
|
+
overagePer: limitDef.overage.per
|
|
1868
|
+
} : {}
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
return consumptions;
|
|
1872
|
+
}
|
|
1873
|
+
async function computeEffectiveLimit(basePlanMax, limitKey, orgId, subscription, accessDef, subscriptionStore, overrides) {
|
|
1874
|
+
let effectiveMax = basePlanMax;
|
|
1875
|
+
const addOns = await subscriptionStore.getAddOns?.(orgId);
|
|
1876
|
+
if (addOns) {
|
|
1877
|
+
for (const addOnId of addOns) {
|
|
1878
|
+
const addOnDef = accessDef.plans?.[addOnId];
|
|
1879
|
+
const addOnLimit = addOnDef?.limits?.[limitKey];
|
|
1880
|
+
if (addOnLimit) {
|
|
1881
|
+
if (effectiveMax === -1)
|
|
1882
|
+
break;
|
|
1883
|
+
effectiveMax += addOnLimit.max;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
const oldOverride = subscription.overrides[limitKey];
|
|
1888
|
+
if (oldOverride) {
|
|
1889
|
+
effectiveMax = Math.max(effectiveMax, oldOverride.max);
|
|
1890
|
+
}
|
|
1891
|
+
const limitOverride = overrides?.limits?.[limitKey];
|
|
1892
|
+
if (limitOverride) {
|
|
1893
|
+
if (limitOverride.max !== undefined) {
|
|
1894
|
+
effectiveMax = limitOverride.max;
|
|
1895
|
+
} else if (limitOverride.add !== undefined) {
|
|
1896
|
+
if (effectiveMax !== -1) {
|
|
1897
|
+
effectiveMax = Math.max(0, effectiveMax + limitOverride.add);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
return effectiveMax;
|
|
1455
1902
|
}
|
|
1456
|
-
function
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
return Math.max(override.max, planLimit.max);
|
|
1903
|
+
async function rollbackConsumptions(orgId, consumed, walletStore, amount) {
|
|
1904
|
+
for (const c of consumed) {
|
|
1905
|
+
await walletStore.unconsume(orgId, c.key, c.periodStart, c.periodEnd, amount);
|
|
1906
|
+
}
|
|
1461
1907
|
}
|
|
1462
1908
|
// src/auth/access-event-broadcaster.ts
|
|
1463
1909
|
function createAccessEventBroadcaster(config) {
|
|
@@ -1605,6 +2051,22 @@ function createAccessEventBroadcaster(config) {
|
|
|
1605
2051
|
const event = { type: "access:plan_changed", orgId };
|
|
1606
2052
|
broadcastToOrg(orgId, JSON.stringify(event));
|
|
1607
2053
|
}
|
|
2054
|
+
function broadcastPlanAssigned(orgId, planId) {
|
|
2055
|
+
const event = { type: "access:plan_assigned", orgId, planId };
|
|
2056
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
2057
|
+
}
|
|
2058
|
+
function broadcastAddonAttached(orgId, addonId) {
|
|
2059
|
+
const event = { type: "access:addon_attached", orgId, addonId };
|
|
2060
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
2061
|
+
}
|
|
2062
|
+
function broadcastAddonDetached(orgId, addonId) {
|
|
2063
|
+
const event = { type: "access:addon_detached", orgId, addonId };
|
|
2064
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
2065
|
+
}
|
|
2066
|
+
function broadcastLimitReset(orgId, entitlement, max) {
|
|
2067
|
+
const event = { type: "access:limit_reset", orgId, entitlement, max };
|
|
2068
|
+
broadcastToOrg(orgId, JSON.stringify(event));
|
|
2069
|
+
}
|
|
1608
2070
|
return {
|
|
1609
2071
|
handleUpgrade,
|
|
1610
2072
|
websocket,
|
|
@@ -1612,148 +2074,1286 @@ function createAccessEventBroadcaster(config) {
|
|
|
1612
2074
|
broadcastLimitUpdate,
|
|
1613
2075
|
broadcastRoleChange,
|
|
1614
2076
|
broadcastPlanChange,
|
|
2077
|
+
broadcastPlanAssigned,
|
|
2078
|
+
broadcastAddonAttached,
|
|
2079
|
+
broadcastAddonDetached,
|
|
2080
|
+
broadcastLimitReset,
|
|
1615
2081
|
get getConnectionCount() {
|
|
1616
2082
|
return connectionCount;
|
|
1617
2083
|
}
|
|
1618
2084
|
};
|
|
1619
2085
|
}
|
|
1620
|
-
// src/auth/
|
|
1621
|
-
|
|
2086
|
+
// src/auth/auth-models.ts
|
|
2087
|
+
import { d } from "@vertz/db";
|
|
2088
|
+
var authUsersTable = d.table("auth_users", {
|
|
2089
|
+
id: d.text().primary(),
|
|
2090
|
+
email: d.text(),
|
|
2091
|
+
passwordHash: d.text().nullable(),
|
|
2092
|
+
role: d.text().default("user"),
|
|
2093
|
+
plan: d.text().nullable(),
|
|
2094
|
+
emailVerified: d.boolean().default(false),
|
|
2095
|
+
createdAt: d.timestamp().default("now"),
|
|
2096
|
+
updatedAt: d.timestamp().default("now")
|
|
2097
|
+
});
|
|
2098
|
+
var authSessionsTable = d.table("auth_sessions", {
|
|
2099
|
+
id: d.text().primary(),
|
|
2100
|
+
userId: d.text(),
|
|
2101
|
+
refreshTokenHash: d.text(),
|
|
2102
|
+
previousRefreshHash: d.text().nullable(),
|
|
2103
|
+
currentTokens: d.text().nullable(),
|
|
2104
|
+
ipAddress: d.text(),
|
|
2105
|
+
userAgent: d.text(),
|
|
2106
|
+
createdAt: d.timestamp().default("now"),
|
|
2107
|
+
lastActiveAt: d.timestamp().default("now"),
|
|
2108
|
+
expiresAt: d.timestamp(),
|
|
2109
|
+
revokedAt: d.timestamp().nullable()
|
|
2110
|
+
});
|
|
2111
|
+
var authOauthAccountsTable = d.table("auth_oauth_accounts", {
|
|
2112
|
+
id: d.text().primary(),
|
|
2113
|
+
userId: d.text(),
|
|
2114
|
+
provider: d.text(),
|
|
2115
|
+
providerId: d.text(),
|
|
2116
|
+
email: d.text().nullable(),
|
|
2117
|
+
createdAt: d.timestamp().default("now")
|
|
2118
|
+
});
|
|
2119
|
+
var authRoleAssignmentsTable = d.table("auth_role_assignments", {
|
|
2120
|
+
id: d.text().primary(),
|
|
2121
|
+
userId: d.text(),
|
|
2122
|
+
resourceType: d.text(),
|
|
2123
|
+
resourceId: d.text(),
|
|
2124
|
+
role: d.text(),
|
|
2125
|
+
createdAt: d.timestamp().default("now")
|
|
2126
|
+
});
|
|
2127
|
+
var authClosureTable = d.table("auth_closure", {
|
|
2128
|
+
id: d.text().primary(),
|
|
2129
|
+
ancestorType: d.text(),
|
|
2130
|
+
ancestorId: d.text(),
|
|
2131
|
+
descendantType: d.text(),
|
|
2132
|
+
descendantId: d.text(),
|
|
2133
|
+
depth: d.integer()
|
|
2134
|
+
});
|
|
2135
|
+
var authPlansTable = d.table("auth_plans", {
|
|
2136
|
+
id: d.text().primary(),
|
|
2137
|
+
tenantId: d.text(),
|
|
2138
|
+
planId: d.text(),
|
|
2139
|
+
startedAt: d.timestamp().default("now"),
|
|
2140
|
+
expiresAt: d.timestamp().nullable()
|
|
2141
|
+
});
|
|
2142
|
+
var authPlanAddonsTable = d.table("auth_plan_addons", {
|
|
2143
|
+
id: d.text().primary(),
|
|
2144
|
+
tenantId: d.text(),
|
|
2145
|
+
addonId: d.text(),
|
|
2146
|
+
isOneOff: d.boolean().default(false),
|
|
2147
|
+
quantity: d.integer().default(1),
|
|
2148
|
+
createdAt: d.timestamp().default("now")
|
|
2149
|
+
});
|
|
2150
|
+
var authFlagsTable = d.table("auth_flags", {
|
|
2151
|
+
id: d.text().primary(),
|
|
2152
|
+
tenantId: d.text(),
|
|
2153
|
+
flag: d.text(),
|
|
2154
|
+
enabled: d.boolean().default(false)
|
|
2155
|
+
});
|
|
2156
|
+
var authOverridesTable = d.table("auth_overrides", {
|
|
2157
|
+
id: d.text().primary(),
|
|
2158
|
+
tenantId: d.text(),
|
|
2159
|
+
overrides: d.text(),
|
|
2160
|
+
updatedAt: d.timestamp().default("now")
|
|
2161
|
+
});
|
|
2162
|
+
var authModels = {
|
|
2163
|
+
auth_users: d.model(authUsersTable),
|
|
2164
|
+
auth_sessions: d.model(authSessionsTable),
|
|
2165
|
+
auth_oauth_accounts: d.model(authOauthAccountsTable),
|
|
2166
|
+
auth_role_assignments: d.model(authRoleAssignmentsTable),
|
|
2167
|
+
auth_closure: d.model(authClosureTable),
|
|
2168
|
+
auth_plans: d.model(authPlansTable),
|
|
2169
|
+
auth_plan_addons: d.model(authPlanAddonsTable),
|
|
2170
|
+
auth_flags: d.model(authFlagsTable),
|
|
2171
|
+
auth_overrides: d.model(authOverridesTable)
|
|
2172
|
+
};
|
|
2173
|
+
// src/auth/auth-tables.ts
|
|
2174
|
+
import { sql } from "@vertz/db/sql";
|
|
1622
2175
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
2176
|
+
// src/auth/dialect-ddl.ts
|
|
2177
|
+
function dialectDDL(dialect) {
|
|
2178
|
+
return {
|
|
2179
|
+
boolean: (def) => dialect === "sqlite" ? `INTEGER NOT NULL DEFAULT ${def ? 1 : 0}` : `BOOLEAN NOT NULL DEFAULT ${def}`,
|
|
2180
|
+
timestamp: () => dialect === "sqlite" ? "TEXT NOT NULL" : "TIMESTAMPTZ NOT NULL",
|
|
2181
|
+
timestampNullable: () => dialect === "sqlite" ? "TEXT" : "TIMESTAMPTZ",
|
|
2182
|
+
text: () => "TEXT",
|
|
2183
|
+
textPrimary: () => "TEXT PRIMARY KEY",
|
|
2184
|
+
integer: () => "INTEGER"
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// src/auth/auth-tables.ts
|
|
2189
|
+
function generateAuthDDL(dialect) {
|
|
2190
|
+
const t = dialectDDL(dialect);
|
|
2191
|
+
const statements = [];
|
|
2192
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_users (
|
|
2193
|
+
id ${t.textPrimary()},
|
|
2194
|
+
email ${t.text()} NOT NULL UNIQUE,
|
|
2195
|
+
password_hash ${t.text()},
|
|
2196
|
+
role ${t.text()} NOT NULL DEFAULT 'user',
|
|
2197
|
+
email_verified ${t.boolean(false)},
|
|
2198
|
+
created_at ${t.timestamp()},
|
|
2199
|
+
updated_at ${t.timestamp()}
|
|
2200
|
+
)`);
|
|
2201
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
2202
|
+
id ${t.textPrimary()},
|
|
2203
|
+
user_id ${t.text()} NOT NULL,
|
|
2204
|
+
refresh_token_hash ${t.text()} NOT NULL,
|
|
2205
|
+
previous_refresh_hash ${t.text()},
|
|
2206
|
+
current_tokens ${t.text()},
|
|
2207
|
+
ip_address ${t.text()} NOT NULL,
|
|
2208
|
+
user_agent ${t.text()} NOT NULL,
|
|
2209
|
+
created_at ${t.timestamp()},
|
|
2210
|
+
last_active_at ${t.timestamp()},
|
|
2211
|
+
expires_at ${t.timestamp()},
|
|
2212
|
+
revoked_at ${t.timestampNullable()}
|
|
2213
|
+
)`);
|
|
2214
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_oauth_accounts (
|
|
2215
|
+
id ${t.textPrimary()},
|
|
2216
|
+
user_id ${t.text()} NOT NULL,
|
|
2217
|
+
provider ${t.text()} NOT NULL,
|
|
2218
|
+
provider_id ${t.text()} NOT NULL,
|
|
2219
|
+
email ${t.text()},
|
|
2220
|
+
created_at ${t.timestamp()},
|
|
2221
|
+
UNIQUE(provider, provider_id)
|
|
2222
|
+
)`);
|
|
2223
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_role_assignments (
|
|
2224
|
+
id ${t.textPrimary()},
|
|
2225
|
+
user_id ${t.text()} NOT NULL,
|
|
2226
|
+
resource_type ${t.text()} NOT NULL,
|
|
2227
|
+
resource_id ${t.text()} NOT NULL,
|
|
2228
|
+
role ${t.text()} NOT NULL,
|
|
2229
|
+
created_at ${t.timestamp()},
|
|
2230
|
+
UNIQUE(user_id, resource_type, resource_id, role)
|
|
2231
|
+
)`);
|
|
2232
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_closure (
|
|
2233
|
+
id ${t.textPrimary()},
|
|
2234
|
+
ancestor_type ${t.text()} NOT NULL,
|
|
2235
|
+
ancestor_id ${t.text()} NOT NULL,
|
|
2236
|
+
descendant_type ${t.text()} NOT NULL,
|
|
2237
|
+
descendant_id ${t.text()} NOT NULL,
|
|
2238
|
+
depth ${t.integer()} NOT NULL,
|
|
2239
|
+
UNIQUE(ancestor_type, ancestor_id, descendant_type, descendant_id)
|
|
2240
|
+
)`);
|
|
2241
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_plans (
|
|
2242
|
+
id ${t.textPrimary()},
|
|
2243
|
+
tenant_id ${t.text()} NOT NULL UNIQUE,
|
|
2244
|
+
plan_id ${t.text()} NOT NULL,
|
|
2245
|
+
started_at ${t.timestamp()},
|
|
2246
|
+
expires_at ${t.timestampNullable()}
|
|
2247
|
+
)`);
|
|
2248
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_plan_addons (
|
|
2249
|
+
id ${t.textPrimary()},
|
|
2250
|
+
tenant_id ${t.text()} NOT NULL,
|
|
2251
|
+
addon_id ${t.text()} NOT NULL,
|
|
2252
|
+
is_one_off ${t.boolean(false)},
|
|
2253
|
+
quantity ${t.integer()} NOT NULL DEFAULT 1,
|
|
2254
|
+
created_at ${t.timestamp()},
|
|
2255
|
+
UNIQUE(tenant_id, addon_id)
|
|
2256
|
+
)`);
|
|
2257
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_flags (
|
|
2258
|
+
id ${t.textPrimary()},
|
|
2259
|
+
tenant_id ${t.text()} NOT NULL,
|
|
2260
|
+
flag ${t.text()} NOT NULL,
|
|
2261
|
+
enabled ${t.boolean(false)},
|
|
2262
|
+
UNIQUE(tenant_id, flag)
|
|
2263
|
+
)`);
|
|
2264
|
+
statements.push(`CREATE TABLE IF NOT EXISTS auth_overrides (
|
|
2265
|
+
id ${t.textPrimary()},
|
|
2266
|
+
tenant_id ${t.text()} NOT NULL UNIQUE,
|
|
2267
|
+
overrides ${t.text()} NOT NULL,
|
|
2268
|
+
updated_at ${t.timestamp()}
|
|
2269
|
+
)`);
|
|
2270
|
+
return statements;
|
|
2271
|
+
}
|
|
2272
|
+
var AUTH_TABLE_NAMES = [
|
|
2273
|
+
"auth_users",
|
|
2274
|
+
"auth_sessions",
|
|
2275
|
+
"auth_oauth_accounts",
|
|
2276
|
+
"auth_role_assignments",
|
|
2277
|
+
"auth_closure",
|
|
2278
|
+
"auth_plans",
|
|
2279
|
+
"auth_plan_addons",
|
|
2280
|
+
"auth_flags",
|
|
2281
|
+
"auth_overrides"
|
|
2282
|
+
];
|
|
2283
|
+
function validateAuthModels(db) {
|
|
2284
|
+
const dbModels = db._internals.models;
|
|
2285
|
+
const missing = AUTH_TABLE_NAMES.filter((m) => !(m in dbModels));
|
|
2286
|
+
if (missing.length > 0) {
|
|
2287
|
+
throw new Error(`Auth requires models ${missing.map((m) => `"${m}"`).join(", ")} in createDb(). ` + "Add authModels to your createDb() call: createDb({ models: { ...authModels, ...yourModels } })");
|
|
1650
2288
|
}
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
return !descendantKeys.has(ancestorKey) && !descendantKeys.has(descendantKey);
|
|
1658
|
-
});
|
|
2289
|
+
}
|
|
2290
|
+
async function initializeAuthTables(db) {
|
|
2291
|
+
const dialectName = db._internals.dialect.name;
|
|
2292
|
+
const statements = generateAuthDDL(dialectName);
|
|
2293
|
+
for (const ddl of statements) {
|
|
2294
|
+
await db.query(sql.raw(ddl));
|
|
1659
2295
|
}
|
|
1660
|
-
|
|
2296
|
+
}
|
|
2297
|
+
// src/auth/billing/event-emitter.ts
|
|
2298
|
+
function createBillingEventEmitter() {
|
|
2299
|
+
const handlers = new Map;
|
|
2300
|
+
return {
|
|
2301
|
+
on(eventType, handler) {
|
|
2302
|
+
const list = handlers.get(eventType) ?? [];
|
|
2303
|
+
list.push(handler);
|
|
2304
|
+
handlers.set(eventType, list);
|
|
2305
|
+
},
|
|
2306
|
+
off(eventType, handler) {
|
|
2307
|
+
const list = handlers.get(eventType);
|
|
2308
|
+
if (!list)
|
|
2309
|
+
return;
|
|
2310
|
+
const idx = list.indexOf(handler);
|
|
2311
|
+
if (idx >= 0)
|
|
2312
|
+
list.splice(idx, 1);
|
|
2313
|
+
},
|
|
2314
|
+
emit(eventType, event) {
|
|
2315
|
+
const list = handlers.get(eventType);
|
|
2316
|
+
if (!list)
|
|
2317
|
+
return;
|
|
2318
|
+
for (const handler of list) {
|
|
2319
|
+
try {
|
|
2320
|
+
handler(event);
|
|
2321
|
+
} catch {}
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
// src/auth/billing/overage.ts
|
|
2327
|
+
function computeOverage(input) {
|
|
2328
|
+
const { consumed, max, rate } = input;
|
|
2329
|
+
if (max === -1)
|
|
2330
|
+
return 0;
|
|
2331
|
+
if (consumed <= max)
|
|
2332
|
+
return 0;
|
|
2333
|
+
const overageUnits = consumed - max;
|
|
2334
|
+
const charge = overageUnits * rate;
|
|
2335
|
+
if (input.cap !== undefined && charge > input.cap) {
|
|
2336
|
+
return input.cap;
|
|
2337
|
+
}
|
|
2338
|
+
return charge;
|
|
2339
|
+
}
|
|
2340
|
+
// src/auth/billing/stripe-adapter.ts
|
|
2341
|
+
var INTERVAL_MAP = {
|
|
2342
|
+
month: "month",
|
|
2343
|
+
quarter: "month",
|
|
2344
|
+
year: "year"
|
|
2345
|
+
};
|
|
2346
|
+
function mapInterval(interval) {
|
|
2347
|
+
if (interval === "quarter") {
|
|
2348
|
+
return { interval: "month", interval_count: 3 };
|
|
2349
|
+
}
|
|
2350
|
+
return { interval: INTERVAL_MAP[interval] ?? "month" };
|
|
2351
|
+
}
|
|
2352
|
+
function createStripeBillingAdapter(config) {
|
|
2353
|
+
const { stripe, currency = "usd" } = config;
|
|
2354
|
+
async function syncPlans(plans) {
|
|
2355
|
+
const existing = await stripe.products.list();
|
|
2356
|
+
const existingByPlanId = new Map;
|
|
2357
|
+
for (const product of existing.data) {
|
|
2358
|
+
if (product.metadata.vertzPlanId) {
|
|
2359
|
+
existingByPlanId.set(product.metadata.vertzPlanId, product);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
for (const [planId, planDef] of Object.entries(plans)) {
|
|
2363
|
+
const metadata = {
|
|
2364
|
+
vertzPlanId: planId
|
|
2365
|
+
};
|
|
2366
|
+
if (planDef.group)
|
|
2367
|
+
metadata.group = planDef.group;
|
|
2368
|
+
if (planDef.addOn)
|
|
2369
|
+
metadata.addOn = "true";
|
|
2370
|
+
let product = existingByPlanId.get(planId);
|
|
2371
|
+
if (!product) {
|
|
2372
|
+
product = await stripe.products.create({
|
|
2373
|
+
name: planDef.title ?? planId,
|
|
2374
|
+
metadata,
|
|
2375
|
+
description: planDef.description
|
|
2376
|
+
});
|
|
2377
|
+
} else {
|
|
2378
|
+
await stripe.products.update(product.id, {
|
|
2379
|
+
name: planDef.title ?? planId,
|
|
2380
|
+
metadata
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
const amount = planDef.price?.amount ?? 0;
|
|
2384
|
+
const interval = planDef.price?.interval;
|
|
2385
|
+
const unitAmount = Math.round(amount * 100);
|
|
2386
|
+
const existingPrices = await stripe.prices.list({ product: product.id });
|
|
2387
|
+
const activePrices = existingPrices.data.filter((p) => p.active);
|
|
2388
|
+
const matchingPrice = activePrices.find((p) => p.unit_amount === unitAmount);
|
|
2389
|
+
if (!matchingPrice) {
|
|
2390
|
+
for (const oldPrice of activePrices) {
|
|
2391
|
+
await stripe.prices.update(oldPrice.id, { active: false });
|
|
2392
|
+
}
|
|
2393
|
+
const priceParams = {
|
|
2394
|
+
product: product.id,
|
|
2395
|
+
unit_amount: unitAmount,
|
|
2396
|
+
currency,
|
|
2397
|
+
metadata: { vertzPlanId: planId }
|
|
2398
|
+
};
|
|
2399
|
+
if (interval && interval !== "one_off") {
|
|
2400
|
+
const mapped = mapInterval(interval);
|
|
2401
|
+
priceParams.recurring = { interval: mapped.interval };
|
|
2402
|
+
}
|
|
2403
|
+
await stripe.prices.create(priceParams);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
async function createSubscription(_tenantId, _planId) {
|
|
2408
|
+
return "sub_placeholder";
|
|
2409
|
+
}
|
|
2410
|
+
async function cancelSubscription(_tenantId) {}
|
|
2411
|
+
async function attachAddOn(_tenantId, _addOnId) {}
|
|
2412
|
+
async function reportOverage(_tenantId, _limitKey, _amount) {}
|
|
2413
|
+
async function getPortalUrl(_tenantId) {
|
|
2414
|
+
return "https://billing.stripe.com/portal/placeholder";
|
|
2415
|
+
}
|
|
2416
|
+
return {
|
|
2417
|
+
syncPlans,
|
|
2418
|
+
createSubscription,
|
|
2419
|
+
cancelSubscription,
|
|
2420
|
+
attachAddOn,
|
|
2421
|
+
reportOverage,
|
|
2422
|
+
getPortalUrl
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
// src/auth/billing/webhook-handler.ts
|
|
2426
|
+
function extractTenantId(obj) {
|
|
2427
|
+
const meta = obj.metadata;
|
|
2428
|
+
if (meta?.tenantId)
|
|
2429
|
+
return meta.tenantId;
|
|
2430
|
+
const subDetails = obj.subscription_details;
|
|
2431
|
+
if (subDetails?.metadata) {
|
|
2432
|
+
const subMeta = subDetails.metadata;
|
|
2433
|
+
if (subMeta.tenantId)
|
|
2434
|
+
return subMeta.tenantId;
|
|
2435
|
+
}
|
|
2436
|
+
return null;
|
|
2437
|
+
}
|
|
2438
|
+
function extractPlanId(obj) {
|
|
2439
|
+
const meta = obj.metadata;
|
|
2440
|
+
if (meta?.vertzPlanId)
|
|
2441
|
+
return meta.vertzPlanId;
|
|
2442
|
+
const items = obj.items;
|
|
2443
|
+
if (items?.data?.[0]?.price?.metadata?.vertzPlanId) {
|
|
2444
|
+
return items.data[0].price.metadata.vertzPlanId;
|
|
2445
|
+
}
|
|
2446
|
+
return null;
|
|
2447
|
+
}
|
|
2448
|
+
function createWebhookHandler(config) {
|
|
2449
|
+
const { subscriptionStore, emitter, defaultPlan, webhookSecret } = config;
|
|
2450
|
+
return async (request) => {
|
|
2451
|
+
const signature = request.headers.get("stripe-signature");
|
|
2452
|
+
if (signature !== webhookSecret) {
|
|
2453
|
+
return new Response(JSON.stringify({ error: "Invalid signature" }), {
|
|
2454
|
+
status: 400,
|
|
2455
|
+
headers: { "content-type": "application/json" }
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
let event;
|
|
2459
|
+
try {
|
|
2460
|
+
const body = await request.text();
|
|
2461
|
+
event = JSON.parse(body);
|
|
2462
|
+
} catch {
|
|
2463
|
+
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
|
|
2464
|
+
status: 400,
|
|
2465
|
+
headers: { "content-type": "application/json" }
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
const obj = event.data.object;
|
|
2469
|
+
try {
|
|
2470
|
+
switch (event.type) {
|
|
2471
|
+
case "customer.subscription.created": {
|
|
2472
|
+
const tenantId = extractTenantId(obj);
|
|
2473
|
+
const planId = extractPlanId(obj);
|
|
2474
|
+
if (tenantId && planId) {
|
|
2475
|
+
await subscriptionStore.assign(tenantId, planId);
|
|
2476
|
+
emitter.emit("subscription:created", { tenantId, planId });
|
|
2477
|
+
}
|
|
2478
|
+
break;
|
|
2479
|
+
}
|
|
2480
|
+
case "customer.subscription.updated": {
|
|
2481
|
+
const tenantId = extractTenantId(obj);
|
|
2482
|
+
const planId = extractPlanId(obj);
|
|
2483
|
+
if (tenantId && planId) {
|
|
2484
|
+
await subscriptionStore.assign(tenantId, planId);
|
|
2485
|
+
}
|
|
2486
|
+
break;
|
|
2487
|
+
}
|
|
2488
|
+
case "customer.subscription.deleted": {
|
|
2489
|
+
const tenantId = extractTenantId(obj);
|
|
2490
|
+
const planId = extractPlanId(obj);
|
|
2491
|
+
if (tenantId) {
|
|
2492
|
+
await subscriptionStore.assign(tenantId, defaultPlan);
|
|
2493
|
+
emitter.emit("subscription:canceled", {
|
|
2494
|
+
tenantId,
|
|
2495
|
+
planId: planId ?? defaultPlan
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
break;
|
|
2499
|
+
}
|
|
2500
|
+
case "invoice.payment_failed": {
|
|
2501
|
+
const tenantId = extractTenantId(obj);
|
|
2502
|
+
const planId = extractPlanId(obj);
|
|
2503
|
+
const attemptCount = obj.attempt_count ?? 1;
|
|
2504
|
+
if (tenantId) {
|
|
2505
|
+
emitter.emit("billing:payment_failed", {
|
|
2506
|
+
tenantId,
|
|
2507
|
+
planId: planId ?? "",
|
|
2508
|
+
attempt: attemptCount
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
break;
|
|
2512
|
+
}
|
|
2513
|
+
case "checkout.session.completed": {
|
|
2514
|
+
const tenantId = extractTenantId(obj);
|
|
2515
|
+
const planId = extractPlanId(obj);
|
|
2516
|
+
const meta = obj.metadata;
|
|
2517
|
+
const isAddOn = meta?.isAddOn === "true";
|
|
2518
|
+
if (tenantId && planId) {
|
|
2519
|
+
if (isAddOn && subscriptionStore.attachAddOn) {
|
|
2520
|
+
await subscriptionStore.attachAddOn(tenantId, planId);
|
|
2521
|
+
} else {
|
|
2522
|
+
await subscriptionStore.assign(tenantId, planId);
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
break;
|
|
2526
|
+
}
|
|
2527
|
+
default:
|
|
2528
|
+
break;
|
|
2529
|
+
}
|
|
2530
|
+
} catch {
|
|
2531
|
+
return new Response(JSON.stringify({ error: "Processing failed" }), {
|
|
2532
|
+
status: 500,
|
|
2533
|
+
headers: { "content-type": "application/json" }
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
return new Response(JSON.stringify({ received: true }), {
|
|
2537
|
+
status: 200,
|
|
2538
|
+
headers: { "content-type": "application/json" }
|
|
2539
|
+
});
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
// src/auth/closure-store.ts
|
|
2543
|
+
var MAX_DEPTH = 4;
|
|
2544
|
+
|
|
2545
|
+
class InMemoryClosureStore {
|
|
2546
|
+
rows = [];
|
|
2547
|
+
async addResource(type, id, parent) {
|
|
2548
|
+
this.rows.push({
|
|
2549
|
+
ancestorType: type,
|
|
2550
|
+
ancestorId: id,
|
|
2551
|
+
descendantType: type,
|
|
2552
|
+
descendantId: id,
|
|
2553
|
+
depth: 0
|
|
2554
|
+
});
|
|
2555
|
+
if (parent) {
|
|
2556
|
+
const parentAncestors = await this.getAncestors(parent.parentType, parent.parentId);
|
|
2557
|
+
const maxParentDepth = Math.max(...parentAncestors.map((a) => a.depth), 0);
|
|
2558
|
+
if (maxParentDepth + 1 >= MAX_DEPTH) {
|
|
2559
|
+
this.rows.pop();
|
|
2560
|
+
throw new Error("Hierarchy depth exceeds maximum of 4 levels");
|
|
2561
|
+
}
|
|
2562
|
+
for (const ancestor of parentAncestors) {
|
|
2563
|
+
this.rows.push({
|
|
2564
|
+
ancestorType: ancestor.type,
|
|
2565
|
+
ancestorId: ancestor.id,
|
|
2566
|
+
descendantType: type,
|
|
2567
|
+
descendantId: id,
|
|
2568
|
+
depth: ancestor.depth + 1
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
async removeResource(type, id) {
|
|
2574
|
+
const descendants = await this.getDescendants(type, id);
|
|
2575
|
+
const descendantKeys = new Set(descendants.map((d2) => `${d2.type}:${d2.id}`));
|
|
2576
|
+
this.rows = this.rows.filter((row) => {
|
|
2577
|
+
const ancestorKey = `${row.ancestorType}:${row.ancestorId}`;
|
|
2578
|
+
const descendantKey = `${row.descendantType}:${row.descendantId}`;
|
|
2579
|
+
return !descendantKeys.has(ancestorKey) && !descendantKeys.has(descendantKey);
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
async getAncestors(type, id) {
|
|
1661
2583
|
return this.rows.filter((row) => row.descendantType === type && row.descendantId === id).map((row) => ({
|
|
1662
2584
|
type: row.ancestorType,
|
|
1663
2585
|
id: row.ancestorId,
|
|
1664
2586
|
depth: row.depth
|
|
1665
2587
|
}));
|
|
1666
2588
|
}
|
|
1667
|
-
async getDescendants(type, id) {
|
|
1668
|
-
return this.rows.filter((row) => row.ancestorType === type && row.ancestorId === id).map((row) => ({
|
|
1669
|
-
type: row.descendantType,
|
|
1670
|
-
id: row.descendantId,
|
|
1671
|
-
depth: row.depth
|
|
1672
|
-
}));
|
|
2589
|
+
async getDescendants(type, id) {
|
|
2590
|
+
return this.rows.filter((row) => row.ancestorType === type && row.ancestorId === id).map((row) => ({
|
|
2591
|
+
type: row.descendantType,
|
|
2592
|
+
id: row.descendantId,
|
|
2593
|
+
depth: row.depth
|
|
2594
|
+
}));
|
|
2595
|
+
}
|
|
2596
|
+
async hasPath(ancestorType, ancestorId, descendantType, descendantId) {
|
|
2597
|
+
return this.rows.some((row) => row.ancestorType === ancestorType && row.ancestorId === ancestorId && row.descendantType === descendantType && row.descendantId === descendantId);
|
|
2598
|
+
}
|
|
2599
|
+
dispose() {
|
|
2600
|
+
this.rows = [];
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
// src/auth/db-closure-store.ts
|
|
2604
|
+
import { sql as sql2 } from "@vertz/db/sql";
|
|
2605
|
+
|
|
2606
|
+
// src/auth/db-types.ts
|
|
2607
|
+
function boolVal(db, value) {
|
|
2608
|
+
return db._internals.dialect.name === "sqlite" ? value ? 1 : 0 : value;
|
|
2609
|
+
}
|
|
2610
|
+
function assertWrite(result, context) {
|
|
2611
|
+
if (!result.ok) {
|
|
2612
|
+
throw new Error(`Auth DB write failed (${context}): ${result.error.message}`);
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
// src/auth/db-closure-store.ts
|
|
2617
|
+
var MAX_DEPTH2 = 4;
|
|
2618
|
+
|
|
2619
|
+
class DbClosureStore {
|
|
2620
|
+
db;
|
|
2621
|
+
constructor(db) {
|
|
2622
|
+
this.db = db;
|
|
2623
|
+
}
|
|
2624
|
+
async addResource(type, id, parent) {
|
|
2625
|
+
const selfId = crypto.randomUUID();
|
|
2626
|
+
const selfResult = await this.db.query(sql2`INSERT INTO auth_closure (id, ancestor_type, ancestor_id, descendant_type, descendant_id, depth)
|
|
2627
|
+
VALUES (${selfId}, ${type}, ${id}, ${type}, ${id}, ${0})
|
|
2628
|
+
ON CONFLICT DO NOTHING`);
|
|
2629
|
+
assertWrite(selfResult, "addResource/self");
|
|
2630
|
+
if (parent) {
|
|
2631
|
+
const parentAncestors = await this.getAncestors(parent.parentType, parent.parentId);
|
|
2632
|
+
const maxParentDepth = Math.max(...parentAncestors.map((a) => a.depth), 0);
|
|
2633
|
+
if (maxParentDepth + 1 >= MAX_DEPTH2) {
|
|
2634
|
+
throw new Error("Hierarchy depth exceeds maximum of 4 levels");
|
|
2635
|
+
}
|
|
2636
|
+
for (const ancestor of parentAncestors) {
|
|
2637
|
+
const rowId = crypto.randomUUID();
|
|
2638
|
+
const ancestorResult = await this.db.query(sql2`INSERT INTO auth_closure (id, ancestor_type, ancestor_id, descendant_type, descendant_id, depth)
|
|
2639
|
+
VALUES (${rowId}, ${ancestor.type}, ${ancestor.id}, ${type}, ${id}, ${ancestor.depth + 1})
|
|
2640
|
+
ON CONFLICT DO NOTHING`);
|
|
2641
|
+
assertWrite(ancestorResult, "addResource/ancestor");
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
async removeResource(type, id) {
|
|
2646
|
+
const descendants = await this.getDescendants(type, id);
|
|
2647
|
+
for (const desc of descendants) {
|
|
2648
|
+
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})`);
|
|
2649
|
+
assertWrite(delResult, "removeResource");
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
async getAncestors(type, id) {
|
|
2653
|
+
const result = await this.db.query(sql2`SELECT ancestor_type, ancestor_id, depth FROM auth_closure WHERE descendant_type = ${type} AND descendant_id = ${id}`);
|
|
2654
|
+
if (!result.ok)
|
|
2655
|
+
return [];
|
|
2656
|
+
return result.data.rows.map((r) => ({
|
|
2657
|
+
type: r.ancestor_type,
|
|
2658
|
+
id: r.ancestor_id,
|
|
2659
|
+
depth: r.depth
|
|
2660
|
+
}));
|
|
2661
|
+
}
|
|
2662
|
+
async getDescendants(type, id) {
|
|
2663
|
+
const result = await this.db.query(sql2`SELECT descendant_type, descendant_id, depth FROM auth_closure WHERE ancestor_type = ${type} AND ancestor_id = ${id}`);
|
|
2664
|
+
if (!result.ok)
|
|
2665
|
+
return [];
|
|
2666
|
+
return result.data.rows.map((r) => ({
|
|
2667
|
+
type: r.descendant_type,
|
|
2668
|
+
id: r.descendant_id,
|
|
2669
|
+
depth: r.depth
|
|
2670
|
+
}));
|
|
2671
|
+
}
|
|
2672
|
+
async hasPath(ancestorType, ancestorId, descendantType, descendantId) {
|
|
2673
|
+
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}`);
|
|
2674
|
+
if (!result.ok)
|
|
2675
|
+
return false;
|
|
2676
|
+
return (result.data.rows[0]?.cnt ?? 0) > 0;
|
|
2677
|
+
}
|
|
2678
|
+
dispose() {}
|
|
2679
|
+
}
|
|
2680
|
+
// src/auth/db-flag-store.ts
|
|
2681
|
+
import { sql as sql3 } from "@vertz/db/sql";
|
|
2682
|
+
class DbFlagStore {
|
|
2683
|
+
db;
|
|
2684
|
+
cache = new Map;
|
|
2685
|
+
constructor(db) {
|
|
2686
|
+
this.db = db;
|
|
2687
|
+
}
|
|
2688
|
+
async loadFlags() {
|
|
2689
|
+
const result = await this.db.query(sql3`SELECT tenant_id, flag, enabled FROM auth_flags`);
|
|
2690
|
+
if (!result.ok)
|
|
2691
|
+
return;
|
|
2692
|
+
this.cache.clear();
|
|
2693
|
+
for (const row of result.data.rows) {
|
|
2694
|
+
let orgFlags = this.cache.get(row.tenant_id);
|
|
2695
|
+
if (!orgFlags) {
|
|
2696
|
+
orgFlags = new Map;
|
|
2697
|
+
this.cache.set(row.tenant_id, orgFlags);
|
|
2698
|
+
}
|
|
2699
|
+
orgFlags.set(row.flag, row.enabled === 1 || row.enabled === true);
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
setFlag(orgId, flag, enabled) {
|
|
2703
|
+
let orgFlags = this.cache.get(orgId);
|
|
2704
|
+
if (!orgFlags) {
|
|
2705
|
+
orgFlags = new Map;
|
|
2706
|
+
this.cache.set(orgId, orgFlags);
|
|
2707
|
+
}
|
|
2708
|
+
orgFlags.set(flag, enabled);
|
|
2709
|
+
const id = crypto.randomUUID();
|
|
2710
|
+
const val = boolVal(this.db, enabled);
|
|
2711
|
+
this.db.query(sql3`INSERT INTO auth_flags (id, tenant_id, flag, enabled) VALUES (${id}, ${orgId}, ${flag}, ${val})
|
|
2712
|
+
ON CONFLICT(tenant_id, flag) DO UPDATE SET enabled = ${val}`).then((result) => {
|
|
2713
|
+
if (!result.ok) {
|
|
2714
|
+
console.error("[DbFlagStore] Failed to persist flag:", result.error);
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
getFlag(orgId, flag) {
|
|
2719
|
+
return this.cache.get(orgId)?.get(flag) ?? false;
|
|
2720
|
+
}
|
|
2721
|
+
getFlags(orgId) {
|
|
2722
|
+
const orgFlags = this.cache.get(orgId);
|
|
2723
|
+
if (!orgFlags)
|
|
2724
|
+
return {};
|
|
2725
|
+
const result = {};
|
|
2726
|
+
for (const [key, value] of orgFlags) {
|
|
2727
|
+
result[key] = value;
|
|
2728
|
+
}
|
|
2729
|
+
return result;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
// src/auth/db-oauth-account-store.ts
|
|
2733
|
+
import { sql as sql4 } from "@vertz/db/sql";
|
|
2734
|
+
class DbOAuthAccountStore {
|
|
2735
|
+
db;
|
|
2736
|
+
constructor(db) {
|
|
2737
|
+
this.db = db;
|
|
2738
|
+
}
|
|
2739
|
+
async linkAccount(userId, provider, providerId, email) {
|
|
2740
|
+
const id = crypto.randomUUID();
|
|
2741
|
+
const now = new Date().toISOString();
|
|
2742
|
+
const emailValue = email ?? null;
|
|
2743
|
+
const result = await this.db.query(sql4`INSERT INTO auth_oauth_accounts (id, user_id, provider, provider_id, email, created_at)
|
|
2744
|
+
VALUES (${id}, ${userId}, ${provider}, ${providerId}, ${emailValue}, ${now})
|
|
2745
|
+
ON CONFLICT DO NOTHING`);
|
|
2746
|
+
assertWrite(result, "linkAccount");
|
|
2747
|
+
}
|
|
2748
|
+
async findByProviderAccount(provider, providerId) {
|
|
2749
|
+
const result = await this.db.query(sql4`SELECT user_id FROM auth_oauth_accounts WHERE provider = ${provider} AND provider_id = ${providerId}`);
|
|
2750
|
+
if (!result.ok || result.data.rows.length === 0)
|
|
2751
|
+
return null;
|
|
2752
|
+
return result.data.rows[0]?.user_id ?? null;
|
|
2753
|
+
}
|
|
2754
|
+
async findByUserId(userId) {
|
|
2755
|
+
const result = await this.db.query(sql4`SELECT provider, provider_id FROM auth_oauth_accounts WHERE user_id = ${userId}`);
|
|
2756
|
+
if (!result.ok)
|
|
2757
|
+
return [];
|
|
2758
|
+
return result.data.rows.map((r) => ({
|
|
2759
|
+
provider: r.provider,
|
|
2760
|
+
providerId: r.provider_id
|
|
2761
|
+
}));
|
|
2762
|
+
}
|
|
2763
|
+
async unlinkAccount(userId, provider) {
|
|
2764
|
+
const result = await this.db.query(sql4`DELETE FROM auth_oauth_accounts WHERE user_id = ${userId} AND provider = ${provider}`);
|
|
2765
|
+
assertWrite(result, "unlinkAccount");
|
|
2766
|
+
}
|
|
2767
|
+
dispose() {}
|
|
2768
|
+
}
|
|
2769
|
+
// src/auth/db-role-assignment-store.ts
|
|
2770
|
+
import { sql as sql5 } from "@vertz/db/sql";
|
|
2771
|
+
class DbRoleAssignmentStore {
|
|
2772
|
+
db;
|
|
2773
|
+
constructor(db) {
|
|
2774
|
+
this.db = db;
|
|
2775
|
+
}
|
|
2776
|
+
async assign(userId, resourceType, resourceId, role) {
|
|
2777
|
+
const id = crypto.randomUUID();
|
|
2778
|
+
const now = new Date().toISOString();
|
|
2779
|
+
const result = await this.db.query(sql5`INSERT INTO auth_role_assignments (id, user_id, resource_type, resource_id, role, created_at)
|
|
2780
|
+
VALUES (${id}, ${userId}, ${resourceType}, ${resourceId}, ${role}, ${now})
|
|
2781
|
+
ON CONFLICT DO NOTHING`);
|
|
2782
|
+
assertWrite(result, "assign");
|
|
2783
|
+
}
|
|
2784
|
+
async revoke(userId, resourceType, resourceId, role) {
|
|
2785
|
+
const result = await this.db.query(sql5`DELETE FROM auth_role_assignments WHERE user_id = ${userId} AND resource_type = ${resourceType} AND resource_id = ${resourceId} AND role = ${role}`);
|
|
2786
|
+
assertWrite(result, "revoke");
|
|
2787
|
+
}
|
|
2788
|
+
async getRoles(userId, resourceType, resourceId) {
|
|
2789
|
+
const result = await this.db.query(sql5`SELECT role FROM auth_role_assignments WHERE user_id = ${userId} AND resource_type = ${resourceType} AND resource_id = ${resourceId}`);
|
|
2790
|
+
if (!result.ok)
|
|
2791
|
+
return [];
|
|
2792
|
+
return result.data.rows.map((r) => r.role);
|
|
2793
|
+
}
|
|
2794
|
+
async getRolesForUser(userId) {
|
|
2795
|
+
const result = await this.db.query(sql5`SELECT user_id, resource_type, resource_id, role FROM auth_role_assignments WHERE user_id = ${userId}`);
|
|
2796
|
+
if (!result.ok)
|
|
2797
|
+
return [];
|
|
2798
|
+
return result.data.rows.map((r) => ({
|
|
2799
|
+
userId: r.user_id,
|
|
2800
|
+
resourceType: r.resource_type,
|
|
2801
|
+
resourceId: r.resource_id,
|
|
2802
|
+
role: r.role
|
|
2803
|
+
}));
|
|
2804
|
+
}
|
|
2805
|
+
async getEffectiveRole(userId, resourceType, resourceId, accessDef, closureStore) {
|
|
2806
|
+
const rolesForType = accessDef.roles[resourceType];
|
|
2807
|
+
if (!rolesForType || rolesForType.length === 0)
|
|
2808
|
+
return null;
|
|
2809
|
+
const candidateRoles = [];
|
|
2810
|
+
const directRoles = await this.getRoles(userId, resourceType, resourceId);
|
|
2811
|
+
candidateRoles.push(...directRoles);
|
|
2812
|
+
const ancestors = await closureStore.getAncestors(resourceType, resourceId);
|
|
2813
|
+
for (const ancestor of ancestors) {
|
|
2814
|
+
if (ancestor.depth === 0)
|
|
2815
|
+
continue;
|
|
2816
|
+
const ancestorRoles = await this.getRoles(userId, ancestor.type, ancestor.id);
|
|
2817
|
+
for (const ancestorRole of ancestorRoles) {
|
|
2818
|
+
const inheritedRole = resolveInheritedRole(ancestor.type, ancestorRole, resourceType, accessDef);
|
|
2819
|
+
if (inheritedRole) {
|
|
2820
|
+
candidateRoles.push(inheritedRole);
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
if (candidateRoles.length === 0)
|
|
2825
|
+
return null;
|
|
2826
|
+
const roleOrder = [...rolesForType];
|
|
2827
|
+
let bestIndex = roleOrder.length;
|
|
2828
|
+
for (const role of candidateRoles) {
|
|
2829
|
+
const idx = roleOrder.indexOf(role);
|
|
2830
|
+
if (idx !== -1 && idx < bestIndex) {
|
|
2831
|
+
bestIndex = idx;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
return bestIndex < roleOrder.length ? roleOrder[bestIndex] : null;
|
|
2835
|
+
}
|
|
2836
|
+
dispose() {}
|
|
2837
|
+
}
|
|
2838
|
+
// src/auth/db-session-store.ts
|
|
2839
|
+
import { sql as sql6 } from "@vertz/db/sql";
|
|
2840
|
+
class DbSessionStore {
|
|
2841
|
+
db;
|
|
2842
|
+
constructor(db) {
|
|
2843
|
+
this.db = db;
|
|
2844
|
+
}
|
|
2845
|
+
async createSessionWithId(id, data) {
|
|
2846
|
+
const now = new Date;
|
|
2847
|
+
const tokensJson = data.currentTokens ? JSON.stringify(data.currentTokens) : null;
|
|
2848
|
+
const result = await this.db.query(sql6`INSERT INTO auth_sessions (id, user_id, refresh_token_hash, previous_refresh_hash, current_tokens, ip_address, user_agent, created_at, last_active_at, expires_at, revoked_at)
|
|
2849
|
+
VALUES (${id}, ${data.userId}, ${data.refreshTokenHash}, ${null}, ${tokensJson}, ${data.ipAddress}, ${data.userAgent}, ${now.toISOString()}, ${now.toISOString()}, ${data.expiresAt.toISOString()}, ${null})`);
|
|
2850
|
+
assertWrite(result, "createSessionWithId");
|
|
2851
|
+
return {
|
|
2852
|
+
id,
|
|
2853
|
+
userId: data.userId,
|
|
2854
|
+
refreshTokenHash: data.refreshTokenHash,
|
|
2855
|
+
previousRefreshHash: null,
|
|
2856
|
+
ipAddress: data.ipAddress,
|
|
2857
|
+
userAgent: data.userAgent,
|
|
2858
|
+
createdAt: now,
|
|
2859
|
+
lastActiveAt: now,
|
|
2860
|
+
expiresAt: data.expiresAt,
|
|
2861
|
+
revokedAt: null
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
async findByRefreshHash(hash) {
|
|
2865
|
+
const nowStr = new Date().toISOString();
|
|
2866
|
+
const result = await this.db.auth_sessions.get({
|
|
2867
|
+
where: {
|
|
2868
|
+
refreshTokenHash: hash,
|
|
2869
|
+
revokedAt: null,
|
|
2870
|
+
expiresAt: { gt: nowStr }
|
|
2871
|
+
}
|
|
2872
|
+
});
|
|
2873
|
+
if (!result.ok)
|
|
2874
|
+
return null;
|
|
2875
|
+
const row = result.data;
|
|
2876
|
+
if (!row)
|
|
2877
|
+
return null;
|
|
2878
|
+
return this.recordToSession(row);
|
|
2879
|
+
}
|
|
2880
|
+
async findActiveSessionById(id) {
|
|
2881
|
+
const nowStr = new Date().toISOString();
|
|
2882
|
+
const result = await this.db.auth_sessions.get({
|
|
2883
|
+
where: {
|
|
2884
|
+
id,
|
|
2885
|
+
revokedAt: null,
|
|
2886
|
+
expiresAt: { gt: nowStr }
|
|
2887
|
+
}
|
|
2888
|
+
});
|
|
2889
|
+
if (!result.ok)
|
|
2890
|
+
return null;
|
|
2891
|
+
const row = result.data;
|
|
2892
|
+
if (!row)
|
|
2893
|
+
return null;
|
|
2894
|
+
return this.recordToSession(row);
|
|
2895
|
+
}
|
|
2896
|
+
async findByPreviousRefreshHash(hash) {
|
|
2897
|
+
const nowStr = new Date().toISOString();
|
|
2898
|
+
const result = await this.db.auth_sessions.get({
|
|
2899
|
+
where: {
|
|
2900
|
+
previousRefreshHash: hash,
|
|
2901
|
+
revokedAt: null,
|
|
2902
|
+
expiresAt: { gt: nowStr }
|
|
2903
|
+
}
|
|
2904
|
+
});
|
|
2905
|
+
if (!result.ok)
|
|
2906
|
+
return null;
|
|
2907
|
+
const row = result.data;
|
|
2908
|
+
if (!row)
|
|
2909
|
+
return null;
|
|
2910
|
+
return this.recordToSession(row);
|
|
2911
|
+
}
|
|
2912
|
+
async revokeSession(id) {
|
|
2913
|
+
const nowStr = new Date().toISOString();
|
|
2914
|
+
const result = await this.db.query(sql6`UPDATE auth_sessions SET revoked_at = ${nowStr}, current_tokens = ${null} WHERE id = ${id}`);
|
|
2915
|
+
assertWrite(result, "revokeSession");
|
|
2916
|
+
}
|
|
2917
|
+
async listActiveSessions(userId) {
|
|
2918
|
+
const nowStr = new Date().toISOString();
|
|
2919
|
+
const result = await this.db.query(sql6`SELECT * FROM auth_sessions WHERE user_id = ${userId} AND revoked_at IS NULL AND expires_at > ${nowStr}`);
|
|
2920
|
+
if (!result.ok)
|
|
2921
|
+
return [];
|
|
2922
|
+
return result.data.rows.map((row) => this.rowToSession(row));
|
|
2923
|
+
}
|
|
2924
|
+
async countActiveSessions(userId) {
|
|
2925
|
+
const nowStr = new Date().toISOString();
|
|
2926
|
+
const result = await this.db.query(sql6`SELECT COUNT(*) as cnt FROM auth_sessions WHERE user_id = ${userId} AND revoked_at IS NULL AND expires_at > ${nowStr}`);
|
|
2927
|
+
if (!result.ok)
|
|
2928
|
+
return 0;
|
|
2929
|
+
return result.data.rows[0]?.cnt ?? 0;
|
|
2930
|
+
}
|
|
2931
|
+
async getCurrentTokens(sessionId) {
|
|
2932
|
+
const result = await this.db.query(sql6`SELECT current_tokens FROM auth_sessions WHERE id = ${sessionId} LIMIT 1`);
|
|
2933
|
+
if (!result.ok)
|
|
2934
|
+
return null;
|
|
2935
|
+
const row = result.data.rows[0];
|
|
2936
|
+
if (!row?.current_tokens)
|
|
2937
|
+
return null;
|
|
2938
|
+
try {
|
|
2939
|
+
return JSON.parse(row.current_tokens);
|
|
2940
|
+
} catch {
|
|
2941
|
+
return null;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
async updateSession(id, data) {
|
|
2945
|
+
const tokensJson = data.currentTokens ? JSON.stringify(data.currentTokens) : null;
|
|
2946
|
+
const result = await this.db.query(sql6`UPDATE auth_sessions SET refresh_token_hash = ${data.refreshTokenHash}, previous_refresh_hash = ${data.previousRefreshHash}, last_active_at = ${data.lastActiveAt.toISOString()}, current_tokens = ${tokensJson} WHERE id = ${id}`);
|
|
2947
|
+
assertWrite(result, "updateSession");
|
|
2948
|
+
}
|
|
2949
|
+
dispose() {}
|
|
2950
|
+
rowToSession(row) {
|
|
2951
|
+
return {
|
|
2952
|
+
id: row.id,
|
|
2953
|
+
userId: row.user_id,
|
|
2954
|
+
refreshTokenHash: row.refresh_token_hash,
|
|
2955
|
+
previousRefreshHash: row.previous_refresh_hash,
|
|
2956
|
+
ipAddress: row.ip_address,
|
|
2957
|
+
userAgent: row.user_agent,
|
|
2958
|
+
createdAt: new Date(row.created_at),
|
|
2959
|
+
lastActiveAt: new Date(row.last_active_at),
|
|
2960
|
+
expiresAt: new Date(row.expires_at),
|
|
2961
|
+
revokedAt: row.revoked_at ? new Date(row.revoked_at) : null
|
|
2962
|
+
};
|
|
1673
2963
|
}
|
|
1674
|
-
|
|
1675
|
-
return
|
|
2964
|
+
recordToSession(rec) {
|
|
2965
|
+
return {
|
|
2966
|
+
id: rec.id,
|
|
2967
|
+
userId: rec.userId,
|
|
2968
|
+
refreshTokenHash: rec.refreshTokenHash,
|
|
2969
|
+
previousRefreshHash: rec.previousRefreshHash,
|
|
2970
|
+
ipAddress: rec.ipAddress,
|
|
2971
|
+
userAgent: rec.userAgent,
|
|
2972
|
+
createdAt: new Date(rec.createdAt),
|
|
2973
|
+
lastActiveAt: new Date(rec.lastActiveAt),
|
|
2974
|
+
expiresAt: new Date(rec.expiresAt),
|
|
2975
|
+
revokedAt: rec.revokedAt ? new Date(rec.revokedAt) : null
|
|
2976
|
+
};
|
|
1676
2977
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
2978
|
+
}
|
|
2979
|
+
// src/auth/db-subscription-store.ts
|
|
2980
|
+
import { sql as sql7 } from "@vertz/db/sql";
|
|
2981
|
+
class DbSubscriptionStore {
|
|
2982
|
+
db;
|
|
2983
|
+
constructor(db) {
|
|
2984
|
+
this.db = db;
|
|
2985
|
+
}
|
|
2986
|
+
async assign(tenantId, planId, startedAt = new Date, expiresAt = null) {
|
|
2987
|
+
await this.db.transaction(async (tx) => {
|
|
2988
|
+
const id = crypto.randomUUID();
|
|
2989
|
+
const startedAtIso = startedAt.toISOString();
|
|
2990
|
+
const expiresAtIso = expiresAt ? expiresAt.toISOString() : null;
|
|
2991
|
+
const upsertResult = await tx.query(sql7`INSERT INTO auth_plans (id, tenant_id, plan_id, started_at, expires_at)
|
|
2992
|
+
VALUES (${id}, ${tenantId}, ${planId}, ${startedAtIso}, ${expiresAtIso})
|
|
2993
|
+
ON CONFLICT(tenant_id) DO UPDATE SET plan_id = ${planId}, started_at = ${startedAtIso}, expires_at = ${expiresAtIso}`);
|
|
2994
|
+
assertWrite(upsertResult, "assign/upsert");
|
|
2995
|
+
const overrideResult = await tx.query(sql7`DELETE FROM auth_overrides WHERE tenant_id = ${tenantId}`);
|
|
2996
|
+
assertWrite(overrideResult, "assign/clearOverrides");
|
|
2997
|
+
});
|
|
2998
|
+
}
|
|
2999
|
+
async get(tenantId) {
|
|
3000
|
+
const result = await this.db.query(sql7`SELECT tenant_id, plan_id, started_at, expires_at FROM auth_plans WHERE tenant_id = ${tenantId}`);
|
|
3001
|
+
if (!result.ok)
|
|
3002
|
+
return null;
|
|
3003
|
+
const row = result.data.rows[0];
|
|
3004
|
+
if (!row)
|
|
3005
|
+
return null;
|
|
3006
|
+
const overrides = await this.loadOverrides(tenantId);
|
|
3007
|
+
return {
|
|
3008
|
+
tenantId: row.tenant_id,
|
|
3009
|
+
planId: row.plan_id,
|
|
3010
|
+
startedAt: new Date(row.started_at),
|
|
3011
|
+
expiresAt: row.expires_at ? new Date(row.expires_at) : null,
|
|
3012
|
+
overrides
|
|
3013
|
+
};
|
|
3014
|
+
}
|
|
3015
|
+
async updateOverrides(tenantId, overrides) {
|
|
3016
|
+
const planResult = await this.db.query(sql7`SELECT tenant_id FROM auth_plans WHERE tenant_id = ${tenantId}`);
|
|
3017
|
+
if (!planResult.ok || planResult.data.rows.length === 0)
|
|
3018
|
+
return;
|
|
3019
|
+
await this.db.transaction(async (tx) => {
|
|
3020
|
+
const existing = await this.loadOverrides(tenantId);
|
|
3021
|
+
const merged = { ...existing, ...overrides };
|
|
3022
|
+
const overridesJson = JSON.stringify(merged);
|
|
3023
|
+
const now = new Date().toISOString();
|
|
3024
|
+
const overrideResult = await tx.query(sql7`SELECT tenant_id FROM auth_overrides WHERE tenant_id = ${tenantId}`);
|
|
3025
|
+
if (overrideResult.ok && overrideResult.data.rows.length > 0) {
|
|
3026
|
+
const updateResult = await tx.query(sql7`UPDATE auth_overrides SET overrides = ${overridesJson}, updated_at = ${now} WHERE tenant_id = ${tenantId}`);
|
|
3027
|
+
assertWrite(updateResult, "updateOverrides/update");
|
|
3028
|
+
} else {
|
|
3029
|
+
const id = crypto.randomUUID();
|
|
3030
|
+
const insertResult = await tx.query(sql7`INSERT INTO auth_overrides (id, tenant_id, overrides, updated_at)
|
|
3031
|
+
VALUES (${id}, ${tenantId}, ${overridesJson}, ${now})`);
|
|
3032
|
+
assertWrite(insertResult, "updateOverrides/insert");
|
|
3033
|
+
}
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
async remove(tenantId) {
|
|
3037
|
+
await this.db.transaction(async (tx) => {
|
|
3038
|
+
const planResult = await tx.query(sql7`DELETE FROM auth_plans WHERE tenant_id = ${tenantId}`);
|
|
3039
|
+
assertWrite(planResult, "remove/plans");
|
|
3040
|
+
const overrideResult = await tx.query(sql7`DELETE FROM auth_overrides WHERE tenant_id = ${tenantId}`);
|
|
3041
|
+
assertWrite(overrideResult, "remove/overrides");
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
async attachAddOn(tenantId, addOnId) {
|
|
3045
|
+
const id = crypto.randomUUID();
|
|
3046
|
+
const now = new Date().toISOString();
|
|
3047
|
+
const result = await this.db.query(sql7`INSERT INTO auth_plan_addons (id, tenant_id, addon_id, quantity, created_at)
|
|
3048
|
+
VALUES (${id}, ${tenantId}, ${addOnId}, ${1}, ${now})
|
|
3049
|
+
ON CONFLICT(tenant_id, addon_id) DO NOTHING`);
|
|
3050
|
+
assertWrite(result, "attachAddOn");
|
|
3051
|
+
}
|
|
3052
|
+
async detachAddOn(tenantId, addOnId) {
|
|
3053
|
+
const result = await this.db.query(sql7`DELETE FROM auth_plan_addons WHERE tenant_id = ${tenantId} AND addon_id = ${addOnId}`);
|
|
3054
|
+
assertWrite(result, "detachAddOn");
|
|
3055
|
+
}
|
|
3056
|
+
async getAddOns(tenantId) {
|
|
3057
|
+
const result = await this.db.query(sql7`SELECT addon_id FROM auth_plan_addons WHERE tenant_id = ${tenantId}`);
|
|
3058
|
+
if (!result.ok)
|
|
3059
|
+
return [];
|
|
3060
|
+
return result.data.rows.map((r) => r.addon_id);
|
|
3061
|
+
}
|
|
3062
|
+
dispose() {}
|
|
3063
|
+
async loadOverrides(tenantId) {
|
|
3064
|
+
const result = await this.db.query(sql7`SELECT overrides FROM auth_overrides WHERE tenant_id = ${tenantId}`);
|
|
3065
|
+
if (!result.ok || result.data.rows.length === 0)
|
|
3066
|
+
return {};
|
|
3067
|
+
try {
|
|
3068
|
+
const overridesStr = result.data.rows[0]?.overrides;
|
|
3069
|
+
if (!overridesStr)
|
|
3070
|
+
return {};
|
|
3071
|
+
return JSON.parse(overridesStr);
|
|
3072
|
+
} catch {
|
|
3073
|
+
return {};
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
// src/auth/db-user-store.ts
|
|
3078
|
+
import { sql as sql8 } from "@vertz/db/sql";
|
|
3079
|
+
class DbUserStore {
|
|
3080
|
+
db;
|
|
3081
|
+
constructor(db) {
|
|
3082
|
+
this.db = db;
|
|
3083
|
+
}
|
|
3084
|
+
async createUser(user, passwordHash) {
|
|
3085
|
+
const result = await this.db.query(sql8`INSERT INTO auth_users (id, email, password_hash, role, email_verified, created_at, updated_at)
|
|
3086
|
+
VALUES (${user.id}, ${user.email.toLowerCase()}, ${passwordHash}, ${user.role}, ${boolVal(this.db, user.emailVerified ?? false)}, ${user.createdAt.toISOString()}, ${user.updatedAt.toISOString()})`);
|
|
3087
|
+
assertWrite(result, "createUser");
|
|
3088
|
+
}
|
|
3089
|
+
async findByEmail(email) {
|
|
3090
|
+
const result = await this.db.query(sql8`SELECT * FROM auth_users WHERE email = ${email.toLowerCase()} LIMIT 1`);
|
|
3091
|
+
if (!result.ok)
|
|
3092
|
+
return null;
|
|
3093
|
+
const row = result.data.rows[0];
|
|
3094
|
+
if (!row)
|
|
3095
|
+
return null;
|
|
3096
|
+
return {
|
|
3097
|
+
user: this.rowToUser(row),
|
|
3098
|
+
passwordHash: row.password_hash
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
async findById(id) {
|
|
3102
|
+
const result = await this.db.query(sql8`SELECT * FROM auth_users WHERE id = ${id} LIMIT 1`);
|
|
3103
|
+
if (!result.ok)
|
|
3104
|
+
return null;
|
|
3105
|
+
const row = result.data.rows[0];
|
|
3106
|
+
if (!row)
|
|
3107
|
+
return null;
|
|
3108
|
+
return this.rowToUser(row);
|
|
3109
|
+
}
|
|
3110
|
+
async updatePasswordHash(userId, passwordHash) {
|
|
3111
|
+
const result = await this.db.query(sql8`UPDATE auth_users SET password_hash = ${passwordHash}, updated_at = ${new Date().toISOString()} WHERE id = ${userId}`);
|
|
3112
|
+
assertWrite(result, "updatePasswordHash");
|
|
3113
|
+
}
|
|
3114
|
+
async updateEmailVerified(userId, verified) {
|
|
3115
|
+
const val = boolVal(this.db, verified);
|
|
3116
|
+
const result = await this.db.query(sql8`UPDATE auth_users SET email_verified = ${val}, updated_at = ${new Date().toISOString()} WHERE id = ${userId}`);
|
|
3117
|
+
assertWrite(result, "updateEmailVerified");
|
|
3118
|
+
}
|
|
3119
|
+
async deleteUser(id) {
|
|
3120
|
+
const result = await this.db.query(sql8`DELETE FROM auth_users WHERE id = ${id}`);
|
|
3121
|
+
assertWrite(result, "deleteUser");
|
|
3122
|
+
}
|
|
3123
|
+
rowToUser(row) {
|
|
3124
|
+
return {
|
|
3125
|
+
id: row.id,
|
|
3126
|
+
email: row.email,
|
|
3127
|
+
role: row.role,
|
|
3128
|
+
emailVerified: row.email_verified === 1 || row.email_verified === true,
|
|
3129
|
+
createdAt: new Date(row.created_at),
|
|
3130
|
+
updatedAt: new Date(row.updated_at)
|
|
3131
|
+
};
|
|
1679
3132
|
}
|
|
1680
3133
|
}
|
|
1681
3134
|
// src/auth/define-access.ts
|
|
1682
3135
|
function defineAccess(input) {
|
|
1683
|
-
|
|
1684
|
-
|
|
3136
|
+
const { entities, entitlements: rawEntitlements } = input;
|
|
3137
|
+
for (const [entityName, entityDef] of Object.entries(entities)) {
|
|
3138
|
+
const roleSet = new Set;
|
|
3139
|
+
for (const role of entityDef.roles) {
|
|
3140
|
+
if (roleSet.has(role)) {
|
|
3141
|
+
throw new Error(`Duplicate role '${role}' in entity '${entityName}'`);
|
|
3142
|
+
}
|
|
3143
|
+
roleSet.add(role);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
const parentToChildren = new Map;
|
|
3147
|
+
const childToParent = new Map;
|
|
3148
|
+
const inheritanceMap = {};
|
|
3149
|
+
for (const [entityName, entityDef] of Object.entries(entities)) {
|
|
3150
|
+
if (!entityDef.inherits)
|
|
3151
|
+
continue;
|
|
3152
|
+
const parentEntities = new Set;
|
|
3153
|
+
for (const [inheritKey, localRole] of Object.entries(entityDef.inherits)) {
|
|
3154
|
+
const colonIdx = inheritKey.indexOf(":");
|
|
3155
|
+
if (colonIdx === -1) {
|
|
3156
|
+
throw new Error(`Invalid inherits key '${inheritKey}' in entity '${entityName}' — expected format 'entity:role'`);
|
|
3157
|
+
}
|
|
3158
|
+
const parentEntityName2 = inheritKey.substring(0, colonIdx);
|
|
3159
|
+
const parentRoleName = inheritKey.substring(colonIdx + 1);
|
|
3160
|
+
if (parentEntityName2 === entityName) {
|
|
3161
|
+
throw new Error(`Entity '${entityName}' cannot inherit from itself`);
|
|
3162
|
+
}
|
|
3163
|
+
if (!(parentEntityName2 in entities)) {
|
|
3164
|
+
throw new Error(`Entity '${parentEntityName2}' in ${entityName}.inherits is not defined`);
|
|
3165
|
+
}
|
|
3166
|
+
const parentEntity = entities[parentEntityName2];
|
|
3167
|
+
if (!parentEntity.roles.includes(parentRoleName)) {
|
|
3168
|
+
throw new Error(`Role '${parentRoleName}' does not exist on entity '${parentEntityName2}'`);
|
|
3169
|
+
}
|
|
3170
|
+
if (!entityDef.roles.includes(localRole)) {
|
|
3171
|
+
throw new Error(`Role '${localRole}' does not exist on entity '${entityName}'`);
|
|
3172
|
+
}
|
|
3173
|
+
parentEntities.add(parentEntityName2);
|
|
3174
|
+
if (!inheritanceMap[parentEntityName2]) {
|
|
3175
|
+
inheritanceMap[parentEntityName2] = {};
|
|
3176
|
+
}
|
|
3177
|
+
inheritanceMap[parentEntityName2][parentRoleName] = localRole;
|
|
3178
|
+
}
|
|
3179
|
+
if (parentEntities.size > 1) {
|
|
3180
|
+
throw new Error(`Entity '${entityName}' inherits from multiple parents (${[...parentEntities].join(", ")}). Each entity can only inherit from one parent.`);
|
|
3181
|
+
}
|
|
3182
|
+
const parentEntityName = [...parentEntities][0];
|
|
3183
|
+
if (parentEntityName) {
|
|
3184
|
+
if (!parentToChildren.has(parentEntityName)) {
|
|
3185
|
+
parentToChildren.set(parentEntityName, new Set);
|
|
3186
|
+
}
|
|
3187
|
+
parentToChildren.get(parentEntityName).add(entityName);
|
|
3188
|
+
if (childToParent.has(entityName)) {
|
|
3189
|
+
throw new Error(`Entity '${entityName}' inherits from multiple parents`);
|
|
3190
|
+
}
|
|
3191
|
+
childToParent.set(entityName, parentEntityName);
|
|
3192
|
+
}
|
|
1685
3193
|
}
|
|
1686
|
-
|
|
3194
|
+
detectCycles(childToParent);
|
|
3195
|
+
const hierarchy = inferHierarchy(entities, childToParent, parentToChildren);
|
|
3196
|
+
if (hierarchy.length > 4) {
|
|
1687
3197
|
throw new Error("Hierarchy depth must not exceed 4 levels");
|
|
1688
3198
|
}
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
3199
|
+
const resolvedEntitlements = {};
|
|
3200
|
+
const ruleContext = createRuleContext();
|
|
3201
|
+
for (const [entName, entValue] of Object.entries(rawEntitlements)) {
|
|
3202
|
+
const colonIdx = entName.indexOf(":");
|
|
3203
|
+
if (colonIdx === -1) {
|
|
3204
|
+
throw new Error(`Entitlement '${entName}' must use format 'entity:action'`);
|
|
1693
3205
|
}
|
|
3206
|
+
const entityPrefix = entName.substring(0, colonIdx);
|
|
3207
|
+
if (!(entityPrefix in entities)) {
|
|
3208
|
+
throw new Error(`Entitlement '${entName}' references undefined entity '${entityPrefix}'`);
|
|
3209
|
+
}
|
|
3210
|
+
let entDef;
|
|
3211
|
+
if (typeof entValue === "function") {
|
|
3212
|
+
entDef = entValue(ruleContext);
|
|
3213
|
+
} else {
|
|
3214
|
+
entDef = entValue;
|
|
3215
|
+
}
|
|
3216
|
+
const entityRoles = entities[entityPrefix].roles;
|
|
3217
|
+
for (const role of entDef.roles) {
|
|
3218
|
+
if (!entityRoles.includes(role)) {
|
|
3219
|
+
throw new Error(`Role '${role}' in '${entName}' does not exist on entity '${entityPrefix}'`);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
resolvedEntitlements[entName] = entDef;
|
|
1694
3223
|
}
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
3224
|
+
const entitlementSet = new Set(Object.keys(resolvedEntitlements));
|
|
3225
|
+
const entityNameSet = new Set(Object.keys(entities));
|
|
3226
|
+
if (input.plans) {
|
|
3227
|
+
for (const [planName, planDef] of Object.entries(input.plans)) {
|
|
3228
|
+
if (planDef.features) {
|
|
3229
|
+
for (const feature of planDef.features) {
|
|
3230
|
+
if (!entitlementSet.has(feature)) {
|
|
3231
|
+
throw new Error(`Plan '${planName}' feature '${feature}' is not a defined entitlement`);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
if (planDef.limits) {
|
|
3236
|
+
for (const [limitKey, limitDef] of Object.entries(planDef.limits)) {
|
|
3237
|
+
if (!entitlementSet.has(limitDef.gates)) {
|
|
3238
|
+
throw new Error(`Limit '${limitKey}' gates '${limitDef.gates}' which is not defined`);
|
|
3239
|
+
}
|
|
3240
|
+
if (limitDef.scope && !entityNameSet.has(limitDef.scope)) {
|
|
3241
|
+
throw new Error(`Limit '${limitKey}' scope '${limitDef.scope}' is not a defined entity`);
|
|
3242
|
+
}
|
|
3243
|
+
if (limitDef.max < -1 || limitDef.max < 0 && limitDef.max !== -1) {
|
|
3244
|
+
throw new Error(`Limit '${limitKey}' max must be -1 (unlimited), 0 (disabled), or a positive integer, got ${limitDef.max}`);
|
|
3245
|
+
}
|
|
3246
|
+
if (!Number.isInteger(limitDef.max)) {
|
|
3247
|
+
throw new Error(`Limit '${limitKey}' max must be -1 (unlimited), 0 (disabled), or a positive integer, got ${limitDef.max}`);
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
if (planDef.addOn) {
|
|
3252
|
+
if (planDef.group) {
|
|
3253
|
+
throw new Error(`Add-on '${planName}' must not have a group`);
|
|
3254
|
+
}
|
|
3255
|
+
} else {
|
|
3256
|
+
if (!planDef.group) {
|
|
3257
|
+
throw new Error(`Base plan '${planName}' must have a group`);
|
|
3258
|
+
}
|
|
3259
|
+
if (planDef.requires) {
|
|
3260
|
+
throw new Error(`Plan '${planName}': 'requires' is only valid on add-on plans`);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
if (planDef.addOn && planDef.requires) {
|
|
3264
|
+
for (const reqPlan of planDef.requires.plans) {
|
|
3265
|
+
if (!input.plans[reqPlan]) {
|
|
3266
|
+
throw new Error(`Add-on '${planName}' requires plan '${reqPlan}' is not defined`);
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
1699
3270
|
}
|
|
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}`);
|
|
3271
|
+
if (input.defaultPlan) {
|
|
3272
|
+
const defaultPlanDef = input.plans[input.defaultPlan];
|
|
3273
|
+
if (defaultPlanDef?.addOn) {
|
|
3274
|
+
throw new Error(`defaultPlan '${input.defaultPlan}' is an add-on, not a base plan`);
|
|
1707
3275
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
3276
|
+
}
|
|
3277
|
+
const basePlanLimitKeys = new Set;
|
|
3278
|
+
for (const [, planDef] of Object.entries(input.plans)) {
|
|
3279
|
+
if (!planDef.addOn && planDef.limits) {
|
|
3280
|
+
for (const limitKey of Object.keys(planDef.limits)) {
|
|
3281
|
+
basePlanLimitKeys.add(limitKey);
|
|
3282
|
+
}
|
|
1710
3283
|
}
|
|
1711
3284
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
throw new Error(`Entitlement "${entName}" references unknown plan: ${planRef}`);
|
|
3285
|
+
for (const [, planDef] of Object.entries(input.plans)) {
|
|
3286
|
+
if (planDef.addOn && planDef.limits) {
|
|
3287
|
+
for (const limitKey of Object.keys(planDef.limits)) {
|
|
3288
|
+
if (!basePlanLimitKeys.has(limitKey)) {
|
|
3289
|
+
throw new Error(`Add-on limit '${limitKey}' not defined in any base plan`);
|
|
3290
|
+
}
|
|
1719
3291
|
}
|
|
1720
3292
|
}
|
|
1721
3293
|
}
|
|
1722
3294
|
}
|
|
1723
|
-
const
|
|
3295
|
+
const planGatedEntitlements = new Set;
|
|
3296
|
+
const entitlementToLimitKeys = {};
|
|
1724
3297
|
if (input.plans) {
|
|
1725
|
-
for (const [
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
throw new Error(`Plan "${planName}" references unknown entitlement: ${ent}`);
|
|
3298
|
+
for (const [, planDef] of Object.entries(input.plans)) {
|
|
3299
|
+
if (planDef.features) {
|
|
3300
|
+
for (const feature of planDef.features) {
|
|
3301
|
+
planGatedEntitlements.add(feature);
|
|
1730
3302
|
}
|
|
1731
3303
|
}
|
|
1732
3304
|
if (planDef.limits) {
|
|
1733
|
-
for (const limitKey of Object.
|
|
1734
|
-
if (!
|
|
1735
|
-
|
|
3305
|
+
for (const [limitKey, limitDef] of Object.entries(planDef.limits)) {
|
|
3306
|
+
if (!entitlementToLimitKeys[limitDef.gates]) {
|
|
3307
|
+
entitlementToLimitKeys[limitDef.gates] = [];
|
|
3308
|
+
}
|
|
3309
|
+
if (!entitlementToLimitKeys[limitDef.gates].includes(limitKey)) {
|
|
3310
|
+
entitlementToLimitKeys[limitDef.gates].push(limitKey);
|
|
1736
3311
|
}
|
|
1737
3312
|
}
|
|
1738
3313
|
}
|
|
1739
3314
|
}
|
|
1740
3315
|
}
|
|
3316
|
+
const rolesMap = {};
|
|
3317
|
+
for (const [entityName, entityDef] of Object.entries(entities)) {
|
|
3318
|
+
rolesMap[entityName] = Object.freeze([...entityDef.roles]);
|
|
3319
|
+
}
|
|
1741
3320
|
const config = {
|
|
1742
|
-
hierarchy: Object.freeze([...
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
3321
|
+
hierarchy: Object.freeze([...hierarchy]),
|
|
3322
|
+
entities: Object.freeze(Object.fromEntries(Object.entries(entities).map(([k, v]) => [
|
|
3323
|
+
k,
|
|
3324
|
+
Object.freeze({
|
|
3325
|
+
roles: Object.freeze([...v.roles]),
|
|
3326
|
+
...v.inherits ? { inherits: Object.freeze({ ...v.inherits }) } : {}
|
|
3327
|
+
})
|
|
3328
|
+
]))),
|
|
3329
|
+
roles: Object.freeze(rolesMap),
|
|
3330
|
+
inheritance: Object.freeze(Object.fromEntries(Object.entries(inheritanceMap).map(([k, v]) => [k, Object.freeze({ ...v })]))),
|
|
3331
|
+
entitlements: Object.freeze(Object.fromEntries(Object.entries(resolvedEntitlements).map(([k, v]) => [k, Object.freeze({ ...v })]))),
|
|
3332
|
+
_planGatedEntitlements: Object.freeze(planGatedEntitlements),
|
|
3333
|
+
_entitlementToLimitKeys: Object.freeze(Object.fromEntries(Object.entries(entitlementToLimitKeys).map(([k, v]) => [k, Object.freeze([...v])]))),
|
|
1746
3334
|
...input.defaultPlan ? { defaultPlan: input.defaultPlan } : {},
|
|
1747
3335
|
...input.plans ? {
|
|
1748
3336
|
plans: Object.freeze(Object.fromEntries(Object.entries(input.plans).map(([planName, planDef]) => [
|
|
1749
3337
|
planName,
|
|
1750
3338
|
Object.freeze({
|
|
1751
|
-
|
|
3339
|
+
...planDef.title ? { title: planDef.title } : {},
|
|
3340
|
+
...planDef.description ? { description: planDef.description } : {},
|
|
3341
|
+
...planDef.group ? { group: planDef.group } : {},
|
|
3342
|
+
...planDef.addOn ? { addOn: planDef.addOn } : {},
|
|
3343
|
+
...planDef.price ? { price: Object.freeze({ ...planDef.price }) } : {},
|
|
3344
|
+
...planDef.features ? { features: Object.freeze([...planDef.features]) } : {},
|
|
1752
3345
|
...planDef.limits ? {
|
|
1753
3346
|
limits: Object.freeze(Object.fromEntries(Object.entries(planDef.limits).map(([k, v]) => [
|
|
1754
3347
|
k,
|
|
1755
3348
|
Object.freeze({ ...v })
|
|
1756
3349
|
])))
|
|
3350
|
+
} : {},
|
|
3351
|
+
...planDef.grandfathering ? { grandfathering: Object.freeze({ ...planDef.grandfathering }) } : {},
|
|
3352
|
+
...planDef.requires ? {
|
|
3353
|
+
requires: Object.freeze({
|
|
3354
|
+
group: planDef.requires.group,
|
|
3355
|
+
plans: Object.freeze([...planDef.requires.plans])
|
|
3356
|
+
})
|
|
1757
3357
|
} : {}
|
|
1758
3358
|
})
|
|
1759
3359
|
])))
|
|
@@ -1761,6 +3361,79 @@ function defineAccess(input) {
|
|
|
1761
3361
|
};
|
|
1762
3362
|
return Object.freeze(config);
|
|
1763
3363
|
}
|
|
3364
|
+
function inferHierarchy(entities, childToParent, parentToChildren) {
|
|
3365
|
+
const inInheritance = new Set;
|
|
3366
|
+
for (const [child, parent] of childToParent.entries()) {
|
|
3367
|
+
inInheritance.add(child);
|
|
3368
|
+
inInheritance.add(parent);
|
|
3369
|
+
}
|
|
3370
|
+
if (inInheritance.size === 0) {
|
|
3371
|
+
return Object.keys(entities);
|
|
3372
|
+
}
|
|
3373
|
+
const roots = [];
|
|
3374
|
+
for (const entity of inInheritance) {
|
|
3375
|
+
if (!childToParent.has(entity)) {
|
|
3376
|
+
roots.push(entity);
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
const hierarchy = [];
|
|
3380
|
+
const visited = new Set;
|
|
3381
|
+
function walkChain(entity) {
|
|
3382
|
+
if (visited.has(entity))
|
|
3383
|
+
return;
|
|
3384
|
+
visited.add(entity);
|
|
3385
|
+
hierarchy.push(entity);
|
|
3386
|
+
const children = parentToChildren.get(entity);
|
|
3387
|
+
if (children) {
|
|
3388
|
+
for (const child of children) {
|
|
3389
|
+
walkChain(child);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
for (const root of roots) {
|
|
3394
|
+
walkChain(root);
|
|
3395
|
+
}
|
|
3396
|
+
for (const entityName of Object.keys(entities)) {
|
|
3397
|
+
if (!visited.has(entityName)) {
|
|
3398
|
+
hierarchy.push(entityName);
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
return hierarchy;
|
|
3402
|
+
}
|
|
3403
|
+
function detectCycles(childToParent) {
|
|
3404
|
+
const visited = new Set;
|
|
3405
|
+
const inPath = new Set;
|
|
3406
|
+
for (const entity of childToParent.keys()) {
|
|
3407
|
+
if (!visited.has(entity)) {
|
|
3408
|
+
walkForCycles(entity, childToParent, visited, inPath);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
function walkForCycles(entity, childToParent, visited, inPath) {
|
|
3413
|
+
if (inPath.has(entity)) {
|
|
3414
|
+
throw new Error("Circular inheritance detected");
|
|
3415
|
+
}
|
|
3416
|
+
if (visited.has(entity))
|
|
3417
|
+
return;
|
|
3418
|
+
inPath.add(entity);
|
|
3419
|
+
const parent = childToParent.get(entity);
|
|
3420
|
+
if (parent) {
|
|
3421
|
+
walkForCycles(parent, childToParent, visited, inPath);
|
|
3422
|
+
}
|
|
3423
|
+
inPath.delete(entity);
|
|
3424
|
+
visited.add(entity);
|
|
3425
|
+
}
|
|
3426
|
+
function createRuleContext() {
|
|
3427
|
+
return {
|
|
3428
|
+
where(conditions) {
|
|
3429
|
+
return { type: "where", conditions: Object.freeze({ ...conditions }) };
|
|
3430
|
+
},
|
|
3431
|
+
user: {
|
|
3432
|
+
id: Object.freeze({ __marker: "user.id" }),
|
|
3433
|
+
tenantId: Object.freeze({ __marker: "user.tenantId" })
|
|
3434
|
+
}
|
|
3435
|
+
};
|
|
3436
|
+
}
|
|
1764
3437
|
// src/auth/entity-access.ts
|
|
1765
3438
|
async function computeEntityAccess(entitlements, entity, accessContext) {
|
|
1766
3439
|
const results = {};
|
|
@@ -1775,23 +3448,23 @@ async function computeEntityAccess(entitlements, entity, accessContext) {
|
|
|
1775
3448
|
// src/auth/flag-store.ts
|
|
1776
3449
|
class InMemoryFlagStore {
|
|
1777
3450
|
flags = new Map;
|
|
1778
|
-
setFlag(
|
|
1779
|
-
let
|
|
1780
|
-
if (!
|
|
1781
|
-
|
|
1782
|
-
this.flags.set(
|
|
3451
|
+
setFlag(tenantId, flag, enabled) {
|
|
3452
|
+
let tenantFlags = this.flags.get(tenantId);
|
|
3453
|
+
if (!tenantFlags) {
|
|
3454
|
+
tenantFlags = new Map;
|
|
3455
|
+
this.flags.set(tenantId, tenantFlags);
|
|
1783
3456
|
}
|
|
1784
|
-
|
|
3457
|
+
tenantFlags.set(flag, enabled);
|
|
1785
3458
|
}
|
|
1786
|
-
getFlag(
|
|
1787
|
-
return this.flags.get(
|
|
3459
|
+
getFlag(tenantId, flag) {
|
|
3460
|
+
return this.flags.get(tenantId)?.get(flag) ?? false;
|
|
1788
3461
|
}
|
|
1789
|
-
getFlags(
|
|
1790
|
-
const
|
|
1791
|
-
if (!
|
|
3462
|
+
getFlags(tenantId) {
|
|
3463
|
+
const tenantFlags = this.flags.get(tenantId);
|
|
3464
|
+
if (!tenantFlags)
|
|
1792
3465
|
return {};
|
|
1793
3466
|
const result = {};
|
|
1794
|
-
for (const [key, value] of
|
|
3467
|
+
for (const [key, value] of tenantFlags) {
|
|
1795
3468
|
result[key] = value;
|
|
1796
3469
|
}
|
|
1797
3470
|
return result;
|
|
@@ -1804,6 +3477,385 @@ function checkFva(payload, maxAgeSeconds) {
|
|
|
1804
3477
|
const now = Math.floor(Date.now() / 1000);
|
|
1805
3478
|
return now - payload.fva < maxAgeSeconds;
|
|
1806
3479
|
}
|
|
3480
|
+
// src/auth/grandfathering-store.ts
|
|
3481
|
+
class InMemoryGrandfatheringStore {
|
|
3482
|
+
states = new Map;
|
|
3483
|
+
async setGrandfathered(tenantId, planId, version, graceEnds) {
|
|
3484
|
+
this.states.set(`${tenantId}:${planId}`, { tenantId, planId, version, graceEnds });
|
|
3485
|
+
}
|
|
3486
|
+
async getGrandfathered(tenantId, planId) {
|
|
3487
|
+
return this.states.get(`${tenantId}:${planId}`) ?? null;
|
|
3488
|
+
}
|
|
3489
|
+
async listGrandfathered(planId) {
|
|
3490
|
+
const result = [];
|
|
3491
|
+
for (const state of this.states.values()) {
|
|
3492
|
+
if (state.planId === planId) {
|
|
3493
|
+
result.push(state);
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
return result;
|
|
3497
|
+
}
|
|
3498
|
+
async removeGrandfathered(tenantId, planId) {
|
|
3499
|
+
this.states.delete(`${tenantId}:${planId}`);
|
|
3500
|
+
}
|
|
3501
|
+
dispose() {
|
|
3502
|
+
this.states.clear();
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
// src/auth/override-store.ts
|
|
3506
|
+
class InMemoryOverrideStore {
|
|
3507
|
+
overrides = new Map;
|
|
3508
|
+
async set(tenantId, overrides) {
|
|
3509
|
+
const existing = this.overrides.get(tenantId) ?? {};
|
|
3510
|
+
if (overrides.features) {
|
|
3511
|
+
const featureSet = new Set(existing.features ?? []);
|
|
3512
|
+
for (const f of overrides.features) {
|
|
3513
|
+
featureSet.add(f);
|
|
3514
|
+
}
|
|
3515
|
+
existing.features = [...featureSet];
|
|
3516
|
+
}
|
|
3517
|
+
if (overrides.limits) {
|
|
3518
|
+
existing.limits = { ...existing.limits, ...overrides.limits };
|
|
3519
|
+
}
|
|
3520
|
+
this.overrides.set(tenantId, existing);
|
|
3521
|
+
}
|
|
3522
|
+
async remove(tenantId, keys) {
|
|
3523
|
+
const existing = this.overrides.get(tenantId);
|
|
3524
|
+
if (!existing)
|
|
3525
|
+
return;
|
|
3526
|
+
if (keys.features && existing.features) {
|
|
3527
|
+
const removeSet = new Set(keys.features);
|
|
3528
|
+
existing.features = existing.features.filter((f) => !removeSet.has(f));
|
|
3529
|
+
if (existing.features.length === 0) {
|
|
3530
|
+
delete existing.features;
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
if (keys.limits && existing.limits) {
|
|
3534
|
+
for (const key of keys.limits) {
|
|
3535
|
+
delete existing.limits[key];
|
|
3536
|
+
}
|
|
3537
|
+
if (Object.keys(existing.limits).length === 0) {
|
|
3538
|
+
delete existing.limits;
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
if (!existing.features && !existing.limits) {
|
|
3542
|
+
this.overrides.delete(tenantId);
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
async get(tenantId) {
|
|
3546
|
+
return this.overrides.get(tenantId) ?? null;
|
|
3547
|
+
}
|
|
3548
|
+
dispose() {
|
|
3549
|
+
this.overrides.clear();
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
function validateOverrides(accessDef, overrides) {
|
|
3553
|
+
const allLimitKeys = new Set;
|
|
3554
|
+
if (accessDef.plans) {
|
|
3555
|
+
for (const planDef of Object.values(accessDef.plans)) {
|
|
3556
|
+
if (planDef.limits) {
|
|
3557
|
+
for (const key of Object.keys(planDef.limits)) {
|
|
3558
|
+
allLimitKeys.add(key);
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
if (overrides.limits) {
|
|
3564
|
+
for (const [key, limitDef] of Object.entries(overrides.limits)) {
|
|
3565
|
+
if (!allLimitKeys.has(key)) {
|
|
3566
|
+
throw new Error(`Override limit key '${key}' is not defined in any plan`);
|
|
3567
|
+
}
|
|
3568
|
+
if (limitDef.max !== undefined) {
|
|
3569
|
+
if (limitDef.max < -1) {
|
|
3570
|
+
throw new Error(`Override limit '${key}' max must be -1 (unlimited), 0 (disabled), or a positive integer, got ${limitDef.max}`);
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
if (limitDef.add !== undefined) {
|
|
3574
|
+
if (!Number.isFinite(limitDef.add)) {
|
|
3575
|
+
throw new Error(`Override limit '${key}' add must be a finite integer, got ${limitDef.add}`);
|
|
3576
|
+
}
|
|
3577
|
+
if (!Number.isInteger(limitDef.add)) {
|
|
3578
|
+
throw new Error(`Override limit '${key}' add must be an integer, got ${limitDef.add}`);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
if (overrides.features) {
|
|
3584
|
+
for (const feature of overrides.features) {
|
|
3585
|
+
if (!accessDef.entitlements[feature]) {
|
|
3586
|
+
throw new Error(`Override feature '${feature}' is not a defined entitlement`);
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
// src/auth/plan-hash.ts
|
|
3592
|
+
async function computePlanHash(config) {
|
|
3593
|
+
const canonical = {
|
|
3594
|
+
features: config.features ?? [],
|
|
3595
|
+
limits: config.limits ?? {},
|
|
3596
|
+
price: config.price ?? null
|
|
3597
|
+
};
|
|
3598
|
+
const json = JSON.stringify(canonical, sortedReplacer);
|
|
3599
|
+
return sha256Hex(json);
|
|
3600
|
+
}
|
|
3601
|
+
function sortedReplacer(_key, value) {
|
|
3602
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
3603
|
+
const sorted = {};
|
|
3604
|
+
for (const k of Object.keys(value).sort()) {
|
|
3605
|
+
sorted[k] = value[k];
|
|
3606
|
+
}
|
|
3607
|
+
return sorted;
|
|
3608
|
+
}
|
|
3609
|
+
return value;
|
|
3610
|
+
}
|
|
3611
|
+
// src/auth/plan-manager.ts
|
|
3612
|
+
var GRACE_DURATION_MS = {
|
|
3613
|
+
"1m": 30 * 24 * 60 * 60 * 1000,
|
|
3614
|
+
"3m": 90 * 24 * 60 * 60 * 1000,
|
|
3615
|
+
"6m": 180 * 24 * 60 * 60 * 1000,
|
|
3616
|
+
"12m": 365 * 24 * 60 * 60 * 1000
|
|
3617
|
+
};
|
|
3618
|
+
function resolveGraceEnd(planDef, now) {
|
|
3619
|
+
const grace = planDef.grandfathering?.grace;
|
|
3620
|
+
if (grace === "indefinite")
|
|
3621
|
+
return null;
|
|
3622
|
+
if (grace && GRACE_DURATION_MS[grace]) {
|
|
3623
|
+
return new Date(now.getTime() + GRACE_DURATION_MS[grace]);
|
|
3624
|
+
}
|
|
3625
|
+
const interval = planDef.price?.interval;
|
|
3626
|
+
if (interval === "year") {
|
|
3627
|
+
return new Date(now.getTime() + GRACE_DURATION_MS["3m"]);
|
|
3628
|
+
}
|
|
3629
|
+
return new Date(now.getTime() + GRACE_DURATION_MS["1m"]);
|
|
3630
|
+
}
|
|
3631
|
+
function createPlanManager(config) {
|
|
3632
|
+
const { plans, versionStore, grandfatheringStore, subscriptionStore } = config;
|
|
3633
|
+
const clock = config.clock ?? (() => new Date);
|
|
3634
|
+
const handlers = [];
|
|
3635
|
+
function emit(event) {
|
|
3636
|
+
for (const handler of handlers) {
|
|
3637
|
+
handler(event);
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
function extractSnapshot(planDef) {
|
|
3641
|
+
return {
|
|
3642
|
+
features: planDef.features ? [...planDef.features] : [],
|
|
3643
|
+
limits: planDef.limits ? { ...planDef.limits } : {},
|
|
3644
|
+
price: planDef.price ? { ...planDef.price } : null
|
|
3645
|
+
};
|
|
3646
|
+
}
|
|
3647
|
+
async function initialize() {
|
|
3648
|
+
const now = clock();
|
|
3649
|
+
for (const [planId, planDef] of Object.entries(plans)) {
|
|
3650
|
+
if (planDef.addOn)
|
|
3651
|
+
continue;
|
|
3652
|
+
const snapshot = extractSnapshot(planDef);
|
|
3653
|
+
const hash = await computePlanHash(snapshot);
|
|
3654
|
+
const currentHash = await versionStore.getCurrentHash(planId);
|
|
3655
|
+
if (currentHash === hash)
|
|
3656
|
+
continue;
|
|
3657
|
+
const version = await versionStore.createVersion(planId, hash, snapshot);
|
|
3658
|
+
const currentVersion = version;
|
|
3659
|
+
emit({
|
|
3660
|
+
type: "plan:version_created",
|
|
3661
|
+
planId,
|
|
3662
|
+
version,
|
|
3663
|
+
currentVersion,
|
|
3664
|
+
timestamp: now
|
|
3665
|
+
});
|
|
3666
|
+
if (version > 1) {
|
|
3667
|
+
const grandfatheredTenants = await listTenantsOnPlan(planId);
|
|
3668
|
+
const graceEnds = resolveGraceEnd(planDef, now);
|
|
3669
|
+
for (const tenantId of grandfatheredTenants) {
|
|
3670
|
+
const tenantVersion = await versionStore.getTenantVersion(tenantId, planId);
|
|
3671
|
+
if (tenantVersion !== null && tenantVersion < version) {
|
|
3672
|
+
await grandfatheringStore.setGrandfathered(tenantId, planId, tenantVersion, graceEnds);
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
async function listTenantsOnPlan(planId) {
|
|
3679
|
+
if (subscriptionStore.listByPlan) {
|
|
3680
|
+
return subscriptionStore.listByPlan(planId);
|
|
3681
|
+
}
|
|
3682
|
+
return [];
|
|
3683
|
+
}
|
|
3684
|
+
async function migrate(planId, opts) {
|
|
3685
|
+
const now = clock();
|
|
3686
|
+
const currentVersion = await versionStore.getCurrentVersion(planId);
|
|
3687
|
+
if (currentVersion === null)
|
|
3688
|
+
return;
|
|
3689
|
+
if (opts?.tenantId) {
|
|
3690
|
+
await migrateTenant(opts.tenantId, planId, currentVersion, now);
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
const grandfatheredList = await grandfatheringStore.listGrandfathered(planId);
|
|
3694
|
+
for (const state of grandfatheredList) {
|
|
3695
|
+
if (state.graceEnds === null)
|
|
3696
|
+
continue;
|
|
3697
|
+
if (state.graceEnds.getTime() <= now.getTime()) {
|
|
3698
|
+
await migrateTenant(state.tenantId, planId, currentVersion, now);
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
async function migrateTenant(tenantId, planId, targetVersion, now) {
|
|
3703
|
+
const previousVersion = await versionStore.getTenantVersion(tenantId, planId);
|
|
3704
|
+
if (previousVersion !== null) {
|
|
3705
|
+
const prevInfo = await versionStore.getVersion(planId, previousVersion);
|
|
3706
|
+
const targetInfo = await versionStore.getVersion(planId, targetVersion);
|
|
3707
|
+
if (prevInfo && targetInfo) {
|
|
3708
|
+
const prevFeatures = new Set(prevInfo.snapshot.features);
|
|
3709
|
+
const targetFeatures = new Set(targetInfo.snapshot.features);
|
|
3710
|
+
for (const f of prevFeatures) {
|
|
3711
|
+
if (!targetFeatures.has(f)) {
|
|
3712
|
+
break;
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
await versionStore.setTenantVersion(tenantId, planId, targetVersion);
|
|
3718
|
+
await grandfatheringStore.removeGrandfathered(tenantId, planId);
|
|
3719
|
+
emit({
|
|
3720
|
+
type: "plan:migrated",
|
|
3721
|
+
planId,
|
|
3722
|
+
tenantId,
|
|
3723
|
+
version: targetVersion,
|
|
3724
|
+
previousVersion: previousVersion ?? undefined,
|
|
3725
|
+
timestamp: now
|
|
3726
|
+
});
|
|
3727
|
+
}
|
|
3728
|
+
async function schedule(planId, opts) {
|
|
3729
|
+
const at = typeof opts.at === "string" ? new Date(opts.at) : opts.at;
|
|
3730
|
+
const grandfatheredList = await grandfatheringStore.listGrandfathered(planId);
|
|
3731
|
+
for (const state of grandfatheredList) {
|
|
3732
|
+
await grandfatheringStore.setGrandfathered(state.tenantId, planId, state.version, at);
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
async function resolve(tenantId) {
|
|
3736
|
+
const subscription = await subscriptionStore.get(tenantId);
|
|
3737
|
+
if (!subscription)
|
|
3738
|
+
return null;
|
|
3739
|
+
const planId = subscription.planId;
|
|
3740
|
+
const currentVersion = await versionStore.getCurrentVersion(planId);
|
|
3741
|
+
if (currentVersion === null)
|
|
3742
|
+
return null;
|
|
3743
|
+
const tenantVersion = await versionStore.getTenantVersion(tenantId, planId);
|
|
3744
|
+
const effectiveVersion = tenantVersion ?? currentVersion;
|
|
3745
|
+
const grandfatheringState = await grandfatheringStore.getGrandfathered(tenantId, planId);
|
|
3746
|
+
const versionInfo = await versionStore.getVersion(planId, effectiveVersion);
|
|
3747
|
+
if (!versionInfo)
|
|
3748
|
+
return null;
|
|
3749
|
+
return {
|
|
3750
|
+
planId,
|
|
3751
|
+
version: effectiveVersion,
|
|
3752
|
+
currentVersion,
|
|
3753
|
+
grandfathered: grandfatheringState !== null,
|
|
3754
|
+
graceEnds: grandfatheringState?.graceEnds ?? null,
|
|
3755
|
+
snapshot: versionInfo.snapshot
|
|
3756
|
+
};
|
|
3757
|
+
}
|
|
3758
|
+
async function grandfatheredFn(planId) {
|
|
3759
|
+
return grandfatheringStore.listGrandfathered(planId);
|
|
3760
|
+
}
|
|
3761
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
3762
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
3763
|
+
async function checkGraceEvents() {
|
|
3764
|
+
const now = clock();
|
|
3765
|
+
for (const planId of Object.keys(plans)) {
|
|
3766
|
+
const grandfatheredList = await grandfatheringStore.listGrandfathered(planId);
|
|
3767
|
+
for (const state of grandfatheredList) {
|
|
3768
|
+
if (state.graceEnds === null)
|
|
3769
|
+
continue;
|
|
3770
|
+
const timeUntilGraceEnd = state.graceEnds.getTime() - now.getTime();
|
|
3771
|
+
if (timeUntilGraceEnd <= SEVEN_DAYS_MS && timeUntilGraceEnd > 0) {
|
|
3772
|
+
emit({
|
|
3773
|
+
type: "plan:grace_expiring",
|
|
3774
|
+
planId,
|
|
3775
|
+
tenantId: state.tenantId,
|
|
3776
|
+
version: state.version,
|
|
3777
|
+
graceEnds: state.graceEnds,
|
|
3778
|
+
timestamp: now
|
|
3779
|
+
});
|
|
3780
|
+
} else if (timeUntilGraceEnd <= THIRTY_DAYS_MS && timeUntilGraceEnd > SEVEN_DAYS_MS) {
|
|
3781
|
+
emit({
|
|
3782
|
+
type: "plan:grace_approaching",
|
|
3783
|
+
planId,
|
|
3784
|
+
tenantId: state.tenantId,
|
|
3785
|
+
version: state.version,
|
|
3786
|
+
graceEnds: state.graceEnds,
|
|
3787
|
+
timestamp: now
|
|
3788
|
+
});
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
function on(handler) {
|
|
3794
|
+
handlers.push(handler);
|
|
3795
|
+
}
|
|
3796
|
+
function off(handler) {
|
|
3797
|
+
const idx = handlers.indexOf(handler);
|
|
3798
|
+
if (idx >= 0)
|
|
3799
|
+
handlers.splice(idx, 1);
|
|
3800
|
+
}
|
|
3801
|
+
return {
|
|
3802
|
+
initialize,
|
|
3803
|
+
migrate,
|
|
3804
|
+
schedule,
|
|
3805
|
+
resolve,
|
|
3806
|
+
grandfathered: grandfatheredFn,
|
|
3807
|
+
checkGraceEvents,
|
|
3808
|
+
on,
|
|
3809
|
+
off
|
|
3810
|
+
};
|
|
3811
|
+
}
|
|
3812
|
+
// src/auth/plan-version-store.ts
|
|
3813
|
+
class InMemoryPlanVersionStore {
|
|
3814
|
+
versions = new Map;
|
|
3815
|
+
tenantVersions = new Map;
|
|
3816
|
+
async createVersion(planId, hash, snapshot) {
|
|
3817
|
+
const planVersions = this.versions.get(planId) ?? [];
|
|
3818
|
+
const version = planVersions.length + 1;
|
|
3819
|
+
const info = {
|
|
3820
|
+
planId,
|
|
3821
|
+
version,
|
|
3822
|
+
hash,
|
|
3823
|
+
snapshot,
|
|
3824
|
+
createdAt: new Date
|
|
3825
|
+
};
|
|
3826
|
+
planVersions.push(info);
|
|
3827
|
+
this.versions.set(planId, planVersions);
|
|
3828
|
+
return version;
|
|
3829
|
+
}
|
|
3830
|
+
async getCurrentVersion(planId) {
|
|
3831
|
+
const planVersions = this.versions.get(planId);
|
|
3832
|
+
if (!planVersions || planVersions.length === 0)
|
|
3833
|
+
return null;
|
|
3834
|
+
return planVersions.length;
|
|
3835
|
+
}
|
|
3836
|
+
async getVersion(planId, version) {
|
|
3837
|
+
const planVersions = this.versions.get(planId);
|
|
3838
|
+
if (!planVersions)
|
|
3839
|
+
return null;
|
|
3840
|
+
return planVersions[version - 1] ?? null;
|
|
3841
|
+
}
|
|
3842
|
+
async getTenantVersion(tenantId, planId) {
|
|
3843
|
+
return this.tenantVersions.get(`${tenantId}:${planId}`) ?? null;
|
|
3844
|
+
}
|
|
3845
|
+
async setTenantVersion(tenantId, planId, version) {
|
|
3846
|
+
this.tenantVersions.set(`${tenantId}:${planId}`, version);
|
|
3847
|
+
}
|
|
3848
|
+
async getCurrentHash(planId) {
|
|
3849
|
+
const planVersions = this.versions.get(planId);
|
|
3850
|
+
if (!planVersions || planVersions.length === 0)
|
|
3851
|
+
return null;
|
|
3852
|
+
return planVersions[planVersions.length - 1].hash;
|
|
3853
|
+
}
|
|
3854
|
+
dispose() {
|
|
3855
|
+
this.versions.clear();
|
|
3856
|
+
this.tenantVersions.clear();
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
1807
3859
|
// src/auth/providers/discord.ts
|
|
1808
3860
|
var AUTHORIZATION_URL = "https://discord.com/api/oauth2/authorize";
|
|
1809
3861
|
var TOKEN_URL = "https://discord.com/api/oauth2/token";
|
|
@@ -1862,8 +3914,7 @@ function discord(config) {
|
|
|
1862
3914
|
providerId: data.id,
|
|
1863
3915
|
email: data.email ?? "",
|
|
1864
3916
|
emailVerified: data.verified ?? false,
|
|
1865
|
-
|
|
1866
|
-
avatarUrl: data.avatar ? `https://cdn.discordapp.com/avatars/${data.id}/${data.avatar}.png` : undefined
|
|
3917
|
+
raw: data
|
|
1867
3918
|
};
|
|
1868
3919
|
}
|
|
1869
3920
|
};
|
|
@@ -1905,6 +3956,9 @@ function github(config) {
|
|
|
1905
3956
|
})
|
|
1906
3957
|
});
|
|
1907
3958
|
const data = await response.json();
|
|
3959
|
+
if (data.error) {
|
|
3960
|
+
throw new Error(`GitHub token exchange failed: ${data.error} — ${data.error_description}`);
|
|
3961
|
+
}
|
|
1908
3962
|
return {
|
|
1909
3963
|
accessToken: data.access_token
|
|
1910
3964
|
};
|
|
@@ -1933,8 +3987,7 @@ function github(config) {
|
|
|
1933
3987
|
providerId: String(userData.id),
|
|
1934
3988
|
email: email?.email ?? "",
|
|
1935
3989
|
emailVerified: email?.verified ?? false,
|
|
1936
|
-
|
|
1937
|
-
avatarUrl: userData.avatar_url
|
|
3990
|
+
raw: userData
|
|
1938
3991
|
};
|
|
1939
3992
|
}
|
|
1940
3993
|
};
|
|
@@ -2006,8 +4059,7 @@ function google(config) {
|
|
|
2006
4059
|
providerId: payload.sub,
|
|
2007
4060
|
email: payload.email,
|
|
2008
4061
|
emailVerified: payload.email_verified,
|
|
2009
|
-
|
|
2010
|
-
avatarUrl: payload.picture
|
|
4062
|
+
raw: payload
|
|
2011
4063
|
};
|
|
2012
4064
|
}
|
|
2013
4065
|
};
|
|
@@ -2043,7 +4095,7 @@ class InMemoryRoleAssignmentStore {
|
|
|
2043
4095
|
continue;
|
|
2044
4096
|
const ancestorRoles = await this.getRoles(userId, ancestor.type, ancestor.id);
|
|
2045
4097
|
for (const ancestorRole of ancestorRoles) {
|
|
2046
|
-
const inheritedRole =
|
|
4098
|
+
const inheritedRole = resolveInheritedRole(ancestor.type, ancestorRole, resourceType, accessDef);
|
|
2047
4099
|
if (inheritedRole) {
|
|
2048
4100
|
candidateRoles.push(inheritedRole);
|
|
2049
4101
|
}
|
|
@@ -2065,28 +4117,13 @@ class InMemoryRoleAssignmentStore {
|
|
|
2065
4117
|
this.assignments = [];
|
|
2066
4118
|
}
|
|
2067
4119
|
}
|
|
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
4120
|
// src/auth/rules.ts
|
|
2085
4121
|
var userMarkers = {
|
|
2086
4122
|
id: Object.freeze({ __marker: "user.id" }),
|
|
2087
4123
|
tenantId: Object.freeze({ __marker: "user.tenantId" })
|
|
2088
4124
|
};
|
|
2089
4125
|
var rules = {
|
|
4126
|
+
public: Object.freeze({ type: "public" }),
|
|
2090
4127
|
role(...roleNames) {
|
|
2091
4128
|
return { type: "role", roles: Object.freeze([...roleNames]) };
|
|
2092
4129
|
},
|
|
@@ -2113,13 +4150,13 @@ var rules = {
|
|
|
2113
4150
|
// src/auth/wallet-store.ts
|
|
2114
4151
|
class InMemoryWalletStore {
|
|
2115
4152
|
entries = new Map;
|
|
2116
|
-
key(
|
|
2117
|
-
return `${
|
|
4153
|
+
key(tenantId, entitlement, periodStart) {
|
|
4154
|
+
return `${tenantId}:${entitlement}:${periodStart.getTime()}`;
|
|
2118
4155
|
}
|
|
2119
|
-
async consume(
|
|
2120
|
-
const k = this.key(
|
|
4156
|
+
async consume(tenantId, entitlement, periodStart, periodEnd, limit, amount = 1) {
|
|
4157
|
+
const k = this.key(tenantId, entitlement, periodStart);
|
|
2121
4158
|
if (!this.entries.has(k)) {
|
|
2122
|
-
this.entries.set(k, {
|
|
4159
|
+
this.entries.set(k, { tenantId, entitlement, periodStart, periodEnd, consumed: 0 });
|
|
2123
4160
|
}
|
|
2124
4161
|
const entry = this.entries.get(k);
|
|
2125
4162
|
if (entry.consumed + amount > limit) {
|
|
@@ -2138,15 +4175,15 @@ class InMemoryWalletStore {
|
|
|
2138
4175
|
remaining: limit - entry.consumed
|
|
2139
4176
|
};
|
|
2140
4177
|
}
|
|
2141
|
-
async unconsume(
|
|
2142
|
-
const k = this.key(
|
|
4178
|
+
async unconsume(tenantId, entitlement, periodStart, _periodEnd, amount = 1) {
|
|
4179
|
+
const k = this.key(tenantId, entitlement, periodStart);
|
|
2143
4180
|
const entry = this.entries.get(k);
|
|
2144
4181
|
if (!entry)
|
|
2145
4182
|
return;
|
|
2146
4183
|
entry.consumed = Math.max(0, entry.consumed - amount);
|
|
2147
4184
|
}
|
|
2148
|
-
async getConsumption(
|
|
2149
|
-
const k = this.key(
|
|
4185
|
+
async getConsumption(tenantId, entitlement, periodStart, _periodEnd) {
|
|
4186
|
+
const k = this.key(tenantId, entitlement, periodStart);
|
|
2150
4187
|
const entry = this.entries.get(k);
|
|
2151
4188
|
return entry?.consumed ?? 0;
|
|
2152
4189
|
}
|
|
@@ -2185,7 +4222,12 @@ function createAuth(config) {
|
|
|
2185
4222
|
if (session.strategy !== "jwt") {
|
|
2186
4223
|
throw new Error(`Session strategy "${session.strategy}" is not yet supported. Use "jwt".`);
|
|
2187
4224
|
}
|
|
2188
|
-
const
|
|
4225
|
+
const ttlSeconds = Math.floor(parseDuration(session.ttl) / 1000);
|
|
4226
|
+
const cookieConfig = {
|
|
4227
|
+
...DEFAULT_COOKIE_CONFIG,
|
|
4228
|
+
maxAge: ttlSeconds,
|
|
4229
|
+
...session.cookie
|
|
4230
|
+
};
|
|
2189
4231
|
const refreshName = session.refreshName ?? "vertz.ref";
|
|
2190
4232
|
const refreshTtlMs = parseDuration(session.refreshTtl ?? "7d");
|
|
2191
4233
|
const refreshMaxAge = Math.floor(refreshTtlMs / 1000);
|
|
@@ -2198,7 +4240,7 @@ function createAuth(config) {
|
|
|
2198
4240
|
if (!isProduction && cookieConfig.secure === false) {
|
|
2199
4241
|
console.warn("Cookie 'secure' flag is disabled. This is allowed in development but must be enabled in production.");
|
|
2200
4242
|
}
|
|
2201
|
-
const ttlMs =
|
|
4243
|
+
const ttlMs = ttlSeconds * 1000;
|
|
2202
4244
|
const DUMMY_HASH = "$2a$12$000000000000000000000uGWDREoC/y2KhZ5l2QkI4j0LpDjWcaq";
|
|
2203
4245
|
const sessionStore = config.sessionStore ?? new InMemorySessionStore;
|
|
2204
4246
|
const userStore = config.userStore ?? new InMemoryUserStore;
|
|
@@ -2228,6 +4270,9 @@ function createAuth(config) {
|
|
|
2228
4270
|
const passwordResetTtlMs = parseDuration(passwordResetConfig?.tokenTtl ?? "1h");
|
|
2229
4271
|
const revokeSessionsOnReset = passwordResetConfig?.revokeSessionsOnReset ?? true;
|
|
2230
4272
|
const passwordResetStore = config.passwordResetStore ?? (passwordResetEnabled ? new InMemoryPasswordResetStore : undefined);
|
|
4273
|
+
const tenantConfig = config.tenant;
|
|
4274
|
+
const onUserCreated = config.onUserCreated;
|
|
4275
|
+
const entityProxy = config._entityProxy ?? {};
|
|
2231
4276
|
const rateLimitStore = config.rateLimitStore ?? new InMemoryRateLimitStore;
|
|
2232
4277
|
const signInWindowMs = parseDuration(emailPassword?.rateLimit?.window || "15m");
|
|
2233
4278
|
const signUpWindowMs = parseDuration("1h");
|
|
@@ -2237,6 +4282,7 @@ function createAuth(config) {
|
|
|
2237
4282
|
const expiresAt = new Date(Date.now() + ttlMs);
|
|
2238
4283
|
const userClaims = claims ? claims(user) : {};
|
|
2239
4284
|
const fvaClaim = options?.fva !== undefined ? { fva: options.fva } : {};
|
|
4285
|
+
const tenantClaim = options?.tenantId ? { tenantId: options.tenantId } : {};
|
|
2240
4286
|
let aclClaim = {};
|
|
2241
4287
|
if (config.access) {
|
|
2242
4288
|
const accessSet = await computeAccessSet({
|
|
@@ -2245,7 +4291,8 @@ function createAuth(config) {
|
|
|
2245
4291
|
roleStore: config.access.roleStore,
|
|
2246
4292
|
closureStore: config.access.closureStore,
|
|
2247
4293
|
flagStore: config.access.flagStore,
|
|
2248
|
-
|
|
4294
|
+
subscriptionStore: config.access?.subscriptionStore,
|
|
4295
|
+
tenantId: null
|
|
2249
4296
|
});
|
|
2250
4297
|
const encoded = encodeAccessSet(accessSet);
|
|
2251
4298
|
const canonicalJson = JSON.stringify(encoded);
|
|
@@ -2265,6 +4312,7 @@ function createAuth(config) {
|
|
|
2265
4312
|
const jwt = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, () => ({
|
|
2266
4313
|
...userClaims,
|
|
2267
4314
|
...fvaClaim,
|
|
4315
|
+
...tenantClaim,
|
|
2268
4316
|
...aclClaim,
|
|
2269
4317
|
jti,
|
|
2270
4318
|
sid: sessionId
|
|
@@ -2280,16 +4328,24 @@ function createAuth(config) {
|
|
|
2280
4328
|
sid: sessionId,
|
|
2281
4329
|
claims: userClaims || undefined,
|
|
2282
4330
|
...options?.fva !== undefined ? { fva: options.fva } : {},
|
|
4331
|
+
...options?.tenantId ? { tenantId: options.tenantId } : {},
|
|
2283
4332
|
...aclClaim
|
|
2284
4333
|
};
|
|
2285
4334
|
return { jwt, refreshToken, payload, expiresAt };
|
|
2286
4335
|
}
|
|
4336
|
+
const FORGOT_PASSWORD_MIN_RESPONSE_MS = 15;
|
|
2287
4337
|
function generateToken() {
|
|
2288
4338
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
2289
4339
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2290
4340
|
}
|
|
4341
|
+
async function waitForMinimumDuration(startedAt, minDurationMs) {
|
|
4342
|
+
const remaining = minDurationMs - (Date.now() - startedAt);
|
|
4343
|
+
if (remaining > 0) {
|
|
4344
|
+
await new Promise((resolve) => setTimeout(resolve, remaining));
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
2291
4347
|
async function signUp(data, ctx) {
|
|
2292
|
-
const { email, password,
|
|
4348
|
+
const { email, password, ...additionalFields } = data;
|
|
2293
4349
|
if (!email || !email.includes("@")) {
|
|
2294
4350
|
return err(createAuthValidationError2("Invalid email format", "email", "INVALID_FORMAT"));
|
|
2295
4351
|
}
|
|
@@ -2311,18 +4367,37 @@ function createAuth(config) {
|
|
|
2311
4367
|
id: _id,
|
|
2312
4368
|
createdAt: _c,
|
|
2313
4369
|
updatedAt: _u,
|
|
4370
|
+
role: _role,
|
|
4371
|
+
emailVerified: _emailVerified,
|
|
2314
4372
|
...safeFields
|
|
2315
4373
|
} = additionalFields;
|
|
2316
4374
|
const user = {
|
|
2317
|
-
...safeFields,
|
|
2318
4375
|
id: crypto.randomUUID(),
|
|
2319
4376
|
email: email.toLowerCase(),
|
|
2320
|
-
role,
|
|
4377
|
+
role: "user",
|
|
2321
4378
|
emailVerified: !emailVerificationEnabled,
|
|
2322
4379
|
createdAt: now,
|
|
2323
4380
|
updatedAt: now
|
|
2324
4381
|
};
|
|
2325
4382
|
await userStore.createUser(user, passwordHash);
|
|
4383
|
+
if (onUserCreated) {
|
|
4384
|
+
const callbackCtx = { entities: entityProxy };
|
|
4385
|
+
const payload = {
|
|
4386
|
+
user,
|
|
4387
|
+
provider: null,
|
|
4388
|
+
signUpData: { ...safeFields }
|
|
4389
|
+
};
|
|
4390
|
+
try {
|
|
4391
|
+
await onUserCreated(payload, callbackCtx);
|
|
4392
|
+
} catch (callbackErr) {
|
|
4393
|
+
try {
|
|
4394
|
+
await userStore.deleteUser(user.id);
|
|
4395
|
+
} catch (rollbackErr) {
|
|
4396
|
+
console.error("[Auth] Failed to rollback user after onUserCreated failure:", rollbackErr);
|
|
4397
|
+
}
|
|
4398
|
+
return err(createAuthValidationError2("User setup failed", "general", "CALLBACK_FAILED"));
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
2326
4401
|
if (emailVerificationEnabled && emailVerificationStore && emailVerificationConfig?.onSend) {
|
|
2327
4402
|
const token = generateToken();
|
|
2328
4403
|
const tokenHash = await sha256Hex(token);
|
|
@@ -2414,6 +4489,10 @@ function createAuth(config) {
|
|
|
2414
4489
|
if (!payload) {
|
|
2415
4490
|
return ok(null);
|
|
2416
4491
|
}
|
|
4492
|
+
const storedSession = await sessionStore.findActiveSessionById(payload.sid);
|
|
4493
|
+
if (!storedSession || storedSession.userId !== payload.sub) {
|
|
4494
|
+
return ok(null);
|
|
4495
|
+
}
|
|
2417
4496
|
const user = await userStore.findById(payload.sub);
|
|
2418
4497
|
if (!user) {
|
|
2419
4498
|
return ok(null);
|
|
@@ -2496,16 +4575,16 @@ function createAuth(config) {
|
|
|
2496
4575
|
}
|
|
2497
4576
|
const currentSid = sessionResult.data.payload.sid;
|
|
2498
4577
|
const sessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
|
|
2499
|
-
const infos = sessions.map((
|
|
2500
|
-
id:
|
|
2501
|
-
userId:
|
|
2502
|
-
ipAddress:
|
|
2503
|
-
userAgent:
|
|
2504
|
-
deviceName: parseDeviceName(
|
|
2505
|
-
createdAt:
|
|
2506
|
-
lastActiveAt:
|
|
2507
|
-
expiresAt:
|
|
2508
|
-
isCurrent:
|
|
4578
|
+
const infos = sessions.map((s2) => ({
|
|
4579
|
+
id: s2.id,
|
|
4580
|
+
userId: s2.userId,
|
|
4581
|
+
ipAddress: s2.ipAddress,
|
|
4582
|
+
userAgent: s2.userAgent,
|
|
4583
|
+
deviceName: parseDeviceName(s2.userAgent),
|
|
4584
|
+
createdAt: s2.createdAt,
|
|
4585
|
+
lastActiveAt: s2.lastActiveAt,
|
|
4586
|
+
expiresAt: s2.expiresAt,
|
|
4587
|
+
isCurrent: s2.id === currentSid
|
|
2509
4588
|
}));
|
|
2510
4589
|
return ok(infos);
|
|
2511
4590
|
}
|
|
@@ -2517,7 +4596,7 @@ function createAuth(config) {
|
|
|
2517
4596
|
return err(createSessionExpiredError("Not authenticated"));
|
|
2518
4597
|
}
|
|
2519
4598
|
const targetSessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
|
|
2520
|
-
const target = targetSessions.find((
|
|
4599
|
+
const target = targetSessions.find((s2) => s2.id === sessionId);
|
|
2521
4600
|
if (!target) {
|
|
2522
4601
|
return err(createSessionNotFoundError("Session not found"));
|
|
2523
4602
|
}
|
|
@@ -2533,9 +4612,9 @@ function createAuth(config) {
|
|
|
2533
4612
|
}
|
|
2534
4613
|
const currentSid = sessionResult.data.payload.sid;
|
|
2535
4614
|
const sessions = await sessionStore.listActiveSessions(sessionResult.data.user.id);
|
|
2536
|
-
for (const
|
|
2537
|
-
if (
|
|
2538
|
-
await sessionStore.revokeSession(
|
|
4615
|
+
for (const s2 of sessions) {
|
|
4616
|
+
if (s2.id !== currentSid) {
|
|
4617
|
+
await sessionStore.revokeSession(s2.id);
|
|
2539
4618
|
}
|
|
2540
4619
|
}
|
|
2541
4620
|
return ok(undefined);
|
|
@@ -2568,6 +4647,32 @@ function createAuth(config) {
|
|
|
2568
4647
|
"Referrer-Policy": "strict-origin-when-cross-origin"
|
|
2569
4648
|
};
|
|
2570
4649
|
}
|
|
4650
|
+
function authValidationResponse(message) {
|
|
4651
|
+
return new Response(JSON.stringify({
|
|
4652
|
+
error: {
|
|
4653
|
+
code: "AUTH_VALIDATION_ERROR",
|
|
4654
|
+
message
|
|
4655
|
+
}
|
|
4656
|
+
}), {
|
|
4657
|
+
status: 400,
|
|
4658
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
4659
|
+
});
|
|
4660
|
+
}
|
|
4661
|
+
async function parseJsonAuthBody(request, schema) {
|
|
4662
|
+
try {
|
|
4663
|
+
const body = await parseBody(request);
|
|
4664
|
+
const result = schema.safeParse(body);
|
|
4665
|
+
if (!result.ok) {
|
|
4666
|
+
return { ok: false, response: authValidationResponse(result.error.message) };
|
|
4667
|
+
}
|
|
4668
|
+
return { ok: true, data: result.data };
|
|
4669
|
+
} catch (error) {
|
|
4670
|
+
if (error instanceof BadRequestException) {
|
|
4671
|
+
return { ok: false, response: authValidationResponse(error.message) };
|
|
4672
|
+
}
|
|
4673
|
+
throw error;
|
|
4674
|
+
}
|
|
4675
|
+
}
|
|
2571
4676
|
async function handleAuthRequest(request) {
|
|
2572
4677
|
const url = new URL(request.url);
|
|
2573
4678
|
const path = url.pathname.replace("/api/auth", "") || "/";
|
|
@@ -2611,7 +4716,11 @@ function createAuth(config) {
|
|
|
2611
4716
|
}
|
|
2612
4717
|
try {
|
|
2613
4718
|
if (method === "POST" && path === "/signup") {
|
|
2614
|
-
const
|
|
4719
|
+
const bodyResult = await parseJsonAuthBody(request, signUpInputSchema);
|
|
4720
|
+
if (!bodyResult.ok) {
|
|
4721
|
+
return bodyResult.response;
|
|
4722
|
+
}
|
|
4723
|
+
const body = bodyResult.data;
|
|
2615
4724
|
const result = await signUp(body, { headers: request.headers });
|
|
2616
4725
|
if (!result.ok) {
|
|
2617
4726
|
return new Response(JSON.stringify({ error: result.error }), {
|
|
@@ -2636,7 +4745,11 @@ function createAuth(config) {
|
|
|
2636
4745
|
});
|
|
2637
4746
|
}
|
|
2638
4747
|
if (method === "POST" && path === "/signin") {
|
|
2639
|
-
const
|
|
4748
|
+
const bodyResult = await parseJsonAuthBody(request, signInInputSchema);
|
|
4749
|
+
if (!bodyResult.ok) {
|
|
4750
|
+
return bodyResult.response;
|
|
4751
|
+
}
|
|
4752
|
+
const body = bodyResult.data;
|
|
2640
4753
|
const result = await signIn(body, { headers: request.headers });
|
|
2641
4754
|
if (!result.ok) {
|
|
2642
4755
|
if (result.error.code === "MFA_REQUIRED" && oauthEncryptionKey) {
|
|
@@ -2725,7 +4838,8 @@ function createAuth(config) {
|
|
|
2725
4838
|
roleStore: config.access.roleStore,
|
|
2726
4839
|
closureStore: config.access.closureStore,
|
|
2727
4840
|
flagStore: config.access.flagStore,
|
|
2728
|
-
|
|
4841
|
+
subscriptionStore: config.access?.subscriptionStore,
|
|
4842
|
+
tenantId: sessionResult.data.payload?.tenantId ?? null
|
|
2729
4843
|
});
|
|
2730
4844
|
const encoded = encodeAccessSet(accessSet);
|
|
2731
4845
|
const hashPayload = {
|
|
@@ -2741,8 +4855,9 @@ function createAuth(config) {
|
|
|
2741
4855
|
status: 304,
|
|
2742
4856
|
headers: {
|
|
2743
4857
|
ETag: quotedEtag,
|
|
4858
|
+
Vary: "Cookie",
|
|
2744
4859
|
...securityHeaders(),
|
|
2745
|
-
"Cache-Control": "no-cache"
|
|
4860
|
+
"Cache-Control": "private, no-cache"
|
|
2746
4861
|
}
|
|
2747
4862
|
});
|
|
2748
4863
|
}
|
|
@@ -2751,8 +4866,9 @@ function createAuth(config) {
|
|
|
2751
4866
|
headers: {
|
|
2752
4867
|
"Content-Type": "application/json",
|
|
2753
4868
|
ETag: quotedEtag,
|
|
4869
|
+
Vary: "Cookie",
|
|
2754
4870
|
...securityHeaders(),
|
|
2755
|
-
"Cache-Control": "no-cache"
|
|
4871
|
+
"Cache-Control": "private, no-cache"
|
|
2756
4872
|
}
|
|
2757
4873
|
});
|
|
2758
4874
|
}
|
|
@@ -2826,6 +4942,17 @@ function createAuth(config) {
|
|
|
2826
4942
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
2827
4943
|
});
|
|
2828
4944
|
}
|
|
4945
|
+
if (method === "GET" && path === "/providers") {
|
|
4946
|
+
const providerList = Array.from(providers.values()).map((p) => ({
|
|
4947
|
+
id: p.id,
|
|
4948
|
+
name: p.name,
|
|
4949
|
+
authUrl: `/api/auth/oauth/${p.id}`
|
|
4950
|
+
}));
|
|
4951
|
+
return new Response(JSON.stringify(providerList), {
|
|
4952
|
+
status: 200,
|
|
4953
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
4954
|
+
});
|
|
4955
|
+
}
|
|
2829
4956
|
if (method === "GET" && path.startsWith("/oauth/") && !path.includes("/callback")) {
|
|
2830
4957
|
const providerId = path.replace("/oauth/", "");
|
|
2831
4958
|
const provider = providers.get(providerId);
|
|
@@ -2872,12 +4999,17 @@ function createAuth(config) {
|
|
|
2872
4999
|
if (method === "GET" && path.includes("/oauth/") && path.endsWith("/callback")) {
|
|
2873
5000
|
const providerId = path.replace("/oauth/", "").replace("/callback", "");
|
|
2874
5001
|
const provider = providers.get(providerId);
|
|
2875
|
-
const
|
|
5002
|
+
const isAbsoluteUrl = /^https?:\/\//.test(oauthErrorRedirect);
|
|
5003
|
+
const errorUrl = (error) => {
|
|
5004
|
+
const url2 = new URL(oauthErrorRedirect, "http://localhost");
|
|
5005
|
+
url2.searchParams.set("error", error);
|
|
5006
|
+
return isAbsoluteUrl ? url2.toString() : url2.pathname + url2.search + url2.hash;
|
|
5007
|
+
};
|
|
2876
5008
|
if (!provider || !oauthEncryptionKey || !oauthAccountStore) {
|
|
2877
5009
|
return new Response(null, {
|
|
2878
5010
|
status: 302,
|
|
2879
5011
|
headers: {
|
|
2880
|
-
Location:
|
|
5012
|
+
Location: errorUrl("provider_not_configured"),
|
|
2881
5013
|
...securityHeaders()
|
|
2882
5014
|
}
|
|
2883
5015
|
});
|
|
@@ -2888,7 +5020,7 @@ function createAuth(config) {
|
|
|
2888
5020
|
return new Response(null, {
|
|
2889
5021
|
status: 302,
|
|
2890
5022
|
headers: {
|
|
2891
|
-
Location:
|
|
5023
|
+
Location: errorUrl("invalid_state"),
|
|
2892
5024
|
...securityHeaders()
|
|
2893
5025
|
}
|
|
2894
5026
|
});
|
|
@@ -2898,7 +5030,7 @@ function createAuth(config) {
|
|
|
2898
5030
|
return new Response(null, {
|
|
2899
5031
|
status: 302,
|
|
2900
5032
|
headers: {
|
|
2901
|
-
Location:
|
|
5033
|
+
Location: errorUrl("invalid_state"),
|
|
2902
5034
|
...securityHeaders()
|
|
2903
5035
|
}
|
|
2904
5036
|
});
|
|
@@ -2909,7 +5041,7 @@ function createAuth(config) {
|
|
|
2909
5041
|
const providerError = url.searchParams.get("error");
|
|
2910
5042
|
if (providerError) {
|
|
2911
5043
|
const headers = new Headers({
|
|
2912
|
-
Location:
|
|
5044
|
+
Location: errorUrl(providerError),
|
|
2913
5045
|
...securityHeaders()
|
|
2914
5046
|
});
|
|
2915
5047
|
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
@@ -2917,7 +5049,7 @@ function createAuth(config) {
|
|
|
2917
5049
|
}
|
|
2918
5050
|
if (stateData.state !== queryState || stateData.provider !== providerId) {
|
|
2919
5051
|
const headers = new Headers({
|
|
2920
|
-
Location:
|
|
5052
|
+
Location: errorUrl("invalid_state"),
|
|
2921
5053
|
...securityHeaders()
|
|
2922
5054
|
});
|
|
2923
5055
|
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
@@ -2925,7 +5057,7 @@ function createAuth(config) {
|
|
|
2925
5057
|
}
|
|
2926
5058
|
if (stateData.expiresAt < Date.now()) {
|
|
2927
5059
|
const headers = new Headers({
|
|
2928
|
-
Location:
|
|
5060
|
+
Location: errorUrl("invalid_state"),
|
|
2929
5061
|
...securityHeaders()
|
|
2930
5062
|
});
|
|
2931
5063
|
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
@@ -2952,7 +5084,7 @@ function createAuth(config) {
|
|
|
2952
5084
|
if (!userId) {
|
|
2953
5085
|
if (!userInfo.email || !userInfo.email.includes("@")) {
|
|
2954
5086
|
const headers = new Headers({
|
|
2955
|
-
Location:
|
|
5087
|
+
Location: errorUrl("email_required"),
|
|
2956
5088
|
...securityHeaders()
|
|
2957
5089
|
});
|
|
2958
5090
|
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
@@ -2962,6 +5094,7 @@ function createAuth(config) {
|
|
|
2962
5094
|
const newUser = {
|
|
2963
5095
|
id: crypto.randomUUID(),
|
|
2964
5096
|
email: userInfo.email.toLowerCase(),
|
|
5097
|
+
emailVerified: userInfo.emailVerified,
|
|
2965
5098
|
role: "user",
|
|
2966
5099
|
createdAt: now,
|
|
2967
5100
|
updatedAt: now
|
|
@@ -2969,6 +5102,34 @@ function createAuth(config) {
|
|
|
2969
5102
|
await userStore.createUser(newUser, null);
|
|
2970
5103
|
userId = newUser.id;
|
|
2971
5104
|
await oauthAccountStore.linkAccount(userId, provider.id, userInfo.providerId, userInfo.email);
|
|
5105
|
+
if (onUserCreated) {
|
|
5106
|
+
const callbackCtx = { entities: entityProxy };
|
|
5107
|
+
const payload = {
|
|
5108
|
+
user: newUser,
|
|
5109
|
+
provider: { id: provider.id, name: provider.name },
|
|
5110
|
+
profile: userInfo.raw
|
|
5111
|
+
};
|
|
5112
|
+
try {
|
|
5113
|
+
await onUserCreated(payload, callbackCtx);
|
|
5114
|
+
} catch {
|
|
5115
|
+
try {
|
|
5116
|
+
await oauthAccountStore.unlinkAccount(userId, provider.id);
|
|
5117
|
+
} catch (rollbackErr) {
|
|
5118
|
+
console.error("[Auth] Failed to unlink OAuth account during rollback:", rollbackErr);
|
|
5119
|
+
}
|
|
5120
|
+
try {
|
|
5121
|
+
await userStore.deleteUser(userId);
|
|
5122
|
+
} catch (rollbackErr) {
|
|
5123
|
+
console.error("[Auth] Failed to delete user during rollback:", rollbackErr);
|
|
5124
|
+
}
|
|
5125
|
+
const headers = new Headers({
|
|
5126
|
+
Location: errorUrl("user_setup_failed"),
|
|
5127
|
+
...securityHeaders()
|
|
5128
|
+
});
|
|
5129
|
+
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
5130
|
+
return new Response(null, { status: 302, headers });
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
2972
5133
|
}
|
|
2973
5134
|
}
|
|
2974
5135
|
const user = await userStore.findById(userId);
|
|
@@ -2976,7 +5137,7 @@ function createAuth(config) {
|
|
|
2976
5137
|
return new Response(null, {
|
|
2977
5138
|
status: 302,
|
|
2978
5139
|
headers: {
|
|
2979
|
-
Location:
|
|
5140
|
+
Location: errorUrl("user_info_failed"),
|
|
2980
5141
|
...securityHeaders()
|
|
2981
5142
|
}
|
|
2982
5143
|
});
|
|
@@ -2997,9 +5158,10 @@ function createAuth(config) {
|
|
|
2997
5158
|
responseHeaders.append("Set-Cookie", buildSessionCookie(sessionTokens.jwt, cookieConfig));
|
|
2998
5159
|
responseHeaders.append("Set-Cookie", buildRefreshCookie(sessionTokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
|
|
2999
5160
|
return new Response(null, { status: 302, headers: responseHeaders });
|
|
3000
|
-
} catch {
|
|
5161
|
+
} catch (oauthErr) {
|
|
5162
|
+
console.error("[Auth] OAuth callback error:", oauthErr);
|
|
3001
5163
|
const headers = new Headers({
|
|
3002
|
-
Location:
|
|
5164
|
+
Location: errorUrl("token_exchange_failed"),
|
|
3003
5165
|
...securityHeaders()
|
|
3004
5166
|
});
|
|
3005
5167
|
headers.append("Set-Cookie", buildOAuthStateCookie("", cookieConfig, true));
|
|
@@ -3049,7 +5211,11 @@ function createAuth(config) {
|
|
|
3049
5211
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3050
5212
|
});
|
|
3051
5213
|
}
|
|
3052
|
-
const
|
|
5214
|
+
const bodyResult = await parseJsonAuthBody(request, codeInputSchema);
|
|
5215
|
+
if (!bodyResult.ok) {
|
|
5216
|
+
return bodyResult.response;
|
|
5217
|
+
}
|
|
5218
|
+
const body = bodyResult.data;
|
|
3053
5219
|
const userId = challengeData.userId;
|
|
3054
5220
|
const encryptedSecret = await mfaStore.getSecret(userId);
|
|
3055
5221
|
if (!encryptedSecret) {
|
|
@@ -3169,7 +5335,11 @@ function createAuth(config) {
|
|
|
3169
5335
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3170
5336
|
});
|
|
3171
5337
|
}
|
|
3172
|
-
const
|
|
5338
|
+
const bodyResult = await parseJsonAuthBody(request, codeInputSchema);
|
|
5339
|
+
if (!bodyResult.ok) {
|
|
5340
|
+
return bodyResult.response;
|
|
5341
|
+
}
|
|
5342
|
+
const body = bodyResult.data;
|
|
3173
5343
|
const valid = await verifyTotpCode(pendingEntry.secret, body.code);
|
|
3174
5344
|
if (!valid) {
|
|
3175
5345
|
return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
|
|
@@ -3203,7 +5373,11 @@ function createAuth(config) {
|
|
|
3203
5373
|
});
|
|
3204
5374
|
}
|
|
3205
5375
|
const userId = sessionResult.data.user.id;
|
|
3206
|
-
const
|
|
5376
|
+
const bodyResult = await parseJsonAuthBody(request, passwordInputSchema);
|
|
5377
|
+
if (!bodyResult.ok) {
|
|
5378
|
+
return bodyResult.response;
|
|
5379
|
+
}
|
|
5380
|
+
const body = bodyResult.data;
|
|
3207
5381
|
const stored = await userStore.findByEmail(sessionResult.data.user.email);
|
|
3208
5382
|
if (!stored || !stored.passwordHash) {
|
|
3209
5383
|
return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
|
|
@@ -3238,7 +5412,11 @@ function createAuth(config) {
|
|
|
3238
5412
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3239
5413
|
});
|
|
3240
5414
|
}
|
|
3241
|
-
const
|
|
5415
|
+
const bodyResult = await parseJsonAuthBody(request, passwordInputSchema);
|
|
5416
|
+
if (!bodyResult.ok) {
|
|
5417
|
+
return bodyResult.response;
|
|
5418
|
+
}
|
|
5419
|
+
const body = bodyResult.data;
|
|
3242
5420
|
const stored = await userStore.findByEmail(sessionResult.data.user.email);
|
|
3243
5421
|
if (!stored || !stored.passwordHash) {
|
|
3244
5422
|
return new Response(JSON.stringify({ error: createInvalidCredentialsError() }), {
|
|
@@ -3325,7 +5503,11 @@ function createAuth(config) {
|
|
|
3325
5503
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3326
5504
|
});
|
|
3327
5505
|
}
|
|
3328
|
-
const
|
|
5506
|
+
const bodyResult = await parseJsonAuthBody(request, codeInputSchema);
|
|
5507
|
+
if (!bodyResult.ok) {
|
|
5508
|
+
return bodyResult.response;
|
|
5509
|
+
}
|
|
5510
|
+
const body = bodyResult.data;
|
|
3329
5511
|
const valid = await verifyTotpCode(totpSecret, body.code);
|
|
3330
5512
|
if (!valid) {
|
|
3331
5513
|
return new Response(JSON.stringify({ error: createMfaInvalidCodeError() }), {
|
|
@@ -3369,7 +5551,11 @@ function createAuth(config) {
|
|
|
3369
5551
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3370
5552
|
});
|
|
3371
5553
|
}
|
|
3372
|
-
const
|
|
5554
|
+
const bodyResult = await parseJsonAuthBody(request, tokenInputSchema);
|
|
5555
|
+
if (!bodyResult.ok) {
|
|
5556
|
+
return bodyResult.response;
|
|
5557
|
+
}
|
|
5558
|
+
const body = bodyResult.data;
|
|
3373
5559
|
if (!body.token) {
|
|
3374
5560
|
return new Response(JSON.stringify({ error: createTokenInvalidError("Token is required") }), {
|
|
3375
5561
|
status: 400,
|
|
@@ -3448,25 +5634,32 @@ function createAuth(config) {
|
|
|
3448
5634
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3449
5635
|
});
|
|
3450
5636
|
}
|
|
3451
|
-
const
|
|
5637
|
+
const bodyResult = await parseJsonAuthBody(request, forgotPasswordInputSchema);
|
|
5638
|
+
if (!bodyResult.ok) {
|
|
5639
|
+
return bodyResult.response;
|
|
5640
|
+
}
|
|
5641
|
+
const body = bodyResult.data;
|
|
5642
|
+
const startedAt = Date.now();
|
|
3452
5643
|
const forgotRateLimit = await rateLimitStore.check(`forgot-password:${(body.email ?? "").toLowerCase()}`, 3, parseDuration("1h"));
|
|
3453
5644
|
if (!forgotRateLimit.allowed) {
|
|
5645
|
+
await waitForMinimumDuration(startedAt, FORGOT_PASSWORD_MIN_RESPONSE_MS);
|
|
3454
5646
|
return new Response(JSON.stringify({ ok: true }), {
|
|
3455
5647
|
status: 200,
|
|
3456
5648
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3457
5649
|
});
|
|
3458
5650
|
}
|
|
5651
|
+
const token = generateToken();
|
|
5652
|
+
const tokenHash = await sha256Hex(token);
|
|
3459
5653
|
const stored = await userStore.findByEmail((body.email ?? "").toLowerCase());
|
|
3460
5654
|
if (stored) {
|
|
3461
|
-
const token = generateToken();
|
|
3462
|
-
const tokenHash = await sha256Hex(token);
|
|
3463
5655
|
await passwordResetStore.createReset({
|
|
3464
5656
|
userId: stored.user.id,
|
|
3465
5657
|
tokenHash,
|
|
3466
5658
|
expiresAt: new Date(Date.now() + passwordResetTtlMs)
|
|
3467
5659
|
});
|
|
3468
|
-
|
|
5660
|
+
passwordResetConfig.onSend(stored.user, token).catch((error) => console.error("[Auth] Failed to send password reset email", error));
|
|
3469
5661
|
}
|
|
5662
|
+
await waitForMinimumDuration(startedAt, FORGOT_PASSWORD_MIN_RESPONSE_MS);
|
|
3470
5663
|
return new Response(JSON.stringify({ ok: true }), {
|
|
3471
5664
|
status: 200,
|
|
3472
5665
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
@@ -3481,7 +5674,11 @@ function createAuth(config) {
|
|
|
3481
5674
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3482
5675
|
});
|
|
3483
5676
|
}
|
|
3484
|
-
const
|
|
5677
|
+
const bodyResult = await parseJsonAuthBody(request, resetPasswordInputSchema);
|
|
5678
|
+
if (!bodyResult.ok) {
|
|
5679
|
+
return bodyResult.response;
|
|
5680
|
+
}
|
|
5681
|
+
const body = bodyResult.data;
|
|
3485
5682
|
if (!body.token) {
|
|
3486
5683
|
return new Response(JSON.stringify({ error: createTokenInvalidError("Token is required") }), {
|
|
3487
5684
|
status: 400,
|
|
@@ -3515,8 +5712,8 @@ function createAuth(config) {
|
|
|
3515
5712
|
await passwordResetStore.deleteByUserId(resetRecord.userId);
|
|
3516
5713
|
if (revokeSessionsOnReset) {
|
|
3517
5714
|
const activeSessions = await sessionStore.listActiveSessions(resetRecord.userId);
|
|
3518
|
-
for (const
|
|
3519
|
-
await sessionStore.revokeSession(
|
|
5715
|
+
for (const s2 of activeSessions) {
|
|
5716
|
+
await sessionStore.revokeSession(s2.id);
|
|
3520
5717
|
}
|
|
3521
5718
|
}
|
|
3522
5719
|
return new Response(JSON.stringify({ ok: true }), {
|
|
@@ -3524,6 +5721,63 @@ function createAuth(config) {
|
|
|
3524
5721
|
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
3525
5722
|
});
|
|
3526
5723
|
}
|
|
5724
|
+
if (method === "POST" && path === "/switch-tenant") {
|
|
5725
|
+
if (!tenantConfig) {
|
|
5726
|
+
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
5727
|
+
status: 404,
|
|
5728
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
5729
|
+
});
|
|
5730
|
+
}
|
|
5731
|
+
const sessionResult = await getSession(request.headers);
|
|
5732
|
+
if (!sessionResult.ok || !sessionResult.data) {
|
|
5733
|
+
return new Response(JSON.stringify({ error: { code: "SESSION_EXPIRED", message: "Not authenticated" } }), {
|
|
5734
|
+
status: 401,
|
|
5735
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
5736
|
+
});
|
|
5737
|
+
}
|
|
5738
|
+
const bodyResult = await parseJsonAuthBody(request, switchTenantInputSchema);
|
|
5739
|
+
if (!bodyResult.ok) {
|
|
5740
|
+
return bodyResult.response;
|
|
5741
|
+
}
|
|
5742
|
+
const { tenantId } = bodyResult.data;
|
|
5743
|
+
const userId = sessionResult.data.user.id;
|
|
5744
|
+
const hasMembership = await tenantConfig.verifyMembership(userId, tenantId);
|
|
5745
|
+
if (!hasMembership) {
|
|
5746
|
+
return new Response(JSON.stringify({
|
|
5747
|
+
error: { code: "AUTH_FORBIDDEN", message: "Not a member of this tenant" }
|
|
5748
|
+
}), {
|
|
5749
|
+
status: 403,
|
|
5750
|
+
headers: { "Content-Type": "application/json", ...securityHeaders() }
|
|
5751
|
+
});
|
|
5752
|
+
}
|
|
5753
|
+
const currentPayload = sessionResult.data.payload;
|
|
5754
|
+
const tokens = await createSessionTokens(sessionResult.data.user, currentPayload.sid, {
|
|
5755
|
+
fva: currentPayload.fva,
|
|
5756
|
+
tenantId
|
|
5757
|
+
});
|
|
5758
|
+
const refreshTokenHash = await sha256Hex(tokens.refreshToken);
|
|
5759
|
+
const previousRefreshHash = (await sessionStore.findActiveSessionById(currentPayload.sid))?.refreshTokenHash ?? "";
|
|
5760
|
+
await sessionStore.updateSession(currentPayload.sid, {
|
|
5761
|
+
refreshTokenHash,
|
|
5762
|
+
previousRefreshHash,
|
|
5763
|
+
lastActiveAt: new Date,
|
|
5764
|
+
currentTokens: { jwt: tokens.jwt, refreshToken: tokens.refreshToken }
|
|
5765
|
+
});
|
|
5766
|
+
const headers = new Headers({
|
|
5767
|
+
"Content-Type": "application/json",
|
|
5768
|
+
...securityHeaders()
|
|
5769
|
+
});
|
|
5770
|
+
headers.append("Set-Cookie", buildSessionCookie(tokens.jwt, cookieConfig));
|
|
5771
|
+
headers.append("Set-Cookie", buildRefreshCookie(tokens.refreshToken, cookieConfig, refreshName, refreshMaxAge));
|
|
5772
|
+
return new Response(JSON.stringify({
|
|
5773
|
+
tenantId,
|
|
5774
|
+
user: sessionResult.data.user,
|
|
5775
|
+
expiresAt: tokens.expiresAt.getTime()
|
|
5776
|
+
}), {
|
|
5777
|
+
status: 200,
|
|
5778
|
+
headers
|
|
5779
|
+
});
|
|
5780
|
+
}
|
|
3527
5781
|
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
3528
5782
|
status: 404,
|
|
3529
5783
|
headers: { "Content-Type": "application/json" }
|
|
@@ -3574,9 +5828,49 @@ function createAuth(config) {
|
|
|
3574
5828
|
emailVerificationStore?.dispose();
|
|
3575
5829
|
passwordResetStore?.dispose();
|
|
3576
5830
|
pendingMfaSecrets.clear();
|
|
5831
|
+
},
|
|
5832
|
+
resolveSessionForSSR: resolveSessionForSSR({
|
|
5833
|
+
jwtSecret,
|
|
5834
|
+
jwtAlgorithm,
|
|
5835
|
+
cookieName: cookieConfig.name || "vertz.sid"
|
|
5836
|
+
})
|
|
5837
|
+
};
|
|
5838
|
+
}
|
|
5839
|
+
// src/content/builders.ts
|
|
5840
|
+
function stringDescriptor(contentType) {
|
|
5841
|
+
return {
|
|
5842
|
+
_kind: "content",
|
|
5843
|
+
_contentType: contentType,
|
|
5844
|
+
parse(value) {
|
|
5845
|
+
if (typeof value === "string") {
|
|
5846
|
+
return { ok: true, data: value };
|
|
5847
|
+
}
|
|
5848
|
+
return { ok: false, error: new Error(`Expected string, got ${typeof value}`) };
|
|
3577
5849
|
}
|
|
3578
5850
|
};
|
|
3579
5851
|
}
|
|
5852
|
+
var content = {
|
|
5853
|
+
xml: () => stringDescriptor("application/xml"),
|
|
5854
|
+
html: () => stringDescriptor("text/html"),
|
|
5855
|
+
text: () => stringDescriptor("text/plain"),
|
|
5856
|
+
binary: () => ({
|
|
5857
|
+
_kind: "content",
|
|
5858
|
+
_contentType: "application/octet-stream",
|
|
5859
|
+
parse(value) {
|
|
5860
|
+
if (value instanceof Uint8Array) {
|
|
5861
|
+
return { ok: true, data: value };
|
|
5862
|
+
}
|
|
5863
|
+
return {
|
|
5864
|
+
ok: false,
|
|
5865
|
+
error: new Error(`Expected Uint8Array, got ${typeof value}`)
|
|
5866
|
+
};
|
|
5867
|
+
}
|
|
5868
|
+
})
|
|
5869
|
+
};
|
|
5870
|
+
// src/content/content-descriptor.ts
|
|
5871
|
+
function isContentDescriptor(value) {
|
|
5872
|
+
return value != null && typeof value === "object" && "_kind" in value && value._kind === "content";
|
|
5873
|
+
}
|
|
3580
5874
|
// src/create-server.ts
|
|
3581
5875
|
import { createServer as coreCreateServer } from "@vertz/core";
|
|
3582
5876
|
import {
|
|
@@ -3657,10 +5951,15 @@ function narrowRelationFields(relationsConfig, data) {
|
|
|
3657
5951
|
if (config === undefined || config === true) {
|
|
3658
5952
|
result[key] = value;
|
|
3659
5953
|
} else if (config === false) {} else if (typeof config === "object" && value !== null && typeof value === "object") {
|
|
5954
|
+
const selectMap = config.select;
|
|
5955
|
+
if (!selectMap) {
|
|
5956
|
+
result[key] = value;
|
|
5957
|
+
continue;
|
|
5958
|
+
}
|
|
3660
5959
|
if (Array.isArray(value)) {
|
|
3661
5960
|
result[key] = value.map((item) => {
|
|
3662
5961
|
const narrowed = {};
|
|
3663
|
-
for (const field of Object.keys(
|
|
5962
|
+
for (const field of Object.keys(selectMap)) {
|
|
3664
5963
|
if (field in item) {
|
|
3665
5964
|
narrowed[field] = item[field];
|
|
3666
5965
|
}
|
|
@@ -3669,7 +5968,7 @@ function narrowRelationFields(relationsConfig, data) {
|
|
|
3669
5968
|
});
|
|
3670
5969
|
} else {
|
|
3671
5970
|
const narrowed = {};
|
|
3672
|
-
for (const field of Object.keys(
|
|
5971
|
+
for (const field of Object.keys(selectMap)) {
|
|
3673
5972
|
if (field in value) {
|
|
3674
5973
|
narrowed[field] = value[field];
|
|
3675
5974
|
}
|
|
@@ -3722,7 +6021,138 @@ import {
|
|
|
3722
6021
|
|
|
3723
6022
|
// src/entity/access-enforcer.ts
|
|
3724
6023
|
import { EntityForbiddenError, err as err2, ok as ok2 } from "@vertz/errors";
|
|
3725
|
-
|
|
6024
|
+
function deny(operation) {
|
|
6025
|
+
return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
|
|
6026
|
+
}
|
|
6027
|
+
function isUserMarker(value) {
|
|
6028
|
+
return typeof value === "object" && value !== null && "__marker" in value;
|
|
6029
|
+
}
|
|
6030
|
+
function resolveMarker(marker, ctx) {
|
|
6031
|
+
switch (marker.__marker) {
|
|
6032
|
+
case "user.id":
|
|
6033
|
+
return ctx.userId;
|
|
6034
|
+
case "user.tenantId":
|
|
6035
|
+
return ctx.tenantId;
|
|
6036
|
+
default:
|
|
6037
|
+
return;
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
async function evaluateRule(rule, operation, ctx, row, options) {
|
|
6041
|
+
switch (rule.type) {
|
|
6042
|
+
case "public":
|
|
6043
|
+
return ok2(undefined);
|
|
6044
|
+
case "authenticated":
|
|
6045
|
+
return ctx.authenticated() ? ok2(undefined) : deny(operation);
|
|
6046
|
+
case "role":
|
|
6047
|
+
return ctx.role(...rule.roles) ? ok2(undefined) : deny(operation);
|
|
6048
|
+
case "entitlement": {
|
|
6049
|
+
if (!options.can) {
|
|
6050
|
+
return deny(operation);
|
|
6051
|
+
}
|
|
6052
|
+
const allowed = await options.can(rule.entitlement);
|
|
6053
|
+
return allowed ? ok2(undefined) : deny(operation);
|
|
6054
|
+
}
|
|
6055
|
+
case "where": {
|
|
6056
|
+
for (const [key, expected] of Object.entries(rule.conditions)) {
|
|
6057
|
+
const resolved = isUserMarker(expected) ? resolveMarker(expected, ctx) : expected;
|
|
6058
|
+
if (row[key] !== resolved) {
|
|
6059
|
+
return deny(operation);
|
|
6060
|
+
}
|
|
6061
|
+
}
|
|
6062
|
+
return ok2(undefined);
|
|
6063
|
+
}
|
|
6064
|
+
case "all": {
|
|
6065
|
+
for (const sub of rule.rules) {
|
|
6066
|
+
const result = await evaluateDescriptor(sub, operation, ctx, row, options);
|
|
6067
|
+
if (!result.ok)
|
|
6068
|
+
return result;
|
|
6069
|
+
}
|
|
6070
|
+
return ok2(undefined);
|
|
6071
|
+
}
|
|
6072
|
+
case "any": {
|
|
6073
|
+
for (const sub of rule.rules) {
|
|
6074
|
+
const result = await evaluateDescriptor(sub, operation, ctx, row, options);
|
|
6075
|
+
if (result.ok)
|
|
6076
|
+
return result;
|
|
6077
|
+
}
|
|
6078
|
+
return deny(operation);
|
|
6079
|
+
}
|
|
6080
|
+
case "fva": {
|
|
6081
|
+
if (options.fvaAge === undefined) {
|
|
6082
|
+
return deny(operation);
|
|
6083
|
+
}
|
|
6084
|
+
return options.fvaAge <= rule.maxAge ? ok2(undefined) : deny(operation);
|
|
6085
|
+
}
|
|
6086
|
+
}
|
|
6087
|
+
}
|
|
6088
|
+
async function evaluateDescriptor(rule, operation, ctx, row, options) {
|
|
6089
|
+
if (typeof rule === "function") {
|
|
6090
|
+
const allowed = await rule(ctx, row);
|
|
6091
|
+
return allowed ? ok2(undefined) : deny(operation);
|
|
6092
|
+
}
|
|
6093
|
+
return evaluateRule(rule, operation, ctx, row, options);
|
|
6094
|
+
}
|
|
6095
|
+
async function evaluateRuleSkipWhere(rule, operation, ctx, row, options) {
|
|
6096
|
+
if (rule.type === "where") {
|
|
6097
|
+
return ok2(undefined);
|
|
6098
|
+
}
|
|
6099
|
+
if (rule.type === "all") {
|
|
6100
|
+
for (const sub of rule.rules) {
|
|
6101
|
+
const result = await evaluateDescriptorSkipWhere(sub, operation, ctx, row, options);
|
|
6102
|
+
if (!result.ok)
|
|
6103
|
+
return result;
|
|
6104
|
+
}
|
|
6105
|
+
return ok2(undefined);
|
|
6106
|
+
}
|
|
6107
|
+
if (rule.type === "any") {
|
|
6108
|
+
for (const sub of rule.rules) {
|
|
6109
|
+
const result = await evaluateDescriptorSkipWhere(sub, operation, ctx, row, options);
|
|
6110
|
+
if (result.ok)
|
|
6111
|
+
return result;
|
|
6112
|
+
}
|
|
6113
|
+
return deny(operation);
|
|
6114
|
+
}
|
|
6115
|
+
return evaluateRule(rule, operation, ctx, row, options);
|
|
6116
|
+
}
|
|
6117
|
+
async function evaluateDescriptorSkipWhere(rule, operation, ctx, row, options) {
|
|
6118
|
+
if (typeof rule === "function") {
|
|
6119
|
+
const allowed = await rule(ctx, row);
|
|
6120
|
+
return allowed ? ok2(undefined) : deny(operation);
|
|
6121
|
+
}
|
|
6122
|
+
return evaluateRuleSkipWhere(rule, operation, ctx, row, options);
|
|
6123
|
+
}
|
|
6124
|
+
function extractFromDescriptor(rule, ctx) {
|
|
6125
|
+
if (typeof rule === "function")
|
|
6126
|
+
return null;
|
|
6127
|
+
switch (rule.type) {
|
|
6128
|
+
case "where": {
|
|
6129
|
+
const resolved = {};
|
|
6130
|
+
for (const [key, value] of Object.entries(rule.conditions)) {
|
|
6131
|
+
resolved[key] = isUserMarker(value) ? resolveMarker(value, ctx) : value;
|
|
6132
|
+
}
|
|
6133
|
+
return resolved;
|
|
6134
|
+
}
|
|
6135
|
+
case "all": {
|
|
6136
|
+
let merged = null;
|
|
6137
|
+
for (const sub of rule.rules) {
|
|
6138
|
+
const extracted = extractFromDescriptor(sub, ctx);
|
|
6139
|
+
if (extracted) {
|
|
6140
|
+
merged = { ...merged ?? {}, ...extracted };
|
|
6141
|
+
}
|
|
6142
|
+
}
|
|
6143
|
+
return merged;
|
|
6144
|
+
}
|
|
6145
|
+
default:
|
|
6146
|
+
return null;
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
function extractWhereConditions(operation, accessRules, ctx) {
|
|
6150
|
+
const rule = accessRules[operation];
|
|
6151
|
+
if (rule === undefined || rule === false)
|
|
6152
|
+
return null;
|
|
6153
|
+
return extractFromDescriptor(rule, ctx);
|
|
6154
|
+
}
|
|
6155
|
+
async function enforceAccess(operation, accessRules, ctx, row, options) {
|
|
3726
6156
|
const rule = accessRules[operation];
|
|
3727
6157
|
if (rule === undefined) {
|
|
3728
6158
|
return err2(new EntityForbiddenError(`Access denied: no access rule for operation "${operation}"`));
|
|
@@ -3730,11 +6160,10 @@ async function enforceAccess(operation, accessRules, ctx, row) {
|
|
|
3730
6160
|
if (rule === false) {
|
|
3731
6161
|
return err2(new EntityForbiddenError(`Operation "${operation}" is disabled`));
|
|
3732
6162
|
}
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
return err2(new EntityForbiddenError(`Access denied for operation "${operation}"`));
|
|
6163
|
+
if (options?.skipWhere) {
|
|
6164
|
+
return evaluateDescriptorSkipWhere(rule, operation, ctx, row ?? {}, options);
|
|
3736
6165
|
}
|
|
3737
|
-
return
|
|
6166
|
+
return evaluateDescriptor(rule, operation, ctx, row ?? {}, options ?? {});
|
|
3738
6167
|
}
|
|
3739
6168
|
|
|
3740
6169
|
// src/entity/action-pipeline.ts
|
|
@@ -3776,6 +6205,7 @@ function createEntityContext(request, entityOps, registryProxy) {
|
|
|
3776
6205
|
const tenantId = request.tenantId ?? null;
|
|
3777
6206
|
return {
|
|
3778
6207
|
userId,
|
|
6208
|
+
tenantId,
|
|
3779
6209
|
authenticated() {
|
|
3780
6210
|
return userId !== null;
|
|
3781
6211
|
},
|
|
@@ -3791,7 +6221,12 @@ function createEntityContext(request, entityOps, registryProxy) {
|
|
|
3791
6221
|
}
|
|
3792
6222
|
|
|
3793
6223
|
// src/entity/crud-pipeline.ts
|
|
3794
|
-
import {
|
|
6224
|
+
import {
|
|
6225
|
+
EntityForbiddenError as EntityForbiddenError2,
|
|
6226
|
+
EntityNotFoundError as EntityNotFoundError2,
|
|
6227
|
+
err as err4,
|
|
6228
|
+
ok as ok4
|
|
6229
|
+
} from "@vertz/errors";
|
|
3795
6230
|
function resolvePrimaryKeyColumn(table) {
|
|
3796
6231
|
for (const key of Object.keys(table._columns)) {
|
|
3797
6232
|
const col = table._columns[key];
|
|
@@ -3800,20 +6235,82 @@ function resolvePrimaryKeyColumn(table) {
|
|
|
3800
6235
|
}
|
|
3801
6236
|
return "id";
|
|
3802
6237
|
}
|
|
3803
|
-
function createCrudHandlers(def, db) {
|
|
6238
|
+
function createCrudHandlers(def, db, options) {
|
|
3804
6239
|
const table = def.model.table;
|
|
6240
|
+
const isTenantScoped = def.tenantScoped;
|
|
6241
|
+
const tenantChain = options?.tenantChain ?? def.tenantChain ?? null;
|
|
6242
|
+
const isIndirectlyScoped = tenantChain !== null;
|
|
6243
|
+
const queryParentIds = options?.queryParentIds ?? null;
|
|
6244
|
+
function notFound(id) {
|
|
6245
|
+
return err4(new EntityNotFoundError2(`${def.name} with id "${id}" not found`));
|
|
6246
|
+
}
|
|
6247
|
+
function isSameTenant(ctx, row) {
|
|
6248
|
+
if (!isTenantScoped)
|
|
6249
|
+
return true;
|
|
6250
|
+
if (isIndirectlyScoped)
|
|
6251
|
+
return true;
|
|
6252
|
+
return row.tenantId === ctx.tenantId;
|
|
6253
|
+
}
|
|
6254
|
+
function withTenantFilter(ctx, where) {
|
|
6255
|
+
if (!isTenantScoped)
|
|
6256
|
+
return where;
|
|
6257
|
+
if (isIndirectlyScoped)
|
|
6258
|
+
return where;
|
|
6259
|
+
return { ...where, tenantId: ctx.tenantId };
|
|
6260
|
+
}
|
|
6261
|
+
async function resolveIndirectTenantWhere(ctx) {
|
|
6262
|
+
if (!isIndirectlyScoped || !tenantChain || !queryParentIds)
|
|
6263
|
+
return null;
|
|
6264
|
+
if (!ctx.tenantId) {
|
|
6265
|
+
return { [tenantChain.hops[0].foreignKey]: { in: [] } };
|
|
6266
|
+
}
|
|
6267
|
+
const { hops, tenantColumn } = tenantChain;
|
|
6268
|
+
const lastHop = hops[hops.length - 1];
|
|
6269
|
+
let currentIds = await queryParentIds(lastHop.tableName, { [tenantColumn]: ctx.tenantId });
|
|
6270
|
+
for (let i = hops.length - 2;i >= 0; i--) {
|
|
6271
|
+
if (currentIds.length === 0)
|
|
6272
|
+
break;
|
|
6273
|
+
const hop = hops[i];
|
|
6274
|
+
const nextHop = hops[i + 1];
|
|
6275
|
+
currentIds = await queryParentIds(hop.tableName, {
|
|
6276
|
+
[nextHop.foreignKey]: { in: currentIds }
|
|
6277
|
+
});
|
|
6278
|
+
}
|
|
6279
|
+
return { [hops[0].foreignKey]: { in: currentIds } };
|
|
6280
|
+
}
|
|
6281
|
+
async function verifyIndirectTenantOwnership(ctx, row) {
|
|
6282
|
+
if (!isIndirectlyScoped || !tenantChain || !queryParentIds)
|
|
6283
|
+
return true;
|
|
6284
|
+
if (!ctx.tenantId)
|
|
6285
|
+
return false;
|
|
6286
|
+
const indirectWhere = await resolveIndirectTenantWhere(ctx);
|
|
6287
|
+
if (!indirectWhere)
|
|
6288
|
+
return false;
|
|
6289
|
+
const firstHopFK = tenantChain.hops[0].foreignKey;
|
|
6290
|
+
const allowed = indirectWhere[firstHopFK];
|
|
6291
|
+
if (!allowed)
|
|
6292
|
+
return false;
|
|
6293
|
+
return allowed.in.includes(row[firstHopFK]);
|
|
6294
|
+
}
|
|
3805
6295
|
return {
|
|
3806
|
-
async list(ctx,
|
|
3807
|
-
const
|
|
6296
|
+
async list(ctx, options2) {
|
|
6297
|
+
const accessWhere = extractWhereConditions("list", def.access, ctx);
|
|
6298
|
+
const accessResult = await enforceAccess("list", def.access, ctx, undefined, {
|
|
6299
|
+
skipWhere: accessWhere !== null
|
|
6300
|
+
});
|
|
3808
6301
|
if (!accessResult.ok)
|
|
3809
6302
|
return err4(accessResult.error);
|
|
3810
|
-
const rawWhere =
|
|
6303
|
+
const rawWhere = options2?.where;
|
|
3811
6304
|
const safeWhere = rawWhere ? stripHiddenFields(table, rawWhere) : undefined;
|
|
3812
|
-
const
|
|
3813
|
-
const
|
|
3814
|
-
const
|
|
3815
|
-
const
|
|
3816
|
-
const
|
|
6305
|
+
const cleanWhere = safeWhere && Object.keys(safeWhere).length > 0 ? safeWhere : undefined;
|
|
6306
|
+
const directWhere = withTenantFilter(ctx, { ...accessWhere, ...cleanWhere });
|
|
6307
|
+
const indirectWhere = await resolveIndirectTenantWhere(ctx);
|
|
6308
|
+
const where = indirectWhere ? { ...directWhere, ...indirectWhere } : directWhere;
|
|
6309
|
+
const limit = Math.max(0, options2?.limit ?? 20);
|
|
6310
|
+
const after = options2?.after && options2.after.length <= 512 ? options2.after : undefined;
|
|
6311
|
+
const orderBy = options2?.orderBy;
|
|
6312
|
+
const include = options2?.include;
|
|
6313
|
+
const { data: rows, total } = await db.list({ where, orderBy, limit, after, include });
|
|
3817
6314
|
const data = rows.map((row) => narrowRelationFields(def.relations, stripHiddenFields(table, row)));
|
|
3818
6315
|
const pkColumn = resolvePrimaryKeyColumn(table);
|
|
3819
6316
|
const lastRow = rows[rows.length - 1];
|
|
@@ -3821,10 +6318,15 @@ function createCrudHandlers(def, db) {
|
|
|
3821
6318
|
const hasNextPage = nextCursor !== null;
|
|
3822
6319
|
return ok4({ status: 200, body: { items: data, total, limit, nextCursor, hasNextPage } });
|
|
3823
6320
|
},
|
|
3824
|
-
async get(ctx, id) {
|
|
3825
|
-
const
|
|
3826
|
-
|
|
3827
|
-
|
|
6321
|
+
async get(ctx, id, options2) {
|
|
6322
|
+
const getOptions = options2?.include ? { include: options2.include } : undefined;
|
|
6323
|
+
const row = await db.get(id, getOptions);
|
|
6324
|
+
if (!row)
|
|
6325
|
+
return notFound(id);
|
|
6326
|
+
if (!isSameTenant(ctx, row))
|
|
6327
|
+
return notFound(id);
|
|
6328
|
+
if (isIndirectlyScoped && !await verifyIndirectTenantOwnership(ctx, row)) {
|
|
6329
|
+
return notFound(id);
|
|
3828
6330
|
}
|
|
3829
6331
|
const accessResult = await enforceAccess("get", def.access, ctx, row);
|
|
3830
6332
|
if (!accessResult.ok)
|
|
@@ -3839,6 +6341,29 @@ function createCrudHandlers(def, db) {
|
|
|
3839
6341
|
if (!accessResult.ok)
|
|
3840
6342
|
return err4(accessResult.error);
|
|
3841
6343
|
let input = stripReadOnlyFields(table, data);
|
|
6344
|
+
if (isIndirectlyScoped && tenantChain && queryParentIds && ctx.tenantId) {
|
|
6345
|
+
const firstHop = tenantChain.hops[0];
|
|
6346
|
+
const parentId = input[firstHop.foreignKey];
|
|
6347
|
+
if (!parentId) {
|
|
6348
|
+
return err4(new EntityNotFoundError2(`Referenced parent not found: ${firstHop.foreignKey} is required`));
|
|
6349
|
+
}
|
|
6350
|
+
const parentExists = await queryParentIds(firstHop.tableName, {
|
|
6351
|
+
[firstHop.targetColumn]: parentId
|
|
6352
|
+
});
|
|
6353
|
+
if (parentExists.length === 0) {
|
|
6354
|
+
return err4(new EntityNotFoundError2(`Referenced parent not found: ${firstHop.tableName} with ${firstHop.targetColumn} "${parentId}" does not exist`));
|
|
6355
|
+
}
|
|
6356
|
+
const indirectWhere = await resolveIndirectTenantWhere(ctx);
|
|
6357
|
+
if (indirectWhere) {
|
|
6358
|
+
const allowed = indirectWhere[firstHop.foreignKey];
|
|
6359
|
+
if (!allowed || !allowed.in.includes(parentId)) {
|
|
6360
|
+
return err4(new EntityForbiddenError2(`Referenced ${firstHop.tableName} does not belong to your tenant`));
|
|
6361
|
+
}
|
|
6362
|
+
}
|
|
6363
|
+
}
|
|
6364
|
+
if (isTenantScoped && !isIndirectlyScoped && ctx.tenantId) {
|
|
6365
|
+
input = { ...input, tenantId: ctx.tenantId };
|
|
6366
|
+
}
|
|
3842
6367
|
if (def.before.create) {
|
|
3843
6368
|
input = await def.before.create(input, ctx);
|
|
3844
6369
|
}
|
|
@@ -3849,12 +6374,19 @@ function createCrudHandlers(def, db) {
|
|
|
3849
6374
|
await def.after.create(strippedResult, ctx);
|
|
3850
6375
|
} catch {}
|
|
3851
6376
|
}
|
|
3852
|
-
return ok4({
|
|
6377
|
+
return ok4({
|
|
6378
|
+
status: 201,
|
|
6379
|
+
body: narrowRelationFields(def.relations, strippedResult)
|
|
6380
|
+
});
|
|
3853
6381
|
},
|
|
3854
6382
|
async update(ctx, id, data) {
|
|
3855
6383
|
const existing = await db.get(id);
|
|
3856
|
-
if (!existing)
|
|
3857
|
-
return
|
|
6384
|
+
if (!existing)
|
|
6385
|
+
return notFound(id);
|
|
6386
|
+
if (!isSameTenant(ctx, existing))
|
|
6387
|
+
return notFound(id);
|
|
6388
|
+
if (isIndirectlyScoped && !await verifyIndirectTenantOwnership(ctx, existing)) {
|
|
6389
|
+
return notFound(id);
|
|
3858
6390
|
}
|
|
3859
6391
|
const accessResult = await enforceAccess("update", def.access, ctx, existing);
|
|
3860
6392
|
if (!accessResult.ok)
|
|
@@ -3878,8 +6410,12 @@ function createCrudHandlers(def, db) {
|
|
|
3878
6410
|
},
|
|
3879
6411
|
async delete(ctx, id) {
|
|
3880
6412
|
const existing = await db.get(id);
|
|
3881
|
-
if (!existing)
|
|
3882
|
-
return
|
|
6413
|
+
if (!existing)
|
|
6414
|
+
return notFound(id);
|
|
6415
|
+
if (!isSameTenant(ctx, existing))
|
|
6416
|
+
return notFound(id);
|
|
6417
|
+
if (isIndirectlyScoped && !await verifyIndirectTenantOwnership(ctx, existing)) {
|
|
6418
|
+
return notFound(id);
|
|
3883
6419
|
}
|
|
3884
6420
|
const accessResult = await enforceAccess("delete", def.access, ctx, existing);
|
|
3885
6421
|
if (!accessResult.ok)
|
|
@@ -3960,14 +6496,7 @@ function entityErrorHandler(error) {
|
|
|
3960
6496
|
// src/entity/vertzql-parser.ts
|
|
3961
6497
|
var MAX_LIMIT = 1000;
|
|
3962
6498
|
var MAX_Q_BASE64_LENGTH = 10240;
|
|
3963
|
-
var ALLOWED_Q_KEYS = new Set([
|
|
3964
|
-
"select",
|
|
3965
|
-
"include",
|
|
3966
|
-
"where",
|
|
3967
|
-
"orderBy",
|
|
3968
|
-
"limit",
|
|
3969
|
-
"offset"
|
|
3970
|
-
]);
|
|
6499
|
+
var ALLOWED_Q_KEYS = new Set(["select", "include", "where", "orderBy", "limit", "offset"]);
|
|
3971
6500
|
function parseVertzQL(query) {
|
|
3972
6501
|
const result = {};
|
|
3973
6502
|
for (const [key, value] of Object.entries(query)) {
|
|
@@ -4077,22 +6606,94 @@ function validateVertzQL(options, table, relationsConfig) {
|
|
|
4077
6606
|
}
|
|
4078
6607
|
}
|
|
4079
6608
|
if (options.include && relationsConfig) {
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
6609
|
+
const includeResult = validateInclude(options.include, relationsConfig, "");
|
|
6610
|
+
if (!includeResult.ok)
|
|
6611
|
+
return includeResult;
|
|
6612
|
+
}
|
|
6613
|
+
return { ok: true };
|
|
6614
|
+
}
|
|
6615
|
+
function validateInclude(include, relationsConfig, pathPrefix) {
|
|
6616
|
+
for (const [relation, requested] of Object.entries(include)) {
|
|
6617
|
+
const entityConfig = relationsConfig[relation];
|
|
6618
|
+
const relationPath = pathPrefix ? `${pathPrefix}.${relation}` : relation;
|
|
6619
|
+
if (entityConfig === undefined || entityConfig === false) {
|
|
6620
|
+
return { ok: false, error: `Relation "${relationPath}" is not exposed` };
|
|
6621
|
+
}
|
|
6622
|
+
if (requested === true)
|
|
6623
|
+
continue;
|
|
6624
|
+
const configObj = typeof entityConfig === "object" ? entityConfig : undefined;
|
|
6625
|
+
if (requested.where) {
|
|
6626
|
+
if (!configObj || !configObj.allowWhere || configObj.allowWhere.length === 0) {
|
|
6627
|
+
return {
|
|
6628
|
+
ok: false,
|
|
6629
|
+
error: `Filtering is not enabled on relation '${relationPath}'. ` + "Add 'allowWhere' to the entity relations config."
|
|
6630
|
+
};
|
|
6631
|
+
}
|
|
6632
|
+
const allowedSet = new Set(configObj.allowWhere);
|
|
6633
|
+
for (const field of Object.keys(requested.where)) {
|
|
6634
|
+
if (!allowedSet.has(field)) {
|
|
6635
|
+
return {
|
|
6636
|
+
ok: false,
|
|
6637
|
+
error: `Field '${field}' is not filterable on relation '${relationPath}'. ` + `Allowed: ${configObj.allowWhere.join(", ")}`
|
|
6638
|
+
};
|
|
6639
|
+
}
|
|
6640
|
+
}
|
|
6641
|
+
}
|
|
6642
|
+
if (requested.orderBy) {
|
|
6643
|
+
if (!configObj || !configObj.allowOrderBy || configObj.allowOrderBy.length === 0) {
|
|
6644
|
+
return {
|
|
6645
|
+
ok: false,
|
|
6646
|
+
error: `Sorting is not enabled on relation '${relationPath}'. ` + "Add 'allowOrderBy' to the entity relations config."
|
|
6647
|
+
};
|
|
6648
|
+
}
|
|
6649
|
+
const allowedSet = new Set(configObj.allowOrderBy);
|
|
6650
|
+
for (const [field, dir] of Object.entries(requested.orderBy)) {
|
|
6651
|
+
if (!allowedSet.has(field)) {
|
|
6652
|
+
return {
|
|
6653
|
+
ok: false,
|
|
6654
|
+
error: `Field '${field}' is not sortable on relation '${relationPath}'. ` + `Allowed: ${configObj.allowOrderBy.join(", ")}`
|
|
6655
|
+
};
|
|
6656
|
+
}
|
|
6657
|
+
if (dir !== "asc" && dir !== "desc") {
|
|
6658
|
+
return {
|
|
6659
|
+
ok: false,
|
|
6660
|
+
error: `Invalid orderBy direction '${String(dir)}' for field '${field}' on relation '${relationPath}'. Must be 'asc' or 'desc'.`
|
|
6661
|
+
};
|
|
6662
|
+
}
|
|
6663
|
+
}
|
|
6664
|
+
}
|
|
6665
|
+
if (requested.limit !== undefined) {
|
|
6666
|
+
if (typeof requested.limit !== "number" || !Number.isFinite(requested.limit)) {
|
|
6667
|
+
return {
|
|
6668
|
+
ok: false,
|
|
6669
|
+
error: `Invalid limit on relation '${relationPath}': must be a finite number`
|
|
6670
|
+
};
|
|
6671
|
+
}
|
|
6672
|
+
if (requested.limit < 0) {
|
|
6673
|
+
requested.limit = 0;
|
|
6674
|
+
}
|
|
6675
|
+
if (configObj?.maxLimit !== undefined && requested.limit > configObj.maxLimit) {
|
|
6676
|
+
requested.limit = configObj.maxLimit;
|
|
6677
|
+
}
|
|
6678
|
+
}
|
|
6679
|
+
if (requested.select && configObj?.select) {
|
|
6680
|
+
for (const field of Object.keys(requested.select)) {
|
|
6681
|
+
if (!(field in configObj.select)) {
|
|
6682
|
+
return {
|
|
6683
|
+
ok: false,
|
|
6684
|
+
error: `Field "${field}" is not exposed on relation "${relationPath}"`
|
|
6685
|
+
};
|
|
4093
6686
|
}
|
|
4094
6687
|
}
|
|
4095
6688
|
}
|
|
6689
|
+
if (requested.include) {
|
|
6690
|
+
if (entityConfig === true) {
|
|
6691
|
+
return {
|
|
6692
|
+
ok: false,
|
|
6693
|
+
error: `Nested includes are not supported on relation '${relationPath}' ` + "without a structured relations config."
|
|
6694
|
+
};
|
|
6695
|
+
}
|
|
6696
|
+
}
|
|
4096
6697
|
}
|
|
4097
6698
|
return { ok: true };
|
|
4098
6699
|
}
|
|
@@ -4120,7 +6721,11 @@ function getParams(ctx) {
|
|
|
4120
6721
|
function generateEntityRoutes(def, registry, db, options) {
|
|
4121
6722
|
const prefix = options?.apiPrefix ?? "/api";
|
|
4122
6723
|
const basePath = `${prefix}/${def.name}`;
|
|
4123
|
-
const
|
|
6724
|
+
const tenantChain = options?.tenantChain ?? null;
|
|
6725
|
+
const crudHandlers = createCrudHandlers(def, db, {
|
|
6726
|
+
tenantChain,
|
|
6727
|
+
queryParentIds: options?.queryParentIds
|
|
6728
|
+
});
|
|
4124
6729
|
const inject = def.inject ?? {};
|
|
4125
6730
|
const registryProxy = Object.keys(inject).length > 0 ? registry.createScopedProxy(inject) : {};
|
|
4126
6731
|
const routes = [];
|
|
@@ -4159,7 +6764,8 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
4159
6764
|
where: parsed.where ? parsed.where : undefined,
|
|
4160
6765
|
orderBy: parsed.orderBy,
|
|
4161
6766
|
limit: parsed.limit,
|
|
4162
|
-
after: parsed.after
|
|
6767
|
+
after: parsed.after,
|
|
6768
|
+
include: parsed.include
|
|
4163
6769
|
};
|
|
4164
6770
|
const result = await crudHandlers.list(entityCtx, options2);
|
|
4165
6771
|
if (!result.ok) {
|
|
@@ -4201,7 +6807,8 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
4201
6807
|
where: parsed.where,
|
|
4202
6808
|
orderBy: parsed.orderBy,
|
|
4203
6809
|
limit: parsed.limit,
|
|
4204
|
-
after: parsed.after
|
|
6810
|
+
after: parsed.after,
|
|
6811
|
+
include: parsed.include
|
|
4205
6812
|
};
|
|
4206
6813
|
const result = await crudHandlers.list(entityCtx, options2);
|
|
4207
6814
|
if (!result.ok) {
|
|
@@ -4246,7 +6853,8 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
4246
6853
|
if (!validation.ok) {
|
|
4247
6854
|
return jsonResponse({ error: { code: "BadRequest", message: validation.error } }, 400);
|
|
4248
6855
|
}
|
|
4249
|
-
const
|
|
6856
|
+
const getOptions = parsed.include ? { include: parsed.include } : undefined;
|
|
6857
|
+
const result = await crudHandlers.get(entityCtx, id, getOptions);
|
|
4250
6858
|
if (!result.ok) {
|
|
4251
6859
|
const { status, body: body2 } = entityErrorHandler(result.error);
|
|
4252
6860
|
return jsonResponse(body2, status);
|
|
@@ -4411,13 +7019,106 @@ function generateEntityRoutes(def, registry, db, options) {
|
|
|
4411
7019
|
return routes;
|
|
4412
7020
|
}
|
|
4413
7021
|
|
|
7022
|
+
// src/entity/tenant-chain.ts
|
|
7023
|
+
function resolveTenantChain(entityKey, tenantGraph, registry) {
|
|
7024
|
+
if (!tenantGraph.indirectlyScoped.includes(entityKey)) {
|
|
7025
|
+
return null;
|
|
7026
|
+
}
|
|
7027
|
+
if (tenantGraph.root === null) {
|
|
7028
|
+
return null;
|
|
7029
|
+
}
|
|
7030
|
+
const rootEntry = registry[tenantGraph.root];
|
|
7031
|
+
if (!rootEntry)
|
|
7032
|
+
return null;
|
|
7033
|
+
const rootTableName = rootEntry.table._name;
|
|
7034
|
+
const tableNameToKey = new Map;
|
|
7035
|
+
for (const [key, entry] of Object.entries(registry)) {
|
|
7036
|
+
tableNameToKey.set(entry.table._name, key);
|
|
7037
|
+
}
|
|
7038
|
+
const directlyScopedTableNames = new Set;
|
|
7039
|
+
for (const key of tenantGraph.directlyScoped) {
|
|
7040
|
+
const entry = registry[key];
|
|
7041
|
+
if (entry)
|
|
7042
|
+
directlyScopedTableNames.add(entry.table._name);
|
|
7043
|
+
}
|
|
7044
|
+
directlyScopedTableNames.add(rootTableName);
|
|
7045
|
+
const hops = [];
|
|
7046
|
+
let currentKey = entityKey;
|
|
7047
|
+
const visited = new Set;
|
|
7048
|
+
while (true) {
|
|
7049
|
+
if (visited.has(currentKey)) {
|
|
7050
|
+
return null;
|
|
7051
|
+
}
|
|
7052
|
+
visited.add(currentKey);
|
|
7053
|
+
const currentEntry = registry[currentKey];
|
|
7054
|
+
if (!currentEntry)
|
|
7055
|
+
return null;
|
|
7056
|
+
let foundHop = false;
|
|
7057
|
+
for (const [, rel] of Object.entries(currentEntry.relations)) {
|
|
7058
|
+
if (rel._type !== "one" || !rel._foreignKey)
|
|
7059
|
+
continue;
|
|
7060
|
+
const targetTableName = rel._target()._name;
|
|
7061
|
+
const targetKey = tableNameToKey.get(targetTableName);
|
|
7062
|
+
if (!targetKey)
|
|
7063
|
+
continue;
|
|
7064
|
+
const targetIsDirectlyScoped = directlyScopedTableNames.has(targetTableName);
|
|
7065
|
+
const targetIsIndirectlyScoped = tenantGraph.indirectlyScoped.includes(targetKey);
|
|
7066
|
+
if (!targetIsDirectlyScoped && !targetIsIndirectlyScoped)
|
|
7067
|
+
continue;
|
|
7068
|
+
const targetEntry = registry[targetKey];
|
|
7069
|
+
if (!targetEntry)
|
|
7070
|
+
continue;
|
|
7071
|
+
const targetPk = resolvePrimaryKey(targetEntry.table._columns);
|
|
7072
|
+
hops.push({
|
|
7073
|
+
tableName: targetTableName,
|
|
7074
|
+
foreignKey: rel._foreignKey,
|
|
7075
|
+
targetColumn: targetPk
|
|
7076
|
+
});
|
|
7077
|
+
if (targetIsDirectlyScoped && targetKey !== tenantGraph.root) {
|
|
7078
|
+
const tenantColumn = resolveTenantFk(targetKey, registry);
|
|
7079
|
+
if (!tenantColumn)
|
|
7080
|
+
return null;
|
|
7081
|
+
return { hops, tenantColumn };
|
|
7082
|
+
}
|
|
7083
|
+
if (targetKey === tenantGraph.root) {
|
|
7084
|
+
return { hops, tenantColumn: rel._foreignKey };
|
|
7085
|
+
}
|
|
7086
|
+
currentKey = targetKey;
|
|
7087
|
+
foundHop = true;
|
|
7088
|
+
break;
|
|
7089
|
+
}
|
|
7090
|
+
if (!foundHop)
|
|
7091
|
+
return null;
|
|
7092
|
+
}
|
|
7093
|
+
}
|
|
7094
|
+
function resolvePrimaryKey(columns) {
|
|
7095
|
+
for (const [key, col] of Object.entries(columns)) {
|
|
7096
|
+
if (col && typeof col === "object" && "_meta" in col) {
|
|
7097
|
+
const meta = col._meta;
|
|
7098
|
+
if (meta.primary)
|
|
7099
|
+
return key;
|
|
7100
|
+
}
|
|
7101
|
+
}
|
|
7102
|
+
return "id";
|
|
7103
|
+
}
|
|
7104
|
+
function resolveTenantFk(modelKey, registry) {
|
|
7105
|
+
const entry = registry[modelKey];
|
|
7106
|
+
if (!entry || !entry._tenant)
|
|
7107
|
+
return null;
|
|
7108
|
+
const tenantRel = entry.relations[entry._tenant];
|
|
7109
|
+
if (!tenantRel || !tenantRel._foreignKey)
|
|
7110
|
+
return null;
|
|
7111
|
+
return tenantRel._foreignKey;
|
|
7112
|
+
}
|
|
7113
|
+
|
|
4414
7114
|
// src/service/context.ts
|
|
4415
|
-
function createServiceContext(request, registryProxy) {
|
|
7115
|
+
function createServiceContext(request, registryProxy, rawRequest) {
|
|
4416
7116
|
const userId = request.userId ?? null;
|
|
4417
7117
|
const roles = request.roles ?? [];
|
|
4418
7118
|
const tenantId = request.tenantId ?? null;
|
|
4419
7119
|
return {
|
|
4420
7120
|
userId,
|
|
7121
|
+
tenantId,
|
|
4421
7122
|
authenticated() {
|
|
4422
7123
|
return userId !== null;
|
|
4423
7124
|
},
|
|
@@ -4427,7 +7128,8 @@ function createServiceContext(request, registryProxy) {
|
|
|
4427
7128
|
role(...rolesToCheck) {
|
|
4428
7129
|
return rolesToCheck.some((r) => roles.includes(r));
|
|
4429
7130
|
},
|
|
4430
|
-
entities: registryProxy
|
|
7131
|
+
entities: registryProxy,
|
|
7132
|
+
request: rawRequest ?? { url: "", method: "", headers: new Headers, body: undefined }
|
|
4431
7133
|
};
|
|
4432
7134
|
}
|
|
4433
7135
|
|
|
@@ -4476,23 +7178,56 @@ function generateServiceRoutes(def, registry, options) {
|
|
|
4476
7178
|
handler: async (ctx) => {
|
|
4477
7179
|
try {
|
|
4478
7180
|
const requestInfo = extractRequestInfo2(ctx);
|
|
4479
|
-
const
|
|
7181
|
+
const raw = ctx.raw;
|
|
7182
|
+
const ctxHeaders = ctx.headers;
|
|
7183
|
+
const rawRequest = {
|
|
7184
|
+
url: raw?.url ?? "",
|
|
7185
|
+
method: raw?.method ?? "",
|
|
7186
|
+
headers: new Headers(ctxHeaders ?? {}),
|
|
7187
|
+
body: ctx.body
|
|
7188
|
+
};
|
|
7189
|
+
const serviceCtx = createServiceContext(requestInfo, registryProxy, rawRequest);
|
|
4480
7190
|
const accessResult = await enforceAccess(handlerName, def.access, serviceCtx);
|
|
4481
7191
|
if (!accessResult.ok) {
|
|
4482
7192
|
return jsonResponse2({ error: { code: "Forbidden", message: accessResult.error.message } }, 403);
|
|
4483
7193
|
}
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
const
|
|
4488
|
-
|
|
7194
|
+
if (handlerDef.body && isContentDescriptor(handlerDef.body)) {
|
|
7195
|
+
const reqContentType = rawRequest.headers.get("content-type")?.toLowerCase() ?? "";
|
|
7196
|
+
const expected = handlerDef.body._contentType;
|
|
7197
|
+
const isXml = expected === "application/xml";
|
|
7198
|
+
const matches = isXml ? reqContentType.includes("application/xml") || reqContentType.includes("text/xml") : reqContentType.includes(expected);
|
|
7199
|
+
if (reqContentType && !matches) {
|
|
7200
|
+
return jsonResponse2({
|
|
7201
|
+
error: {
|
|
7202
|
+
code: "UnsupportedMediaType",
|
|
7203
|
+
message: `Expected content-type ${expected}, got ${reqContentType}`
|
|
7204
|
+
}
|
|
7205
|
+
}, 415);
|
|
7206
|
+
}
|
|
7207
|
+
}
|
|
7208
|
+
let input;
|
|
7209
|
+
if (handlerDef.body) {
|
|
7210
|
+
const rawBody = ctx.body ?? {};
|
|
7211
|
+
const parsed = handlerDef.body.parse(rawBody);
|
|
7212
|
+
if (!parsed.ok) {
|
|
7213
|
+
const message = parsed.error instanceof Error ? parsed.error.message : "Invalid request body";
|
|
7214
|
+
return jsonResponse2({ error: { code: "BadRequest", message } }, 400);
|
|
7215
|
+
}
|
|
7216
|
+
input = parsed.data;
|
|
4489
7217
|
}
|
|
4490
|
-
const result = await handlerDef.handler(
|
|
7218
|
+
const result = await handlerDef.handler(input, serviceCtx);
|
|
4491
7219
|
const responseParsed = handlerDef.response.parse(result);
|
|
4492
7220
|
if (!responseParsed.ok) {
|
|
4493
7221
|
const message = responseParsed.error instanceof Error ? responseParsed.error.message : "Response validation failed";
|
|
4494
7222
|
console.warn(`[vertz] Service response validation warning: ${message}`);
|
|
4495
7223
|
}
|
|
7224
|
+
if (isContentDescriptor(handlerDef.response)) {
|
|
7225
|
+
const body = result instanceof Uint8Array ? result : String(result);
|
|
7226
|
+
return new Response(body, {
|
|
7227
|
+
status: 200,
|
|
7228
|
+
headers: { "content-type": handlerDef.response._contentType }
|
|
7229
|
+
});
|
|
7230
|
+
}
|
|
4496
7231
|
return jsonResponse2(result, 200);
|
|
4497
7232
|
} catch (error) {
|
|
4498
7233
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -4565,23 +7300,62 @@ function createServer(config) {
|
|
|
4565
7300
|
const allRoutes = [];
|
|
4566
7301
|
const registry = new EntityRegistry;
|
|
4567
7302
|
const apiPrefix = config.apiPrefix === undefined ? "/api" : config.apiPrefix;
|
|
7303
|
+
const { db } = config;
|
|
7304
|
+
const hasDbClient = db && isDatabaseClient(db);
|
|
7305
|
+
if (hasDbClient && config.auth) {
|
|
7306
|
+
validateAuthModels(db);
|
|
7307
|
+
}
|
|
4568
7308
|
if (config.entities && config.entities.length > 0) {
|
|
4569
|
-
const { db } = config;
|
|
4570
7309
|
let dbFactory;
|
|
4571
|
-
|
|
7310
|
+
const tableOf = (e) => e.table ?? e.name;
|
|
7311
|
+
if (hasDbClient) {
|
|
4572
7312
|
const dbModels = db._internals.models;
|
|
4573
|
-
const missing = config.entities.filter((e) => !(e
|
|
4574
|
-
|
|
7313
|
+
const missing = config.entities.filter((e) => !(tableOf(e) in dbModels)).map((e) => `"${tableOf(e)}"`);
|
|
7314
|
+
const uniqueMissing = [...new Set(missing)];
|
|
7315
|
+
if (uniqueMissing.length > 0) {
|
|
4575
7316
|
const registered = Object.keys(dbModels).map((k) => `"${k}"`).join(", ");
|
|
4576
|
-
const plural =
|
|
4577
|
-
throw new Error(`${plural ? "Entities" : "Entity"} ${
|
|
7317
|
+
const plural = uniqueMissing.length > 1;
|
|
7318
|
+
throw new Error(`${plural ? "Entities" : "Entity"} ${uniqueMissing.join(", ")} ${plural ? "are" : "is"} not registered in createDb(). ` + `Add the missing model${plural ? "s" : ""} to the models object in your createDb() call. ` + `Registered models: ${registered || "(none)"}`);
|
|
4578
7319
|
}
|
|
4579
|
-
dbFactory = (entityDef) => createDatabaseBridgeAdapter(db, entityDef
|
|
7320
|
+
dbFactory = (entityDef) => createDatabaseBridgeAdapter(db, tableOf(entityDef));
|
|
4580
7321
|
} else if (db) {
|
|
4581
7322
|
dbFactory = () => db;
|
|
4582
7323
|
} else {
|
|
4583
7324
|
dbFactory = config._entityDbFactory ?? createNoopDbAdapter;
|
|
4584
7325
|
}
|
|
7326
|
+
const tenantChains = new Map;
|
|
7327
|
+
let queryParentIds;
|
|
7328
|
+
if (hasDbClient) {
|
|
7329
|
+
const dbClient = db;
|
|
7330
|
+
const tenantGraph = dbClient._internals.tenantGraph;
|
|
7331
|
+
const dbModelsMap = dbClient._internals.models;
|
|
7332
|
+
for (const entityDef of config.entities) {
|
|
7333
|
+
const eDef = entityDef;
|
|
7334
|
+
if (eDef.tenantScoped === false)
|
|
7335
|
+
continue;
|
|
7336
|
+
const modelKey = tableOf(eDef);
|
|
7337
|
+
const chain = resolveTenantChain(modelKey, tenantGraph, dbModelsMap);
|
|
7338
|
+
if (chain) {
|
|
7339
|
+
tenantChains.set(eDef.name, chain);
|
|
7340
|
+
}
|
|
7341
|
+
}
|
|
7342
|
+
if (tenantChains.size > 0) {
|
|
7343
|
+
queryParentIds = async (tableName, where) => {
|
|
7344
|
+
const delegate = dbClient[tableName];
|
|
7345
|
+
if (!delegate)
|
|
7346
|
+
return [];
|
|
7347
|
+
const result = await delegate.list({ where });
|
|
7348
|
+
if (!result.ok || !result.data)
|
|
7349
|
+
return [];
|
|
7350
|
+
return result.data.map((row) => row.id);
|
|
7351
|
+
};
|
|
7352
|
+
}
|
|
7353
|
+
}
|
|
7354
|
+
if (config._tenantChains) {
|
|
7355
|
+
for (const [name, chain] of config._tenantChains) {
|
|
7356
|
+
tenantChains.set(name, chain);
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
4585
7359
|
for (const entityDef of config.entities) {
|
|
4586
7360
|
const entityDb = dbFactory(entityDef);
|
|
4587
7361
|
const ops = createEntityOps(entityDef, entityDb);
|
|
@@ -4589,8 +7363,11 @@ function createServer(config) {
|
|
|
4589
7363
|
}
|
|
4590
7364
|
for (const entityDef of config.entities) {
|
|
4591
7365
|
const entityDb = dbFactory(entityDef);
|
|
7366
|
+
const tenantChain = tenantChains.get(entityDef.name) ?? null;
|
|
4592
7367
|
const routes = generateEntityRoutes(entityDef, registry, entityDb, {
|
|
4593
|
-
apiPrefix
|
|
7368
|
+
apiPrefix,
|
|
7369
|
+
tenantChain,
|
|
7370
|
+
queryParentIds: tenantChain ? queryParentIds ?? config._queryParentIds : undefined
|
|
4594
7371
|
});
|
|
4595
7372
|
allRoutes.push(...routes);
|
|
4596
7373
|
}
|
|
@@ -4601,10 +7378,53 @@ function createServer(config) {
|
|
|
4601
7378
|
allRoutes.push(...routes);
|
|
4602
7379
|
}
|
|
4603
7380
|
}
|
|
4604
|
-
|
|
7381
|
+
const app = coreCreateServer({
|
|
4605
7382
|
...config,
|
|
4606
7383
|
_entityRoutes: allRoutes.length > 0 ? allRoutes : undefined
|
|
4607
7384
|
});
|
|
7385
|
+
if (hasDbClient && config.auth) {
|
|
7386
|
+
const dbClient = db;
|
|
7387
|
+
const authConfig = {
|
|
7388
|
+
...config.auth,
|
|
7389
|
+
userStore: config.auth.userStore ?? new DbUserStore(dbClient),
|
|
7390
|
+
sessionStore: config.auth.sessionStore ?? new DbSessionStore(dbClient),
|
|
7391
|
+
oauthAccountStore: config.auth.oauthAccountStore ?? (config.auth.providers?.length ? new DbOAuthAccountStore(dbClient) : undefined),
|
|
7392
|
+
_entityProxy: config.auth.onUserCreated ? config.auth._entityProxy ?? registry.createProxy() : undefined
|
|
7393
|
+
};
|
|
7394
|
+
const auth = createAuth(authConfig);
|
|
7395
|
+
if (apiPrefix !== "/api") {
|
|
7396
|
+
throw new Error(`requestHandler requires apiPrefix to be '/api' (got '${apiPrefix}'). ` + "Custom API prefixes are not yet supported with auth.");
|
|
7397
|
+
}
|
|
7398
|
+
const authPrefix = `${apiPrefix}/auth`;
|
|
7399
|
+
const authPrefixSlash = `${authPrefix}/`;
|
|
7400
|
+
const serverInstance = app;
|
|
7401
|
+
serverInstance.auth = auth;
|
|
7402
|
+
serverInstance.initialize = async () => {
|
|
7403
|
+
await initializeAuthTables(dbClient);
|
|
7404
|
+
await auth.initialize();
|
|
7405
|
+
};
|
|
7406
|
+
let cachedRequestHandler = null;
|
|
7407
|
+
Object.defineProperty(serverInstance, "requestHandler", {
|
|
7408
|
+
get() {
|
|
7409
|
+
if (!cachedRequestHandler) {
|
|
7410
|
+
const entityHandler = this.handler;
|
|
7411
|
+
const authHandler = this.auth.handler;
|
|
7412
|
+
cachedRequestHandler = (request) => {
|
|
7413
|
+
const pathname = new URL(request.url).pathname;
|
|
7414
|
+
if (pathname === authPrefix || pathname.startsWith(authPrefixSlash)) {
|
|
7415
|
+
return authHandler(request);
|
|
7416
|
+
}
|
|
7417
|
+
return entityHandler(request);
|
|
7418
|
+
};
|
|
7419
|
+
}
|
|
7420
|
+
return cachedRequestHandler;
|
|
7421
|
+
},
|
|
7422
|
+
enumerable: true,
|
|
7423
|
+
configurable: false
|
|
7424
|
+
});
|
|
7425
|
+
return serverInstance;
|
|
7426
|
+
}
|
|
7427
|
+
return app;
|
|
4608
7428
|
}
|
|
4609
7429
|
// src/entity/entity.ts
|
|
4610
7430
|
import { deepFreeze } from "@vertz/core";
|
|
@@ -4616,6 +7436,8 @@ function entity(name, config) {
|
|
|
4616
7436
|
if (!config.model) {
|
|
4617
7437
|
throw new Error("entity() requires a model in the config.");
|
|
4618
7438
|
}
|
|
7439
|
+
const hasTenantIdColumn = "tenantId" in config.model.table._columns;
|
|
7440
|
+
const tenantScoped = config.tenantScoped ?? hasTenantIdColumn;
|
|
4619
7441
|
const def = {
|
|
4620
7442
|
kind: "entity",
|
|
4621
7443
|
name,
|
|
@@ -4625,7 +7447,10 @@ function entity(name, config) {
|
|
|
4625
7447
|
before: config.before ?? {},
|
|
4626
7448
|
after: config.after ?? {},
|
|
4627
7449
|
actions: config.actions ?? {},
|
|
4628
|
-
relations: config.relations ?? {}
|
|
7450
|
+
relations: config.relations ?? {},
|
|
7451
|
+
table: config.table ?? name,
|
|
7452
|
+
tenantScoped,
|
|
7453
|
+
tenantChain: null
|
|
4629
7454
|
};
|
|
4630
7455
|
return deepFreeze(def);
|
|
4631
7456
|
}
|
|
@@ -4652,14 +7477,20 @@ export {
|
|
|
4652
7477
|
vertz,
|
|
4653
7478
|
verifyPassword,
|
|
4654
7479
|
validatePassword,
|
|
7480
|
+
validateOverrides,
|
|
7481
|
+
validateAuthModels,
|
|
4655
7482
|
stripReadOnlyFields,
|
|
4656
7483
|
stripHiddenFields,
|
|
4657
7484
|
service,
|
|
4658
7485
|
rules,
|
|
7486
|
+
resolveTenantChain,
|
|
4659
7487
|
makeImmutable,
|
|
7488
|
+
isContentDescriptor,
|
|
7489
|
+
initializeAuthTables,
|
|
4660
7490
|
hashPassword,
|
|
4661
7491
|
google,
|
|
4662
7492
|
github,
|
|
7493
|
+
getIncompatibleAddOns,
|
|
4663
7494
|
generateEntityRoutes,
|
|
4664
7495
|
entityErrorHandler,
|
|
4665
7496
|
entity,
|
|
@@ -4670,20 +7501,29 @@ export {
|
|
|
4670
7501
|
defaultAccess,
|
|
4671
7502
|
deepFreeze3 as deepFreeze,
|
|
4672
7503
|
decodeAccessSet,
|
|
7504
|
+
createWebhookHandler,
|
|
7505
|
+
createStripeBillingAdapter,
|
|
4673
7506
|
createServer,
|
|
7507
|
+
createPlanManager,
|
|
4674
7508
|
createMiddleware,
|
|
4675
7509
|
createImmutableProxy,
|
|
4676
7510
|
createEnv,
|
|
4677
7511
|
createEntityContext,
|
|
4678
7512
|
createCrudHandlers,
|
|
7513
|
+
createBillingEventEmitter,
|
|
4679
7514
|
createAuth,
|
|
4680
7515
|
createAccessEventBroadcaster,
|
|
4681
7516
|
createAccessContext,
|
|
4682
7517
|
createAccess,
|
|
7518
|
+
content,
|
|
7519
|
+
computePlanHash,
|
|
7520
|
+
computeOverage,
|
|
4683
7521
|
computeEntityAccess,
|
|
4684
7522
|
computeAccessSet,
|
|
4685
7523
|
checkFva,
|
|
7524
|
+
checkAddOnCompatibility,
|
|
4686
7525
|
calculateBillingPeriod,
|
|
7526
|
+
authModels,
|
|
4687
7527
|
VertzException2 as VertzException,
|
|
4688
7528
|
ValidationException2 as ValidationException,
|
|
4689
7529
|
UnauthorizedException,
|
|
@@ -4692,19 +7532,30 @@ export {
|
|
|
4692
7532
|
InternalServerErrorException,
|
|
4693
7533
|
InMemoryWalletStore,
|
|
4694
7534
|
InMemoryUserStore,
|
|
7535
|
+
InMemorySubscriptionStore,
|
|
4695
7536
|
InMemorySessionStore,
|
|
4696
7537
|
InMemoryRoleAssignmentStore,
|
|
4697
7538
|
InMemoryRateLimitStore,
|
|
4698
|
-
|
|
7539
|
+
InMemoryPlanVersionStore,
|
|
4699
7540
|
InMemoryPasswordResetStore,
|
|
7541
|
+
InMemoryOverrideStore,
|
|
4700
7542
|
InMemoryOAuthAccountStore,
|
|
4701
7543
|
InMemoryMFAStore,
|
|
7544
|
+
InMemoryGrandfatheringStore,
|
|
4702
7545
|
InMemoryFlagStore,
|
|
4703
7546
|
InMemoryEmailVerificationStore,
|
|
4704
7547
|
InMemoryClosureStore,
|
|
4705
7548
|
ForbiddenException,
|
|
4706
7549
|
EntityRegistry,
|
|
7550
|
+
DbUserStore,
|
|
7551
|
+
DbSubscriptionStore,
|
|
7552
|
+
DbSessionStore,
|
|
7553
|
+
DbRoleAssignmentStore,
|
|
7554
|
+
DbOAuthAccountStore,
|
|
7555
|
+
DbFlagStore,
|
|
7556
|
+
DbClosureStore,
|
|
4707
7557
|
ConflictException,
|
|
4708
|
-
BadRequestException,
|
|
4709
|
-
AuthorizationError
|
|
7558
|
+
BadRequestException2 as BadRequestException,
|
|
7559
|
+
AuthorizationError,
|
|
7560
|
+
AUTH_TABLE_NAMES
|
|
4710
7561
|
};
|