@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.
- package/dist/index.js +137 -12
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- 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 +375 -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 +15 -0
- package/template/src/styles/globals.css +7 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Skill: Configure File Storage
|
|
2
|
+
|
|
3
|
+
Set up file uploads and storage in a MARS application using Vercel Blob, S3, or local storage.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add file uploads, image uploads, document storage, or configure a storage provider.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- `appConfig.features.fileUpload` set to `true`
|
|
12
|
+
- `appConfig.services.storage.provider` set to `'vercel'`, `'s3'`, or `'local'`
|
|
13
|
+
|
|
14
|
+
## Step 1: Install SDK
|
|
15
|
+
|
|
16
|
+
**Vercel Blob:**
|
|
17
|
+
```bash
|
|
18
|
+
yarn add @vercel/blob
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**AWS S3:**
|
|
22
|
+
```bash
|
|
23
|
+
yarn add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Step 2: Environment Variables
|
|
27
|
+
|
|
28
|
+
**Vercel Blob:**
|
|
29
|
+
```bash
|
|
30
|
+
BLOB_READ_WRITE_TOKEN="vercel_blob_..."
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**AWS S3:**
|
|
34
|
+
```bash
|
|
35
|
+
AWS_ACCESS_KEY_ID="..."
|
|
36
|
+
AWS_SECRET_ACCESS_KEY="..."
|
|
37
|
+
AWS_REGION="eu-west-2"
|
|
38
|
+
S3_BUCKET_NAME="my-app-uploads"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Step 3: Create the Storage Service
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// src/core/storage/index.ts
|
|
45
|
+
import 'server-only';
|
|
46
|
+
|
|
47
|
+
import { appConfig } from '@/config/app.config';
|
|
48
|
+
|
|
49
|
+
export interface UploadResult {
|
|
50
|
+
url: string;
|
|
51
|
+
pathname: string;
|
|
52
|
+
size: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface StorageService {
|
|
56
|
+
upload(file: File, path: string): Promise<UploadResult>;
|
|
57
|
+
delete(url: string): Promise<void>;
|
|
58
|
+
getSignedUrl(url: string): Promise<string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function createVercelBlobStorage(): Promise<StorageService> {
|
|
62
|
+
const { put, del } = await import('@vercel/blob');
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async upload(file: File, path: string): Promise<UploadResult> {
|
|
66
|
+
const blob = await put(path, file, {
|
|
67
|
+
access: 'public',
|
|
68
|
+
token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
69
|
+
});
|
|
70
|
+
return { url: blob.url, pathname: blob.pathname, size: file.size };
|
|
71
|
+
},
|
|
72
|
+
async delete(url: string): Promise<void> {
|
|
73
|
+
await del(url, { token: process.env.BLOB_READ_WRITE_TOKEN });
|
|
74
|
+
},
|
|
75
|
+
async getSignedUrl(url: string): Promise<string> {
|
|
76
|
+
return url; // Vercel Blob public URLs don't need signing
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function createS3Storage(): Promise<StorageService> {
|
|
82
|
+
const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } =
|
|
83
|
+
await import('@aws-sdk/client-s3');
|
|
84
|
+
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
|
85
|
+
|
|
86
|
+
const client = new S3Client({ region: process.env.AWS_REGION });
|
|
87
|
+
const bucket = process.env.S3_BUCKET_NAME!;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
async upload(file: File, path: string): Promise<UploadResult> {
|
|
91
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
92
|
+
await client.send(
|
|
93
|
+
new PutObjectCommand({
|
|
94
|
+
Bucket: bucket,
|
|
95
|
+
Key: path,
|
|
96
|
+
Body: buffer,
|
|
97
|
+
ContentType: file.type,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
const url = `https://${bucket}.s3.${process.env.AWS_REGION}.amazonaws.com/${path}`;
|
|
101
|
+
return { url, pathname: path, size: file.size };
|
|
102
|
+
},
|
|
103
|
+
async delete(url: string): Promise<void> {
|
|
104
|
+
const key = new URL(url).pathname.slice(1);
|
|
105
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
|
106
|
+
},
|
|
107
|
+
async getSignedUrl(url: string): Promise<string> {
|
|
108
|
+
const key = new URL(url).pathname.slice(1);
|
|
109
|
+
return getSignedUrl(client, new GetObjectCommand({ Bucket: bucket, Key: key }), {
|
|
110
|
+
expiresIn: 3600,
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let _storage: StorageService | null = null;
|
|
117
|
+
|
|
118
|
+
export async function getStorage(): Promise<StorageService> {
|
|
119
|
+
if (_storage) return _storage;
|
|
120
|
+
|
|
121
|
+
const provider = appConfig.services.storage.provider;
|
|
122
|
+
switch (provider) {
|
|
123
|
+
case 'vercel':
|
|
124
|
+
_storage = await createVercelBlobStorage();
|
|
125
|
+
break;
|
|
126
|
+
case 's3':
|
|
127
|
+
_storage = await createS3Storage();
|
|
128
|
+
break;
|
|
129
|
+
default:
|
|
130
|
+
throw new Error(`Storage provider "${provider}" is not configured. Set appConfig.services.storage.provider.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return _storage;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Step 4: Upload API Route
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// src/app/api/protected/upload/route.ts
|
|
141
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
142
|
+
import { getStorage } from '@/core/storage';
|
|
143
|
+
import { NextResponse } from 'next/server';
|
|
144
|
+
|
|
145
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
146
|
+
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
|
147
|
+
|
|
148
|
+
export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
149
|
+
try {
|
|
150
|
+
const formData = await request.formData();
|
|
151
|
+
const file = formData.get('file') as File | null;
|
|
152
|
+
|
|
153
|
+
if (!file) {
|
|
154
|
+
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
158
|
+
return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
162
|
+
return NextResponse.json({ error: 'File type not allowed' }, { status: 400 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const userId = request.session.userId;
|
|
166
|
+
const ext = file.name.split('.').pop() || 'bin';
|
|
167
|
+
const path = `uploads/${userId}/${Date.now()}.${ext}`;
|
|
168
|
+
|
|
169
|
+
const storage = await getStorage();
|
|
170
|
+
const result = await storage.upload(file, path);
|
|
171
|
+
|
|
172
|
+
return NextResponse.json(result, { status: 201 });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return handleApiError(error, { endpoint: '/api/protected/upload' });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Step 5: Client Upload Component
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
'use client';
|
|
183
|
+
|
|
184
|
+
import { Button, Spinner } from '@mars-stack/ui';
|
|
185
|
+
import { useState, useRef } from 'react';
|
|
186
|
+
|
|
187
|
+
interface FileUploadProps {
|
|
188
|
+
onUpload: (result: { url: string; pathname: string }) => void;
|
|
189
|
+
accept?: string;
|
|
190
|
+
maxSize?: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function FileUpload({ onUpload, accept = 'image/*', maxSize = 10 }: FileUploadProps) {
|
|
194
|
+
const [uploading, setUploading] = useState(false);
|
|
195
|
+
const [error, setError] = useState<string | null>(null);
|
|
196
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
197
|
+
|
|
198
|
+
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
199
|
+
const file = e.target.files?.[0];
|
|
200
|
+
if (!file) return;
|
|
201
|
+
|
|
202
|
+
if (file.size > maxSize * 1024 * 1024) {
|
|
203
|
+
setError(`File too large (max ${maxSize}MB)`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setError(null);
|
|
208
|
+
setUploading(true);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const formData = new FormData();
|
|
212
|
+
formData.append('file', file);
|
|
213
|
+
|
|
214
|
+
const res = await fetch('/api/protected/upload', {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
body: formData,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
const data = await res.json();
|
|
221
|
+
throw new Error(data.error || 'Upload failed');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const result = await res.json();
|
|
225
|
+
onUpload(result);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
setError(err instanceof Error ? err.message : 'Upload failed');
|
|
228
|
+
} finally {
|
|
229
|
+
setUploading(false);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div>
|
|
235
|
+
<input
|
|
236
|
+
ref={inputRef}
|
|
237
|
+
type="file"
|
|
238
|
+
accept={accept}
|
|
239
|
+
onChange={handleChange}
|
|
240
|
+
className="hidden"
|
|
241
|
+
/>
|
|
242
|
+
<Button
|
|
243
|
+
type="button"
|
|
244
|
+
variant="secondary"
|
|
245
|
+
onClick={() => inputRef.current?.click()}
|
|
246
|
+
disabled={uploading}
|
|
247
|
+
>
|
|
248
|
+
{uploading ? <Spinner size="sm" /> : 'Upload File'}
|
|
249
|
+
</Button>
|
|
250
|
+
{error && <p className="mt-1 text-sm text-text-error">{error}</p>}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Security Rules
|
|
257
|
+
|
|
258
|
+
- **Always validate file size and type** on the server, even if checked on the client.
|
|
259
|
+
- **Scope upload paths by userId** to prevent users from overwriting each other's files.
|
|
260
|
+
- **Sanitize filenames** -- never use the original filename in the storage path.
|
|
261
|
+
- **Stream responses** when proxying file downloads (see `data-access` rule).
|
|
262
|
+
- **Set appropriate `Content-Disposition` headers** when serving files for download.
|
|
263
|
+
|
|
264
|
+
## Checklist
|
|
265
|
+
|
|
266
|
+
- [ ] Storage SDK installed
|
|
267
|
+
- [ ] Environment variables set
|
|
268
|
+
- [ ] Storage service created (`src/core/storage/index.ts`)
|
|
269
|
+
- [ ] Upload API route with size and type validation
|
|
270
|
+
- [ ] Upload paths scoped by userId
|
|
271
|
+
- [ ] Client upload component
|
|
272
|
+
- [ ] Feature flag checked (`appConfig.features.fileUpload`)
|
|
273
|
+
- [ ] File metadata stored in database (optional but recommended)
|