@mars-stack/cli 0.2.0 → 0.2.2

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 (173) hide show
  1. package/package.json +2 -2
  2. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  3. package/template/.cursor/rules/data-access.mdc +29 -0
  4. package/template/.cursor/rules/project-structure.mdc +34 -0
  5. package/template/.cursor/rules/security.mdc +25 -0
  6. package/template/.cursor/rules/testing.mdc +24 -0
  7. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  8. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  9. package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
  10. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  11. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  12. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  13. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  14. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  15. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  16. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  17. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  18. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  19. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  20. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  21. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  22. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  23. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  24. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  25. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  26. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  27. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  28. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  29. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  30. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  31. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  32. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  33. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  34. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  35. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  36. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  37. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  38. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  39. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  40. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  41. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  42. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  43. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  44. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  45. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  46. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  47. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  48. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  49. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  50. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  51. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  52. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  53. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  54. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  55. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  56. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  57. package/template/AGENTS.md +104 -0
  58. package/template/ARCHITECTURE.md +102 -0
  59. package/template/docs/QUALITY_SCORE.md +20 -0
  60. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  61. package/template/docs/design-docs/core-beliefs.md +43 -0
  62. package/template/docs/design-docs/index.md +8 -0
  63. package/template/docs/exec-plans/active/.gitkeep +0 -0
  64. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  65. package/template/docs/exec-plans/tech-debt.md +7 -0
  66. package/template/docs/generated/.gitkeep +0 -0
  67. package/template/docs/product-specs/index.md +7 -0
  68. package/template/docs/references/index.md +18 -0
  69. package/template/e2e/api.spec.ts +20 -0
  70. package/template/e2e/auth.spec.ts +24 -0
  71. package/template/e2e/public.spec.ts +25 -0
  72. package/template/eslint.config.mjs +24 -0
  73. package/template/next-env.d.ts +6 -0
  74. package/template/next.config.ts +45 -0
  75. package/template/package.json +80 -0
  76. package/template/playwright.config.ts +31 -0
  77. package/template/postcss.config.mjs +8 -0
  78. package/template/prisma/generated/prisma/browser.ts +49 -0
  79. package/template/prisma/generated/prisma/client.ts +73 -0
  80. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  81. package/template/prisma/generated/prisma/enums.ts +15 -0
  82. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  83. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  84. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  85. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  86. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  87. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  88. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  89. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  90. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  91. package/template/prisma/generated/prisma/models.ts +17 -0
  92. package/template/prisma/schema/auth.prisma +69 -0
  93. package/template/prisma/schema/base.prisma +8 -0
  94. package/template/prisma/schema/file.prisma +15 -0
  95. package/template/prisma/schema/subscription.prisma +17 -0
  96. package/template/prisma.config.ts +13 -0
  97. package/template/scripts/check-architecture.ts +221 -0
  98. package/template/scripts/check-doc-freshness.ts +242 -0
  99. package/template/scripts/ensure-db.mjs +291 -0
  100. package/template/scripts/generate-docs.ts +143 -0
  101. package/template/scripts/generate-env-example.ts +89 -0
  102. package/template/scripts/seed.ts +56 -0
  103. package/template/scripts/update-quality-score.ts +263 -0
  104. package/template/src/__tests__/architecture.test.ts +114 -0
  105. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  106. package/template/src/app/(auth)/layout.tsx +11 -0
  107. package/template/src/app/(auth)/register/page.tsx +162 -0
  108. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  109. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  110. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  111. package/template/src/app/(auth)/verify/page.tsx +56 -0
  112. package/template/src/app/(protected)/admin/page.tsx +108 -0
  113. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  114. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  115. package/template/src/app/(protected)/layout.tsx +262 -0
  116. package/template/src/app/(protected)/settings/page.tsx +370 -0
  117. package/template/src/app/api/auth/forgot/route.ts +63 -0
  118. package/template/src/app/api/auth/login/route.ts +121 -0
  119. package/template/src/app/api/auth/logout/route.ts +19 -0
  120. package/template/src/app/api/auth/me/route.ts +30 -0
  121. package/template/src/app/api/auth/reset/route.ts +45 -0
  122. package/template/src/app/api/auth/signup/route.ts +85 -0
  123. package/template/src/app/api/auth/verify/route.ts +46 -0
  124. package/template/src/app/api/csrf/route.ts +12 -0
  125. package/template/src/app/api/health/route.ts +10 -0
  126. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  127. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  128. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  129. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  130. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  131. package/template/src/app/api/protected/user/password/route.ts +63 -0
  132. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  133. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  134. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  135. package/template/src/app/api/readiness/route.ts +15 -0
  136. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  137. package/template/src/app/error.tsx +33 -0
  138. package/template/src/app/layout.tsx +29 -0
  139. package/template/src/app/not-found.tsx +20 -0
  140. package/template/src/app/page.tsx +136 -0
  141. package/template/src/app/privacy/page.tsx +178 -0
  142. package/template/src/app/providers.tsx +8 -0
  143. package/template/src/app/terms/page.tsx +139 -0
  144. package/template/src/config/app.config.ts +70 -0
  145. package/template/src/config/routes.ts +17 -0
  146. package/template/src/features/admin/index.ts +11 -0
  147. package/template/src/features/admin/permissions.ts +64 -0
  148. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  149. package/template/src/features/auth/context/index.ts +2 -0
  150. package/template/src/features/auth/index.ts +3 -0
  151. package/template/src/features/auth/server/consent.ts +66 -0
  152. package/template/src/features/auth/server/session-revocation.ts +20 -0
  153. package/template/src/features/auth/server/sessions.ts +66 -0
  154. package/template/src/features/auth/server/user.ts +166 -0
  155. package/template/src/features/auth/types.ts +19 -0
  156. package/template/src/features/auth/validators.ts +29 -0
  157. package/template/src/features/billing/server/index.ts +66 -0
  158. package/template/src/features/billing/types.ts +43 -0
  159. package/template/src/features/uploads/server/index.ts +49 -0
  160. package/template/src/features/uploads/types.ts +26 -0
  161. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  162. package/template/src/lib/core/email/templates/index.ts +4 -0
  163. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  164. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  165. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  166. package/template/src/lib/mars.ts +56 -0
  167. package/template/src/lib/prisma.ts +19 -0
  168. package/template/src/proxy.ts +92 -0
  169. package/template/src/styles/brand.css +17 -0
  170. package/template/src/styles/globals.css +6 -0
  171. package/template/tsconfig.json +59 -0
  172. package/template/vitest.config.ts +41 -0
  173. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,385 @@
