@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.
- package/LICENSE +144 -0
- package/bin/index.mjs +2 -0
- package/package.json +36 -0
- package/src/copy.mjs +45 -0
- package/src/log.mjs +23 -0
- package/src/main.mjs +221 -0
- package/src/run.mjs +52 -0
- package/src/substitute.mjs +40 -0
- package/template/.env.example +36 -0
- package/template/.github/workflows/ci.yml +36 -0
- package/template/.husky/pre-commit +1 -0
- package/template/README.md +146 -0
- package/template/_editorconfig +8 -0
- package/template/_gitignore +7 -0
- package/template/_nvmrc +1 -0
- package/template/_package.json +107 -0
- package/template/_prettierignore +5 -0
- package/template/_prettierrc +6 -0
- package/template/docs/API_REFERENCE.md +123 -0
- package/template/docs/GETTING_STARTED.md +65 -0
- package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
- package/template/docs/OAUTH.md +46 -0
- package/template/docs/SAMPLE_MODULE.md +23 -0
- package/template/docs/api.http +269 -0
- package/template/eslint.config.mjs +51 -0
- package/template/nest-cli.json +8 -0
- package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
- package/template/prisma/schema.prisma +299 -0
- package/template/prisma/seed.ts +44 -0
- package/template/scripts/db-create.mjs +126 -0
- package/template/scripts/gen-module.mjs +217 -0
- package/template/scripts/seed-test-user-org.ts +264 -0
- package/template/scripts/setup-local.mjs +224 -0
- package/template/scripts/test-db.mjs +69 -0
- package/template/src/app.module.ts +58 -0
- package/template/src/common/decorators/.gitkeep +1 -0
- package/template/src/common/dto/error-response.dto.ts +17 -0
- package/template/src/common/dto/membership-response.dto.ts +51 -0
- package/template/src/common/dto/mutation-response.dto.ts +11 -0
- package/template/src/common/dto/role-summary.dto.ts +18 -0
- package/template/src/common/dto/user-summary.dto.ts +23 -0
- package/template/src/common/enums/.gitkeep +1 -0
- package/template/src/common/filters/.gitkeep +1 -0
- package/template/src/common/filters/http-exception.filter.ts +78 -0
- package/template/src/common/guards/.gitkeep +1 -0
- package/template/src/common/interceptors/.gitkeep +1 -0
- package/template/src/common/pipes/.gitkeep +1 -0
- package/template/src/common/swagger/api-error-responses.ts +54 -0
- package/template/src/common/types/.gitkeep +1 -0
- package/template/src/config/app.config.ts +7 -0
- package/template/src/config/auth.config.ts +33 -0
- package/template/src/config/database.config.ts +6 -0
- package/template/src/config/env.validation.ts +131 -0
- package/template/src/config/index.ts +5 -0
- package/template/src/config/rbac.config.ts +6 -0
- package/template/src/database/prisma/prisma-transaction.ts +22 -0
- package/template/src/database/prisma/prisma.module.ts +9 -0
- package/template/src/database/prisma/prisma.service.ts +16 -0
- package/template/src/main.ts +42 -0
- package/template/src/modules/access-control/access-control.module.ts +24 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
- package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
- package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
- package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
- package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
- package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
- package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
- package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
- package/template/src/modules/access-control/types/permission-key.ts +37 -0
- package/template/src/modules/access-control/types/rbac-context.ts +11 -0
- package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
- package/template/src/modules/audit/application/services/audit.service.ts +97 -0
- package/template/src/modules/audit/audit.module.ts +13 -0
- package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
- package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
- package/template/src/modules/auth/application/services/auth.service.ts +509 -0
- package/template/src/modules/auth/application/services/password.service.ts +15 -0
- package/template/src/modules/auth/application/services/token.service.ts +95 -0
- package/template/src/modules/auth/auth.module.ts +73 -0
- package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
- package/template/src/modules/auth/dto/login.dto.ts +15 -0
- package/template/src/modules/auth/dto/logout.dto.ts +3 -0
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
- package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
- package/template/src/modules/auth/dto/signup.dto.ts +27 -0
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
- package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
- package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
- package/template/src/modules/auth/types/authenticated-user.ts +7 -0
- package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
- package/template/src/modules/auth/types/jwt-payload.ts +5 -0
- package/template/src/modules/health/dto/health-response.dto.ts +9 -0
- package/template/src/modules/health/health.module.ts +7 -0
- package/template/src/modules/health/presentation/health.controller.ts +33 -0
- package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
- package/template/src/modules/invitations/invitations.module.ts +12 -0
- package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
- package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
- package/template/src/modules/memberships/memberships.module.ts +12 -0
- package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
- package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
- package/template/src/modules/organisations/organisations.module.ts +12 -0
- package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
- package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
- package/template/src/modules/platform-admin/.gitkeep +1 -0
- package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
- package/template/src/modules/request-context/request-context.module.ts +25 -0
- package/template/src/modules/request-context/types/request-context.ts +29 -0
- package/template/src/modules/sample/application/services/sample.service.ts +67 -0
- package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
- package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
- package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
- package/template/src/modules/sample/sample.module.ts +11 -0
- package/template/src/modules/settings/application/services/settings.service.ts +139 -0
- package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
- package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
- package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
- package/template/src/modules/settings/settings.module.ts +12 -0
- package/template/src/modules/settings/types/setting-definitions.ts +104 -0
- package/template/src/modules/users/.gitkeep +1 -0
- package/template/test/.gitkeep +1 -0
- package/template/test/jest-e2e.json +9 -0
- package/template/test/permission.guard.spec.ts +22 -0
- package/template/test/route-registry.validator.spec.ts +90 -0
- package/template/test/security.e2e-spec.ts +102 -0
- package/template/tsconfig.build.json +4 -0
- package/template/tsconfig.json +18 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|
2
|
+
|
|
3
|
+
export class RoleSummaryDto {
|
|
4
|
+
@ApiProperty({
|
|
5
|
+
example: "f602c057-04f4-4ef8-8c84-1b7c62fbf8c5",
|
|
6
|
+
format: "uuid",
|
|
7
|
+
})
|
|
8
|
+
id!: string;
|
|
9
|
+
|
|
10
|
+
@ApiProperty({ example: "Owner" })
|
|
11
|
+
name!: string;
|
|
12
|
+
|
|
13
|
+
@ApiPropertyOptional({ example: "Full organisation access.", nullable: true })
|
|
14
|
+
description?: string | null;
|
|
15
|
+
|
|
16
|
+
@ApiPropertyOptional({ example: true })
|
|
17
|
+
isSystemSeeded?: boolean;
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|
2
|
+
|
|
3
|
+
export class UserSummaryDto {
|
|
4
|
+
@ApiProperty({
|
|
5
|
+
example: "4a4f0d8a-4bd2-469f-a6a9-3e1cb6a2b456",
|
|
6
|
+
format: "uuid",
|
|
7
|
+
})
|
|
8
|
+
id!: string;
|
|
9
|
+
|
|
10
|
+
@ApiPropertyOptional({ example: "owner@example.com", nullable: true })
|
|
11
|
+
email?: string | null;
|
|
12
|
+
|
|
13
|
+
@ApiPropertyOptional({ example: "+14155552671", nullable: true })
|
|
14
|
+
mobile?: string | null;
|
|
15
|
+
|
|
16
|
+
@ApiPropertyOptional({ example: "Starter Owner", nullable: true })
|
|
17
|
+
displayName?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ActiveUserSummaryDto extends UserSummaryDto {
|
|
21
|
+
@ApiProperty({ example: true })
|
|
22
|
+
isActive!: boolean;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentsHost,
|
|
3
|
+
Catch,
|
|
4
|
+
ExceptionFilter,
|
|
5
|
+
HttpException,
|
|
6
|
+
HttpStatus,
|
|
7
|
+
} from "@nestjs/common";
|
|
8
|
+
|
|
9
|
+
type HttpResponse = {
|
|
10
|
+
status: (statusCode: number) => {
|
|
11
|
+
json: (body: unknown) => void;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
@Catch()
|
|
16
|
+
export class HttpExceptionFilter implements ExceptionFilter {
|
|
17
|
+
catch(exception: unknown, host: ArgumentsHost) {
|
|
18
|
+
const response = host.switchToHttp().getResponse<HttpResponse>();
|
|
19
|
+
const status =
|
|
20
|
+
exception instanceof HttpException
|
|
21
|
+
? exception.getStatus()
|
|
22
|
+
: HttpStatus.INTERNAL_SERVER_ERROR;
|
|
23
|
+
|
|
24
|
+
response.status(status).json({
|
|
25
|
+
error: {
|
|
26
|
+
code: errorCode(status),
|
|
27
|
+
message: errorMessage(exception),
|
|
28
|
+
details: errorDetails(exception),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function errorCode(status: number) {
|
|
35
|
+
return HttpStatus[status] ?? "ERROR";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function errorMessage(exception: unknown) {
|
|
39
|
+
if (!(exception instanceof HttpException)) {
|
|
40
|
+
return "Internal server error.";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body = exception.getResponse();
|
|
44
|
+
if (typeof body === "string") {
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isObject(body) && typeof body.message === "string") {
|
|
49
|
+
return body.message;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (isObject(body) && Array.isArray(body.message)) {
|
|
53
|
+
return body.message.join("; ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return exception.message;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function errorDetails(exception: unknown) {
|
|
60
|
+
if (!(exception instanceof HttpException)) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const body = exception.getResponse();
|
|
65
|
+
if (!isObject(body)) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return Object.fromEntries(
|
|
70
|
+
Object.entries(body).filter(
|
|
71
|
+
([key]) => !["statusCode", "error", "message"].includes(key),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null;
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { applyDecorators } from "@nestjs/common";
|
|
2
|
+
import {
|
|
3
|
+
ApiBadRequestResponse,
|
|
4
|
+
ApiConflictResponse,
|
|
5
|
+
ApiForbiddenResponse,
|
|
6
|
+
ApiGoneResponse,
|
|
7
|
+
ApiInternalServerErrorResponse,
|
|
8
|
+
ApiNotFoundResponse,
|
|
9
|
+
ApiServiceUnavailableResponse,
|
|
10
|
+
ApiTooManyRequestsResponse,
|
|
11
|
+
ApiUnauthorizedResponse,
|
|
12
|
+
} from "@nestjs/swagger";
|
|
13
|
+
import { ErrorResponseDto } from "../dto/error-response.dto";
|
|
14
|
+
|
|
15
|
+
type ApiErrorStatus = 400 | 401 | 403 | 404 | 409 | 410 | 429 | 500 | 503;
|
|
16
|
+
|
|
17
|
+
const errorResponseFactories = {
|
|
18
|
+
400: ApiBadRequestResponse,
|
|
19
|
+
401: ApiUnauthorizedResponse,
|
|
20
|
+
403: ApiForbiddenResponse,
|
|
21
|
+
404: ApiNotFoundResponse,
|
|
22
|
+
409: ApiConflictResponse,
|
|
23
|
+
410: ApiGoneResponse,
|
|
24
|
+
429: ApiTooManyRequestsResponse,
|
|
25
|
+
500: ApiInternalServerErrorResponse,
|
|
26
|
+
503: ApiServiceUnavailableResponse,
|
|
27
|
+
} satisfies Record<ApiErrorStatus, typeof ApiBadRequestResponse>;
|
|
28
|
+
|
|
29
|
+
const descriptions: Record<ApiErrorStatus, string> = {
|
|
30
|
+
400: "The request body, route parameter, or query string is invalid.",
|
|
31
|
+
401: "Authentication failed or the bearer token is missing.",
|
|
32
|
+
403: "The authenticated user does not have access to this organisation or permission.",
|
|
33
|
+
404: "The requested resource was not found.",
|
|
34
|
+
409: "The request conflicts with current resource state.",
|
|
35
|
+
410: "The resource is no longer available.",
|
|
36
|
+
429: "Too many requests.",
|
|
37
|
+
500: "Unexpected server error.",
|
|
38
|
+
503: "The service is temporarily unavailable.",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function ApiErrorResponses(...statuses: ApiErrorStatus[]) {
|
|
42
|
+
return applyDecorators(
|
|
43
|
+
...statuses.map((status) =>
|
|
44
|
+
errorResponseFactories[status]({
|
|
45
|
+
description: descriptions[status],
|
|
46
|
+
type: ErrorResponseDto,
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ApiProtectedErrorResponses(...extraStatuses: ApiErrorStatus[]) {
|
|
53
|
+
return ApiErrorResponses(400, 401, 403, ...extraStatuses);
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { registerAs } from "@nestjs/config";
|
|
2
|
+
import { getEnv } from "./env.validation";
|
|
3
|
+
|
|
4
|
+
export default registerAs("auth", () => {
|
|
5
|
+
const env = getEnv();
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
jwt: {
|
|
9
|
+
secret: env.JWT_SECRET,
|
|
10
|
+
accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
|
|
11
|
+
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
|
|
12
|
+
issuer: env.JWT_ISSUER,
|
|
13
|
+
audience: env.JWT_AUDIENCE,
|
|
14
|
+
},
|
|
15
|
+
providers: {
|
|
16
|
+
emailPasswordEnabled: env.AUTH_EMAIL_PASSWORD_ENABLED,
|
|
17
|
+
googleEnabled: env.AUTH_GOOGLE_ENABLED,
|
|
18
|
+
facebookEnabled: env.AUTH_FACEBOOK_ENABLED,
|
|
19
|
+
mobileOtpEnabled: env.AUTH_MOBILE_OTP_ENABLED,
|
|
20
|
+
magicLinkEnabled: env.AUTH_MAGIC_LINK_ENABLED,
|
|
21
|
+
},
|
|
22
|
+
google: {
|
|
23
|
+
clientId: env.AUTH_GOOGLE_CLIENT_ID,
|
|
24
|
+
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
|
|
25
|
+
callbackUrl: env.AUTH_GOOGLE_CALLBACK_URL,
|
|
26
|
+
successRedirectUrl: env.AUTH_GOOGLE_SUCCESS_REDIRECT_URL,
|
|
27
|
+
errorRedirectUrl: env.AUTH_GOOGLE_ERROR_REDIRECT_URL,
|
|
28
|
+
},
|
|
29
|
+
session: {
|
|
30
|
+
secret: env.SESSION_SECRET,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const booleanFromEnv = z.preprocess((value) => {
|
|
4
|
+
if (typeof value === "boolean") {
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (typeof value === "string") {
|
|
9
|
+
const normalized = value.trim().toLowerCase();
|
|
10
|
+
if (normalized === "true") {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (normalized === "false") {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return value;
|
|
20
|
+
}, z.boolean());
|
|
21
|
+
|
|
22
|
+
const durationSchema = z
|
|
23
|
+
.string()
|
|
24
|
+
.trim()
|
|
25
|
+
.regex(/^\d+[smhdw]$/, "duration must use s, m, h, d, or w units");
|
|
26
|
+
|
|
27
|
+
export const envSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
NODE_ENV: z
|
|
30
|
+
.enum(["development", "test", "production"])
|
|
31
|
+
.default("development"),
|
|
32
|
+
PORT: z.coerce.number().int().positive().default(3000),
|
|
33
|
+
DATABASE_URL: z
|
|
34
|
+
.string()
|
|
35
|
+
.trim()
|
|
36
|
+
.min(1, "DATABASE_URL is required")
|
|
37
|
+
.url("DATABASE_URL must be a valid URL"),
|
|
38
|
+
JWT_SECRET: z
|
|
39
|
+
.string()
|
|
40
|
+
.trim()
|
|
41
|
+
.min(16, "JWT_SECRET must be at least 16 characters"),
|
|
42
|
+
JWT_ACCESS_EXPIRES_IN: durationSchema.default("15m"),
|
|
43
|
+
JWT_REFRESH_EXPIRES_IN: durationSchema.default("7d"),
|
|
44
|
+
JWT_ISSUER: z.string().trim().min(1).default("foundation-starter"),
|
|
45
|
+
JWT_AUDIENCE: z.string().trim().min(1).default("foundation-api"),
|
|
46
|
+
AUTH_EMAIL_PASSWORD_ENABLED: booleanFromEnv.default(true),
|
|
47
|
+
AUTH_GOOGLE_ENABLED: booleanFromEnv.default(false),
|
|
48
|
+
AUTH_GOOGLE_CLIENT_ID: z.string().trim().optional().default(""),
|
|
49
|
+
AUTH_GOOGLE_CLIENT_SECRET: z.string().trim().optional().default(""),
|
|
50
|
+
AUTH_GOOGLE_CALLBACK_URL: z
|
|
51
|
+
.string()
|
|
52
|
+
.trim()
|
|
53
|
+
.url()
|
|
54
|
+
.default("http://localhost:3000/auth/google/callback"),
|
|
55
|
+
AUTH_GOOGLE_SUCCESS_REDIRECT_URL: z
|
|
56
|
+
.string()
|
|
57
|
+
.trim()
|
|
58
|
+
.url()
|
|
59
|
+
.default("http://localhost:3000/auth/google/success"),
|
|
60
|
+
AUTH_GOOGLE_ERROR_REDIRECT_URL: z
|
|
61
|
+
.string()
|
|
62
|
+
.trim()
|
|
63
|
+
.url()
|
|
64
|
+
.default("http://localhost:3000/auth/google/error"),
|
|
65
|
+
SESSION_SECRET: z.string().trim().optional().default(""),
|
|
66
|
+
AUTH_FACEBOOK_ENABLED: booleanFromEnv.default(false),
|
|
67
|
+
AUTH_MOBILE_OTP_ENABLED: booleanFromEnv.default(false),
|
|
68
|
+
AUTH_MAGIC_LINK_ENABLED: booleanFromEnv.default(false),
|
|
69
|
+
RBAC_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(60),
|
|
70
|
+
ORG_CONTEXT_MODE: z.literal("path").default("path"),
|
|
71
|
+
})
|
|
72
|
+
.superRefine((env, ctx) => {
|
|
73
|
+
if (!env.AUTH_GOOGLE_ENABLED) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!env.AUTH_GOOGLE_CLIENT_ID) {
|
|
78
|
+
ctx.addIssue({
|
|
79
|
+
code: "custom",
|
|
80
|
+
path: ["AUTH_GOOGLE_CLIENT_ID"],
|
|
81
|
+
message:
|
|
82
|
+
"AUTH_GOOGLE_CLIENT_ID is required when AUTH_GOOGLE_ENABLED=true",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!env.AUTH_GOOGLE_CLIENT_SECRET) {
|
|
87
|
+
ctx.addIssue({
|
|
88
|
+
code: "custom",
|
|
89
|
+
path: ["AUTH_GOOGLE_CLIENT_SECRET"],
|
|
90
|
+
message:
|
|
91
|
+
"AUTH_GOOGLE_CLIENT_SECRET is required when AUTH_GOOGLE_ENABLED=true",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (env.SESSION_SECRET.length < 32) {
|
|
96
|
+
ctx.addIssue({
|
|
97
|
+
code: "custom",
|
|
98
|
+
path: ["SESSION_SECRET"],
|
|
99
|
+
message:
|
|
100
|
+
"SESSION_SECRET must be at least 32 characters when AUTH_GOOGLE_ENABLED=true",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export type Env = z.infer<typeof envSchema>;
|
|
106
|
+
|
|
107
|
+
let validatedEnv: Env | undefined;
|
|
108
|
+
|
|
109
|
+
function parseEnv(config: Record<string, unknown>): Env {
|
|
110
|
+
const parsed = envSchema.safeParse(config);
|
|
111
|
+
|
|
112
|
+
if (parsed.success) {
|
|
113
|
+
return parsed.data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const details = parsed.error.issues
|
|
117
|
+
.map((issue) => `- ${issue.path.join(".") || "env"}: ${issue.message}`)
|
|
118
|
+
.join("\n");
|
|
119
|
+
|
|
120
|
+
throw new Error(`Environment validation failed:\n${details}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function validate(config: Record<string, unknown>): Env {
|
|
124
|
+
validatedEnv = parseEnv(config);
|
|
125
|
+
return validatedEnv;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getEnv(): Env {
|
|
129
|
+
validatedEnv ??= parseEnv(process.env);
|
|
130
|
+
return validatedEnv;
|
|
131
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as appConfig } from "./app.config";
|
|
2
|
+
export { default as authConfig } from "./auth.config";
|
|
3
|
+
export { default as databaseConfig } from "./database.config";
|
|
4
|
+
export { default as rbacConfig } from "./rbac.config";
|
|
5
|
+
export { validate } from "./env.validation";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Prisma } from "@prisma/client";
|
|
2
|
+
|
|
3
|
+
type TransactionOptions = {
|
|
4
|
+
maxWait?: number;
|
|
5
|
+
timeout?: number;
|
|
6
|
+
isolationLevel?: Prisma.TransactionIsolationLevel;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type TransactionCapablePrisma = {
|
|
10
|
+
$transaction<T>(
|
|
11
|
+
fn: (tx: Prisma.TransactionClient) => Promise<T>,
|
|
12
|
+
options?: TransactionOptions,
|
|
13
|
+
): Promise<T>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function runInTransaction<T>(
|
|
17
|
+
prisma: TransactionCapablePrisma,
|
|
18
|
+
fn: (tx: Prisma.TransactionClient) => Promise<T>,
|
|
19
|
+
options?: TransactionOptions,
|
|
20
|
+
) {
|
|
21
|
+
return prisma.$transaction(fn, options);
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
|
2
|
+
import { PrismaClient } from "@prisma/client";
|
|
3
|
+
|
|
4
|
+
@Injectable()
|
|
5
|
+
export class PrismaService
|
|
6
|
+
extends PrismaClient
|
|
7
|
+
implements OnModuleInit, OnModuleDestroy
|
|
8
|
+
{
|
|
9
|
+
async onModuleInit() {
|
|
10
|
+
await this.$connect();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async onModuleDestroy() {
|
|
14
|
+
await this.$disconnect();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ValidationPipe } from "@nestjs/common";
|
|
2
|
+
import { ConfigService } from "@nestjs/config";
|
|
3
|
+
import { NestFactory } from "@nestjs/core";
|
|
4
|
+
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
|
5
|
+
import { AppModule } from "./app.module";
|
|
6
|
+
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
|
7
|
+
|
|
8
|
+
async function bootstrap() {
|
|
9
|
+
const app = await NestFactory.create(AppModule);
|
|
10
|
+
const config = app.get(ConfigService);
|
|
11
|
+
app.enableShutdownHooks();
|
|
12
|
+
app.useGlobalFilters(new HttpExceptionFilter());
|
|
13
|
+
|
|
14
|
+
app.useGlobalPipes(
|
|
15
|
+
new ValidationPipe({
|
|
16
|
+
forbidNonWhitelisted: true,
|
|
17
|
+
transform: true,
|
|
18
|
+
whitelist: true,
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const document = SwaggerModule.createDocument(
|
|
23
|
+
app,
|
|
24
|
+
new DocumentBuilder()
|
|
25
|
+
.setTitle("Foundation Starter API")
|
|
26
|
+
.setDescription(
|
|
27
|
+
"Modular-monolith starter API with auth, organisations, RBAC, audit, and settings.",
|
|
28
|
+
)
|
|
29
|
+
.setVersion("0.1.0")
|
|
30
|
+
.addBearerAuth()
|
|
31
|
+
.build(),
|
|
32
|
+
);
|
|
33
|
+
SwaggerModule.setup("docs", app, document, {
|
|
34
|
+
swaggerOptions: {
|
|
35
|
+
persistAuthorization: true,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await app.listen(config.get<number>("app.port") ?? 3000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
void bootstrap();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Global, Module } from "@nestjs/common";
|
|
2
|
+
import { DiscoveryModule } from "@nestjs/core";
|
|
3
|
+
import { AuthModule } from "../auth/auth.module";
|
|
4
|
+
import { RouteRegistryValidator } from "./application/route-registry.validator";
|
|
5
|
+
import { AbilityFactory } from "./application/services/ability.factory";
|
|
6
|
+
import { AccessControlService } from "./application/services/access-control.service";
|
|
7
|
+
import { PermissionGuard } from "./application/services/permission.guard";
|
|
8
|
+
import { RbacCacheService } from "./application/services/rbac-cache.service";
|
|
9
|
+
import { AccessControlController } from "./presentation/access-control.controller";
|
|
10
|
+
|
|
11
|
+
@Global()
|
|
12
|
+
@Module({
|
|
13
|
+
imports: [AuthModule, DiscoveryModule],
|
|
14
|
+
controllers: [AccessControlController],
|
|
15
|
+
providers: [
|
|
16
|
+
AbilityFactory,
|
|
17
|
+
AccessControlService,
|
|
18
|
+
PermissionGuard,
|
|
19
|
+
RbacCacheService,
|
|
20
|
+
RouteRegistryValidator,
|
|
21
|
+
],
|
|
22
|
+
exports: [AbilityFactory, PermissionGuard, RbacCacheService],
|
|
23
|
+
})
|
|
24
|
+
export class AccessControlModule {}
|