better-ts-stack 2.1.2 → 2.2.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 (117) hide show
  1. package/README.md +16 -8
  2. package/dist/args.js +2 -1
  3. package/dist/builder/buildPlanner.js +7 -1
  4. package/dist/builder/configGenerator.js +28 -3
  5. package/dist/builder/index.js +2 -1
  6. package/dist/builder/readmeGenerator.js +2 -2
  7. package/dist/output/nextSteps.js +2 -4
  8. package/dist/prompts/stack.js +3 -5
  9. package/dist/types/index.js +1 -1
  10. package/package.json +1 -1
  11. package/templates/backend/express/src/env.ts.hbs +13 -2
  12. package/templates/backend/express/src/index.ts.hbs +31 -2
  13. package/templates/backend/nestjs/src/app.module.ts.hbs +4 -1
  14. package/templates/backend/nestjs/src/env.ts.hbs +13 -2
  15. package/templates/backend/nestjs/src/health/health.controller.ts.hbs +7 -0
  16. package/templates/backend/nestjs/src/{main.ts → main.ts.hbs} +11 -0
  17. package/templates/frontend/nextjs/app/globals.css +119 -79
  18. package/templates/frontend/nextjs/app/layout.tsx +13 -11
  19. package/templates/frontend/nextjs/app/page.tsx.hbs +63 -43
  20. package/templates/frontend/nextjs/components/ui/alert.tsx +75 -0
  21. package/templates/frontend/nextjs/components/ui/badge.tsx +51 -0
  22. package/templates/frontend/nextjs/components/ui/button.tsx +33 -27
  23. package/templates/frontend/nextjs/components/ui/card.tsx +76 -34
  24. package/templates/frontend/nextjs/components/ui/field.tsx +203 -32
  25. package/templates/frontend/nextjs/components/ui/input.tsx +8 -7
  26. package/templates/frontend/nextjs/components/ui/label.tsx +19 -0
  27. package/templates/frontend/nextjs/components/ui/separator.tsx +24 -0
  28. package/templates/frontend/nextjs/components/ui/skeleton.tsx +13 -0
  29. package/templates/frontend/nextjs/components/ui/spinner.tsx +10 -0
  30. package/templates/frontend/nextjs/components.json +11 -5
  31. package/templates/frontend/nextjs/config.json +4 -3
  32. package/templates/frontend/nextjs/lib/utils.ts +1 -6
  33. package/templates/frontend/tanstack/components.json +12 -5
  34. package/templates/frontend/tanstack/config.json +5 -2
  35. package/templates/frontend/tanstack/src/components/ui/alert.tsx +75 -0
  36. package/templates/frontend/tanstack/src/components/ui/badge.tsx +51 -0
  37. package/templates/frontend/tanstack/src/components/ui/button.tsx +33 -27
  38. package/templates/frontend/tanstack/src/components/ui/card.tsx +76 -34
  39. package/templates/frontend/tanstack/src/components/ui/field.tsx +201 -32
  40. package/templates/frontend/tanstack/src/components/ui/input.tsx +8 -7
  41. package/templates/frontend/tanstack/src/components/ui/label.tsx +17 -0
  42. package/templates/frontend/tanstack/src/components/ui/separator.tsx +24 -0
  43. package/templates/frontend/tanstack/src/components/ui/skeleton.tsx +13 -0
  44. package/templates/frontend/tanstack/src/components/ui/spinner.tsx +10 -0
  45. package/templates/frontend/tanstack/src/lib/utils.ts +1 -6
  46. package/templates/frontend/tanstack/src/routes/index.tsx.hbs +64 -45
  47. package/templates/frontend/tanstack/src/styles/app.css +100 -55
  48. package/templates/modules/agents/AGENTS.md.hbs +29 -21
  49. package/templates/modules/auth/express/config.json +12 -11
  50. package/templates/modules/auth/express/src/lib/auth.ts.hbs +143 -0
  51. package/templates/modules/auth/express/src/lib/email.ts +37 -0
  52. package/templates/modules/auth/express/src/middleware/requireAuth.ts +29 -0
  53. package/templates/modules/auth/express/src/routes/me.ts +13 -0
  54. package/templates/modules/auth/nestjs/config.json +13 -11
  55. package/templates/modules/auth/nestjs/src/auth/auth.module.ts.hbs +64 -14
  56. package/templates/modules/auth/nestjs/src/auth/auth.ts.hbs +152 -0
  57. package/templates/modules/auth/nestjs/src/auth/email.ts +37 -0
  58. package/templates/modules/auth/nestjs/src/me/me.controller.ts +15 -0
  59. package/templates/modules/auth/nextjs/app/dashboard/page.tsx +101 -45
  60. package/templates/modules/auth/nextjs/app/forgot-password/page.tsx +11 -0
  61. package/templates/modules/auth/nextjs/app/reset-password/page.tsx +17 -0
  62. package/templates/modules/auth/nextjs/app/sign-in/page.tsx +23 -17
  63. package/templates/modules/auth/nextjs/app/sign-up/page.tsx +25 -19
  64. package/templates/modules/auth/nextjs/components/auth/change-password-form.tsx +125 -0
  65. package/templates/modules/auth/nextjs/components/auth/email-verification-card.tsx +91 -0
  66. package/templates/modules/auth/nextjs/components/auth/forgot-password-form.tsx +133 -0
  67. package/templates/modules/auth/nextjs/components/auth/reset-password-form.tsx +155 -0
  68. package/templates/modules/auth/nextjs/components/auth/sign-in-form.tsx +46 -32
  69. package/templates/modules/auth/nextjs/components/auth/sign-out-button.tsx +6 -5
  70. package/templates/modules/auth/nextjs/components/auth/sign-up-form.tsx +36 -30
  71. package/templates/modules/auth/nextjs/components/auth/social-buttons.tsx +73 -0
  72. package/templates/modules/auth/nextjs/config.json +8 -2
  73. package/templates/modules/auth/nextjs/lib/auth-schema.ts +27 -2
  74. package/templates/modules/auth/nextjs/lib/auth.ts.hbs +65 -0
  75. package/templates/modules/auth/nextjs/lib/email.ts +43 -0
  76. package/templates/modules/auth/nextjs/lib/session.ts +12 -0
  77. package/templates/modules/auth/tanstack/config.json +9 -3
  78. package/templates/modules/auth/tanstack/src/components/auth/change-password-form.tsx +123 -0
  79. package/templates/modules/auth/tanstack/src/components/auth/email-verification-card.tsx +89 -0
  80. package/templates/modules/auth/tanstack/src/components/auth/forgot-password-form.tsx +131 -0
  81. package/templates/modules/auth/tanstack/src/components/auth/reset-password-form.tsx +149 -0
  82. package/templates/modules/auth/tanstack/src/components/auth/sign-in-form.tsx +44 -35
  83. package/templates/modules/auth/tanstack/src/components/auth/sign-out-button.tsx +7 -6
  84. package/templates/modules/auth/tanstack/src/components/auth/sign-up-form.tsx +37 -34
  85. package/templates/modules/auth/tanstack/src/components/auth/social-buttons.tsx +71 -0
  86. package/templates/modules/auth/tanstack/src/lib/auth-functions.ts +7 -1
  87. package/templates/modules/auth/tanstack/src/lib/auth-schema.ts +27 -2
  88. package/templates/modules/auth/tanstack/src/lib/auth.ts.hbs +61 -1
  89. package/templates/modules/auth/tanstack/src/lib/email.ts +43 -0
  90. package/templates/modules/auth/tanstack/src/routeTree.gen.ts +61 -3
  91. package/templates/modules/auth/tanstack/src/routes/dashboard.tsx +40 -32
  92. package/templates/modules/auth/tanstack/src/routes/forgot-password.tsx +17 -0
  93. package/templates/modules/auth/tanstack/src/routes/reset-password.tsx +25 -0
  94. package/templates/modules/auth/tanstack/src/routes/sign-in.tsx +19 -12
  95. package/templates/modules/auth/tanstack/src/routes/sign-up.tsx +21 -14
  96. package/templates/modules/drizzle/express/src/lib/schema.ts.hbs +90 -0
  97. package/templates/modules/drizzle/nestjs/src/database/schema.ts.hbs +90 -0
  98. package/templates/modules/mongoose/nestjs/config.json +2 -1
  99. package/templates/modules/prisma/express/prisma/schema.prisma.hbs +92 -0
  100. package/templates/modules/prisma/nestjs/prisma/schema.prisma.hbs +91 -0
  101. package/templates/modules/rate-limit/express/src/middleware/rateLimit.ts +0 -8
  102. package/templates/modules/auth/express/src/controllers/authController.ts.hbs +0 -116
  103. package/templates/modules/auth/express/src/lib/jwt.ts.hbs +0 -27
  104. package/templates/modules/auth/express/src/middleware/requireAuth.ts.hbs +0 -25
  105. package/templates/modules/auth/express/src/routes/auth.ts.hbs +0 -27
  106. package/templates/modules/auth/express/src/services/userStore.ts.hbs +0 -117
  107. package/templates/modules/auth/nestjs/src/auth/auth.controller.ts.hbs +0 -61
  108. package/templates/modules/auth/nestjs/src/auth/auth.guard.ts +0 -36
  109. package/templates/modules/auth/nestjs/src/auth/auth.schemas.ts +0 -35
  110. package/templates/modules/auth/nestjs/src/auth/auth.service.ts +0 -70
  111. package/templates/modules/auth/nestjs/src/auth/jwt.ts +0 -27
  112. package/templates/modules/auth/nestjs/src/auth/users.service.ts.hbs +0 -144
  113. package/templates/modules/auth/nestjs/src/auth/zod-validation.pipe.ts +0 -16
  114. package/templates/modules/drizzle/express/src/lib/schema.ts +0 -12
  115. package/templates/modules/drizzle/nestjs/src/database/schema.ts +0 -12
  116. package/templates/modules/prisma/express/prisma/schema.prisma +0 -24
  117. package/templates/modules/prisma/nestjs/prisma/schema.prisma +0 -23