1
+ # Skill: Configure Magic Links
2
+
3
+ Set up passwordless authentication via magic links in a MARS application.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add passwordless login, magic link authentication, email-only login, or remove the password requirement from sign-in.
8
+
9
+ ## Prerequisites
10
+
11
+ - Email provider configured (see `configure-email` skill)
12
+ - Prisma schema includes a `User` model
13
+
14
+ ## Architecture
15
+
16
+ Magic link flow:
17
+ 1. User enters email → server generates a single-use token, stores it hashed with expiry, sends the raw token via email
18
+ 2. User clicks link → server hashes the incoming token, validates against DB, creates a session, and invalidates the token
19
+ 3. Tokens are single-use and time-limited to prevent replay attacks
20
+
21
+ The magic link service lives in `src/features/auth/server/magic-link.ts`.
22
+
23
+ ## Step 1: Prisma Schema
24
+
25
+ Add magic link fields to the `User` model or create a dedicated model:
26
+
27
+ ```prisma
28
+ // prisma/schema/auth.prisma
29
+ model MagicLink {
30
+ id String @id @default(cuid())
31
+ token String @unique
32
+ email String
33
+ expiresAt DateTime
34
+ usedAt DateTime?
35
+ createdAt DateTime @default(now())
36
+
37
+ @@index([token])
38
+ @@index([email])
39
+ }
40
+ ```
41
+
42
+ Run `yarn db:push` after changes.
43
+
44
+ ## Step 2: Configuration
45
+
46
+ Add magic link settings to `src/config/app.config.ts`:
47
+
48
+ ```typescript
49
+ auth: {
50
+ magicLink: {
51
+ enabled: true,
52
+ tokenExpiryMinutes: 15,
53
+ tokenLength: 32,
54
+ },
55
+ },
56
+ ```
57
+
58
+ ## Step 3: Magic Link Service
59
+
60
+ ```typescript
61
+ // src/features/auth/server/magic-link.ts
62
+ import 'server-only';
63
+
64
+ import { randomBytes, createHash } from 'crypto';
65
+ import { prisma } from '@/lib/prisma';
66
+ import { sendEmail } from '@/lib/mars';
67
+ import { appConfig } from '@/config/app.config';
68
+
69
+ function hashToken(token: string): string {
70
+ return createHash('sha256').update(token).digest('hex');
71
+ }
72
+
73
+ export async function createMagicLink(email: string): Promise<void> {
74
+ const rawToken = randomBytes(appConfig.auth.magicLink.tokenLength).toString('hex');
75
+ const hashedToken = hashToken(rawToken);
76
+ const expiryMinutes = appConfig.auth.magicLink.tokenExpiryMinutes;
77
+
78
+ // Invalidate any existing unused tokens for this email
79
+ await prisma.magicLink.updateMany({
80
+ where: { email, usedAt: null },
81
+ data: { usedAt: new Date() },
82
+ });
83
+
84
+ await prisma.magicLink.create({
85
+ data: {
86
+ token: hashedToken,
87
+ email,
88
+ expiresAt: new Date(Date.now() + expiryMinutes * 60 * 1000),
89
+ },
90
+ });
91
+
92
+ const baseUrl = process.env.APP_URL || 'http://localhost:3000';
93
+ const loginUrl = `${baseUrl}/api/auth/magic-link/verify?token=${rawToken}`;
94
+
95
+ await sendEmail({
96
+ to: email,
97
+ subject: `Sign in to ${appConfig.name}`,
98
+ text: `Click here to sign in: ${loginUrl}\n\nThis link expires in ${expiryMinutes} minutes and can only be used once.`,
99
+ html: `
100
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
101
+ <h2>Sign in to ${appConfig.name}</h2>
102
+ <p>Click the button below to sign in:</p>
103
+ <div style="text-align: center; margin: 30px 0;">
104
+ <a href="${loginUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
105
+ Sign In
106
+ </a>
107
+ </div>
108
+ <p style="color: #6b7280; font-size: 14px;">
109
+ This link expires in ${expiryMinutes} minutes and can only be used once.
110
+ </p>
111
+ <p style="color: #6b7280; font-size: 14px;">
112
+ If you didn't request this, you can safely ignore this email.
113
+ </p>
114
+ </div>
115
+ `,
116
+ });
117
+ }
118
+
119
+ export async function verifyMagicLink(
120
+ rawToken: string,
121
+ ): Promise<{ success: boolean; email?: string; error?: string }> {
122
+ const hashedToken = hashToken(rawToken);
123
+
124
+ const magicLink = await prisma.magicLink.findUnique({
125
+ where: { token: hashedToken },
126
+ });
127
+
128
+ if (!magicLink) {
129
+ return { success: false, error: 'Invalid or expired link' };
130
+ }
131
+
132
+ if (magicLink.usedAt) {
133
+ return { success: false, error: 'This link has already been used' };
134
+ }
135
+
136
+ if (magicLink.expiresAt < new Date()) {
137
+ return { success: false, error: 'This link has expired' };
138
+ }
139
+
140
+ // Mark token as used (single-use enforcement)
141
+ await prisma.magicLink.update({
142
+ where: { id: magicLink.id },
143
+ data: { usedAt: new Date() },
144
+ });
145
+
146
+ return { success: true, email: magicLink.email };
147
+ }
148
+ ```
149
+
150
+ ## Step 4: Request Magic Link Route
151
+
152
+ ```typescript
153
+ // src/app/api/auth/magic-link/route.ts
154
+ import { handleApiError } from '@/lib/mars';
155
+ import { checkRateLimit } from '@/lib/core/rate-limit';
156
+ import { createMagicLink } from '@/features/auth/server/magic-link';
157
+ import { NextResponse, type NextRequest } from 'next/server';
158
+ import { z } from 'zod';
159
+
160
+ const requestSchema = z.object({
161
+ email: z.string().email('Invalid email address'),
162
+ });
163
+
164
+ export async function POST(request: NextRequest) {
165
+ try {
166
+ const { email } = requestSchema.parse(await request.json());
167
+
168
+ await checkRateLimit(`magic-link:${email}`, {
169
+ maxAttempts: 5,
170
+ windowMs: 15 * 60 * 1000,
171
+ });
172
+
173
+ // Always return success to prevent email enumeration
174
+ try {
175
+ await createMagicLink(email);
176
+ } catch {
177
+ // Silently fail — don't reveal whether the email exists
178
+ }
179
+
180
+ return NextResponse.json({
181
+ message: 'If an account exists with that email, a sign-in link has been sent.',
182
+ });
183
+ } catch (error) {
184
+ return handleApiError(error, { endpoint: '/api/auth/magic-link' });
185
+ }
186
+ }
187
+ ```
188
+
189
+ ## Step 5: Verify Magic Link Route
190
+
191
+ ```typescript
192
+ // src/app/api/auth/magic-link/verify/route.ts
193
+ import { handleApiError } from '@/lib/mars';
194
+ import { verifyMagicLink } from '@/features/auth/server/magic-link';
195
+ import { createSession } from '@/features/auth/server/sessions';
196
+ import { prisma } from '@/lib/prisma';
197
+ import { NextResponse, type NextRequest } from 'next/server';
198
+ import { z } from 'zod';
199
+
200
+ const verifySchema = z.object({
201
+ token: z.string().min(1),
202
+ });
203
+
204
+ export async function GET(request: NextRequest) {
205
+ try {
206
+ const { searchParams } = new URL(request.url);
207
+ const { token } = verifySchema.parse({ token: searchParams.get('token') });
208
+
209
+ const result = await verifyMagicLink(token);
210
+
211
+ if (!result.success) {
212
+ return NextResponse.redirect(
213
+ new URL(`/auth/login?error=${encodeURIComponent(result.error!)}`, request.url),
214
+ );
215
+ }
216
+
217
+ // Find or create the user
218
+ let user = await prisma.user.findUnique({ where: { email: result.email! } });
219
+
220
+ if (!user) {
221
+ user = await prisma.user.create({
222
+ data: {
223
+ email: result.email!,
224
+ emailVerified: true,
225
+ },
226
+ });
227
+ } else if (!user.emailVerified) {
228
+ await prisma.user.update({
229
+ where: { id: user.id },
230
+ data: { emailVerified: true },
231
+ });
232
+ }
233
+
234
+ // Create session and redirect
235
+ const response = NextResponse.redirect(new URL('/dashboard', request.url));
236
+ await createSession(user.id, response);
237
+
238
+ return response;
239
+ } catch (error) {
240
+ return handleApiError(error, { endpoint: '/api/auth/magic-link/verify' });
241
+ }
242
+ }
243
+ ```
244
+
245
+ ## Step 6: Login Page — Magic Link Form
246
+
247
+ Add a magic link option to the login page:
248
+
249
+ ```typescript
250
+ // src/features/auth/components/MagicLinkForm.tsx
251
+ 'use client';
252
+
253
+ import { useState } from 'react';
254
+
255
+ export function MagicLinkForm() {
256
+ const [email, setEmail] = useState('');
257
+ const [submitted, setSubmitted] = useState(false);
258
+ const [loading, setLoading] = useState(false);
259
+ const [error, setError] = useState('');
260
+
261
+ async function handleSubmit(e: React.FormEvent) {
262
+ e.preventDefault();
263
+ setLoading(true);
264
+ setError('');
265
+
266
+ try {
267
+ const res = await fetch('/api/auth/magic-link', {
268
+ method: 'POST',
269
+ headers: { 'Content-Type': 'application/json' },
270
+ body: JSON.stringify({ email }),
271
+ });
272
+
273
+ if (!res.ok) {
274
+ const data = await res.json();
275
+ setError(data.error || 'Something went wrong');
276
+ return;
277
+ }
278
+
279
+ setSubmitted(true);
280
+ } catch {
281
+ setError('Network error. Please try again.');
282
+ } finally {
283
+ setLoading(false);
284
+ }
285
+ }
286
+
287
+ if (submitted) {
288
+ return (
289
+ <div className="text-center">
290
+ <h2 className="text-xl font-semibold text-content-primary">Check your email</h2>
291
+ <p className="mt-2 text-content-secondary">
292
+ We sent a sign-in link to <strong>{email}</strong>.
293
+ </p>
294
+ <p className="mt-1 text-sm text-content-tertiary">
295
+ The link expires in 15 minutes.
296
+ </p>
297
+ </div>
298
+ );
299
+ }
300
+
301
+ return (
302
+ <form onSubmit={handleSubmit} className="space-y-4">
303
+ <div>
304
+ <label htmlFor="email" className="block text-sm font-medium text-content-primary">
305
+ Email address
306
+ </label>
307
+ <input
308
+ id="email"
309
+ type="email"
310
+ value={email}
311
+ onChange={(e) => setEmail(e.target.value)}
312
+ required
313
+ className="mt-1 block w-full rounded-md border border-border-primary bg-surface-primary px-3 py-2 text-content-primary placeholder:text-content-tertiary focus:border-interactive-primary focus:outline-none focus:ring-1 focus:ring-interactive-primary"
314
+ placeholder="you@example.com"
315
+ />
316
+ </div>
317
+ {error && <p className="text-sm text-status-error">{error}</p>}
318
+ <button
319
+ type="submit"
320
+ disabled={loading}
321
+ className="w-full rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover disabled:opacity-50"
322
+ >
323
+ {loading ? 'Sending...' : 'Send Magic Link'}
324
+ </button>
325
+ </form>
326
+ );
327
+ }
328
+ ```
329
+
330
+ ## Step 7: Cleanup Expired Tokens
331
+
332
+ Add a lazy cleanup function to prevent unbounded growth:
333
+
334
+ ```typescript
335
+ // In magic-link.ts
336
+ export async function cleanupExpiredTokens(): Promise<void> {
337
+ await prisma.magicLink.deleteMany({
338
+ where: {
339
+ OR: [
340
+ { expiresAt: { lt: new Date() } },
341
+ { usedAt: { not: null }, createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
342
+ ],
343
+ },
344
+ });
345
+ }
346
+ ```
347
+
348
+ Call this lazily during token creation (1% chance per request) to keep the table clean without `setInterval`:
349
+
350
+ ```typescript
351
+ if (Math.random() < 0.01) {
352
+ cleanupExpiredTokens().catch(() => {});
353
+ }
354
+ ```
355
+
356
+ ## Security Considerations
357
+
358
+ - **Single-use tokens**: Each token is marked as used immediately upon verification.
359
+ - **Previous tokens invalidated**: When a new magic link is requested, all previous unused tokens for that email are invalidated.
360
+ - **Hashed storage**: Raw tokens are never stored; only SHA-256 hashes are persisted.
361
+ - **No email enumeration**: The request endpoint always returns a success message regardless of whether the email exists.
362
+ - **Rate limiting**: Magic link requests are rate-limited per email address.
363
+ - **Short expiry**: Default 15 minutes prevents stale token abuse.
364
+
365
+ ## Testing
366
+
367
+ 1. Request a magic link with `console` email provider — check terminal for the link.
368
+ 2. Click the link — verify a session is created and the user is redirected.
369
+ 3. Click the same link again — should show "already used" error.
370
+ 4. Request a link, wait for expiry, then click — should show "expired" error.
371
+ 5. Request two links for the same email — only the second should work.
372
+ 6. Send 6 requests in 15 minutes — the 6th should be rate-limited.
373
+
374
+ ## Checklist
375
+
376
+ - [ ] `MagicLink` model added to Prisma schema
377
+ - [ ] Magic link config added to `app.config.ts`
378
+ - [ ] Token generation stores hashed token with expiry
379
+ - [ ] Previous tokens invalidated on new request
380
+ - [ ] Verification marks token as used before creating session
381
+ - [ ] Request endpoint prevents email enumeration
382
+ - [ ] Rate limiting applied to magic link requests
383
+ - [ ] Login page includes `MagicLinkForm` component
384
+ - [ ] Expired token cleanup runs lazily
385
+ - [ ] `db:push` run after schema changes