@nestledjs/api 2.8.0 → 2.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestledjs/api",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "generators": "./generators.json",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -6,6 +6,10 @@ import { CookieOptions } from 'express'
6
6
  export class ConfigService {
7
7
  constructor(public readonly config: NestConfigService) {}
8
8
 
9
+ get environment(): string {
10
+ return this.config.getOrThrow<string>('environment')
11
+ }
12
+
9
13
  get apiUrl(): string {
10
14
  return this.config.getOrThrow<string>('apiUrl')
11
15
  }
@@ -52,10 +56,26 @@ export class ConfigService {
52
56
  return this.config.getOrThrow<string>('siteUrl')
53
57
  }
54
58
 
59
+ get emailProvider(): string {
60
+ const provider = this.config.get<string>('email.provider')
61
+ if (provider) {
62
+ return provider
63
+ }
64
+
65
+ // Auto-detect: if SMTP is configured, use it; otherwise use mock
66
+ const smtpHost = this.config.get<string>('smtp.host')
67
+ return smtpHost ? 'smtp' : 'mock'
68
+ }
69
+
70
+ get frontendUrl(): string {
71
+ return this.config.getOrThrow<string>('frontend.url')
72
+ }
73
+
55
74
  get mailerConfig() {
56
75
  return {
57
76
  host: this.config.getOrThrow<string>('smtp.host'),
58
77
  port: this.config.getOrThrow<string>('smtp.port'),
78
+ secure: this.config.get<boolean>('smtp.secure') || false,
59
79
  auth: {
60
80
  user: this.config.getOrThrow<string>('smtp.user'),
61
81
  pass: this.config.getOrThrow<string>('smtp.pass'),
@@ -63,6 +83,23 @@ export class ConfigService {
63
83
  }
64
84
  }
65
85
 
86
+ get twilio() {
87
+ return {
88
+ accountSid: this.config.get('twilio.accountSid'),
89
+ authToken: this.config.get('twilio.authToken'),
90
+ fromNumber: this.config.get('twilio.fromNumber'),
91
+ }
92
+ }
93
+
94
+ get stripe() {
95
+ return {
96
+ secretKey: this.config.get<string>('STRIPE_SECRET_KEY') || '',
97
+ publishableKey: this.config.get<string>('STRIPE_PUBLISHABLE_KEY') || '',
98
+ webhookSecret: this.config.get<string>('STRIPE_WEBHOOK_SECRET') || '',
99
+ currency: this.config.get<string>('STRIPE_CURRENCY') || 'usd',
100
+ }
101
+ }
102
+
66
103
  get prismaOptimizeEnabled(): boolean {
67
104
  return this.config.get<boolean>('prisma.optimize.enabled') ?? false
68
105
  }
@@ -1,39 +1,66 @@
1
1
  export const configuration = () => ({
2
2
  prefix: 'api',
3
3
  environment: process.env['NODE_ENV'],
4
- host: process.env['HOST'],
4
+ host: process.env['HOST'] ?? '0.0.0.0',
5
5
  port: parseInt(process.env['PORT'] ?? '3000', 10),
6
6
  apiUrl: process.env['API_URL'],
7
7
  api: {
8
8
  cookie: {
9
9
  name: process.env['API_COOKIE_NAME'],
10
- secret: process.env['API_COOKIE_SECRET'] || 'secret',
10
+ secret: process.env['API_COOKIE_SECRET'] ?? 'secret',
11
11
  options: {
12
- domain: process.env['API_COOKIE_DOMAIN'],
12
+ // Only set cookie domain if it is a valid registrable domain. Avoid 'localhost' or IPs.
13
+ ...(() => {
14
+ const dom = (process.env['API_COOKIE_DOMAIN'] ?? '').trim()
15
+ if (!dom || dom === 'localhost' || dom === '127.0.0.1' || dom === '[::1]') {
16
+ return {}
17
+ }
18
+ return { domain: dom }
19
+ })(),
13
20
  httpOnly: true,
21
+ secure: process.env['NODE_ENV'] === 'production',
22
+ sameSite: 'lax',
23
+ path: '/',
14
24
  },
15
25
  },
16
26
  cors: {
17
- origin: [process.env['WEB_URL']],
27
+ origin: (process.env['ALLOWED_ORIGINS'] ?? '').split(',').map(o => o.trim()).filter(o => o.length > 0),
18
28
  },
19
29
  },
20
- siteUrl: process.env['SITE_URL'] || process.env['API_URL']?.replace('/api', ''),
30
+ siteUrl: process.env['SITE_URL'] ?? process.env['API_URL']?.replace('/api', ''),
21
31
  app: {
22
32
  email: process.env['APP_EMAIL'],
23
33
  supportEmail: process.env['APP_SUPPORT_EMAIL'],
24
34
  adminEmails: process.env['APP_ADMIN_EMAILS'],
25
35
  name: process.env['APP_NAME'],
26
36
  },
37
+ email: {
38
+ provider: process.env['EMAIL_PROVIDER'] ?? 'smtp',
39
+ },
27
40
  smtp: {
28
41
  host: process.env['SMTP_HOST'],
29
42
  port: process.env['SMTP_PORT'],
30
43
  user: process.env['SMTP_USER'],
31
44
  pass: process.env['SMTP_PASS'],
32
45
  },
33
- prisma: {
34
- optimize: {
35
- enabled: process.env['OPTIMIZE_ENABLED'] === 'true',
36
- apiKey: process.env['OPTIMIZE_API_KEY'],
46
+ twoFactor: {
47
+ // Issuer name shown in authenticator apps
48
+ issuer: process.env['TWO_FACTOR_ISSUER'] ?? process.env['APP_NAME'] ?? 'MyApp',
49
+ // Window for time drift (in 30-second increments, 2 = 60 seconds tolerance)
50
+ window: parseInt(process.env['TWO_FACTOR_WINDOW'] ?? '2', 10),
51
+ // Encryption key for storing secrets (should be 32 characters)
52
+ encryptionKey: process.env['TWO_FACTOR_ENCRYPTION_KEY'] ?? process.env['JWT_SECRET'],
53
+ },
54
+ oauth: {
55
+ google: {
56
+ clientId: process.env['GOOGLE_OAUTH_CLIENT_ID'],
57
+ clientSecret: process.env['GOOGLE_OAUTH_CLIENT_SECRET'],
58
+ enabled: !!(process.env['GOOGLE_OAUTH_CLIENT_ID'] && process.env['GOOGLE_OAUTH_CLIENT_SECRET']),
59
+ },
60
+ github: {
61
+ clientId: process.env['GITHUB_OAUTH_CLIENT_ID'],
62
+ clientSecret: process.env['GITHUB_OAUTH_CLIENT_SECRET'],
63
+ enabled: !!(process.env['GITHUB_OAUTH_CLIENT_ID'] && process.env['GITHUB_OAUTH_CLIENT_SECRET']),
37
64
  },
38
65
  },
39
66
  })
@@ -2,25 +2,27 @@ import * as Joi from 'joi'
2
2
 
3
3
  export const validationSchema = Joi.object({
4
4
  NODE_ENV: Joi.string().valid('development', 'production', 'test'),
5
- HOST: Joi.alternatives()
6
- .try(
7
- Joi.string().ip({ version: ['ipv4', 'ipv6'] }),
8
- Joi.string().hostname(),
9
- )
10
- .default('localhost'),
5
+ HOST: Joi.alternatives().try(
6
+ Joi.string().ip(), // Allow IP addresses like 0.0.0.0, 127.0.0.1
7
+ Joi.string().hostname(), // Allow hostnames like localhost, example.com
8
+ ).default('localhost'),
11
9
  PORT: Joi.number().default(3000),
12
10
  WEB_PORT: Joi.number().default(4200),
13
- WEB_URL: Joi.string().default(`http://${process.env['HOST'] || 'localhost'}:${process.env['WEB_PORT']}`),
11
+ WEB_URL: Joi.string().default(
12
+ `http://${process.env['HOST'] || 'localhost'}:${process.env['WEB_PORT']}`,
13
+ ),
14
14
  API_COOKIE_DOMAIN: Joi.string().default('localhost'),
15
15
  API_COOKIE_NAME: Joi.string().default('__session'),
16
- API_URL: Joi.string().default(`http://${process.env['HOST'] || 'localhost'}:${process.env['PORT']}/api`),
17
- APP_NAME: Joi.string().required(),
18
- APP_EMAIL: Joi.string().email().required(),
19
- APP_SUPPORT_EMAIL: Joi.string().email().required(),
20
- APP_ADMIN_EMAILS: Joi.string().required(),
21
- SITE_URL: Joi.string().uri().required(),
22
- SMTP_HOST: Joi.string().required(),
23
- SMTP_PORT: Joi.string().required(),
24
- SMTP_USER: Joi.string().required(),
25
- SMTP_PASS: Joi.string().required(),
16
+ API_URL: Joi.string().default(
17
+ `http://${process.env['HOST'] || 'localhost'}:${process.env['PORT']}/api`,
18
+ ),
19
+ APP_NAME: Joi.string().default('BizToBiz'), // Made optional with default
20
+ APP_EMAIL: Joi.string().email().default('admin@example.com'), // Made optional with default
21
+ APP_SUPPORT_EMAIL: Joi.string().email().default('support@example.com'), // Made optional with default
22
+ APP_ADMIN_EMAILS: Joi.string().default('admin@example.com'), // Made optional with default
23
+ SITE_URL: Joi.string().uri().default('http://localhost:4200'), // Made optional with default
24
+ SMTP_HOST: Joi.string().default('localhost'), // Made optional with default
25
+ SMTP_PORT: Joi.string().default('587'), // Made optional with default
26
+ SMTP_USER: Joi.string().default('user'), // Made optional with default
27
+ SMTP_PASS: Joi.string().default('pass'), // Made optional with default
26
28
  })
@@ -6,6 +6,7 @@ const devkit_1 = require("@nx/devkit");
6
6
  const utils_1 = require("@nestledjs/utils");
7
7
  function generateLibraries(tree_1) {
8
8
  return tslib_1.__awaiter(this, arguments, void 0, function* (tree, options = {}) {
9
+ const npmScope = (0, utils_1.getNpmScope)(tree);
9
10
  const templateRootPath = (0, devkit_1.joinPathFragments)(__dirname, './files');
10
11
  const overwrite = options.overwrite === true;
11
12
  // Create prisma.config.ts at workspace root via template (idempotent)
@@ -68,7 +69,7 @@ function generateLibraries(tree_1) {
68
69
  // Add db-update convenience script to regenerate CRUD, models, custom, and SDK
69
70
  if (!json.scripts['db-update']) {
70
71
  json.scripts['db-update'] =
71
- 'nx g @nestledjs/api:generate-crud && pnpm generate:models && nx g @nestledjs/api:custom && nx g @nestledjs/shared:sdk';
72
+ `pnpm prisma:generate && nx g @${npmScope}/api:generate-crud && pnpm generate:models && nx g @${npmScope}/api:custom && nx g @${npmScope}/shared:sdk`;
72
73
  }
73
74
  return json;
74
75
  });
@@ -1 +1 @@
1
- {"version":3,"file":"generator.js","sourceRoot":"","sources":["../../../../../generators/api/src/prisma/generator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"generator.js","sourceRoot":"","sources":["../../../../../generators/api/src/prisma/generator.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,12 @@
1
1
  export * from './lib/guards/gql-auth.guard'
2
2
  export * from './lib/guards/gql-auth-admin.guard'
3
+ export * from './lib/guards/gql-organization-scoped.guard'
4
+ export * from './lib/guards/permissions.guard'
5
+ export * from './lib/guards/subscription.guard'
3
6
  export * from './lib/decorators/ctx-user.decorator'
4
- export * from './lib/types/nest-context-type'
7
+ export * from './lib/decorators/ctx-organization.decorator'
8
+ export * from './lib/services/auth-cache.service'
9
+ export * from './lib/services/auth-loader.service'
5
10
 
6
11
  // Explicitly export types for webpack compatibility
7
- export type { NestContextType } from './lib/types/nest-context-type'
12
+ export type { OrganizationContext, NestContextType } from './lib/types/nest-context-type'
@@ -0,0 +1,41 @@
1
+ import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common'
2
+ import { GqlExecutionContext } from '@nestjs/graphql'
3
+ import { OrganizationContext } from '../types/nest-context-type'
4
+
5
+ /**
6
+ * Extract organization context from GraphQL request
7
+ * Throws UnauthorizedException if no organization context is found
8
+ */
9
+ export const CtxOrganization = createParamDecorator(
10
+ (data: unknown, ctx: ExecutionContext): OrganizationContext => {
11
+ const gqlContext = GqlExecutionContext.create(ctx).getContext()
12
+ const organizationContext = gqlContext.req.organizationContext
13
+
14
+ if (!organizationContext) {
15
+ throw new UnauthorizedException(
16
+ 'Organization context required. Please set X-Organization-ID header or ensure user has an active organization.'
17
+ )
18
+ }
19
+
20
+ return organizationContext
21
+ }
22
+ )
23
+
24
+ /**
25
+ * Extract organization ID from context
26
+ * Throws UnauthorizedException if no organization context is found
27
+ */
28
+ export const CtxOrganizationId = createParamDecorator(
29
+ (data: unknown, ctx: ExecutionContext): string => {
30
+ const gqlContext = GqlExecutionContext.create(ctx).getContext()
31
+ const organizationContext = gqlContext.req.organizationContext
32
+
33
+ if (!organizationContext) {
34
+ throw new UnauthorizedException(
35
+ 'Organization context required. Please set X-Organization-ID header or ensure user has an active organization.'
36
+ )
37
+ }
38
+
39
+ return organizationContext.organizationId
40
+ }
41
+ )
@@ -1,6 +1,6 @@
1
- import { createParamDecorator } from '@nestjs/common'
1
+ import { createParamDecorator, ExecutionContext } from '@nestjs/common'
2
2
  import { GqlExecutionContext } from '@nestjs/graphql'
3
3
 
4
- export const CtxUser = createParamDecorator(
5
- (data, ctx) => GqlExecutionContext.create(ctx).getContext().req.user,
6
- )
4
+ export const CtxUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
5
+ return GqlExecutionContext.create(ctx).getContext().req.user
6
+ })
@@ -5,8 +5,6 @@ import { User } from '@<%= npmScope %>/api/core/models'
5
5
 
6
6
  @Injectable()
7
7
  export class GqlAuthAdminGuard extends AuthGuard('jwt') {
8
- private readonly _roles: string[] = ['Admin']
9
-
10
8
  override getRequest(context: ExecutionContext) {
11
9
  const ctx = GqlExecutionContext.create(context)
12
10
 
@@ -22,18 +20,19 @@ export class GqlAuthAdminGuard extends AuthGuard('jwt') {
22
20
  const ctx = GqlExecutionContext.create(context)
23
21
  const req = ctx.getContext().req
24
22
 
25
- if (!req || !req.user) {
23
+ if (!req?.user) {
26
24
  return false
27
25
  }
28
26
  const hasAccess = this.hasAccess(req.user)
29
27
 
30
28
  if (!hasAccess) {
31
- throw new ForbiddenException(`You need to have Admin access`)
29
+ throw new ForbiddenException(`You need to have Super Admin access`)
32
30
  }
33
31
  return req && req.user && this.hasAccess(req.user)
34
32
  }
35
33
 
36
34
  private hasAccess(user: User): boolean {
37
- return !!(user.role && this._roles.includes(user.role))
35
+ // Only super admins can access admin-protected routes
36
+ return !!user.isSuperAdmin
38
37
  }
39
38
  }
@@ -0,0 +1,43 @@
1
+ import { ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common'
2
+ import { GqlExecutionContext } from '@nestjs/graphql'
3
+ import { AuthGuard } from '@nestjs/passport'
4
+ import { OrganizationContext } from '../types/nest-context-type'
5
+
6
+ /**
7
+ * Guard that requires both authentication AND organization context.
8
+ * Use this guard for resolvers that operate on organization-scoped data.
9
+ */
10
+ @Injectable()
11
+ export class GqlOrganizationScopedGuard extends AuthGuard('jwt') {
12
+ override getRequest(context: ExecutionContext): any {
13
+ const ctx = GqlExecutionContext.create(context)
14
+ return ctx.getContext().req
15
+ }
16
+
17
+ override async canActivate(context: ExecutionContext): Promise<boolean> {
18
+ // First, run the standard JWT authentication
19
+ const canActivate = await super.canActivate(context)
20
+ if (!canActivate) {
21
+ return false
22
+ }
23
+
24
+ // Then check for organization context
25
+ const req = this.getRequest(context)
26
+ const organizationContext: OrganizationContext | undefined = req.organizationContext
27
+
28
+ if (!organizationContext) {
29
+ throw new ForbiddenException(
30
+ 'Organization context is required for this operation. Please set an active organization.'
31
+ )
32
+ }
33
+
34
+ // Check if organization context is complete
35
+ if (!organizationContext.organizationId || !organizationContext.roleId) {
36
+ throw new ForbiddenException(
37
+ 'Invalid organization context. Please ensure you have a valid membership in the organization.'
38
+ )
39
+ }
40
+
41
+ return true
42
+ }
43
+ }
@@ -0,0 +1,101 @@
1
+ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common'
2
+ import { Reflector } from '@nestjs/core'
3
+ import { GqlExecutionContext } from '@nestjs/graphql'
4
+ import { OrganizationContext } from '../types/nest-context-type'
5
+
6
+ export const PERMISSIONS_KEY = 'permissions'
7
+
8
+ export interface PermissionRequirement {
9
+ subject: string
10
+ action: string
11
+ }
12
+
13
+ /**
14
+ * Decorator to require specific permissions for a resolver/mutation
15
+ * Usage: @RequirePermissions({ subject: 'organization', action: 'update' })
16
+ */
17
+ export const RequirePermissions = (...permissions: PermissionRequirement[]) =>
18
+ SetMetadata(PERMISSIONS_KEY, permissions)
19
+
20
+ /**
21
+ * Guard that checks if the user has required permissions in their current organization
22
+ */
23
+ @Injectable()
24
+ export class PermissionsGuard implements CanActivate {
25
+ constructor(private reflector: Reflector) {}
26
+
27
+ canActivate(context: ExecutionContext): boolean {
28
+ const requiredPermissions = this.reflector.getAllAndOverride<PermissionRequirement[]>(
29
+ PERMISSIONS_KEY,
30
+ [context.getHandler(), context.getClass()]
31
+ )
32
+
33
+ if (!requiredPermissions || requiredPermissions.length === 0) {
34
+ return true
35
+ }
36
+
37
+ const gqlContext = GqlExecutionContext.create(context).getContext()
38
+ const organizationContext: OrganizationContext | undefined = gqlContext.req.organizationContext
39
+
40
+ if (!organizationContext) {
41
+ throw new ForbiddenException('Organization context required for this operation')
42
+ }
43
+
44
+ const hasAllPermissions = requiredPermissions.every(required =>
45
+ organizationContext.permissions.some(
46
+ p => p.subject === required.subject && p.action === required.action
47
+ )
48
+ )
49
+
50
+ if (!hasAllPermissions) {
51
+ const missingPermissions = requiredPermissions
52
+ .filter(
53
+ required =>
54
+ !organizationContext.permissions.some(
55
+ p => p.subject === required.subject && p.action === required.action
56
+ )
57
+ )
58
+ .map(p => `${p.subject}:${p.action}`)
59
+
60
+ throw new ForbiddenException(
61
+ `Missing required permissions: ${missingPermissions.join(', ')}. Current role: ${organizationContext.roleName}`
62
+ )
63
+ }
64
+
65
+ return true
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Helper function to check permissions programmatically in services
71
+ */
72
+ export function hasPermission(
73
+ organizationContext: OrganizationContext | undefined,
74
+ subject: string,
75
+ action: string
76
+ ): boolean {
77
+ if (!organizationContext) {
78
+ return false
79
+ }
80
+
81
+ return organizationContext.permissions.some(
82
+ p => p.subject === subject && p.action === action
83
+ )
84
+ }
85
+
86
+ /**
87
+ * Helper function to require permission or throw
88
+ */
89
+ export function requirePermission(
90
+ organizationContext: OrganizationContext | undefined,
91
+ subject: string,
92
+ action: string
93
+ ): void {
94
+ if (!hasPermission(organizationContext, subject, action)) {
95
+ throw new ForbiddenException(
96
+ `Missing required permission: ${subject}:${action}${
97
+ organizationContext ? `. Current role: ${organizationContext.roleName}` : ''
98
+ }`
99
+ )
100
+ }
101
+ }
@@ -0,0 +1,79 @@
1
+ import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common'
2
+ import { GqlExecutionContext } from '@nestjs/graphql'
3
+ import { SubscriptionStatus } from '@<%= npmScope %>/api/prisma'
4
+ import { ApiCoreDataAccessService } from '@<%= npmScope %>/api/core/data-access'
5
+
6
+ /**
7
+ * Subscription Guard - Protects resolvers by requiring an active subscription.
8
+ */
9
+ @Injectable()
10
+ export class SubscriptionGuard implements CanActivate {
11
+ constructor(private readonly prisma: ApiCoreDataAccessService) {}
12
+
13
+ async canActivate(context: ExecutionContext): Promise<boolean> {
14
+ const ctx = GqlExecutionContext.create(context)
15
+ const { req } = ctx.getContext()
16
+
17
+ const user = req.user
18
+ if (!user) {
19
+ throw new HttpException('Authentication required', HttpStatus.UNAUTHORIZED)
20
+ }
21
+
22
+ const activeOrganizationId = user.activeOrganizationId
23
+ if (!activeOrganizationId) {
24
+ throw new HttpException(
25
+ 'No active organization. Please select an organization.',
26
+ HttpStatus.FORBIDDEN,
27
+ )
28
+ }
29
+
30
+ const subscription = await this.prisma.subscription.findUnique({
31
+ where: { organizationId: activeOrganizationId },
32
+ include: { plan: true },
33
+ })
34
+
35
+ if (!subscription) {
36
+ throw new HttpException(
37
+ 'No subscription found. Please subscribe to a plan to access this feature.',
38
+ HttpStatus.PAYMENT_REQUIRED,
39
+ )
40
+ }
41
+
42
+ const validStatuses: SubscriptionStatus[] = [
43
+ SubscriptionStatus.ACTIVE,
44
+ SubscriptionStatus.TRIALING,
45
+ ]
46
+
47
+ if (!validStatuses.includes(subscription.status)) {
48
+ if (subscription.status === SubscriptionStatus.PAST_DUE) {
49
+ const gracePeriodDays = 3
50
+ const currentPeriodEnd = subscription.stripeCurrentPeriodEnd
51
+ if (currentPeriodEnd) {
52
+ const gracePeriodEnd = new Date(currentPeriodEnd)
53
+ gracePeriodEnd.setDate(gracePeriodEnd.getDate() + gracePeriodDays)
54
+
55
+ if (new Date() <= gracePeriodEnd) {
56
+ return true
57
+ }
58
+ }
59
+ }
60
+
61
+ throw new HttpException(
62
+ `Subscription is ${subscription.status.toLowerCase()}. Please update your payment method.`,
63
+ HttpStatus.PAYMENT_REQUIRED,
64
+ )
65
+ }
66
+
67
+ if (subscription.cancelAtPeriodEnd && subscription.stripeCurrentPeriodEnd) {
68
+ const periodEnd = new Date(subscription.stripeCurrentPeriodEnd)
69
+ if (new Date() > periodEnd) {
70
+ throw new HttpException(
71
+ 'Subscription has expired. Please renew your subscription.',
72
+ HttpStatus.PAYMENT_REQUIRED,
73
+ )
74
+ }
75
+ }
76
+
77
+ return true
78
+ }
79
+ }
@@ -0,0 +1,232 @@
1
+ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'
2
+ import { Redis } from 'ioredis'
3
+ import { OrganizationContext } from '../types/nest-context-type'
4
+
5
+ const SESSION_TTL = 15 * 60 // 15 minutes
6
+ const MEMBERSHIP_TTL = 10 * 60 // 10 minutes
7
+ const USER_ORG_TTL = 10 * 60 // 10 minutes
8
+
9
+ export interface CachedSession {
10
+ userId: string
11
+ email: string
12
+ isSuperAdmin: boolean
13
+ activeOrganizationId?: string
14
+ }
15
+
16
+ export interface CachedMembership {
17
+ organizationId: string
18
+ userId: string
19
+ roleId: string
20
+ roleName: string
21
+ permissions: Array<{ subject: string; action: string }>
22
+ }
23
+
24
+ @Injectable()
25
+ export class AuthCacheService implements OnModuleInit {
26
+ private readonly logger = new Logger(AuthCacheService.name)
27
+ private redis: Redis | null = null
28
+ private isConnected = false
29
+
30
+ async onModuleInit() {
31
+ const redisUrl = process.env['REDIS_URL']
32
+ if (!redisUrl) {
33
+ this.logger.warn('REDIS_URL not configured - auth caching disabled')
34
+ return
35
+ }
36
+
37
+ try {
38
+ this.redis = new Redis(redisUrl, {
39
+ maxRetriesPerRequest: 3,
40
+ retryDelayOnFailover: 100,
41
+ lazyConnect: true,
42
+ })
43
+
44
+ this.redis.on('connect', () => {
45
+ this.isConnected = true
46
+ this.logger.log('Redis connected for auth caching')
47
+ })
48
+
49
+ this.redis.on('error', (err) => {
50
+ this.isConnected = false
51
+ this.logger.warn(`Redis error: ${err.message}`)
52
+ })
53
+
54
+ this.redis.on('close', () => {
55
+ this.isConnected = false
56
+ })
57
+
58
+ await this.redis.connect()
59
+ } catch (error) {
60
+ this.logger.warn(`Failed to connect to Redis: ${error}`)
61
+ this.redis = null
62
+ }
63
+ }
64
+
65
+ private get available(): boolean {
66
+ return this.redis !== null && this.isConnected
67
+ }
68
+
69
+ // Session caching
70
+ private sessionKey(userId: string): string {
71
+ return `auth:session:${userId}`
72
+ }
73
+
74
+ async getSession(userId: string): Promise<CachedSession | null> {
75
+ if (!this.available) return null
76
+
77
+ try {
78
+ const data = await this.redis!.get(this.sessionKey(userId))
79
+ return data ? JSON.parse(data) : null
80
+ } catch (error) {
81
+ this.logger.debug(`Cache miss for session ${userId}: ${error}`)
82
+ return null
83
+ }
84
+ }
85
+
86
+ async setSession(userId: string, session: CachedSession): Promise<void> {
87
+ if (!this.available) return
88
+
89
+ try {
90
+ await this.redis!.setex(
91
+ this.sessionKey(userId),
92
+ SESSION_TTL,
93
+ JSON.stringify(session)
94
+ )
95
+ } catch (error) {
96
+ this.logger.debug(`Failed to cache session: ${error}`)
97
+ }
98
+ }
99
+
100
+ async invalidateSession(userId: string): Promise<void> {
101
+ if (!this.available) return
102
+
103
+ try {
104
+ await this.redis!.del(this.sessionKey(userId))
105
+ } catch (error) {
106
+ this.logger.debug(`Failed to invalidate session: ${error}`)
107
+ }
108
+ }
109
+
110
+ // Membership context caching
111
+ private membershipKey(userId: string, organizationId: string): string {
112
+ return `auth:membership:${userId}:${organizationId}`
113
+ }
114
+
115
+ async getMembershipContext(
116
+ userId: string,
117
+ organizationId: string
118
+ ): Promise<OrganizationContext | null> {
119
+ if (!this.available) return null
120
+
121
+ try {
122
+ const data = await this.redis!.get(this.membershipKey(userId, organizationId))
123
+ return data ? JSON.parse(data) : null
124
+ } catch (error) {
125
+ this.logger.debug(`Cache miss for membership ${userId}:${organizationId}: ${error}`)
126
+ return null
127
+ }
128
+ }
129
+
130
+ async setMembershipContext(
131
+ userId: string,
132
+ organizationId: string,
133
+ context: OrganizationContext
134
+ ): Promise<void> {
135
+ if (!this.available) return
136
+
137
+ try {
138
+ await this.redis!.setex(
139
+ this.membershipKey(userId, organizationId),
140
+ MEMBERSHIP_TTL,
141
+ JSON.stringify(context)
142
+ )
143
+ } catch (error) {
144
+ this.logger.debug(`Failed to cache membership context: ${error}`)
145
+ }
146
+ }
147
+
148
+ async invalidateMembership(userId: string, organizationId: string): Promise<void> {
149
+ if (!this.available) return
150
+
151
+ try {
152
+ await this.redis!.del(this.membershipKey(userId, organizationId))
153
+ } catch (error) {
154
+ this.logger.debug(`Failed to invalidate membership: ${error}`)
155
+ }
156
+ }
157
+
158
+ async invalidateAllMembershipsForUser(userId: string): Promise<void> {
159
+ if (!this.available) return
160
+
161
+ try {
162
+ const pattern = `auth:membership:${userId}:*`
163
+ const keys = await this.redis!.keys(pattern)
164
+ if (keys.length > 0) {
165
+ await this.redis!.del(...keys)
166
+ }
167
+ } catch (error) {
168
+ this.logger.debug(`Failed to invalidate all memberships for user: ${error}`)
169
+ }
170
+ }
171
+
172
+ async invalidateAllMembershipsForOrganization(organizationId: string): Promise<void> {
173
+ if (!this.available) return
174
+
175
+ try {
176
+ const pattern = `auth:membership:*:${organizationId}`
177
+ const keys = await this.redis!.keys(pattern)
178
+ if (keys.length > 0) {
179
+ await this.redis!.del(...keys)
180
+ }
181
+ } catch (error) {
182
+ this.logger.debug(`Failed to invalidate all memberships for org: ${error}`)
183
+ }
184
+ }
185
+
186
+ // User active organization caching
187
+ private userOrgKey(userId: string): string {
188
+ return `auth:user-org:${userId}`
189
+ }
190
+
191
+ async getUserActiveOrganization(userId: string): Promise<string | null> {
192
+ if (!this.available) return null
193
+
194
+ try {
195
+ return await this.redis!.get(this.userOrgKey(userId))
196
+ } catch (error) {
197
+ this.logger.debug(`Cache miss for user org ${userId}: ${error}`)
198
+ return null
199
+ }
200
+ }
201
+
202
+ async setUserActiveOrganization(userId: string, organizationId: string): Promise<void> {
203
+ if (!this.available) return
204
+
205
+ try {
206
+ await this.redis!.setex(this.userOrgKey(userId), USER_ORG_TTL, organizationId)
207
+ } catch (error) {
208
+ this.logger.debug(`Failed to cache user org: ${error}`)
209
+ }
210
+ }
211
+
212
+ async invalidateUserActiveOrganization(userId: string): Promise<void> {
213
+ if (!this.available) return
214
+
215
+ try {
216
+ await this.redis!.del(this.userOrgKey(userId))
217
+ } catch (error) {
218
+ this.logger.debug(`Failed to invalidate user org: ${error}`)
219
+ }
220
+ }
221
+
222
+ // Role change invalidation
223
+ async onRolePermissionsChanged(roleId: string, organizationId: string): Promise<void> {
224
+ // When role permissions change, invalidate all memberships for that organization
225
+ await this.invalidateAllMembershipsForOrganization(organizationId)
226
+ }
227
+
228
+ async onUserRoleChanged(userId: string, organizationId: string): Promise<void> {
229
+ // When a user's role changes, invalidate their membership context
230
+ await this.invalidateMembership(userId, organizationId)
231
+ }
232
+ }
@@ -0,0 +1,176 @@
1
+ import { Injectable, Scope } from '@nestjs/common'
2
+ import DataLoader from 'dataloader'
3
+ import { ApiCoreDataAccessService } from '@<%= npmScope %>/api/core/data-access'
4
+ import { AuthCacheService } from './auth-cache.service'
5
+ import { OrganizationContext } from '../types/nest-context-type'
6
+
7
+ interface MembershipKey {
8
+ userId: string
9
+ organizationId: string
10
+ }
11
+
12
+ interface MembershipWithRole {
13
+ id: string
14
+ userId: string
15
+ organizationId: string
16
+ role: {
17
+ id: string
18
+ name: string
19
+ permissions: Array<{
20
+ permission: {
21
+ subject: string
22
+ action: string
23
+ }
24
+ }>
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Request-scoped DataLoader service for batching auth lookups.
30
+ * Uses three-tier caching: request → Redis → database
31
+ */
32
+ @Injectable({ scope: Scope.REQUEST })
33
+ export class AuthLoaderService {
34
+ private membershipLoader: DataLoader<string, MembershipWithRole | null>
35
+
36
+ constructor(
37
+ private readonly prisma: ApiCoreDataAccessService,
38
+ private readonly authCache: AuthCacheService
39
+ ) {
40
+ this.membershipLoader = new DataLoader<string, MembershipWithRole | null>(
41
+ async (keys) => this.batchLoadMemberships(keys as string[]),
42
+ {
43
+ cacheKeyFn: (key) => key,
44
+ }
45
+ )
46
+ }
47
+
48
+ private membershipKeyToString(key: MembershipKey): string {
49
+ return `${key.userId}:${key.organizationId}`
50
+ }
51
+
52
+ private stringToMembershipKey(str: string): MembershipKey {
53
+ const [userId, organizationId] = str.split(':')
54
+ return { userId, organizationId }
55
+ }
56
+
57
+ private async batchLoadMemberships(
58
+ keys: string[]
59
+ ): Promise<(MembershipWithRole | null)[]> {
60
+ const membershipKeys = keys.map((k) => this.stringToMembershipKey(k))
61
+
62
+ // Build WHERE conditions for batch query
63
+ const conditions = membershipKeys.map((k) => ({
64
+ userId: k.userId,
65
+ organizationId: k.organizationId,
66
+ }))
67
+
68
+ // Batch fetch all memberships with their roles and permissions
69
+ const memberships = await this.prisma.organizationMembership.findMany({
70
+ where: {
71
+ OR: conditions,
72
+ },
73
+ include: {
74
+ role: {
75
+ include: {
76
+ permissions: {
77
+ include: {
78
+ permission: true,
79
+ },
80
+ },
81
+ },
82
+ },
83
+ },
84
+ })
85
+
86
+ // Map results back to original key order
87
+ const membershipMap = new Map<string, MembershipWithRole>()
88
+ for (const membership of memberships) {
89
+ const key = this.membershipKeyToString({
90
+ userId: membership.userId,
91
+ organizationId: membership.organizationId,
92
+ })
93
+ membershipMap.set(key, membership as MembershipWithRole)
94
+ }
95
+
96
+ return keys.map((key) => membershipMap.get(key) || null)
97
+ }
98
+
99
+ /**
100
+ * Load membership context with three-tier caching
101
+ */
102
+ async loadMembershipContext(
103
+ userId: string,
104
+ organizationId: string
105
+ ): Promise<OrganizationContext | null> {
106
+ // Tier 1: Check Redis cache
107
+ const cached = await this.authCache.getMembershipContext(userId, organizationId)
108
+ if (cached) {
109
+ return cached
110
+ }
111
+
112
+ // Tier 2: Use DataLoader (request-scoped batch caching)
113
+ const key = this.membershipKeyToString({ userId, organizationId })
114
+ const membership = await this.membershipLoader.load(key)
115
+
116
+ if (!membership) {
117
+ return null
118
+ }
119
+
120
+ // Build organization context from membership
121
+ const context: OrganizationContext = {
122
+ organizationId: membership.organizationId,
123
+ userId: membership.userId,
124
+ roleId: membership.role.id,
125
+ roleName: membership.role.name,
126
+ permissions: membership.role.permissions.map((rp) => ({
127
+ subject: rp.permission.subject,
128
+ action: rp.permission.action,
129
+ })),
130
+ }
131
+
132
+ // Tier 3: Store in Redis for future requests
133
+ await this.authCache.setMembershipContext(userId, organizationId, context)
134
+
135
+ return context
136
+ }
137
+
138
+ /**
139
+ * Preload multiple membership contexts (useful for batch operations)
140
+ */
141
+ async preloadMembershipContexts(
142
+ keys: MembershipKey[]
143
+ ): Promise<Map<string, OrganizationContext | null>> {
144
+ const results = new Map<string, OrganizationContext | null>()
145
+
146
+ await Promise.all(
147
+ keys.map(async (key) => {
148
+ const context = await this.loadMembershipContext(key.userId, key.organizationId)
149
+ results.set(this.membershipKeyToString(key), context)
150
+ })
151
+ )
152
+
153
+ return results
154
+ }
155
+
156
+ /**
157
+ * Clear the request-scoped DataLoader cache
158
+ */
159
+ clearCache(): void {
160
+ this.membershipLoader.clearAll()
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Factory function for creating AuthLoaderService
166
+ * Use this when you need to inject the service with its dependencies
167
+ */
168
+ export const AUTH_LOADER_SERVICE = 'AUTH_LOADER_SERVICE'
169
+
170
+ export const authLoaderServiceFactory = {
171
+ provide: AUTH_LOADER_SERVICE,
172
+ useFactory: (prisma: ApiCoreDataAccessService, authCache: AuthCacheService) => {
173
+ return new AuthLoaderService(prisma, authCache)
174
+ },
175
+ inject: [ApiCoreDataAccessService, AuthCacheService],
176
+ }
@@ -1,4 +1,18 @@
1
+ import { Request, Response } from 'express'
2
+ import { User } from '@<%= npmScope %>/api/core/models'
3
+
4
+ export interface OrganizationContext {
5
+ organizationId: string
6
+ userId: string
7
+ roleId: string
8
+ roleName: string
9
+ permissions: Array<{ subject: string; action: string }>
10
+ }
11
+
1
12
  export interface NestContextType {
2
- req: Request
13
+ req: Request & {
14
+ user?: User
15
+ organizationContext?: OrganizationContext
16
+ }
3
17
  res: Response
4
18
  }