@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.
Files changed (220) hide show
  1. package/.prumo-templates/api/auth.md +130 -0
  2. package/.prumo-templates/api/config.md +85 -0
  3. package/.prumo-templates/api/errors.md +82 -0
  4. package/.prumo-templates/api/logging.md +79 -0
  5. package/.prumo-templates/api/modules.md +117 -0
  6. package/.prumo-templates/api/observability.md +91 -0
  7. package/.prumo-templates/api/pagination.md +71 -0
  8. package/.prumo-templates/api/routes.md +89 -0
  9. package/.prumo-templates/client/data.md +124 -0
  10. package/.prumo-templates/client/forms.md +88 -0
  11. package/.prumo-templates/core/conventions.md +74 -0
  12. package/.prumo-templates/core/testing.md +100 -0
  13. package/.prumo-templates/core/tooling.md +118 -0
  14. package/.prumo-templates/database/entities.md +97 -0
  15. package/.prumo-templates/database/migrations.md +74 -0
  16. package/.prumo-templates/database/queries.md +79 -0
  17. package/.prumo-templates/database/testing.md +82 -0
  18. package/.prumo-templates/database/transactions.md +68 -0
  19. package/.prumo-templates/mobile/components.md +83 -0
  20. package/.prumo-templates/mobile/config.md +84 -0
  21. package/.prumo-templates/mobile/routing.md +74 -0
  22. package/.prumo-templates/mobile/storage.md +84 -0
  23. package/.prumo-templates/mobile/structure.md +79 -0
  24. package/.prumo-templates/monorepo/dependencies.md +84 -0
  25. package/.prumo-templates/monorepo/layout.md +89 -0
  26. package/.prumo-templates/monorepo/tasks.md +76 -0
  27. package/.prumo-templates/multi-tenancy/client.md +83 -0
  28. package/.prumo-templates/multi-tenancy/isolation.md +104 -0
  29. package/.prumo-templates/site/rendering.md +83 -0
  30. package/.prumo-templates/site/structure.md +68 -0
  31. package/.prumo-templates/web/components.md +106 -0
  32. package/.prumo-templates/web/routing.md +82 -0
  33. package/.prumo-templates/web/structure.md +92 -0
  34. package/LICENSE +21 -0
  35. package/README.md +110 -0
  36. package/dist/asker.js +28 -0
  37. package/dist/cli.js +62 -0
  38. package/dist/compose.js +222 -0
  39. package/dist/context.js +38 -0
  40. package/dist/generate.js +60 -0
  41. package/dist/jsonc.js +76 -0
  42. package/dist/names.js +10 -0
  43. package/dist/questions.js +63 -0
  44. package/package.json +55 -0
  45. package/templates/api/.env.example +12 -0
  46. package/templates/api/.githooks/install.mjs +18 -0
  47. package/templates/api/.githooks/pre-commit +2 -0
  48. package/templates/api/.githooks/pre-push +2 -0
  49. package/templates/api/README.md +35 -0
  50. package/templates/api/biome.jsonc +18 -0
  51. package/templates/api/docker/init/01-auth-schema.sql +1 -0
  52. package/templates/api/docker-compose.yml +20 -0
  53. package/templates/api/gitignore +5 -0
  54. package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
  55. package/templates/api/nest-cli.json +10 -0
  56. package/templates/api/package.json +69 -0
  57. package/templates/api/pnpm-workspace.yaml +9 -0
  58. package/templates/api/src/app.module.ts +46 -0
  59. package/templates/api/src/auth/auth.constants.ts +2 -0
  60. package/templates/api/src/auth/auth.controller.ts +19 -0
  61. package/templates/api/src/auth/auth.factory.spec.ts +29 -0
  62. package/templates/api/src/auth/auth.factory.ts +52 -0
  63. package/templates/api/src/auth/auth.guard.ts +43 -0
  64. package/templates/api/src/auth/auth.module.ts +30 -0
  65. package/templates/api/src/auth/auth.ts +4 -0
  66. package/templates/api/src/auth/current-user.decorator.ts +9 -0
  67. package/templates/api/src/auth/express.d.ts +10 -0
  68. package/templates/api/src/auth/public.decorator.ts +4 -0
  69. package/templates/api/src/config/env.module.ts +11 -0
  70. package/templates/api/src/config/env.ts +81 -0
  71. package/templates/api/src/errors/problem-details.filter.ts +86 -0
  72. package/templates/api/src/errors/validation-exception.factory.ts +30 -0
  73. package/templates/api/src/health/health.controller.ts +32 -0
  74. package/templates/api/src/health/health.module.ts +9 -0
  75. package/templates/api/src/main.ts +43 -0
  76. package/templates/api/src/mikro-orm.config.ts +4 -0
  77. package/templates/api/src/mikro-orm.factory.ts +14 -0
  78. package/templates/api/src/request-context/request-context.module.ts +9 -0
  79. package/templates/api/src/request-context/request-context.service.ts +17 -0
  80. package/templates/api/src/request-context/request-id.middleware.ts +19 -0
  81. package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
  82. package/templates/api/src/users/entities/profile.entity.ts +27 -0
  83. package/templates/api/src/users/users.controller.ts +23 -0
  84. package/templates/api/src/users/users.module.ts +10 -0
  85. package/templates/api/src/users/users.service.spec.ts +44 -0
  86. package/templates/api/src/users/users.service.ts +36 -0
  87. package/templates/api/test/factories/profile.factory.ts +26 -0
  88. package/templates/api/test/factories/user.factory.ts +21 -0
  89. package/templates/api/test/global-setup.ts +20 -0
  90. package/templates/api/test/setup.ts +80 -0
  91. package/templates/api/tsconfig.build.json +8 -0
  92. package/templates/api/tsconfig.json +29 -0
  93. package/templates/api/vitest.config.mts +17 -0
  94. package/templates/mobile/.env.example +1 -0
  95. package/templates/mobile/.githooks/install.mjs +18 -0
  96. package/templates/mobile/.githooks/pre-commit +2 -0
  97. package/templates/mobile/.githooks/pre-push +2 -0
  98. package/templates/mobile/README.md +30 -0
  99. package/templates/mobile/app.config.ts +13 -0
  100. package/templates/mobile/app.json +39 -0
  101. package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
  102. package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
  103. package/templates/mobile/assets/expo.icon/icon.json +35 -0
  104. package/templates/mobile/assets/images/android-icon-background.png +0 -0
  105. package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
  106. package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
  107. package/templates/mobile/assets/images/icon.png +0 -0
  108. package/templates/mobile/assets/images/splash-icon.png +0 -0
  109. package/templates/mobile/babel.config.js +6 -0
  110. package/templates/mobile/biome.jsonc +21 -0
  111. package/templates/mobile/gitignore +11 -0
  112. package/templates/mobile/global.d.ts +1 -0
  113. package/templates/mobile/jest.config.js +21 -0
  114. package/templates/mobile/metro.config.js +4 -0
  115. package/templates/mobile/nativewind-env.d.ts +1 -0
  116. package/templates/mobile/package.json +63 -0
  117. package/templates/mobile/src/api-contract/api-error.ts +51 -0
  118. package/templates/mobile/src/api-contract/client.ts +26 -0
  119. package/templates/mobile/src/api-contract/index.ts +10 -0
  120. package/templates/mobile/src/api-contract/profile.ts +27 -0
  121. package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
  122. package/templates/mobile/src/app/(app)/index.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
  125. package/templates/mobile/src/app/_layout.tsx +54 -0
  126. package/templates/mobile/src/components/ui/button.tsx +36 -0
  127. package/templates/mobile/src/components/ui/field.tsx +30 -0
  128. package/templates/mobile/src/config.ts +3 -0
  129. package/templates/mobile/src/features/auth/auth-client.ts +27 -0
  130. package/templates/mobile/src/features/auth/intended-route.ts +11 -0
  131. package/templates/mobile/src/features/auth/session.ts +17 -0
  132. package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
  133. package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
  134. package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
  135. package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
  136. package/templates/mobile/src/features/forms/form-error.tsx +16 -0
  137. package/templates/mobile/src/features/forms/server-errors.ts +33 -0
  138. package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
  139. package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
  140. package/templates/mobile/src/features/storage/query-persister.ts +15 -0
  141. package/templates/mobile/src/features/storage/storage.ts +11 -0
  142. package/templates/mobile/src/global.css +3 -0
  143. package/templates/mobile/tailwind.config.js +9 -0
  144. package/templates/mobile/test/fake-transport.ts +23 -0
  145. package/templates/mobile/tsconfig.json +21 -0
  146. package/templates/site/.githooks/install.mjs +18 -0
  147. package/templates/site/.githooks/pre-commit +2 -0
  148. package/templates/site/.githooks/pre-push +2 -0
  149. package/templates/site/README.md +28 -0
  150. package/templates/site/biome.jsonc +12 -0
  151. package/templates/site/components.json +25 -0
  152. package/templates/site/gitignore +7 -0
  153. package/templates/site/next.config.ts +7 -0
  154. package/templates/site/package.json +40 -0
  155. package/templates/site/pnpm-workspace.yaml +5 -0
  156. package/templates/site/postcss.config.mjs +7 -0
  157. package/templates/site/src/app/globals.css +129 -0
  158. package/templates/site/src/app/layout.tsx +13 -0
  159. package/templates/site/src/app/page.spec.ts +10 -0
  160. package/templates/site/src/app/page.tsx +17 -0
  161. package/templates/site/src/lib/utils.ts +1 -0
  162. package/templates/site/tsconfig.json +34 -0
  163. package/templates/site/vitest.config.mts +8 -0
  164. package/templates/web/.env.example +1 -0
  165. package/templates/web/.githooks/install.mjs +18 -0
  166. package/templates/web/.githooks/pre-commit +2 -0
  167. package/templates/web/.githooks/pre-push +2 -0
  168. package/templates/web/README.md +29 -0
  169. package/templates/web/biome.jsonc +24 -0
  170. package/templates/web/components.json +25 -0
  171. package/templates/web/gitignore +6 -0
  172. package/templates/web/index.html +12 -0
  173. package/templates/web/package.json +55 -0
  174. package/templates/web/src/api-contract/api-error.ts +51 -0
  175. package/templates/web/src/api-contract/client.ts +26 -0
  176. package/templates/web/src/api-contract/index.ts +10 -0
  177. package/templates/web/src/api-contract/profile.ts +27 -0
  178. package/templates/web/src/app.spec.tsx +12 -0
  179. package/templates/web/src/app.tsx +14 -0
  180. package/templates/web/src/components/ui/button.tsx +57 -0
  181. package/templates/web/src/components/ui/field.tsx +224 -0
  182. package/templates/web/src/components/ui/input.tsx +19 -0
  183. package/templates/web/src/components/ui/label.tsx +17 -0
  184. package/templates/web/src/components/ui/separator.tsx +20 -0
  185. package/templates/web/src/config.ts +7 -0
  186. package/templates/web/src/features/auth/auth-client.ts +12 -0
  187. package/templates/web/src/features/auth/session.ts +17 -0
  188. package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
  189. package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
  190. package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
  191. package/templates/web/src/features/forms/form-error.tsx +15 -0
  192. package/templates/web/src/features/forms/server-errors.ts +33 -0
  193. package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
  194. package/templates/web/src/features/profile/profile-form.tsx +74 -0
  195. package/templates/web/src/index.css +130 -0
  196. package/templates/web/src/lib/utils.ts +1 -0
  197. package/templates/web/src/main.tsx +28 -0
  198. package/templates/web/src/routeTree.gen.ts +125 -0
  199. package/templates/web/src/router.tsx +21 -0
  200. package/templates/web/src/routes/__root.tsx +10 -0
  201. package/templates/web/src/routes/_authenticated/index.tsx +21 -0
  202. package/templates/web/src/routes/_authenticated.tsx +15 -0
  203. package/templates/web/src/routes/sign-in.tsx +20 -0
  204. package/templates/web/src/routes/sign-up.tsx +17 -0
  205. package/templates/web/test/fake-transport.ts +23 -0
  206. package/templates/web/test/render-app.tsx +17 -0
  207. package/templates/web/test/setup.ts +7 -0
  208. package/templates/web/tsconfig.json +21 -0
  209. package/templates/web/vite.config.ts +18 -0
  210. package/templates/workspace/.githooks/install.mjs +18 -0
  211. package/templates/workspace/.githooks/pre-commit +2 -0
  212. package/templates/workspace/.githooks/pre-push +2 -0
  213. package/templates/workspace/README.md +25 -0
  214. package/templates/workspace/biome.jsonc +8 -0
  215. package/templates/workspace/gitignore +5 -0
  216. package/templates/workspace/package.json +17 -0
  217. package/templates/workspace/packages/api-contract/package.json +20 -0
  218. package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
  219. package/templates/workspace/pnpm-workspace.yaml +3 -0
  220. package/templates/workspace/tsconfig.base.json +10 -0
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/nest-cli",
3
+ "collection": "@nestjs/schematics",
4
+ "sourceRoot": "src",
5
+ "compilerOptions": {
6
+ "deleteOutDir": true,
7
+ "tsConfigPath": "tsconfig.build.json",
8
+ "plugins": ["@nestjs/swagger/plugin"]
9
+ }
10
+ }
@@ -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,2 @@
1
+ export const AUTH = Symbol('AUTH')
2
+ export const IS_PUBLIC = Symbol('IS_PUBLIC')
@@ -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,4 @@
1
+ import { loadEnv } from '@/config/env'
2
+ import { createAuth } from './auth.factory'
3
+
4
+ export const auth = createAuth(loadEnv())
@@ -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,10 @@
1
+ import type { AuthSession, AuthUser } from './auth.factory'
2
+
3
+ declare global {
4
+ namespace Express {
5
+ interface Request {
6
+ user?: AuthUser
7
+ session?: NonNullable<AuthSession>['session']
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,4 @@
1
+ import { SetMetadata } from '@nestjs/common'
2
+ import { IS_PUBLIC } from './auth.constants'
3
+
4
+ export const Public = () => SetMetadata(IS_PUBLIC, true)
@@ -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,9 @@
1
+ import { Module } from '@nestjs/common'
2
+ import { TerminusModule } from '@nestjs/terminus'
3
+ import { HealthController } from './health.controller'
4
+
5
+ @Module({
6
+ imports: [TerminusModule],
7
+ controllers: [HealthController],
8
+ })
9
+ export class HealthModule {}
@@ -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,4 @@
1
+ import { loadEnv } from './config/env'
2
+ import { createOrmConfig } from './mikro-orm.factory'
3
+
4
+ export default createOrmConfig(loadEnv())
@@ -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
+ }