@@ -0,0 +1,25 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+
3
+ import { ResetPasswordForm } from "~/components/auth/reset-password-form";
4
+
5
+ // The link in the reset email lands here with ?token=..., or with ?error=...
6
+ // when the token is invalid or has expired
7
+ export const Route = createFileRoute("/reset-password")({
8
+ validateSearch: (search: Record<string, unknown>) => ({
9
+ token: typeof search.token === "string" ? search.token : undefined,
10
+ error: typeof search.error === "string" ? search.error : undefined,
11
+ }),
12
+ component: ResetPasswordPage,
13
+ });
14
+
15
+ function ResetPasswordPage() {
16
+ const { token, error } = Route.useSearch();
17
+
18
+ return (
19
+ <main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
20
+ <div className="w-full max-w-md">
21
+ <ResetPasswordForm token={error ? null : (token ?? null)} />
22
+ </div>
23
+ </main>
24
+ );
25
+ }
@@ -1,37 +1,44 @@
1
1
  import { createFileRoute, redirect } from "@tanstack/react-router";
2
2
 
3
3
  import { SignInForm } from "~/components/auth/sign-in-form";
4
- import { getSession } from "~/lib/auth-functions";
4
+ import { Badge } from "~/components/ui/badge";
5
+ import { getSession, getSocialProviders } from "~/lib/auth-functions";
5
6
 
