@heavybit/pendoadmin-shared-lib 1.0.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.
Files changed (132) hide show
  1. package/dist/common/index.d.ts +6 -0
  2. package/dist/common/index.d.ts.map +1 -0
  3. package/dist/common/index.js +6 -0
  4. package/dist/common/index.js.map +1 -0
  5. package/dist/common/jwt.utils.d.ts +7 -0
  6. package/dist/common/jwt.utils.d.ts.map +1 -0
  7. package/dist/common/jwt.utils.js +31 -0
  8. package/dist/common/jwt.utils.js.map +1 -0
  9. package/dist/common/password.utils.d.ts +4 -0
  10. package/dist/common/password.utils.d.ts.map +1 -0
  11. package/dist/common/password.utils.js +17 -0
  12. package/dist/common/password.utils.js.map +1 -0
  13. package/dist/common/phone.utils.d.ts +4 -0
  14. package/dist/common/phone.utils.d.ts.map +1 -0
  15. package/dist/common/phone.utils.js +36 -0
  16. package/dist/common/phone.utils.js.map +1 -0
  17. package/dist/common/response.utils.d.ts +6 -0
  18. package/dist/common/response.utils.d.ts.map +1 -0
  19. package/dist/common/response.utils.js +33 -0
  20. package/dist/common/response.utils.js.map +1 -0
  21. package/dist/common/uuid.utils.d.ts +5 -0
  22. package/dist/common/uuid.utils.d.ts.map +1 -0
  23. package/dist/common/uuid.utils.js +10 -0
  24. package/dist/common/uuid.utils.js.map +1 -0
  25. package/dist/config/index.d.ts +95 -0
  26. package/dist/config/index.d.ts.map +1 -0
  27. package/dist/config/index.js +71 -0
  28. package/dist/config/index.js.map +1 -0
  29. package/dist/database/index.d.ts +25 -0
  30. package/dist/database/index.d.ts.map +1 -0
  31. package/dist/database/index.js +62 -0
  32. package/dist/database/index.js.map +1 -0
  33. package/dist/express/auth.middleware.d.ts +20 -0
  34. package/dist/express/auth.middleware.d.ts.map +1 -0
  35. package/dist/express/auth.middleware.js +83 -0
  36. package/dist/express/auth.middleware.js.map +1 -0
  37. package/dist/express/correlation.middleware.d.ts +6 -0
  38. package/dist/express/correlation.middleware.d.ts.map +1 -0
  39. package/dist/express/correlation.middleware.js +15 -0
  40. package/dist/express/correlation.middleware.js.map +1 -0
  41. package/dist/express/error-handler.d.ts +8 -0
  42. package/dist/express/error-handler.d.ts.map +1 -0
  43. package/dist/express/error-handler.js +18 -0
  44. package/dist/express/error-handler.js.map +1 -0
  45. package/dist/express/index.d.ts +6 -0
  46. package/dist/express/index.d.ts.map +1 -0
  47. package/dist/express/index.js +6 -0
  48. package/dist/express/index.js.map +1 -0
  49. package/dist/express/permission.guard.d.ts +14 -0
  50. package/dist/express/permission.guard.d.ts.map +1 -0
  51. package/dist/express/permission.guard.js +66 -0
  52. package/dist/express/permission.guard.js.map +1 -0
  53. package/dist/express/validation.middleware.d.ts +14 -0
  54. package/dist/express/validation.middleware.d.ts.map +1 -0
  55. package/dist/express/validation.middleware.js +80 -0
  56. package/dist/express/validation.middleware.js.map +1 -0
  57. package/dist/index.d.ts +9 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +17 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/logging/index.d.ts +4 -0
  62. package/dist/logging/index.d.ts.map +1 -0
  63. package/dist/logging/index.js +24 -0
  64. package/dist/logging/index.js.map +1 -0
  65. package/dist/messaging/index.d.ts +58 -0
  66. package/dist/messaging/index.d.ts.map +1 -0
  67. package/dist/messaging/index.js +132 -0
  68. package/dist/messaging/index.js.map +1 -0
  69. package/dist/nestjs/auth.guard.d.ts +12 -0
  70. package/dist/nestjs/auth.guard.d.ts.map +1 -0
  71. package/dist/nestjs/auth.guard.js +75 -0
  72. package/dist/nestjs/auth.guard.js.map +1 -0
  73. package/dist/nestjs/correlation.interceptor.d.ts +9 -0
  74. package/dist/nestjs/correlation.interceptor.d.ts.map +1 -0
  75. package/dist/nestjs/correlation.interceptor.js +29 -0
  76. package/dist/nestjs/correlation.interceptor.js.map +1 -0
  77. package/dist/nestjs/exception.filter.d.ts +8 -0
  78. package/dist/nestjs/exception.filter.d.ts.map +1 -0
  79. package/dist/nestjs/exception.filter.js +54 -0
  80. package/dist/nestjs/exception.filter.js.map +1 -0
  81. package/dist/nestjs/index.d.ts +7 -0
  82. package/dist/nestjs/index.d.ts.map +1 -0
  83. package/dist/nestjs/index.js +7 -0
  84. package/dist/nestjs/index.js.map +1 -0
  85. package/dist/nestjs/permission.decorator.d.ts +22 -0
  86. package/dist/nestjs/permission.decorator.d.ts.map +1 -0
  87. package/dist/nestjs/permission.decorator.js +30 -0
  88. package/dist/nestjs/permission.decorator.js.map +1 -0
  89. package/dist/nestjs/permission.guard.d.ts +12 -0
  90. package/dist/nestjs/permission.guard.d.ts.map +1 -0
  91. package/dist/nestjs/permission.guard.js +66 -0
  92. package/dist/nestjs/permission.guard.js.map +1 -0
  93. package/dist/nestjs/user.decorator.d.ts +12 -0
  94. package/dist/nestjs/user.decorator.d.ts.map +1 -0
  95. package/dist/nestjs/user.decorator.js +16 -0
  96. package/dist/nestjs/user.decorator.js.map +1 -0
  97. package/dist/types/index.d.ts +193 -0
  98. package/dist/types/index.d.ts.map +1 -0
  99. package/dist/types/index.js +17 -0
  100. package/dist/types/index.js.map +1 -0
  101. package/dist/validation/index.d.ts +32 -0
  102. package/dist/validation/index.d.ts.map +1 -0
  103. package/dist/validation/index.js +21 -0
  104. package/dist/validation/index.js.map +1 -0
  105. package/package.json +100 -0
  106. package/src/common/index.ts +5 -0
  107. package/src/common/jwt.utils.ts +43 -0
  108. package/src/common/password.utils.ts +20 -0
  109. package/src/common/phone.utils.ts +36 -0
  110. package/src/common/response.utils.ts +48 -0
  111. package/src/common/uuid.utils.ts +11 -0
  112. package/src/config/index.ts +73 -0
  113. package/src/database/index.ts +77 -0
  114. package/src/express/auth.middleware.ts +93 -0
  115. package/src/express/correlation.middleware.ts +16 -0
  116. package/src/express/error-handler.ts +28 -0
  117. package/src/express/index.ts +5 -0
  118. package/src/express/permission.guard.ts +72 -0
  119. package/src/express/validation.middleware.ts +76 -0
  120. package/src/index.ts +23 -0
  121. package/src/logging/index.ts +31 -0
  122. package/src/messaging/index.ts +161 -0
  123. package/src/nestjs/auth.guard.ts +69 -0
  124. package/src/nestjs/correlation.interceptor.ts +30 -0
  125. package/src/nestjs/exception.filter.ts +53 -0
  126. package/src/nestjs/index.ts +11 -0
  127. package/src/nestjs/permission.decorator.ts +36 -0
  128. package/src/nestjs/permission.guard.ts +76 -0
  129. package/src/nestjs/user.decorator.ts +19 -0
  130. package/src/types/index.ts +239 -0
  131. package/src/validation/index.ts +26 -0
  132. package/tsconfig.json +22 -0
