create-nexst-monorepo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. package/dist/index.js +1727 -0
  2. package/package.json +37 -0
  3. package/template/.agents/plugins/marketplace.json +21 -0
  4. package/template/.claude/settings.json +6 -0
  5. package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
  6. package/template/.claude/skills/frontend-design/SKILL.md +154 -0
  7. package/template/.claude/skills/theme-factory/SKILL.md +95 -0
  8. package/template/.claude/skills/theme-factory/references/schema.md +104 -0
  9. package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
  10. package/template/.dockerignore +43 -0
  11. package/template/.editorconfig +10 -0
  12. package/template/.github/workflows/ci.yaml +289 -0
  13. package/template/.github/workflows/publish-app.yaml +66 -0
  14. package/template/.github/workflows/publish.yaml +148 -0
  15. package/template/.github/workflows/snyk.yaml +34 -0
  16. package/template/.vscode/settings.json +8 -0
  17. package/template/AGENTS.md +42 -0
  18. package/template/Dockerfile.base +9 -0
  19. package/template/README.md +54 -0
  20. package/template/_gitignore +10 -0
  21. package/template/apps/api/.env.example +32 -0
  22. package/template/apps/api/AGENTS.md +44 -0
  23. package/template/apps/api/Dockerfile +69 -0
  24. package/template/apps/api/_gitignore +8 -0
  25. package/template/apps/api/knip.config.ts +22 -0
  26. package/template/apps/api/kustomize/base/application.properties +16 -0
  27. package/template/apps/api/kustomize/base/deployment.yaml +94 -0
  28. package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
  29. package/template/apps/api/kustomize/base/service.yaml +10 -0
  30. package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
  31. package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
  32. package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
  33. package/template/apps/api/kustomize/secret.keys +14 -0
  34. package/template/apps/api/oxfmt.config.ts +8 -0
  35. package/template/apps/api/oxlint.config.ts +107 -0
  36. package/template/apps/api/package.json +79 -0
  37. package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
  38. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  39. package/template/apps/api/prisma/schema.prisma +113 -0
  40. package/template/apps/api/prisma.config.ts +7 -0
  41. package/template/apps/api/scripts/auth.cli.ts +19 -0
  42. package/template/apps/api/scripts/seal-secret.sh +171 -0
  43. package/template/apps/api/sonar-project.properties +9 -0
  44. package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
  45. package/template/apps/api/src/app-info/app-info.module.ts +7 -0
  46. package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
  47. package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
  48. package/template/apps/api/src/app.module.spec.ts +93 -0
  49. package/template/apps/api/src/app.module.ts +77 -0
  50. package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
  51. package/template/apps/api/src/auth/auth.guard.ts +97 -0
  52. package/template/apps/api/src/auth/auth.ts +129 -0
  53. package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
  54. package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
  55. package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
  56. package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
  57. package/template/apps/api/src/auth/testing/make-session.ts +31 -0
  58. package/template/apps/api/src/auth/types/auth.types.ts +27 -0
  59. package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
  60. package/template/apps/api/src/bootstrap/security.ts +24 -0
  61. package/template/apps/api/src/bootstrap/swagger.ts +28 -0
  62. package/template/apps/api/src/config/app.config.spec.ts +22 -0
  63. package/template/apps/api/src/config/app.config.ts +122 -0
  64. package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
  65. package/template/apps/api/src/config/graphql.config.ts +77 -0
  66. package/template/apps/api/src/config/resources/app.yaml +29 -0
  67. package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
  68. package/template/apps/api/src/errors/app.exception.ts +47 -0
  69. package/template/apps/api/src/errors/error-code.enum.ts +54 -0
  70. package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
  71. package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
  72. package/template/apps/api/src/health/health.controller.spec.ts +82 -0
  73. package/template/apps/api/src/health/health.controller.ts +57 -0
  74. package/template/apps/api/src/health/health.module.ts +12 -0
  75. package/template/apps/api/src/logging/context-logger.ts +18 -0
  76. package/template/apps/api/src/logging/logger.config.ts +23 -0
  77. package/template/apps/api/src/logging/logging.module.ts +22 -0
  78. package/template/apps/api/src/mail/mail.module.ts +12 -0
  79. package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
  80. package/template/apps/api/src/mail/mail.service.ts +123 -0
  81. package/template/apps/api/src/main.ts +41 -0
  82. package/template/apps/api/src/prisma/prisma.module.ts +12 -0
  83. package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
  84. package/template/apps/api/src/prisma/prisma.service.ts +138 -0
  85. package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
  86. package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
  87. package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
  88. package/template/apps/api/src/queue/example-queue.service.ts +32 -0
  89. package/template/apps/api/src/queue/queue.module.ts +27 -0
  90. package/template/apps/api/src/r2/r2.module.ts +12 -0
  91. package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
  92. package/template/apps/api/src/r2/r2.service.ts +131 -0
  93. package/template/apps/api/src/user/dto/user.dto.ts +26 -0
  94. package/template/apps/api/src/user/user.module.ts +10 -0
  95. package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
  96. package/template/apps/api/src/user/user.resolver.ts +24 -0
  97. package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
  98. package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
  99. package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
  100. package/template/apps/api/src/utils/error-handler.util.ts +12 -0
  101. package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
  102. package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
  103. package/template/apps/api/src/utils/pagination.input.ts +20 -0
  104. package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
  105. package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
  106. package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
  107. package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
  108. package/template/apps/api/tests/app.e2e-spec.ts +35 -0
  109. package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
  110. package/template/apps/api/tests/helpers/api.ts +27 -0
  111. package/template/apps/api/tsconfig.json +11 -0
  112. package/template/apps/api/vitest.config.e2e.ts +16 -0
  113. package/template/apps/api/vitest.config.ts +40 -0
  114. package/template/apps/web/.env.example +7 -0
  115. package/template/apps/web/.storybook/decorators.tsx +38 -0
  116. package/template/apps/web/.storybook/main.ts +17 -0
  117. package/template/apps/web/.storybook/preview.ts +38 -0
  118. package/template/apps/web/.storybook/vitest.setup.ts +5 -0
  119. package/template/apps/web/AGENTS.md +29 -0
  120. package/template/apps/web/Dockerfile +49 -0
  121. package/template/apps/web/_gitignore +16 -0
  122. package/template/apps/web/knip.config.ts +15 -0
  123. package/template/apps/web/kustomize/base/application.properties +5 -0
  124. package/template/apps/web/kustomize/base/deployment.yaml +60 -0
  125. package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
  126. package/template/apps/web/kustomize/base/service.yaml +10 -0
  127. package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
  128. package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
  129. package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
  130. package/template/apps/web/messages/de.json +124 -0
  131. package/template/apps/web/messages/en.json +124 -0
  132. package/template/apps/web/next-env.d.ts +7 -0
  133. package/template/apps/web/next.config.ts +39 -0
  134. package/template/apps/web/oxfmt.config.ts +9 -0
  135. package/template/apps/web/oxlint.config.ts +36 -0
  136. package/template/apps/web/package.json +80 -0
  137. package/template/apps/web/playwright.config.ts +34 -0
  138. package/template/apps/web/postcss.config.mjs +7 -0
  139. package/template/apps/web/sonar-project.properties +9 -0
  140. package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
  141. package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
  142. package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
  143. package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
  144. package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
  145. package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
  146. package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
  147. package/template/apps/web/src/app/[locale]/page.tsx +23 -0
  148. package/template/apps/web/src/app/api/health/route.ts +3 -0
  149. package/template/apps/web/src/app/global-error.tsx +19 -0
  150. package/template/apps/web/src/app/globals.css +98 -0
  151. package/template/apps/web/src/app/icon.svg +1 -0
  152. package/template/apps/web/src/app/robots.ts +15 -0
  153. package/template/apps/web/src/app/sitemap.ts +24 -0
  154. package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
  155. package/template/apps/web/src/components/alert/alert.tsx +50 -0
  156. package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
  157. package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
  158. package/template/apps/web/src/components/button/button-variants.ts +28 -0
  159. package/template/apps/web/src/components/button/button.stories.tsx +33 -0
  160. package/template/apps/web/src/components/button/button.tsx +32 -0
  161. package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
  162. package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
  163. package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
  164. package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
  165. package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
  166. package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
  167. package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
  168. package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
  169. package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
  170. package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
  171. package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
  172. package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
  173. package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
  174. package/template/apps/web/src/components/empty-state.tsx +33 -0
  175. package/template/apps/web/src/components/field-group.stories.tsx +47 -0
  176. package/template/apps/web/src/components/field-group.tsx +20 -0
  177. package/template/apps/web/src/components/field-label.stories.tsx +34 -0
  178. package/template/apps/web/src/components/field-label.tsx +28 -0
  179. package/template/apps/web/src/components/icons.tsx +25 -0
  180. package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
  181. package/template/apps/web/src/components/input/input-otp.tsx +184 -0
  182. package/template/apps/web/src/components/input/input.stories.tsx +35 -0
  183. package/template/apps/web/src/components/input/input.tsx +180 -0
  184. package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
  185. package/template/apps/web/src/components/popover/popover.tsx +45 -0
  186. package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
  187. package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
  188. package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
  189. package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
  190. package/template/apps/web/src/components/select/select.stories.tsx +293 -0
  191. package/template/apps/web/src/components/select/select.tsx +445 -0
  192. package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
  193. package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
  194. package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
  195. package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
  196. package/template/apps/web/src/components/switch/switch.tsx +35 -0
  197. package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
  198. package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
  199. package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
  200. package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
  201. package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
  202. package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
  203. package/template/apps/web/src/components/toast/toaster.tsx +38 -0
  204. package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
  205. package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
  206. package/template/apps/web/src/config/csp.ts +59 -0
  207. package/template/apps/web/src/config/routes.ts +14 -0
  208. package/template/apps/web/src/config/site.ts +5 -0
  209. package/template/apps/web/src/env.ts +15 -0
  210. package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
  211. package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
  212. package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
  213. package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
  214. package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
  215. package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
  216. package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
  217. package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
  218. package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
  219. package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
  220. package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
  221. package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
  222. package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
  223. package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
  224. package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
  225. package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
  226. package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
  227. package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
  228. package/template/apps/web/src/features/shared/provider.tsx +16 -0
  229. package/template/apps/web/src/features/shared/site-header.tsx +48 -0
  230. package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
  231. package/template/apps/web/src/i18n/navigation.ts +5 -0
  232. package/template/apps/web/src/i18n/request.ts +21 -0
  233. package/template/apps/web/src/i18n/routing.ts +6 -0
  234. package/template/apps/web/src/lib/auth-client.ts +19 -0
  235. package/template/apps/web/src/lib/auth-server.ts +46 -0
  236. package/template/apps/web/src/lib/auth.ts +8 -0
  237. package/template/apps/web/src/lib/codegen.ts +30 -0
  238. package/template/apps/web/src/lib/graphql-client.ts +29 -0
  239. package/template/apps/web/src/lib/graphql-server.ts +28 -0
  240. package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
  241. package/template/apps/web/src/lib/graphql-utils.ts +76 -0
  242. package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
  243. package/template/apps/web/src/lib/metadata.ts +17 -0
  244. package/template/apps/web/src/lib/network-error.test.ts +30 -0
  245. package/template/apps/web/src/lib/network-error.ts +17 -0
  246. package/template/apps/web/src/lib/proxy-utils.ts +42 -0
  247. package/template/apps/web/src/lib/query-client.ts +29 -0
  248. package/template/apps/web/src/lib/utils.ts +5 -0
  249. package/template/apps/web/src/proxy.ts +57 -0
  250. package/template/apps/web/test-results/.last-run.json +4 -0
  251. package/template/apps/web/tests/auth.spec.ts +17 -0
  252. package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
  253. package/template/apps/web/tests/home.spec.ts +25 -0
  254. package/template/apps/web/tsconfig.build.json +4 -0
  255. package/template/apps/web/tsconfig.json +28 -0
  256. package/template/apps/web/vitest.config.ts +40 -0
  257. package/template/apps/web/vitest.shims.d.ts +1 -0
  258. package/template/apps/worker/.env.example +4 -0
  259. package/template/apps/worker/AGENTS.md +11 -0
  260. package/template/apps/worker/Dockerfile +35 -0
  261. package/template/apps/worker/knip.config.ts +3 -0
  262. package/template/apps/worker/kustomize/base/application.properties +4 -0
  263. package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
  264. package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
  265. package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
  266. package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
  267. package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
  268. package/template/apps/worker/oxfmt.config.ts +8 -0
  269. package/template/apps/worker/oxlint.config.ts +15 -0
  270. package/template/apps/worker/package.json +33 -0
  271. package/template/apps/worker/sonar-project.properties +8 -0
  272. package/template/apps/worker/src/env.ts +13 -0
  273. package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
  274. package/template/apps/worker/src/lib/logger.ts +20 -0
  275. package/template/apps/worker/src/main.ts +17 -0
  276. package/template/apps/worker/src/processors/example-processor.ts +15 -0
  277. package/template/apps/worker/src/worker/redis.ts +19 -0
  278. package/template/apps/worker/src/worker/worker.ts +37 -0
  279. package/template/apps/worker/tests/example-processor.test.ts +13 -0
  280. package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
  281. package/template/apps/worker/tsconfig.json +4 -0
  282. package/template/apps/worker/vitest.config.ts +13 -0
  283. package/template/bun.lock +3053 -0
  284. package/template/bunfig.toml +2 -0
  285. package/template/compose.yaml +53 -0
  286. package/template/docker-bake.hcl +86 -0
  287. package/template/package.json +47 -0
  288. package/template/packages/lint/knip.ts +40 -0
  289. package/template/packages/lint/oxfmt.ts +8 -0
  290. package/template/packages/lint/oxlint.ts +136 -0
  291. package/template/packages/lint/package.json +13 -0
  292. package/template/packages/queues/oxfmt.config.ts +8 -0
  293. package/template/packages/queues/oxlint.config.ts +7 -0
  294. package/template/packages/queues/package.json +15 -0
  295. package/template/packages/queues/src/example-job.ts +11 -0
  296. package/template/packages/queues/src/index.ts +3 -0
  297. package/template/packages/queues/src/queue-names.ts +2 -0
  298. package/template/packages/queues/src/redis-connection.ts +27 -0
  299. package/template/packages/queues/tsconfig.json +4 -0
  300. package/template/packages/tsconfig/base.json +23 -0
  301. package/template/packages/tsconfig/node.json +11 -0
  302. package/template/packages/tsconfig/package.json +4 -0
  303. package/template/turbo.json +50 -0
