@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,121 @@
1
+ import 'server-only';
2
+
3
+ import { handleApiError, logSecurityEvent, createSession } from '@/lib/mars';
4
+ import { checkRateLimit, getClientIP, RATE_LIMITS, rateLimitResponse } from '@mars-stack/core/rate-limit';
5
+ import { verifyPassword } from '@mars-stack/core/auth/password';
6
+ import {
7
+ findUserByEmailForAuth,
8
+ incrementFailedLoginAttempts,
9
+ resetFailedLoginAttempts,
10
+ } from '@/features/auth/server/user';
11
+ import { createDbSession } from '@/features/auth/server/sessions';
12
+ import { apiSchemas } from '@mars-stack/core/auth/validation';
13
+ import { buildCredentialTag } from '@mars-stack/core/auth/credential-tag';
14
+ import { NextResponse } from 'next/server';
15
+
16
+ const DUMMY_HASH =
17
+ '$2a$12$zEgRvyqUT4JQBPCQZmCIteu6sSGKbqwm0D93.CC8C8AImVzKsMe0q';
18
+
19
+ export async function POST(request: Request) {
20
+ const ipAddress = getClientIP(request);
21
+ const rateLimit = await checkRateLimit(ipAddress, RATE_LIMITS.login);
22
+ if (!rateLimit.success) return rateLimitResponse(rateLimit.resetAt);
23
+
24
+ const userAgent = request.headers.get('user-agent') || 'unknown';
25
+
26
+ try {
27
+ const body = await request.json();
28
+ const { email, password } = apiSchemas.login.parse(body);
29
+
30
+ const user = await findUserByEmailForAuth(email);
31
+
32
+ if (!user || !user.password) {
33
+ await verifyPassword('dummy-password-for-timing', DUMMY_HASH);
34
+ logSecurityEvent.loginFailure({
35
+ email,
36
+ ipAddress,
37
+ userAgent,
38
+ reason: 'Invalid credentials - user not found or no password',
39
+ });
40
+ return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
41
+ }
42
+
43
+ if (!user.emailVerified) {
44
+ logSecurityEvent.loginFailure({ email, ipAddress, userAgent, reason: 'Email not verified' });
45
+ return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
46
+ }
47
+
48
+ if (user.lockedUntil && user.lockedUntil > new Date()) {
49
+ logSecurityEvent.loginFailure({ email, ipAddress, userAgent, reason: 'Account locked' });
50
+ return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
51
+ }
52
+
53
+ const isPasswordValid = await verifyPassword(password, user.password);
54
+
55
+ if (!isPasswordValid) {
56
+ const updatedUser = await incrementFailedLoginAttempts(user.id);
57
+
58
+ logSecurityEvent.loginFailure({ email, ipAddress, userAgent, reason: 'Invalid password' });
59
+
60
+ if (updatedUser.lockedUntil && updatedUser.lockedUntil > new Date()) {
61
+ logSecurityEvent.accountLocked({
62
+ userId: user.id,
63
+ email,
64
+ failedAttempts: updatedUser.failedLoginAttempts,
65
+ });
66
+ }
67
+
68
+ return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
69
+ }
70
+
71
+ await resetFailedLoginAttempts(user.id);
72
+
73
+ logSecurityEvent.loginSuccess({ userId: user.id, email, ipAddress, userAgent });
74
+
75
+ if (user.role === 'admin') {
76
+ logSecurityEvent.adminLogin({ userId: user.id, email, ipAddress });
77
+ }
78
+
79
+ await createSession({
80
+ id: user.id,
81
+ email: user.email,
82
+ name: user.name || user.email,
83
+ role: user.role,
84
+ emailVerified: !!user.emailVerified,
85
+ credentialTag: await buildCredentialTag(user.password),
86
+ });
87
+
88
+ await createDbSession({
89
+ userId: user.id,
90
+ ipAddress,
91
+ userAgent,
92
+ });
93
+
94
+ return NextResponse.json(
95
+ {
96
+ message: 'Login successful',
97
+ user: {
98
+ id: user.id,
99
+ name: user.name || user.email,
100
+ role: user.role,
101
+ emailVerified: !!user.emailVerified,
102
+ },
103
+ },
104
+ { status: 200 },
105
+ );
106
+ } catch (error) {
107
+ const safeReason =
108
+ error instanceof Error ? String(error.message).slice(0, 100) : 'Unknown error';
109
+ logSecurityEvent.loginFailure({
110
+ email: 'unknown',
111
+ ipAddress,
112
+ userAgent,
113
+ reason: `Server error: ${safeReason}`,
114
+ });
115
+
116
+ return handleApiError(error, {
117
+ endpoint: '/api/auth/login',
118
+ fallbackMessage: 'An error occurred during login',
119
+ });
120
+ }
121
+ }
@@ -0,0 +1,19 @@
1
+ import { deleteSession, getSession } from '@/lib/mars';
2
+ import { revokeAllSessionsForUser } from '@/features/auth/server/sessions';
3
+ import { NextResponse } from 'next/server';
4
+
5
+ export async function POST() {
6
+ try {
7
+ const session = await getSession();
8
+
9
+ if (session?.userId) {
10
+ await revokeAllSessionsForUser(session.userId);
11
+ }
12
+
13
+ await deleteSession();
14
+ return NextResponse.json({ message: 'Logout successful' }, { status: 200 });
15
+ } catch (error) {
16
+ console.error('Logout error:', error);
17
+ return NextResponse.json({ error: 'An error occurred during logout' }, { status: 500 });
18
+ }
19
+ }
@@ -0,0 +1,30 @@
1
+ import { handleApiError, getSession } from '@/lib/mars';
2
+ import { NextResponse } from 'next/server';
3
+
4
+ export async function GET() {
5
+ try {
6
+ const session = await getSession();
7
+
8
+ if (!session) {
9
+ return NextResponse.json(
10
+ { authenticated: false, user: null },
11
+ { status: 200, headers: { 'Cache-Control': 'no-store' } },
12
+ );
13
+ }
14
+
15
+ return NextResponse.json(
16
+ {
17
+ authenticated: true,
18
+ user: {
19
+ id: session.userId,
20
+ name: session.name,
21
+ role: session.role,
22
+ emailVerified: session.emailVerified,
23
+ },
24
+ },
25
+ { status: 200, headers: { 'Cache-Control': 'no-store' } },
26
+ );
27
+ } catch (error) {
28
+ return handleApiError(error, { endpoint: '/api/auth/me' });
29
+ }
30
+ }
@@ -0,0 +1,45 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import { checkRateLimit, getClientIP, RATE_LIMITS, rateLimitResponse } from '@mars-stack/core/rate-limit';
3
+ import { hashPassword } from '@mars-stack/core/auth/password';
4
+ import { hashPasswordResetToken } from '@mars-stack/core/auth/reset-token';
5
+ import { findUserByEmailPublic, updateUserPassword } from '@/features/auth/server/user';
6
+ import { handleApiError } from '@/lib/mars';
7
+ import { apiSchemas } from '@mars-stack/core/auth/validation';
8
+ import { NextResponse } from 'next/server';
9
+
10
+ export async function POST(req: Request) {
11
+ const ip = getClientIP(req);
12
+ const rateLimit = await checkRateLimit(ip, RATE_LIMITS.resetPassword);
13
+ if (!rateLimit.success) return rateLimitResponse(rateLimit.resetAt);
14
+
15
+ try {
16
+ const { token, email, password } = apiSchemas.resetPassword.parse(await req.json());
17
+ const tokenHash = await hashPasswordResetToken(token);
18
+
19
+ const verificationToken = await prisma.verificationToken.findUnique({
20
+ where: { token: tokenHash },
21
+ });
22
+
23
+ if (!verificationToken || verificationToken.expires < new Date()) {
24
+ return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 });
25
+ }
26
+
27
+ if (verificationToken.identifier !== email) {
28
+ return NextResponse.json({ error: 'Invalid token for this email' }, { status: 400 });
29
+ }
30
+
31
+ const user = await findUserByEmailPublic(email);
32
+ if (!user) {
33
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
34
+ }
35
+
36
+ const hashedPassword = await hashPassword(password);
37
+ await updateUserPassword(user.id, hashedPassword);
38
+
39
+ await prisma.verificationToken.delete({ where: { token: tokenHash } });
40
+
41
+ return NextResponse.json({ message: 'Password reset successfully', ok: true });
42
+ } catch (error) {
43
+ return handleApiError(error, { endpoint: '/api/auth/reset' });
44
+ }
45
+ }
@@ -0,0 +1,85 @@
1
+ import 'server-only';
2
+
3
+ import { prisma } from '@/lib/prisma';
4
+ import { sendEmail, handleApiError, getBaseUrl } from '@/lib/mars';
5
+ import { checkRateLimit, getClientIP, RATE_LIMITS, rateLimitResponse } from '@mars-stack/core/rate-limit';
6
+ import { hashPassword } from '@mars-stack/core/auth/password';
7
+ import { hashToken } from '@mars-stack/core/auth/crypto-utils';
8
+ import { findUserByEmailPublic } from '@/features/auth/server/user';
9
+ import { buildEmailVerificationUrl } from '@mars-stack/core/auth/link-utils';
10
+ import { apiSchemas } from '@mars-stack/core/auth/validation';
11
+ import { verificationEmailHtml } from '@/lib/core/email/templates';
12
+ import { appConfig } from '@/config/app.config';
13
+ import { randomBytes } from 'crypto';
14
+ import { NextResponse } from 'next/server';
15
+
16
+ export async function POST(request: Request) {
17
+ const ip = getClientIP(request);
18
+ const rateLimit = await checkRateLimit(ip, RATE_LIMITS.signup);
19
+ if (!rateLimit.success) return rateLimitResponse(rateLimit.resetAt);
20
+
21
+ try {
22
+ const body = await request.json();
23
+ const { email, password, name, termsAccepted, marketingOptIn } = apiSchemas.signup.parse(body);
24
+
25
+ const existingUser = await findUserByEmailPublic(email);
26
+
27
+ if (existingUser) {
28
+ await hashPassword('dummy-password-for-timing');
29
+ return NextResponse.json({ message: 'User created successfully' }, { status: 201 });
30
+ }
31
+
32
+ const hashedPassword = await hashPassword(password);
33
+ const token = randomBytes(32).toString('hex');
34
+ const tokenHash = await hashToken(token, 'email-verification-v1');
35
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
36
+
37
+ const autoVerify =
38
+ process.env.NODE_ENV === 'development' &&
39
+ process.env.AUTO_VERIFY_EMAIL === 'true';
40
+
41
+ await prisma.$transaction(async (tx) => {
42
+ await tx.user.create({
43
+ data: {
44
+ email,
45
+ password: hashedPassword,
46
+ name,
47
+ role: 'user',
48
+ emailVerified: autoVerify ? new Date() : null,
49
+ termsAcceptedAt: termsAccepted ? new Date() : null,
50
+ privacyAcceptedAt: termsAccepted ? new Date() : null,
51
+ marketingOptIn: marketingOptIn || false,
52
+ marketingOptInAt: marketingOptIn ? new Date() : null,
53
+ },
54
+ });
55
+ if (!autoVerify) {
56
+ await tx.verificationToken.create({
57
+ data: { identifier: email, token: tokenHash, expires },
58
+ });
59
+ }
60
+ });
61
+
62
+ if (autoVerify) {
63
+ console.log(`[mars:auth] AUTO_VERIFY_EMAIL is enabled — ${email} verified immediately`);
64
+ } else {
65
+ const baseUrl = getBaseUrl();
66
+ const verifyUrl = buildEmailVerificationUrl({ baseUrl, token });
67
+ const { html, text } = verificationEmailHtml({
68
+ appName: appConfig.name,
69
+ verifyUrl,
70
+ userName: name || undefined,
71
+ });
72
+
73
+ await sendEmail({
74
+ to: email,
75
+ subject: `Welcome to ${appConfig.name} - Verify Your Email`,
76
+ html,
77
+ text,
78
+ });
79
+ }
80
+
81
+ return NextResponse.json({ message: 'User created successfully' }, { status: 201 });
82
+ } catch (error) {
83
+ return handleApiError(error, { endpoint: '/api/auth/signup', fallbackMessage: 'An error occurred during signup' });
84
+ }
85
+ }
@@ -0,0 +1,46 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import { checkRateLimit, getClientIP, RATE_LIMITS, rateLimitResponse } from '@mars-stack/core/rate-limit';
3
+ import { verifyEmailFromToken } from '@mars-stack/core/auth/verification';
4
+ import { handleApiError } from '@/lib/mars';
5
+ import { apiSchemas } from '@mars-stack/core/auth/validation';
6
+ import { NextResponse } from 'next/server';
7
+
8
+ export async function POST(req: Request) {
9
+ const ip = getClientIP(req);
10
+ const rateLimit = await checkRateLimit(ip, RATE_LIMITS.emailVerification);
11
+ if (!rateLimit.success) return rateLimitResponse(rateLimit.resetAt);
12
+
13
+ try {
14
+ const { token, email } = apiSchemas.emailVerification.parse(await req.json());
15
+
16
+ const result = await verifyEmailFromToken({
17
+ token,
18
+ email,
19
+ verificationTokens: prisma.verificationToken,
20
+ users: prisma.user,
21
+ });
22
+
23
+ if (!result.ok) {
24
+ if (result.error === 'invalid_or_expired') {
25
+ return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 });
26
+ }
27
+ if (result.error === 'email_mismatch') {
28
+ return NextResponse.json({ error: 'Invalid verification link' }, { status: 400 });
29
+ }
30
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
31
+ }
32
+
33
+ return NextResponse.json(
34
+ {
35
+ message:
36
+ result.status === 'already_verified'
37
+ ? 'Email already verified'
38
+ : 'Email verified successfully',
39
+ ok: true,
40
+ },
41
+ { status: 200 },
42
+ );
43
+ } catch (error) {
44
+ return handleApiError(error, { endpoint: '/api/auth/verify' });
45
+ }
46
+ }
@@ -0,0 +1,12 @@
1
+ import { requireCSRFToken } from '@/lib/mars';
2
+ import { NextResponse } from 'next/server';
3
+
4
+ export async function GET() {
5
+ try {
6
+ const token = await requireCSRFToken();
7
+ return NextResponse.json({ token }, { status: 200 });
8
+ } catch (error) {
9
+ console.error('CSRF token generation error:', error);
10
+ return NextResponse.json({ error: 'Failed to generate CSRF token' }, { status: 500 });
11
+ }
12
+ }
@@ -0,0 +1,10 @@
1
+ import { NextResponse } from 'next/server';
2
+ import packageJson from '../../../../package.json';
3
+
4
+ export async function GET() {
5
+ return NextResponse.json({
6
+ status: 'ok',
7
+ version: packageJson.version,
8
+ uptime: process.uptime(),
9
+ });
10
+ }
@@ -0,0 +1,24 @@
1
+ import { withRole, handleApiError } from '@/lib/mars';
2
+ import { prisma } from '@/lib/prisma';
3
+ import { NextResponse } from 'next/server';
4
+ import type { AuthenticatedRequest } from '@mars-stack/core/auth/middleware';
5
+
6
+ export const GET = withRole(['admin'], async (_request: AuthenticatedRequest, _context) => {
7
+ try {
8
+ const users = await prisma.user.findMany({
9
+ select: {
10
+ id: true,
11
+ name: true,
12
+ email: true,
13
+ role: true,
14
+ emailVerified: true,
15
+ createdAt: true,
16
+ },
17
+ orderBy: { createdAt: 'desc' },
18
+ });
19
+
20
+ return NextResponse.json({ users });
21
+ } catch (error) {
22
+ return handleApiError(error, { endpoint: '/api/protected/admin/users' });
23
+ }
24
+ });
@@ -0,0 +1,83 @@
1
+ import { withAuthNoParams, handleApiError } from '@/lib/mars';
2
+ import { prisma } from '@/lib/prisma';
3
+ import { createPaymentService } from '@mars-stack/core/payments';
4
+ import { appConfig } from '@/config/app.config';
5
+ import type { AuthenticatedRequest } from '@mars-stack/core/auth/middleware';
6
+ import { NextResponse } from 'next/server';
7
+ import { z } from 'zod';
8
+
9
+ const checkoutSchema = z.object({
10
+ priceId: z.string().min(1, 'Price ID is required'),
11
+ });
12
+
13
+ const payments = createPaymentService({
14
+ provider: appConfig.services.payments.provider,
15
+ });
16
+
17
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
18
+ try {
19
+ const body = await request.json();
20
+ const result = checkoutSchema.safeParse(body);
21
+
22
+ if (!result.success) {
23
+ return NextResponse.json(
24
+ { error: result.error.issues[0]?.message || 'Invalid input' },
25
+ { status: 400 },
26
+ );
27
+ }
28
+
29
+ const { priceId } = result.data;
30
+ const userId = request.session.userId;
31
+
32
+ const user = await prisma.user.findUnique({
33
+ where: { id: userId },
34
+ select: { email: true, subscription: { select: { stripeCustomerId: true } } },
35
+ });
36
+
37
+ if (!user) {
38
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
39
+ }
40
+
41
+ let stripeCustomerId = user.subscription?.stripeCustomerId;
42
+
43
+ if (!stripeCustomerId) {
44
+ const Stripe = (await import('stripe')).default;
45
+ const secretKey = process.env.STRIPE_SECRET_KEY;
46
+ if (!secretKey) throw new Error('STRIPE_SECRET_KEY is not set');
47
+
48
+ const stripe = new Stripe(secretKey);
49
+ const customer = await stripe.customers.create({
50
+ email: user.email,
51
+ metadata: { userId },
52
+ });
53
+
54
+ stripeCustomerId = customer.id;
55
+
56
+ await prisma.subscription.upsert({
57
+ where: { userId },
58
+ create: {
59
+ userId,
60
+ stripeCustomerId: stripeCustomerId,
61
+ status: 'inactive',
62
+ },
63
+ update: {
64
+ stripeCustomerId: stripeCustomerId,
65
+ },
66
+ });
67
+ }
68
+
69
+ const baseUrl = new URL(request.url).origin;
70
+
71
+ const { url } = await payments.createCheckoutSession({
72
+ customerId: stripeCustomerId as string,
73
+ priceId,
74
+ successUrl: `${baseUrl}/settings?billing=success`,
75
+ cancelUrl: `${baseUrl}/settings?billing=cancelled`,
76
+ metadata: { userId },
77
+ });
78
+
79
+ return NextResponse.json({ url });
80
+ } catch (error) {
81
+ return handleApiError(error, { endpoint: '/api/protected/billing/checkout' });
82
+ }
83
+ });
@@ -0,0 +1,39 @@
1
+ import { withAuthNoParams, handleApiError } from '@/lib/mars';
2
+ import { prisma } from '@/lib/prisma';
3
+ import { createPaymentService } from '@mars-stack/core/payments';
4
+ import { appConfig } from '@/config/app.config';
5
+ import type { AuthenticatedRequest } from '@mars-stack/core/auth/middleware';
6
+ import { NextResponse } from 'next/server';
7
+
8
+ const payments = createPaymentService({
9
+ provider: appConfig.services.payments.provider,
10
+ });
11
+
12
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
13
+ try {
14
+ const userId = request.session.userId;
15
+
16
+ const subscription = await prisma.subscription.findUnique({
17
+ where: { userId },
18
+ select: { stripeCustomerId: true },
19
+ });
20
+
21
+ if (!subscription?.stripeCustomerId) {
22
+ return NextResponse.json(
23
+ { error: 'No billing account found. Please subscribe first.' },
24
+ { status: 404 },
25
+ );
26
+ }
27
+
28
+ const baseUrl = new URL(request.url).origin;
29
+
30
+ const { url } = await payments.createPortalSession({
31
+ customerId: subscription.stripeCustomerId,
32
+ returnUrl: `${baseUrl}/settings`,
33
+ });
34
+
35
+ return NextResponse.json({ url });
36
+ } catch (error) {
37
+ return handleApiError(error, { endpoint: '/api/protected/billing/portal' });
38
+ }
39
+ });
@@ -0,0 +1,86 @@
1
+ import { withOwnership, handleApiError } from '@/lib/mars';
2
+ import { appConfig } from '@/config/app.config';
3
+ import { createStorageService } from '@mars-stack/core/storage';
4
+ import { prisma } from '@/lib/prisma';
5
+ import { getFileRecord, deleteFileRecord } from '@/features/uploads/server';
6
+ import type { AuthenticatedRequest } from '@mars-stack/core/auth/middleware';
7
+ import { NextResponse } from 'next/server';
8
+
9
+ const storage = createStorageService({
10
+ provider: appConfig.services.storage.provider,
11
+ });
12
+
13
+ async function getFileOwnerUserId(
14
+ _request: AuthenticatedRequest,
15
+ context: { params: Promise<{ [key: string]: string }> },
16
+ ): Promise<string | null> {
17
+ const { fileId } = await context.params;
18
+ const file = await prisma.file.findUnique({
19
+ where: { id: fileId },
20
+ select: { userId: true },
21
+ });
22
+ return file?.userId ?? null;
23
+ }
24
+
25
+ export const GET = withOwnership(
26
+ getFileOwnerUserId,
27
+ async (request: AuthenticatedRequest, context) => {
28
+ try {
29
+ const { fileId } = await context.params;
30
+ const file = await getFileRecord(fileId, request.session.userId);
31
+
32
+ if (!file) {
33
+ return NextResponse.json({ error: 'File not found' }, { status: 404 });
34
+ }
35
+
36
+ if (file.size > 100 * 1024 * 1024) {
37
+ return NextResponse.json({ error: 'File too large to proxy' }, { status: 413 });
38
+ }
39
+
40
+ const url = await storage.getSignedUrl(file.url);
41
+ const fileResponse = await fetch(url);
42
+
43
+ if (!fileResponse.ok || !fileResponse.body) {
44
+ return NextResponse.json(
45
+ { error: 'Failed to retrieve file from storage' },
46
+ { status: 502 },
47
+ );
48
+ }
49
+
50
+ const safeFilename = file.filename
51
+ .replace(/[^\w.\- ]/g, '_')
52
+ .slice(0, 255);
53
+
54
+ return new NextResponse(fileResponse.body, {
55
+ headers: {
56
+ 'Content-Type': file.contentType,
57
+ 'Content-Disposition': `attachment; filename="${safeFilename}"`,
58
+ 'Content-Length': String(file.size),
59
+ 'Cache-Control': 'private, no-cache',
60
+ },
61
+ });
62
+ } catch (error) {
63
+ return handleApiError(error, { endpoint: '/api/protected/files/[fileId]' });
64
+ }
65
+ },
66
+ );
67
+
68
+ export const DELETE = withOwnership(
69
+ getFileOwnerUserId,
70
+ async (request: AuthenticatedRequest, context) => {
71
+ try {
72
+ const { fileId } = await context.params;
73
+ const file = await deleteFileRecord(fileId, request.session.userId);
74
+
75
+ if (!file) {
76
+ return NextResponse.json({ error: 'File not found' }, { status: 404 });
77
+ }
78
+
79
+ await storage.delete(file.url);
80
+
81
+ return NextResponse.json({ message: 'File deleted' });
82
+ } catch (error) {
83
+ return handleApiError(error, { endpoint: '/api/protected/files/[fileId]' });
84
+ }
85
+ },
86
+ );
@@ -0,0 +1,64 @@
1
+ import { withAuthNoParams, handleApiError } from '@/lib/mars';
2
+ import { appConfig } from '@/config/app.config';
3
+ import { createStorageService } from '@mars-stack/core/storage';
4
+ import { createFileRecord } from '@/features/uploads/server';
5
+ import type { AuthenticatedRequest } from '@mars-stack/core/auth/middleware';
6
+ import { NextResponse } from 'next/server';
7
+
8
+ const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
9
+
10
+ const storage = createStorageService({
11
+ provider: appConfig.services.storage.provider,
12
+ maxFileSizeBytes: MAX_FILE_SIZE_BYTES,
13
+ });
14
+
15
+ function sanitizeForContentDisposition(filename: string): string {
16
+ return filename.replace(/[^\w.\-]/g, '_').slice(0, 255);
17
+ }
18
+
19
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
20
+ try {
21
+ const formData = await request.formData();
22
+ const file = formData.get('file');
23
+
24
+ if (!(file instanceof File)) {
25
+ return NextResponse.json(
26
+ { error: 'No file provided. Send a FormData field named "file".' },
27
+ { status: 400 },
28
+ );
29
+ }
30
+
31
+ if (file.size === 0) {
32
+ return NextResponse.json({ error: 'File is empty' }, { status: 400 });
33
+ }
34
+
35
+ if (file.size > MAX_FILE_SIZE_BYTES) {
36
+ return NextResponse.json(
37
+ { error: `File size exceeds maximum of ${MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB` },
38
+ { status: 413 },
39
+ );
40
+ }
41
+
42
+ const access = (formData.get('access') as string) === 'public' ? 'public' : 'private';
43
+
44
+ const result = await storage.upload({
45
+ filename: sanitizeForContentDisposition(file.name),
46
+ contentType: file.type || 'application/octet-stream',
47
+ data: file,
48
+ access,
49
+ });
50
+
51
+ const record = await createFileRecord({
52
+ userId: request.session.userId,
53
+ filename: file.name,
54
+ url: result.url,
55
+ contentType: result.contentType,
56
+ size: result.size,
57
+ access,
58
+ });
59
+
60
+ return NextResponse.json({ file: record }, { status: 201 });
61
+ } catch (error) {
62
+ return handleApiError(error, { endpoint: '/api/protected/files/upload' });
63
+ }
64
+ });