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.
- package/dist/index.js +1727 -0
- package/package.json +37 -0
- package/template/.agents/plugins/marketplace.json +21 -0
- package/template/.claude/settings.json +6 -0
- package/template/.claude/skills/frontend-design/LICENSE.txt +177 -0
- package/template/.claude/skills/frontend-design/SKILL.md +154 -0
- package/template/.claude/skills/theme-factory/SKILL.md +95 -0
- package/template/.claude/skills/theme-factory/references/schema.md +104 -0
- package/template/.claude/skills/theme-factory/scripts/theme.mjs +261 -0
- package/template/.dockerignore +43 -0
- package/template/.editorconfig +10 -0
- package/template/.github/workflows/ci.yaml +289 -0
- package/template/.github/workflows/publish-app.yaml +66 -0
- package/template/.github/workflows/publish.yaml +148 -0
- package/template/.github/workflows/snyk.yaml +34 -0
- package/template/.vscode/settings.json +8 -0
- package/template/AGENTS.md +42 -0
- package/template/Dockerfile.base +9 -0
- package/template/README.md +54 -0
- package/template/_gitignore +10 -0
- package/template/apps/api/.env.example +32 -0
- package/template/apps/api/AGENTS.md +44 -0
- package/template/apps/api/Dockerfile +69 -0
- package/template/apps/api/_gitignore +8 -0
- package/template/apps/api/knip.config.ts +22 -0
- package/template/apps/api/kustomize/base/application.properties +16 -0
- package/template/apps/api/kustomize/base/deployment.yaml +94 -0
- package/template/apps/api/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/base/service.yaml +10 -0
- package/template/apps/api/kustomize/overlays/prod/kustomization.yaml +17 -0
- package/template/apps/api/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/api/kustomize/overlays/staging/kustomization.yaml +21 -0
- package/template/apps/api/kustomize/secret.keys +14 -0
- package/template/apps/api/oxfmt.config.ts +8 -0
- package/template/apps/api/oxlint.config.ts +107 -0
- package/template/apps/api/package.json +79 -0
- package/template/apps/api/prisma/migrations/20260927000000_init/migration.sql +113 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/schema.prisma +113 -0
- package/template/apps/api/prisma.config.ts +7 -0
- package/template/apps/api/scripts/auth.cli.ts +19 -0
- package/template/apps/api/scripts/seal-secret.sh +171 -0
- package/template/apps/api/sonar-project.properties +9 -0
- package/template/apps/api/src/app-info/app-info.dto.ts +10 -0
- package/template/apps/api/src/app-info/app-info.module.ts +7 -0
- package/template/apps/api/src/app-info/app-info.resolver.spec.ts +14 -0
- package/template/apps/api/src/app-info/app-info.resolver.ts +21 -0
- package/template/apps/api/src/app.module.spec.ts +93 -0
- package/template/apps/api/src/app.module.ts +77 -0
- package/template/apps/api/src/auth/auth.guard.spec.ts +90 -0
- package/template/apps/api/src/auth/auth.guard.ts +97 -0
- package/template/apps/api/src/auth/auth.ts +129 -0
- package/template/apps/api/src/auth/decorator/allow-anonymous.decorator.ts +17 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.spec.ts +41 -0
- package/template/apps/api/src/auth/decorator/is-email-verified.decorator.ts +20 -0
- package/template/apps/api/src/auth/decorator/roles.decorator.ts +27 -0
- package/template/apps/api/src/auth/testing/make-session.ts +31 -0
- package/template/apps/api/src/auth/types/auth.types.ts +27 -0
- package/template/apps/api/src/bootstrap/register-graphql-enums.ts +12 -0
- package/template/apps/api/src/bootstrap/security.ts +24 -0
- package/template/apps/api/src/bootstrap/swagger.ts +28 -0
- package/template/apps/api/src/config/app.config.spec.ts +22 -0
- package/template/apps/api/src/config/app.config.ts +122 -0
- package/template/apps/api/src/config/graphql-depth-limit.ts +103 -0
- package/template/apps/api/src/config/graphql.config.ts +77 -0
- package/template/apps/api/src/config/resources/app.yaml +29 -0
- package/template/apps/api/src/errors/app-parse-uuid.pipe.ts +27 -0
- package/template/apps/api/src/errors/app.exception.ts +47 -0
- package/template/apps/api/src/errors/error-code.enum.ts +54 -0
- package/template/apps/api/src/errors/http-status-fallback-code.util.ts +27 -0
- package/template/apps/api/src/errors/validation-exception.factory.ts +43 -0
- package/template/apps/api/src/health/health.controller.spec.ts +82 -0
- package/template/apps/api/src/health/health.controller.ts +57 -0
- package/template/apps/api/src/health/health.module.ts +12 -0
- package/template/apps/api/src/logging/context-logger.ts +18 -0
- package/template/apps/api/src/logging/logger.config.ts +23 -0
- package/template/apps/api/src/logging/logging.module.ts +22 -0
- package/template/apps/api/src/mail/mail.module.ts +12 -0
- package/template/apps/api/src/mail/mail.service.spec.ts +47 -0
- package/template/apps/api/src/mail/mail.service.ts +123 -0
- package/template/apps/api/src/main.ts +41 -0
- package/template/apps/api/src/prisma/prisma.module.ts +12 -0
- package/template/apps/api/src/prisma/prisma.service.spec.ts +136 -0
- package/template/apps/api/src/prisma/prisma.service.ts +138 -0
- package/template/apps/api/src/queue/dto/enqueue-example.input.ts +11 -0
- package/template/apps/api/src/queue/example-queue.resolver.ts +14 -0
- package/template/apps/api/src/queue/example-queue.service.spec.ts +21 -0
- package/template/apps/api/src/queue/example-queue.service.ts +32 -0
- package/template/apps/api/src/queue/queue.module.ts +27 -0
- package/template/apps/api/src/r2/r2.module.ts +12 -0
- package/template/apps/api/src/r2/r2.service.spec.ts +117 -0
- package/template/apps/api/src/r2/r2.service.ts +131 -0
- package/template/apps/api/src/user/dto/user.dto.ts +26 -0
- package/template/apps/api/src/user/user.module.ts +10 -0
- package/template/apps/api/src/user/user.resolver.spec.ts +18 -0
- package/template/apps/api/src/user/user.resolver.ts +24 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.spec.ts +292 -0
- package/template/apps/api/src/utils/catch-everything-exception.filter.ts +70 -0
- package/template/apps/api/src/utils/error-handler.util.spec.ts +20 -0
- package/template/apps/api/src/utils/error-handler.util.ts +12 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.spec.ts +29 -0
- package/template/apps/api/src/utils/escape-like-pattern.util.ts +11 -0
- package/template/apps/api/src/utils/pagination.input.ts +20 -0
- package/template/apps/api/src/utils/prisma-exception.filter.spec.ts +186 -0
- package/template/apps/api/src/utils/prisma-exception.filter.ts +45 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.spec.ts +216 -0
- package/template/apps/api/src/utils/prisma-graphql-exception.filter.ts +43 -0
- package/template/apps/api/tests/app.e2e-spec.ts +35 -0
- package/template/apps/api/tests/auth.e2e-spec.ts +38 -0
- package/template/apps/api/tests/helpers/api.ts +27 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/api/vitest.config.e2e.ts +16 -0
- package/template/apps/api/vitest.config.ts +40 -0
- package/template/apps/web/.env.example +7 -0
- package/template/apps/web/.storybook/decorators.tsx +38 -0
- package/template/apps/web/.storybook/main.ts +17 -0
- package/template/apps/web/.storybook/preview.ts +38 -0
- package/template/apps/web/.storybook/vitest.setup.ts +5 -0
- package/template/apps/web/AGENTS.md +29 -0
- package/template/apps/web/Dockerfile +49 -0
- package/template/apps/web/_gitignore +16 -0
- package/template/apps/web/knip.config.ts +15 -0
- package/template/apps/web/kustomize/base/application.properties +5 -0
- package/template/apps/web/kustomize/base/deployment.yaml +60 -0
- package/template/apps/web/kustomize/base/kustomization.yaml +17 -0
- package/template/apps/web/kustomize/base/service.yaml +10 -0
- package/template/apps/web/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/web/kustomize/overlays/staging/application.properties +2 -0
- package/template/apps/web/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/web/messages/de.json +124 -0
- package/template/apps/web/messages/en.json +124 -0
- package/template/apps/web/next-env.d.ts +7 -0
- package/template/apps/web/next.config.ts +39 -0
- package/template/apps/web/oxfmt.config.ts +9 -0
- package/template/apps/web/oxlint.config.ts +36 -0
- package/template/apps/web/package.json +80 -0
- package/template/apps/web/playwright.config.ts +34 -0
- package/template/apps/web/postcss.config.mjs +7 -0
- package/template/apps/web/sonar-project.properties +9 -0
- package/template/apps/web/src/app/[locale]/(auth)/recovery/page.tsx +22 -0
- package/template/apps/web/src/app/[locale]/(auth)/reset-password/page.tsx +29 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-in/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/sign-up/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/(auth)/verify/page.tsx +24 -0
- package/template/apps/web/src/app/[locale]/dashboard/page.tsx +34 -0
- package/template/apps/web/src/app/[locale]/layout.tsx +60 -0
- package/template/apps/web/src/app/[locale]/page.tsx +23 -0
- package/template/apps/web/src/app/api/health/route.ts +3 -0
- package/template/apps/web/src/app/global-error.tsx +19 -0
- package/template/apps/web/src/app/globals.css +98 -0
- package/template/apps/web/src/app/icon.svg +1 -0
- package/template/apps/web/src/app/robots.ts +15 -0
- package/template/apps/web/src/app/sitemap.ts +24 -0
- package/template/apps/web/src/components/alert/alert.stories.tsx +25 -0
- package/template/apps/web/src/components/alert/alert.tsx +50 -0
- package/template/apps/web/src/components/avatar/avatar.stories.tsx +116 -0
- package/template/apps/web/src/components/avatar/avatar.tsx +88 -0
- package/template/apps/web/src/components/button/button-variants.ts +28 -0
- package/template/apps/web/src/components/button/button.stories.tsx +33 -0
- package/template/apps/web/src/components/button/button.tsx +32 -0
- package/template/apps/web/src/components/checkbox/checkbox.stories.tsx +312 -0
- package/template/apps/web/src/components/checkbox/checkbox.tsx +38 -0
- package/template/apps/web/src/components/dialog/danger-panel.stories.tsx +41 -0
- package/template/apps/web/src/components/dialog/danger-panel.tsx +32 -0
- package/template/apps/web/src/components/dialog/dialog.stories.tsx +239 -0
- package/template/apps/web/src/components/dialog/dialog.tsx +136 -0
- package/template/apps/web/src/components/dialog-buttons.stories.tsx +55 -0
- package/template/apps/web/src/components/dialog-buttons.tsx +48 -0
- package/template/apps/web/src/components/drawer/drawer.stories.tsx +251 -0
- package/template/apps/web/src/components/drawer/drawer.tsx +120 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.stories.tsx +329 -0
- package/template/apps/web/src/components/dropdown-menu/dropdown-menu.tsx +263 -0
- package/template/apps/web/src/components/empty-state.stories.tsx +49 -0
- package/template/apps/web/src/components/empty-state.tsx +33 -0
- package/template/apps/web/src/components/field-group.stories.tsx +47 -0
- package/template/apps/web/src/components/field-group.tsx +20 -0
- package/template/apps/web/src/components/field-label.stories.tsx +34 -0
- package/template/apps/web/src/components/field-label.tsx +28 -0
- package/template/apps/web/src/components/icons.tsx +25 -0
- package/template/apps/web/src/components/input/input-otp.stories.tsx +86 -0
- package/template/apps/web/src/components/input/input-otp.tsx +184 -0
- package/template/apps/web/src/components/input/input.stories.tsx +35 -0
- package/template/apps/web/src/components/input/input.tsx +180 -0
- package/template/apps/web/src/components/popover/popover.stories.tsx +479 -0
- package/template/apps/web/src/components/popover/popover.tsx +45 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.stories.tsx +307 -0
- package/template/apps/web/src/components/responsive-dialog/responsive-dialog.tsx +96 -0
- package/template/apps/web/src/components/searchbar/searchbar.stories.tsx +59 -0
- package/template/apps/web/src/components/searchbar/searchbar.tsx +64 -0
- package/template/apps/web/src/components/select/select.stories.tsx +293 -0
- package/template/apps/web/src/components/select/select.tsx +445 -0
- package/template/apps/web/src/components/skeleton/skeleton.stories.tsx +76 -0
- package/template/apps/web/src/components/skeleton/skeleton.tsx +13 -0
- package/template/apps/web/src/components/spinner/spinner.tsx +18 -0
- package/template/apps/web/src/components/switch/switch.stories.tsx +71 -0
- package/template/apps/web/src/components/switch/switch.tsx +35 -0
- package/template/apps/web/src/components/tabs/tabs.stories.tsx +209 -0
- package/template/apps/web/src/components/tabs/tabs.tsx +69 -0
- package/template/apps/web/src/components/tabs/url-tabs.stories.tsx +68 -0
- package/template/apps/web/src/components/tabs/url-tabs.tsx +42 -0
- package/template/apps/web/src/components/textarea/textarea.stories.tsx +144 -0
- package/template/apps/web/src/components/textarea/textarea.tsx +115 -0
- package/template/apps/web/src/components/toast/toaster.tsx +38 -0
- package/template/apps/web/src/components/tooltip/tooltip.stories.tsx +237 -0
- package/template/apps/web/src/components/tooltip/tooltip.tsx +49 -0
- package/template/apps/web/src/config/csp.ts +59 -0
- package/template/apps/web/src/config/routes.ts +14 -0
- package/template/apps/web/src/config/site.ts +5 -0
- package/template/apps/web/src/env.ts +15 -0
- package/template/apps/web/src/features/auth/auth-card.tsx +20 -0
- package/template/apps/web/src/features/auth/auth-divider.tsx +14 -0
- package/template/apps/web/src/features/auth/google-auth-button.tsx +49 -0
- package/template/apps/web/src/features/auth/google-one-tap.tsx +23 -0
- package/template/apps/web/src/features/auth/lib/auth-error.test.ts +22 -0
- package/template/apps/web/src/features/auth/lib/auth-error.ts +16 -0
- package/template/apps/web/src/features/auth/lib/return-to.test.ts +41 -0
- package/template/apps/web/src/features/auth/lib/return-to.ts +19 -0
- package/template/apps/web/src/features/auth/lib/validator.test.ts +45 -0
- package/template/apps/web/src/features/auth/lib/validator.ts +49 -0
- package/template/apps/web/src/features/auth/passkey-settings.tsx +106 -0
- package/template/apps/web/src/features/auth/recovery-form.tsx +61 -0
- package/template/apps/web/src/features/auth/resend-verification-button.tsx +36 -0
- package/template/apps/web/src/features/auth/reset-password-form.tsx +62 -0
- package/template/apps/web/src/features/auth/sign-in-form.stories.tsx +22 -0
- package/template/apps/web/src/features/auth/sign-in-form.tsx +116 -0
- package/template/apps/web/src/features/auth/sign-out-button.tsx +26 -0
- package/template/apps/web/src/features/auth/sign-up-form.tsx +107 -0
- package/template/apps/web/src/features/shared/provider.tsx +16 -0
- package/template/apps/web/src/features/shared/site-header.tsx +48 -0
- package/template/apps/web/src/features/shared/theme-toggle.tsx +24 -0
- package/template/apps/web/src/i18n/navigation.ts +5 -0
- package/template/apps/web/src/i18n/request.ts +21 -0
- package/template/apps/web/src/i18n/routing.ts +6 -0
- package/template/apps/web/src/lib/auth-client.ts +19 -0
- package/template/apps/web/src/lib/auth-server.ts +46 -0
- package/template/apps/web/src/lib/auth.ts +8 -0
- package/template/apps/web/src/lib/codegen.ts +30 -0
- package/template/apps/web/src/lib/graphql-client.ts +29 -0
- package/template/apps/web/src/lib/graphql-server.ts +28 -0
- package/template/apps/web/src/lib/graphql-utils.test.ts +32 -0
- package/template/apps/web/src/lib/graphql-utils.ts +76 -0
- package/template/apps/web/src/lib/hooks/use-media-query.ts +21 -0
- package/template/apps/web/src/lib/metadata.ts +17 -0
- package/template/apps/web/src/lib/network-error.test.ts +30 -0
- package/template/apps/web/src/lib/network-error.ts +17 -0
- package/template/apps/web/src/lib/proxy-utils.ts +42 -0
- package/template/apps/web/src/lib/query-client.ts +29 -0
- package/template/apps/web/src/lib/utils.ts +5 -0
- package/template/apps/web/src/proxy.ts +57 -0
- package/template/apps/web/test-results/.last-run.json +4 -0
- package/template/apps/web/tests/auth.spec.ts +17 -0
- package/template/apps/web/tests/csp-theme-script.spec.ts +37 -0
- package/template/apps/web/tests/home.spec.ts +25 -0
- package/template/apps/web/tsconfig.build.json +4 -0
- package/template/apps/web/tsconfig.json +28 -0
- package/template/apps/web/vitest.config.ts +40 -0
- package/template/apps/web/vitest.shims.d.ts +1 -0
- package/template/apps/worker/.env.example +4 -0
- package/template/apps/worker/AGENTS.md +11 -0
- package/template/apps/worker/Dockerfile +35 -0
- package/template/apps/worker/knip.config.ts +3 -0
- package/template/apps/worker/kustomize/base/application.properties +4 -0
- package/template/apps/worker/kustomize/base/deployment.yaml +62 -0
- package/template/apps/worker/kustomize/base/kustomization.yaml +16 -0
- package/template/apps/worker/kustomize/overlays/prod/kustomization.yaml +12 -0
- package/template/apps/worker/kustomize/overlays/staging/application.properties +1 -0
- package/template/apps/worker/kustomize/overlays/staging/kustomization.yaml +18 -0
- package/template/apps/worker/oxfmt.config.ts +8 -0
- package/template/apps/worker/oxlint.config.ts +15 -0
- package/template/apps/worker/package.json +33 -0
- package/template/apps/worker/sonar-project.properties +8 -0
- package/template/apps/worker/src/env.ts +13 -0
- package/template/apps/worker/src/lib/graceful-shutdown.ts +27 -0
- package/template/apps/worker/src/lib/logger.ts +20 -0
- package/template/apps/worker/src/main.ts +17 -0
- package/template/apps/worker/src/processors/example-processor.ts +15 -0
- package/template/apps/worker/src/worker/redis.ts +19 -0
- package/template/apps/worker/src/worker/worker.ts +37 -0
- package/template/apps/worker/tests/example-processor.test.ts +13 -0
- package/template/apps/worker/tests/graceful-shutdown.test.ts +34 -0
- package/template/apps/worker/tsconfig.json +4 -0
- package/template/apps/worker/vitest.config.ts +13 -0
- package/template/bun.lock +3053 -0
- package/template/bunfig.toml +2 -0
- package/template/compose.yaml +53 -0
- package/template/docker-bake.hcl +86 -0
- package/template/package.json +47 -0
- package/template/packages/lint/knip.ts +40 -0
- package/template/packages/lint/oxfmt.ts +8 -0
- package/template/packages/lint/oxlint.ts +136 -0
- package/template/packages/lint/package.json +13 -0
- package/template/packages/queues/oxfmt.config.ts +8 -0
- package/template/packages/queues/oxlint.config.ts +7 -0
- package/template/packages/queues/package.json +15 -0
- package/template/packages/queues/src/example-job.ts +11 -0
- package/template/packages/queues/src/index.ts +3 -0
- package/template/packages/queues/src/queue-names.ts +2 -0
- package/template/packages/queues/src/redis-connection.ts +27 -0
- package/template/packages/queues/tsconfig.json +4 -0
- package/template/packages/tsconfig/base.json +23 -0
- package/template/packages/tsconfig/node.json +11 -0
- package/template/packages/tsconfig/package.json +4 -0
- 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
|
+
});
|