@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.
- package/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- 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
|