@mars-stack/cli 0.2.0 → 1.0.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 (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Scans each feature directory and updates docs/QUALITY_SCORE.md.
3
+ *
4
+ * For each feature in src/features/, checks:
5
+ * - Has server functions (files in server/)
6
+ * - Has types (*.types.ts, types.ts, or types/)
7
+ * - Has tests (*.test.ts, *.spec.ts, or __tests__/)
8
+ * - Has API routes (corresponding routes in src/app/api/)
9
+ *
10
+ * Grading:
11
+ * - A: All present + tests passing (test files exist with assertions)
12
+ * - B: All four categories present
13
+ * - C: Partial (2-3 categories)
14
+ * - D: Scaffold only (1 category)
15
+ * - F: Missing (0 categories)
16
+ *
17
+ * Usage: tsx scripts/update-quality-score.ts
18
+ */
19
+
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+
23
+ const ROOT = path.resolve(__dirname, '..');
24
+ const FEATURES_DIR = path.join(ROOT, 'src', 'features');
25
+ const API_DIR = path.join(ROOT, 'src', 'app', 'api');
26
+ const QUALITY_SCORE_PATH = path.join(ROOT, 'docs', 'QUALITY_SCORE.md');
27
+
28
+ type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
29
+
30
+ interface FeatureAssessment {
31
+ name: string;
32
+ hasServer: boolean;
33
+ hasTypes: boolean;
34
+ hasTests: boolean;
35
+ hasApiRoutes: boolean;
36
+ grade: Grade;
37
+ notes: string;
38
+ }
39
+
40
+ function collectFilesRecursive(dir: string): string[] {
41
+ const results: string[] = [];
42
+ if (!fs.existsSync(dir)) return results;
43
+
44
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
45
+ for (const entry of entries) {
46
+ const full = path.join(dir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ results.push(...collectFilesRecursive(full));
49
+ } else {
50
+ results.push(full);
51
+ }
52
+ }
53
+ return results;
54
+ }
55
+
56
+ function hasServerFunctions(featureDir: string): boolean {
57
+ const serverDir = path.join(featureDir, 'server');
58
+ if (!fs.existsSync(serverDir)) return false;
59
+
60
+ const files = collectFilesRecursive(serverDir);
61
+ return files.some((f) => f.endsWith('.ts') || f.endsWith('.tsx'));
62
+ }
63
+
64
+ function hasTypeDefinitions(featureDir: string): boolean {
65
+ const files = collectFilesRecursive(featureDir);
66
+ return files.some((f) => {
67
+ const basename = path.basename(f);
68
+ return (
69
+ basename === 'types.ts' ||
70
+ basename === 'types.tsx' ||
71
+ basename.endsWith('.types.ts') ||
72
+ basename.endsWith('.types.tsx')
73
+ );
74
+ });
75
+ }
76
+
77
+ function hasTestFiles(featureDir: string): boolean {
78
+ const files = collectFilesRecursive(featureDir);
79
+ const hasDirectTests = files.some((f) => {
80
+ const basename = path.basename(f);
81
+ return basename.endsWith('.test.ts') || basename.endsWith('.spec.ts') ||
82
+ basename.endsWith('.test.tsx') || basename.endsWith('.spec.tsx');
83
+ });
84
+
85
+ if (hasDirectTests) return true;
86
+
87
+ const testDir = path.join(featureDir, '__tests__');
88
+ if (fs.existsSync(testDir)) {
89
+ const testFiles = collectFilesRecursive(testDir);
90
+ return testFiles.some((f) => f.endsWith('.ts') || f.endsWith('.tsx'));
91
+ }
92
+
93
+ return false;
94
+ }
95
+
96
+ function hasApiRoutes(featureName: string): boolean {
97
+ const protectedDir = path.join(API_DIR, 'protected', featureName);
98
+ const publicDir = path.join(API_DIR, featureName);
99
+
100
+ function hasRouteFile(dir: string): boolean {
101
+ if (!fs.existsSync(dir)) return false;
102
+ const files = collectFilesRecursive(dir);
103
+ return files.some((f) => path.basename(f) === 'route.ts' || path.basename(f) === 'route.tsx');
104
+ }
105
+
106
+ return hasRouteFile(protectedDir) || hasRouteFile(publicDir);
107
+ }
108
+
109
+ function testsHaveAssertions(featureDir: string): boolean {
110
+ const files = collectFilesRecursive(featureDir);
111
+ const testFiles = files.filter((f) => {
112
+ const basename = path.basename(f);
113
+ return basename.endsWith('.test.ts') || basename.endsWith('.spec.ts') ||
114
+ basename.endsWith('.test.tsx') || basename.endsWith('.spec.tsx');
115
+ });
116
+
117
+ for (const testFile of testFiles) {
118
+ const content = fs.readFileSync(testFile, 'utf-8');
119
+ if (content.includes('expect(') || content.includes('assert(') || content.includes('assert.')) {
120
+ return true;
121
+ }
122
+ }
123
+
124
+ return false;
125
+ }
126
+
127
+ function computeGrade(assessment: FeatureAssessment): Grade {
128
+ const presentCount = [
129
+ assessment.hasServer,
130
+ assessment.hasTypes,
131
+ assessment.hasTests,
132
+ assessment.hasApiRoutes,
133
+ ].filter(Boolean).length;
134
+
135
+ if (presentCount === 0) return 'F';
136
+ if (presentCount === 1) return 'D';
137
+ if (presentCount < 4) return 'C';
138
+
139
+ if (assessment.hasTests && testsHaveAssertions(path.join(FEATURES_DIR, assessment.name))) {
140
+ return 'A';
141
+ }
142
+
143
+ return 'B';
144
+ }
145
+
146
+ function buildNotes(assessment: FeatureAssessment): string {
147
+ const present: string[] = [];
148
+ const missing: string[] = [];
149
+
150
+ if (assessment.hasServer) present.push('server'); else missing.push('server');
151
+ if (assessment.hasTypes) present.push('types'); else missing.push('types');
152
+ if (assessment.hasTests) present.push('tests'); else missing.push('tests');
153
+ if (assessment.hasApiRoutes) present.push('api routes'); else missing.push('api routes');
154
+
155
+ const parts: string[] = [];
156
+ if (present.length > 0) parts.push(`Has: ${present.join(', ')}`);
157
+ if (missing.length > 0) parts.push(`Missing: ${missing.join(', ')}`);
158
+
159
+ return parts.join('. ');
160
+ }
161
+
162
+ function assessFeature(featureName: string): FeatureAssessment {
163
+ const featureDir = path.join(FEATURES_DIR, featureName);
164
+
165
+ const assessment: FeatureAssessment = {
166
+ name: featureName,
167
+ hasServer: hasServerFunctions(featureDir),
168
+ hasTypes: hasTypeDefinitions(featureDir),
169
+ hasTests: hasTestFiles(featureName),
170
+ hasApiRoutes: hasApiRoutes(featureName),
171
+ grade: 'F',
172
+ notes: '',
173
+ };
174
+
175
+ assessment.grade = computeGrade(assessment);
176
+ assessment.notes = buildNotes(assessment);
177
+
178
+ return assessment;
179
+ }
180
+
181
+ function generateMarkdownSection(assessments: FeatureAssessment[]): string {
182
+ const lines: string[] = [
183
+ '## Feature Quality (Auto-Generated)',
184
+ '',
185
+ '> Auto-generated by `yarn update:quality`. Do not edit manually.',
186
+ '',
187
+ '| Feature | Grade | Server | Types | Tests | API Routes | Notes |',
188
+ '|---------|-------|--------|-------|-------|------------|-------|',
189
+ ];
190
+
191
+ for (const a of assessments) {
192
+ const check = (v: boolean): string => v ? 'Yes' : 'No';
193
+ lines.push(
194
+ `| ${a.name} | ${a.grade} | ${check(a.hasServer)} | ${check(a.hasTypes)} | ${check(a.hasTests)} | ${check(a.hasApiRoutes)} | ${a.notes} |`,
195
+ );
196
+ }
197
+
198
+ lines.push('');
199
+ return lines.join('\n');
200
+ }
201
+
202
+ function updateQualityScoreFile(newSection: string): void {
203
+ const docsDir = path.dirname(QUALITY_SCORE_PATH);
204
+ if (!fs.existsSync(docsDir)) {
205
+ fs.mkdirSync(docsDir, { recursive: true });
206
+ }
207
+
208
+ if (!fs.existsSync(QUALITY_SCORE_PATH)) {
209
+ fs.writeFileSync(QUALITY_SCORE_PATH, `# Quality Score\n\n${newSection}`);
210
+ return;
211
+ }
212
+
213
+ const content = fs.readFileSync(QUALITY_SCORE_PATH, 'utf-8');
214
+
215
+ const sectionStart = content.indexOf('## Feature Quality (Auto-Generated)');
216
+ if (sectionStart === -1) {
217
+ fs.writeFileSync(QUALITY_SCORE_PATH, content.trimEnd() + '\n\n' + newSection);
218
+ return;
219
+ }
220
+
221
+ const nextSectionMatch = content.slice(sectionStart + 1).match(/\n## [^#]/);
222
+ const sectionEnd = nextSectionMatch
223
+ ? sectionStart + 1 + nextSectionMatch.index!
224
+ : content.length;
225
+
226
+ const updated = content.slice(0, sectionStart) + newSection + content.slice(sectionEnd);
227
+ fs.writeFileSync(QUALITY_SCORE_PATH, updated);
228
+ }
229
+
230
+ function main(): void {
231
+ console.log('Scanning features and updating quality scores...\n');
232
+
233
+ if (!fs.existsSync(FEATURES_DIR)) {
234
+ console.log('No features directory found at src/features/. Nothing to scan.');
235
+ process.exit(0);
236
+ }
237
+
238
+ const featureNames = fs
239
+ .readdirSync(FEATURES_DIR, { withFileTypes: true })
240
+ .filter((e) => e.isDirectory())
241
+ .map((e) => e.name)
242
+ .sort();
243
+
244
+ if (featureNames.length === 0) {
245
+ console.log('No features found in src/features/.');
246
+ process.exit(0);
247
+ }
248
+
249
+ const assessments = featureNames.map(assessFeature);
250
+
251
+ console.log('Feature Assessment Results:\n');
252
+ for (const a of assessments) {
253
+ const icon = a.grade === 'A' ? '+' : a.grade === 'F' ? '!' : '-';
254
+ console.log(` [${icon}] ${a.name}: ${a.grade} — ${a.notes}`);
255
+ }
256
+
257
+ const newSection = generateMarkdownSection(assessments);
258
+ updateQualityScoreFile(newSection);
259
+
260
+ console.log(`\nUpdated ${path.relative(ROOT, QUALITY_SCORE_PATH)}`);
261
+ }
262
+
263
+ main();
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Structural tests that validate architecture invariants.
3
+ * These tests scan the codebase and assert that architectural rules are followed.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+
10
+ const SRC_DIR = path.resolve(__dirname, '..');
11
+
12
+ function getAllFiles(dir: string, extensions: string[]): string[] {
13
+ const results: string[] = [];
14
+ if (!fs.existsSync(dir)) return results;
15
+
16
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
17
+ for (const entry of entries) {
18
+ const fullPath = path.join(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ if (entry.name === 'node_modules' || entry.name === '.next' || entry.name === 'dist') continue;
21
+ results.push(...getAllFiles(fullPath, extensions));
22
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
23
+ results.push(fullPath);
24
+ }
25
+ }
26
+ return results;
27
+ }
28
+
29
+ describe('Architecture: Dependency Direction', () => {
30
+ it('features do not import from other features', () => {
31
+ const featuresDir = path.join(SRC_DIR, 'features');
32
+ if (!fs.existsSync(featuresDir)) return;
33
+
34
+ const featureDirs = fs
35
+ .readdirSync(featuresDir, { withFileTypes: true })
36
+ .filter((d) => d.isDirectory())
37
+ .map((d) => d.name);
38
+
39
+ const violations: string[] = [];
40
+
41
+ for (const feature of featureDirs) {
42
+ const featureDir = path.join(featuresDir, feature);
43
+ const files = getAllFiles(featureDir, ['.ts', '.tsx']);
44
+
45
+ for (const file of files) {
46
+ const content = fs.readFileSync(file, 'utf-8');
47
+ const importMatches = content.matchAll(
48
+ /(?:import|from)\s+['"](?:@\/|\.\.\/.*\/)features\/([^/'"\s]+)/g
49
+ );
50
+
51
+ for (const match of importMatches) {
52
+ const importedFeature = match[1];
53
+ if (importedFeature !== feature) {
54
+ const relPath = path.relative(SRC_DIR, file);
55
+ violations.push(
56
+ `${relPath} imports from features/${importedFeature} (cross-feature import)`
57
+ );
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ expect(violations).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe('Architecture: API Auth Coverage', () => {
68
+ it('all routes in api/protected/ use auth middleware', () => {
69
+ const protectedDir = path.join(SRC_DIR, 'app', 'api', 'protected');
70
+ if (!fs.existsSync(protectedDir)) return;
71
+
72
+ const routeFiles = getAllFiles(protectedDir, ['.ts', '.tsx']).filter(
73
+ (f) => path.basename(f).startsWith('route.')
74
+ );
75
+
76
+ const unprotected: string[] = [];
77
+
78
+ for (const file of routeFiles) {
79
+ const content = fs.readFileSync(file, 'utf-8');
80
+ const hasAuth =
81
+ content.includes('withAuth') ||
82
+ content.includes('withRole') ||
83
+ content.includes('withOwnership');
84
+
85
+ if (!hasAuth) {
86
+ unprotected.push(path.relative(SRC_DIR, file));
87
+ }
88
+ }
89
+
90
+ expect(unprotected).toEqual([]);
91
+ });
92
+ });
93
+
94
+ describe('Architecture: Server-Only Boundary', () => {
95
+ it('files in server/ directories import "server-only"', () => {
96
+ const files = getAllFiles(SRC_DIR, ['.ts', '.tsx']).filter(
97
+ (f) =>
98
+ f.includes('/server/') &&
99
+ !f.includes('.test.') &&
100
+ !f.includes('node_modules')
101
+ );
102
+
103
+ const missing: string[] = [];
104
+
105
+ for (const file of files) {
106
+ const content = fs.readFileSync(file, 'utf-8');
107
+ if (!content.includes("import 'server-only'") && !content.includes('import "server-only"')) {
108
+ missing.push(path.relative(SRC_DIR, file));
109
+ }
110
+ }
111
+
112
+ expect(missing).toEqual([]);
113
+ });
114
+ });
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { useCSRF } from '@mars-stack/core/auth/hooks';
4
+ import { formSchemas } from '@mars-stack/core/auth/validation';
5
+ import { useZodForm } from '@mars-stack/ui/hooks';
6
+ import { Button, Input, Text } from '@mars-stack/ui';
7
+ import Link from 'next/link';
8
+ import { routes } from '@/config/routes';
9
+ import { useState } from 'react';
10
+
11
+ export default function ForgottenPassword() {
12
+ const { getCSRFHeaders } = useCSRF();
13
+ const [serverError, setServerError] = useState('');
14
+ const [success, setSuccess] = useState(false);
15
+
16
+ const form = useZodForm({
17
+ schema: formSchemas.forgotPassword,
18
+ initialValues: { email: '' },
19
+ mode: 'onBlur',
20
+ });
21
+
22
+ const handleSubmit = form.handleSubmit(async (values) => {
23
+ setServerError('');
24
+ try {
25
+ const response = await fetch('/api/auth/forgot', {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
28
+ body: JSON.stringify(values),
29
+ });
30
+
31
+ if (!response.ok) {
32
+ const data = await response.json();
33
+ setServerError(data.error || 'Failed to send reset email');
34
+ return;
35
+ }
36
+
37
+ setSuccess(true);
38
+ } catch {
39
+ setServerError('Network error. Please try again.');
40
+ }
41
+ });
42
+
43
+ if (success) {
44
+ return (
45
+ <div className="text-center">
46
+ <Text.H3 as="h2">Check your email</Text.H3>
47
+ <Text.Paragraph>
48
+ If an account exists for that email, we&apos;ve sent password reset instructions.
49
+ </Text.Paragraph>
50
+ <Link href={routes.signIn} className="mt-6 inline-block text-sm text-text-link hover:text-text-link-hover hover:underline">
51
+ Back to sign in
52
+ </Link>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <>
59
+ <div>
60
+ <Text.H3 as="h2">Reset your password</Text.H3>
61
+ <Text.Paragraph className="text-sm">
62
+ Enter your email address and we&apos;ll send you a link to reset your password.
63
+ </Text.Paragraph>
64
+ </div>
65
+
66
+ <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
67
+ {serverError && (
68
+ <div className="rounded-md bg-error-muted p-3 text-sm text-text-error">{serverError}</div>
69
+ )}
70
+
71
+ <Input
72
+ {...form.getFieldProps('email')}
73
+ label="Email Address"
74
+ type="email"
75
+ placeholder="you@example.com"
76
+ fullWidth
77
+ required
78
+ />
79
+
80
+ <Button type="submit" disabled={form.isSubmitting || !form.isValid} fullWidth loading={form.isSubmitting}>
81
+ {form.isSubmitting ? 'Sending...' : 'Send reset link'}
82
+ </Button>
83
+ </form>
84
+
85
+ <div className="mt-4 text-center">
86
+ <Link href={routes.signIn} className="text-sm text-text-link hover:text-text-link-hover hover:underline">
87
+ Back to sign in
88
+ </Link>
89
+ </div>
90
+ </>
91
+ );
92
+ }
@@ -0,0 +1,11 @@
1
+ import { Card } from '@mars-stack/ui';
2
+
3
+ export default function AuthLayout({ children }: Readonly<{ children: React.ReactNode }>) {
4
+ return (
5
+ <div className="flex min-h-screen items-start justify-center bg-surface-background px-4 py-8 sm:px-6 lg:px-8">
6
+ <Card className="w-full max-w-md space-y-8" padding="lg">
7
+ {children}
8
+ </Card>
9
+ </div>
10
+ );
11
+ }
@@ -0,0 +1,162 @@
1
+ 'use client';
2
+
3
+ import { useAuth } from '@/features/auth/context/AuthContext';
4
+ import { useCSRF, usePasswordStrength } from '@mars-stack/core/auth/hooks';
5
+ import { formSchemas, type SignupFormData } from '@mars-stack/core/auth/validation';
6
+ import { routes } from '@/config/routes';
7
+ import { useZodForm } from '@mars-stack/ui/hooks';
8
+ import { Button, Checkbox, Input, Spinner, Text, PasswordStrengthIndicator } from '@mars-stack/ui';
9
+ import Link from 'next/link';
10
+ import { useRouter } from 'next/navigation';
11
+ import { Suspense, useEffect, useState } from 'react';
12
+
13
+ function SignUpForm() {
14
+ const router = useRouter();
15
+ const { isAuthenticated, isLoading } = useAuth();
16
+ const { getCSRFHeaders, getCSRFFormData } = useCSRF();
17
+ const [serverError, setServerError] = useState('');
18
+
19
+ useEffect(() => {
20
+ if (!isLoading && isAuthenticated) router.push(routes.dashboard);
21
+ }, [isAuthenticated, isLoading, router]);
22
+
23
+ const form = useZodForm({
24
+ schema: formSchemas.signup,
25
+ initialValues: {
26
+ name: '',
27
+ email: '',
28
+ password: '',
29
+ confirmPassword: '',
30
+ termsAccepted: false,
31
+ marketingOptIn: false,
32
+ },
33
+ mode: 'onBlur',
34
+ });
35
+
36
+ const passwordStrength = usePasswordStrength(form.values.password);
37
+
38
+ const handleSubmit = form.handleSubmit(async (values: SignupFormData) => {
39
+ setServerError('');
40
+ try {
41
+ const response = await fetch('/api/auth/signup', {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
44
+ body: JSON.stringify({ ...values, ...getCSRFFormData() }),
45
+ });
46
+
47
+ const data = await response.json();
48
+
49
+ if (!response.ok) {
50
+ setServerError(data.error || 'Failed to create account');
51
+ return;
52
+ }
53
+
54
+ sessionStorage.setItem('verify-email', values.email);
55
+ router.push('/verify');
56
+ } catch {
57
+ setServerError('Network error. Please try again.');
58
+ }
59
+ });
60
+
61
+ if (isLoading) return <Spinner size="md" />;
62
+ if (isAuthenticated) return <Text.Paragraph>Redirecting...</Text.Paragraph>;
63
+
64
+ return (
65
+ <>
66
+ <div>
67
+ <Text.H3 as="h2">Create your account</Text.H3>
68
+ <Text.Paragraph className="text-sm">
69
+ Already have an account?{' '}
70
+ <Link href={routes.signIn} className="text-text-link hover:text-text-link-hover hover:underline">
71
+ Sign in
72
+ </Link>
73
+ </Text.Paragraph>
74
+ </div>
75
+
76
+ <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
77
+ {serverError && (
78
+ <div className="rounded-md bg-error-muted p-3 text-sm text-text-error">{serverError}</div>
79
+ )}
80
+
81
+ <div className="space-y-4">
82
+ <Input
83
+ {...form.getFieldProps('name')}
84
+ label="Full Name"
85
+ type="text"
86
+ placeholder="John Doe"
87
+ fullWidth
88
+ required
89
+ />
90
+ <Input
91
+ {...form.getFieldProps('email')}
92
+ label="Email Address"
93
+ type="email"
94
+ placeholder="you@example.com"
95
+ fullWidth
96
+ required
97
+ />
98
+ <Input
99
+ {...form.getFieldProps('password')}
100
+ label="Password"
101
+ type="password"
102
+ placeholder="Create a strong password"
103
+ showPasswordToggle
104
+ fullWidth
105
+ required
106
+ />
107
+ {form.values.password && (
108
+ <PasswordStrengthIndicator
109
+ strength={passwordStrength.strength}
110
+ requirements={passwordStrength.requirements}
111
+ />
112
+ )}
113
+ <Input
114
+ {...form.getFieldProps('confirmPassword')}
115
+ label="Confirm Password"
116
+ type="password"
117
+ placeholder="Re-enter your password"
118
+ fullWidth
119
+ required
120
+ />
121
+
122
+ <div className="space-y-3">
123
+ <Checkbox
124
+ checked={form.values.termsAccepted}
125
+ onChange={(e) => form.setValue('termsAccepted', (e.target as HTMLInputElement).checked)}
126
+ required
127
+ label={
128
+ <span>
129
+ I agree to the{' '}
130
+ <Link href="/terms" className="text-text-link hover:underline">
131
+ Terms of Service
132
+ </Link>{' '}
133
+ and{' '}
134
+ <Link href="/privacy" className="text-text-link hover:underline">
135
+ Privacy Policy
136
+ </Link>
137
+ </span>
138
+ }
139
+ />
140
+ <Checkbox
141
+ checked={form.values.marketingOptIn}
142
+ onChange={(e) => form.setValue('marketingOptIn', (e.target as HTMLInputElement).checked)}
143
+ label="I would like to receive product updates and marketing emails"
144
+ />
145
+ </div>
146
+ </div>
147
+
148
+ <Button type="submit" disabled={form.isSubmitting || !form.isValid} fullWidth loading={form.isSubmitting}>
149
+ {form.isSubmitting ? 'Creating account...' : 'Create account'}
150
+ </Button>
151
+ </form>
152
+ </>
153
+ );
154
+ }
155
+
156
+ export default function Register() {
157
+ return (
158
+ <Suspense fallback={<Spinner size="md" />}>
159
+ <SignUpForm />
160
+ </Suspense>
161
+ );
162
+ }