6
7
  export const Route = createFileRoute("/sign-in")({
7
8
  beforeLoad: async () => {
8
- const session = await getSession();
9
+ // Two independent server calls, made at the same time
10
+ const [session, providers] = await Promise.all([
11
+ getSession(),
12
+ getSocialProviders(),
13
+ ]);
9
14
 
10
15
  if (session) {
11
16
  throw redirect({ to: "/dashboard" });
12
17
  }
18
+
19
+ return { providers };
13
20
  },
14
21
  component: SignInPage,
15
22
  });
16
23
 
17
24
  function SignInPage() {
18
25
  return (
19
- <main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.16),_transparent_30%),linear-gradient(180deg,_#f8fafc_0%,_#eef2ff_100%)] px-6 py-16 sm:px-10">
20
- <div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-5xl items-center gap-12 lg:grid lg:grid-cols-[0.9fr_1.1fr]">
21
- <section className="hidden space-y-5 lg:block">
22
- <span className="inline-flex rounded-full border border-sky-200 bg-white/80 px-3 py-1 text-xs font-medium uppercase tracking-[0.24em] text-sky-700">
23
- Better Auth
24
- </span>
25
- <h1 className="text-4xl font-semibold tracking-tight text-slate-950">
26
+ <main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
27
+ <div className="grid w-full max-w-5xl items-center gap-12 lg:grid-cols-[0.9fr_1.1fr]">
28
+ <section className="hidden flex-col gap-5 lg:flex">
29
+ <Badge variant="outline">Better Auth</Badge>
30
+ <h1 className="text-4xl font-semibold tracking-tight">
26
31
  Welcome back.
27
32
  </h1>
28
- <p className="max-w-md text-base leading-7 text-slate-600">
33
+ <p className="max-w-md text-base leading-7 text-muted-foreground">
29
34
  This generated starter includes protected routes, session-aware page
30
35
  guards, and reusable auth form primitives you can extend from here.
31
36
  </p>
32
37
  </section>
33
- <SignInForm />
38
+ <SignInForm
39
+ providers={Route.useRouteContext({ select: (ctx) => ctx.providers })}
40
+ />
34
41
  </div>
35
42
  </main>
36
43
  );
