@stjoseph/prumo 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +130 -0
- package/.prumo-templates/api/config.md +85 -0
- package/.prumo-templates/api/errors.md +82 -0
- package/.prumo-templates/api/logging.md +79 -0
- package/.prumo-templates/api/modules.md +117 -0
- package/.prumo-templates/api/observability.md +91 -0
- package/.prumo-templates/api/pagination.md +71 -0
- package/.prumo-templates/api/routes.md +89 -0
- package/.prumo-templates/client/data.md +124 -0
- package/.prumo-templates/client/forms.md +88 -0
- package/.prumo-templates/core/conventions.md +74 -0
- package/.prumo-templates/core/testing.md +100 -0
- package/.prumo-templates/core/tooling.md +118 -0
- package/.prumo-templates/database/entities.md +97 -0
- package/.prumo-templates/database/migrations.md +74 -0
- package/.prumo-templates/database/queries.md +79 -0
- package/.prumo-templates/database/testing.md +82 -0
- package/.prumo-templates/database/transactions.md +68 -0
- package/.prumo-templates/mobile/components.md +83 -0
- package/.prumo-templates/mobile/config.md +84 -0
- package/.prumo-templates/mobile/routing.md +74 -0
- package/.prumo-templates/mobile/storage.md +84 -0
- package/.prumo-templates/mobile/structure.md +79 -0
- package/.prumo-templates/monorepo/dependencies.md +84 -0
- package/.prumo-templates/monorepo/layout.md +89 -0
- package/.prumo-templates/monorepo/tasks.md +76 -0
- package/.prumo-templates/multi-tenancy/client.md +83 -0
- package/.prumo-templates/multi-tenancy/isolation.md +104 -0
- package/.prumo-templates/site/rendering.md +83 -0
- package/.prumo-templates/site/structure.md +68 -0
- package/.prumo-templates/web/components.md +106 -0
- package/.prumo-templates/web/routing.md +82 -0
- package/.prumo-templates/web/structure.md +92 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/asker.js +28 -0
- package/dist/cli.js +62 -0
- package/dist/compose.js +222 -0
- package/dist/context.js +38 -0
- package/dist/generate.js +60 -0
- package/dist/jsonc.js +76 -0
- package/dist/names.js +10 -0
- package/dist/questions.js +63 -0
- package/package.json +55 -0
- package/templates/api/.env.example +12 -0
- package/templates/api/.githooks/install.mjs +18 -0
- package/templates/api/.githooks/pre-commit +2 -0
- package/templates/api/.githooks/pre-push +2 -0
- package/templates/api/README.md +35 -0
- package/templates/api/biome.jsonc +18 -0
- package/templates/api/docker/init/01-auth-schema.sql +1 -0
- package/templates/api/docker-compose.yml +20 -0
- package/templates/api/gitignore +5 -0
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
- package/templates/api/nest-cli.json +10 -0
- package/templates/api/package.json +69 -0
- package/templates/api/pnpm-workspace.yaml +9 -0
- package/templates/api/src/app.module.ts +46 -0
- package/templates/api/src/auth/auth.constants.ts +2 -0
- package/templates/api/src/auth/auth.controller.ts +19 -0
- package/templates/api/src/auth/auth.factory.spec.ts +29 -0
- package/templates/api/src/auth/auth.factory.ts +52 -0
- package/templates/api/src/auth/auth.guard.ts +43 -0
- package/templates/api/src/auth/auth.module.ts +30 -0
- package/templates/api/src/auth/auth.ts +4 -0
- package/templates/api/src/auth/current-user.decorator.ts +9 -0
- package/templates/api/src/auth/express.d.ts +10 -0
- package/templates/api/src/auth/public.decorator.ts +4 -0
- package/templates/api/src/config/env.module.ts +11 -0
- package/templates/api/src/config/env.ts +81 -0
- package/templates/api/src/errors/problem-details.filter.ts +86 -0
- package/templates/api/src/errors/validation-exception.factory.ts +30 -0
- package/templates/api/src/health/health.controller.ts +32 -0
- package/templates/api/src/health/health.module.ts +9 -0
- package/templates/api/src/main.ts +43 -0
- package/templates/api/src/mikro-orm.config.ts +4 -0
- package/templates/api/src/mikro-orm.factory.ts +14 -0
- package/templates/api/src/request-context/request-context.module.ts +9 -0
- package/templates/api/src/request-context/request-context.service.ts +17 -0
- package/templates/api/src/request-context/request-id.middleware.ts +19 -0
- package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
- package/templates/api/src/users/entities/profile.entity.ts +27 -0
- package/templates/api/src/users/users.controller.ts +23 -0
- package/templates/api/src/users/users.module.ts +10 -0
- package/templates/api/src/users/users.service.spec.ts +44 -0
- package/templates/api/src/users/users.service.ts +36 -0
- package/templates/api/test/factories/profile.factory.ts +26 -0
- package/templates/api/test/factories/user.factory.ts +21 -0
- package/templates/api/test/global-setup.ts +20 -0
- package/templates/api/test/setup.ts +80 -0
- package/templates/api/tsconfig.build.json +8 -0
- package/templates/api/tsconfig.json +29 -0
- package/templates/api/vitest.config.mts +17 -0
- package/templates/mobile/.env.example +1 -0
- package/templates/mobile/.githooks/install.mjs +18 -0
- package/templates/mobile/.githooks/pre-commit +2 -0
- package/templates/mobile/.githooks/pre-push +2 -0
- package/templates/mobile/README.md +30 -0
- package/templates/mobile/app.config.ts +13 -0
- package/templates/mobile/app.json +39 -0
- package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
- package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
- package/templates/mobile/assets/expo.icon/icon.json +35 -0
- package/templates/mobile/assets/images/android-icon-background.png +0 -0
- package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
- package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
- package/templates/mobile/assets/images/icon.png +0 -0
- package/templates/mobile/assets/images/splash-icon.png +0 -0
- package/templates/mobile/babel.config.js +6 -0
- package/templates/mobile/biome.jsonc +21 -0
- package/templates/mobile/gitignore +11 -0
- package/templates/mobile/global.d.ts +1 -0
- package/templates/mobile/jest.config.js +21 -0
- package/templates/mobile/metro.config.js +4 -0
- package/templates/mobile/nativewind-env.d.ts +1 -0
- package/templates/mobile/package.json +63 -0
- package/templates/mobile/src/api-contract/api-error.ts +51 -0
- package/templates/mobile/src/api-contract/client.ts +26 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/api-contract/profile.ts +27 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
- package/templates/mobile/src/app/(app)/index.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
- package/templates/mobile/src/app/_layout.tsx +54 -0
- package/templates/mobile/src/components/ui/button.tsx +36 -0
- package/templates/mobile/src/components/ui/field.tsx +30 -0
- package/templates/mobile/src/config.ts +3 -0
- package/templates/mobile/src/features/auth/auth-client.ts +27 -0
- package/templates/mobile/src/features/auth/intended-route.ts +11 -0
- package/templates/mobile/src/features/auth/session.ts +17 -0
- package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
- package/templates/mobile/src/features/forms/form-error.tsx +16 -0
- package/templates/mobile/src/features/forms/server-errors.ts +33 -0
- package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
- package/templates/mobile/src/features/storage/query-persister.ts +15 -0
- package/templates/mobile/src/features/storage/storage.ts +11 -0
- package/templates/mobile/src/global.css +3 -0
- package/templates/mobile/tailwind.config.js +9 -0
- package/templates/mobile/test/fake-transport.ts +23 -0
- package/templates/mobile/tsconfig.json +21 -0
- package/templates/site/.githooks/install.mjs +18 -0
- package/templates/site/.githooks/pre-commit +2 -0
- package/templates/site/.githooks/pre-push +2 -0
- package/templates/site/README.md +28 -0
- package/templates/site/biome.jsonc +12 -0
- package/templates/site/components.json +25 -0
- package/templates/site/gitignore +7 -0
- package/templates/site/next.config.ts +7 -0
- package/templates/site/package.json +40 -0
- package/templates/site/pnpm-workspace.yaml +5 -0
- package/templates/site/postcss.config.mjs +7 -0
- package/templates/site/src/app/globals.css +129 -0
- package/templates/site/src/app/layout.tsx +13 -0
- package/templates/site/src/app/page.spec.ts +10 -0
- package/templates/site/src/app/page.tsx +17 -0
- package/templates/site/src/lib/utils.ts +1 -0
- package/templates/site/tsconfig.json +34 -0
- package/templates/site/vitest.config.mts +8 -0
- package/templates/web/.env.example +1 -0
- package/templates/web/.githooks/install.mjs +18 -0
- package/templates/web/.githooks/pre-commit +2 -0
- package/templates/web/.githooks/pre-push +2 -0
- package/templates/web/README.md +29 -0
- package/templates/web/biome.jsonc +24 -0
- package/templates/web/components.json +25 -0
- package/templates/web/gitignore +6 -0
- package/templates/web/index.html +12 -0
- package/templates/web/package.json +55 -0
- package/templates/web/src/api-contract/api-error.ts +51 -0
- package/templates/web/src/api-contract/client.ts +26 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/api-contract/profile.ts +27 -0
- package/templates/web/src/app.spec.tsx +12 -0
- package/templates/web/src/app.tsx +14 -0
- package/templates/web/src/components/ui/button.tsx +57 -0
- package/templates/web/src/components/ui/field.tsx +224 -0
- package/templates/web/src/components/ui/input.tsx +19 -0
- package/templates/web/src/components/ui/label.tsx +17 -0
- package/templates/web/src/components/ui/separator.tsx +20 -0
- package/templates/web/src/config.ts +7 -0
- package/templates/web/src/features/auth/auth-client.ts +12 -0
- package/templates/web/src/features/auth/session.ts +17 -0
- package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
- package/templates/web/src/features/forms/form-error.tsx +15 -0
- package/templates/web/src/features/forms/server-errors.ts +33 -0
- package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/web/src/features/profile/profile-form.tsx +74 -0
- package/templates/web/src/index.css +130 -0
- package/templates/web/src/lib/utils.ts +1 -0
- package/templates/web/src/main.tsx +28 -0
- package/templates/web/src/routeTree.gen.ts +125 -0
- package/templates/web/src/router.tsx +21 -0
- package/templates/web/src/routes/__root.tsx +10 -0
- package/templates/web/src/routes/_authenticated/index.tsx +21 -0
- package/templates/web/src/routes/_authenticated.tsx +15 -0
- package/templates/web/src/routes/sign-in.tsx +20 -0
- package/templates/web/src/routes/sign-up.tsx +17 -0
- package/templates/web/test/fake-transport.ts +23 -0
- package/templates/web/test/render-app.tsx +17 -0
- package/templates/web/test/setup.ts +7 -0
- package/templates/web/tsconfig.json +21 -0
- package/templates/web/vite.config.ts +18 -0
- package/templates/workspace/.githooks/install.mjs +18 -0
- package/templates/workspace/.githooks/pre-commit +2 -0
- package/templates/workspace/.githooks/pre-push +2 -0
- package/templates/workspace/README.md +25 -0
- package/templates/workspace/biome.jsonc +8 -0
- package/templates/workspace/gitignore +5 -0
- package/templates/workspace/package.json +17 -0
- package/templates/workspace/packages/api-contract/package.json +20 -0
- package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
- package/templates/workspace/pnpm-workspace.yaml +3 -0
- package/templates/workspace/tsconfig.base.json +10 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "api",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "nest build",
|
|
7
|
+
"start": "nest start",
|
|
8
|
+
"start:dev": "nest start --watch",
|
|
9
|
+
"start:prod": "node dist/main",
|
|
10
|
+
"lint": "biome check",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest",
|
|
14
|
+
"test:cov": "vitest run --coverage",
|
|
15
|
+
"db:migrate": "nest build && auth migrate --config dist/auth/auth.js --yes && mikro-orm migration:up",
|
|
16
|
+
"db:migration:create": "mikro-orm migration:create",
|
|
17
|
+
"prepare": "node .githooks/install.mjs"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@better-auth/expo": "1.7.4",
|
|
21
|
+
"@mikro-orm/core": "7.2.0",
|
|
22
|
+
"@mikro-orm/migrations": "7.2.0",
|
|
23
|
+
"@mikro-orm/nestjs": "7.1.0",
|
|
24
|
+
"@mikro-orm/postgresql": "7.2.0",
|
|
25
|
+
"@nestjs/common": "12.0.1",
|
|
26
|
+
"@nestjs/config": "12.0.0",
|
|
27
|
+
"@nestjs/core": "12.0.1",
|
|
28
|
+
"@nestjs/platform-express": "12.0.1",
|
|
29
|
+
"@nestjs/swagger": "12.0.1",
|
|
30
|
+
"@nestjs/terminus": "12.0.0",
|
|
31
|
+
"better-auth": "1.7.4",
|
|
32
|
+
"class-transformer": "0.5.1",
|
|
33
|
+
"class-validator": "0.15.1",
|
|
34
|
+
"nestjs-pino": "5.1.0",
|
|
35
|
+
"pg": "8.23.0",
|
|
36
|
+
"pino": "10.3.1",
|
|
37
|
+
"pino-http": "11.0.0",
|
|
38
|
+
"reflect-metadata": "0.2.2",
|
|
39
|
+
"rxjs": "7.8.2"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@biomejs/biome": "2.5.13",
|
|
43
|
+
"@mikro-orm/cli": "7.2.0",
|
|
44
|
+
"@nestjs/cli": "12.0.0",
|
|
45
|
+
"@nestjs/schematics": "12.0.1",
|
|
46
|
+
"@nestjs/testing": "12.0.1",
|
|
47
|
+
"@swc-node/register": "1.12.1",
|
|
48
|
+
"@swc/core": "1.16.2",
|
|
49
|
+
"@testcontainers/postgresql": "12.1.0",
|
|
50
|
+
"@types/express": "5.0.6",
|
|
51
|
+
"@types/node": "24.13.4",
|
|
52
|
+
"@types/pg": "8.23.1",
|
|
53
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
54
|
+
"auth": "1.7.4",
|
|
55
|
+
"pino-pretty": "13.1.3",
|
|
56
|
+
"typescript": "6.0.3",
|
|
57
|
+
"unplugin-swc": "1.6.0",
|
|
58
|
+
"vitest": "4.1.11"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=22.17.0"
|
|
62
|
+
},
|
|
63
|
+
"mikro-orm": {
|
|
64
|
+
"configPaths": [
|
|
65
|
+
"./src/mikro-orm.config.ts",
|
|
66
|
+
"./dist/mikro-orm.config.js"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
allowBuilds:
|
|
2
|
+
# Its install script only sends usage telemetry; it arrives through @nestjs/swagger's swagger-ui-dist.
|
|
3
|
+
'@scarf/scarf': false
|
|
4
|
+
# Its platform binary arrives as an optional dependency, so the install script is not needed.
|
|
5
|
+
'@swc/core': false
|
|
6
|
+
# These three arrive through Testcontainers, and the suite runs without their native builds.
|
|
7
|
+
cpu-features: false
|
|
8
|
+
protobufjs: false
|
|
9
|
+
ssh2: false
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { MikroOrmModule } from '@mikro-orm/nestjs'
|
|
2
|
+
import { PostgreSqlDriver } from '@mikro-orm/postgresql'
|
|
3
|
+
import { type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'
|
|
4
|
+
import { APP_FILTER } from '@nestjs/core'
|
|
5
|
+
import { LoggerModule } from 'nestjs-pino'
|
|
6
|
+
import { AuthModule } from '@/auth/auth.module'
|
|
7
|
+
import { Env } from '@/config/env'
|
|
8
|
+
import { EnvModule } from '@/config/env.module'
|
|
9
|
+
import { ProblemDetailsFilter } from '@/errors/problem-details.filter'
|
|
10
|
+
import { HealthModule } from '@/health/health.module'
|
|
11
|
+
import { createOrmConfig } from '@/mikro-orm.factory'
|
|
12
|
+
import { RequestContextModule } from '@/request-context/request-context.module'
|
|
13
|
+
import { RequestIdMiddleware } from '@/request-context/request-id.middleware'
|
|
14
|
+
import { UsersModule } from '@/users/users.module'
|
|
15
|
+
|
|
16
|
+
@Module({
|
|
17
|
+
imports: [
|
|
18
|
+
EnvModule,
|
|
19
|
+
LoggerModule.forRootAsync({
|
|
20
|
+
inject: [Env],
|
|
21
|
+
useFactory: (env: Env) => ({
|
|
22
|
+
pinoHttp: {
|
|
23
|
+
level: env.LOG_LEVEL,
|
|
24
|
+
redact: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
|
|
25
|
+
transport: env.NODE_ENV === 'development' ? { target: 'pino-pretty' } : undefined,
|
|
26
|
+
autoLogging: { ignore: (req) => req.url?.startsWith('/api/health') === true },
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
MikroOrmModule.forRootAsync({
|
|
31
|
+
inject: [Env],
|
|
32
|
+
driver: PostgreSqlDriver,
|
|
33
|
+
useFactory: (env: Env) => createOrmConfig(env),
|
|
34
|
+
}),
|
|
35
|
+
RequestContextModule,
|
|
36
|
+
AuthModule,
|
|
37
|
+
HealthModule,
|
|
38
|
+
UsersModule,
|
|
39
|
+
],
|
|
40
|
+
providers: [{ provide: APP_FILTER, useClass: ProblemDetailsFilter }],
|
|
41
|
+
})
|
|
42
|
+
export class AppModule implements NestModule {
|
|
43
|
+
configure(consumer: MiddlewareConsumer): void {
|
|
44
|
+
consumer.apply(RequestIdMiddleware).forRoutes('*path')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { All, Controller, Inject, Req, Res, VERSION_NEUTRAL } from '@nestjs/common'
|
|
2
|
+
import { ApiExcludeController } from '@nestjs/swagger'
|
|
3
|
+
import { toNodeHandler } from 'better-auth/node'
|
|
4
|
+
import type { Request, Response } from 'express'
|
|
5
|
+
import { AUTH } from './auth.constants'
|
|
6
|
+
import type { Auth } from './auth.factory'
|
|
7
|
+
import { Public } from './public.decorator'
|
|
8
|
+
|
|
9
|
+
@ApiExcludeController()
|
|
10
|
+
@Controller({ path: 'auth', version: VERSION_NEUTRAL })
|
|
11
|
+
export class AuthController {
|
|
12
|
+
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
|
13
|
+
|
|
14
|
+
@Public()
|
|
15
|
+
@All('*path')
|
|
16
|
+
handle(@Req() request: Request, @Res() response: Response): Promise<void> {
|
|
17
|
+
return toNodeHandler(this.auth)(request, response)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { testDatabaseUrl } from '../../test/setup'
|
|
3
|
+
import { createAuth } from './auth.factory'
|
|
4
|
+
|
|
5
|
+
const env = () => ({
|
|
6
|
+
AUTH_DATABASE_URL: testDatabaseUrl(),
|
|
7
|
+
BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-characters',
|
|
8
|
+
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
9
|
+
WEB_ORIGIN: 'http://localhost:5173',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const proxy =
|
|
13
|
+
'http://localhost:3000/api/auth/expo-authorization-proxy?authorizationURL=https://example.com/authorize&oauthState=s'
|
|
14
|
+
|
|
15
|
+
describe('createAuth', () => {
|
|
16
|
+
it('exposes no Expo redirect endpoint without a mobile scheme', async () => {
|
|
17
|
+
const response = await createAuth(env()).handler(new Request(proxy))
|
|
18
|
+
|
|
19
|
+
expect(response.status).toBe(404)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('exposes the Expo endpoint and trusts the scheme once a mobile scheme is set', async () => {
|
|
23
|
+
const auth = createAuth({ ...env(), MOBILE_APP_SCHEME: 'app' })
|
|
24
|
+
const response = await auth.handler(new Request(proxy))
|
|
25
|
+
|
|
26
|
+
expect(response.status).toBe(302)
|
|
27
|
+
expect(auth.options.trustedOrigins).toContain('app://')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { expo } from '@better-auth/expo'
|
|
2
|
+
import { type BetterAuthPlugin, betterAuth } from 'better-auth'
|
|
3
|
+
import { Pool } from 'pg'
|
|
4
|
+
import type { Env } from '@/config/env'
|
|
5
|
+
|
|
6
|
+
type NewUser = { id: string; name: string }
|
|
7
|
+
|
|
8
|
+
export type AuthHooks = {
|
|
9
|
+
onUserCreated?: (user: NewUser) => Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createAuth(
|
|
13
|
+
env: Pick<
|
|
14
|
+
Env,
|
|
15
|
+
| 'AUTH_DATABASE_URL'
|
|
16
|
+
| 'BETTER_AUTH_SECRET'
|
|
17
|
+
| 'BETTER_AUTH_URL'
|
|
18
|
+
| 'WEB_ORIGIN'
|
|
19
|
+
| 'MOBILE_APP_SCHEME'
|
|
20
|
+
>,
|
|
21
|
+
hooks: AuthHooks = {},
|
|
22
|
+
) {
|
|
23
|
+
const mobile = env.MOBILE_APP_SCHEME
|
|
24
|
+
const plugins: BetterAuthPlugin[] = mobile === undefined ? [] : [expo()]
|
|
25
|
+
|
|
26
|
+
return betterAuth({
|
|
27
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
28
|
+
basePath: '/api/auth',
|
|
29
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
30
|
+
trustedOrigins: mobile === undefined ? [env.WEB_ORIGIN] : [env.WEB_ORIGIN, `${mobile}://`],
|
|
31
|
+
plugins,
|
|
32
|
+
database: new Pool({
|
|
33
|
+
connectionString: env.AUTH_DATABASE_URL,
|
|
34
|
+
options: '-c search_path=auth',
|
|
35
|
+
}),
|
|
36
|
+
emailAndPassword: { enabled: true },
|
|
37
|
+
advanced: { database: { generateId: 'uuid' } },
|
|
38
|
+
databaseHooks: {
|
|
39
|
+
user: {
|
|
40
|
+
create: {
|
|
41
|
+
after: async (user) => {
|
|
42
|
+
await hooks.onUserCreated?.(user)
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type Auth = ReturnType<typeof createAuth>
|
|
51
|
+
export type AuthSession = Awaited<ReturnType<Auth['api']['getSession']>>
|
|
52
|
+
export type AuthUser = NonNullable<AuthSession>['user']
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CanActivate,
|
|
3
|
+
type ExecutionContext,
|
|
4
|
+
Inject,
|
|
5
|
+
Injectable,
|
|
6
|
+
UnauthorizedException,
|
|
7
|
+
} from '@nestjs/common'
|
|
8
|
+
import { Reflector } from '@nestjs/core'
|
|
9
|
+
import { fromNodeHeaders } from 'better-auth/node'
|
|
10
|
+
import type { Request } from 'express'
|
|
11
|
+
import { AUTH, IS_PUBLIC } from './auth.constants'
|
|
12
|
+
import type { Auth } from './auth.factory'
|
|
13
|
+
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class AuthGuard implements CanActivate {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly reflector: Reflector,
|
|
18
|
+
@Inject(AUTH) private readonly auth: Auth,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
22
|
+
const request = context.switchToHttp().getRequest<Request>()
|
|
23
|
+
const session = await this.auth.api.getSession({ headers: fromNodeHeaders(request.headers) })
|
|
24
|
+
|
|
25
|
+
request.user = session?.user
|
|
26
|
+
request.session = session?.session
|
|
27
|
+
|
|
28
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [
|
|
29
|
+
context.getHandler(),
|
|
30
|
+
context.getClass(),
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
if (isPublic === true) {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (session === null) {
|
|
38
|
+
throw new UnauthorizedException()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common'
|
|
2
|
+
import { APP_GUARD } from '@nestjs/core'
|
|
3
|
+
import { Env } from '@/config/env'
|
|
4
|
+
import { UsersModule } from '@/users/users.module'
|
|
5
|
+
import { UsersService } from '@/users/users.service'
|
|
6
|
+
import { AUTH } from './auth.constants'
|
|
7
|
+
import { AuthController } from './auth.controller'
|
|
8
|
+
import { createAuth } from './auth.factory'
|
|
9
|
+
import { AuthGuard } from './auth.guard'
|
|
10
|
+
|
|
11
|
+
@Global()
|
|
12
|
+
@Module({
|
|
13
|
+
imports: [UsersModule],
|
|
14
|
+
controllers: [AuthController],
|
|
15
|
+
providers: [
|
|
16
|
+
{
|
|
17
|
+
provide: AUTH,
|
|
18
|
+
inject: [Env, UsersService],
|
|
19
|
+
useFactory: (env: Env, users: UsersService) =>
|
|
20
|
+
createAuth(env, {
|
|
21
|
+
onUserCreated: async (user) => {
|
|
22
|
+
await users.createProfile(user.id, user.name)
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
{ provide: APP_GUARD, useClass: AuthGuard },
|
|
27
|
+
],
|
|
28
|
+
exports: [AUTH],
|
|
29
|
+
})
|
|
30
|
+
export class AuthModule {}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createParamDecorator, type ExecutionContext } from '@nestjs/common'
|
|
2
|
+
import type { Request } from 'express'
|
|
3
|
+
import type { AuthUser } from './auth.factory'
|
|
4
|
+
|
|
5
|
+
export const CurrentUser = createParamDecorator(
|
|
6
|
+
(_data: unknown, context: ExecutionContext): AuthUser | undefined => {
|
|
7
|
+
return context.switchToHttp().getRequest<Request>().user
|
|
8
|
+
},
|
|
9
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common'
|
|
2
|
+
import { ConfigModule } from '@nestjs/config'
|
|
3
|
+
import { Env, loadEnv } from './env'
|
|
4
|
+
|
|
5
|
+
@Global()
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [ConfigModule.forRoot({ cache: true, ignoreEnvFile: true, validate: loadEnv })],
|
|
8
|
+
providers: [{ provide: Env, useFactory: loadEnv }],
|
|
9
|
+
exports: [Env],
|
|
10
|
+
})
|
|
11
|
+
export class EnvModule {}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { plainToInstance } from 'class-transformer'
|
|
4
|
+
import {
|
|
5
|
+
IsEnum,
|
|
6
|
+
IsInt,
|
|
7
|
+
IsOptional,
|
|
8
|
+
IsString,
|
|
9
|
+
Matches,
|
|
10
|
+
Max,
|
|
11
|
+
Min,
|
|
12
|
+
MinLength,
|
|
13
|
+
validateSync,
|
|
14
|
+
} from 'class-validator'
|
|
15
|
+
|
|
16
|
+
export type NodeEnv = 'development' | 'test' | 'production'
|
|
17
|
+
export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
|
|
18
|
+
|
|
19
|
+
export class Env {
|
|
20
|
+
@IsEnum(['development', 'test', 'production'])
|
|
21
|
+
NODE_ENV: NodeEnv
|
|
22
|
+
|
|
23
|
+
@IsInt()
|
|
24
|
+
@Min(1)
|
|
25
|
+
@Max(65535)
|
|
26
|
+
PORT: number
|
|
27
|
+
|
|
28
|
+
@IsString()
|
|
29
|
+
@MinLength(1)
|
|
30
|
+
DATABASE_URL: string
|
|
31
|
+
|
|
32
|
+
@IsString()
|
|
33
|
+
@MinLength(1)
|
|
34
|
+
AUTH_DATABASE_URL: string
|
|
35
|
+
|
|
36
|
+
@IsString()
|
|
37
|
+
@MinLength(32)
|
|
38
|
+
BETTER_AUTH_SECRET: string
|
|
39
|
+
|
|
40
|
+
@IsString()
|
|
41
|
+
@MinLength(1)
|
|
42
|
+
BETTER_AUTH_URL: string
|
|
43
|
+
|
|
44
|
+
@IsString()
|
|
45
|
+
@MinLength(1)
|
|
46
|
+
WEB_ORIGIN: string
|
|
47
|
+
|
|
48
|
+
@IsOptional()
|
|
49
|
+
@Matches(/^[a-z][a-z0-9+.-]*$/)
|
|
50
|
+
MOBILE_APP_SCHEME?: string
|
|
51
|
+
|
|
52
|
+
@IsEnum(['error', 'warn', 'info', 'debug'])
|
|
53
|
+
LOG_LEVEL: LogLevel
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validate(raw: Record<string, unknown>): Env {
|
|
57
|
+
const env = plainToInstance(Env, raw, { enableImplicitConversion: true })
|
|
58
|
+
const errors = validateSync(env, { whitelist: true, forbidUnknownValues: false })
|
|
59
|
+
|
|
60
|
+
if (errors.length > 0) {
|
|
61
|
+
const named = errors.map((error) => error.property).join(', ')
|
|
62
|
+
throw new Error(`Invalid environment: ${named}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
loaded = env
|
|
66
|
+
return env
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let loaded: Env | undefined
|
|
70
|
+
|
|
71
|
+
export function loadEnv(): Env {
|
|
72
|
+
if (loaded !== undefined) {
|
|
73
|
+
return loaded
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (process.env.NODE_ENV !== 'production' && existsSync('.env')) {
|
|
77
|
+
process.loadEnvFile('.env')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return validate(process.env)
|
|
81
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ArgumentsHost,
|
|
3
|
+
Catch,
|
|
4
|
+
type ExceptionFilter,
|
|
5
|
+
HttpException,
|
|
6
|
+
HttpStatus,
|
|
7
|
+
Logger,
|
|
8
|
+
} from '@nestjs/common'
|
|
9
|
+
import type { Request, Response } from 'express'
|
|
10
|
+
import { RequestContextService } from '@/request-context/request-context.service'
|
|
11
|
+
import { ValidationFailedException } from './validation-exception.factory'
|
|
12
|
+
|
|
13
|
+
type ProblemDocument = {
|
|
14
|
+
type: string
|
|
15
|
+
title: string
|
|
16
|
+
status: number
|
|
17
|
+
detail: string
|
|
18
|
+
instance: string
|
|
19
|
+
requestId?: string
|
|
20
|
+
errors?: Record<string, string[]>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TITLES: Record<number, string> = {
|
|
24
|
+
400: 'Bad Request',
|
|
25
|
+
401: 'Unauthorized',
|
|
26
|
+
403: 'Forbidden',
|
|
27
|
+
404: 'Not Found',
|
|
28
|
+
409: 'Conflict',
|
|
29
|
+
422: 'Unprocessable Entity',
|
|
30
|
+
500: 'Internal Server Error',
|
|
31
|
+
503: 'Service Unavailable',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function detailOf(exception: HttpException): string {
|
|
35
|
+
const response = exception.getResponse()
|
|
36
|
+
|
|
37
|
+
if (typeof response === 'string') {
|
|
38
|
+
return response
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const message = (response as { message?: unknown }).message
|
|
42
|
+
|
|
43
|
+
if (typeof message === 'string') {
|
|
44
|
+
return message
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return exception.message
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Catch()
|
|
51
|
+
export class ProblemDetailsFilter implements ExceptionFilter {
|
|
52
|
+
private readonly logger = new Logger(ProblemDetailsFilter.name)
|
|
53
|
+
|
|
54
|
+
constructor(private readonly context: RequestContextService) {}
|
|
55
|
+
|
|
56
|
+
catch(exception: unknown, host: ArgumentsHost): void {
|
|
57
|
+
const http = host.switchToHttp()
|
|
58
|
+
const request = http.getRequest<Request>()
|
|
59
|
+
const response = http.getResponse<Response>()
|
|
60
|
+
|
|
61
|
+
const status =
|
|
62
|
+
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
|
|
63
|
+
|
|
64
|
+
const problem: ProblemDocument = {
|
|
65
|
+
type: 'about:blank',
|
|
66
|
+
title: TITLES[status] ?? 'Error',
|
|
67
|
+
status,
|
|
68
|
+
detail:
|
|
69
|
+
exception instanceof HttpException && status < HttpStatus.INTERNAL_SERVER_ERROR
|
|
70
|
+
? detailOf(exception)
|
|
71
|
+
: 'An unexpected error occurred.',
|
|
72
|
+
instance: request.originalUrl,
|
|
73
|
+
requestId: this.context.requestId,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (exception instanceof ValidationFailedException) {
|
|
77
|
+
problem.errors = exception.fields
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
|
|
81
|
+
this.logger.error(exception)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
response.status(status).type('application/problem+json').json(problem)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BadRequestException, type ValidationError } from '@nestjs/common'
|
|
2
|
+
|
|
3
|
+
export type FieldErrors = Record<string, string[]>
|
|
4
|
+
|
|
5
|
+
function collect(errors: ValidationError[], prefix: string, into: FieldErrors): void {
|
|
6
|
+
for (const error of errors) {
|
|
7
|
+
const key = prefix === '' ? error.property : `${prefix}.${error.property}`
|
|
8
|
+
const messages = Object.values(error.constraints ?? {})
|
|
9
|
+
|
|
10
|
+
if (messages.length > 0) {
|
|
11
|
+
into[key] = messages
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (error.children !== undefined && error.children.length > 0) {
|
|
15
|
+
collect(error.children, key, into)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ValidationFailedException extends BadRequestException {
|
|
21
|
+
constructor(readonly fields: FieldErrors) {
|
|
22
|
+
super('Validation failed')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validationExceptionFactory(errors: ValidationError[]): ValidationFailedException {
|
|
27
|
+
const fields: FieldErrors = {}
|
|
28
|
+
collect(errors, '', fields)
|
|
29
|
+
return new ValidationFailedException(fields)
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common'
|
|
2
|
+
import { ApiTags } from '@nestjs/swagger'
|
|
3
|
+
import {
|
|
4
|
+
HealthCheck,
|
|
5
|
+
type HealthCheckResult,
|
|
6
|
+
HealthCheckService,
|
|
7
|
+
MikroOrmHealthIndicator,
|
|
8
|
+
} from '@nestjs/terminus'
|
|
9
|
+
import { Public } from '@/auth/public.decorator'
|
|
10
|
+
|
|
11
|
+
@ApiTags('health')
|
|
12
|
+
@Controller({ path: 'health', version: VERSION_NEUTRAL })
|
|
13
|
+
export class HealthController {
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly health: HealthCheckService,
|
|
16
|
+
private readonly database: MikroOrmHealthIndicator,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
@Public()
|
|
20
|
+
@Get('live')
|
|
21
|
+
@HealthCheck()
|
|
22
|
+
live(): Promise<HealthCheckResult> {
|
|
23
|
+
return this.health.check([])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Public()
|
|
27
|
+
@Get('ready')
|
|
28
|
+
@HealthCheck()
|
|
29
|
+
ready(): Promise<HealthCheckResult> {
|
|
30
|
+
return this.health.check([() => this.database.pingCheck('database')])
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { MikroORM } from '@mikro-orm/postgresql'
|
|
2
|
+
import { ClassSerializerInterceptor, ValidationPipe, VersioningType } from '@nestjs/common'
|
|
3
|
+
import { NestFactory, Reflector } from '@nestjs/core'
|
|
4
|
+
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
|
|
5
|
+
import { Logger } from 'nestjs-pino'
|
|
6
|
+
import { AppModule } from './app.module'
|
|
7
|
+
import { Env } from './config/env'
|
|
8
|
+
import { validationExceptionFactory } from './errors/validation-exception.factory'
|
|
9
|
+
|
|
10
|
+
async function bootstrap(): Promise<void> {
|
|
11
|
+
const app = await NestFactory.create(AppModule, { bufferLogs: true })
|
|
12
|
+
const env = app.get(Env)
|
|
13
|
+
|
|
14
|
+
app.useLogger(app.get(Logger))
|
|
15
|
+
app.setGlobalPrefix('api')
|
|
16
|
+
app.enableCors({ origin: env.WEB_ORIGIN, credentials: true, exposedHeaders: ['X-Request-Id'] })
|
|
17
|
+
app.enableVersioning({ type: VersioningType.URI })
|
|
18
|
+
|
|
19
|
+
app.useGlobalPipes(
|
|
20
|
+
new ValidationPipe({
|
|
21
|
+
whitelist: true,
|
|
22
|
+
forbidNonWhitelisted: true,
|
|
23
|
+
transform: true,
|
|
24
|
+
exceptionFactory: validationExceptionFactory,
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))
|
|
28
|
+
|
|
29
|
+
if (env.NODE_ENV !== 'production') {
|
|
30
|
+
const document = SwaggerModule.createDocument(
|
|
31
|
+
app,
|
|
32
|
+
new DocumentBuilder().setTitle('API').setVersion('1').build(),
|
|
33
|
+
)
|
|
34
|
+
SwaggerModule.setup('api/docs', app, document)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// MikroORM 7 no longer connects on init, so readiness would report the database down until a first query.
|
|
38
|
+
await app.get(MikroORM).connect()
|
|
39
|
+
|
|
40
|
+
await app.listen(env.PORT)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
void bootstrap()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Migrator } from '@mikro-orm/migrations'
|
|
2
|
+
import { defineConfig } from '@mikro-orm/postgresql'
|
|
3
|
+
import type { Env } from './config/env'
|
|
4
|
+
import { Profile } from './users/entities/profile.entity'
|
|
5
|
+
|
|
6
|
+
export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
|
|
7
|
+
return defineConfig({
|
|
8
|
+
clientUrl: env.DATABASE_URL,
|
|
9
|
+
entities: [Profile],
|
|
10
|
+
extensions: [Migrator],
|
|
11
|
+
migrations: { path: './migrations', snapshot: false },
|
|
12
|
+
debug: env.NODE_ENV === 'development',
|
|
13
|
+
})
|
|
14
|
+
}
|