plans: drop implemented plan folders

Both plans shipped to production:
  - 260509-2309-canvas-on-do-storage (Upstash → DO migration, deployed
    2026-05-10, Upstash fully removed in a977adc).
  - 260510-0232-fix-do-migration-followups (post-migration P1 fixes,
    docs cleanup, full DO test coverage — all completed 2026-05-11).

Git history retains the full content if needed; plans/reports/ kept
as-is for the original research/review artifacts.
This commit is contained in:
tiennm99 committed 2026-05-11 16:40:04 +07:00
1 parent 214a00f9d7
commit 7ef64ecd4f
12 files changed
-1466

No files matched your search

@@ -1,110 +0,0 @@
---
phase: 1
title: "Storage Foundation"
status: completed
priority: P2
effort: "3h"
dependencies: []
---
# Phase 1: Storage Foundation
## Overview
Add SQLite-backed storage modules consumable by the DO. Pure logic, no integration yet. Unit-testable in isolation.
## Requirements
**Functional:**
- Read/write canvas bytes via chunked BLOB rows.
- Read/write per-user cooldowns with lazy expiry GC.
- Lazy-initialize missing chunks (return zero-fill on read; INSERT on first write).
**Non-functional:**
- Pure functions / classes accepting `sql` interface — testable without a live DO.
- Write path: at most 1 transaction per `placePixels` call.
- O(touched_chunks) writes, not O(total_chunks).
## Architecture
Two new modules inside the DO directory:
- `src/durable-objects/lib/chunk-storage.js` — canvas chunk BLOB R/W, batch-aware
- `src/durable-objects/lib/cooldown-store.js` — user_id → expires_at SET-NX semantic
Constants stay in `src/lib/constants.js` but get new entries:
```js
export const CHUNK_BYTES = 65536; // 64 KB
export const CHUNK_COUNT = Math.ceil(TOTAL_PIXELS / CHUNK_BYTES); // 256
```
**Why 64 KB chunks (informed by actual CF DO limits, May 2026):**
- BLOB row cap is **2 MB** → `CHUNK_BYTES` could be up to ~2 MB; we use 64 KB (32× under).
- Storage caps are **10 GB / DO** and **5 GB / account** (Free) → 16 MB canvas
is ~300× under either cap; resize to ~70K×70K is feasible on Free.
- Soft request cap is **1,000 req/sec / DO** → hobby workload is 1‰ of this.
- Smaller chunks minimize read-modify-write amplification on **clustered** writes
(single placements + image patches), which is rplace's actual workload pattern.
Bigger chunks would only help uniform-random writes — not a real workload here.
Schema (created in DO constructor via `sql.exec` if not exists):
```sql
CREATE TABLE IF NOT EXISTS canvas_chunks (
chunk_id INTEGER PRIMARY KEY,
bytes BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS cooldowns (
user_id TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cooldowns_expires ON cooldowns(expires_at);
```
## Related Code Files
**Create:**
- `src/durable-objects/lib/chunk-storage.js`
- `src/durable-objects/lib/cooldown-store.js`
- `src/durable-objects/lib/schema.js` (CREATE TABLE statements, exported as a single `init(sql)` function)
- `test/unit/chunk-storage.test.js`
- `test/unit/cooldown-store.test.js`
**Modify:**
- `src/lib/constants.js` — add `CHUNK_BYTES`, `CHUNK_COUNT`
**Delete:** None this phase.
## Implementation Steps
1. Add `CHUNK_BYTES`, `CHUNK_COUNT` to `src/lib/constants.js`. Verify `CHUNK_COUNT = 256` for 4096×4096.
2. Write `schema.js` with idempotent `CREATE TABLE`/`CREATE INDEX` statements wrapped in single `init(sql)` export.
3. Write `chunk-storage.js`:
- `readChunk(sql, chunkId) → Uint8Array(CHUNK_BYTES)` — zero-fill if missing.
- `readAllChunks(sql) → Uint8Array(TOTAL_PIXELS)` — concatenated buffer.
- `writePixels(sql, pixels)` — group by chunk_id, single transaction, UPDATE OR INSERT each touched chunk.
- Helper: `pixelToChunk(x, y) → {chunkId, byteOffset}`.
4. Write `cooldown-store.js`:
- `tryAcquire(sql, userId, ttlMs, now) → {allowed, retryAfter}` — INSERT OR FAIL pattern: if a non-expired row exists, return `{allowed:false}`; else upsert with `expires_at = now + ttlMs`.
- `gc(sql, now)` — DELETE WHERE `expires_at < now`. Called opportunistically (e.g., 1% of acquires).
5. Write unit tests using `better-sqlite3` or equivalent in-memory SQLite (Vitest):
- chunk-storage: write-then-read, partial-chunk write, batch across chunks, lazy zero-init.
- cooldown-store: acquire success, acquire blocked, acquire after expiry, GC removes stale rows.
6. `npm test` — all green.
## Success Criteria
- [ ] `src/lib/constants.js` exports `CHUNK_BYTES`, `CHUNK_COUNT`
- [ ] `chunk-storage.js` and `cooldown-store.js` exist with documented exports
- [ ] Unit tests cover happy path + edge cases (lazy init, expiry, batch across chunks)
- [ ] `npm test` passes
- [ ] No imports from `@upstash/redis` in new modules
## Risk Assessment
| Risk | Mitigation |
|---|---|
| SQLite BLOB API differs in CF DO vs better-sqlite3 | Keep modules thin; integration test in Phase 2 confirms |
| Chunk boundaries off-by-one for non-power-of-2 sizes | `CHUNK_COUNT = ceil()` handles partial last chunk; test it |
| Cooldown table grows unbounded | Lazy GC + 1s TTL keeps rows ephemeral; index on expires_at keeps GC cheap |
@@ -1,132 +0,0 @@
---
phase: 2
title: "DO Integration & Worker Proxy"
status: completed
priority: P2
effort: "4h"
dependencies: [1]
---
# Phase 2: DO Integration & Worker Proxy
## Overview
Wire Phase 1 storage modules into `CanvasRoom` DO. Add DO-side methods (`getFullCanvas`, `placePixels`, accept WS) using SQLite. Refactor `worker.js` to a thin validation/routing proxy. End state: app works against DO storage, Upstash still present but unused except for migration in Phase 3.
## Requirements
**Functional:**
- DO exposes 3 internal HTTP endpoints (called by worker via `room.fetch`):
- `GET /canvas` — returns 16 MB binary
- `POST /place` — body `{userId, pixels}`, does cooldown check + write + broadcast atomically
- `GET /ws` — WebSocket upgrade (existing behavior)
- Worker validates request shape and forwards to DO. Worker no longer touches storage.
- Broadcast still happens after successful write.
**Non-functional:**
- Single round-trip Worker → DO per `/api/place` (no extra trips for cooldown).
- DO write + broadcast same transaction-equivalent: cooldown check, pixel write, then broadcast — if write fails, no broadcast.
## Architecture
### DO method shape
```js
// canvas-room.js (refactored)
import { init as initSchema } from './lib/schema.js';
import { readAllChunks, writePixels } from './lib/chunk-storage.js';
import { tryAcquire, gc } from './lib/cooldown-store.js';
import { REQUEST_COOLDOWN_SEC } from '../lib/constants.js';
export class CanvasRoom {
constructor(state, env) {
this.state = state;
this.sql = state.storage.sql;
initSchema(this.sql);
}
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/canvas') return this.#handleGetCanvas();
if (url.pathname === '/place') return this.#handlePlace(request);
if (url.pathname === '/ws') return this.#handleWsUpgrade();
return new Response('not found', { status: 404 });
}
// ... existing webSocketMessage/Close/Error handlers unchanged
}
```
### Worker shape
```js
// worker.js (refactored)
app.get('/api/canvas', (c) => forwardToRoom(c, 'GET', '/canvas'));
app.post('/api/place', async (c) => {
// validate body shape, batch size, pixel ranges (existing logic)
const userId = await getUserId(c.req.raw);
return forwardToRoom(c, 'POST', '/place', { userId, pixels: body.pixels });
});
app.get('/api/ws', (c) => forwardToRoom(c, 'GET', '/ws', null, c.req.raw));
```
### Broadcast after write
Inside DO `#handlePlace`:
1. `tryAcquire(sql, userId, 1000, Date.now())`. If blocked → return 429 with retryAfter.
2. `writePixels(sql, pixels)`.
3. Iterate `state.getWebSockets()` and `ws.send(JSON.stringify({type:'pixels', pixels}))`.
4. Return 200 OK.
Broadcast is now in-DO, not via separate `room.fetch('/broadcast')`. Eliminates the internal `broadcastPixels` round-trip from Phase 0 design.
## Related Code Files
**Modify:**
- `src/durable-objects/canvas-room.js` — add `#handleGetCanvas`, `#handlePlace`, refactor `#handleWsUpgrade`. Schema init in constructor.
- `src/worker.js` — strip storage logic, add `forwardToRoom` helper, keep validation.
**Create:**
- `test/integration/do-canvas-room.test.js` — Wrangler `unstable_dev` or Vitest CF pool harness, exercising place/get/ws end-to-end.
**Delete:** None this phase (Upstash code stays for Phase 3 migration).
## Implementation Steps
1. Refactor `canvas-room.js`:
- Constructor calls `initSchema(state.storage.sql)`.
- Replace single `fetch()` body with method dispatch on `url.pathname`.
- Implement `#handleGetCanvas`: `readAllChunks(sql)` → return `application/octet-stream` with `Cache-Control: public, max-age=10, s-maxage=10, stale-while-revalidate=30`.
- Implement `#handlePlace`: parse body, cooldown check, write pixels, broadcast, return JSON.
- Move WS upgrade from worker into `#handleWsUpgrade`.
2. Refactor `worker.js`:
- Add `forwardToRoom(c, method, path, jsonBody?, rawReq?)` helper.
- Keep input validation (body size, pixel ranges, batch cap) in worker — defense in depth.
- Replace direct Upstash calls with `forwardToRoom`.
- `getUserId` stays in worker (uses request headers).
3. Add `c.executionCtx.waitUntil(...)` for opportunistic `gc()` calls (or move GC inside DO with low probability).
4. Write integration tests:
- Boot Wrangler `unstable_dev` against this code.
- Test: GET /api/canvas returns 16 MB, POST /api/place updates pixel, GET /api/canvas reflects update, second place from same user within 1s returns 429.
- Test: WS connects, receives broadcast after another client's place.
5. Run `npm run test:all`. Existing Upstash-dependent tests will fail; mark them xfail or skip in this phase (will be deleted in Phase 4).
6. Run `npm run dev` (wrangler dev) and smoke-test in browser.
## Success Criteria
- [ ] `canvas-room.js` uses SQLite for canvas + cooldown
- [ ] `worker.js` has zero `redis*` or `Upstash` references
- [ ] Integration test: place pixel → read canvas → byte at expected offset is correct color
- [ ] Integration test: rate-limit returns 429 with `retryAfter`
- [ ] Browser smoke test: place pixels, see broadcast in second tab
- [ ] No regression in WS hibernation behavior
## Risk Assessment
| Risk | Mitigation |
|---|---|
| `room.fetch` overhead per request | Single round-trip per /api/place; same as today's setup |
| WS upgrade routing — worker → DO via fetch | Existing pattern works; passes `c.req.raw` through |
| 16 MB GET response in single DO call may hit limits | DO subrequest size cap is 32 MB; canvas is 16 MB → fits |
| Body size mismatch worker validation vs DO | Validate at both layers; DO trusts but verifies |
| Existing tests break | Expected — will be replaced in Phase 4 |
@@ -1,112 +0,0 @@
---
phase: 3
title: "One-Shot Upstash Migration"
status: completed
priority: P2
effort: "2h"
dependencies: [2]
---
# Phase 3: One-Shot Upstash Migration
## Overview
Copy production canvas bytes from Upstash Redis into the DO's SQLite chunks. Run once, verify, schedule removal. The migration endpoint is token-gated and short-lived — it exists for one execution, then deleted in Phase 4.
## Requirements
**Functional:**
- Admin endpoint `POST /admin/migrate-from-upstash` reads full canvas from Upstash via existing `getFullCanvas` (kept in `lib/legacy-upstash-storage.js` for this phase) and forwards bytes to DO `/import`.
- DO `POST /import` accepts a 16 MB body, splits into 256 chunks, INSERT OR REPLACE all rows in single transaction.
- Endpoint is gated by a `MIGRATION_TOKEN` env var; reject if missing/wrong.
- Idempotent: running twice yields the same final state.
**Non-functional:**
- Migration completes in <30s (well under DO timeout).
- Pre-flight checks: refuse to run if `canvas_chunks` already non-empty (unless `?force=1`).
- Verification step: known coordinate roundtrip post-migration.
## Architecture
### Endpoint flow
```
curl -X POST $WORKER_URL/admin/migrate-from-upstash \
-H "Authorization: Bearer $MIGRATION_TOKEN"
```
Worker:
1. Verify `Authorization` header matches `c.env.MIGRATION_TOKEN`.
2. Read full canvas from Upstash via legacy module (existing 4-chunk GETRANGE).
3. POST raw bytes to DO `/import`.
4. Verify by reading back a known coordinate from DO and comparing.
5. Return JSON `{imported_bytes, sample_check_passed}`.
DO `/import`:
1. Read body as `Uint8Array` (length must equal `TOTAL_PIXELS`).
2. Pre-flight: if any rows exist and no `?force=1`, return 409.
3. Open transaction; for each chunk index 0..CHUNK_COUNT-1, slice the buffer, `INSERT OR REPLACE INTO canvas_chunks`.
4. Commit. Return 200.
### Files
```
src/lib/legacy-upstash-storage.js ← copy of old canvas-storage.js, used only by migration
src/admin/migrate.js ← admin route handler
src/worker.js ← mount admin route under /admin/*
```
## Related Code Files
**Create:**
- `src/lib/legacy-upstash-storage.js` (renamed from old `lib/canvas-storage.js` — keep Upstash dependency until Phase 4)
- `src/admin/migrate.js`
- `src/durable-objects/lib/migration-import.js` (DO-side `/import` handler logic)
**Modify:**
- `src/worker.js` — mount `/admin/*` route, add MIGRATION_TOKEN check
- `src/durable-objects/canvas-room.js` — handle `/import` path
- `wrangler.json` — add `MIGRATION_TOKEN` to `vars` placeholder (real value via secret)
**Delete:** None this phase.
## Implementation Steps
1. Copy current `src/lib/canvas-storage.js` (Upstash version) → `src/lib/legacy-upstash-storage.js`. Rename exports if needed to avoid clash.
2. Add `wrangler secret put MIGRATION_TOKEN` (random 32 bytes hex).
3. Implement `src/admin/migrate.js`:
- Auth check.
- Read all bytes via `legacy-upstash-storage.getFullCanvas(env)`.
- POST to `room.fetch('http://internal/import', {method:'POST', body: bytes})`.
- Sample check: pick a coordinate that's likely set (e.g., `(0,0)`, `(2000,2000)`), read post-migration DO canvas, compare.
- Return JSON with stats.
4. Implement DO `/import` path in `canvas-room.js` using `migration-import.js`:
- Validate body length.
- Pre-flight check (refuse if non-empty unless force).
- Transactional bulk insert.
5. Local test: seed local Upstash with known pattern (test pixel at known coords), run migration against local wrangler dev, verify pixel readable from new DO.
6. Production run:
- Deploy current branch to staging or use `wrangler dev --remote`.
- `curl -X POST -H "Authorization: Bearer $TOKEN" $URL/admin/migrate-from-upstash`.
- Verify `sample_check_passed: true`.
- Browser smoke test: visible canvas matches pre-migration.
7. **Do not delete Upstash data yet.** Keep for 7 days as rollback safety.
## Success Criteria
- [ ] Migration endpoint requires valid `MIGRATION_TOKEN`
- [ ] Local migration: canvas_chunks count == CHUNK_COUNT after import
- [ ] Local migration: sample pixel at known coordinate matches pre-migration value
- [ ] Idempotency: re-running with `?force=1` yields same state
- [ ] Production migration successful (run once, log retained)
- [ ] Pre-migration screenshot vs post-migration screenshot identical (visual diff)
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Migration corrupts canvas | Pre-flight refuses to overwrite unless `?force=1`; keep Upstash data 7 days |
| Token leaked in logs | Use `Authorization` header (less likely logged than query string); rotate after migration |
| Body size 16 MB exceeds worker request limit | CF Workers request body cap is 100 MB on free tier; 16 MB fits |
| Partial migration (network failure mid-way) | Transactional bulk insert in DO; either all chunks committed or none |
| Sample-check coordinate happens to be unset (0,0) | Use multiple sample coordinates; require ≥1 known-set coordinate to pass |
@@ -1,120 +0,0 @@
---
phase: 4
title: "Cleanup & Dependency Removal"
status: completed
priority: P2
effort: "2h"
dependencies: [3]
---
# Phase 4: Cleanup & Dependency Removal
## Overview
After successful migration, delete all Upstash code paths, drop the `@upstash/redis` and `ioredis` dependencies, remove the migration endpoint, and rewrite the test suite to target DO storage.
## Requirements
**Functional:**
- Zero Upstash references in `src/`.
- `package.json` no longer depends on `@upstash/redis` or `ioredis`.
- `wrangler.json` and `.env` no longer reference Upstash secrets.
- `MIGRATION_TOKEN` secret deleted.
- All tests pass against DO storage (no testcontainers Redis).
**Non-functional:**
- No dead code left behind.
- `npm run dev` works without `UPSTASH_REDIS_*` env vars set.
## Architecture
### Files going away
```
src/lib/canvas-storage.js ← already orphaned by Phase 2
src/lib/legacy-upstash-storage.js ← created in Phase 3, now removed
src/lib/redis-client.js
src/lib/rate-limiter.js
src/admin/migrate.js
src/durable-objects/lib/migration-import.js
test/... ← any testcontainers Redis tests
vitest.integration.config.js ← if it only existed for Redis
```
### Test rewrite
Replace `testcontainers` Redis fixtures with Wrangler `unstable_dev` or Vitest `@cloudflare/vitest-pool-workers`.
```js
// test/integration/canvas-do.test.js
import { unstable_dev } from 'wrangler';
let worker;
beforeAll(async () => {
worker = await unstable_dev('src/worker.js', { local: true, persist: false });
});
afterAll(() => worker?.stop());
test('place pixel persists across reads', async () => {
await worker.fetch('/api/place', { method:'POST', body: JSON.stringify({pixels:[{x:10,y:20,color:5}]}) });
const res = await worker.fetch('/api/canvas');
const buf = new Uint8Array(await res.arrayBuffer());
expect(buf[20 * CANVAS_WIDTH + 10]).toBe(5);
});
```
## Related Code Files
**Delete:**
- `src/lib/canvas-storage.js`
- `src/lib/legacy-upstash-storage.js`
- `src/lib/redis-client.js`
- `src/lib/rate-limiter.js`
- `src/admin/migrate.js`
- `src/durable-objects/lib/migration-import.js`
- Any test file that imports from above
**Modify:**
- `package.json` — remove `@upstash/redis`, `ioredis`, `testcontainers` (if only used for Redis)
- `package-lock.json` — regenerate via `npm install`
- `wrangler.json` — remove any Upstash env var references
- `vitest.config.js` / `vitest.integration.config.js` — drop Redis-specific setup
- `src/worker.js` — remove `/admin/*` mount
**Create:**
- `test/integration/canvas-do.test.js` — replaces `testcontainers` Redis tests
- `test/integration/cooldown-do.test.js` — rate-limit semantics against DO
## Implementation Steps
1. Verify Phase 3 production migration successful and ≥7 days have passed (rollback window).
2. Delete the file list above.
3. `npm uninstall @upstash/redis ioredis testcontainers`.
4. Search-and-destroy: `grep -r -i "upstash\|redis\|cooldown:.*ttl" src/ test/` — must return zero hits.
5. Rewrite tests:
- `test/integration/canvas-do.test.js` — pixel placement, batch, validation, full canvas read.
- `test/integration/cooldown-do.test.js` — rate-limit window, retry-after value.
- Delete `vitest.integration.config.js` if no longer needed (or simplify).
6. Run `npm run test:all` — all green.
7. Run `npm run dev` — verify no Upstash env-var errors at startup.
8. `wrangler secret delete UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` and `MIGRATION_TOKEN` in production.
9. Update `.env.example` — remove Upstash entries, leave only `MIGRATION_TOKEN` placeholder commented as historical (or delete entirely).
## Success Criteria
- [ ] `grep -r -i "upstash" src/ test/` returns no matches
- [ ] `grep -r "redis" src/ test/` returns no matches (case-insensitive)
- [ ] `package.json` has no `@upstash/redis`, `ioredis`
- [ ] `npm run test:all` green
- [ ] `npm run dev` starts without Upstash env vars set
- [ ] Production secrets deleted
- [ ] `.env.example` clean
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Premature deletion before migration verified | Wait 7 days post-Phase-3 deploy before this phase |
| Test rewrite drops coverage | Match test cases 1:1 with deleted Upstash tests; review diff |
| Forgotten Upstash reference somewhere | Final grep across full repo, not just src/ |
| Wrangler `unstable_dev` API changes | Pin wrangler version in `devDependencies`; document in README |
@@ -1,96 +0,0 @@
---
phase: 5
title: "Deploy & Documentation"
status: pending
priority: P3
effort: "1h"
dependencies: [4]
---
# Phase 5: Deploy & Documentation
## Overview
Production rollout of the cleaned-up codebase. Update architecture docs to reflect single-DO-with-storage. Document the resize procedure so future canvas growth is one constant change + redeploy.
## Requirements
**Functional:**
- Production deploy of post-Phase-4 code with WS broadcast verified.
- `docs/system-architecture.md` updated.
- New doc: `docs/canvas-resize-procedure.md`.
- README architecture diagram updated.
**Non-functional:**
- Smoke test under realistic load (manual: 2 browsers, 30 placements/min for 5 min).
- 7-day post-deploy observation: zero Upstash refs in logs, $0 bill confirmed.
## Architecture
Documentation updates only — no code change.
### Resize procedure (the headline doc)
```
1. Edit src/lib/constants.js:
CANVAS_WIDTH = <new> // must be multiple of CHUNK_BYTES width factor
CANVAS_HEIGHT = <new>
2. Verify new TOTAL_PIXELS / CHUNK_BYTES gives integer (or accept partial last chunk).
3. npm run build
4. npm run deploy
5. New chunks lazy-init on first read (zero-fill).
6. Existing pixels remain at original (x,y) coordinates; effective canvas just becomes larger.
```
**Caveat:** shrinking the canvas truncates pixels outside new bounds. They're still in SQLite (orphan rows) — clean up by manually `DELETE FROM canvas_chunks WHERE chunk_id >= NEW_CHUNK_COUNT` via DO admin endpoint if needed (out of scope).
## Related Code Files
**Modify:**
- `docs/system-architecture.md` — replace Upstash mentions with DO SQLite layout
- `README.md` — update Tech Stack table (drop Upstash row), update architecture ASCII diagram, update Setup section (remove Upstash steps)
**Create:**
- `docs/canvas-resize-procedure.md`
**Delete:** None.
## Implementation Steps
1. `npm run deploy` to production.
2. Manual smoke test:
- Open 2 browser tabs.
- Place a pixel in tab A → confirm appears in tab B within 1s (WS broadcast).
- Refresh tab B → canvas reflects placed pixel (DO storage persistence).
- Spam-place in tab A → confirm 429 after 1st in same second (rate limit).
3. Check Cloudflare dashboard:
- Workers requests over past 24h within free tier.
- DO storage size reported.
- No errors in logs.
4. Update `README.md`:
- Tech Stack table: replace `Upstash Redis` row with `Cloudflare DO (SQLite)`.
- Architecture diagram: drop Upstash node, show DO contains canvas + cooldown.
- Setup section: drop `wrangler secret put UPSTASH_REDIS_*` lines.
5. Update `docs/system-architecture.md` with new component diagram, DO storage schema, request flow.
6. Write `docs/canvas-resize-procedure.md` (~30 lines max, KISS).
7. Tag a release: `v2.0.0-do-storage` (breaking change in deployment requirements).
8. 7-day check: revisit dashboard, confirm $0 bill.
## Success Criteria
- [ ] Production deploy successful, smoke test passes
- [ ] Manual broadcast test green (2-tab placement)
- [ ] CF dashboard shows zero Upstash-related env vars
- [ ] README + system-architecture reflect new architecture
- [ ] `docs/canvas-resize-procedure.md` exists and is testable (someone can follow it)
- [ ] Release tagged `v2.0.0-do-storage`
- [ ] 7-day observation: $0 bill, no error spike
## Risk Assessment
| Risk | Mitigation |
|---|---|
| Production WS regression unnoticed | Manual 2-tab test in smoke; consider scripted Playwright test in future |
| Docs drift from code over time | Resize procedure intentionally short and tied to constants file |
| Free tier breach after deploy under unexpected load | Existing `s-maxage=10` cache absorbs; CF dashboard alarm thresholds optional |
| Breaking change to deployment (no Upstash needed) | Major version bump (v2.0.0) signals it; release notes call it out |
@@ -1,87 +0,0 @@
---
title: "Migrate canvas storage from Upstash to Durable Object SQLite"
status: in-progress
priority: P2
created: 2026-05-09
phases: 5
source: brainstorm
brainstorm: ../reports/brainstorm-260509-2309-canvas-on-do-storage.md
researchReports:
- ../reports/researcher-260509-2255-forever-free-hosting.md
- ../reports/researcher-260509-2246-vercel-migration-feasibility.md
deployment:
productionUrl: https://rplace.miti99.workers.dev
migratedAt: 2026-05-10
postCleanupVersionId: 34cd1900-dd16-47f1-abae-8d195b076b0a
---
# Plan: Migrate canvas storage from Upstash → Durable Object SQLite
## Goal
Eliminate Upstash Redis dependency. Move canvas (16 MB) and per-user cooldown state into the existing `CanvasRoom` Durable Object via its SQLite-backed storage. Keep $0/month forever, hobby-scale (50 concurrent / <1 placement/sec). Resize must be config-change-only.
## Context
- Brainstorm: `plans/reports/brainstorm-260509-2309-canvas-on-do-storage.md` (approved)
- Storage research: `plans/reports/researcher-260509-2255-forever-free-hosting.md`
- Migration feasibility: `plans/reports/researcher-260509-2246-vercel-migration-feasibility.md`
## Architecture (locked)
```
Browser ──HTTP/WS──▶ Worker (Hono, thin proxy)
│
└─▶ CanvasRoom DO (idFromName('main'))
├── canvas_chunks (SQLite BLOB rows × CHUNK_COUNT)
├── cooldowns (user_id → expires_at, lazy GC)
└── WebSocket hibernation hub (broadcast)
```
Constants drive resize: `CHUNK_COUNT = ceil(CANVAS_WIDTH * CANVAS_HEIGHT / CHUNK_BYTES)`. Bump width/height → redeploy → DO lazy-inits missing chunks.
## Phases
| # | Phase | Status | Effort |
|---|---|---|---|
| 1 | [Storage Foundation](phase-01-storage-foundation.md) | completed | ~3h |
| 2 | [DO Integration & Worker Proxy](phase-02-do-integration-worker-proxy.md) | completed | ~4h |
| 3 | [One-Shot Upstash Migration](phase-03-one-shot-upstash-migration.md) | completed | ~2h |
| 4 | [Cleanup & Dependency Removal](phase-04-cleanup-dependency-removal.md) | completed | ~2h |
| 5 | [Deploy & Documentation](phase-05-deploy-documentation.md) | partial (deploy done, READMEs done, 7-day observation pending) | ~1h |
**Total estimate:** ~12h (1.5 working days)
**Status:** Phases 1–4 complete. Phase 5 done except 7-day post-deploy observation window.
**Production:** https://rplace.miti99.workers.dev — version 34cd1900, Upstash fully removed.
## Dependencies
Phase order is strict: 1 → 2 → 3 → 4 → 5. Each phase blocks the next.
## Success Criteria
- [ ] Zero `@upstash/redis` references in `src/`
- [ ] `package.json` no longer depends on `@upstash/redis` or `ioredis`
- [ ] `wrangler dev` runs locally without `UPSTASH_REDIS_*` env vars
- [ ] Existing pixels preserved through migration (verify a known coordinate)
- [ ] WS broadcast still functional end-to-end
- [ ] All tests pass (`npm run test:all`)
- [ ] Resize procedure documented in `docs/`
- [ ] Production deploy verified with smoke test
- [ ] $0 monthly bill confirmed after 7 days
## Risk Register
| Risk | Severity | Mitigation |
|---|---|---|
| Workers req/day at 87% of cap on peak day | Med | Keep `s-maxage=10` cache on `/api/canvas`; monitor via CF dashboard |
| Test rewrite scope creep | Low | Time-box testcontainers→DO test rewrite to 4h; cut e2e if needed |
| Migration corrupts canvas | High | Keep Upstash data 7 days post-migration as rollback |
| DO single-region latency regression | Low | Same as today's Upstash (single region) — no change |
| Storage exposure if canvas grows | Low | Per-DO 10 GB / per-account 5 GB on Free; current 16 MB has 300× headroom |
| BLOB row size cap (2 MB) | None | `CHUNK_BYTES = 64 KB` has 32× headroom from this limit |
| Per-DO 1,000 req/sec soft cap | None | Hobby load is ~1 req/sec; edge cache absorbs `/api/canvas` |
## Rollback Plan
Phases 1–4 are reversible until Phase 5 deploy. Keep Upstash creds and old code paths in git history for 30 days. If post-deploy issues, `git revert` + redeploy.
@@ -1,151 +0,0 @@
---
phase: 1
title: "DO storage atomicity & correctness"
status: completed
priority: P1
effort: "4h"
dependencies: []
---
# Phase 1: DO Storage Atomicity & Correctness
## Overview
Fix the four correctness bugs in the Durable Object storage path: non-atomic multi-chunk writes, BLOB-grow data loss after canvas resize, cooldown burned on storage failure, and orphan-row crash on `/api/canvas`. Sanitize the 500 response body. Replace the bypassable `content-length` cap.
## Context Links
- Reports: `plans/reports/code-reviewer-260510-0211-rplace-do-migration.md` (C3, H1, H2, H3, H5)
- Reports: `plans/reports/debugger-260510-0211-rplace-edge-cases.md` (C1, C2, M5)
## Key Insights
- "No `await` between statements" ≠ atomic. Each `sql.exec` auto-commits unless wrapped in `state.storage.transactionSync(...)`.
- `new Uint8Array(typedArray)` copies but preserves the source's length, so growing a short BLOB requires explicit re-allocation against `chunkSize(chunkId)`.
- Cooldown UPDATE must roll back if the subsequent write throws — otherwise transient storage flakes silently halve image-uploader throughput.
## Requirements
**Functional**
- Multi-chunk batch writes atomic: all chunks commit, or none do.
- After `CANVAS_WIDTH`/`CANVAS_HEIGHT` grow + redeploy, new pixels in the formerly-last chunk persist correctly.
- Cooldown row reverted when `writePixels` throws.
- `GET /api/canvas` returns 200 even with orphan `chunk_id ≥ CHUNK_COUNT` rows present (post-shrink).
- `POST /api/place` rejects requests with missing or zero `content-length` (or reads body with hard byte cap).
- 500 responses do not echo raw error message.
**Non-functional**
- No regression in happy-path latency (single-chunk batch).
- Static assertion `CHUNK_BYTES <= 2_000_000` near constants to prevent future BLOB-cell overflow.
## Architecture
```
worker.js (edge)
├─ guard: content-length present AND > 0 AND ≤ MAX_BODY_BYTES (else 413)
└─ forwards to DO
CanvasRoom.placePixels (canvas-room.js)
├─ tryAcquire(userId) → reserves cooldown
├─ TRY:
│ state.storage.transactionSync(() => writePixels(sql, edits))
│ broadcast(edits)
└─ CATCH:
sql.exec('DELETE FROM cooldowns WHERE user_id = ?', userId) // rollback
return Response.json({ error: 'storage_failed' }, { status: 500 })
// no message field; full err logged separately
```
`chunk-storage.writePixels` rewritten so `next` is sized to `chunkSize(chunkId)`, with `buf.subarray(0, min(buf.length, expected))` copied into it.
`chunk-storage.readAllChunks` filters `WHERE chunk_id < ?` bound to `CHUNK_COUNT`.
## Related Code Files
**Modify**
- `src/worker.js` — content-length validator (require > 0 OR switch to bounded `arrayBuffer`)
- `src/durable-objects/canvas-room.js` — wrap write in transactionSync, rollback cooldown on error, drop `message` from 500
- `src/durable-objects/lib/chunk-storage.js` — fix `writePixels` BLOB-grow, bound `readAllChunks`, replace `new Uint8Array(buf)` aliasing comment with `.slice()` or explicit allocation
- `src/lib/constants.js` — add static assertion for `CHUNK_BYTES`
- `src/durable-objects/canvas-room.js:119` — fix the inverted "pre-2026-04-07" comment (M2 in code-review)
**Create** — none
**Delete** — none
## Implementation Steps
1. **`chunk-storage.writePixels` BLOB-grow fix** (review C3)
- In the per-chunk loop, compute `expected = chunkSize(chunkId)`.
- `const next = new Uint8Array(expected); next.set(buf.subarray(0, Math.min(buf.length, expected)));`
- Update inline comment: "size against chunkSize, never trust persisted blob length".
2. **`chunk-storage.writePixels` atomicity** (debugger C2)
- Wrap the entire `for (const [chunkId, edits] of grouped)` block in `state.storage.transactionSync(() => { ... })`.
- Verify `transactionSync` exists at compat date `2025-04-01`; if not, fall back to async `state.storage.transaction(async () => {...})` and propagate rejection.
- Update the comment at L86-87 to describe the transactional invariant, not "no await".
3. **`chunk-storage.readAllChunks` orphan guard** (review H3)
- Change SQL to `SELECT chunk_id, bytes FROM canvas_chunks WHERE chunk_id < ?` bound to `CHUNK_COUNT`.
- Optional secondary defense: skip rows where `chunkId * CHUNK_BYTES + view.length > out.length`.
4. **`canvas-room.placePixels` cooldown rollback** (review H1, debugger C1)
- Add try/catch around the `writePixels` call.
- In catch: `sql.exec('DELETE FROM cooldowns WHERE user_id = ?', userId)`. Rethrow or return 500.
- Add a one-line comment: `// refund cooldown so transient storage errors don't soft-DOS user`.
5. **500 response sanitization** (review H2)
- Drop `message: String(err)` from the response JSON.
- Keep the `console.error` call so debugging info stays in logs.
6. **`worker.js` content-length guard** (review H5)
- Require `content-length` header present AND > 0 AND ≤ `MAX_BODY_BYTES`. Reject with 411 (length required) or 413 (too large).
- Alternative: read raw body via `c.req.arrayBuffer()`, check `byteLength`, then `JSON.parse(decoder.decode(buf))`.
7. **Static `CHUNK_BYTES` cap assertion** (debugger M5)
- At top of `constants.js` (or in a small init): `if (CHUNK_BYTES > 2_000_000) throw new Error('CHUNK_BYTES exceeds DO SQLite cell limit');`
- Document compat-date-versioned cell limit in the comment.
8. **WS-close comment correction** (review M2)
- `canvas-room.js:119` — replace misleading comment with: `// Required because compatibility_date 2025-04-01 predates the 2026-04-07 default-close cutoff. Remove if/when wrangler.json bumps past that date.`
- Add try/catch around the `ws.close(...)` call to handle already-closed sockets (debugger M7).
9. **Compile + smoke**
- `npm run build` — must pass.
- Local `wrangler dev`, hit `POST /api/place` with a 2-pixel batch spanning 2 chunks; verify success.
- Force a write error (e.g. temporarily throw inside `writePixels`); verify cooldown row deleted, 500 returned without raw message.
- Hit `GET /api/canvas` after manually inserting an orphan row; verify 200.
## Todo List
- [ ] Implement BLOB-grow fix in `chunk-storage.writePixels`
- [ ] Wrap multi-chunk write in `transactionSync` (or async fallback)
- [ ] Bound `readAllChunks` query by `chunk_id < CHUNK_COUNT`
- [ ] Add cooldown rollback in `canvas-room.placePixels` catch path
- [ ] Drop `message` from 500 response
- [ ] Replace `content-length` guard with present+nonzero check or bounded body read
- [ ] Add `CHUNK_BYTES <= 2_000_000` static assertion
- [ ] Fix inverted WS-close comment + add try/catch
- [ ] `npm run build` passes
- [ ] Manual smoke: 2-chunk batch success
- [ ] Manual smoke: forced write error → cooldown refunded, no message leak
- [ ] Manual smoke: orphan row → `/api/canvas` 200
## Success Criteria
- [ ] No regressions in existing 94-test suite
- [ ] `transactionSync` (or async transaction) confirmed available at compat date 2025-04-01
- [ ] All bullets in Todo List checked
- [ ] PR includes file:line evidence linking each change to the originating finding
## Risk Assessment
- **Risk:** `transactionSync` not available at our compat date → fix back-compat.
**Mitigation:** Verify via `wrangler` runtime docs first; fall back to `state.storage.transaction(async () => {...})` and ensure `placePixels` is awaited end-to-end.
- **Risk:** Cooldown rollback DELETE race against concurrent insert from same user.
**Mitigation:** Single-DO is single-threaded per request; rollback runs synchronously before response. No lock needed.
- **Risk:** Bounded body read changes Hono request-handling shape; could break the existing tests.
**Mitigation:** Keep `c.req.json()` if guard is sufficient; only refactor to `arrayBuffer()` if guard alone leaves a gap.
## Security Considerations
- 500 sanitization closes a low-severity info-leak.
- content-length guard removes a DOS-amplification vector against the JSON parser.
- Atomicity fix prevents broadcast/persistence divergence that could be probed for state inference.
@@ -1,153 +0,0 @@
---
phase: 2
title: "Cookie+IP identity & broadcast sequence numbers"
status: completed
priority: P1
effort: "3h"
dependencies: []
---
# Phase 2: Cookie+IP Identity & Broadcast Sequence Numbers
## Overview
Replace IP-only rate-limit identity with cookie-preferred / IP-fallback to unblock NAT/CGNAT/mobile users. Add monotonically-increasing sequence numbers to WebSocket broadcast frames so clients can detect missed pixels and refetch.
## Context Links
- Reports: `plans/reports/debugger-260510-0211-rplace-edge-cases.md` (C3 NAT, H2 dev bucket, H3 hibernation gap)
- Reports: `plans/reports/code-reviewer-260510-0211-rplace-do-migration.md` (M4 dev bucket)
## Key Insights
- Cookie identity isn't a security boundary — it's a usability fix to break NAT collisions. Trivially defeated by clearing cookies; that's acceptable scope.
- Broadcast sequence numbers don't require persistence: a per-DO instance counter works because reconnects after hibernation refetch the canvas anyway.
- `cf-connecting-ip` missing in production is an alarm condition, not a fallback path. Fail-closed in prod, soft-fall in dev.
## Requirements
**Functional**
- First request without `rplace_id` cookie: server issues `Set-Cookie: rplace_id=<uuid>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=31536000`.
- Subsequent requests with cookie: identity = `cookie:<uuid>`. Without cookie but with valid `cf-connecting-ip`: identity = `ip:<ip>`. Neither in prod: 500 with `{ error: "no_identity" }`. Neither in dev: identity = `dev:<random-per-process>`.
- WS broadcast frames carry monotonic `seq` field (uint32, wraps at 2^32).
- Client tracks last-seen `seq`; on gap (`seq != lastSeq + 1`) triggers `refetchCanvas()`.
**Non-functional**
- Cookie issuance adds zero latency (Set-Cookie on existing 200 response).
- No new dependencies.
## Architecture
```
src/lib/get-user-id.js
resolveIdentity(request, env):
if (cookie 'rplace_id' present) → "cookie:" + uuid
else if (cf-connecting-ip present) → "ip:" + sha256(ip).slice(0,16)
else if (env is production) → throw NoIdentityError → caller maps to 500
else → "dev:<isolate-stable-uuid>"
needsCookieIssue(request) → true if no rplace_id cookie
src/worker.js
/api/canvas handler:
if needsCookieIssue → response.headers.append('Set-Cookie', issueCookie(uuid))
src/durable-objects/canvas-room.js
state #seq = 0 (in-memory, resets on hibernation rehydrate)
broadcast(edits):
#seq = (#seq + 1) >>> 0
payload = { type: 'pixels', seq: #seq, edits }
src/client/App.svelte
ws.onmessage:
if (msg.seq != null) {
if (lastSeq != null && msg.seq !== ((lastSeq + 1) >>> 0)) {
refetchCanvas() // gap detected
}
lastSeq = msg.seq
}
```
## Related Code Files
**Modify**
- `src/lib/get-user-id.js` — cookie-first resolution + production fail-closed
- `src/worker.js` — issue cookie on `/api/canvas` when missing; pass identity into DO
- `src/durable-objects/canvas-room.js` — instance `#seq` counter; include in broadcast frames
- `src/client/App.svelte` — track `lastSeq`, refetch on gap
- `src/client/components/CanvasRenderer.svelte` — accept `seq`-aware events if needed (likely not)
**Create**
- `src/lib/cookie.js` — minimal helpers: `parseCookie(header)`, `formatSetCookie(name, value, opts)`. ~30 lines, kebab-case per code standards.
## Implementation Steps
1. **Cookie helpers** (`src/lib/cookie.js`)
- `parseCookie(header) → Map<name, value>` — handle missing header, malformed pairs.
- `formatSetCookie(name, value, { httpOnly, secure, sameSite, path, maxAge })` → string.
- Export both as named exports.
- Add tests in `test/lib/cookie.test.js` (Phase 4).
2. **Identity resolution rewrite** (`get-user-id.js`)
- Import `parseCookie`.
- `resolveIdentity(request, env)` returns `{ id, issueCookie?: { name, value } }`.
- Production path: if no cookie AND no `cf-connecting-ip` → throw `NoIdentityError`.
- Dev path: stable `dev:<uuid>` per-isolate (cache module-level).
- When identity comes from `ip:`, mark `issueCookie = { name: 'rplace_id', value: crypto.randomUUID() }` so the worker can attach Set-Cookie even before the user has one.
3. **Worker integration** (`worker.js`)
- On `/api/canvas` and `/api/place`: call `resolveIdentity(c.req.raw, c.env)`. If it throws `NoIdentityError`, return 500 `{ error: "no_identity" }`.
- Pass `id` into the DO body / header.
- On `/api/canvas` only: if `issueCookie` is set, append `Set-Cookie` header to the response.
- On `/api/place`: don't issue cookies (POST shouldn't mutate cookies in this design).
4. **DO sequence counter** (`canvas-room.js`)
- Add `#seq = 0` private field.
- In `#broadcastPixels(edits)`: `this.#seq = (this.#seq + 1) >>> 0; const message = JSON.stringify({ type: 'pixels', seq: this.#seq, edits });`
- Note: counter resets on DO hibernation rehydrate; that's fine — clients refetch on reconnect.
5. **Client gap detection** (`App.svelte`)
- Track `let lastSeq = null;`.
- In WS message handler: if `msg.seq != null && lastSeq != null && msg.seq !== ((lastSeq + 1) >>> 0)` → call `canvasRenderer.refetchCanvas()`. Always update `lastSeq = msg.seq`.
- On reconnect: reset `lastSeq = null` (it's already a fresh canvas fetch).
6. **Compile + smoke**
- `npm run build` passes.
- Cookie issuance: open `wrangler dev`, GET `/api/canvas`, confirm `Set-Cookie` present. Subsequent request shows cookie.
- Identity: 2 tabs, 1 cookie each → independent rate limits even on same IP.
- Sequence gap: temporarily log `seq` on client; place pixel, confirm seq increments.
## Todo List
- [ ] Create `src/lib/cookie.js` (parseCookie + formatSetCookie)
- [ ] Refactor `src/lib/get-user-id.js` to cookie-first / IP-fallback / fail-closed
- [ ] Worker issues `Set-Cookie` on `/api/canvas` when missing
- [ ] Worker forwards resolved identity to DO
- [ ] DO maintains `#seq` counter; broadcast includes `seq`
- [ ] Client tracks `lastSeq`, refetches on gap
- [ ] Client resets `lastSeq` on reconnect
- [ ] `npm run build` passes
- [ ] Manual smoke: cookie issued, identity changes per cookie
- [ ] Manual smoke: seq gap → client refetches
## Success Criteria
- [ ] Two browsers on the same IP can place pixels independently within their own 1Hz cooldown.
- [ ] In production env (`env.ENVIRONMENT === 'production'`), missing both cookie and `cf-connecting-ip` returns 500 with `no_identity`.
- [ ] Forced WS gap (drop a frame in dev console) triggers `refetchCanvas`.
- [ ] No regression in existing tests.
## Risk Assessment
- **Risk:** Browser cookie blocking (private mode, strict tracker prevention) → falls back to IP, NAT users still collide.
**Mitigation:** Acceptable per Q&A scope; document. Future plan can add HMAC-signed token for stricter envs.
- **Risk:** Cookie collision if two users share a stolen cookie value (e.g., copy-paste curl).
**Mitigation:** Cookies are HttpOnly — not exfiltrable from JS. The opaque UUID is high-entropy.
- **Risk:** Seq counter wraparound after 2^32 broadcasts.
**Mitigation:** ~136 years at 1 broadcast/sec. Wrap is deliberately handled via `>>> 0` so wrap doesn't trigger false gap.
- **Risk:** Production fail-closed behind a misconfigured proxy denies all traffic.
**Mitigation:** Add a `wrangler tail` check before deploy; if 500s spike, hotfix to fall back to a synthetic identity.
## Security Considerations
- Cookie is `HttpOnly`, `Secure`, `SameSite=Lax` — standard hygiene.
- Cookie is opaque (UUID v4) — no PII, no prediction.
- Identity is used only for rate-limiting; no privilege gating, so cookie theft doesn't escalate.
- No CSRF concern — POST `/api/place` doesn't depend on cookie identity for authorization, only for rate-limit bucketing. (Worth confirming in PR review.)
@@ -1,153 +0,0 @@
---
phase: 3
title: "WebSocket hardening & client race fix"
status: completed
priority: P1
effort: "3h"
dependencies: []
---
# Phase 3: WebSocket Hardening & Client Race Fix
## Overview
Fix the WebSocket-during-initial-fetch race that silently drops pixels. Add Origin allowlist + per-identity connection cap on WS upgrade. Add a minimal heartbeat so dead connections fire `onclose` promptly.
## Context Links
- Reports: `plans/reports/code-reviewer-260510-0211-rplace-do-migration.md` (C2 race, H4 conn cap, M3 heartbeat)
- Reports: `plans/reports/debugger-260510-0211-rplace-edge-cases.md` (H5 origin/amplification, L6 conn cap, M3 heartbeat — duplicate)
## Key Insights
- The pre-allocated `committedColors` zero array gets overwritten by the post-fetch `new Uint8Array(indices)` — any WS edits between WS-open and fetch-resolve are lost. Fix: buffer WS edits, replay after replacement.
- CF DO `state.acceptWebSocket(ws, [tag1, tag2])` lets `getWebSockets(tag)` filter — perfect for per-identity caps.
- Hibernation API may auto-ping at TCP level, but app-level heartbeat is cheaper insurance and gives clients a way to detect zombies.
## Requirements
**Functional**
- WS messages received during the initial canvas fetch are applied (not dropped) once the fetch resolves.
- WS upgrade rejected (403) if `Origin` header is present and not in allowlist.
- WS upgrade rejected (429) if the requesting identity already has ≥ N (default 5) live sockets.
- Server accepts `ping` text message and responds `pong`. Client sends ping every 30s; if no pong in 60s, closes WS to trigger reconnect.
**Non-functional**
- Allowlist configurable via `wrangler.json` env vars (`ALLOWED_ORIGINS`, comma-separated).
- Conn cap configurable (`MAX_WS_PER_IDENTITY`, default 5).
## Architecture
```
Worker /api/ws
├─ resolve identity (Phase 2 helper)
├─ origin check: if Origin present AND not in env.ALLOWED_ORIGINS → 403
└─ forward to DO with identity in header
CanvasRoom #handleWsUpgrade
├─ existing = state.getWebSockets(identity)
├─ if existing.length >= MAX_WS_PER_IDENTITY → 429
├─ state.acceptWebSocket(server, [identity])
└─ return 101
CanvasRoom webSocketMessage(ws, msg)
├─ if msg === 'ping' → ws.send('pong'); return
└─ else → ws.close(1003, 'unsupported message')
Client (CanvasRenderer.svelte loadCanvas)
let pendingWsEdits = [];
ws.onmessage during fetch → push to pendingWsEdits (don't apply)
fetch resolves:
committedColors = new Uint8Array(indices);
apply pendingWsEdits to committedColors
pendingWsEdits = null
flag "live mode" — onmessage now applies directly
Client (App.svelte)
setInterval(() => ws.send('ping'), 30_000)
trackPongTimer; if no pong in 60s → ws.close()
```
## Related Code Files
**Modify**
- `src/worker.js` — origin check before WS upgrade forwarding
- `src/durable-objects/canvas-room.js` — per-identity conn cap, ping handling, tag-aware acceptWebSocket
- `src/client/components/CanvasRenderer.svelte` — buffer-and-replay during initial fetch (around lines 465–484)
- `src/client/App.svelte` — ping interval, pong watchdog
- `src/lib/constants.js` — add `MAX_WS_PER_IDENTITY = 5`
- `wrangler.json` — add `vars: { ALLOWED_ORIGINS: "https://rplace.miti99.workers.dev" }`
**Create** — none
## Implementation Steps
1. **Origin allowlist in worker** (debugger H5)
- Read `env.ALLOWED_ORIGINS` (comma-separated). Parse to Set at module top.
- In `/api/ws` handler: if `Origin` header present and not in allowlist, return `c.text('forbidden_origin', 403)`. Empty allowlist → allow all (dev default).
- Document in `wrangler.json` comment.
2. **Per-identity WS cap in DO** (review H4, debugger L6)
- In `#handleWsUpgrade(request, identity)`: `const existing = this.state.getWebSockets(identity);`
- If `existing.length >= MAX_WS_PER_IDENTITY` → return `new Response('too_many_sockets', { status: 429 })`.
- Replace `state.acceptWebSocket(server)` with `state.acceptWebSocket(server, [identity])`.
3. **Server-side heartbeat** (review M3, debugger M3)
- In `webSocketMessage(ws, message)`: if `message === 'ping'` → `ws.send('pong'); return;`. Else keep current close behavior.
- Note: this works under hibernation because messages auto-rehydrate the DO.
4. **Client buffer-and-replay** (review C2)
- In `loadCanvas` (`CanvasRenderer.svelte:465-484`):
- Add `let pendingWsEdits = [];` and `let isLive = false;` at top of `loadCanvas`.
- Expose `pushWsEdit(edit)` from the component: if `!isLive` → `pendingWsEdits.push(edit)`; else apply directly.
- After fetch resolves and `committedColors = new Uint8Array(indices)`, replay: for each `edit` in `pendingWsEdits`, write to `committedColors[edit.idx] = edit.color` AND update `imageData`. Then `isLive = true; pendingWsEdits = null;`.
- In parent (`App.svelte`), route ws.onmessage pixel events to `canvasRenderer.pushWsEdit(...)` instead of applying directly when first connect.
5. **Client heartbeat** (review M3)
- In `App.svelte` WS open handler: start `setInterval(() => ws.readyState === 1 && ws.send('ping'), 30_000)`. Track `lastPongAt = Date.now()`.
- On message `'pong'`: `lastPongAt = Date.now()`.
- Watchdog: if `Date.now() - lastPongAt > 60_000` → `ws.close()` to trigger reconnect logic.
- Clear interval/watchdog on `onclose`.
6. **Compile + smoke**
- `npm run build` passes.
- Open dev console, throttle network to "Slow 3G", reload, place pixel from a second tab during fetch, confirm pixel appears in tab 1 once fetch completes.
- Try opening 6 WS connections from same browser → 6th gets 429.
- Try opening WS from a different origin (curl with `Origin: https://evil.example`) → 403.
- Confirm `ping`/`pong` round-trips in dev console.
## Todo List
- [ ] Origin allowlist parsing + worker check
- [ ] Per-identity WS cap (`MAX_WS_PER_IDENTITY`) with `acceptWebSocket(server, [identity])`
- [ ] Server `ping` handler returns `pong`
- [ ] `wrangler.json` `vars.ALLOWED_ORIGINS`
- [ ] Client buffer-and-replay for WS during initial fetch
- [ ] Client 30s ping / 60s pong watchdog
- [ ] `npm run build` passes
- [ ] Manual smoke: race-fix verified by slow network reload + remote pixel placement
- [ ] Manual smoke: 6th WS rejected with 429
- [ ] Manual smoke: foreign-origin WS rejected with 403
- [ ] Manual smoke: ping/pong visible in dev tools
## Success Criteria
- [ ] No pixel placed during the initial-fetch window is dropped (verified via instrumented log).
- [ ] WS upgrade from disallowed origin returns 403 in production.
- [ ] Per-identity cap enforced; logs show `too_many_sockets` when triggered.
- [ ] `onclose` fires within ~60s of network drop (verified via airplane-mode toggle).
- [ ] No regression in 94-test suite.
## Risk Assessment
- **Risk:** Origin allowlist set too tight → legitimate clients (preview deployments, custom domains) get 403.
**Mitigation:** Empty `ALLOWED_ORIGINS` allows all — start with empty in dev/preview, populate before production deploy.
- **Risk:** Cap on identity blocks tab-power-users (5 tabs is normal for some folks).
**Mitigation:** Cap is configurable; bump to 10 if support tickets appear.
- **Risk:** Heartbeat interval too aggressive → battery drain on mobile.
**Mitigation:** 30s ping is well below the typical mobile-radio-wakeup penalty; keeping interval >= 25s avoids extra wakes.
- **Risk:** Buffer-and-replay logic interacts oddly with the existing `imageData` invalidation in CanvasRenderer.
**Mitigation:** Replay loop must call the same path the live message handler does (write to both `committedColors` AND `imageData`); add a unit test in Phase 4.
## Security Considerations
- Origin check is a usability/cost barrier, not a security one — WS protocol allows non-browser clients to spoof Origin. Real defense is the per-identity cap + Worker request budget.
- Per-identity cap prevents broadcast amplification (debugger H5, L6).
- Heartbeat surface is a single text-equality check; no parser exposure.
@@ -1,161 +0,0 @@
---
phase: 4
title: "Full DO surface test coverage"
status: completed
priority: P2
effort: "6h"
dependencies: [1, 2, 3]
---
# Phase 4: Full DO Surface Test Coverage
## Overview
Close the test gap: today only `test/worker-validation.test.js` exists. The DO surface (chunk-storage, cooldown-store, WS hub, identity resolution) has zero unit tests. Add a vitest harness that runs against `wrangler unstable_dev` so DO state is real, not mocked.
## Context Links
- Reports: `plans/reports/code-reviewer-260510-0211-rplace-do-migration.md` (positive observations re tests; gap implicit)
- Reports: `plans/reports/debugger-260510-0211-rplace-edge-cases.md` (L7 — explicit DO test gap)
## Key Insights
- Mocked DO unit tests (current style) won't catch the bugs Phase 1–3 fix. Need real DO state.
- `wrangler unstable_dev` lets vitest hit a live local Worker + DO via fetch. Heavier but accurate.
- For pure-function modules (`pixel-buffer`, `chunk-storage` math), keep classic vitest unit tests — fast loop.
- The 200-line file rule applies to test files too; split per concern (one test file per source file).
## Requirements
**Functional**
- Tests cover: write atomicity, BLOB-grow, orphan-row read, cooldown refund, cooldown TTL, WS broadcast frame format with `seq`, WS per-identity cap, ping/pong, identity cookie/IP/dev fallback, gap detection.
- All tests pass on `npm test`.
**Non-functional**
- Total test runtime under 30s on a dev machine.
- No hard-coded sleeps > 100ms; use polling helpers where needed.
- Tests are deterministic — no flakes when run 10× consecutively.
## Architecture
```
test/
├── lib/
│ ├── cookie.test.js # NEW — parseCookie/formatSetCookie
│ ├── get-user-id.test.js # EXTEND — cookie/ip/dev fallback paths
│ ├── pixel-buffer.test.js # EXISTING — keep
│ └── ... (existing image-* tests stay)
├── durable-objects/
│ ├── chunk-storage.test.js # NEW — pure-function unit tests via mocked sql
│ ├── cooldown-store.test.js # NEW — TTL math, GC sample, race
│ └── canvas-room.integration.test.js # NEW — wrangler unstable_dev
├── worker-validation.test.js # EXISTING — keep
└── helpers/
└── do-harness.js # NEW — boot wrangler unstable_dev once per file
```
`do-harness.js` exports `setupDO()` returning `{ worker, fetch, close }`. Suite uses `beforeAll` / `afterAll` to share the harness.
## Related Code Files
**Create**
- `test/helpers/do-harness.js`
- `test/lib/cookie.test.js`
- `test/durable-objects/chunk-storage.test.js`
- `test/durable-objects/cooldown-store.test.js`
- `test/durable-objects/canvas-room.integration.test.js`
**Modify**
- `test/lib/get-user-id.test.js` — extend for new resolution logic
- `vitest.config.js` — add `testTimeout: 30_000`, include `test/durable-objects/**/*`
- `package.json` — no new deps; `wrangler` is already a devDependency
**Delete** — none
## Implementation Steps
### Pure-function tests
1. **`test/lib/cookie.test.js`**
- `parseCookie('a=1; b=2')` → Map `{a:'1', b:'2'}`
- `parseCookie('')` → empty Map
- `parseCookie(undefined)` → empty Map
- Malformed: `parseCookie('a; b=2')` → Map `{b:'2'}`
- `formatSetCookie('rplace_id', 'uuid', { httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 31536000, path: '/' })` → exact string match.
2. **`test/lib/get-user-id.test.js`** (extend)
- Cookie present → `cookie:<uuid>`
- No cookie, IP present → `ip:<hash>`
- Production env, neither → throws `NoIdentityError`
- Dev env, neither → `dev:*`
- Cookie + IP both present → cookie wins
- Cookie issuance flag set when caller should `Set-Cookie`
3. **`test/durable-objects/chunk-storage.test.js`** (mock sql)
- Build a fake `sql` with an in-memory `Map<chunkId, Uint8Array>` backing `INSERT OR REPLACE` and `SELECT`.
- `writePixels` single chunk: write 3 pixels, read back identical bytes.
- `writePixels` 2-chunk batch: pixels at offset 0 and offset 65535+1, both persist.
- `writePixels` BLOB-grow: pre-seed last chunk with a short blob (8KB), grow via fake `chunkSize` returning 64KB, write a pixel at byte 30000, read back exactly 64KB blob with byte 30000 set.
- `readAllChunks` orphan: pre-seed `chunk_id = 999`, `CHUNK_COUNT = 256`, read returns valid `Uint8Array(TOTAL_PIXELS)` without throwing.
- `chunkSize(255)` for current dims = 65536; `chunkSize(0..254)` = 65536.
- Test the bound query: orphan row not selected.
4. **`test/durable-objects/cooldown-store.test.js`** (mock sql)
- `tryAcquire(userId, now)` first call returns `{ ok: true }`.
- Second call within 1s returns `{ ok: false, retryAfter: <ms remaining> }`.
- Second call after 1s returns `{ ok: true }`.
- GC sample with deterministic RNG: pre-seed expired rows, run `tryAcquire` with mocked `Math.random()` returning 0 (always GC) → expired rows deleted.
- INSERT cursor symmetry: ensure both branches drain (after fix in Phase 1's M1).
### Integration tests (real DO)
5. **`test/helpers/do-harness.js`**
- `import { unstable_dev } from 'wrangler'`
- `export async function setupDO() { const worker = await unstable_dev('src/worker.js', { config: 'wrangler.json', experimental: { disableExperimentalWarning: true } }); return { worker, fetch: worker.fetch.bind(worker), close: () => worker.stop() }; }`
- Add helper `placePixel(worker, { x, y, color, cookie? })` returning `{ status, body, setCookie }`.
6. **`test/durable-objects/canvas-room.integration.test.js`**
- **Cookie issuance:** GET `/api/canvas` without cookie → response has `Set-Cookie: rplace_id=...`. Subsequent GET with cookie → no new Set-Cookie.
- **Cooldown isolation:** two cookies, same simulated IP → both place pixels in the same second.
- **Cooldown refund on error:** force a write error path (test-only env flag throws inside `writePixels`) → cooldown row deleted, second attempt succeeds immediately.
- **Multi-chunk atomicity:** force-error mid-batch on second chunk → `GET /api/canvas` shows none of the batch applied, broadcast not fired.
- **Orphan row:** seed orphan via DO debug endpoint (test-only) → `GET /api/canvas` returns 200.
- **content-length=0 rejected:** POST `/api/place` with `Content-Length: 0` → 411 or 413.
- **WS broadcast carries seq:** open WS, place a pixel, receive frame with `{ type:'pixels', seq:1, edits:[...] }`. Place another → `seq:2`.
- **WS per-identity cap:** open 5 WS with same cookie → all accepted. 6th → 429 / connection-close.
- **WS ping/pong:** send `'ping'`, receive `'pong'`.
- **WS gap → client refetch:** simulate dropped frame by manually skipping a `seq`; client-side gap detection is a unit-test concern (covered in `test/lib/seq-gap.test.js` if extracted; otherwise verify protocol shape only).
- **Origin allowlist:** WS upgrade with `Origin: https://evil.example` → 403; with no Origin → allowed.
## Todo List
- [ ] Decide test-only env flag mechanism (e.g., `env.TEST_FORCE_WRITE_ERROR === '1'`) and add gated branches
- [ ] Write `test/helpers/do-harness.js`
- [ ] Write `test/lib/cookie.test.js`
- [ ] Extend `test/lib/get-user-id.test.js`
- [ ] Write `test/durable-objects/chunk-storage.test.js`
- [ ] Write `test/durable-objects/cooldown-store.test.js`
- [ ] Write `test/durable-objects/canvas-room.integration.test.js`
- [ ] Update `vitest.config.js` (timeout + include patterns)
- [ ] `npm test` — all green
- [ ] Run 10× back-to-back; no flakes
## Success Criteria
- [ ] All Phase 1–3 fixes have at least one test that fails on the pre-fix code and passes after.
- [ ] Coverage report shows DO source files at ≥ 80% line coverage.
- [ ] Total test runtime under 30s on dev hardware.
- [ ] CI (if configured) passes; otherwise local 10× green.
## Risk Assessment
- **Risk:** `wrangler unstable_dev` is not stable across versions; tests may break on upgrade.
**Mitigation:** Pin `wrangler` version in `package.json` (already pinned to `^4.14.1`); update tests when intentionally bumping.
- **Risk:** Test-only env flags leak into production.
**Mitigation:** Gate behind `env.NODE_ENV === 'test'` AND require an explicit `env.TEST_HOOKS === 'enabled'` second flag. Document; reject in deploy script.
- **Risk:** Integration tests slow down dev loop, devs skip them.
**Mitigation:** Add `npm run test:unit` (excludes `**/integration.*`) for fast inner loop.
- **Risk:** WS testing in vitest is awkward (`unstable_dev` returns Worker, not Server).
**Mitigation:** Use `worker.fetch` for upgrade and treat the returned `WebSocket` directly. If tooling doesn't allow that, fall back to spawning `wrangler dev` in a child process.
## Security Considerations
- Test-only hooks must never ship to production. Add a build-time assertion (e.g., a fail-fast check at worker startup if `TEST_HOOKS` is enabled in production env).
- Tests must not commit any secrets — use `crypto.randomUUID()` for ephemeral test cookies.
@@ -1,121 +0,0 @@
---
phase: 5
title: "Docs cleanup & legacy plan archival"
status: completed
priority: P2
effort: "30m"
dependencies: []
---
# Phase 5: Docs Cleanup & Legacy Plan Archival
## Overview
Strike stale Upstash references from `README.md` and `.env.example`. Verify the `docs/` directory matches current code (the docs-manager and code-reviewer reports disagree — verify file by file). Mark the migration plan as `completed` after the 7-day rollback observation window.
## Context Links
- Reports: `plans/reports/docs-manager-260510-0211-rplace-docs-drift.md` (primary)
- Reports: `plans/reports/code-reviewer-260510-0211-rplace-do-migration.md` (C1 — claims docs/*.md still mention Upstash; conflicts with docs-manager finding)
## Key Insights
- Docs-manager said `docs/system-architecture.md`, `deployment-guide.md`, `code-standards.md` are accurate. Code-reviewer C1 said they still reference Upstash. Verify directly before deciding.
- Migration endpoint already removed from worker; documenting it as "transitional" misleads onboarding.
- `.env.example` placeholders for deleted services cause new-developer confusion.
## Requirements
**Functional**
- README's Project Structure block lists only files that exist in `src/`.
- README does not advertise `/admin/migrate-from-upstash` as transitional.
- `.env.example` either deleted or replaced with a comment explaining no external secrets.
- All `docs/*.md` files verified accurate; any Upstash references that describe current state (not history) removed.
- `plans/260509-2309-canvas-on-do-storage/plan.md` frontmatter `status: completed` (after 2026-05-17, the rollback window cutoff).
**Non-functional**
- Edits target only the lines in question; no unrelated reformatting.
- Git history preserves clear "docs:" commit type per `.claude` convention (drop `docs:` from `.claude/` paths only — not relevant here).
## Architecture
N/A — text edits.
## Related Code Files
**Modify**
- `README.md` — Project Structure tree (lines 79–111), API section (lines 146–151)
- `.env.example` — content
- `docs/system-architecture.md` — only if verification finds Upstash-as-current text
- `docs/deployment-guide.md` — only if verification finds Upstash-as-current text
- `docs/code-standards.md` — only if verification finds Upstash-as-current text
- `plans/260509-2309-canvas-on-do-storage/plan.md` — frontmatter `status` field (after rollback window)
**Create** — none
**Delete** — possibly `.env.example` (Option A in docs-manager report)
## Implementation Steps
1. **Verify docs/ accuracy**
- `grep -ni 'upstash\|redis\|@upstash' docs/*.md`
- For each match: read context, classify as (a) historical mention OK, (b) presented as current — needs edit.
- Decide whether to keep migration narrative as historical or scrub entirely.
2. **README.md Project Structure** (docs-manager STALE finding 1)
- Replace lines 79–111 with the corrected tree from docs-manager report (no `src/admin/`, no legacy lib files; include image pipeline files).
- Use the actual `find src -type f` output as ground truth.
3. **README.md API Section** (docs-manager MISLEADING finding 2)
- Delete the `### POST /admin/migrate-from-upstash (transitional)` block (lines 146–151).
- Verify no other doc references this section.
4. **.env.example** (docs-manager STALE finding 3)
- Choose Option B (keep as documentation): replace contents with a single comment block:
```
# No external secrets required.
# Canvas + cooldown state live inside CanvasRoom Durable Object (SQLite).
# All configuration is in src/lib/constants.js.
```
- Rationale: discoverability. New devs find `.env.example` and learn there's nothing to set.
5. **docs/ targeted edits** (only if step 1 found drift)
- Edit each flagged section. Keep migration mentioned in `deployment-guide.md` "Optional One-Shot Migration from Upstash" as historical — do not delete the narrative if it's already framed as past.
6. **Migration plan archival** (docs-manager finding 4)
- Today is 2026-05-10; rollback window per migration-plan ends ~2026-05-17.
- Add a TODO note in this phase saying "after 2026-05-17, change `plans/260509-2309-canvas-on-do-storage/plan.md` frontmatter `status` to `completed`".
- For now (before 2026-05-17), leave `status: in-progress` BUT add `deployment.cleanupAt: 2026-05-10` (already present per existing frontmatter) and verify it's accurate.
7. **Sanity sweep**
- `grep -rni 'upstash\|@upstash\|redis-client\|rate-limiter\.js\|migrate-from-upstash' README.md docs/ .env.example src/` — should return only intentional historical refs.
- `npm run build` — must still pass (sanity check that a doc edit didn't break a `<script src>` reference or similar).
## Todo List
- [ ] Run grep sweep on `docs/` for upstash/redis terms
- [ ] Classify each match (historical vs current-as-of-today)
- [ ] Edit README.md Project Structure tree
- [ ] Delete README.md `/admin/migrate-from-upstash` API section
- [ ] Rewrite `.env.example` with documentation comment
- [ ] Apply targeted edits in `docs/*.md` if step 1 found current-as-of-today refs
- [ ] Add reminder note (this phase) to flip migration plan status to `completed` after 2026-05-17
- [ ] Final grep sweep — no stale refs remain
- [ ] `npm run build` passes
## Success Criteria
- [ ] `find src -type f` matches the README Project Structure tree
- [ ] No `/admin/migrate-from-upstash` reference in `README.md`
- [ ] `.env.example` is documentation-only or absent
- [ ] `docs/*.md` accurately describes current code (no current-tense Upstash refs)
- [ ] Final grep sweep shows only intentional historical mentions
## Risk Assessment
- **Risk:** Editing docs accidentally drops useful historical context for future operators.
**Mitigation:** Keep "Optional One-Shot Migration from Upstash" sections as past-tense narrative; only scrub anything that says "use this NOW".
- **Risk:** Premature archival of migration plan loses rollback information.
**Mitigation:** Wait until 2026-05-17 (7-day window). Frontmatter already records `migratedAt` and `postCleanupVersionId` — those stay regardless of `status`.
## Security Considerations
N/A — docs only.
## Next Steps
None — this is the cleanup tail.
@@ -1,70 +0,0 @@
---
title: "Fix critical bugs and security gaps from DO migration code review"
status: completed
priority: P1
created: 2026-05-10
completed: 2026-05-11
phases: 5
source: skill
sourceReports:
- ../reports/code-reviewer-260510-0211-rplace-do-migration.md
- ../reports/debugger-260510-0211-rplace-edge-cases.md
- ../reports/docs-manager-260510-0211-rplace-docs-drift.md
blockedBy: []
blocks: []
---
# Plan: Fix DO Migration Code-Review Follow-ups
## Goal
Close the 3 Critical + 5 High findings from the post-migration triple-review (code-reviewer, debugger, docs-manager). Add full DO surface test coverage. Scrub stale Upstash references from `README.md` and `.env.example`.
## Decisions Locked (from validation Q&A)
- **Identity:** Cookie + IP fallback. Issue opaque per-browser cookie on first `/api/canvas`; rate-limit by cookie when present, fall back to IP otherwise.
- **Daily pixel cap:** Deferred to a follow-up plan (product input needed).
- **Test scope:** Full DO surface coverage (chunk-storage, cooldown-store, WS hub, get-user-id, integration).
## Phases
| # | File | Title | Status | Priority | Blocks |
|---|---|---|---|---|---|
| 1 | [phase-01-do-storage-atomicity.md](phase-01-do-storage-atomicity.md) | DO storage atomicity & correctness | completed | P1 | 4 |
| 2 | [phase-02-cookie-ip-identity.md](phase-02-cookie-ip-identity.md) | Cookie+IP identity & broadcast sequence | completed | P1 | 4 |
| 3 | [phase-03-ws-hardening-client-race.md](phase-03-ws-hardening-client-race.md) | WebSocket hardening & client race fix | completed | P1 | 4 |
| 4 | [phase-04-do-surface-tests.md](phase-04-do-surface-tests.md) | Full DO surface test coverage | completed | P2 | — |
| 5 | [phase-05-docs-cleanup.md](phase-05-docs-cleanup.md) | Docs cleanup & legacy plan archival | completed | P2 | — |
Phases 1, 2, 3 are independent and can ship in any order or in parallel. Phase 4 depends on the API surfaces stabilized in 1–3. Phase 5 is independent — can land first.
## Findings Coverage Map
| Phase | Critical | High | Medium |
|---|---|---|---|
| 1 | C2 (review C3 BLOB-grow), C2 (debugger atomicity), C1 (debugger cooldown burn) | review-H1, H2, H3, H5 | review-M1 |
| 2 | C3 (debugger NAT) | debugger-H2, H3 | review-M4 |
| 3 | C2 (review WS race) | review-H4, debugger-H5 | review-M2, M3, M7 |
| 4 | — | L7 test gap | — |
| 5 | C1 (review stale docs) | — | — |
## Out of Scope
- Per-IP daily pixel quota (deferred per Q&A).
- Edge-cache strategy change (`s-maxage` tuning) — needs product call on live-fresh vs cheap.
- Multi-room sharding (`idFromName('main')` stays single-DO).
- Signed cookie / HMAC identity — opaque cookie is sufficient for this round.
## Success Criteria
- All listed Critical + High findings resolved with file:line citations in PR description.
- `npm test` passes; new DO tests cover write atomicity, BLOB-grow, cooldown refund, WS hub, identity.
- README + `.env.example` purged of Upstash refs; docs/ verified accurate.
- Production deploy + 24h soak shows no new error class in CF logs.
- Migration plan `260509-2309-canvas-on-do-storage/plan.md` marked `status: completed`.
## Related Reports
- `plans/reports/code-reviewer-260510-0211-rplace-do-migration.md`
- `plans/reports/debugger-260510-0211-rplace-edge-cases.md`
- `plans/reports/docs-manager-260510-0211-rplace-docs-drift.md`