37
- }
44
+ }
@@ -1,37 +1,44 @@
1
1
  import { createFileRoute, redirect } from "@tanstack/react-router";
2
2
 
3
3
  import { SignUpForm } from "~/components/auth/sign-up-form";
4
- import { getSession } from "~/lib/auth-functions";
4
+ import { Badge } from "~/components/ui/badge";
5
+ import { getSession, getSocialProviders } from "~/lib/auth-functions";
5
6
 
6
7
  export const Route = createFileRoute("/sign-up")({
7
8
  beforeLoad: async () => {
8
- const session = await getSession();
9
+ // Two independent server calls, made at the same time
10
+ const [session, providers] = await Promise.all([
11
+ getSession(),
12
+ getSocialProviders(),
13
+ ]);
9
14
 
10
15
  if (session) {
11
16
  throw redirect({ to: "/dashboard" });
12
17
  }
18
+
19
+ return { providers };
13
20
  },
14
21
  component: SignUpPage,
15
22
  });
16
23
 
17
24
  function SignUpPage() {
18
25
  return (
19
- <main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(180deg,_#f8fafc_0%,_#ecfeff_100%)] px-6 py-16 sm:px-10">
20
- <div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-5xl items-center gap-12 lg:grid lg:grid-cols-[0.9fr_1.1fr]">
21
- <section className="hidden space-y-5 lg:block">
22
- <span className="inline-flex rounded-full border border-sky-200 bg-white/80 px-3 py-1 text-xs font-medium uppercase tracking-[0.24em] text-sky-700">
23
- Better Auth
24
- </span>
25
- <h1 className="text-4xl font-semibold tracking-tight text-slate-950">
26
+ <main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
27
+ <div className="grid w-full max-w-5xl items-center gap-12 lg:grid-cols-[0.9fr_1.1fr]">
28
+ <section className="hidden flex-col gap-5 lg:flex">
29
+ <Badge variant="outline">Better Auth</Badge>
30
+ <h1 className="text-4xl font-semibold tracking-tight">
26
31
  Create your account.
27
32
  </h1>
28
- <p className="max-w-md text-base leading-7 text-slate-600">
29
- Start with email and password today, then layer in profile data,
30
- onboarding, and provider-based auth when your product needs it.
33
+ <p className="max-w-md text-base leading-7 text-muted-foreground">
34
+ Sign up with email and password, or with GitHub or Google once
35
+ their keys are set. A verification link goes to your inbox.
31
36
  </p>
32
37
  </section>
33
- <SignUpForm />
38
+ <SignUpForm
39
+ providers={Route.useRouteContext({ select: (ctx) => ctx.providers })}
40
+ />
34
41
  </div>
35
42
  </main>
36
43
  );
37
- }
44
+ }
@@ -0,0 +1,90 @@
1
+ {{#if useAuth}}
2
+ import { boolean, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
3
+
4
+ export const user = pgTable("user", {
5
+ id: text("id").primaryKey(),
6
+ name: text("name").notNull(),
7
+ email: text("email").notNull().unique(),
8
+ emailVerified: boolean("email_verified").notNull().default(false),
9
+ image: text("image"),
10
+ createdAt: timestamp("created_at", { withTimezone: true })
11
+ .notNull()
12
+ .defaultNow(),
13
+ updatedAt: timestamp("updated_at", { withTimezone: true })
14
+ .notNull()
15
+ .defaultNow()
16
+ .$onUpdate(() => new Date()),
17
+ });
18
+
19
+ export const session = pgTable("session", {
20
+ id: text("id").primaryKey(),
21
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
22
+ token: text("token").notNull().unique(),
23
+ createdAt: timestamp("created_at", { withTimezone: true })
24
+ .notNull()
25
+ .defaultNow(),
26
+ updatedAt: timestamp("updated_at", { withTimezone: true })
27
+ .notNull()
28
+ .defaultNow()
29
+ .$onUpdate(() => new Date()),
30
+ ipAddress: text("ip_address"),
31
+ userAgent: text("user_agent"),
32
+ userId: text("user_id")
33
+ .notNull()
34
+ .references(() => user.id, { onDelete: "cascade" }),
35
+ });
36
+
37
+ export const account = pgTable("account", {
38
+ id: text("id").primaryKey(),
39
+ accountId: text("account_id").notNull(),
40
+ providerId: text("provider_id").notNull(),
41
+ userId: text("user_id")
42
+ .notNull()
43
+ .references(() => user.id, { onDelete: "cascade" }),
44
+ accessToken: text("access_token"),
45
+ refreshToken: text("refresh_token"),
46
+ idToken: text("id_token"),
47
+ accessTokenExpiresAt: timestamp("access_token_expires_at", {
48
+ withTimezone: true,
49
+ }),
50
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
51
+ withTimezone: true,
52
+ }),
53
+ scope: text("scope"),
54
+ password: text("password"),
55
+ createdAt: timestamp("created_at", { withTimezone: true })
56
+ .notNull()
57
+ .defaultNow(),
58
+ updatedAt: timestamp("updated_at", { withTimezone: true })
59
+ .notNull()
60
+ .defaultNow()
61
+ .$onUpdate(() => new Date()),
62
+ }, (table) => [uniqueIndex("account_provider_account_id_unique").on(table.providerId, table.accountId)]);
63
+
64
+ export const verification = pgTable("verification", {
65
+ id: text("id").primaryKey(),
66
+ identifier: text("identifier").notNull(),
67
+ value: text("value").notNull(),
68
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
69
+ createdAt: timestamp("created_at", { withTimezone: true })
70
+ .notNull()
71
+ .defaultNow(),
72
+ updatedAt: timestamp("updated_at", { withTimezone: true })
73
+ .notNull()
74
+ .defaultNow()
75
+ .$onUpdate(() => new Date()),
76
+ });
77
+ {{else}}
78
+ import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
79
+
80
+ export const users = pgTable("users", {
81
+ id: uuid("id").primaryKey().defaultRandom(),
82
+ email: text("email").notNull().unique(),
83
+ name: text("name"),
84
+ password: text("password").notNull(),
85
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
86
+ updatedAt: timestamp("updated_at", { withTimezone: true })
87
+ .defaultNow()
88
+ .$onUpdate(() => new Date()),
89
+ });
90
+ {{/if}}
@@ -0,0 +1,90 @@
1
+ {{#if useAuth}}
2
+ import { boolean, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
3
+
4
+ export const user = pgTable("user", {
5
+ id: text("id").primaryKey(),
6
+ name: text("name").notNull(),
7
+ email: text("email").notNull().unique(),
8
+ emailVerified: boolean("email_verified").notNull().default(false),
9
+ image: text("image"),
10
+ createdAt: timestamp("created_at", { withTimezone: true })
11
+ .notNull()
12
+ .defaultNow(),
13
+ updatedAt: timestamp("updated_at", { withTimezone: true })
14
+ .notNull()
15
+ .defaultNow()
16
+ .$onUpdate(() => new Date()),
17
+ });
18
+
19
+ export const session = pgTable("session", {
20
+ id: text("id").primaryKey(),
21
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
22
+ token: text("token").notNull().unique(),
23
+ createdAt: timestamp("created_at", { withTimezone: true })
24
+ .notNull()
25
+ .defaultNow(),
26
+ updatedAt: timestamp("updated_at", { withTimezone: true })
27
+ .notNull()
28
+ .defaultNow()
29
+ .$onUpdate(() => new Date()),
30
+ ipAddress: text("ip_address"),
31
+ userAgent: text("user_agent"),
32
+ userId: text("user_id")
33
+ .notNull()
34
+ .references(() => user.id, { onDelete: "cascade" }),
35
+ });
36
+
37
+ export const account = pgTable("account", {
38
+ id: text("id").primaryKey(),
39
+ accountId: text("account_id").notNull(),
40
+ providerId: text("provider_id").notNull(),
41
+ userId: text("user_id")
42
+ .notNull()
43
+ .references(() => user.id, { onDelete: "cascade" }),
44
+ accessToken: text("access_token"),
45
+ refreshToken: text("refresh_token"),
46
+ idToken: text("id_token"),
47
+ accessTokenExpiresAt: timestamp("access_token_expires_at", {
48
+ withTimezone: true,
49
+ }),
50
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
51
+ withTimezone: true,
52
+ }),
53
+ scope: text("scope"),
54
+ password: text("password"),
55
+ createdAt: timestamp("created_at", { withTimezone: true })
56
+ .notNull()
57
+ .defaultNow(),
58
+ updatedAt: timestamp("updated_at", { withTimezone: true })
59
+ .notNull()
60
+ .defaultNow()
61
+ .$onUpdate(() => new Date()),
62
+ }, (table) => [uniqueIndex("account_provider_account_id_unique").on(table.providerId, table.accountId)]);
63
+
64
+ export const verification = pgTable("verification", {
65
+ id: text("id").primaryKey(),
66
+ identifier: text("identifier").notNull(),
67
+ value: text("value").notNull(),
68
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
69
+ createdAt: timestamp("created_at", { withTimezone: true })
70
+ .notNull()
71
+ .defaultNow(),
72
+ updatedAt: timestamp("updated_at", { withTimezone: true })
73
+ .notNull()
74
+ .defaultNow()
75
+ .$onUpdate(() => new Date()),
76
+ });
77
+ {{else}}
78
+ import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
79
+
80
+ export const users = pgTable("users", {
81
+ id: uuid("id").primaryKey().defaultRandom(),
82
+ email: text("email").notNull().unique(),
83
+ name: text("name"),
84
+ password: text("password").notNull(),
85
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
86
+ updatedAt: timestamp("updated_at", { withTimezone: true })
87
+ .defaultNow()
88
+ .$onUpdate(() => new Date()),
89
+ });
90
+ {{/if}}
@@ -5,7 +5,8 @@
5
5
  "type": "database",
6
6
  "dependencies": {
7
7
  "@nestjs/mongoose": "^12.0.0",
8
- "mongoose": "^9.11.4"
8
+ "mongoose": "^9.11.4",
9
+ "mongodb": "^7.8.0"
9
10
  },
10
11
  "devDependencies": {},
11
12
  "scripts": {},
@@ -0,0 +1,92 @@
1
+ // Prisma schema file
2
+ // Learn more: https://pris.ly/d/prisma-schema
3
+
4
+ generator client {
5
+ provider = "prisma-client"
6
+ moduleFormat = "cjs"
7
+ output = "../src/generated/prisma"
8
+ }
9
+
10
+ datasource db {
11
+ provider = "postgresql"
12
+ }
13
+
14
+ {{#if useAuth}}
15
+ // Better Auth tables
16
+ model User {
17
+ id String @id
18
+ name String
19
+ email String
20
+ emailVerified Boolean @default(false)
21
+ image String?
22
+ createdAt DateTime @default(now())
23
+ updatedAt DateTime @updatedAt
24
+ sessions Session[]
25
+ accounts Account[]
26
+
27
+ @@unique([email])
28
+ @@map("user")
29
+ }
30
+
31
+ model Session {
32
+ id String @id
33
+ expiresAt DateTime
34
+ token String
35
+ createdAt DateTime @default(now())
36
+ updatedAt DateTime @updatedAt
37
+ ipAddress String?
38
+ userAgent String?
39
+ userId String
40
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
41
+
42
+ @@unique([token])
43
+ @@index([userId])
44
+ @@map("session")
45
+ }
46
+
47
+ model Account {
48
+ id String @id
49
+ accountId String
50
+ providerId String
51
+ userId String
52
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
53
+ accessToken String?
54
+ refreshToken String?
55
+ idToken String?
56
+ accessTokenExpiresAt DateTime?
57
+ refreshTokenExpiresAt DateTime?
58
+ scope String?
59
+ password String?
60
+ createdAt DateTime @default(now())
61
+ updatedAt DateTime @updatedAt
62
+
63
+ @@index([userId])
64
+ @@unique([providerId, accountId])
65
+ @@map("account")
66
+ }
67
+
68
+ model Verification {
69
+ id String @id
70
+ identifier String
71
+ value String
72
+ expiresAt DateTime
73
+ createdAt DateTime @default(now())
74
+ updatedAt DateTime @updatedAt
75
+
76
+ @@index([identifier])
77
+ @@map("verification")
78
+ }
79
+
80
+ {{else}}
81
+ // Sample User model
82
+ model User {
83
+ id String @id @default(uuid())
84
+ email String @unique
85
+ name String?
86
+ password String
87
+ createdAt DateTime @default(now())
88
+ updatedAt DateTime @updatedAt
89
+
90
+ @@map("users")
91
+ }
92
+ {{/if}}
@@ -0,0 +1,91 @@
1
+ // Prisma schema file
2
+ // Learn more: https://pris.ly/d/prisma-schema
3
+
4
+ generator client {
5
+ provider = "prisma-client"
6
+ output = "../src/generated/prisma"
7
+ }
8
+
9
+ datasource db {
10
+ provider = "postgresql"
11
+ }
12
+
13
+ {{#if useAuth}}
14
+ // Better Auth tables
15
+ model User {
16
+ id String @id
17
+ name String
18
+ email String
19
+ emailVerified Boolean @default(false)
20
+ image String?
21
+ createdAt DateTime @default(now())
22
+ updatedAt DateTime @updatedAt
23
+ sessions Session[]
24
+ accounts Account[]
25
+
26
+ @@unique([email])
27
+ @@map("user")
28
+ }
29
+
30
+ model Session {
31
+ id String @id
32
+ expiresAt DateTime
33
+ token String
34
+ createdAt DateTime @default(now())
35
+ updatedAt DateTime @updatedAt
36
+ ipAddress String?
37
+ userAgent String?
38
+ userId String
39
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
40
+
41
+ @@unique([token])
42
+ @@index([userId])
43
+ @@map("session")
44
+ }
45
+
46
+ model Account {
47
+ id String @id
48
+ accountId String
49
+ providerId String
50
+ userId String
51
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
52
+ accessToken String?
53
+ refreshToken String?
54
+ idToken String?
55
+ accessTokenExpiresAt DateTime?
56
+ refreshTokenExpiresAt DateTime?
57
+ scope String?
58
+ password String?
59
+ createdAt DateTime @default(now())
60
+ updatedAt DateTime @updatedAt
61
+
62
+ @@index([userId])
63
+ @@unique([providerId, accountId])
64
+ @@map("account")
65
+ }
66
+
67
+ model Verification {
68
+ id String @id
69
+ identifier String
70
+ value String
71
+ expiresAt DateTime
72
+ createdAt DateTime @default(now())
73
+ updatedAt DateTime @updatedAt
74
+
75
+ @@index([identifier])
76
+ @@map("verification")
77
+ }
78
+
79
+ {{else}}
80
+ // Sample User model
81
+ model User {
82
+ id String @id @default(uuid())
83
+ email String @unique
84
+ name String?
85
+ password String
86
+ createdAt DateTime @default(now())
87
+ updatedAt DateTime @updatedAt
88
+
89
+ @@map("users")
90
+ }
91
+ {{/if}}
@@ -13,11 +13,3 @@ export const apiLimiter = rateLimit({
13
13
  legacyHeaders: false,
14
14
  skip: (req) => req.path === "/health",
15
15
  });
16
-
17
- // Sign-up and login: 10 attempts per IP in 15 minutes, against password guessing
18
- export const authLimiter = rateLimit({
19
- windowMs: FIFTEEN_MINUTES,
20
- limit: 10,
21
- standardHeaders: "draft-8",
22
- legacyHeaders: false,
23
- });
@@ -1,116 +0,0 @@
1
- import { Request, Response } from "express";
2
- import bcrypt from "bcrypt";
3
- import { z } from "zod";
4
- import { signToken } from "../lib/jwt";
5
- import { AuthenticatedRequest } from "../middleware/requireAuth";
6
- import {
7
- createUser,
8
- findUserByEmail,
9
- isDuplicateEmailError,
10
- toPublicUser,
11
- } from "../services/userStore";
12
-
13
- const SALT_ROUNDS = 10;
14
- const MIN_PASSWORD_LENGTH = 8;
15
- // bcrypt ignores bytes beyond 72, so longer passwords would be silently truncated
16
- const MAX_PASSWORD_BYTES = 72;
17
-
18
- // Emails are trimmed and lowercased so sign-in is case-insensitive
19
- const emailSchema = z
20
- .string("Email and password are required")
21
- .trim()
22
- .toLowerCase()
23
- .pipe(z.email("Email must be a valid email address"));
24
-
25
- const registerSchema = z.object({
26
- email: emailSchema,
27
- password: z
28
- .string("Email and password are required")
29
- .min(
30
- MIN_PASSWORD_LENGTH,
31
- `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
32
- )
33
- .refine(
34
- (password) => Buffer.byteLength(password, "utf8") <= MAX_PASSWORD_BYTES,
35
- `Password must be at most ${MAX_PASSWORD_BYTES} bytes`,
36
- ),
37
- name: z.string("Name must be a string").nullish(),
38
- });
39
-
40
- const loginSchema = z.object({
41
- email: emailSchema,
42
- password: z.string("Email and password are required").min(1),
43
- });
44
-
45
- // Sends a 400 with the first validation message and returns null when the body is invalid
46
- function parseBody<T>(schema: z.ZodType<T>, req: Request, res: Response): T | null {
47
- // Express 5 leaves req.body undefined when the request has no body
48
- const result = schema.safeParse(req.body ?? {});
49
- if (result.success) return result.data;
50
-
51
- res.status(400).json({ error: result.error.issues[0].message });
52
- return null;
53
- }
54
-
55
- export async function register(req: Request, res: Response): Promise<void> {
56
- const body = parseBody(registerSchema, req, res);
57
- if (!body) return;
58
- const { email, password, name = null } = body;
59
-
60
- const existingUser = await findUserByEmail(email);
61
- if (existingUser) {
62
- res.status(409).json({ error: "User already exists" });
63
- return;
64
- }
65
-
66
- const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
67
-
68
- let user;
69
- try {
70
- user = await createUser({ email, name, password: hashedPassword });
71
- } catch (error) {
72
- // A concurrent request registered the same email after the check above
73
- if (isDuplicateEmailError(error)) {
74
- res.status(409).json({ error: "User already exists" });
75
- return;
76
- }
77
- throw error;
78
- }
79
-
80
- const token = signToken({ sub: user.id, email: user.email, name: user.name });
81
-
82
- res.status(201).json({ user: toPublicUser(user), token });
83
- }
84
-
85
- export async function login(req: Request, res: Response): Promise<void> {
86
- const body = parseBody(loginSchema, req, res);
87
- if (!body) return;
88
- const { email, password } = body;
89
-
90
- const user = await findUserByEmail(email);
91
-
92
- if (!user) {
93
- res.status(401).json({ error: "Invalid credentials" });
94
- return;
95
- }
96
-
97
- const isValidPassword = await bcrypt.compare(password, user.password);
98
-
99
- if (!isValidPassword) {
100
- res.status(401).json({ error: "Invalid credentials" });
101
- return;
102
- }
103
-
104
- const token = signToken({ sub: user.id, email: user.email, name: user.name });
105
-
106
- res.json({ user: toPublicUser(user), token });
107
- }
108
-
109
- export function me(req: AuthenticatedRequest, res: Response): void {
110
- if (!req.user) {
111
- res.status(401).json({ error: "Unauthorized" });
112
- return;
113
- }
114
-
115
- res.json({ user: req.user });
116
- }
@@ -1,27 +0,0 @@
1
- import jwt, { type SignOptions } from "jsonwebtoken";
2
-
3
- export interface AuthTokenPayload {
4
- sub: string;
5
- email: string;
6
- name?: string | null;
7
- }
8
-
9
- const { JWT_SECRET, JWT_EXPIRES_IN } = process.env;
10
-
11
- function ensureSecret(): string {
12
- if (!JWT_SECRET) {
13
- throw new Error("JWT_SECRET is not set");
14
- }
15
- return JWT_SECRET;
16
- }
17
-
18
- export function signToken(payload: AuthTokenPayload): string {
19
- const secret = ensureSecret();
20
- const expiresIn = JWT_EXPIRES_IN || "1h";
21
- return jwt.sign(payload, secret, { expiresIn } as SignOptions);
22
- }
23
-
24
- export function verifyToken(token: string): AuthTokenPayload {
25
- const secret = ensureSecret();
26
- return jwt.verify(token, secret) as AuthTokenPayload;
27
- }