@ftisindia/create-app 0.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.
Files changed (151) hide show
  1. package/LICENSE +144 -0
  2. package/bin/index.mjs +2 -0
  3. package/package.json +36 -0
  4. package/src/copy.mjs +45 -0
  5. package/src/log.mjs +23 -0
  6. package/src/main.mjs +221 -0
  7. package/src/run.mjs +52 -0
  8. package/src/substitute.mjs +40 -0
  9. package/template/.env.example +36 -0
  10. package/template/.github/workflows/ci.yml +36 -0
  11. package/template/.husky/pre-commit +1 -0
  12. package/template/README.md +146 -0
  13. package/template/_editorconfig +8 -0
  14. package/template/_gitignore +7 -0
  15. package/template/_nvmrc +1 -0
  16. package/template/_package.json +107 -0
  17. package/template/_prettierignore +5 -0
  18. package/template/_prettierrc +6 -0
  19. package/template/docs/API_REFERENCE.md +123 -0
  20. package/template/docs/GETTING_STARTED.md +65 -0
  21. package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
  22. package/template/docs/OAUTH.md +46 -0
  23. package/template/docs/SAMPLE_MODULE.md +23 -0
  24. package/template/docs/api.http +269 -0
  25. package/template/eslint.config.mjs +51 -0
  26. package/template/nest-cli.json +8 -0
  27. package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
  28. package/template/prisma/schema.prisma +299 -0
  29. package/template/prisma/seed.ts +44 -0
  30. package/template/scripts/db-create.mjs +126 -0
  31. package/template/scripts/gen-module.mjs +217 -0
  32. package/template/scripts/seed-test-user-org.ts +264 -0
  33. package/template/scripts/setup-local.mjs +224 -0
  34. package/template/scripts/test-db.mjs +69 -0
  35. package/template/src/app.module.ts +58 -0
  36. package/template/src/common/decorators/.gitkeep +1 -0
  37. package/template/src/common/dto/error-response.dto.ts +17 -0
  38. package/template/src/common/dto/membership-response.dto.ts +51 -0
  39. package/template/src/common/dto/mutation-response.dto.ts +11 -0
  40. package/template/src/common/dto/role-summary.dto.ts +18 -0
  41. package/template/src/common/dto/user-summary.dto.ts +23 -0
  42. package/template/src/common/enums/.gitkeep +1 -0
  43. package/template/src/common/filters/.gitkeep +1 -0
  44. package/template/src/common/filters/http-exception.filter.ts +78 -0
  45. package/template/src/common/guards/.gitkeep +1 -0
  46. package/template/src/common/interceptors/.gitkeep +1 -0
  47. package/template/src/common/pipes/.gitkeep +1 -0
  48. package/template/src/common/swagger/api-error-responses.ts +54 -0
  49. package/template/src/common/types/.gitkeep +1 -0
  50. package/template/src/config/app.config.ts +7 -0
  51. package/template/src/config/auth.config.ts +33 -0
  52. package/template/src/config/database.config.ts +6 -0
  53. package/template/src/config/env.validation.ts +131 -0
  54. package/template/src/config/index.ts +5 -0
  55. package/template/src/config/rbac.config.ts +6 -0
  56. package/template/src/database/prisma/prisma-transaction.ts +22 -0
  57. package/template/src/database/prisma/prisma.module.ts +9 -0
  58. package/template/src/database/prisma/prisma.service.ts +16 -0
  59. package/template/src/main.ts +42 -0
  60. package/template/src/modules/access-control/access-control.module.ts +24 -0
  61. package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
  62. package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
  63. package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
  64. package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
  65. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
  66. package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
  67. package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
  68. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
  69. package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
  70. package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
  71. package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
  72. package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
  73. package/template/src/modules/access-control/types/permission-key.ts +37 -0
  74. package/template/src/modules/access-control/types/rbac-context.ts +11 -0
  75. package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
  76. package/template/src/modules/audit/application/services/audit.service.ts +97 -0
  77. package/template/src/modules/audit/audit.module.ts +13 -0
  78. package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
  79. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
  80. package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
  81. package/template/src/modules/auth/application/services/auth.service.ts +509 -0
  82. package/template/src/modules/auth/application/services/password.service.ts +15 -0
  83. package/template/src/modules/auth/application/services/token.service.ts +95 -0
  84. package/template/src/modules/auth/auth.module.ts +73 -0
  85. package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
  86. package/template/src/modules/auth/dto/login.dto.ts +15 -0
  87. package/template/src/modules/auth/dto/logout.dto.ts +3 -0
  88. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
  89. package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
  90. package/template/src/modules/auth/dto/signup.dto.ts +27 -0
  91. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
  92. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
  93. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
  94. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
  95. package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
  96. package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
  97. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
  98. package/template/src/modules/auth/types/authenticated-user.ts +7 -0
  99. package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
  100. package/template/src/modules/auth/types/jwt-payload.ts +5 -0
  101. package/template/src/modules/health/dto/health-response.dto.ts +9 -0
  102. package/template/src/modules/health/health.module.ts +7 -0
  103. package/template/src/modules/health/presentation/health.controller.ts +33 -0
  104. package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
  105. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
  106. package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
  107. package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
  108. package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
  109. package/template/src/modules/invitations/invitations.module.ts +12 -0
  110. package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
  111. package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
  112. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
  113. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
  114. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
  115. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
  116. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
  117. package/template/src/modules/memberships/memberships.module.ts +12 -0
  118. package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
  119. package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
  120. package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
  121. package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
  122. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
  123. package/template/src/modules/organisations/organisations.module.ts +12 -0
  124. package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
  125. package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
  126. package/template/src/modules/platform-admin/.gitkeep +1 -0
  127. package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
  128. package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
  129. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
  130. package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
  131. package/template/src/modules/request-context/request-context.module.ts +25 -0
  132. package/template/src/modules/request-context/types/request-context.ts +29 -0
  133. package/template/src/modules/sample/application/services/sample.service.ts +67 -0
  134. package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
  135. package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
  136. package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
  137. package/template/src/modules/sample/sample.module.ts +11 -0
  138. package/template/src/modules/settings/application/services/settings.service.ts +139 -0
  139. package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
  140. package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
  141. package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
  142. package/template/src/modules/settings/settings.module.ts +12 -0
  143. package/template/src/modules/settings/types/setting-definitions.ts +104 -0
  144. package/template/src/modules/users/.gitkeep +1 -0
  145. package/template/test/.gitkeep +1 -0
  146. package/template/test/jest-e2e.json +9 -0
  147. package/template/test/permission.guard.spec.ts +22 -0
  148. package/template/test/route-registry.validator.spec.ts +90 -0
  149. package/template/test/security.e2e-spec.ts +102 -0
  150. package/template/tsconfig.build.json +4 -0
  151. package/template/tsconfig.json +18 -0
