@heavybit/pendoadmin-shared-lib 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/rate-limit/create-rate-limiter.d.ts +23 -0
  6. package/dist/rate-limit/create-rate-limiter.d.ts.map +1 -0
  7. package/dist/rate-limit/create-rate-limiter.js +60 -0
  8. package/dist/rate-limit/create-rate-limiter.js.map +1 -0
  9. package/dist/rate-limit/index.d.ts +3 -0
  10. package/dist/rate-limit/index.d.ts.map +1 -0
  11. package/dist/rate-limit/index.js +2 -0
  12. package/dist/rate-limit/index.js.map +1 -0
  13. package/dist/scheduling/event-driven-scheduler.d.ts +33 -0
  14. package/dist/scheduling/event-driven-scheduler.d.ts.map +1 -0
  15. package/dist/scheduling/event-driven-scheduler.js +67 -0
  16. package/dist/scheduling/event-driven-scheduler.js.map +1 -0
  17. package/dist/scheduling/index.d.ts +3 -0
  18. package/dist/scheduling/index.d.ts.map +1 -0
  19. package/dist/scheduling/index.js +2 -0
  20. package/dist/scheduling/index.js.map +1 -0
  21. package/dist/socket/create-socket-server.d.ts +30 -0
  22. package/dist/socket/create-socket-server.d.ts.map +1 -0
  23. package/dist/socket/create-socket-server.js +59 -0
  24. package/dist/socket/create-socket-server.js.map +1 -0
  25. package/dist/socket/index.d.ts +5 -0
  26. package/dist/socket/index.d.ts.map +1 -0
  27. package/dist/socket/index.js +3 -0
  28. package/dist/socket/index.js.map +1 -0
  29. package/dist/socket/nestjs-socket.module.d.ts +20 -0
  30. package/dist/socket/nestjs-socket.module.d.ts.map +1 -0
  31. package/dist/socket/nestjs-socket.module.js +64 -0
  32. package/dist/socket/nestjs-socket.module.js.map +1 -0
  33. package/dist/tenant/drizzle-tenant.d.ts +25 -0
  34. package/dist/tenant/drizzle-tenant.d.ts.map +1 -0
  35. package/dist/tenant/drizzle-tenant.js +35 -0
  36. package/dist/tenant/drizzle-tenant.js.map +1 -0
  37. package/dist/tenant/index.d.ts +7 -0
  38. package/dist/tenant/index.d.ts.map +1 -0
  39. package/dist/tenant/index.js +7 -0
  40. package/dist/tenant/index.js.map +1 -0
  41. package/dist/tenant/knex-tenant.d.ts +13 -0
  42. package/dist/tenant/knex-tenant.d.ts.map +1 -0
  43. package/dist/tenant/knex-tenant.js +43 -0
  44. package/dist/tenant/knex-tenant.js.map +1 -0
  45. package/dist/tenant/tenant-access-control.d.ts +93 -0
  46. package/dist/tenant/tenant-access-control.d.ts.map +1 -0
  47. package/dist/tenant/tenant-access-control.js +260 -0
  48. package/dist/tenant/tenant-access-control.js.map +1 -0
  49. package/package.json +33 -4
  50. package/src/index.ts +6 -0
  51. package/src/rate-limit/create-rate-limiter.ts +107 -0
  52. package/src/rate-limit/index.ts +2 -0
  53. package/src/scheduling/event-driven-scheduler.ts +87 -0
  54. package/src/scheduling/index.ts +2 -0
  55. package/src/socket/create-socket-server.ts +112 -0
  56. package/src/socket/index.ts +14 -0
  57. package/src/socket/nestjs-socket.module.ts +77 -0
  58. package/src/tenant/drizzle-tenant.ts +51 -0
  59. package/src/tenant/index.ts +24 -0
  60. package/src/tenant/knex-tenant.ts +59 -0
  61. package/src/tenant/tenant-access-control.ts +370 -0
