@heavybit/pendoadmin-shared-lib 1.0.0 → 1.1.0
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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/rate-limit/create-rate-limiter.d.ts +23 -0
- package/dist/rate-limit/create-rate-limiter.d.ts.map +1 -0
- package/dist/rate-limit/create-rate-limiter.js +60 -0
- package/dist/rate-limit/create-rate-limiter.js.map +1 -0
- package/dist/rate-limit/index.d.ts +3 -0
- package/dist/rate-limit/index.d.ts.map +1 -0
- package/dist/rate-limit/index.js +2 -0
- package/dist/rate-limit/index.js.map +1 -0
- package/dist/scheduling/event-driven-scheduler.d.ts +33 -0
- package/dist/scheduling/event-driven-scheduler.d.ts.map +1 -0
- package/dist/scheduling/event-driven-scheduler.js +67 -0
- package/dist/scheduling/event-driven-scheduler.js.map +1 -0
- package/dist/scheduling/index.d.ts +3 -0
- package/dist/scheduling/index.d.ts.map +1 -0
- package/dist/scheduling/index.js +2 -0
- package/dist/scheduling/index.js.map +1 -0
- package/dist/socket/create-socket-server.d.ts +30 -0
- package/dist/socket/create-socket-server.d.ts.map +1 -0
- package/dist/socket/create-socket-server.js +59 -0
- package/dist/socket/create-socket-server.js.map +1 -0
- package/dist/socket/index.d.ts +5 -0
- package/dist/socket/index.d.ts.map +1 -0
- package/dist/socket/index.js +3 -0
- package/dist/socket/index.js.map +1 -0
- package/dist/socket/nestjs-socket.module.d.ts +20 -0
- package/dist/socket/nestjs-socket.module.d.ts.map +1 -0
- package/dist/socket/nestjs-socket.module.js +64 -0
- package/dist/socket/nestjs-socket.module.js.map +1 -0
- package/dist/tenant/drizzle-tenant.d.ts +25 -0
- package/dist/tenant/drizzle-tenant.d.ts.map +1 -0
- package/dist/tenant/drizzle-tenant.js +35 -0
- package/dist/tenant/drizzle-tenant.js.map +1 -0
- package/dist/tenant/index.d.ts +7 -0
- package/dist/tenant/index.d.ts.map +1 -0
- package/dist/tenant/index.js +7 -0
- package/dist/tenant/index.js.map +1 -0
- package/dist/tenant/knex-tenant.d.ts +13 -0
- package/dist/tenant/knex-tenant.d.ts.map +1 -0
- package/dist/tenant/knex-tenant.js +43 -0
- package/dist/tenant/knex-tenant.js.map +1 -0
- package/dist/tenant/tenant-access-control.d.ts +93 -0
- package/dist/tenant/tenant-access-control.d.ts.map +1 -0
- package/dist/tenant/tenant-access-control.js +260 -0
- package/dist/tenant/tenant-access-control.js.map +1 -0
- package/package.json +33 -4
- package/src/index.ts +6 -0
- package/src/rate-limit/create-rate-limiter.ts +107 -0
- package/src/rate-limit/index.ts +2 -0
- package/src/scheduling/event-driven-scheduler.ts +87 -0
- package/src/scheduling/index.ts +2 -0
- package/src/socket/create-socket-server.ts +112 -0
- package/src/socket/index.ts +14 -0
- package/src/socket/nestjs-socket.module.ts +77 -0
- package/src/tenant/drizzle-tenant.ts +51 -0
- package/src/tenant/index.ts +24 -0
- package/src/tenant/knex-tenant.ts +59 -0
- package/src/tenant/tenant-access-control.ts +370 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// pendoadmin-shared-lib/src/tenant/tenant-access-control.ts
|
|
2
|
+
//
|
|
3
|
+
// Feature-gating middleware and helpers.
|
|
4
|
+
// Restricts tenant access to services/features based on their subscription plan
|
|
5
|
+
// and per-service activation status (ICompanyServiceConfig).
|
|
6
|
+
|
|
7
|
+
import type { Request, Response, NextFunction } from "express";
|
|
8
|
+
import type { Knex } from "knex";
|
|
9
|
+
|
|
10
|
+
// ─── Feature & Plan Definitions ─────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every discrete feature / service area that can be gated.
|
|
14
|
+
* Extend this union as the platform grows.
|
|
15
|
+
*/
|
|
16
|
+
export type Feature =
|
|
17
|
+
| "sms"
|
|
18
|
+
| "email"
|
|
19
|
+
| "whatsapp"
|
|
20
|
+
| "ussd"
|
|
21
|
+
| "mpesa"
|
|
22
|
+
| "analytics"
|
|
23
|
+
| "ai"
|
|
24
|
+
| "notifications"
|
|
25
|
+
| "api-gateway"
|
|
26
|
+
| (string & {}); // allow custom feature strings while providing autocomplete
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Named subscription plans. Each plan grants access to a set of features.
|
|
30
|
+
* Plans are ordered by tier — higher tiers include all features of lower tiers.
|
|
31
|
+
*/
|
|
32
|
+
export interface SubscriptionPlan {
|
|
33
|
+
name: string;
|
|
34
|
+
/** Numeric tier level — higher = more features */
|
|
35
|
+
tier: number;
|
|
36
|
+
/** Features included in this plan */
|
|
37
|
+
features: Feature[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Default plans — consumers can override with their own */
|
|
41
|
+
export const DEFAULT_PLANS: SubscriptionPlan[] = [
|
|
42
|
+
{
|
|
43
|
+
name: "starter",
|
|
44
|
+
tier: 1,
|
|
45
|
+
features: ["sms", "notifications"],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "professional",
|
|
49
|
+
tier: 2,
|
|
50
|
+
features: ["sms", "email", "notifications", "analytics"],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "business",
|
|
54
|
+
tier: 3,
|
|
55
|
+
features: ["sms", "email", "whatsapp", "mpesa", "notifications", "analytics"],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "enterprise",
|
|
59
|
+
tier: 4,
|
|
60
|
+
features: ["sms", "email", "whatsapp", "ussd", "mpesa", "analytics", "ai", "notifications", "api-gateway"],
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// ─── Access Control Config ──────────────────────────────
|
|
65
|
+
|
|
66
|
+
export interface TenantAccessControlConfig {
|
|
67
|
+
/**
|
|
68
|
+
* Function that looks up a company's active features.
|
|
69
|
+
* Should return the list of feature names the company is entitled to.
|
|
70
|
+
* This is called once per request and cached on the request object.
|
|
71
|
+
*/
|
|
72
|
+
getCompanyFeatures: (companyId: string) => Promise<Feature[]>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Optional: check whether the tenant (company) itself is active.
|
|
76
|
+
* If it returns false the request is rejected with 403.
|
|
77
|
+
* Default: always active (not checked).
|
|
78
|
+
*/
|
|
79
|
+
isCompanyActive?: (companyId: string) => Promise<boolean>;
|
|
80
|
+
|
|
81
|
+
/** Cache TTL in seconds for feature lookups. 0 = no cache. Default: 60. */
|
|
82
|
+
cacheTtlSeconds?: number;
|
|
83
|
+
|
|
84
|
+
/** Custom logger (default: console) */
|
|
85
|
+
logger?: Pick<Console, "info" | "warn" | "error">;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── In-Memory Feature Cache ────────────────────────────
|
|
89
|
+
|
|
90
|
+
interface CacheEntry {
|
|
91
|
+
features: Feature[];
|
|
92
|
+
expiresAt: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const featureCache = new Map<string, CacheEntry>();
|
|
96
|
+
|
|
97
|
+
function getCachedFeatures(companyId: string, ttl: number): Feature[] | null {
|
|
98
|
+
if (ttl <= 0) return null;
|
|
99
|
+
const entry = featureCache.get(companyId);
|
|
100
|
+
if (entry && Date.now() < entry.expiresAt) return entry.features;
|
|
101
|
+
featureCache.delete(companyId);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function setCachedFeatures(companyId: string, features: Feature[], ttl: number): void {
|
|
106
|
+
if (ttl <= 0) return;
|
|
107
|
+
featureCache.set(companyId, { features, expiresAt: Date.now() + ttl * 1000 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Clear the entire feature cache (useful for tests or after plan changes) */
|
|
111
|
+
export function clearFeatureCache(): void {
|
|
112
|
+
featureCache.clear();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Invalidate cache for a specific company (call after plan upgrade/downgrade) */
|
|
116
|
+
export function invalidateCompanyFeatureCache(companyId: string): void {
|
|
117
|
+
featureCache.delete(companyId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Middleware Factory ─────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates an Express middleware that checks whether the requesting tenant
|
|
124
|
+
* is active and has access to the specified feature(s).
|
|
125
|
+
*
|
|
126
|
+
* Usage:
|
|
127
|
+
* ```ts
|
|
128
|
+
* const ctrl = createTenantAccessControl({ getCompanyFeatures, isCompanyActive });
|
|
129
|
+
*
|
|
130
|
+
* // Gate an entire router
|
|
131
|
+
* router.use(ctrl.requireFeature("whatsapp"));
|
|
132
|
+
*
|
|
133
|
+
* // Gate a specific route with multiple required features
|
|
134
|
+
* router.post("/campaigns", ctrl.requireAllFeatures(["sms", "analytics"]), handler);
|
|
135
|
+
*
|
|
136
|
+
* // Gate where ANY one feature is sufficient
|
|
137
|
+
* router.get("/inbox", ctrl.requireAnyFeature(["sms", "whatsapp", "email"]), handler);
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export function createTenantAccessControl(config: TenantAccessControlConfig) {
|
|
141
|
+
const {
|
|
142
|
+
getCompanyFeatures,
|
|
143
|
+
isCompanyActive,
|
|
144
|
+
cacheTtlSeconds = 60,
|
|
145
|
+
logger = console,
|
|
146
|
+
} = config;
|
|
147
|
+
|
|
148
|
+
// ─── Resolve features (with cache) ─────────────────
|
|
149
|
+
|
|
150
|
+
async function resolveFeatures(companyId: string): Promise<Feature[]> {
|
|
151
|
+
const cached = getCachedFeatures(companyId, cacheTtlSeconds);
|
|
152
|
+
if (cached) return cached;
|
|
153
|
+
|
|
154
|
+
const features = await getCompanyFeatures(companyId);
|
|
155
|
+
setCachedFeatures(companyId, features, cacheTtlSeconds);
|
|
156
|
+
return features;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Tenant active check ───────────────────────────
|
|
160
|
+
|
|
161
|
+
async function checkTenantActive(companyId: string, res: Response): Promise<boolean> {
|
|
162
|
+
if (!isCompanyActive) return true;
|
|
163
|
+
const active = await isCompanyActive(companyId);
|
|
164
|
+
if (!active) {
|
|
165
|
+
logger.warn(`[TenantAccess] Company ${companyId} is disabled — blocking request`);
|
|
166
|
+
res.status(403).json({
|
|
167
|
+
success: false,
|
|
168
|
+
message: "Your account has been disabled. Please contact support.",
|
|
169
|
+
});
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Middleware: require a single feature ──────────
|
|
176
|
+
|
|
177
|
+
function requireFeature(feature: Feature) {
|
|
178
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
179
|
+
const companyId = extractCompanyId(req);
|
|
180
|
+
if (!companyId) {
|
|
181
|
+
return res.status(401).json({ success: false, message: "Company context required." });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!(await checkTenantActive(companyId, res))) return;
|
|
185
|
+
|
|
186
|
+
const features = await resolveFeatures(companyId);
|
|
187
|
+
if (!features.includes(feature)) {
|
|
188
|
+
logger.info(
|
|
189
|
+
`[TenantAccess] Company ${companyId} denied access to "${feature}" — not in plan`,
|
|
190
|
+
);
|
|
191
|
+
return res.status(403).json({
|
|
192
|
+
success: false,
|
|
193
|
+
message: `Your subscription does not include access to the "${feature}" service. Please upgrade your plan.`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
next();
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Middleware: require ALL listed features ───────
|
|
201
|
+
|
|
202
|
+
function requireAllFeatures(features: Feature[]) {
|
|
203
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
204
|
+
const companyId = extractCompanyId(req);
|
|
205
|
+
if (!companyId) {
|
|
206
|
+
return res.status(401).json({ success: false, message: "Company context required." });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!(await checkTenantActive(companyId, res))) return;
|
|
210
|
+
|
|
211
|
+
const allowed = await resolveFeatures(companyId);
|
|
212
|
+
const missing = features.filter((f) => !allowed.includes(f));
|
|
213
|
+
if (missing.length > 0) {
|
|
214
|
+
logger.info(
|
|
215
|
+
`[TenantAccess] Company ${companyId} missing features: ${missing.join(", ")}`,
|
|
216
|
+
);
|
|
217
|
+
return res.status(403).json({
|
|
218
|
+
success: false,
|
|
219
|
+
message: `Your subscription is missing access to: ${missing.join(", ")}. Please upgrade your plan.`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
next();
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Middleware: require ANY one of listed features ─
|
|
227
|
+
|
|
228
|
+
function requireAnyFeature(features: Feature[]) {
|
|
229
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
230
|
+
const companyId = extractCompanyId(req);
|
|
231
|
+
if (!companyId) {
|
|
232
|
+
return res.status(401).json({ success: false, message: "Company context required." });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!(await checkTenantActive(companyId, res))) return;
|
|
236
|
+
|
|
237
|
+
const allowed = await resolveFeatures(companyId);
|
|
238
|
+
const hasAny = features.some((f) => allowed.includes(f));
|
|
239
|
+
if (!hasAny) {
|
|
240
|
+
return res.status(403).json({
|
|
241
|
+
success: false,
|
|
242
|
+
message: `Your subscription does not include any of: ${features.join(", ")}. Please upgrade your plan.`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
next();
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── Middleware: just check tenant is active ───────
|
|
250
|
+
|
|
251
|
+
function requireActiveTenant() {
|
|
252
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
253
|
+
const companyId = extractCompanyId(req);
|
|
254
|
+
if (!companyId) {
|
|
255
|
+
return res.status(401).json({ success: false, message: "Company context required." });
|
|
256
|
+
}
|
|
257
|
+
if (!(await checkTenantActive(companyId, res))) return;
|
|
258
|
+
next();
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
requireFeature,
|
|
264
|
+
requireAllFeatures,
|
|
265
|
+
requireAnyFeature,
|
|
266
|
+
requireActiveTenant,
|
|
267
|
+
resolveFeatures,
|
|
268
|
+
clearFeatureCache,
|
|
269
|
+
invalidateCompanyFeatureCache,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Knex-backed Feature Resolver ───────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Default implementation of `getCompanyFeatures` using Knex
|
|
277
|
+
* against the `company_service_configs` table.
|
|
278
|
+
*
|
|
279
|
+
* Returns the `service_name` of every row where `is_active = true`
|
|
280
|
+
* for the given company.
|
|
281
|
+
*/
|
|
282
|
+
export function knexFeatureResolver(knex: Knex) {
|
|
283
|
+
return async (companyId: string): Promise<Feature[]> => {
|
|
284
|
+
const rows = await knex("company_service_configs")
|
|
285
|
+
.select("service_name")
|
|
286
|
+
.where({ company_id: companyId, is_active: true });
|
|
287
|
+
return rows.map((r: any) => r.service_name as Feature);
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Default implementation of `isCompanyActive` using Knex
|
|
293
|
+
* against the `companies` table.
|
|
294
|
+
*/
|
|
295
|
+
export function knexCompanyActiveChecker(knex: Knex) {
|
|
296
|
+
return async (companyId: string): Promise<boolean> => {
|
|
297
|
+
const row = await knex("companies")
|
|
298
|
+
.select("is_active")
|
|
299
|
+
.where({ id: companyId })
|
|
300
|
+
.first();
|
|
301
|
+
return row?.is_active === true || row?.is_active === 1;
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Plan Helpers ───────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Given a plan name, return the set of features it grants.
|
|
309
|
+
* Useful for provisioning a new company.
|
|
310
|
+
*/
|
|
311
|
+
export function getFeaturesForPlan(
|
|
312
|
+
planName: string,
|
|
313
|
+
plans: SubscriptionPlan[] = DEFAULT_PLANS,
|
|
314
|
+
): Feature[] {
|
|
315
|
+
const plan = plans.find((p) => p.name === planName);
|
|
316
|
+
return plan ? [...plan.features] : [];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Provision a company's service configs based on a plan.
|
|
321
|
+
* Inserts rows into `company_service_configs` for each feature in the plan.
|
|
322
|
+
*/
|
|
323
|
+
export async function provisionCompanyPlan(
|
|
324
|
+
knex: Knex,
|
|
325
|
+
companyId: string,
|
|
326
|
+
planName: string,
|
|
327
|
+
plans: SubscriptionPlan[] = DEFAULT_PLANS,
|
|
328
|
+
): Promise<void> {
|
|
329
|
+
const features = getFeaturesForPlan(planName, plans);
|
|
330
|
+
if (features.length === 0) return;
|
|
331
|
+
|
|
332
|
+
// Deactivate all existing configs
|
|
333
|
+
await knex("company_service_configs")
|
|
334
|
+
.where({ company_id: companyId })
|
|
335
|
+
.update({ is_active: false });
|
|
336
|
+
|
|
337
|
+
// Upsert new configs
|
|
338
|
+
for (const feature of features) {
|
|
339
|
+
const existing = await knex("company_service_configs")
|
|
340
|
+
.where({ company_id: companyId, service_name: feature })
|
|
341
|
+
.first();
|
|
342
|
+
|
|
343
|
+
if (existing) {
|
|
344
|
+
await knex("company_service_configs")
|
|
345
|
+
.where({ id: existing.id })
|
|
346
|
+
.update({ is_active: true, updated_at: knex.fn.now() });
|
|
347
|
+
} else {
|
|
348
|
+
await knex("company_service_configs").insert({
|
|
349
|
+
id: knex.raw("UUID()"),
|
|
350
|
+
company_id: companyId,
|
|
351
|
+
service_name: feature,
|
|
352
|
+
config: JSON.stringify({}),
|
|
353
|
+
is_active: true,
|
|
354
|
+
created_at: knex.fn.now(),
|
|
355
|
+
updated_at: knex.fn.now(),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Helpers ────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
function extractCompanyId(req: Request): string | undefined {
|
|
364
|
+
// Gateway sets x-company-id header; fallback to req.user.company_id / companyId
|
|
365
|
+
return (
|
|
366
|
+
(req.headers["x-company-id"] as string) ||
|
|
367
|
+
(req as any).user?.company_id ||
|
|
368
|
+
(req as any).user?.companyId
|
|
369
|
+
);
|
|
370
|
+
}
|