Problem
`/api/upload-cover` has no authentication. Anyone can upload images to our Filebase bucket. The in-memory IP rate limit resets on serverless cold starts and is easily bypassed.
Scope — Minimal Change Only
Add EIP-191 wallet signature requirement. Nothing else — no Supabase rate limit tables, no new infrastructure.
API Change
Update `POST /api/upload-cover` to require two additional fields in the multipart FormData:
- `message` — plaintext message string
- `signature` — EIP-191 hex signature
Message format:
```
PlotLink: Upload cover image
Timestamp: {unix_ms}
```
Server-side verification:
```ts
import { recoverMessageAddress } from "viem";
// 1. Extract message and signature from FormData
// 2. If missing, return 401
// 3. Parse timestamp from message, reject if > 5 minutes old
// 4. Recover signer address from signature
// 5. Switch in-memory rate limit key from IP to recovered wallet address
// 6. Proceed with existing upload logic
```
Keep existing in-memory rate limit but key by recovered wallet address instead of IP. Keep max 5/min — simple and sufficient.
Frontend Changes (MUST ship in same PR)
Both callers must be updated to sign before uploading. If the API deploys without frontend changes, uploads will break.
`src/components/StoryEditPanel.tsx`
Already has `useSignMessage` imported and `signMessageAsync` available (line 35). Add:
```ts
const timestamp = Date.now();
const message = `PlotLink: Upload cover image
Timestamp: ${timestamp}`;
const signature = await signMessageAsync({ message });
formData.append("message", message);
formData.append("signature", signature);
```
`src/app/create/page.tsx`
Does NOT have `useSignMessage` yet. Add the import and hook, then same signing pattern before the fetch call on line 167.
What NOT to do
- Do NOT create a Supabase table for rate limiting
- Do NOT change the message format beyond what's specified above
- Do NOT add any other auth mechanisms
- Do NOT touch `/api/upload` (text upload) — out of scope
Security Analysis
What this prevents:
- Anonymous spam uploads (attacker needs a real wallet key to sign)
- Automated abuse (each upload requires a fresh signature within 5-min window)
Accepted risks:
- Same signature can upload multiple files within 5-min window → mitigated by rate limit
- In-memory rate limit resets on cold starts → acceptable; signature is the primary defense
- Uploaded images persist on IPFS even if never associated with a storyline → unavoidable with IPFS; low impact since images are inert without DB association
What this does NOT break:
- PlotLink OWS does not currently call this endpoint → no impact
- Both frontend callers are updated in the same PR → atomic Vercel deploy, no window of breakage
Files
- `src/app/api/upload-cover/route.ts` — add signature verification, switch rate limit key
- `src/components/StoryEditPanel.tsx` — sign before upload (~5 lines)
- `src/app/create/page.tsx` — add `useSignMessage` hook + sign before upload (~10 lines)
Edge Case: Wallet Not Connected on Create Page
On the create page, users may try to upload a cover image before connecting their wallet. Since signing requires a connected wallet, handle this gracefully:
- If wallet is not connected when cover is selected, show a message: "Connect your wallet to upload a cover image"
- Alternatively, defer the upload until the publish step (but this complicates the flow — prefer the connect-first approach)
- The edit panel does not have this issue since it only appears for connected authors
Acceptance Criteria
Problem
`/api/upload-cover` has no authentication. Anyone can upload images to our Filebase bucket. The in-memory IP rate limit resets on serverless cold starts and is easily bypassed.
Scope — Minimal Change Only
Add EIP-191 wallet signature requirement. Nothing else — no Supabase rate limit tables, no new infrastructure.
API Change
Update `POST /api/upload-cover` to require two additional fields in the multipart FormData:
Message format:
```
PlotLink: Upload cover image
Timestamp: {unix_ms}
```
Server-side verification:
```ts
import { recoverMessageAddress } from "viem";
// 1. Extract message and signature from FormData
// 2. If missing, return 401
// 3. Parse timestamp from message, reject if > 5 minutes old
// 4. Recover signer address from signature
// 5. Switch in-memory rate limit key from IP to recovered wallet address
// 6. Proceed with existing upload logic
```
Keep existing in-memory rate limit but key by recovered wallet address instead of IP. Keep max 5/min — simple and sufficient.
Frontend Changes (MUST ship in same PR)
Both callers must be updated to sign before uploading. If the API deploys without frontend changes, uploads will break.
`src/components/StoryEditPanel.tsx`
Already has `useSignMessage` imported and `signMessageAsync` available (line 35). Add:
```ts
const timestamp = Date.now();
const message = `PlotLink: Upload cover image
Timestamp: ${timestamp}`;
const signature = await signMessageAsync({ message });
formData.append("message", message);
formData.append("signature", signature);
```
`src/app/create/page.tsx`
Does NOT have `useSignMessage` yet. Add the import and hook, then same signing pattern before the fetch call on line 167.
What NOT to do
Security Analysis
What this prevents:
Accepted risks:
What this does NOT break:
Files
Edge Case: Wallet Not Connected on Create Page
On the create page, users may try to upload a cover image before connecting their wallet. Since signing requires a connected wallet, handle this gracefully:
Acceptance Criteria