From f59e55a852d75e6c641c82cc995136e66f0dae68 Mon Sep 17 00:00:00 2001 From: Tien Nguyen Minh Date: Sat, 18 Apr 2026 10:19:00 +0700 Subject: [PATCH] refactor(rate-limit): 1 req/sec cooldown, batch up to 2048 (#3) * refactor(rate-limit): switch to 1 req/sec cooldown, batch size up to 2048 Replace per-pixel credit/token-bucket model with a simple per-user cooldown (SET NX EX 1). Batch size is now independent of the rate limit and capped at MAX_BATCH_SIZE = 2048. - rate-limiter: SET NX EX replaces Lua credit script - worker: response shape { ok: true } (no credits field) - client: drop credit state/timer/UserInfo; uploader paces by cooldown - tests: mock checkRateLimit; integration test exercises SET NX EX - docs: README, system-architecture, code-standards, deployment-guide * chore(plans): remove implemented plan directories rplace-implementation (base build), review-fixes, and image-importer-enhancements are all shipped. Keep plans/reports/ as historical code-review and research references. --- README.md | 30 +-- docs/code-standards.md | 4 +- docs/deployment-guide.md | 4 +- docs/system-architecture.md | 37 ++-- .../phase-01-project-setup.md | 140 ------------- .../phase-02-canvas-backend.md | 143 ------------- .../phase-03-rate-limiting.md | 166 --------------- .../phase-04-real-time-updates.md | 170 ---------------- .../phase-05-frontend-canvas.md | 190 ------------------ .../phase-06-authentication.md | 148 -------------- .../phase-07-polish-and-deploy.md | 95 --------- .../260416-1513-rplace-implementation/plan.md | 62 ------ plans/260417-0935-review-fixes/plan.md | 69 ------- .../phase-01-resize-controls.md | 74 ------- .../phase-02-overlay-preview.md | 61 ------ .../phase-03-transforms.md | 50 ----- .../phase-04-dithering-algorithms.md | 55 ----- .../phase-05-color-correction.md | 60 ------ .../phase-06-skip-white.md | 57 ------ .../plan.md | 43 ---- src/client/App.svelte | 18 +- src/client/components/ImageImporter.svelte | 7 +- src/client/components/UserInfo.svelte | 80 -------- src/lib/constants.js | 8 +- src/lib/image-uploader.js | 68 +++---- src/lib/rate-limiter.js | 83 ++------ src/worker.js | 12 +- .../redis-canvas-roundtrip.test.js | 86 ++------ test/worker-validation.test.js | 17 +- 29 files changed, 119 insertions(+), 1918 deletions(-) delete mode 100644 plans/260416-1513-rplace-implementation/phase-01-project-setup.md delete mode 100644 plans/260416-1513-rplace-implementation/phase-02-canvas-backend.md delete mode 100644 plans/260416-1513-rplace-implementation/phase-03-rate-limiting.md delete mode 100644 plans/260416-1513-rplace-implementation/phase-04-real-time-updates.md delete mode 100644 plans/260416-1513-rplace-implementation/phase-05-frontend-canvas.md delete mode 100644 plans/260416-1513-rplace-implementation/phase-06-authentication.md delete mode 100644 plans/260416-1513-rplace-implementation/phase-07-polish-and-deploy.md delete mode 100644 plans/260416-1513-rplace-implementation/plan.md delete mode 100644 plans/260417-0935-review-fixes/plan.md delete mode 100644 plans/260417-1125-image-importer-enhancements/phase-01-resize-controls.md delete mode 100644 plans/260417-1125-image-importer-enhancements/phase-02-overlay-preview.md delete mode 100644 plans/260417-1125-image-importer-enhancements/phase-03-transforms.md delete mode 100644 plans/260417-1125-image-importer-enhancements/phase-04-dithering-algorithms.md delete mode 100644 plans/260417-1125-image-importer-enhancements/phase-05-color-correction.md delete mode 100644 plans/260417-1125-image-importer-enhancements/phase-06-skip-white.md delete mode 100644 plans/260417-1125-image-importer-enhancements/plan.md delete mode 100644 src/client/components/UserInfo.svelte diff --git a/README.md b/README.md index 1454bad..c056d49 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ A collaborative pixel art canvas inspired by [Reddit's r/place](https://www.redd - **2048x2048 canvas** with 32-color palette (from [rplace.live](https://rplace.live/)) - **Real-time updates** via WebSocket (Cloudflare Durable Objects) -- **Batch pixel placement** up to 32 pixels per request -- **Stackable credit system** — earn 1 pixel/sec, stack up to 256, spend in batches +- **Batch pixel placement** up to 2048 pixels per request +- **Rate limit** — 1 request per second per user (batch size independent) - **Zoom/pan** with mouse wheel + drag (desktop) and pinch-zoom + drag (mobile) - **Long-press to place** on touch devices -- **Credit bar** with visual regeneration feedback +- **Image importer** — upload, dither, and auto-paint images onto the canvas ## Tech Stack @@ -19,7 +19,7 @@ A collaborative pixel art canvas inspired by [Reddit's r/place](https://www.redd | Frontend | [Svelte 5](https://svelte.dev/) (runes) + HTML5 Canvas | | Backend | [Hono](https://hono.dev/) on Cloudflare Workers | | Real-time | WebSocket via Cloudflare Durable Objects | -| Storage | [Upstash Redis](https://upstash.com/) (BITFIELD for canvas, Lua for rate limiting) | +| Storage | [Upstash Redis](https://upstash.com/) (BITFIELD for canvas, SET NX EX for rate limiting) | | Build | [Vite](https://vite.dev/) | ## Architecture @@ -32,12 +32,12 @@ Browser (Svelte SPA + WebSocket) v Cloudflare Worker (Hono) ├── Canvas API (read/write pixels via Redis BITFIELD) - ├── Rate Limiter (Lua script, atomic token bucket) + ├── Rate Limiter (SET NX EX — atomic per-user cooldown) └── Durable Object (WebSocket broadcast to all clients) ↕ Upstash Redis ├── BITFIELD "canvas" (5-bit per pixel, 2048x2048 = 2.62MB) - └── HASH "credits:{userId}" (lastUpdate + credits) + └── STRING "cooldown:{userId}" (1s TTL, blocks repeat requests) ``` ## Getting Started @@ -94,17 +94,19 @@ src/ │ ├── redis-client.js # Upstash Redis factory │ ├── canvas-storage.js # BITFIELD read/write │ ├── canvas-decoder.js # 5-bit → RGBA (client-side) -│ ├── rate-limiter.js # Lua token bucket +│ ├── rate-limiter.js # SET NX EX cooldown +│ ├── image-uploader.js # Browser-side batched uploader │ └── get-user-id.js # IP-based identity ├── client/ │ ├── main.js # Svelte mount -│ ├── App.svelte # Root + WebSocket + credit timer +│ ├── App.svelte # Root + WebSocket │ ├── app.css # Global styles │ └── components/ │ ├── CanvasRenderer.svelte # Canvas + zoom/pan + touch │ ├── ColorPicker.svelte # 32-color palette grid │ ├── CanvasControls.svelte # Zoom buttons + coordinates -│ └── UserInfo.svelte # Credit counter + bar +│ ├── DrawToolbar.svelte # Paint / submit / undo / redo +│ └── ImageImporter.svelte # Image-to-canvas uploader └── index.html # Vite entry ``` @@ -126,10 +128,11 @@ Place pixels on the canvas. } ``` -**Response:** `{ "ok": true, "credits": 255 }` +**Response:** `{ "ok": true }` **Errors:** -- `400` — invalid pixel data or batch > 32 +- `400` — invalid pixel data or batch > 2048 +- `413` — request body too large - `429` — rate limited (includes `retryAfter` seconds) ### `WS /api/ws` @@ -149,9 +152,8 @@ Key constants in `src/lib/constants.js`: | `CANVAS_WIDTH` | 2048 | Canvas width in pixels | | `CANVAS_HEIGHT` | 2048 | Canvas height in pixels | | `MAX_COLORS` | 32 | Number of colors in palette | -| `MAX_BATCH_SIZE` | 32 | Max pixels per placement request | -| `MAX_CREDITS` | 256 | Max stackable credits | -| `CREDIT_REGEN_RATE` | 1 | Credits earned per second | +| `MAX_BATCH_SIZE` | 2048 | Max pixels per placement request | +| `REQUEST_COOLDOWN_SEC` | 1 | Minimum seconds between requests per user | ## Credits & References diff --git a/docs/code-standards.md b/docs/code-standards.md index 29064d9..ffd5ecb 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -46,5 +46,5 @@ src/ ## API Response Format -Success: `{ ok: true, credits: N }` -Error: `{ error: "error_code", ...details }` with appropriate HTTP status +Success: `{ ok: true }` +Error: `{ error: "error_code", ...details }` with appropriate HTTP status (e.g., `retryAfter` on 429) diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index e79b100..c34f947 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -62,7 +62,7 @@ npx wrangler domains add rplace.yourdomain.com |---|---|---| | CF Workers | 100K requests/day | Canvas reads + pixel placements | | CF Durable Objects | Free with Workers | WebSocket connections | -| Upstash Redis | 10K commands/day | BITFIELD reads/writes + rate limiting | +| Upstash Redis | 10K commands/day | BITFIELD reads/writes + cooldown SET NX EX | For hobby traffic (< few hundred users/day), free tiers are sufficient. Upstash pay-as-you-go ($0.2/100K commands) is the first thing to hit limits. @@ -71,4 +71,4 @@ For hobby traffic (< few hundred users/day), free tiers are sufficient. Upstash - **Canvas loads empty**: Check Upstash credentials in secrets - **Pixels don't persist**: Verify BITFIELD support — test with `redis-cli BITFIELD canvas SET u5 #0 1` - **WebSocket not connecting**: Ensure Durable Object migration ran (check `wrangler.json` migrations) -- **Rate limiting not working**: Verify `redis.eval()` works on your Upstash tier (Lua scripting) +- **Rate limiting not working**: Verify `SET key value NX EX 1` returns `"OK"` / `null` as expected on your Upstash tier diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 6ccb822..fb35d62 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -9,14 +9,14 @@ rplace is a collaborative pixel canvas deployed as a single Cloudflare Worker. T ### Pixel Placement ``` -1. User clicks canvas → optimistic render + credit deduction -2. POST /api/place { pixels: [{x, y, color}] } -3. Worker validates input (bounds, types, batch size ≤ 32) -4. Worker checks credits via Lua script (atomic check-and-deduct) +1. User draws on canvas → optimistic render into a pending buffer +2. User hits Submit → POST /api/place { pixels: [{x, y, color}] } +3. Worker validates input (bounds, types, batch size ≤ 2048) +4. Worker checks cooldown via SET NX EX (atomic per-user lock) 5. Worker writes pixels via Redis BITFIELD (atomic batch) 6. Worker sends pixels to Durable Object /broadcast 7. Durable Object fans out to all WebSocket clients -8. Response: { ok: true, credits: N } +8. Response: { ok: true } ``` ### Canvas Loading @@ -50,32 +50,31 @@ rplace is a collaborative pixel canvas deployed as a single Cloudflare Worker. T - Offset: `y * CANVAS_WIDTH + x` - Atomic batch writes: single BITFIELD command with chained .set() calls -### Redis HASH (Credits) +### Redis STRING (Cooldown) -- Key pattern: `credits:{userId}` -- Fields: `lu` (last update, unix seconds), `cr` (current credits) -- TTL: 24 hours (auto-expire inactive users) -- Accessed via Lua script for atomic check-and-deduct +- Key pattern: `cooldown:{userId}` +- Value: `"1"` (presence is the signal; content is irrelevant) +- TTL: `REQUEST_COOLDOWN_SEC` (1s) — auto-expires, no explicit cleanup +- Atomic via `SET key "1" NX EX 1` ## Rate Limiting -Token bucket algorithm implemented as a Lua script: +Fixed-window cooldown, one request per second per user: ``` On placement request: -1. Read stored credits + lastUpdate from HASH -2. Calculate accrued = stored + floor(elapsed_seconds * regen_rate) -3. Cap at MAX_CREDITS (256) -4. If accrued < requested → reject (429) -5. Else → deduct, update HASH, return remaining +1. SET cooldown:{userId} "1" NX EX 1 +2. If reply == "OK" → allow (key set, TTL 1s) +3. If reply == null → reject (429, retryAfter = 1) ``` -New users start with full credits (256). Anonymous identity via CF-Connecting-IP hash. +Batch size is independent of the cooldown; it is validated separately +(MAX_BATCH_SIZE = 2048). Anonymous identity via CF-Connecting-IP hash. ## Security -- **Rate limiting**: Atomic Lua script prevents race conditions +- **Rate limiting**: Atomic `SET NX EX` prevents race conditions - **Identity**: CF-Connecting-IP (set by Cloudflare, unspoofable) - **Input validation**: Bounds checking, type checking, integer validation on all pixel data -- **Batch cap**: Max 32 pixels per request to limit burst damage +- **Batch cap**: Max 2048 pixels per request + request body size guard - **DO isolation**: /broadcast route only reachable via DO stub, not externally diff --git a/plans/260416-1513-rplace-implementation/phase-01-project-setup.md b/plans/260416-1513-rplace-implementation/phase-01-project-setup.md deleted file mode 100644 index aa4dfa9..0000000 --- a/plans/260416-1513-rplace-implementation/phase-01-project-setup.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -phase: 1 -title: "Project Setup" -status: pending -effort: 1.5h -priority: P1 ---- - -# Phase 1 — Project Setup - -## Context Links -- [Next.js App Router docs](https://nextjs.org/docs/app) -- [Upstash Redis SDK](https://github.com/upstash/upstash-redis) - -## Overview -Initialize Next.js project with App Router, install dependencies, configure environment variables, and establish project structure with kebab-case naming. - -## Requirements - -### Functional -- Next.js App Router project scaffolded -- All dependencies installed -- Environment config for Upstash Redis + NextAuth -- Project directory structure established - -### Non-functional -- JavaScript only (no TypeScript) -- Files under 200 lines -- kebab-case file naming - -## Dependencies to Install - -``` -next react react-dom -@upstash/redis # Redis client (REST-based, Vercel-friendly) -next-auth # OAuth (Google, GitHub) -``` - -Dev dependencies: -``` -eslint eslint-config-next -``` - -## Project Structure - -``` -src/ -├── app/ -│ ├── layout.js -│ ├── page.js -│ ├── api/ -│ │ ├── canvas/ -│ │ │ ├── route.js # GET full canvas -│ │ │ ├── place/ -│ │ │ │ └── route.js # POST batch pixel placement -│ │ │ └── stream/ -│ │ │ └── route.js # GET SSE stream -│ │ └── auth/ -│ │ └── [...nextauth]/ -│ │ └── route.js # NextAuth catch-all -│ ├── components/ -│ │ ├── canvas-renderer.js # HTML5 Canvas rendering -│ │ ├── color-picker.js # 32-color palette UI -│ │ ├── canvas-controls.js # Zoom/pan controls -│ │ └── user-info.js # Auth status + cooldown display -│ └── globals.css -├── lib/ -│ ├── redis-client.js # Upstash Redis singleton -│ ├── canvas-storage.js # BITFIELD read/write helpers -│ ├── rate-limiter.js # Stackable credit system -│ ├── sse-broadcaster.js # Pub/Sub → SSE bridge -│ ├── auth-options.js # NextAuth config -│ └── constants.js # Canvas size, colors, limits -└── .env.example -``` - -## Implementation Steps - -1. Run `npx create-next-app@latest . --js --app --eslint --no-tailwind --no-src-dir --import-alias "@/*"` (adjust if src dir preferred — using `src/` per structure above, so add `--src-dir`) -2. Install production deps: `npm i @upstash/redis next-auth` -3. Create `.env.example` with required vars: - ``` - UPSTASH_REDIS_REST_URL= - UPSTASH_REDIS_REST_TOKEN= - NEXTAUTH_URL=http://localhost:3000 - NEXTAUTH_SECRET= - GOOGLE_CLIENT_ID= - GOOGLE_CLIENT_SECRET= - GITHUB_CLIENT_ID= - GITHUB_CLIENT_SECRET= - ``` -4. Create `src/lib/constants.js` with canvas config: - ```js - export const CANVAS_WIDTH = 2048; - export const CANVAS_HEIGHT = 2048; - export const BITS_PER_PIXEL = 5; - export const MAX_COLORS = 32; - export const MAX_BATCH_SIZE = 256; - export const CREDIT_REGEN_RATE = 1; // per second - export const MAX_CREDITS = 256; - export const REDIS_CANVAS_KEY = 'canvas'; - export const REDIS_PUBSUB_CHANNEL = 'canvas:updates'; - export const COLORS = [ - '#6d001a','#be0039','#ff4500','#ffa800','#ffd635','#fff8b8', - '#00a368','#00cc78','#7eed56','#00756f','#009eaa','#00ccc0', - '#2450a4','#3690ea','#51e9f4','#493ac1','#6a5cff','#94b3ff', - '#811e9f','#b44ac0','#e4abff','#de107f','#ff3881','#ff99aa', - '#6d482f','#9c6926','#ffb470','#000000','#515252','#898d90', - '#d4d7d9','#ffffff', - ]; - ``` -5. Create `src/lib/redis-client.js` — Upstash Redis singleton -6. Create stub files for remaining `lib/` and `api/` routes -7. Verify `npm run dev` starts without errors - -## Todo List - -- [ ] Scaffold Next.js project -- [ ] Install dependencies -- [ ] Create `.env.example` -- [ ] Create `constants.js` with palette and config -- [ ] Create `redis-client.js` singleton -- [ ] Create directory structure with stub files -- [ ] Verify dev server starts clean - -## Success Criteria -- `npm run dev` runs without errors -- Project structure matches spec -- All stub files exist and export empty functions/components -- `.env.example` documents all required vars - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| create-next-app flags change | Low | Low | Check docs, adjust flags | -| Upstash SDK version mismatch | Low | Med | Pin version in package.json | - -## Rollback -Delete generated files, re-scaffold. No data at risk. diff --git a/plans/260416-1513-rplace-implementation/phase-02-canvas-backend.md b/plans/260416-1513-rplace-implementation/phase-02-canvas-backend.md deleted file mode 100644 index a52d5e5..0000000 --- a/plans/260416-1513-rplace-implementation/phase-02-canvas-backend.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -phase: 2 -title: "Canvas Backend" -status: pending -effort: 3h -priority: P1 -blocked_by: [1] ---- - -# Phase 2 — Canvas Backend - -## Context Links -- [Redis BITFIELD command](https://redis.io/docs/latest/commands/bitfield/) -- [Upstash Redis REST API](https://docs.upstash.com/redis/features/restapi) - -## Overview -Implement Redis BITFIELD-based canvas storage and two API routes: GET full canvas (binary), POST batch pixel placement. - -## Key Insights - -- 5 bits per pixel → offset = `(y * CANVAS_WIDTH + x) * 5` for bit-level, or use `u5 #(y * CANVAS_WIDTH + x)` for field-level indexing -- Upstash `@upstash/redis` supports BITFIELD via `redis.bitfield(key, ...commands)` -- Single BITFIELD command can batch multiple SET subcommands → one round-trip for 256 pixels -- Full canvas = `2048 * 2048 * 5 / 8 = 2,621,440 bytes` (~2.5MB raw, ~1.5MB gzip) - -## Data Flow - -``` -GET /api/canvas: - Client → API Route → redis.get("canvas") as Buffer → gzip → Response (binary) - -POST /api/canvas/place: - Client → API Route - → Validate batch (coords, color indices, size ≤ 256) - → Check rate limit credits - → redis.bitfield("canvas", ...SET commands) - → Publish update to Pub/Sub - → Return {ok: true, remaining_credits} -``` - -## Architecture - -### `src/lib/canvas-storage.js` - -```js -// getFullCanvas() → Buffer (raw BITFIELD bytes) -// setPixels(pixels: [{x, y, color}]) → void (batch BITFIELD SET) -// getPixel(x, y) → colorIndex (single BITFIELD GET) -``` - -### `src/app/api/canvas/route.js` - -```js -// GET handler: -// 1. Call getFullCanvas() -// 2. Gzip compress -// 3. Return with Content-Type: application/octet-stream -// + Content-Encoding: gzip -// + Cache-Control: public, max-age=1, stale-while-revalidate=5 -``` - -### `src/app/api/canvas/place/route.js` - -```js -// POST handler: -// 1. Parse body: { pixels: [{x, y, color}] } -// 2. Validate: all coords in range, color 0-31, batch ≤ 256 -// 3. Identify user (IP or auth session) -// 4. Check/deduct rate limit credits -// 5. Call setPixels(pixels) -// 6. Publish batch to Redis Pub/Sub -// 7. Return { ok: true, credits: remaining } -``` - -## Related Code Files - -### Create -- `src/lib/canvas-storage.js` -- `src/app/api/canvas/route.js` -- `src/app/api/canvas/place/route.js` - -### Modify -- `src/lib/redis-client.js` (if stub needs fleshing out) - -## Implementation Steps - -1. **Implement `canvas-storage.js`** - - `getFullCanvas()`: Use `redis.get(REDIS_CANVAS_KEY)` — Upstash returns base64, decode to Buffer - - `setPixels(pixels)`: Build BITFIELD command array: for each pixel, push `['SET', 'u5', `#${y * W + x}`, color]`, execute as single `redis.bitfield()` - - `getPixel(x, y)`: `redis.bitfield(key, ['GET', 'u5', `#${y * W + x}`])` - - Handle empty canvas (key doesn't exist) → return zeroed buffer - -2. **Implement GET `/api/canvas`** - - Import `getFullCanvas` - - Compress with `zlib.gzipSync()` - - Return `new Response(gzipped, { headers })` with proper content headers - - Add `Cache-Control: public, max-age=1, s-maxage=1, stale-while-revalidate=5` - -3. **Implement POST `/api/canvas/place`** - - Parse JSON body - - Validate input schema: - - `pixels` is array, length 1-256 - - Each pixel: `x` int 0-2047, `y` int 0-2047, `color` int 0-31 - - Extract user identity (IP from `request.headers.get('x-forwarded-for')` or fallback) - - Rate limiting call (stub for now, Phase 3) - - Call `setPixels(validatedPixels)` - - Publish to Pub/Sub (stub for now, Phase 4) - - Return JSON response - -4. **Initialize canvas** — add a utility or on-demand initialization: if canvas key missing, SET empty buffer of correct size - -## Todo List - -- [ ] Implement `canvas-storage.js` with getFullCanvas, setPixels, getPixel -- [ ] Handle empty/missing canvas key initialization -- [ ] Implement GET `/api/canvas` with gzip compression -- [ ] Implement POST `/api/canvas/place` with validation -- [ ] Add input validation helpers -- [ ] Test with curl / httpie against local dev -- [ ] Verify BITFIELD offset calculation is correct - -## Success Criteria -- GET `/api/canvas` returns gzipped binary of correct size (2.5MB uncompressed) -- POST `/api/canvas/place` with valid payload writes pixels and returns success -- POST with invalid coords/colors returns 400 -- POST with >256 pixels returns 400 -- Empty canvas initializes correctly on first read - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Upstash BITFIELD API differences from raw Redis | Med | High | Test early; check Upstash docs for BITFIELD support | -| Buffer encoding issues (base64 vs binary) | Med | Med | Log and compare byte lengths; add unit tests | -| BITFIELD command size limit per request | Low | Med | Upstash allows large commands; if limited, chunk into batches of 50 | - -## Failure Modes -1. **Redis unavailable** → API returns 503, client retries with backoff -2. **Corrupt canvas data** → Validate BITFIELD size on read; re-init if wrong size -3. **Race condition on concurrent writes** → BITFIELD is atomic per command; batch SET is atomic → safe - -## Rollback -Remove API route files and canvas-storage.js. No persistent side effects beyond Redis data (flush key). diff --git a/plans/260416-1513-rplace-implementation/phase-03-rate-limiting.md b/plans/260416-1513-rplace-implementation/phase-03-rate-limiting.md deleted file mode 100644 index 9adaafa..0000000 --- a/plans/260416-1513-rplace-implementation/phase-03-rate-limiting.md +++ /dev/null @@ -1,166 +0,0 @@ ---- -phase: 3 -title: "Rate Limiting" -status: pending -effort: 2h -priority: P1 -blocked_by: [2] ---- - -# Phase 3 — Rate Limiting (Stackable Credits) - -## Context Links -- [Token bucket algorithm](https://en.wikipedia.org/wiki/Token_bucket) - -## Overview -Implement stackable credit system: users accumulate 1 credit/second (max 256). Each pixel placement costs 1 credit. Batch placement deducts batch size from credits. All state stored in Redis Hash per user. - -## Key Insights -- This is a **token bucket** pattern stored in Redis -- No background process needed — calculate credits on-demand from elapsed time -- Atomic check-and-deduct prevents race conditions via Redis scripting or MULTI/EXEC -- User key: `credits:{userId}` where userId = IP hash (anonymous) or user ID (authenticated) - -## Data Flow - -``` -POST /api/canvas/place: - 1. Identify user → userId - 2. HGETALL credits:{userId} → {lastUpdate, credits} - 3. elapsed = now - lastUpdate - 4. newCredits = min(MAX_CREDITS, storedCredits + floor(elapsed)) - 5. if newCredits < batchSize → reject 429 - 6. remaining = newCredits - batchSize - 7. HSET credits:{userId} lastUpdate=now credits=remaining - 8. Proceed with pixel placement -``` - -## Architecture - -### `src/lib/rate-limiter.js` - -```js -// checkAndDeductCredits(userId, count) → { allowed: bool, remaining: int, retryAfter?: int } -// -// Algorithm: -// 1. Get stored state from Redis Hash -// 2. Calculate accrued credits from elapsed time -// 3. If sufficient: deduct and update; return allowed=true -// 4. If insufficient: return allowed=false with retryAfter seconds -// -// Edge cases: -// - First-time user (no hash exists) → initialize with MAX_CREDITS -// - Clock skew → use Redis server time (TIME command) if needed -``` - -### User Identity Extraction - -```js -// src/lib/get-user-id.js -// getUserId(request) → string -// 1. Check NextAuth session → user.id -// 2. Fallback: hash of x-forwarded-for or request IP -// 3. Prefix: "auth:" or "anon:" to avoid collision -``` - -## Related Code Files - -### Create -- `src/lib/rate-limiter.js` -- `src/lib/get-user-id.js` - -### Modify -- `src/app/api/canvas/place/route.js` — integrate rate limiter - -## Implementation Steps - -1. **Create `get-user-id.js`** - - Extract IP from `x-forwarded-for` header (first IP if multiple) - - Hash IP with simple hash (e.g., substring of SHA-256) for privacy - - If NextAuth session exists, use `session.user.id` with `auth:` prefix - - Anonymous users get `anon:` prefix - -2. **Create `rate-limiter.js`** - - `checkAndDeductCredits(userId, count)`: - - `redis.hgetall(`credits:${userId}`)` → parse lastUpdate, credits - - If null (new user): set credits = MAX_CREDITS, lastUpdate = now - - Calculate: `accrued = min(MAX_CREDITS, stored + floor((now - lastUpdate) / 1000))` - - If `accrued < count`: return `{ allowed: false, remaining: accrued, retryAfter: count - accrued }` - - Else: `redis.hset(key, { lastUpdate: now, credits: accrued - count })`, return `{ allowed: true, remaining: accrued - count }` - - Use seconds (Unix timestamp) for lastUpdate - - **Race condition mitigation**: Use Redis Lua script or pipeline with WATCH for atomicity. Upstash supports `redis.eval()` for Lua scripts. - -3. **Integrate into place route** - - Import `getUserId`, `checkAndDeductCredits` - - Before pixel write: check credits - - On rejection: return 429 with `{ error: 'rate_limited', retryAfter, remaining }` - - On success: include `remaining` credits in response - -4. **Add credits info endpoint** (optional, could be part of place response) - - GET `/api/credits` → returns current credit count for user (calculated, not stored) - -## Lua Script for Atomicity - -```lua --- KEYS[1] = credits:{userId} --- ARGV[1] = count (pixels to place) --- ARGV[2] = now (unix seconds) --- ARGV[3] = max credits --- ARGV[4] = regen rate (credits per second) - -local data = redis.call('HGETALL', KEYS[1]) -local lastUpdate = 0 -local credits = tonumber(ARGV[3]) -- default max for new users - -if #data > 0 then - for i = 1, #data, 2 do - if data[i] == 'lastUpdate' then lastUpdate = tonumber(data[i+1]) end - if data[i] == 'credits' then credits = tonumber(data[i+1]) end - end -end - -local elapsed = tonumber(ARGV[2]) - lastUpdate -local accrued = math.min(tonumber(ARGV[3]), credits + math.floor(elapsed * tonumber(ARGV[4]))) -local count = tonumber(ARGV[1]) - -if accrued < count then - return {0, accrued, count - accrued} -- denied, remaining, retryAfter -end - -local remaining = accrued - count -redis.call('HSET', KEYS[1], 'lastUpdate', ARGV[2], 'credits', remaining) -return {1, remaining, 0} -- allowed, remaining, 0 -``` - -## Todo List - -- [ ] Create `get-user-id.js` with IP extraction + hashing -- [ ] Create `rate-limiter.js` with credit calculation logic -- [ ] Implement Lua script for atomic check-and-deduct -- [ ] Integrate into POST `/api/canvas/place` -- [ ] Return 429 with retryAfter on rate limit -- [ ] Test: new user gets full credits -- [ ] Test: credits deplete and regenerate correctly -- [ ] Test: batch larger than available credits rejected - -## Success Criteria -- New user can place 256 pixels immediately -- After depleting credits, requests return 429 with correct retryAfter -- Credits regenerate at 1/sec (verified by waiting and retrying) -- Concurrent requests don't grant double credits (Lua script atomicity) - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Upstash Lua script limitations | Med | High | Test EVAL support early; fallback to non-atomic HGETALL+HSET | -| IP spoofing for unlimited credits | Med | Low | Vercel provides real IP; rate limit is soft defense anyway | -| Clock drift between app and Redis | Low | Low | Use Redis TIME or consistent Date.now() | - -## Failure Modes -1. **Redis EVAL not supported** → Fallback to HGETALL + HSET (small race window acceptable for MVP) -2. **IP header missing** → Use fallback `127.0.0.1` hash (all anonymous users share limit — degrade gracefully) -3. **Hash key explosion** (many unique IPs) → Set TTL on credit hashes (e.g., 24h expiry via EXPIRE) - -## Rollback -Remove rate-limiter.js, get-user-id.js. Remove rate limit check from place route (it was a stub before). Place route works without rate limiting. diff --git a/plans/260416-1513-rplace-implementation/phase-04-real-time-updates.md b/plans/260416-1513-rplace-implementation/phase-04-real-time-updates.md deleted file mode 100644 index 9647d9c..0000000 --- a/plans/260416-1513-rplace-implementation/phase-04-real-time-updates.md +++ /dev/null @@ -1,170 +0,0 @@ ---- -phase: 4 -title: "Real-time Updates (SSE + Pub/Sub)" -status: pending -effort: 3h -priority: P1 -blocked_by: [2] ---- - -# Phase 4 — Real-time Updates - -## Context Links -- [MDN Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) -- [Upstash Redis Pub/Sub](https://docs.upstash.com/redis/howto/pubsub) - -## Overview -Implement SSE endpoint that streams pixel updates to connected clients. When pixels are placed, the place route publishes deltas to Redis Pub/Sub. The SSE route subscribes and forwards to clients. - -## Key Insights - -- Vercel serverless functions have max execution time (10s free, 60s pro). SSE on Vercel works via **streaming responses** with Edge Runtime. -- Use **Edge Runtime** for SSE route — no cold start, streaming support -- Upstash Redis Pub/Sub works differently from traditional Redis: use `@upstash/redis` REST-based pub/sub or polling approach -- Alternative: Use Upstash's `@upstash/redis` with `subscribe` (if available) or implement polling-based SSE -- **Practical approach for Vercel**: SSE endpoint polls Redis for updates using a sorted set or list as message queue, rather than true Pub/Sub (which requires persistent connection) - -## Data Flow - -``` -Pixel Placement: - place/route.js → setPixels() → redis.publish("canvas:updates", JSON.stringify(batch)) - + redis.lpush("canvas:queue", JSON.stringify({ts, pixels})) - -SSE Stream: - stream/route.js (Edge Runtime): - 1. Client connects via EventSource - 2. Send initial heartbeat - 3. Poll loop: redis.lrange("canvas:queue", ...) for new updates since client's last seen ts - 4. Send each batch as SSE event - 5. Trim old entries periodically (LTRIM) - -Alternative (simpler, recommended for MVP): - stream/route.js: - 1. Client connects with ?since={timestamp} - 2. Server polls Redis list every 500ms - 3. Sends new batches as SSE data events - 4. Client reconnects on disconnect (EventSource auto-reconnects) -``` - -## Architecture - -### Message Format (SSE event data) - -```json -{ - "ts": 1713200000000, - "pixels": [ - {"x": 100, "y": 200, "color": 5}, - {"x": 101, "y": 200, "color": 5} - ] -} -``` - -### `src/lib/sse-broadcaster.js` - -```js -// publishPixelUpdates(pixels) → void -// - Add timestamped batch to Redis sorted set (score = timestamp) -// - ZADD canvas:updates {score: Date.now(), member: JSON.stringify(batch)} -// - ZREMRANGEBYSCORE to trim entries older than 60s (keep queue bounded) - -// getUpdatesSince(since) → Array -// - ZRANGEBYSCORE canvas:updates since +inf -// - Parse and return batches -``` - -### `src/app/api/canvas/stream/route.js` - -```js -// Edge Runtime for streaming -export const runtime = 'edge'; - -// GET handler: -// 1. Create ReadableStream -// 2. In stream: poll getUpdatesSince() every 500ms -// 3. Send SSE-formatted events for each batch -// 4. Send heartbeat comment every 15s to keep connection alive -// 5. Respect AbortSignal for cleanup -``` - -## Related Code Files - -### Create -- `src/lib/sse-broadcaster.js` -- `src/app/api/canvas/stream/route.js` - -### Modify -- `src/app/api/canvas/place/route.js` — add publishPixelUpdates() call after successful placement - -## Implementation Steps - -1. **Implement `sse-broadcaster.js`** - - `publishPixelUpdates(pixels)`: - - `redis.zadd('canvas:updates', { score: Date.now(), member: JSON.stringify({ ts: Date.now(), pixels }) })` - - `redis.zremrangebyscore('canvas:updates', 0, Date.now() - 60000)` — trim old - - `getUpdatesSince(since)`: - - `redis.zrangebyscore('canvas:updates', since, '+inf')` - - Parse each member, return array - -2. **Implement SSE stream route** - - Use Edge Runtime (`export const runtime = 'edge'`) - - Create `ReadableStream` with `start(controller)`: - ```js - const encoder = new TextEncoder(); - let lastSeen = parseInt(url.searchParams.get('since') || '0'); - const interval = setInterval(async () => { - const updates = await getUpdatesSince(lastSeen + 1); - for (const update of updates) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(update)}\n\n`)); - lastSeen = Math.max(lastSeen, update.ts); - } - }, 500); - // heartbeat every 15s - const heartbeat = setInterval(() => { - controller.enqueue(encoder.encode(': heartbeat\n\n')); - }, 15000); - ``` - - Return `new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' } })` - -3. **Integrate publishing into place route** - - After `setPixels()` succeeds, call `publishPixelUpdates(pixels)` - -4. **Handle edge cases** - - AbortSignal / client disconnect → clear intervals - - Empty poll → no-op (don't send empty events) - - Reconnection: client sends `Last-Event-ID` or `?since=` param - -## Todo List - -- [ ] Create `sse-broadcaster.js` with ZADD/ZRANGEBYSCORE helpers -- [ ] Create SSE stream route with Edge Runtime -- [ ] Implement polling loop with heartbeat -- [ ] Integrate publishPixelUpdates into place route -- [ ] Handle client disconnect cleanup -- [ ] Test SSE stream with curl: `curl -N localhost:3000/api/canvas/stream` -- [ ] Test update delivery latency (<1s) - -## Success Criteria -- SSE endpoint streams events to connected client -- Placing a pixel triggers SSE event within 1 second -- Heartbeat keeps connection alive -- Client reconnect resumes from last seen timestamp -- Old updates (>60s) cleaned up automatically - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Vercel Edge Runtime limits SSE duration | High | High | Document limit; client auto-reconnects via EventSource; accept 30s reconnect cycle | -| Upstash REST latency for 500ms polling | Med | Med | Acceptable for MVP; upgrade to WebSocket/Ably if needed | -| Sorted set grows unbounded | Med | Med | ZREMRANGEBYSCORE trims old; add ZCARD check as safety | - -## Failure Modes -1. **SSE connection drops** → EventSource auto-reconnects; `?since=` param ensures no missed updates -2. **Redis sorted set too large** → Trim runs on every publish; worst case: add ZCARD limit check -3. **High-frequency updates overwhelm client** → Batch multiple updates per SSE event; client-side throttle rendering -4. **Edge Runtime timeout** → Client reconnects; stateless polling means no server-side state lost - -## Rollback -Remove stream route and sse-broadcaster.js. Remove publish call from place route. Frontend falls back to periodic full canvas refresh (degrade to polling). diff --git a/plans/260416-1513-rplace-implementation/phase-05-frontend-canvas.md b/plans/260416-1513-rplace-implementation/phase-05-frontend-canvas.md deleted file mode 100644 index bcaa904..0000000 --- a/plans/260416-1513-rplace-implementation/phase-05-frontend-canvas.md +++ /dev/null @@ -1,190 +0,0 @@ ---- -phase: 5 -title: "Frontend Canvas" -status: pending -effort: 5h -priority: P1 -blocked_by: [2, 3, 4] ---- - -# Phase 5 — Frontend Canvas - -## Overview -Build interactive HTML5 Canvas UI: load full canvas from API, render 2048x2048 grid, support zoom/pan, color picker with 32-color palette, pixel placement on click, real-time SSE updates. - -## Key Insights -- HTML5 Canvas with `ImageData` is the performant way to render millions of pixels -- Use `OffscreenCanvas` or direct `putImageData` for bulk updates -- Zoom/pan via CSS `transform` on a wrapper or by scaling canvas draw calls -- Decode 5-bit packed binary into RGBA ImageData on client side -- Use `requestAnimationFrame` for smooth rendering - -## Data Flow - -``` -Initial Load: - 1. fetch('/api/canvas') → gzipped binary (auto-decompressed by browser) - 2. Decode 5-bit packed buffer → Uint8Array of color indices - 3. Map color indices → RGBA via palette lookup - 4. Create ImageData, putImageData to canvas - -Live Updates (SSE): - 1. EventSource('/api/canvas/stream?since=0') - 2. On message: parse pixel batch - 3. For each pixel: update ImageData at (x,y), queue re-render - 4. Batch re-renders via requestAnimationFrame - -Pixel Placement: - 1. User clicks canvas → translate screen coords to canvas coords (account for zoom/pan) - 2. Validate selected color - 3. POST /api/canvas/place with [{x, y, color}] - 4. Optimistic update: paint pixel immediately - 5. On error: revert pixel -``` - -## Architecture - -### Components - -``` -src/app/page.js - └── Client-side canvas app - ├── CanvasRenderer — HTML5 Canvas element, ImageData management - ├── ColorPicker — 32-color palette grid - ├── CanvasControls — Zoom buttons, coordinates display - └── UserInfo — Credit counter, auth status -``` - -### `src/app/components/canvas-renderer.js` - -Core rendering component. Manages: -- Canvas element ref -- ImageData buffer (2048x2048 RGBA) -- Zoom level and pan offset -- Mouse/touch event handlers for pan, zoom, click-to-place -- SSE connection lifecycle - -### `src/app/components/color-picker.js` - -- Grid of 32 color swatches -- Selected color highlighted -- Click to select - -### `src/app/components/canvas-controls.js` - -- Zoom in/out buttons -- Reset view button -- Coordinates display (current hover position) -- Scroll wheel zoom - -### `src/app/components/user-info.js` - -- Display remaining credits (poll or derive from last placement response) -- Login/logout button (Phase 6) -- Cooldown timer visualization - -## Related Code Files - -### Create -- `src/app/components/canvas-renderer.js` -- `src/app/components/color-picker.js` -- `src/app/components/canvas-controls.js` -- `src/app/components/user-info.js` -- `src/app/hooks/use-canvas-state.js` — shared state hook -- `src/app/hooks/use-sse-updates.js` — SSE connection hook - -### Modify -- `src/app/page.js` — compose components -- `src/app/globals.css` — canvas styles -- `src/app/layout.js` — metadata, viewport - -## Implementation Steps - -1. **Binary decoding utility** (`src/lib/canvas-decoder.js` — client-side) - - `decodeCanvas(buffer)` → Uint8Array of color indices - - Read 5-bit values from packed binary: bit manipulation with DataView - - `indicesToImageData(indices, palette)` → ImageData (RGBA) - -2. **Canvas renderer component** - - `useRef` for canvas element - - On mount: fetch `/api/canvas`, decode, render with `putImageData` - - Track zoom (1x-40x) and pan offset in state - - Apply transform: scale canvas context or use CSS transform - - Mouse events: - - `mousedown` + `mousemove` → pan (when not placing) - - `click` → place pixel (when color selected) - - `wheel` → zoom in/out centered on cursor - - Touch events for mobile: pinch-to-zoom, drag-to-pan - -3. **SSE updates hook** - - `useSSEUpdates(onBatch)` custom hook - - Create `EventSource('/api/canvas/stream?since={ts}')` - - On message: parse JSON, call `onBatch(pixels)` - - Handle reconnection (EventSource does this natively) - - Track last event timestamp for reconnect `since` param - -4. **Color picker component** - - Display 32 colors in 4x8 or 8x4 grid - - CSS grid layout - - Selected state with border/highlight - - Keyboard shortcuts (number keys for quick select) - -5. **Canvas controls** - - Zoom level display (e.g., "4x") - - +/- buttons - - "Reset" to fit canvas in viewport - - Coordinate display updating on mousemove - -6. **User info component** - - Display credit count from last placement response - - Animate credit regeneration client-side (increment every second) - - Show "Ready" / "Cooldown: Xs" status - -7. **Main page composition** - - Import all components - - Shared state via `use-canvas-state.js` hook or props - - Layout: canvas fills viewport, color picker bottom, controls top-right, user-info top-left - -## Todo List - -- [ ] Create binary decoder (5-bit unpacking to RGBA) -- [ ] Create canvas-renderer with zoom/pan -- [ ] Create SSE updates hook -- [ ] Create color-picker component -- [ ] Create canvas-controls component -- [ ] Create user-info component -- [ ] Compose in page.js -- [ ] Add mobile touch support (pinch-zoom, drag-pan) -- [ ] Style with globals.css -- [ ] Test: full canvas loads and renders -- [ ] Test: click places pixel with optimistic update -- [ ] Test: SSE updates render in real-time -- [ ] Test: zoom/pan works smoothly - -## Success Criteria -- Canvas loads and renders 2048x2048 pixels from API -- Zoom in/out works (1x to 40x), smooth with mouse wheel -- Pan by click-drag works -- Color picker shows 32 colors, selection is visible -- Clicking canvas with selected color sends POST and updates pixel -- SSE updates from other users appear within 1 second -- Mobile: pinch-zoom and drag-pan functional -- Credits display updates after placement - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| 2.5MB canvas download slow on mobile | Med | Med | Gzip reduces to ~1.5MB; show loading indicator | -| Canvas rendering performance at 40x zoom | Med | Med | Only render visible viewport tiles; use `drawImage` with source rect | -| 5-bit decoding bugs (off-by-one) | Med | High | Unit test decoder with known byte sequences | -| Touch event conflicts (pan vs place) | Med | Med | Long-press to place on mobile; short drag = pan | - -## Failure Modes -1. **Canvas download fails** → Show error + retry button; cache last successful state in localStorage -2. **SSE disconnects** → EventSource auto-reconnects; on reconnect, fetch full canvas to resync -3. **Optimistic update wrong** → On POST error, revert pixel to previous color from ImageData backup -4. **Memory pressure (2048x2048 ImageData = 16MB RGBA)** → Acceptable for modern browsers; warn if <2GB RAM detected - -## Rollback -Revert page.js to stub. Remove component files. Backend remains functional (testable via curl). diff --git a/plans/260416-1513-rplace-implementation/phase-06-authentication.md b/plans/260416-1513-rplace-implementation/phase-06-authentication.md deleted file mode 100644 index f7dcba0..0000000 --- a/plans/260416-1513-rplace-implementation/phase-06-authentication.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -phase: 6 -title: "Authentication" -status: pending -effort: 3h -priority: P2 -blocked_by: [5] ---- - -# Phase 6 — Authentication - -## Overview -Add optional Google/GitHub OAuth via NextAuth.js. Anonymous users continue working via IP-based identity. Authenticated users get stable identity (no credit reset on IP change). - -## Key Insights -- NextAuth.js App Router integration uses route handler at `app/api/auth/[...nextauth]/route.js` -- JWT strategy (no database session) — stateless, Vercel-friendly -- Anonymous users work immediately — auth is opt-in enhancement -- Transition: when user logs in, optionally migrate credits from anon identity to auth identity - -## Data Flow - -``` -Anonymous: - Request → getUserId() → hash(IP) → "anon:abc123" - -Authenticated: - Request → NextAuth session → session.user.id → "auth:google-12345" - -Login Flow: - 1. User clicks "Sign in" → NextAuth OAuth flow - 2. Redirect to Google/GitHub → consent → callback - 3. NextAuth creates JWT session cookie - 4. Subsequent requests include session → getUserId returns auth ID - 5. Optional: migrate credits from anon key to auth key -``` - -## Architecture - -### `src/lib/auth-options.js` - -```js -// NextAuth configuration -// Providers: Google, GitHub -// Strategy: JWT (no database) -// Callbacks: include user ID in session -// Pages: custom sign-in page (optional, default works for MVP) -``` - -### `src/app/api/auth/[...nextauth]/route.js` - -```js -// Standard NextAuth route handler -import NextAuth from 'next-auth'; -import { authOptions } from '@/lib/auth-options'; -const handler = NextAuth(authOptions); -export { handler as GET, handler as POST }; -``` - -### Modify `src/lib/get-user-id.js` - -```js -// Updated flow: -// 1. getServerSession(authOptions) -// 2. If session: return `auth:${session.user.id}` -// 3. Else: return `anon:${hash(ip)}` -``` - -## Related Code Files - -### Create -- `src/lib/auth-options.js` -- `src/app/api/auth/[...nextauth]/route.js` - -### Modify -- `src/lib/get-user-id.js` — add session check -- `src/app/components/user-info.js` — add login/logout buttons -- `src/app/layout.js` — wrap with SessionProvider (client-side) - -## Implementation Steps - -1. **Create `auth-options.js`** - - Configure Google and GitHub providers from env vars - - JWT strategy, no database adapter - - Add `session` callback to expose provider account ID - - Add `jwt` callback to persist user ID in token - -2. **Create NextAuth route handler** - - Standard catch-all route at `api/auth/[...nextauth]` - -3. **Update `get-user-id.js`** - - Import `getServerSession` from `next-auth` - - Check session first, fallback to IP hash - - Handle edge case: session exists but user.id missing - -4. **Update `user-info.js` component** - - Import `useSession` from `next-auth/react` - - Show "Sign in" button when not authenticated - - Show user avatar/name + "Sign out" when authenticated - - Use `signIn()` and `signOut()` from next-auth/react - -5. **Add SessionProvider wrapper** - - Create `src/app/providers.js` — client component wrapping `SessionProvider` - - Import in `layout.js` - -6. **Credit migration (optional, nice-to-have)** - - On first authenticated request: check if anon key has credits - - If so: transfer credits from anon to auth key, delete anon key - - Skip if complexity not worth it for MVP - -## Todo List - -- [ ] Create `auth-options.js` with Google + GitHub providers -- [ ] Create NextAuth route handler -- [ ] Create `providers.js` with SessionProvider -- [ ] Update layout.js with providers wrapper -- [ ] Update `get-user-id.js` with session check -- [ ] Update `user-info.js` with login/logout UI -- [ ] Test Google OAuth flow end-to-end -- [ ] Test GitHub OAuth flow end-to-end -- [ ] Test anonymous fallback still works -- [ ] Test credits persist across sessions for authenticated users - -## Success Criteria -- Anonymous users can place pixels without signing in -- Google OAuth login/logout works -- GitHub OAuth login/logout works -- Authenticated user has stable identity (credits persist) -- Session persists across page refreshes (JWT cookie) -- No regression in anonymous flow - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| OAuth provider setup complexity (client ID/secret) | Low | Med | Document setup steps in README | -| NextAuth version breaking changes | Low | Med | Pin version; test before upgrade | -| SessionProvider SSR hydration issues | Med | Med | Wrap in client component boundary | - -## Security Considerations -- NEXTAUTH_SECRET must be strong random string (32+ chars) -- OAuth callback URLs must be registered with providers -- JWT tokens are httpOnly cookies — no XSS exposure -- Rate limit keys use provider user ID — no spoofing possible -- CSRF protection built into NextAuth - -## Rollback -Remove auth-options.js, NextAuth route, providers.js. Revert get-user-id.js to IP-only. Revert user-info.js to remove login buttons. App works fully anonymous. diff --git a/plans/260416-1513-rplace-implementation/phase-07-polish-and-deploy.md b/plans/260416-1513-rplace-implementation/phase-07-polish-and-deploy.md deleted file mode 100644 index d265362..0000000 --- a/plans/260416-1513-rplace-implementation/phase-07-polish-and-deploy.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -phase: 7 -title: "Polish & Deploy" -status: pending -effort: 2.5h -priority: P2 -blocked_by: [6] ---- - -# Phase 7 — Polish & Deploy - -## Overview -Production hardening: Vercel deployment config, Upstash Redis provisioning, environment setup, performance optimization, error handling polish, and documentation. - -## Implementation Steps - -1. **Vercel Configuration** - - Create `vercel.json` if needed (usually not required for Next.js) - - Set Edge Runtime for SSE route - - Configure function regions (closest to Upstash Redis region) - - Set environment variables in Vercel dashboard - -2. **Upstash Redis Setup** - - Create Upstash database (choose region matching Vercel) - - Enable eviction policy: noeviction (canvas data must persist) - - Set maxmemory appropriately (canvas = ~3MB + credit hashes + update queue) - - Copy REST URL and token to Vercel env vars - -3. **Performance Optimization** - - Canvas API: verify gzip compression working (check Content-Encoding header) - - Add `Cache-Control` headers: canvas GET (short TTL), stream (no-cache) - - Consider canvas snapshot caching at edge (Vercel Edge Config or KV) - - Lazy load non-critical UI components - - Optimize binary decoder: use DataView for efficient 5-bit reads - -4. **Error Handling Polish** - - Global error boundary for React components - - API routes: consistent error response format `{ error: string, code?: string }` - - SSE: graceful reconnection with exponential backoff (EventSource default) - - Canvas load failure: retry with backoff, show user-friendly error - - Redis connection failure: 503 response with retry header - -5. **Canvas Initialization** - - Admin/setup script to initialize empty canvas in Redis - - Or: auto-initialize on first GET request if key missing - - Add `GET /api/canvas/info` endpoint: returns canvas dimensions, total pixels placed, etc. - -6. **Meta & SEO** - - Open Graph tags - - Favicon - - Page title and description - - Mobile viewport meta - -7. **Documentation** - - Update README.md with: - - Project description - - Setup instructions (local dev + Vercel deploy) - - Environment variables reference - - Architecture overview - - Update docs/ directory per documentation management rules - -## Todo List - -- [ ] Configure Vercel deployment settings -- [ ] Provision Upstash Redis database -- [ ] Set all environment variables -- [ ] Add error boundaries and consistent error responses -- [ ] Add canvas initialization logic -- [ ] Optimize gzip and caching headers -- [ ] Add meta tags and favicon -- [ ] Update README with setup instructions -- [ ] Update docs/ (architecture, code standards) -- [ ] Deploy to Vercel and smoke test -- [ ] Test full flow: load canvas → place pixels → see real-time updates -- [ ] Test OAuth flows in production - -## Success Criteria -- App deployed to Vercel and accessible via URL -- Canvas loads within 3 seconds on broadband -- Pixel placement works end-to-end in production -- SSE updates work in production -- OAuth works with production callback URLs -- No console errors in production build -- Redis memory usage is predictable and bounded - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Vercel Edge Runtime SSE timeout | High | Med | Document 30s limit; client reconnects automatically | -| Upstash free tier limits (10K commands/day) | Med | High | Monitor usage; upgrade plan if needed; batch reads | -| Cold start latency | Med | Low | Edge Runtime eliminates cold starts for SSE; serverless routes accept ~200ms | - -## Rollback -Vercel supports instant rollback to previous deployment. Redis data persists independently. Rollback = redeploy previous commit. diff --git a/plans/260416-1513-rplace-implementation/plan.md b/plans/260416-1513-rplace-implementation/plan.md deleted file mode 100644 index 3523696..0000000 --- a/plans/260416-1513-rplace-implementation/plan.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: "rplace — Reddit r/place Clone" -description: "Full implementation plan for a 2048x2048 collaborative pixel canvas with real-time updates" -status: pending -priority: P1 -effort: 20h -branch: main -tags: [nextjs, redis, sse, canvas, real-time] -created: 2026-04-16 ---- - -# rplace Implementation Plan - -## Architecture - -``` -Browser (HTML5 Canvas + SSE client) - | GET /api/canvas → full canvas binary (gzip) - | POST /api/canvas/place → batch pixel placement (up to 256px) - | GET /api/canvas/stream → SSE delta updates - | GET/POST /api/auth/* → NextAuth.js routes - v -Next.js App Router (Vercel serverless) - | - v -Upstash Redis - ├── BITFIELD "canvas" (5-bit per pixel, 2048x2048 = 2.62MB) - ├── HASH "credits:{id}" → {lastUpdate, credits} - └── Pub/Sub channel "canvas:updates" -``` - -## Phases - -| # | Phase | Status | Effort | File | -|---|-------|--------|--------|------| -| 1 | Project Setup | Pending | 1.5h | [phase-01](./phase-01-project-setup.md) | -| 2 | Canvas Backend | Pending | 3h | [phase-02](./phase-02-canvas-backend.md) | -| 3 | Rate Limiting | Pending | 2h | [phase-03](./phase-03-rate-limiting.md) | -| 4 | Real-time Updates | Pending | 3h | [phase-04](./phase-04-real-time-updates.md) | -| 5 | Frontend Canvas | Pending | 5h | [phase-05](./phase-05-frontend-canvas.md) | -| 6 | Authentication | Pending | 3h | [phase-06](./phase-06-authentication.md) | -| 7 | Polish & Deploy | Pending | 2.5h | [phase-07](./phase-07-polish-and-deploy.md) | - -## Dependencies - -``` -Phase 1 (Setup) - └─> Phase 2 (Canvas Backend) - ├─> Phase 3 (Rate Limiting) - └─> Phase 4 (Real-time) - └─> Phase 5 (Frontend) ← also depends on Phase 2, 3 - └─> Phase 6 (Auth) - └─> Phase 7 (Polish & Deploy) -``` - -## Key Decisions - -- **JavaScript only** — no TypeScript (user preference) -- **SSE over WebSocket** — free on Vercel serverless, simpler -- **Redis BITFIELD** — 5-bit color encoding, single key for entire canvas -- **Stackable credits** — not fixed cooldown timer; allows batch placement -- **Anonymous-first** — IP-based identity, OAuth optional diff --git a/plans/260417-0935-review-fixes/plan.md b/plans/260417-0935-review-fixes/plan.md deleted file mode 100644 index a291781..0000000 --- a/plans/260417-0935-review-fixes/plan.md +++ /dev/null @@ -1,69 +0,0 @@ -# Plan: Review-Fix Sweep (2026-04-17) - -Fixes for issues from `/ultrareview` reports: -- `plans/reports/code-review-260417-0926-backend.md` -- `plans/reports/code-review-260417-0926-frontend.md` -- `plans/reports/tester-260417-0927-test-suite.md` - -## Status: COMPLETE — 76/76 unit tests pass; vite build OK. - -## Backend fixes - -| ID | File | Change | -|---|---|---| -| C1, C2 | `src/lib/rate-limiter.js` | Switch `lu` to ms precision. `retryAfter` now in seconds via `ceil(deficit * msPerCredit / 1000)`. Fractional regen handled (cap-aware lu advancement preserves sub-credit residue). | -| NH1 | `src/lib/redis-client.js` | `redisRaw` / `redisRawBinary` throw on Upstash 200-with-`error` envelope. `redisRaw` now returns `body.result` (was full envelope). | -| NC2 | `src/lib/constants.js` | `MAX_BATCH_SIZE = MAX_CREDITS = 256` (was 512 vs 256 mismatch). | -| NC2+L4 | `src/worker.js` | Early `Content-Length` reject (>16KB) before parsing JSON. | -| NH2 | `src/lib/canvas-storage.js` | `console.warn` on truncated canvas read instead of silent zero-pad. | -| H4 | `src/worker.js` | Bumped `s-maxage` 1→10, `stale-while-revalidate` 5→30. Manual gzip via `CompressionStream` when `Accept-Encoding: gzip`. `Vary: Accept-Encoding`. | -| H5 | `src/worker.js` | Broadcast moved to `c.executionCtx.waitUntil(broadcastPixels(...))` with `r.ok` check + error log. Resilient when `executionCtx` absent (test env). | -| H1, H2 | `src/lib/get-user-id.js` | SHA-256 (16 hex chars) replaces 32-bit string hash. Missing `cf-connecting-ip` → `anon:dev` shared bucket + `console.warn`. Function is now async — `worker.js` awaits. | -| NH4, N5 | `src/durable-objects/canvas-room.js` | Constructor now `(state, env)`. `webSocketClose` logs unclean disconnects. `webSocketError` logs error message. `webSocketMessage` defensively closes (1003) on unexpected client message. | - -## Frontend fixes - -| ID | File | Change | -|---|---|---| -| NC2 | `CanvasRenderer.svelte`, `App.svelte` | `addToStroke` blocks when `buffer.pixelCount + currentStrokeKeys.size >= MAX_BATCH_SIZE` (only if pixel isn't already buffered). New `onBufferFull` callback shows toast in App. | -| NC1 | `App.svelte` | `handleSubmit` shows toast on 429 (with `retryAfter`), 413, 400, 5xx, network error. `commitPending` only on `data.ok === true`. Single `toast` state with auto-dismiss. | -| NC3 | `CanvasRenderer.svelte` | `committedColors` allocated as zero-filled `Uint8Array` upfront (was `null` until fetch). Replaced after fetch. WS updates during initial fetch no longer null-deref. | -| C1, C2 | `CanvasRenderer.svelte` | `render(effZoom = zoom)` accepts explicit zoom. `handleWheel` always calls `render(newZoom)` after pan mutation, fixing the clamped-zoom no-render case. Touch pinch-zoom branch same. | -| C3 | `src/lib/canvas-decoder.js` | Throws on `buffer.byteLength < EXPECTED_BYTES` instead of silently `\|\| 0` reading past end. | -| C4 | `App.svelte`, `CanvasRenderer.svelte` | `isReconnect` flag in App; `canvasRenderer.refetchCanvas()` exported and called on reconnect `onopen`. Recovers pixels missed during disconnect. | -| NH1 | `src/lib/pixel-buffer.js` | Internal `Map` cache. O(1) `getColorAt`, `pixelCount`, `getAffectedKeys`, `getAllPixels`. Invalidated on `addStroke/undo/redo/clear`. | -| NH3 | `CanvasRenderer.svelte` | `$effect(() => { mode; ... })` calls `cancelStroke()` (restores pixels, clears state) on mode change. No more dangling-stroke merge across modes. | -| NH5 | `CanvasRenderer.svelte` | `loadError` state + retry button overlay on initial fetch failure. | -| H1 | `CanvasRenderer.svelte` | DPR-aware sizing: `canvasEl.{width,height} = innerSize * dpr` + CSS sets logical size. `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` in render before pan/zoom. | -| H2 | `CanvasRenderer.svelte` | `onMount` is now sync; cleanup returns sync fn. `loadCanvas()` runs in background. Resize listener cleanup no longer leaks across HMR. | -| Touch | `CanvasRenderer.svelte` | Single-finger touch move now calls `onCursorMove` (was only mouse). | -| Defensive | `App.svelte` | `handleKeyDown` skips when target is `/