@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,135 @@
1
+ # Skill: Add Middleware Logic
2
+
3
+ Extend the Next.js middleware to add new route protection, redirects, or request processing.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add route guards, redirects, header injection, geo-based routing, A/B testing at the edge, or any request-level logic.
8
+
9
+ ## Architecture
10
+
11
+ MARS uses a single `src/middleware.ts` file. Next.js only supports one middleware -- all logic lives here.
12
+
13
+ ## Current Middleware Structure
14
+
15
+ The middleware runs in order:
16
+
17
+ 1. **Coming-soon mode** -- redirects all non-API traffic to `/coming-soon` when `COMING_SOON=true`.
18
+ 2. **CSRF skip** -- allows `/api/csrf` through without validation.
19
+ 3. **CSRF validation** -- validates CSRF token on protected API routes and auth endpoints.
20
+ 4. **Session check** -- decrypts the session cookie.
21
+ 5. **Auth redirects** -- redirects authenticated users away from auth pages, unauthenticated users to sign-in.
22
+ 6. **Admin guard** -- redirects non-admin users away from admin routes.
23
+
24
+ ## Adding a New Route Guard
25
+
26
+ ### Step 1: Define the Routes
27
+
28
+ Add your route constants at the top of the file:
29
+
30
+ ```typescript
31
+ const premiumRoutes = ['/pro', '/analytics'];
32
+ ```
33
+
34
+ ### Step 2: Add the Check
35
+
36
+ Insert your logic after the session check (after line ~50) but before the final `NextResponse.next()`:
37
+
38
+ ```typescript
39
+ // Premium route guard
40
+ if (premiumRoutes.some((route) => pathname.startsWith(route))) {
41
+ if (!isAuthenticated) {
42
+ const url = new URL(routes.signIn, request.url);
43
+ url.searchParams.set('callbackUrl', encodeURI(request.url));
44
+ return NextResponse.redirect(url);
45
+ }
46
+ // Optional: check subscription status via a lightweight cookie or header
47
+ }
48
+ ```
49
+
50
+ ### Step 3: Update the Matcher
51
+
52
+ Add the new paths to the `config.matcher` array:
53
+
54
+ ```typescript
55
+ export const config = {
56
+ matcher: [
57
+ // ... existing matchers
58
+ '/pro/:path*',
59
+ '/analytics/:path*',
60
+ ],
61
+ };
62
+ ```
63
+
64
+ ## Common Patterns
65
+
66
+ ### Adding Response Headers
67
+
68
+ ```typescript
69
+ const response = NextResponse.next();
70
+ response.headers.set('X-Frame-Options', 'DENY');
71
+ response.headers.set('X-Content-Type-Options', 'nosniff');
72
+ response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
73
+ return response;
74
+ ```
75
+
76
+ ### Maintenance Mode (like coming-soon but for specific routes)
77
+
78
+ ```typescript
79
+ const maintenanceRoutes = ['/billing'];
80
+ const isMaintenanceMode = process.env.MAINTENANCE_MODE === 'true';
81
+
82
+ if (isMaintenanceMode && maintenanceRoutes.some((r) => pathname.startsWith(r))) {
83
+ return NextResponse.redirect(new URL('/maintenance', request.url));
84
+ }
85
+ ```
86
+
87
+ ### Geo-Based Redirects
88
+
89
+ ```typescript
90
+ const country = request.geo?.country || request.headers.get('x-vercel-ip-country');
91
+
92
+ if (country === 'GB' && pathname === '/pricing') {
93
+ return NextResponse.rewrite(new URL('/pricing/gb', request.url));
94
+ }
95
+ ```
96
+
97
+ ### Rate Limiting at the Edge (lightweight)
98
+
99
+ For heavy rate limiting, use `@mars-stack/core/rate-limit` in API routes. For lightweight edge protection:
100
+
101
+ ```typescript
102
+ // Simple bot detection
103
+ const userAgent = request.headers.get('user-agent') || '';
104
+ if (!userAgent || userAgent.length < 10) {
105
+ return new NextResponse('Forbidden', { status: 403 });
106
+ }
107
+ ```
108
+
109
+ ## Rules
110
+
111
+ - **Keep middleware fast.** It runs on every matched request. No database calls, no heavy computation.
112
+ - **Use cookies or headers** for edge-level checks, not database queries.
113
+ - **Database-backed authorization** belongs in API route wrappers (`withAuth`, `withRole`), not middleware.
114
+ - **The `config.matcher`** must include every route the middleware needs to process. Unmatched routes skip middleware entirely.
115
+ - **Order matters.** CSRF validation must happen before route guards. Session check must happen before auth redirects.
116
+
117
+ ## Excluding Routes from CSRF
118
+
119
+ If you add a new public API endpoint (like a webhook), exclude it from CSRF validation:
120
+
121
+ ```typescript
122
+ // At the top of the middleware function
123
+ if (pathname.startsWith('/api/webhooks/')) {
124
+ return NextResponse.next();
125
+ }
126
+ ```
127
+
128
+ ## Checklist
129
+
130
+ - [ ] Logic added in the correct order within middleware
131
+ - [ ] Route patterns added to `config.matcher`
132
+ - [ ] New route constants defined at top of file
133
+ - [ ] No database calls in middleware (use cookies/headers)
134
+ - [ ] CSRF exclusions added for public API endpoints
135
+ - [ ] Tested with authenticated and unauthenticated users
@@ -0,0 +1,151 @@
1
+ # Skill: Add a Page
2
+
3
+ Create a new Next.js page following MARS conventions for routing, layout, and component usage.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add a new page, screen, view, or route to the application.
8
+
9
+ ## Decision: Public or Protected?
10
+
11
+ | Type | Location | Layout | Auth |
12
+ |------|----------|--------|------|
13
+ | Public | `src/app/(public)/<name>/page.tsx` | Public layout | No auth required |
14
+ | Auth | `src/app/(auth)/<name>/page.tsx` | Centred card layout | No auth (login/signup flows) |
15
+ | Protected | `src/app/(protected)/<name>/page.tsx` | App layout (sidebar/nav) | Requires session |
16
+
17
+ The middleware at `src/middleware.ts` handles auth redirects automatically based on route groups.
18
+
19
+ ## Protected Page Template
20
+
21
+ ```tsx
22
+ import { Card, CardHeader, CardBody, H1, Paragraph } from '@mars-stack/ui';
23
+
24
+ export default function WidgetsPage() {
25
+ return (
26
+ <div className="mx-auto max-w-4xl space-y-6 p-6">
27
+ <div>
28
+ <H1>Widgets</H1>
29
+ <Paragraph className="text-text-secondary">
30
+ Manage your widgets.
31
+ </Paragraph>
32
+ </div>
33
+
34
+ <Card>
35
+ <CardHeader>
36
+ <h2 className="text-lg font-semibold text-text-primary">All Widgets</h2>
37
+ </CardHeader>
38
+ <CardBody>
39
+ {/* Content here */}
40
+ </CardBody>
41
+ </Card>
42
+ </div>
43
+ );
44
+ }
45
+ ```
46
+
47
+ ## Loading State
48
+
49
+ Create `loading.tsx` beside the page for Suspense:
50
+
51
+ ```tsx
52
+ import { Spinner } from '@mars-stack/ui';
53
+
54
+ export default function Loading() {
55
+ return (
56
+ <div className="flex min-h-[50vh] items-center justify-center">
57
+ <Spinner size="lg" />
58
+ </div>
59
+ );
60
+ }
61
+ ```
62
+
63
+ ## Client Components with Data Fetching
64
+
65
+ For pages that need client-side data, create a client component in the feature module:
66
+
67
+ ```tsx
68
+ // src/features/widgets/components/WidgetList.tsx
69
+ 'use client';
70
+
71
+ import { Card, CardBody, Spinner, Badge, EmptyState } from '@mars-stack/ui';
72
+ import { useEffect, useState } from 'react';
73
+ import type { Widget } from '@/features/widgets/types';
74
+
75
+ export function WidgetList() {
76
+ const [widgets, setWidgets] = useState<Widget[]>([]);
77
+ const [loading, setLoading] = useState(true);
78
+
79
+ useEffect(() => {
80
+ fetch('/api/protected/widgets')
81
+ .then((res) => res.json())
82
+ .then(setWidgets)
83
+ .finally(() => setLoading(false));
84
+ }, []);
85
+
86
+ if (loading) return <Spinner size="md" />;
87
+ if (widgets.length === 0) return <EmptyState title="No widgets yet" description="Create your first widget to get started." />;
88
+
89
+ return (
90
+ <div className="space-y-3">
91
+ {widgets.map((widget) => (
92
+ <Card key={widget.id}>
93
+ <CardBody className="flex items-center justify-between">
94
+ <span className="text-text-primary font-medium">{widget.name}</span>
95
+ <Badge variant="success">{widget.status}</Badge>
96
+ </CardBody>
97
+ </Card>
98
+ ))}
99
+ </div>
100
+ );
101
+ }
102
+ ```
103
+
104
+ Then import in the page:
105
+
106
+ ```tsx
107
+ import { WidgetList } from '@/features/widgets/components/WidgetList';
108
+
109
+ export default function WidgetsPage() {
110
+ return (
111
+ <div className="mx-auto max-w-4xl space-y-6 p-6">
112
+ <H1>Widgets</H1>
113
+ <WidgetList />
114
+ </div>
115
+ );
116
+ }
117
+ ```
118
+
119
+ ## Routes Config
120
+
121
+ Add the new route to `src/config/routes.ts`:
122
+
123
+ ```typescript
124
+ export const routes = {
125
+ // ... existing routes
126
+ widgets: '/widgets',
127
+ } as const;
128
+ ```
129
+
130
+ ## SEO (Optional)
131
+
132
+ Export `metadata` for static SEO or `generateMetadata` for dynamic:
133
+
134
+ ```tsx
135
+ import type { Metadata } from 'next';
136
+
137
+ export const metadata: Metadata = {
138
+ title: 'Widgets',
139
+ description: 'Manage your widgets',
140
+ };
141
+ ```
142
+
143
+ ## Checklist
144
+
145
+ - [ ] Correct route group (public / auth / protected)
146
+ - [ ] `loading.tsx` created for Suspense
147
+ - [ ] Components use design system primitives and patterns
148
+ - [ ] All colours use semantic tokens
149
+ - [ ] Route added to `src/config/routes.ts`
150
+ - [ ] SEO metadata exported
151
+ - [ ] Client components live in `src/features/<name>/components/`
@@ -0,0 +1,148 @@
1
+ # Skill: Add a Prisma Model
2
+
3
+ Add a new database model to the MARS multi-file Prisma schema.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add a database table, model, entity, or schema.
8
+
9
+ ## Prerequisites
10
+
11
+ - Read the existing schema files in `prisma/schema/` to understand current models and relations.
12
+ - Check `prisma/schema/base.prisma` for the datasource and generator configuration.
13
+
14
+ ## Steps
15
+
16
+ ### 1. Create a New Schema File
17
+
18
+ Add `prisma/schema/<name>.prisma`:
19
+
20
+ ```prisma
21
+ model Invoice {
22
+ id String @id @default(cuid())
23
+ number String @unique
24
+ amount Int
25
+ currency String @default("gbp")
26
+ status String @default("draft")
27
+ userId String
28
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
29
+ createdAt DateTime @default(now())
30
+ updatedAt DateTime @updatedAt
31
+
32
+ @@index([userId])
33
+ @@index([status])
34
+ }
35
+ ```
36
+
37
+ ### Key Conventions
38
+
39
+ - **IDs**: Use `@id @default(cuid())` for primary keys.
40
+ - **Timestamps**: Always include `createdAt` and `updatedAt`.
41
+ - **User relation**: If user-scoped, add `userId String` + `@relation` + `@@index([userId])`.
42
+ - **Cascade deletes**: Use `onDelete: Cascade` for child records owned by a user.
43
+ - **Indexes**: Add `@@index` for columns used in `where` clauses and foreign keys.
44
+ - **Enums**: Prefer string fields with TypeScript union types over Prisma enums (more flexible for migrations).
45
+ - **Naming**: PascalCase for models, camelCase for fields.
46
+
47
+ ### 2. Update the User Model (if user-scoped)
48
+
49
+ In `prisma/schema/auth.prisma`, add the reverse relation:
50
+
51
+ ```prisma
52
+ model User {
53
+ // ... existing fields
54
+ invoices Invoice[]
55
+ }
56
+ ```
57
+
58
+ ### 3. Sync the Database
59
+
60
+ ```bash
61
+ yarn db:push # Development: push schema directly
62
+ yarn db:migrate dev # With migration history (recommended for teams)
63
+ ```
64
+
65
+ ### 4. Create Server Access Functions
66
+
67
+ In `src/features/<name>/server/`:
68
+
69
+ ```typescript
70
+ import 'server-only';
71
+
72
+ import { prisma } from '@/lib/prisma';
73
+
74
+ export async function findInvoicesByUserId(userId: string) {
75
+ return prisma.invoice.findMany({
76
+ where: { userId },
77
+ orderBy: { createdAt: 'desc' },
78
+ });
79
+ }
80
+
81
+ export async function findInvoiceByIdForUser(id: string, userId: string) {
82
+ return prisma.invoice.findFirst({
83
+ where: { id, userId },
84
+ });
85
+ }
86
+
87
+ export async function createInvoice(userId: string, data: { number: string; amount: number }) {
88
+ return prisma.invoice.create({
89
+ data: { ...data, userId },
90
+ });
91
+ }
92
+ ```
93
+
94
+ **Critical**: Always scope queries by `userId` from the session. Never trust client-provided user IDs.
95
+
96
+ ### 5. Regenerate the Prisma Client
97
+
98
+ This happens automatically with `db:push`, but if needed:
99
+
100
+ ```bash
101
+ yarn db:generate
102
+ ```
103
+
104
+ ## Common Patterns
105
+
106
+ ### Soft Deletes
107
+ ```prisma
108
+ model Post {
109
+ // ...
110
+ deletedAt DateTime?
111
+ @@index([deletedAt])
112
+ }
113
+ ```
114
+
115
+ ### Polymorphic Relations (via discriminator)
116
+ ```prisma
117
+ model Notification {
118
+ id String @id @default(cuid())
119
+ type String // "invoice.paid", "user.invited"
120
+ resourceId String? // FK to the related entity
121
+ userId String
122
+ // ...
123
+ }
124
+ ```
125
+
126
+ ### Many-to-Many
127
+ ```prisma
128
+ model Tag {
129
+ id String @id @default(cuid())
130
+ name String @unique
131
+ posts Post[] @relation("PostTags")
132
+ }
133
+
134
+ model Post {
135
+ // ...
136
+ tags Tag[] @relation("PostTags")
137
+ }
138
+ ```
139
+
140
+ ## Checklist
141
+
142
+ - [ ] Schema file created in `prisma/schema/`
143
+ - [ ] cuid IDs, timestamps included
144
+ - [ ] User relation and index added (if user-scoped)
145
+ - [ ] `db:push` or `db:migrate dev` run
146
+ - [ ] Server access functions created with `'server-only'` import
147
+ - [ ] All queries scoped by userId
148
+ - [ ] Reverse relation added to User model if needed
@@ -0,0 +1,192 @@
1
+ # Skill: Add a Protected Resource
2
+
3
+ Create a user-owned resource with proper ownership verification, ensuring users can only access their own data.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add a resource that belongs to a specific user (e.g., "add notes", "add projects", "add invoices").
8
+
9
+ ## Architecture
10
+
11
+ MARS enforces ownership at the API layer using `withOwnership`. This wrapper:
12
+ 1. Authenticates the request (session)
13
+ 2. Resolves the resource owner from the database
14
+ 3. Compares the resource owner to the authenticated user
15
+ 4. Returns 403 if they don't match
16
+
17
+ ## Full Implementation
18
+
19
+ ### Step 1: Prisma Model
20
+
21
+ ```prisma
22
+ // prisma/schema/projects.prisma
23
+ model Project {
24
+ id String @id @default(cuid())
25
+ name String
26
+ description String?
27
+ status String @default("active")
28
+ userId String
29
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
30
+ createdAt DateTime @default(now())
31
+ updatedAt DateTime @updatedAt
32
+
33
+ @@index([userId])
34
+ @@index([status])
35
+ }
36
+ ```
37
+
38
+ Update `prisma/schema/auth.prisma`:
39
+ ```prisma
40
+ model User {
41
+ // ...existing fields
42
+ projects Project[]
43
+ }
44
+ ```
45
+
46
+ ### Step 2: Server Access Layer
47
+
48
+ ```typescript
49
+ // src/features/projects/server/index.ts
50
+ import 'server-only';
51
+
52
+ import { prisma } from '@/lib/prisma';
53
+ import type { CreateProjectInput, UpdateProjectInput } from '../validation/schemas';
54
+
55
+ export async function findProjectsByUser(userId: string) {
56
+ return prisma.project.findMany({
57
+ where: { userId },
58
+ orderBy: { updatedAt: 'desc' },
59
+ });
60
+ }
61
+
62
+ export async function findProjectById(id: string) {
63
+ return prisma.project.findUnique({ where: { id } });
64
+ }
65
+
66
+ export async function createProject(userId: string, data: CreateProjectInput) {
67
+ return prisma.project.create({
68
+ data: { ...data, userId },
69
+ });
70
+ }
71
+
72
+ export async function updateProject(id: string, data: UpdateProjectInput) {
73
+ return prisma.project.update({ where: { id }, data });
74
+ }
75
+
76
+ export async function deleteProject(id: string) {
77
+ return prisma.project.delete({ where: { id } });
78
+ }
79
+ ```
80
+
81
+ ### Step 3: API Routes with Ownership
82
+
83
+ ```typescript
84
+ // src/app/api/protected/projects/[id]/route.ts
85
+ import { handleApiError, withOwnership, type AuthenticatedRequest } from '@/lib/mars';
86
+ import { findProjectById, updateProject, deleteProject } from '@/features/projects/server';
87
+ import { projectSchemas } from '@/features/projects/validation/schemas';
88
+ import { NextResponse } from 'next/server';
89
+
90
+ async function getProjectOwner(
91
+ _request: AuthenticatedRequest,
92
+ context: { params: Promise<{ [key: string]: string }> },
93
+ ): Promise<string | null> {
94
+ const { id } = await context.params;
95
+ const project = await findProjectById(id);
96
+ return project?.userId ?? null;
97
+ }
98
+
99
+ export const GET = withOwnership(getProjectOwner, async (_request, context) => {
100
+ try {
101
+ const { id } = await context.params;
102
+ const project = await findProjectById(id);
103
+ return NextResponse.json(project);
104
+ } catch (error) {
105
+ return handleApiError(error, { endpoint: '/api/protected/projects/[id]' });
106
+ }
107
+ });
108
+
109
+ export const PATCH = withOwnership(getProjectOwner, async (request, context) => {
110
+ try {
111
+ const { id } = await context.params;
112
+ const body = projectSchemas.update.parse(await request.json());
113
+ const project = await updateProject(id, body);
114
+ return NextResponse.json(project);
115
+ } catch (error) {
116
+ return handleApiError(error, { endpoint: '/api/protected/projects/[id]' });
117
+ }
118
+ });
119
+
120
+ export const DELETE = withOwnership(getProjectOwner, async (_request, context) => {
121
+ try {
122
+ const { id } = await context.params;
123
+ await deleteProject(id);
124
+ return NextResponse.json({ message: 'Project deleted' });
125
+ } catch (error) {
126
+ return handleApiError(error, { endpoint: '/api/protected/projects/[id]' });
127
+ }
128
+ });
129
+ ```
130
+
131
+ ### Step 4: Collection Route (no ownership check needed)
132
+
133
+ ```typescript
134
+ // src/app/api/protected/projects/route.ts
135
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
136
+ import { findProjectsByUser, createProject } from '@/features/projects/server';
137
+ import { projectSchemas } from '@/features/projects/validation/schemas';
138
+ import { NextResponse } from 'next/server';
139
+
140
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
141
+ try {
142
+ const projects = await findProjectsByUser(request.session.userId);
143
+ return NextResponse.json(projects);
144
+ } catch (error) {
145
+ return handleApiError(error, { endpoint: '/api/protected/projects' });
146
+ }
147
+ });
148
+
149
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
150
+ try {
151
+ const body = projectSchemas.create.parse(await request.json());
152
+ const project = await createProject(request.session.userId, body);
153
+ return NextResponse.json(project, { status: 201 });
154
+ } catch (error) {
155
+ return handleApiError(error, { endpoint: '/api/protected/projects' });
156
+ }
157
+ });
158
+ ```
159
+
160
+ ## How `withOwnership` Works
161
+
162
+ ```
163
+ Client: PATCH /api/protected/projects/abc123
164
+
165
+ withOwnership calls getProjectOwner("abc123")
166
+ ↓ returns "user_xyz" (the project's userId)
167
+
168
+ Compares "user_xyz" with request.session.userId
169
+
170
+ ✅ Match → handler runs
171
+ ❌ Mismatch → 403 Forbidden
172
+ ❌ null (not found) → 404 Not Found
173
+ ```
174
+
175
+ ## Security Rules
176
+
177
+ - **Never accept userId from the client** for authorization. Always use `request.session.userId`.
178
+ - **List endpoints don't need `withOwnership`** -- they scope the query by `userId` from the session.
179
+ - **Individual resource endpoints use `withOwnership`** -- they verify the resource belongs to the user.
180
+ - **Admin override**: If admins should access any resource, use `withRole` instead of `withOwnership`.
181
+ - **The ownership resolver fetches from DB** every time -- no caching, no stale data.
182
+
183
+ ## Checklist
184
+
185
+ - [ ] Prisma model with `userId` field and `@@index([userId])`
186
+ - [ ] `onDelete: Cascade` on the user relation
187
+ - [ ] Server functions scope list queries by `userId`
188
+ - [ ] Individual resource routes use `withOwnership`
189
+ - [ ] Collection routes use `withAuth` / `withAuthNoParams`
190
+ - [ ] Ownership resolver returns `userId` or `null`
191
+ - [ ] No client-provided `userId` used for authorization
192
+ - [ ] `handleApiError` in every catch block