@@ -0,0 +1,87 @@
1
+ // pendoadmin-shared-lib/src/scheduling/event-driven-scheduler.ts
2
+ //
3
+ // Replaces naive setInterval polling with an event-driven pattern
4
+ // that still has a safety-net fallback interval.
5
+
6
+ import { EventEmitter } from "events";
7
+
8
+ // ─── Types ──────────────────────────────────────────────
9
+
10
+ export interface SchedulerOptions {
11
+ /** Human-readable name for logging (default: "scheduler") */
12
+ name?: string;
13
+ /** Safety-net fallback interval in ms (default: 60 000) */
14
+ fallbackIntervalMs?: number;
15
+ /** The work function to execute on each trigger */
16
+ work: () => Promise<void> | void;
17
+ /** Logger (default: console) */
18
+ logger?: Pick<Console, "debug" | "info" | "error">;
19
+ }
20
+
21
+ // ─── Scheduler ──────────────────────────────────────────
22
+
23
+ export class EventDrivenScheduler extends EventEmitter {
24
+ private readonly name: string;
25
+ private readonly fallbackMs: number;
26
+ private readonly work: () => Promise<void> | void;
27
+ private readonly logger: Pick<Console, "debug" | "info" | "error">;
28
+
29
+ private timer: ReturnType<typeof setInterval> | null = null;
30
+ private running = false;
31
+
32
+ constructor(options: SchedulerOptions) {
33
+ super();
34
+ this.name = options.name ?? "scheduler";
35
+ this.fallbackMs = options.fallbackIntervalMs ?? 60_000;
36
+ this.work = options.work;
37
+ this.logger = options.logger ?? console;
38
+ }
39
+
40
+ /** Start the safety-net interval. Does not run work immediately. */
41
+ start(): void {
42
+ if (this.timer) return;
43
+ this.logger.info(`[${this.name}] Started (fallback every ${this.fallbackMs}ms)`);
44
+ this.timer = setInterval(() => void this.execute(), this.fallbackMs);
45
+ }
46
+
47
+ /** Stop the scheduler and cancel the safety-net interval. */
48
+ stop(): void {
49
+ if (this.timer) {
50
+ clearInterval(this.timer);
51
+ this.timer = null;
52
+ this.logger.info(`[${this.name}] Stopped`);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Trigger work immediately (event-driven).
58
+ * De-duplicates: if work is already running, the call is silently skipped.
59
+ */
60
+ async trigger(): Promise<void> {
61
+ return this.execute();
62
+ }
63
+
64
+ /** Whether the scheduler is currently executing its work function. */
65
+ get isRunning(): boolean {
66
+ return this.running;
67
+ }
68
+
69
+ // ─── Internal ─────────────────────────────────────
70
+
71
+ private async execute(): Promise<void> {
72
+ if (this.running) {
73
+ this.logger.debug(`[${this.name}] Skipped — already running`);
74
+ return;
75
+ }
76
+ this.running = true;
77
+ try {
78
+ await this.work();
79
+ this.emit("done");
80
+ } catch (err: any) {
81
+ this.logger.error(`[${this.name}] Error: ${err.message}`);
82
+ this.emit("error", err);
83
+ } finally {
84
+ this.running = false;
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,2 @@
1
+ export { EventDrivenScheduler } from "./event-driven-scheduler.js";
2
+ export type { SchedulerOptions } from "./event-driven-scheduler.js";
@@ -0,0 +1,112 @@
1
+ // pendoadmin-shared-lib/src/socket/create-socket-server.ts
2
+ //
3
+ // Factory that creates a Socket.IO server with company-scoped rooms,
4
+ // authentication, and convenience emitters.
5
+
6
+ import { Server as HttpServer } from "http";
7
+ import { Server, ServerOptions, Socket } from "socket.io";
8
+
9
+ // ─── Types ──────────────────────────────────────────────
10
+
11
+ export interface SocketServerOptions {
12
+ /** Room prefix used to namespace broadcasts (default: "company") */
13
+ roomPrefix?: string;
14
+ /** CORS origin list (default: ["*"]) */
15
+ corsOrigins?: string[];
16
+ /** Additional Socket.IO server options */
17
+ serverOptions?: Partial<ServerOptions>;
18
+ /** Logger — any object with debug / info / warn / error (default: console) */
19
+ logger?: Pick<Console, "debug" | "info" | "warn" | "error">;
20
+ /**
21
+ * Authenticate the incoming socket.
22
+ * Return truthy to allow, falsy or throw to reject.
23
+ * The decoded user should be attached to `socket.data.user`.
24
+ */
25
+ authenticate?: (socket: Socket) => Promise<boolean>;
26
+ }
27
+
28
+ export interface SocketEmitters {
29
+ /** Raw Socket.IO server instance */
30
+ io: Server;
31
+ /** Send to a specific room */
32
+ emitToRoom: (room: string, event: string, data: unknown) => void;
33
+ /** Send to a company-scoped room (roomPrefix:companyId) */
34
+ emitToCompany: (companyId: string | number, event: string, data: unknown) => void;
35
+ /** Broadcast to all connected clients */
36
+ emitGlobal: (event: string, data: unknown) => void;
37
+ }
38
+
39
+ // ─── Factory ────────────────────────────────────────────
40
+
41
+ export function createSocketServer(
42
+ httpServer: HttpServer,
43
+ options: SocketServerOptions = {},
44
+ ): SocketEmitters {
45
+ const {
46
+ roomPrefix = "company",
47
+ corsOrigins = ["*"],
48
+ serverOptions = {},
49
+ logger = console,
50
+ authenticate,
51
+ } = options;
52
+
53
+ const io = new Server(httpServer, {
54
+ cors: { origin: corsOrigins, credentials: true },
55
+ ...serverOptions,
56
+ });
57
+
58
+ // ─── Authentication middleware ───────────────────────
59
+
60
+ if (authenticate) {
61
+ io.use(async (socket, next) => {
62
+ try {
63
+ const ok = await authenticate(socket);
64
+ if (!ok) return next(new Error("Authentication failed"));
65
+ next();
66
+ } catch (err: any) {
67
+ logger.warn(`[Socket] Auth rejected: ${err.message}`);
68
+ next(new Error("Authentication failed"));
69
+ }
70
+ });
71
+ }
72
+
73
+ // ─── Connection lifecycle ───────────────────────────
74
+
75
+ io.on("connection", (socket) => {
76
+ const userId = socket.data?.user?.id ?? socket.id;
77
+ logger.debug(`[Socket] Connected: ${userId}`);
78
+
79
+ // Auto-join the company room when the client provides a companyId
80
+ socket.on("join:company", (companyId: string | number) => {
81
+ const room = `${roomPrefix}:${companyId}`;
82
+ socket.join(room);
83
+ logger.debug(`[Socket] ${userId} joined ${room}`);
84
+ });
85
+
86
+ socket.on("leave:company", (companyId: string | number) => {
87
+ const room = `${roomPrefix}:${companyId}`;
88
+ socket.leave(room);
89
+ logger.debug(`[Socket] ${userId} left ${room}`);
90
+ });
91
+
92
+ socket.on("disconnect", (reason) => {
93
+ logger.debug(`[Socket] Disconnected: ${userId} (${reason})`);
94
+ });
95
+ });
96
+
97
+ // ─── Emitter helpers ────────────────────────────────
98
+
99
+ function emitToRoom(room: string, event: string, data: unknown): void {
100
+ io.to(room).emit(event, data);
101
+ }
102
+
103
+ function emitToCompany(companyId: string | number, event: string, data: unknown): void {
104
+ emitToRoom(`${roomPrefix}:${companyId}`, event, data);
105
+ }
106
+
107
+ function emitGlobal(event: string, data: unknown): void {
108
+ io.emit(event, data);
109
+ }
110
+
111
+ return { io, emitToRoom, emitToCompany, emitGlobal };
112
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ createSocketServer,
3
+ } from "./create-socket-server.js";
4
+ export type {
5
+ SocketServerOptions,
6
+ SocketEmitters,
7
+ } from "./create-socket-server.js";
8
+
9
+ export {
10
+ BaseSocketGateway,
11
+ } from "./nestjs-socket.module.js";
12
+ export type {
13
+ NestSocketOptions,
14
+ } from "./nestjs-socket.module.js";
@@ -0,0 +1,77 @@
1
+ // pendoadmin-shared-lib/src/socket/nestjs-socket.module.ts
2
+ //
3
+ // Abstract base gateway for NestJS microservices that use Socket.IO.
4
+ // Provides room management and typed emit helpers.
5
+
6
+ import {
7
+ WebSocketGateway,
8
+ OnGatewayConnection,
9
+ OnGatewayDisconnect,
10
+ WebSocketServer,
11
+ } from "@nestjs/websockets";
12
+ import { Server, Socket } from "socket.io";
13
+
14
+ // ─── Types ──────────────────────────────────────────────
15
+
16
+ export interface NestSocketOptions {
17
+ /** Room prefix used to namespace broadcasts (default: "company") */
18
+ roomPrefix?: string;
19
+ /** Logger — any object with debug / info / warn / error (default: console) */
20
+ logger?: Pick<Console, "debug" | "info" | "warn" | "error">;
21
+ }
22
+
23
+ // ─── Base Gateway ───────────────────────────────────────
24
+
25
+ @WebSocketGateway({ cors: { origin: "*" } })
26
+ export abstract class BaseSocketGateway
27
+ implements OnGatewayConnection, OnGatewayDisconnect
28
+ {
29
+ @WebSocketServer()
30
+ server!: Server;
31
+
32
+ protected readonly roomPrefix: string;
33
+ protected readonly logger: Pick<Console, "debug" | "info" | "warn" | "error">;
34
+
35
+ constructor(options: NestSocketOptions = {}) {
36
+ this.roomPrefix = options.roomPrefix ?? "company";
37
+ this.logger = options.logger ?? console;
38
+ }
39
+
40
+ // ─── Lifecycle ────────────────────────────────────
41
+
42
+ handleConnection(client: Socket): void {
43
+ const userId = (client as any).data?.user?.id ?? client.id;
44
+ this.logger.debug(`[WS] Connected: ${userId}`);
45
+
46
+ client.on("join:company", (companyId: string | number) => {
47
+ const room = `${this.roomPrefix}:${companyId}`;
48
+ client.join(room);
49
+ this.logger.debug(`[WS] ${userId} joined ${room}`);
50
+ });
51
+
52
+ client.on("leave:company", (companyId: string | number) => {
53
+ const room = `${this.roomPrefix}:${companyId}`;
54
+ client.leave(room);
55
+ this.logger.debug(`[WS] ${userId} left ${room}`);
56
+ });
57
+ }
58
+
59
+ handleDisconnect(client: Socket): void {
60
+ const userId = (client as any).data?.user?.id ?? client.id;
61
+ this.logger.debug(`[WS] Disconnected: ${userId}`);
62
+ }
63
+
64
+ // ─── Emit helpers ─────────────────────────────────
65
+
66
+ emitToRoom(room: string, event: string, data: unknown): void {
67
+ this.server.to(room).emit(event, data);
68
+ }
69
+
70
+ emitToCompany(companyId: string | number, event: string, data: unknown): void {
71
+ this.emitToRoom(`${this.roomPrefix}:${companyId}`, event, data);
72
+ }
73
+
74
+ emitGlobal(event: string, data: unknown): void {
75
+ this.server.emit(event, data);
76
+ }
77
+ }
@@ -0,0 +1,51 @@
1
+ // pendoadmin-shared-lib/src/tenant/drizzle-tenant.ts
2
+ //
3
+ // Drizzle ORM helper that builds reusable tenant WHERE clauses.
4
+
5
+ import { eq, and, SQL } from "drizzle-orm";
6
+ import type { Request, Response, NextFunction } from "express";
7
+
8
+ // ─── Types ──────────────────────────────────────────────
9
+
10
+ export interface TenantWhereHelper {
11
+ /** Standalone tenant filter — e.g. db.select().where(tw.where) */
12
+ where: SQL;
13
+ /** Combine tenant filter with additional conditions */
14
+ and: (...conditions: SQL[]) => SQL;
15
+ }
16
+
17
+ /**
18
+ * Create a reusable WHERE helper scoped to a single company.
19
+ *
20
+ * @param companyIdColumn - The Drizzle column reference (e.g. `users.companyId`)
21
+ * @param companyId - The company / tenant ID value
22
+ */
23
+ export function createTenantWhere(
24
+ companyIdColumn: any,
25
+ companyId: string | number,
26
+ ): TenantWhereHelper {
27
+ const tenantFilter = eq(companyIdColumn, companyId);
28
+
29
+ return {
30
+ where: tenantFilter,
31
+ and: (...conditions: SQL[]) => and(tenantFilter, ...conditions)!,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Express middleware that extracts companyId from `req.user` (set by
37
+ * auth middleware) and attaches a `tenantWhere` helper to the request.
38
+ *
39
+ * Usage:
40
+ * app.use(tenantMiddleware(schema.users.companyId));
41
+ * // Then in handlers: req.tenantWhere.where or req.tenantWhere.and(...)
42
+ */
43
+ export function tenantMiddleware(companyIdColumn: any) {
44
+ return (req: Request, _res: Response, next: NextFunction) => {
45
+ const companyId = (req as any).user?.company_id;
46
+ if (companyId) {
47
+ (req as any).tenantWhere = createTenantWhere(companyIdColumn, companyId);
48
+ }
49
+ next();
50
+ };
51
+ }
@@ -0,0 +1,24 @@
1
+ // Knex tenant scoping
2
+ export { scopedKnex } from "./knex-tenant.js";
3
+ export type { ScopedKnexOptions } from "./knex-tenant.js";
4
+
5
+ // Drizzle tenant scoping
6
+ export { createTenantWhere, tenantMiddleware } from "./drizzle-tenant.js";
7
+ export type { TenantWhereHelper } from "./drizzle-tenant.js";
8
+
9
+ // Tenant access control (feature gating)
10
+ export {
11
+ createTenantAccessControl,
12
+ knexFeatureResolver,
13
+ knexCompanyActiveChecker,
14
+ getFeaturesForPlan,
15
+ provisionCompanyPlan,
16
+ clearFeatureCache,
17
+ invalidateCompanyFeatureCache,
18
+ DEFAULT_PLANS,
19
+ } from "./tenant-access-control.js";
20
+ export type {
21
+ Feature,
22
+ SubscriptionPlan,
23
+ TenantAccessControlConfig,
24
+ } from "./tenant-access-control.js";
@@ -0,0 +1,59 @@
1
+ // pendoadmin-shared-lib/src/tenant/knex-tenant.ts
2
+ //
3
+ // Proxy-based Knex wrapper that automatically applies
4
+ // WHERE company_id = ? to every query on the returned builder.
5
+
6
+ import type { Knex } from "knex";
7
+
8
+ export interface ScopedKnexOptions {
9
+ /** Column name for tenant isolation (default: "company_id") */
10
+ tenantColumn?: string;
11
+ /** Tables to exclude from automatic scoping */
12
+ excludeTables?: string[];
13
+ }
14
+
15
+ /**
16
+ * Returns a Proxy around `knex` that intercepts table calls and
17
+ * injects `.where(tenantColumn, companyId)`.
18
+ */
19
+ export function scopedKnex(
20
+ knex: Knex,
21
+ companyId: string | number,
22
+ options: ScopedKnexOptions = {},
23
+ ): Knex {
24
+ const { tenantColumn = "company_id", excludeTables = [] } = options;
25
+ const excluded = new Set(excludeTables.map((t) => t.toLowerCase()));
26
+
27
+ return new Proxy(knex, {
28
+ apply(_target, _thisArg, args: [string, ...unknown[]]) {
29
+ const tableName = args[0];
30
+ const qb = knex(tableName);
31
+ if (typeof tableName === "string" && !excluded.has(tableName.toLowerCase())) {
32
+ return qb.where(`${tableName}.${tenantColumn}`, companyId);
33
+ }
34
+ return qb;
35
+ },
36
+ get(target, prop, receiver) {
37
+ if (prop === "raw" || prop === "schema" || prop === "destroy" || prop === "transaction") {
38
+ return Reflect.get(target, prop, receiver);
39
+ }
40
+ const value = Reflect.get(target, prop, receiver);
41
+ if (typeof value === "function" && typeof prop === "string") {
42
+ return (...args: unknown[]) => {
43
+ const result = (value as Function).apply(target, args);
44
+ if (
45
+ result &&
46
+ typeof result === "object" &&
47
+ "where" in result &&
48
+ typeof args[0] === "string" &&
49
+ !excluded.has((args[0] as string).toLowerCase())
50
+ ) {
51
+ return result.where(`${args[0]}.${tenantColumn}`, companyId);
52
+ }
53
+ return result;
54
+ };
55
+ }
56
+ return value;
57
+ },
58
+ }) as unknown as Knex;
59
+ }