@@ -0,0 +1,72 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+
3
+ /**
4
+ * Require a specific permission. Superadmins bypass all checks.
5
+ */
6
+ export function requirePermission(...permissions: string[]) {
7
+ return (req: Request, res: Response, next: NextFunction): void => {
8
+ if (!req.user) {
9
+ res.status(401).json({ success: false, message: 'Authentication required' });
10
+ return;
11
+ }
12
+
13
+ if (req.user.isSuperAdmin) return next();
14
+
15
+ const hasPermission = permissions.some(p => req.user!.permissions.includes(p));
16
+ if (!hasPermission) {
17
+ res.status(403).json({
18
+ success: false,
19
+ message: 'Insufficient permissions',
20
+ errors: { required: permissions },
21
+ });
22
+ return;
23
+ }
24
+ next();
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Require ALL of the listed permissions.
30
+ */
31
+ export function requireAllPermissions(...permissions: string[]) {
32
+ return (req: Request, res: Response, next: NextFunction): void => {
33
+ if (!req.user) {
34
+ res.status(401).json({ success: false, message: 'Authentication required' });
35
+ return;
36
+ }
37
+
38
+ if (req.user.isSuperAdmin) return next();
39
+
40
+ const hasAll = permissions.every(p => req.user!.permissions.includes(p));
41
+ if (!hasAll) {
42
+ res.status(403).json({
43
+ success: false,
44
+ message: 'Insufficient permissions',
45
+ errors: { required: permissions },
46
+ });
47
+ return;
48
+ }
49
+ next();
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Require a specific role.
55
+ */
56
+ export function requireRole(...roles: string[]) {
57
+ return (req: Request, res: Response, next: NextFunction): void => {
58
+ if (!req.user) {
59
+ res.status(401).json({ success: false, message: 'Authentication required' });
60
+ return;
61
+ }
62
+
63
+ if (req.user.isSuperAdmin) return next();
64
+
65
+ const hasRole = roles.some(r => req.user!.roles.includes(r));
66
+ if (!hasRole) {
67
+ res.status(403).json({ success: false, message: 'Insufficient role' });
68
+ return;
69
+ }
70
+ next();
71
+ };
72
+ }
@@ -0,0 +1,76 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ import type { ZodSchema, ZodObject, ZodRawShape } from 'zod';
3
+
4
+ /**
5
+ * Zod validation middleware for Express routes.
6
+ * @param schema Zod schema to validate against
7
+ * @param property Which part of the request to validate ('body' | 'query' | 'params')
8
+ */
9
+ export function validate(schema: ZodSchema, property: 'body' | 'query' | 'params' = 'body') {
10
+ return (req: Request, res: Response, next: NextFunction): void => {
11
+ const result = schema.safeParse(req[property]);
12
+ if (!result.success) {
13
+ const errors: Record<string, string[]> = {};
14
+ for (const issue of result.error.issues) {
15
+ const key = issue.path.join('.');
16
+ if (!errors[key]) errors[key] = [];
17
+ errors[key].push(issue.message);
18
+ }
19
+ res.status(422).json({ success: false, message: 'Validation failed', errors });
20
+ return;
21
+ }
22
+ req[property] = result.data;
23
+ next();
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Zod validation middleware – accepts a compound schema with body/query/params
29
+ * keys, or a simple schema validated against body.
30
+ */
31
+ export function validateRequest(schema: ZodSchema) {
32
+ return (req: Request, res: Response, next: NextFunction): void => {
33
+ // Check if this is a compound schema with body/query/params keys
34
+ const shape = (schema as ZodObject<ZodRawShape>)?.shape;
35
+ const isCompound = shape && ('body' in shape || 'query' in shape || 'params' in shape);
36
+
37
+ if (isCompound) {
38
+ const data: Record<string, unknown> = {};
39
+ if (shape.body) data.body = req.body;
40
+ if (shape.query) data.query = req.query;
41
+ if (shape.params) data.params = req.params;
42
+
43
+ const result = schema.safeParse(data);
44
+ if (!result.success) {
45
+ const errors: Record<string, string[]> = {};
46
+ for (const issue of result.error.issues) {
47
+ const key = issue.path.join('.');
48
+ if (!errors[key]) errors[key] = [];
49
+ errors[key].push(issue.message);
50
+ }
51
+ res.status(422).json({ success: false, message: 'Validation failed', errors });
52
+ return;
53
+ }
54
+ const parsed = result.data as Record<string, unknown>;
55
+ if (parsed.body) req.body = parsed.body;
56
+ if (parsed.query) (req as any).query = parsed.query;
57
+ if (parsed.params) (req as any).params = parsed.params;
58
+ } else {
59
+ // Simple schema – validate body
60
+ const result = schema.safeParse(req.body);
61
+ if (!result.success) {
62
+ const errors: Record<string, string[]> = {};
63
+ for (const issue of result.error.issues) {
64
+ const key = issue.path.join('.');
65
+ if (!errors[key]) errors[key] = [];
66
+ errors[key].push(issue.message);
67
+ }
68
+ res.status(422).json({ success: false, message: 'Validation failed', errors });
69
+ return;
70
+ }
71
+ req.body = result.data;
72
+ }
73
+
74
+ next();
75
+ };
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Types
2
+ export * from './types/index.js';
3
+
4
+ // Common utilities
5
+ export * from './common/index.js';
6
+
7
+ // Validation
8
+ export * from './validation/index.js';
9
+
10
+ // Logging
11
+ export * from './logging/index.js';
12
+
13
+ // Config
14
+ export * from './config/index.js';
15
+
16
+ // Messaging
17
+ export * from './messaging/index.js';
18
+
19
+ // Database
20
+ export * from './database/index.js';
21
+
22
+ // Express middleware (for Express-based services)
23
+ export * from './express/index.js';
@@ -0,0 +1,31 @@
1
+ import winston from 'winston';
2
+
3
+ const { combine, timestamp, json, errors, colorize, simple } = winston.format;
4
+
5
+ export function createLogger(service: string, level?: string): winston.Logger {
6
+ const isProduction = process.env.NODE_ENV === 'production';
7
+
8
+ return winston.createLogger({
9
+ level: level || process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
10
+ defaultMeta: { service },
11
+ format: combine(
12
+ errors({ stack: true }),
13
+ timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
14
+ isProduction ? json() : combine(colorize(), simple())
15
+ ),
16
+ transports: [
17
+ new winston.transports.Console(),
18
+ ],
19
+ });
20
+ }
21
+
22
+ export function createAuditLogger(service: string): winston.Logger {
23
+ return winston.createLogger({
24
+ level: 'info',
25
+ defaultMeta: { service, type: 'audit' },
26
+ format: combine(timestamp(), json()),
27
+ transports: [
28
+ new winston.transports.Console(),
29
+ ],
30
+ });
31
+ }
@@ -0,0 +1,161 @@
1
+ import amqplib from 'amqplib';
2
+ import type { Channel, ChannelModel, ConsumeMessage } from 'amqplib';
3
+ import { createLogger } from '../logging/index.js';
4
+
5
+ const logger = createLogger('rabbitmq');
6
+
7
+ // ── Event Type Constants ──────────────────────────────
8
+
9
+ export const EVENTS = {
10
+ // Auth events
11
+ AUTH_USER_CREATED: 'auth.user.created',
12
+ AUTH_USER_UPDATED: 'auth.user.updated',
13
+ AUTH_USER_DELETED: 'auth.user.deleted',
14
+ AUTH_USER_REGISTERED: 'auth.user.registered',
15
+ AUTH_LOGIN_SUCCESS: 'auth.login.success',
16
+ AUTH_LOGIN_FAILED: 'auth.login.failed',
17
+ AUTH_ROLE_UPDATED: 'auth.role.updated',
18
+ AUTH_COMPANY_CREATED: 'auth.company.created',
19
+ AUTH_COMPANY_UPDATED: 'auth.company.updated',
20
+ AUTH_COMPANY_CONFIG_UPDATED: 'auth.company.config_updated',
21
+ AUTH_PASSWORD_RESET_REQUESTED: 'auth.password.reset_requested',
22
+ AUTH_OTP_REQUESTED: 'auth.otp.requested',
23
+
24
+ // SMS events
25
+ SMS_MESSAGE_SENT: 'sms.message.sent',
26
+ SMS_MESSAGE_DELIVERED: 'sms.message.delivered',
27
+ SMS_MESSAGE_FAILED: 'sms.message.failed',
28
+ SMS_DELIVERY_FAILED: 'sms.delivery.failed',
29
+ SMS_SCHEDULE_TRIGGERED: 'sms.schedule.triggered',
30
+ SMS_PROCESS_SCHEDULED: 'sms.process_scheduled',
31
+ SMS_BULK_QUEUED: 'sms.bulk.queued',
32
+ SMS_BULK_COMPLETED: 'sms.bulk.completed',
33
+
34
+ // M-Pesa events
35
+ MPESA_PAYMENT_RECEIVED: 'mpesa.payment.received',
36
+ MPESA_STK_PUSH_SUCCESS: 'mpesa.stk_push.success',
37
+ MPESA_STK_PUSH_FAILED: 'mpesa.stk_push.failed',
38
+ MPESA_STK_PUSH_COMPLETED: 'mpesa.stk_push.completed',
39
+ MPESA_B2C_COMPLETED: 'mpesa.b2c.completed',
40
+
41
+ // USSD events
42
+ USSD_SESSION_STARTED: 'ussd.session.started',
43
+ USSD_SESSION_ENDED: 'ussd.session.ended',
44
+ USSD_SUBSCRIBER_REGISTERED: 'ussd.subscriber.registered',
45
+ USSD_STK_PUSH_REQUEST: 'ussd.stk_push.request',
46
+
47
+ // WhatsApp events
48
+ WHATSAPP_MESSAGE_SENT: 'whatsapp.message.sent',
49
+ WHATSAPP_MESSAGE_RECEIVED: 'whatsapp.message.received',
50
+ WHATSAPP_MESSAGE_DELIVERED: 'whatsapp.message.delivered',
51
+ WHATSAPP_MESSAGE_READ: 'whatsapp.message.read',
52
+
53
+ // Notification events
54
+ NOTIFICATION_SEND_SMS: 'notification.send_sms',
55
+ NOTIFICATION_SEND_WHATSAPP: 'notification.send_whatsapp',
56
+ NOTIFICATION_SEND_EMAIL: 'notification.send_email',
57
+
58
+ // AI events
59
+ AI_COMPLETION_SUCCESS: 'ai.completion.success',
60
+ AI_COMPLETION_FAILED: 'ai.completion.failed',
61
+ AI_ANOMALY_DETECTED: 'ai.anomaly.detected',
62
+
63
+ // Gateway events
64
+ GATEWAY_REQUEST_COMPLETED: 'gateway.request.completed',
65
+ GATEWAY_REQUEST_ERROR: 'gateway.request.error',
66
+ } as const;
67
+
68
+ export const EXCHANGES = {
69
+ EVENTS: 'pendoadmin.events',
70
+ COMMANDS: 'pendoadmin.commands',
71
+ } as const;
72
+
73
+ // ── RabbitMQ Client ───────────────────────────────────
74
+
75
+ export class RabbitMQClient {
76
+ private connection: ChannelModel | null = null;
77
+ private channel: Channel | null = null;
78
+ private url: string;
79
+
80
+ constructor(url: string) {
81
+ this.url = url;
82
+ }
83
+
84
+ async connect(): Promise<void> {
85
+ try {
86
+ this.connection = await amqplib.connect(this.url);
87
+ this.channel = await this.connection.createChannel();
88
+
89
+ // Set up exchanges
90
+ await this.channel.assertExchange(EXCHANGES.EVENTS, 'topic', { durable: true });
91
+ await this.channel.assertExchange(EXCHANGES.COMMANDS, 'direct', { durable: true });
92
+
93
+ this.connection.on('error', (err) => {
94
+ logger.error('RabbitMQ connection error', { error: err.message });
95
+ });
96
+
97
+ this.connection.on('close', () => {
98
+ logger.warn('RabbitMQ connection closed, reconnecting...');
99
+ setTimeout(() => this.connect(), 5000);
100
+ });
101
+
102
+ logger.info('Connected to RabbitMQ');
103
+ } catch (error) {
104
+ logger.error('Failed to connect to RabbitMQ', { error: (error as Error).message });
105
+ setTimeout(() => this.connect(), 5000);
106
+ }
107
+ }
108
+
109
+ async publish(routingKey: string, data: Record<string, unknown>, exchange: string = EXCHANGES.EVENTS): Promise<void> {
110
+ if (!this.channel) {
111
+ logger.warn('RabbitMQ channel not ready, message dropped', { routingKey });
112
+ return;
113
+ }
114
+
115
+ const message = Buffer.from(JSON.stringify({
116
+ routingKey,
117
+ data,
118
+ timestamp: new Date().toISOString(),
119
+ }));
120
+
121
+ this.channel.publish(exchange, routingKey, message, {
122
+ persistent: true,
123
+ contentType: 'application/json',
124
+ });
125
+ }
126
+
127
+ async subscribe(
128
+ queueName: string,
129
+ routingKeys: string | string[],
130
+ handler: (data: Record<string, unknown>, routingKey: string) => Promise<void>,
131
+ exchange: string = EXCHANGES.EVENTS
132
+ ): Promise<void> {
133
+ if (!this.channel) throw new Error('RabbitMQ channel not ready');
134
+
135
+ const keys = Array.isArray(routingKeys) ? routingKeys : [routingKeys];
136
+ await this.channel.assertQueue(queueName, { durable: true });
137
+
138
+ for (const key of keys) {
139
+ await this.channel.bindQueue(queueName, exchange, key);
140
+ }
141
+
142
+ await this.channel.consume(queueName, async (msg: ConsumeMessage | null) => {
143
+ if (!msg) return;
144
+ try {
145
+ const parsed = JSON.parse(msg.content.toString());
146
+ await handler(parsed.data || parsed, msg.fields.routingKey);
147
+ this.channel!.ack(msg);
148
+ } catch (error) {
149
+ logger.error('Error processing message', { queue: queueName, error: (error as Error).message });
150
+ this.channel!.nack(msg, false, false); // Dead letter
151
+ }
152
+ });
153
+
154
+ logger.info(`Subscribed to queue: ${queueName}`, { routingKeys });
155
+ }
156
+
157
+ async close(): Promise<void> {
158
+ await this.channel?.close();
159
+ await this.connection?.close();
160
+ }
161
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ Injectable,
3
+ CanActivate,
4
+ ExecutionContext,
5
+ UnauthorizedException,
6
+ } from '@nestjs/common';
7
+ import { Reflector } from '@nestjs/core';
8
+ import { verifyToken } from '../common/jwt.utils.js';
9
+ import { GATEWAY_HEADERS, type IAuthUser } from '../types/index.js';
10
+
11
+ export const IS_PUBLIC_KEY = 'isPublic';
12
+
13
+ /**
14
+ * NestJS Guard: validates JWT or trusts gateway-injected headers.
15
+ */
16
+ @Injectable()
17
+ export class AuthGuard implements CanActivate {
18
+ constructor(private reflector: Reflector) {}
19
+
20
+ async canActivate(context: ExecutionContext): Promise<boolean> {
21
+ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
22
+ context.getHandler(),
23
+ context.getClass(),
24
+ ]);
25
+ if (isPublic) return true;
26
+
27
+ const request = context.switchToHttp().getRequest();
28
+
29
+ // Trust gateway-injected headers first
30
+ const gatewayUserId = request.headers[GATEWAY_HEADERS.USER_ID];
31
+ if (gatewayUserId) {
32
+ const permissions = request.headers[GATEWAY_HEADERS.USER_PERMISSIONS];
33
+ request.user = {
34
+ id: gatewayUserId,
35
+ companyId: request.headers[GATEWAY_HEADERS.COMPANY_ID] || undefined,
36
+ email: request.headers[GATEWAY_HEADERS.USER_EMAIL] || undefined,
37
+ roles: request.headers[GATEWAY_HEADERS.USER_ROLES]
38
+ ? (request.headers[GATEWAY_HEADERS.USER_ROLES] as string).split(',')
39
+ : [],
40
+ permissions: permissions ? (permissions as string).split(',') : [],
41
+ isSuperAdmin:
42
+ request.headers[GATEWAY_HEADERS.IS_SUPERADMIN] === 'true',
43
+ } as IAuthUser;
44
+ return true;
45
+ }
46
+
47
+ // Fallback to direct JWT validation
48
+ const authHeader = request.headers.authorization;
49
+ if (!authHeader?.startsWith('Bearer ')) {
50
+ throw new UnauthorizedException('Missing authentication token');
51
+ }
52
+
53
+ try {
54
+ const token = authHeader.slice(7);
55
+ const payload = verifyToken(token, process.env.JWT_SECRET!);
56
+ request.user = {
57
+ id: payload.sub,
58
+ companyId: payload.companyId,
59
+ email: payload.email,
60
+ roles: payload.roles || [],
61
+ permissions: payload.permissions || [],
62
+ isSuperAdmin: payload.isSuperAdmin || false,
63
+ } as IAuthUser;
64
+ return true;
65
+ } catch {
66
+ throw new UnauthorizedException('Invalid or expired token');
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,30 @@
1
+ import {
2
+ Injectable,
3
+ NestInterceptor,
4
+ ExecutionContext,
5
+ CallHandler,
6
+ } from '@nestjs/common';
7
+ import { Observable, tap } from 'rxjs';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+
10
+ /**
11
+ * NestJS Interceptor: propagates or generates x-correlation-id header.
12
+ */
13
+ @Injectable()
14
+ export class CorrelationInterceptor implements NestInterceptor {
15
+ intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
16
+ const request = context.switchToHttp().getRequest();
17
+ const response = context.switchToHttp().getResponse();
18
+
19
+ const correlationId =
20
+ request.headers['x-correlation-id'] || uuidv4();
21
+ request.correlationId = correlationId;
22
+ response.setHeader('x-correlation-id', correlationId);
23
+
24
+ return next.handle().pipe(
25
+ tap(() => {
26
+ // correlation id already set on response
27
+ })
28
+ );
29
+ }
30
+ }
@@ -0,0 +1,53 @@
1
+ import {
2
+ ExceptionFilter,
3
+ Catch,
4
+ ArgumentsHost,
5
+ HttpException,
6
+ HttpStatus,
7
+ } from '@nestjs/common';
8
+ import { createLogger } from '../logging/index.js';
9
+
10
+ const logger = createLogger('exception-filter');
11
+
12
+ /**
13
+ * NestJS Exception Filter: standardises error responses.
14
+ */
15
+ @Catch()
16
+ export class GlobalExceptionFilter implements ExceptionFilter {
17
+ catch(exception: unknown, host: ArgumentsHost) {
18
+ const ctx = host.switchToHttp();
19
+ const response = ctx.getResponse();
20
+ const request = ctx.getRequest();
21
+
22
+ let status = HttpStatus.INTERNAL_SERVER_ERROR;
23
+ let message = 'Internal server error';
24
+ let errors: any = undefined;
25
+
26
+ if (exception instanceof HttpException) {
27
+ status = exception.getStatus();
28
+ const exResponse = exception.getResponse();
29
+ if (typeof exResponse === 'string') {
30
+ message = exResponse;
31
+ } else if (typeof exResponse === 'object') {
32
+ message = (exResponse as any).message || message;
33
+ errors = (exResponse as any).errors;
34
+ }
35
+ } else if (exception instanceof Error) {
36
+ message = exception.message;
37
+ logger.error('Unhandled exception', {
38
+ error: exception.message,
39
+ stack: exception.stack,
40
+ path: request.url,
41
+ });
42
+ }
43
+
44
+ response.status(status).json({
45
+ success: false,
46
+ message,
47
+ ...(errors ? { errors } : {}),
48
+ statusCode: status,
49
+ timestamp: new Date().toISOString(),
50
+ path: request.url,
51
+ });
52
+ }
53
+ }
@@ -0,0 +1,11 @@
1
+ export { AuthGuard } from './auth.guard.js';
2
+ export {
3
+ RequirePermission,
4
+ RequireAllPermissions,
5
+ RequireRole,
6
+ Public,
7
+ } from './permission.decorator.js';
8
+ export { PermissionGuard } from './permission.guard.js';
9
+ export { CurrentUser } from './user.decorator.js';
10
+ export { CorrelationInterceptor } from './correlation.interceptor.js';
11
+ export { GlobalExceptionFilter } from './exception.filter.js';
@@ -0,0 +1,36 @@
1
+ import { SetMetadata } from '@nestjs/common';
2
+ import { IS_PUBLIC_KEY } from './auth.guard.js';
3
+
4
+ export const PERMISSIONS_KEY = 'permissions';
5
+ export const REQUIRE_ALL_KEY = 'requireAll';
6
+ export const ROLES_KEY = 'roles';
7
+
8
+ /**
9
+ * Decorator: require at least one of the listed permissions.
10
+ * @example @RequirePermission('sms.messages:read', 'sms.messages:list')
11
+ */
12
+ export const RequirePermission = (...permissions: string[]) =>
13
+ SetMetadata(PERMISSIONS_KEY, permissions);
14
+
15
+ /**
16
+ * Decorator: require ALL of the listed permissions.
17
+ */
18
+ export const RequireAllPermissions = (...permissions: string[]) => {
19
+ return (target: any, key?: string | symbol, descriptor?: any) => {
20
+ SetMetadata(PERMISSIONS_KEY, permissions)(target, key!, descriptor);
21
+ SetMetadata(REQUIRE_ALL_KEY, true)(target, key!, descriptor);
22
+ return descriptor;
23
+ };
24
+ };
25
+
26
+ /**
27
+ * Decorator: require at least one of the listed roles.
28
+ * @example @RequireRole('admin', 'manager')
29
+ */
30
+ export const RequireRole = (...roles: string[]) =>
31
+ SetMetadata(ROLES_KEY, roles);
32
+
33
+ /**
34
+ * Decorator: mark route as public (skip auth guard).
35
+ */
36
+ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,76 @@
1
+ import {
2
+ Injectable,
3
+ CanActivate,
4
+ ExecutionContext,
5
+ ForbiddenException,
6
+ } from '@nestjs/common';
7
+ import { Reflector } from '@nestjs/core';
8
+ import {
9
+ PERMISSIONS_KEY,
10
+ REQUIRE_ALL_KEY,
11
+ ROLES_KEY,
12
+ } from './permission.decorator.js';
13
+ import { IS_PUBLIC_KEY } from './auth.guard.js';
14
+ import type { IAuthUser } from '../types/index.js';
15
+
16
+ /**
17
+ * NestJS Guard: checks permissions/roles from decorators.
18
+ * Must be used after AuthGuard so that request.user is populated.
19
+ */
20
+ @Injectable()
21
+ export class PermissionGuard implements CanActivate {
22
+ constructor(private reflector: Reflector) {}
23
+
24
+ canActivate(context: ExecutionContext): boolean {
25
+ // Skip permission check for public routes
26
+ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
27
+ context.getHandler(),
28
+ context.getClass(),
29
+ ]);
30
+ if (isPublic) return true;
31
+
32
+ const request = context.switchToHttp().getRequest();
33
+ const user: IAuthUser | undefined = request.user;
34
+
35
+ if (!user) {
36
+ throw new ForbiddenException('User context not found');
37
+ }
38
+
39
+ // Super admins bypass all permission checks
40
+ if (user.isSuperAdmin) return true;
41
+
42
+ // Check roles
43
+ const requiredRoles = this.reflector.getAllAndOverride<string[]>(
44
+ ROLES_KEY,
45
+ [context.getHandler(), context.getClass()]
46
+ );
47
+ if (requiredRoles?.length) {
48
+ const hasRole = requiredRoles.some((role: string) => user.roles.includes(role));
49
+ if (!hasRole) {
50
+ throw new ForbiddenException('Insufficient role');
51
+ }
52
+ }
53
+
54
+ // Check permissions
55
+ const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
56
+ PERMISSIONS_KEY,
57
+ [context.getHandler(), context.getClass()]
58
+ );
59
+ if (!requiredPermissions?.length) return true;
60
+
61
+ const requireAll = this.reflector.getAllAndOverride<boolean>(
62
+ REQUIRE_ALL_KEY,
63
+ [context.getHandler(), context.getClass()]
64
+ );
65
+
66
+ const hasPermission = requireAll
67
+ ? requiredPermissions.every((p: string) => user.permissions.includes(p))
68
+ : requiredPermissions.some((p: string) => user.permissions.includes(p));
69
+
70
+ if (!hasPermission) {
71
+ throw new ForbiddenException('Insufficient permissions');
72
+ }
73
+
74
+ return true;
75
+ }
76
+ }
@@ -0,0 +1,19 @@
1
+ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
2
+ import type { IAuthUser } from '../types/index.js';
3
+
4
+ /**
5
+ * Decorator: extract the authenticated user from the request.
6
+ * @example
7
+ * @Get('profile')
8
+ * getProfile(@CurrentUser() user: IAuthUser) { ... }
9
+ *
10
+ * @Get('id')
11
+ * getId(@CurrentUser('id') userId: string) { ... }
12
+ */
13
+ export const CurrentUser = createParamDecorator(
14
+ (data: keyof IAuthUser | undefined, ctx: ExecutionContext) => {
15
+ const request = ctx.switchToHttp().getRequest();
16
+ const user: IAuthUser = request.user;
17
+ return data ? user?.[data] : user;
18
+ }
19
+ );