@@ -0,0 +1,95 @@
1
+ import { randomBytes, createHash } from "node:crypto";
2
+ import { InternalServerErrorException, Injectable } from "@nestjs/common";
3
+ import { ConfigService } from "@nestjs/config";
4
+ import { JwtService } from "@nestjs/jwt";
5
+ import { Prisma } from "@prisma/client";
6
+ import { PrismaService } from "../../../../database/prisma/prisma.service";
7
+ import { JwtPayload } from "../../types/jwt-payload";
8
+
9
+ type TokenUser = {
10
+ id: string;
11
+ email: string | null;
12
+ mobile: string | null;
13
+ };
14
+
15
+ type RefreshTokenStore = Prisma.TransactionClient | PrismaService;
16
+
17
+ @Injectable()
18
+ export class TokenService {
19
+ constructor(
20
+ private readonly config: ConfigService,
21
+ private readonly jwt: JwtService,
22
+ private readonly prisma: PrismaService,
23
+ ) {}
24
+
25
+ async signAccessToken(user: TokenUser) {
26
+ const payload: JwtPayload = {
27
+ sub: user.id,
28
+ email: user.email,
29
+ mobile: user.mobile,
30
+ };
31
+
32
+ return this.jwt.signAsync(payload, {
33
+ expiresIn: this.getAccessTokenTtl() as never,
34
+ algorithm: "HS256",
35
+ issuer: this.config.get<string>("auth.jwt.issuer"),
36
+ audience: this.config.get<string>("auth.jwt.audience"),
37
+ });
38
+ }
39
+
40
+ async createRefreshToken(
41
+ userId: string,
42
+ client: RefreshTokenStore = this.prisma,
43
+ ) {
44
+ const refreshToken = randomBytes(48).toString("base64url");
45
+ const tokenHash = this.hashRefreshToken(refreshToken);
46
+ const expiresAt = this.getRefreshTokenExpiry();
47
+
48
+ const record = await client.refreshToken.create({
49
+ data: {
50
+ userId,
51
+ tokenHash,
52
+ expiresAt,
53
+ },
54
+ });
55
+
56
+ return {
57
+ refreshToken,
58
+ record,
59
+ };
60
+ }
61
+
62
+ hashRefreshToken(refreshToken: string) {
63
+ return createHash("sha256").update(refreshToken).digest("hex");
64
+ }
65
+
66
+ getAccessTokenTtl() {
67
+ return this.config.get<string>("auth.jwt.accessExpiresIn") ?? "15m";
68
+ }
69
+
70
+ private getRefreshTokenExpiry() {
71
+ const ttl = this.config.get<string>("auth.jwt.refreshExpiresIn") ?? "7d";
72
+ return new Date(Date.now() + parseDurationToMs(ttl));
73
+ }
74
+ }
75
+
76
+ function parseDurationToMs(value: string) {
77
+ const match = /^(\d+)([smhdw])$/.exec(value.trim());
78
+ if (!match) {
79
+ throw new InternalServerErrorException(
80
+ `Unsupported duration value: ${value}`,
81
+ );
82
+ }
83
+
84
+ const amount = Number(match[1]);
85
+ const unit = match[2];
86
+ const multipliers: Record<string, number> = {
87
+ s: 1000,
88
+ m: 60 * 1000,
89
+ h: 60 * 60 * 1000,
90
+ d: 24 * 60 * 60 * 1000,
91
+ w: 7 * 24 * 60 * 60 * 1000,
92
+ };
93
+
94
+ return amount * multipliers[unit];
95
+ }
@@ -0,0 +1,73 @@
1
+ import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
2
+ import { ConfigModule, ConfigService } from "@nestjs/config";
3
+ import { JwtModule } from "@nestjs/jwt";
4
+ import { PassportModule } from "@nestjs/passport";
5
+ import session = require("express-session");
6
+ import { AuthService } from "./application/services/auth.service";
7
+ import { PasswordService } from "./application/services/password.service";
8
+ import { TokenService } from "./application/services/token.service";
9
+ import { GoogleAuthGuard } from "./infrastructure/passport/google-auth.guard";
10
+ import { GoogleStrategy } from "./infrastructure/passport/google.strategy";
11
+ import { JwtAuthGuard } from "./infrastructure/passport/jwt-auth.guard";
12
+ import { JwtStrategy } from "./infrastructure/passport/jwt.strategy";
13
+ import { AuthController } from "./presentation/auth.controller";
14
+ import { GoogleOAuthExceptionFilter } from "./presentation/google-oauth-exception.filter";
15
+
16
+ @Module({
17
+ imports: [
18
+ PassportModule,
19
+ JwtModule.registerAsync({
20
+ imports: [ConfigModule],
21
+ inject: [ConfigService],
22
+ useFactory: (config: ConfigService) => ({
23
+ secret: config.get<string>("auth.jwt.secret") ?? "",
24
+ signOptions: {
25
+ algorithm: "HS256",
26
+ issuer: config.get<string>("auth.jwt.issuer"),
27
+ audience: config.get<string>("auth.jwt.audience"),
28
+ },
29
+ }),
30
+ }),
31
+ ],
32
+ controllers: [AuthController],
33
+ providers: [
34
+ AuthService,
35
+ GoogleAuthGuard,
36
+ GoogleOAuthExceptionFilter,
37
+ GoogleStrategy,
38
+ JwtAuthGuard,
39
+ JwtStrategy,
40
+ PasswordService,
41
+ TokenService,
42
+ ],
43
+ exports: [JwtAuthGuard, PasswordService],
44
+ })
45
+ export class AuthModule implements NestModule {
46
+ constructor(private readonly config: ConfigService) {}
47
+
48
+ configure(consumer: MiddlewareConsumer) {
49
+ consumer
50
+ .apply(buildGoogleOAuthSessionMiddleware(this.config))
51
+ .forRoutes("auth/google", "auth/google/callback");
52
+ }
53
+ }
54
+
55
+ function buildGoogleOAuthSessionMiddleware(config: ConfigService) {
56
+ const secret =
57
+ config.get<string>("auth.session.secret") ||
58
+ "google-oauth-disabled-session-secret";
59
+
60
+ return session({
61
+ secret,
62
+ name: "oauth.sid",
63
+ resave: false,
64
+ saveUninitialized: false,
65
+ cookie: {
66
+ httpOnly: true,
67
+ sameSite: "lax",
68
+ secure: config.get<string>("app.nodeEnv") === "production",
69
+ maxAge: 10 * 60 * 1000,
70
+ path: "/auth/google",
71
+ },
72
+ });
73
+ }
@@ -0,0 +1,29 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { ActiveUserSummaryDto } from "../../../common/dto/user-summary.dto";
3
+
4
+ export class AuthTokensResponseDto {
5
+ @ApiProperty({
6
+ description: "JWT access token for bearer authentication.",
7
+ example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
8
+ })
9
+ accessToken!: string;
10
+
11
+ @ApiProperty({
12
+ description:
13
+ "Opaque refresh token. Store it securely and send it only to auth endpoints.",
14
+ example: "rfr_9b2f4f8d2d9f4d65a1f4",
15
+ })
16
+ refreshToken!: string;
17
+
18
+ @ApiProperty({ example: "Bearer" })
19
+ tokenType!: "Bearer";
20
+
21
+ @ApiProperty({
22
+ description: "Access token lifetime in seconds.",
23
+ example: 900,
24
+ })
25
+ expiresIn!: number;
26
+
27
+ @ApiProperty({ type: ActiveUserSummaryDto })
28
+ user!: ActiveUserSummaryDto;
29
+ }
@@ -0,0 +1,15 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsEmail, IsString, MaxLength, MinLength } from "class-validator";
3
+
4
+ export class LoginDto {
5
+ @ApiProperty({ example: "owner@example.com" })
6
+ @IsEmail()
7
+ @MaxLength(320)
8
+ email!: string;
9
+
10
+ @ApiProperty({ example: "dev-password-123" })
11
+ @IsString()
12
+ @MinLength(8)
13
+ @MaxLength(128)
14
+ password!: string;
15
+ }
@@ -0,0 +1,3 @@
1
+ import { RefreshTokenDto } from "./refresh-token.dto";
2
+
3
+ export class LogoutDto extends RefreshTokenDto {}
@@ -0,0 +1,15 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsString, Length } from "class-validator";
3
+
4
+ export class OAuthExchangeDto {
5
+ @ApiProperty({
6
+ description:
7
+ "One-time code returned to the frontend by the Google OAuth callback redirect.",
8
+ example: "DXB2rcx5HcKXy35bA3AzYdVq2e4nGsgYeG3D9M7uAQM",
9
+ minLength: 32,
10
+ maxLength: 256,
11
+ })
12
+ @IsString()
13
+ @Length(32, 256)
14
+ code!: string;
15
+ }
@@ -0,0 +1,14 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsString, MinLength } from "class-validator";
3
+
4
+ export class RefreshTokenDto {
5
+ @ApiProperty({
6
+ description:
7
+ "Opaque refresh token returned by signup, login, refresh, or OAuth exchange.",
8
+ example: "rfr_9b2f4f8d2d9f4d65a1f4",
9
+ minLength: 32,
10
+ })
11
+ @IsString()
12
+ @MinLength(32)
13
+ refreshToken!: string;
14
+ }
@@ -0,0 +1,27 @@
1
+ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
2
+ import {
3
+ IsEmail,
4
+ IsOptional,
5
+ IsString,
6
+ MaxLength,
7
+ MinLength,
8
+ } from "class-validator";
9
+
10
+ export class SignupDto {
11
+ @ApiProperty({ example: "owner@example.com" })
12
+ @IsEmail()
13
+ @MaxLength(320)
14
+ email!: string;
15
+
16
+ @ApiProperty({ minLength: 8, maxLength: 128, example: "dev-password-123" })
17
+ @IsString()
18
+ @MinLength(8)
19
+ @MaxLength(128)
20
+ password!: string;
21
+
22
+ @ApiPropertyOptional({ example: "Starter Owner" })
23
+ @IsOptional()
24
+ @IsString()
25
+ @MaxLength(120)
26
+ displayName?: string;
27
+ }
@@ -0,0 +1,27 @@
1
+ import {
2
+ CanActivate,
3
+ ExecutionContext,
4
+ ForbiddenException,
5
+ Injectable,
6
+ } from "@nestjs/common";
7
+ import { ConfigService } from "@nestjs/config";
8
+ import { AuthGuard } from "@nestjs/passport";
9
+
10
+ @Injectable()
11
+ export class GoogleAuthGuard
12
+ extends AuthGuard("google")
13
+ implements CanActivate
14
+ {
15
+ constructor(private readonly config: ConfigService) {
16
+ super();
17
+ }
18
+
19
+ canActivate(context: ExecutionContext) {
20
+ const enabled = this.config.get<boolean>("auth.providers.googleEnabled");
21
+ if (!enabled) {
22
+ throw new ForbiddenException("Google authentication is disabled.");
23
+ }
24
+
25
+ return super.canActivate(context);
26
+ }
27
+ }
@@ -0,0 +1,56 @@
1
+ import { Injectable, UnauthorizedException } from "@nestjs/common";
2
+ import { ConfigService } from "@nestjs/config";
3
+ import { PassportStrategy } from "@nestjs/passport";
4
+ import { Profile, Strategy, VerifyCallback } from "passport-google-oauth20";
5
+ import { GoogleAuthProfile } from "../../types/google-auth-profile";
6
+
7
+ @Injectable()
8
+ export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
9
+ constructor(config: ConfigService) {
10
+ const enabled = config.get<boolean>("auth.providers.googleEnabled");
11
+
12
+ super({
13
+ clientID: enabled
14
+ ? (config.get<string>("auth.google.clientId") ?? "")
15
+ : "google-disabled",
16
+ clientSecret: enabled
17
+ ? (config.get<string>("auth.google.clientSecret") ?? "")
18
+ : "google-disabled",
19
+ callbackURL:
20
+ config.get<string>("auth.google.callbackUrl") ??
21
+ "http://localhost:3000/auth/google/callback",
22
+ state: true,
23
+ pkce: true,
24
+ scope: ["email", "profile"],
25
+ });
26
+ }
27
+
28
+ validate(
29
+ _accessToken: string,
30
+ _refreshToken: string,
31
+ profile: Profile,
32
+ done: VerifyCallback,
33
+ ) {
34
+ const email = profile.emails?.[0]?.value?.trim().toLowerCase();
35
+ if (!email) {
36
+ done(
37
+ new UnauthorizedException(
38
+ "Google account did not provide an email address.",
39
+ ),
40
+ );
41
+ return;
42
+ }
43
+
44
+ const profileJson = profile._json as
45
+ | { email_verified?: boolean }
46
+ | undefined;
47
+ const result: GoogleAuthProfile = {
48
+ providerUserId: profile.id,
49
+ email,
50
+ emailVerified: profileJson?.email_verified === true,
51
+ displayName: profile.displayName || null,
52
+ };
53
+
54
+ done(null, result);
55
+ }
56
+ }
@@ -0,0 +1,5 @@
1
+ import { Injectable } from "@nestjs/common";
2
+ import { AuthGuard } from "@nestjs/passport";
3
+
4
+ @Injectable()
5
+ export class JwtAuthGuard extends AuthGuard("jwt") {}
@@ -0,0 +1,43 @@
1
+ import { Injectable, UnauthorizedException } from "@nestjs/common";
2
+ import { ConfigService } from "@nestjs/config";
3
+ import { PassportStrategy } from "@nestjs/passport";
4
+ import { ExtractJwt, Strategy } from "passport-jwt";
5
+ import { PrismaService } from "../../../../database/prisma/prisma.service";
6
+ import { AuthenticatedUser } from "../../types/authenticated-user";
7
+ import { JwtPayload } from "../../types/jwt-payload";
8
+
9
+ @Injectable()
10
+ export class JwtStrategy extends PassportStrategy(Strategy) {
11
+ constructor(
12
+ private readonly prisma: PrismaService,
13
+ config: ConfigService,
14
+ ) {
15
+ super({
16
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
17
+ ignoreExpiration: false,
18
+ algorithms: ["HS256"],
19
+ issuer: config.get<string>("auth.jwt.issuer"),
20
+ audience: config.get<string>("auth.jwt.audience"),
21
+ secretOrKey: config.get<string>("auth.jwt.secret") ?? "",
22
+ });
23
+ }
24
+
25
+ async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
26
+ const user = await this.prisma.user.findUnique({
27
+ where: { id: payload.sub },
28
+ select: {
29
+ id: true,
30
+ email: true,
31
+ mobile: true,
32
+ displayName: true,
33
+ isActive: true,
34
+ },
35
+ });
36
+
37
+ if (!user?.isActive) {
38
+ throw new UnauthorizedException();
39
+ }
40
+
41
+ return user;
42
+ }
43
+ }
@@ -0,0 +1,148 @@
1
+ import {
2
+ Body,
3
+ Controller,
4
+ Get,
5
+ HttpCode,
6
+ Post,
7
+ Req,
8
+ Res,
9
+ UseFilters,
10
+ UseGuards,
11
+ } from "@nestjs/common";
12
+ import {
13
+ ApiBearerAuth,
14
+ ApiCreatedResponse,
15
+ ApiFoundResponse,
16
+ ApiOkResponse,
17
+ ApiOperation,
18
+ ApiTags,
19
+ } from "@nestjs/swagger";
20
+ import { Throttle } from "@nestjs/throttler";
21
+ import { RevokedResponseDto } from "../../../common/dto/mutation-response.dto";
22
+ import { ActiveUserSummaryDto } from "../../../common/dto/user-summary.dto";
23
+ import { ApiErrorResponses } from "../../../common/swagger/api-error-responses";
24
+ import { AuthService } from "../application/services/auth.service";
25
+ import { AuthTokensResponseDto } from "../dto/auth-response.dto";
26
+ import { LoginDto } from "../dto/login.dto";
27
+ import { LogoutDto } from "../dto/logout.dto";
28
+ import { OAuthExchangeDto } from "../dto/oauth-exchange.dto";
29
+ import { RefreshTokenDto } from "../dto/refresh-token.dto";
30
+ import { SignupDto } from "../dto/signup.dto";
31
+ import { GoogleAuthGuard } from "../infrastructure/passport/google-auth.guard";
32
+ import { JwtAuthGuard } from "../infrastructure/passport/jwt-auth.guard";
33
+ import { AuthenticatedUser } from "../types/authenticated-user";
34
+ import { GoogleAuthProfile } from "../types/google-auth-profile";
35
+ import { CurrentUser } from "./current-user.decorator";
36
+ import { GoogleOAuthExceptionFilter } from "./google-oauth-exception.filter";
37
+
38
+ @ApiTags("Auth")
39
+ @Controller("auth")
40
+ export class AuthController {
41
+ constructor(private readonly authService: AuthService) {}
42
+
43
+ @Post("signup")
44
+ @Throttle({ default: { limit: 5, ttl: 60_000 } })
45
+ @ApiOperation({ summary: "Create an email/password user and issue tokens." })
46
+ @ApiCreatedResponse({
47
+ description: "User created and tokens issued.",
48
+ type: AuthTokensResponseDto,
49
+ })
50
+ @ApiErrorResponses(400, 409, 429)
51
+ signup(@Body() dto: SignupDto) {
52
+ return this.authService.signup(dto);
53
+ }
54
+
55
+ @Post("login")
56
+ @HttpCode(200)
57
+ @Throttle({ default: { limit: 5, ttl: 60_000 } })
58
+ @ApiOperation({ summary: "Login with email/password." })
59
+ @ApiOkResponse({
60
+ description: "Login succeeded and tokens were issued.",
61
+ type: AuthTokensResponseDto,
62
+ })
63
+ @ApiErrorResponses(400, 401, 429)
64
+ login(@Body() dto: LoginDto) {
65
+ return this.authService.login(dto);
66
+ }
67
+
68
+ @Post("refresh")
69
+ @HttpCode(200)
70
+ @Throttle({ default: { limit: 10, ttl: 60_000 } })
71
+ @ApiOperation({
72
+ summary: "Rotate a refresh token and issue a new access token.",
73
+ })
74
+ @ApiOkResponse({
75
+ description: "Refresh token rotated.",
76
+ type: AuthTokensResponseDto,
77
+ })
78
+ @ApiErrorResponses(400, 401, 429)
79
+ refresh(@Body() dto: RefreshTokenDto) {
80
+ return this.authService.refresh(dto);
81
+ }
82
+
83
+ @Post("oauth/exchange")
84
+ @HttpCode(200)
85
+ @Throttle({ default: { limit: 10, ttl: 60_000 } })
86
+ @ApiOperation({
87
+ summary: "Exchange a Google OAuth one-time code for tokens.",
88
+ })
89
+ @ApiOkResponse({
90
+ description: "OAuth exchange succeeded.",
91
+ type: AuthTokensResponseDto,
92
+ })
93
+ @ApiErrorResponses(400, 401, 429)
94
+ exchangeOAuthCode(@Body() dto: OAuthExchangeDto) {
95
+ return this.authService.exchangeOAuthCode(dto);
96
+ }
97
+
98
+ @Post("logout")
99
+ @HttpCode(200)
100
+ @ApiOperation({ summary: "Revoke a refresh token." })
101
+ @ApiOkResponse({
102
+ description: "Refresh token is revoked or already unusable.",
103
+ type: RevokedResponseDto,
104
+ })
105
+ @ApiErrorResponses(400)
106
+ logout(@Body() dto: LogoutDto) {
107
+ return this.authService.logout(dto);
108
+ }
109
+
110
+ @Get("google")
111
+ @UseFilters(GoogleOAuthExceptionFilter)
112
+ @UseGuards(GoogleAuthGuard)
113
+ @ApiOperation({ summary: "Start the Google OAuth login flow." })
114
+ @ApiFoundResponse({ description: "Redirects the browser to Google OAuth." })
115
+ @ApiErrorResponses(403)
116
+ google() {
117
+ return undefined;
118
+ }
119
+
120
+ @Get("google/callback")
121
+ @UseFilters(GoogleOAuthExceptionFilter)
122
+ @UseGuards(GoogleAuthGuard)
123
+ @ApiOperation({ summary: "Handle the Google OAuth callback." })
124
+ @ApiFoundResponse({
125
+ description: "Redirects to the configured frontend success or error URL.",
126
+ })
127
+ @ApiErrorResponses(401, 403)
128
+ async googleCallback(
129
+ @Req() request: { user: GoogleAuthProfile },
130
+ @Res() response: { redirect: (status: number, url: string) => void },
131
+ ) {
132
+ const redirectUrl = await this.authService.googleLogin(request.user);
133
+ response.redirect(302, redirectUrl);
134
+ }
135
+
136
+ @Get("me")
137
+ @UseGuards(JwtAuthGuard)
138
+ @ApiBearerAuth()
139
+ @ApiOperation({ summary: "Return the current JWT identity." })
140
+ @ApiOkResponse({
141
+ description: "Current authenticated user.",
142
+ type: ActiveUserSummaryDto,
143
+ })
144
+ @ApiErrorResponses(401)
145
+ me(@CurrentUser() user: AuthenticatedUser) {
146
+ return this.authService.me(user);
147
+ }
148
+ }
@@ -0,0 +1,11 @@
1
+ import { createParamDecorator, ExecutionContext } from "@nestjs/common";
2
+ import { AuthenticatedUser } from "../types/authenticated-user";
3
+
4
+ export const CurrentUser = createParamDecorator(
5
+ (_data: unknown, context: ExecutionContext): AuthenticatedUser => {
6
+ const request = context
7
+ .switchToHttp()
8
+ .getRequest<{ user: AuthenticatedUser }>();
9
+ return request.user;
10
+ },
11
+ );
@@ -0,0 +1,33 @@
1
+ import {
2
+ ArgumentsHost,
3
+ Catch,
4
+ ExceptionFilter,
5
+ Injectable,
6
+ } from "@nestjs/common";
7
+ import { ConfigService } from "@nestjs/config";
8
+
9
+ type RedirectResponse = {
10
+ headersSent?: boolean;
11
+ redirect: (status: number, url: string) => void;
12
+ };
13
+
14
+ @Injectable()
15
+ @Catch()
16
+ export class GoogleOAuthExceptionFilter implements ExceptionFilter {
17
+ constructor(private readonly config: ConfigService) {}
18
+
19
+ catch(_exception: unknown, host: ArgumentsHost) {
20
+ const response = host.switchToHttp().getResponse<RedirectResponse>();
21
+ if (response.headersSent) {
22
+ return;
23
+ }
24
+
25
+ const redirectUrl = new URL(
26
+ this.config.get<string>("auth.google.errorRedirectUrl") ??
27
+ "http://localhost:3000/auth/google/error",
28
+ );
29
+ redirectUrl.searchParams.set("error", "oauth_failed");
30
+
31
+ response.redirect(302, redirectUrl.toString());
32
+ }
33
+ }
@@ -0,0 +1,7 @@
1
+ export type AuthenticatedUser = {
2
+ id: string;
3
+ email: string | null;
4
+ mobile: string | null;
5
+ displayName: string | null;
6
+ isActive: boolean;
7
+ };
@@ -0,0 +1,6 @@
1
+ export type GoogleAuthProfile = {
2
+ providerUserId: string;
3
+ email: string;
4
+ emailVerified: boolean;
5
+ displayName: string | null;
6
+ };
@@ -0,0 +1,5 @@
1
+ export type JwtPayload = {
2
+ sub: string;
3
+ email?: string | null;
4
+ mobile?: string | null;
5
+ };
@@ -0,0 +1,9 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+
3
+ export class HealthResponseDto {
4
+ @ApiProperty({ example: "ok" })
5
+ status!: "ok";
6
+
7
+ @ApiProperty({ example: "ok" })
8
+ db!: "ok";
9
+ }
@@ -0,0 +1,7 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { HealthController } from "./presentation/health.controller";
3
+
4
+ @Module({
5
+ controllers: [HealthController],
6
+ })
7
+ export class HealthModule {}