@@ -0,0 +1,14 @@
1
+ import type { ConfigService } from "@nestjs/config";
2
+ import { vi } from "vitest";
3
+ import { AppInfoResolver } from "#src/app-info/app-info.resolver.js";
4
+ import type { AppConfig } from "#src/config/app.config.js";
5
+
6
+ describe("appInfoResolver", () => {
7
+ it("returns the configured title and version", () => {
8
+ const cfg = {
9
+ get: vi.fn(() => ({ description: "d", title: "Test API", version: "1.2.3" })),
10
+ } as unknown as ConfigService<AppConfig, true>;
11
+
12
+ expect(new AppInfoResolver(cfg).appInfo()).toEqual({ title: "Test API", version: "1.2.3" });
13
+ });
14
+ });
@@ -0,0 +1,21 @@
1
+ import { ConfigService } from "@nestjs/config";
2
+ import { Query, Resolver } from "@nestjs/graphql";
3
+ import { AppInfoDto } from "#src/app-info/app-info.dto.js";
4
+ import { AllowAnonymous } from "#src/auth/decorator/allow-anonymous.decorator.js"; // @if auth
5
+ import type { AppConfig } from "#src/config/app.config.js";
6
+
7
+ /**
8
+ * Public metadata about the running API. Also guarantees the GraphQL schema
9
+ * always has a root `Query`, which Apollo requires.
10
+ */
11
+ @Resolver(() => AppInfoDto)
12
+ export class AppInfoResolver {
13
+ constructor(private readonly cfg: ConfigService<AppConfig, true>) {}
14
+
15
+ @AllowAnonymous() // @if auth
16
+ @Query(() => AppInfoDto)
17
+ appInfo(): AppInfoDto {
18
+ const { title, version } = this.cfg.get("app", { infer: true });
19
+ return { title, version };
20
+ }
21
+ }
@@ -0,0 +1,93 @@
1
+ import type { NestFastifyApplication } from "@nestjs/platform-fastify";
2
+ import { FastifyAdapter } from "@nestjs/platform-fastify";
3
+ import type { TestingModule } from "@nestjs/testing";
4
+ import { Test } from "@nestjs/testing";
5
+ import { Logger } from "nestjs-pino";
6
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
7
+ import { AppModule } from "#src/app.module.js";
8
+ import { registerGraphqlEnums } from "#src/bootstrap/register-graphql-enums.js"; // @if auth
9
+ import { addSecurity } from "#src/bootstrap/security.js";
10
+ import { addSwagger } from "#src/bootstrap/swagger.js";
11
+ import { PrismaService } from "#src/prisma/prisma.service.js";
12
+
13
+ // @if auth || worker
14
+ const { fn: mockFn } = vi.hoisted(() => ({ fn: vi.fn }));
15
+ // @endif
16
+
17
+ // @if auth
18
+ vi.mock("#src/auth/auth.js", () => ({
19
+ createAuth: mockFn(() => ({ api: { getSession: mockFn() }, options: {} })),
20
+ }));
21
+ // @endif
22
+ // @if worker
23
+
24
+ vi.mock("ioredis", () => {
25
+ const Redis = mockFn(function () {
26
+ return {
27
+ disconnect: mockFn().mockResolvedValue("OK"),
28
+ off: mockFn(),
29
+ on: mockFn(),
30
+ ping: mockFn().mockResolvedValue("PONG"),
31
+ quit: mockFn().mockResolvedValue("OK"),
32
+ };
33
+ });
34
+ return { default: Redis, Redis };
35
+ });
36
+
37
+ vi.mock("bullmq", () => {
38
+ function connectionLike() {
39
+ return {
40
+ add: mockFn(),
41
+ close: mockFn().mockResolvedValue(undefined),
42
+ off: mockFn(),
43
+ on: mockFn(),
44
+ waitUntilReady: mockFn().mockResolvedValue(undefined),
45
+ };
46
+ }
47
+ return {
48
+ FlowProducer: mockFn(connectionLike),
49
+ Job: class Job {},
50
+ Queue: mockFn(connectionLike),
51
+ QueueEvents: mockFn(connectionLike),
52
+ Worker: mockFn(connectionLike),
53
+ };
54
+ });
55
+ // @endif
56
+
57
+ describe("appModule (wiring)", () => {
58
+ let moduleRef: TestingModule;
59
+ let app: NestFastifyApplication;
60
+
61
+ beforeAll(async () => {
62
+ const prismaMock = {
63
+ $connect: vi.fn(),
64
+ $disconnect: vi.fn(),
65
+ $on: vi.fn(),
66
+ onModuleDestroy: vi.fn(),
67
+ onModuleInit: vi.fn(),
68
+ };
69
+
70
+ moduleRef = await Test.createTestingModule({ imports: [AppModule] })
71
+ .overrideProvider(PrismaService)
72
+ .useValue(prismaMock)
73
+ .compile();
74
+
75
+ app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
76
+
77
+ await addSecurity(app);
78
+ registerGraphqlEnums(); // @if auth
79
+ app.useLogger(app.get(Logger));
80
+ addSwagger(app);
81
+
82
+ await app.init();
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await app?.close();
87
+ await moduleRef?.close();
88
+ });
89
+
90
+ it("compiles", () => {
91
+ expect(app).toBeDefined();
92
+ });
93
+ });
@@ -0,0 +1,77 @@
1
+ import type { ApolloDriverConfig } from "@nestjs/apollo";
2
+ import { Module } from "@nestjs/common";
3
+ import { ConfigModule, ConfigService } from "@nestjs/config"; // @if auth
4
+ // import { ConfigModule } from "@nestjs/config"; // @if !auth
5
+ import { APP_FILTER, APP_GUARD } from "@nestjs/core"; // @if auth
6
+ // import { APP_FILTER } from "@nestjs/core"; // @if !auth
7
+ import { GraphQLModule } from "@nestjs/graphql";
8
+ import { ThrottlerModule } from "@nestjs/throttler";
9
+ import { AuthModule } from "@thallesp/nestjs-better-auth"; // @if auth
10
+ import { AppInfoModule } from "#src/app-info/app-info.module.js";
11
+ import { AuthGuard } from "#src/auth/auth.guard.js"; // @if auth
12
+ import { createAuth } from "#src/auth/auth.js"; // @if auth
13
+ import { loadConfig } from "#src/config/app.config.js";
14
+ import type { AppConfig } from "#src/config/app.config.js"; // @if auth
15
+ import { graphqlConfig } from "#src/config/graphql.config.js";
16
+ import { HealthModule } from "#src/health/health.module.js";
17
+ import { LoggingModule } from "#src/logging/logging.module.js";
18
+ import { MailModule } from "#src/mail/mail.module.js"; // @if auth
19
+ import { MailService } from "#src/mail/mail.service.js"; // @if auth
20
+ import { PrismaModule } from "#src/prisma/prisma.module.js";
21
+ import { PrismaService } from "#src/prisma/prisma.service.js"; // @if auth
22
+ import { QueueModule } from "#src/queue/queue.module.js"; // @if worker
23
+ import { R2Module } from "#src/r2/r2.module.js"; // @if r2
24
+ import { UserModule } from "#src/user/user.module.js"; // @if auth
25
+ import { CatchEverythingFilter } from "#src/utils/catch-everything-exception.filter.js";
26
+ import { PrismaExceptionFilter } from "#src/utils/prisma-exception.filter.js";
27
+ import { PrismaGraphQLExceptionFilter } from "#src/utils/prisma-graphql-exception.filter.js";
28
+
29
+ /**
30
+ * Root NestJS module
31
+ */
32
+ @Module({
33
+ imports: [
34
+ GraphQLModule.forRoot<ApolloDriverConfig>(graphqlConfig),
35
+ LoggingModule,
36
+ ConfigModule.forRoot({ isGlobal: true, load: [loadConfig] }),
37
+ ThrottlerModule.forRoot([
38
+ {
39
+ limit: 100,
40
+ skipIf: () => process.env.NODE_ENV !== "production",
41
+ ttl: 10_000,
42
+ },
43
+ ]),
44
+ PrismaModule,
45
+ HealthModule,
46
+ AppInfoModule,
47
+ // @if auth
48
+ MailModule,
49
+ AuthModule.forRootAsync({
50
+ disableGlobalAuthGuard: true,
51
+ imports: [ConfigModule, PrismaModule, MailModule],
52
+ inject: [PrismaService, ConfigService, MailService],
53
+ useFactory: (
54
+ prisma: PrismaService,
55
+ cfg: ConfigService<AppConfig, true>,
56
+ mail: MailService,
57
+ ) => ({
58
+ auth: createAuth(prisma, cfg.get("security", { infer: true }).allowedOrigins, mail),
59
+ }),
60
+ }),
61
+ UserModule,
62
+ // @endif
63
+ // @if r2
64
+ R2Module,
65
+ // @endif
66
+ // @if worker
67
+ QueueModule,
68
+ // @endif
69
+ ],
70
+ providers: [
71
+ { provide: APP_GUARD, useClass: AuthGuard }, // @if auth
72
+ { provide: APP_FILTER, useClass: PrismaExceptionFilter },
73
+ { provide: APP_FILTER, useClass: PrismaGraphQLExceptionFilter },
74
+ { provide: APP_FILTER, useClass: CatchEverythingFilter },
75
+ ],
76
+ })
77
+ export class AppModule {}
@@ -0,0 +1,90 @@
1
+ import type { ExecutionContext } from "@nestjs/common";
2
+ import type { Reflector } from "@nestjs/core";
3
+ import type { AuthService } from "@thallesp/nestjs-better-auth";
4
+ import { vi } from "vitest";
5
+ import type { Mock } from "vitest";
6
+ import { AuthGuard } from "#src/auth/auth.guard.js";
7
+ import type { AuthInstance } from "#src/auth/auth.js";
8
+ import { ALLOW_ANONYMOUS_KEY } from "#src/auth/decorator/allow-anonymous.decorator.js";
9
+ import { IS_EMAIL_VERIFIED_KEY } from "#src/auth/decorator/is-email-verified.decorator.js";
10
+ import { ROLES_KEY } from "#src/auth/decorator/roles.decorator.js";
11
+ import { makeSession } from "#src/auth/testing/make-session.js";
12
+ import { ErrorCode } from "#src/errors/error-code.enum.js";
13
+ import { UserRole } from "#src/generated/prisma/client.js";
14
+
15
+ type AnyRequest = Record<string, unknown>;
16
+
17
+ const makeContext = (request: AnyRequest): ExecutionContext =>
18
+ ({
19
+ getClass: () => ({}),
20
+ getHandler: () => ({}),
21
+ getType: () => "http",
22
+ switchToHttp: () => ({ getRequest: () => request }),
23
+ }) as unknown as ExecutionContext;
24
+
25
+ describe("authGuard", () => {
26
+ let guard: AuthGuard;
27
+ let reflector: { getAllAndOverride: Mock };
28
+
29
+ const build = (session: unknown) => {
30
+ const getSession = vi.fn().mockResolvedValue(session);
31
+ const authService = { api: { getSession } } as unknown as AuthService<AuthInstance>;
32
+ guard = new AuthGuard(reflector as unknown as Reflector, authService);
33
+ };
34
+
35
+ /** Marks metadata keys as present (as if the route carried their decorators). */
36
+ const withMetadata = (metadata: Record<string, unknown>) =>
37
+ reflector.getAllAndOverride.mockImplementation((key: string) => metadata[key]);
38
+
39
+ beforeEach(() => {
40
+ reflector = { getAllAndOverride: vi.fn().mockReturnValue(undefined) };
41
+ build(makeSession());
42
+ });
43
+
44
+ it("rejects anonymous access to a protected route", async () => {
45
+ build(null);
46
+
47
+ await expect(guard.canActivate(makeContext({ headers: {} }))).rejects.toMatchObject({
48
+ code: ErrorCode.UNAUTHORIZED,
49
+ });
50
+ });
51
+
52
+ it("allows @AllowAnonymous routes without a session", async () => {
53
+ withMetadata({ [ALLOW_ANONYMOUS_KEY]: true });
54
+ build(null);
55
+
56
+ await expect(guard.canActivate(makeContext({ headers: {} }))).resolves.toBe(true);
57
+ });
58
+
59
+ it("attaches the resolved session to the request", async () => {
60
+ const request: AnyRequest = { headers: {} };
61
+
62
+ await guard.canActivate(makeContext(request));
63
+
64
+ expect(request.user).toMatchObject({ id: "user-1" });
65
+ });
66
+
67
+ it("rejects an unverified email on @IsEmailVerified routes", async () => {
68
+ withMetadata({ [IS_EMAIL_VERIFIED_KEY]: true });
69
+ build(makeSession("user-1", { emailVerified: false }));
70
+
71
+ await expect(guard.canActivate(makeContext({ headers: {} }))).rejects.toMatchObject({
72
+ code: ErrorCode.EMAIL_NOT_VERIFIED,
73
+ });
74
+ });
75
+
76
+ it("rejects a user without any of the @Roles", async () => {
77
+ withMetadata({ [ROLES_KEY]: [UserRole.ADMIN] });
78
+
79
+ await expect(guard.canActivate(makeContext({ headers: {} }))).rejects.toMatchObject({
80
+ code: ErrorCode.INSUFFICIENT_ROLE,
81
+ });
82
+ });
83
+
84
+ it("allows a user holding one of the @Roles", async () => {
85
+ withMetadata({ [ROLES_KEY]: [UserRole.ADMIN] });
86
+ build(makeSession("user-1", { role: [UserRole.USER, UserRole.ADMIN] }));
87
+
88
+ await expect(guard.canActivate(makeContext({ headers: {} }))).resolves.toBe(true);
89
+ });
90
+ });
@@ -0,0 +1,97 @@
1
+ import { HttpStatus, Injectable } from "@nestjs/common";
2
+ import type { CanActivate, ExecutionContext } from "@nestjs/common";
3
+ import { Reflector } from "@nestjs/core";
4
+ import { GqlExecutionContext } from "@nestjs/graphql";
5
+ import { AuthService } from "@thallesp/nestjs-better-auth";
6
+ import { fromNodeHeaders } from "better-auth/node";
7
+ import type { AuthInstance } from "#src/auth/auth.js";
8
+ import { ALLOW_ANONYMOUS_KEY } from "#src/auth/decorator/allow-anonymous.decorator.js";
9
+ import { IS_EMAIL_VERIFIED_KEY } from "#src/auth/decorator/is-email-verified.decorator.js";
10
+ import { ROLES_KEY } from "#src/auth/decorator/roles.decorator.js";
11
+ import type { RequestWithSession, UserSession } from "#src/auth/types/auth.types.js";
12
+ import { AppException } from "#src/errors/app.exception.js";
13
+ import { ErrorCode } from "#src/errors/error-code.enum.js";
14
+ import type { UserRole } from "#src/generated/prisma/client.js";
15
+
16
+ /**
17
+ * Single global authentication + authorization guard.
18
+ *
19
+ * Better Auth's own global guard is disabled (`disableGlobalAuthGuard: true`),
20
+ * so this one guard both authenticates and authorizes — which guarantees
21
+ * authentication runs *before* the app-specific checks, instead of relying on
22
+ * the order two separate global guards happen to execute in.
23
+ *
24
+ * Flow:
25
+ *
26
+ * 1. Resolve the session and attach it — with the app's `customSession` fields —
27
+ * to `request.session`/`request.user`.
28
+ * 2. `@AllowAnonymous()` routes stay reachable without a session (e.g. `/health`).
29
+ * Every other route requires a session (401 otherwise).
30
+ * 3. Enforce email verification on `@IsEmailVerified()` routes.
31
+ * 4. Enforce role membership on `@Roles(...)` routes.
32
+ */
33
+ @Injectable()
34
+ export class AuthGuard implements CanActivate {
35
+ constructor(
36
+ private readonly reflector: Reflector,
37
+ private readonly authService: AuthService<AuthInstance>,
38
+ ) {}
39
+
40
+ async canActivate(context: ExecutionContext): Promise<boolean> {
41
+ const request = this.extractRequest(context);
42
+
43
+ const session = (await this.authService.api.getSession({
44
+ headers: fromNodeHeaders(request.headers),
45
+ })) as null | UserSession;
46
+ request.session = session;
47
+ request.user = session?.user ?? null;
48
+
49
+ if (this.requires(context, ALLOW_ANONYMOUS_KEY)) {
50
+ return true;
51
+ }
52
+
53
+ if (!session) {
54
+ throw new AppException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED);
55
+ }
56
+
57
+ if (this.requires(context, IS_EMAIL_VERIFIED_KEY) && !session.user.emailVerified) {
58
+ throw new AppException(ErrorCode.EMAIL_NOT_VERIFIED, HttpStatus.FORBIDDEN);
59
+ }
60
+
61
+ const requiredRoles = this.reflector.getAllAndOverride<undefined | UserRole[]>(ROLES_KEY, [
62
+ context.getHandler(),
63
+ context.getClass(),
64
+ ]);
65
+ if (requiredRoles?.length && !requiredRoles.some((role) => session.user.role.includes(role))) {
66
+ throw new AppException(ErrorCode.INSUFFICIENT_ROLE, HttpStatus.FORBIDDEN);
67
+ }
68
+
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Extracts the request object from an HTTP or GraphQL execution context.
74
+ */
75
+ private extractRequest(context: ExecutionContext): RequestWithSession {
76
+ if (context.getType<"graphql" | "http">() === "graphql") {
77
+ return GqlExecutionContext.create(context).getContext<{
78
+ req: RequestWithSession;
79
+ }>().req;
80
+ }
81
+
82
+ const request = context.switchToHttp().getRequest<RequestWithSession | undefined>();
83
+ if (!request) {
84
+ throw new AppException(ErrorCode.REQUEST_CONTEXT_MISSING, HttpStatus.UNAUTHORIZED);
85
+ }
86
+ return request;
87
+ }
88
+
89
+ private requires(context: ExecutionContext, key: string): boolean {
90
+ return (
91
+ this.reflector.getAllAndOverride<boolean | undefined>(key, [
92
+ context.getHandler(),
93
+ context.getClass(),
94
+ ]) ?? false
95
+ );
96
+ }
97
+ }
@@ -0,0 +1,129 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { passkey } from "@better-auth/passkey";
3
+ import { prismaAdapter } from "better-auth/adapters/prisma";
4
+ import { betterAuth } from "better-auth/minimal";
5
+ import { customSession, oneTap } from "better-auth/plugins";
6
+ import type { Prisma, PrismaClient } from "#src/generated/prisma/client.js";
7
+ import type { MailService } from "#src/mail/mail.service.js";
8
+ import type { PrismaService } from "#src/prisma/prisma.service.js";
9
+
10
+ /**
11
+ * App fields merged onto the session user via the {@link customSession}
12
+ * plugin. Better Auth only returns its own core columns, so the app fields are
13
+ * selected explicitly here. Mirrors `SessionProfile` in `types/auth.types.ts`;
14
+ * keep them in sync.
15
+ */
16
+ const USER_SESSION_SELECT = {
17
+ role: true,
18
+ } satisfies Prisma.UserSelect;
19
+
20
+ /**
21
+ * Google sign-in (button, One Tap, account linking) is on only when both
22
+ * credentials are set, so local dev and E2E runs work without a Google client.
23
+ */
24
+ const googleProvider = () => {
25
+ const clientId = process.env.GOOGLE_CLIENT_ID?.trim();
26
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim();
27
+ return clientId && clientSecret ? { google: { clientId, clientSecret } } : undefined;
28
+ };
29
+
30
+ /**
31
+ * Shared better-auth configuration.
32
+ *
33
+ * Better Auth owns the Prisma `User`, `Session`, `Account` and `Verification`
34
+ * models. The prisma adapter resolves a model via `prisma[modelName]`, so the
35
+ * names must match the camelCase Prisma client accessors.
36
+ *
37
+ * @param prisma - Any PrismaClient-compatible instance (the NestJS PrismaService
38
+ * at runtime, or a bare client for the better-auth CLI).
39
+ * @param allowedOrigins - Origins from the shared security/CORS configuration.
40
+ */
41
+ export const buildBaseAuthOptions = (prisma: PrismaClient, allowedOrigins: string[]) => {
42
+ const cookieDomain = process.env.COOKIE_DOMAIN?.trim();
43
+ const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3001";
44
+ // Passkeys are bound to the registrable domain the web app runs on (the API sits on
45
+ // a subdomain), so the relying party is the cookie domain, not the API host.
46
+ const passkeyRpId = cookieDomain ? cookieDomain.replace(/^\./, "") : new URL(baseURL).hostname;
47
+ const google = googleProvider();
48
+
49
+ return {
50
+ account: {
51
+ // A Google sign-in with the email of an existing account links to it instead of failing.
52
+ accountLinking: { enabled: true, trustedProviders: ["google"] },
53
+ },
54
+ advanced: {
55
+ database: { generateId: () => randomUUID() },
56
+ ipAddress: { ipAddressHeaders: ["cf-connecting-ip", "x-forwarded-for"] },
57
+ ...(cookieDomain ? { crossSubDomainCookies: { domain: cookieDomain, enabled: true } } : {}),
58
+ },
59
+ baseURL,
60
+ database: prismaAdapter(prisma, { provider: "postgresql" }),
61
+ // E2E runs (NODE_ENV=test) skip the verification mail round-trip.
62
+ ...(process.env.NODE_ENV === "test"
63
+ ? {
64
+ databaseHooks: {
65
+ user: {
66
+ create: {
67
+ before: () => Promise.resolve({ data: { emailVerified: true } }),
68
+ },
69
+ },
70
+ },
71
+ }
72
+ : {}),
73
+ emailAndPassword: {
74
+ enabled: true,
75
+ },
76
+ emailVerification: {
77
+ autoSignInAfterVerification: true,
78
+ },
79
+ plugins: [
80
+ passkey({ origin: allowedOrigins, rpID: passkeyRpId, rpName: "Nexst" }),
81
+ oneTap(),
82
+ customSession(async ({ session, user }) => {
83
+ const profile = await prisma.user.findUnique({
84
+ select: USER_SESSION_SELECT,
85
+ where: { id: user.id },
86
+ });
87
+ return { session, user: { ...user, ...profile } };
88
+ }),
89
+ ],
90
+ secret: process.env.BETTER_AUTH_SECRET,
91
+ ...(google ? { socialProviders: google } : {}),
92
+ trustedOrigins: allowedOrigins,
93
+ } satisfies Parameters<typeof betterAuth>[0];
94
+ };
95
+
96
+ /**
97
+ * Builds the better-auth instance from the app's PrismaService.
98
+ *
99
+ * PrismaService extends PrismaClient, so it can be passed straight to the prisma
100
+ * adapter. This reuses the same connection pool, ConfigService-derived URL, and
101
+ * logging as the rest of the app instead of opening a second client.
102
+ *
103
+ * @param mail - The injected MailService used to deliver auth emails.
104
+ */
105
+ export const createAuth = (prisma: PrismaService, allowedOrigins: string[], mail: MailService) => {
106
+ const options = buildBaseAuthOptions(prisma, allowedOrigins);
107
+
108
+ return betterAuth({
109
+ ...options,
110
+ emailAndPassword: {
111
+ ...options.emailAndPassword,
112
+ sendResetPassword: async ({ url, user }) => {
113
+ await mail.sendPasswordResetEmail(user.email, url);
114
+ },
115
+ },
116
+ emailVerification: {
117
+ ...options.emailVerification,
118
+ sendOnSignUp: true,
119
+ sendVerificationEmail: async ({ url, user }) => {
120
+ await mail.sendVerificationEmail(user.email, url);
121
+ },
122
+ },
123
+ });
124
+ };
125
+
126
+ /**
127
+ * Type of the configured better-auth instance, for injection sites.
128
+ */
129
+ export type AuthInstance = ReturnType<typeof createAuth>;
@@ -0,0 +1,17 @@
1
+ import { SetMetadata } from "@nestjs/common";
2
+
3
+ /**
4
+ * Metadata key that marks a route as public. Same `"PUBLIC"` string the library's
5
+ * `@AllowAnonymous()` uses; kept as a named constant here because the library
6
+ * does not export it and {@link AuthGuard} needs to read it.
7
+ */
8
+ export const ALLOW_ANONYMOUS_KEY = "PUBLIC";
9
+
10
+ /**
11
+ * Allows unauthenticated (anonymous) access to a route or controller.
12
+ *
13
+ * Local wrapper over the library's `@AllowAnonymous()` that sets
14
+ * {@link ALLOW_ANONYMOUS_KEY}. The library's own global guard is disabled
15
+ * (`disableGlobalAuthGuard: true`), so {@link AuthGuard} enforces this.
16
+ */
17
+ export const AllowAnonymous = () => SetMetadata(ALLOW_ANONYMOUS_KEY, true);
@@ -0,0 +1,41 @@
1
+ import { Reflector } from "@nestjs/core";
2
+ import {
3
+ IS_EMAIL_VERIFIED_KEY,
4
+ IsEmailVerified,
5
+ } from "#src/auth/decorator/is-email-verified.decorator.js";
6
+
7
+ describe("isEmailVerified", () => {
8
+ it("should set metadata to true", () => {
9
+ const decorator = IsEmailVerified();
10
+
11
+ class TestClass {
12
+ @decorator
13
+ testMethod() {
14
+ return "test";
15
+ }
16
+ }
17
+
18
+ const reflector = new Reflector();
19
+ const metadata = reflector.get(IS_EMAIL_VERIFIED_KEY, TestClass.prototype.testMethod);
20
+
21
+ expect(metadata).toBe(true);
22
+ });
23
+
24
+ it("should be usable as a method decorator", () => {
25
+ class TestController {
26
+ @IsEmailVerified()
27
+ testMethod() {
28
+ return "test";
29
+ }
30
+ }
31
+
32
+ const reflector = new Reflector();
33
+ const metadata = reflector.get(IS_EMAIL_VERIFIED_KEY, TestController.prototype.testMethod);
34
+
35
+ expect(metadata).toBe(true);
36
+ });
37
+
38
+ it("should have the correct metadata key", () => {
39
+ expect(IS_EMAIL_VERIFIED_KEY).toBe("auth:is-email-verified");
40
+ });
41
+ });
@@ -0,0 +1,20 @@
1
+ import { SetMetadata } from "@nestjs/common";
2
+
3
+ export const IS_EMAIL_VERIFIED_KEY = "auth:is-email-verified";
4
+
5
+ /**
6
+ * Decorator to require email verification for route access.
7
+ *
8
+ * When applied, the AuthGuard checks `session.user.emailVerified` from
9
+ * Better Auth and rejects the request if the email is not verified.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * @IsEmailVerified()
14
+ * @Mutation(() => Boolean)
15
+ * publish(@Session() session: UserSession) {
16
+ * ...
17
+ * }
18
+ * ```
19
+ */
20
+ export const IsEmailVerified = () => SetMetadata(IS_EMAIL_VERIFIED_KEY, true);
@@ -0,0 +1,27 @@
1
+ import { SetMetadata } from "@nestjs/common";
2
+ import type { UserRole } from "#src/generated/prisma/client.js";
3
+
4
+ /**
5
+ * Metadata key read by Better Auth's global AuthGuard to enforce role access.
6
+ * Mirrors the `@Roles()` decorator from `@thallesp/nestjs-better-auth`.
7
+ */
8
+ export const ROLES_KEY = "ROLES";
9
+
10
+ /**
11
+ * Restricts a route to users holding at least one of the given {@link UserRole}s.
12
+ * Roles are additive: a user matches if any of their roles is listed.
13
+ *
14
+ * A local wrapper over the library's `@Roles()`: it takes a variadic list of the
15
+ * Prisma {@link UserRole} enum (rather than a `string[]`) and sets the same
16
+ * `ROLES` metadata key. The library's own global guard is disabled
17
+ * (`disableGlobalAuthGuard: true`), so the check is enforced by our
18
+ * {@link AuthGuard}, which reads this key.
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * @Roles(UserRole.ADMIN)
23
+ * @Mutation(() => Boolean)
24
+ * purgeCache() { ... }
25
+ * ```
26
+ */
27
+ export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,31 @@
1
+ import type { SessionUser, UserSession } from "#src/auth/types/auth.types.js";
2
+ import { UserRole } from "#src/generated/prisma/client.js";
3
+
4
+ /**
5
+ * Builds a fully populated {@link UserSession} for use in unit tests, mirroring
6
+ * what Better Auth's `customSession` plugin attaches to `request.session`.
7
+ *
8
+ * @param id - The authenticated user id.
9
+ * @param user - Overrides for the session user.
10
+ */
11
+ export const makeSession = (id = "user-1", user: Partial<SessionUser> = {}): UserSession => ({
12
+ session: {
13
+ createdAt: new Date(0),
14
+ expiresAt: new Date(0),
15
+ id: "session-1",
16
+ token: "token-1",
17
+ updatedAt: new Date(0),
18
+ userId: id,
19
+ },
20
+ user: {
21
+ createdAt: new Date(0),
22
+ email: "test@example.com",
23
+ emailVerified: true,
24
+ id,
25
+ image: null,
26
+ name: "Test User",
27
+ role: [UserRole.USER],
28
+ updatedAt: new Date(0),
29
+ ...user,
30
+ },
31
+ });