feat(canvas): cookie+IP rate-limit identity and broadcast sequence numbers

- resolveIdentity prefers an opaque rplace_id cookie; falls back to a
  cf-connecting-ip hash; in production a request with neither now returns
  500 no_identity instead of bucketing all such traffic together
- /api/canvas issues Set-Cookie when no cookie is present so subsequent
  requests escape NAT-shared IP buckets (mobile/CGNAT users)
- DO maintains an in-memory monotonic broadcast counter; broadcast frames
  carry { seq } so the client can detect missed pixels and refetch
- client tracks lastSeq, refetches on gap, resets on every (re)connect

NAT/CGNAT users previously shared a single 1Hz bucket per egress IP. With
cookie identity they each get their own bucket. Cookie is HttpOnly, Secure,
SameSite=Lax, 1y Max-Age. Stripped/cleared cookies fall through to IP.

The seq counter resets on DO hibernation rehydrate; client always refetches
on reconnect, so a reset is indistinguishable from a fresh connect.

Plan: plans/260510-0232-fix-do-migration-followups/phase-02-cookie-ip-identity.md
This commit is contained in:
tiennm99 committed 2026-05-10 03:00:39 +07:00
1 parent 3c1263add6
commit 42d1ca19ee
7 files changed
+283 -42

No files matched your search

+42 -5
View File
@@ -1,5 +1,6 @@
import { Hono } from 'hono';
import { getUserId } from './lib/get-user-id.js';
import { resolveIdentity, NoIdentityError } from './lib/get-user-id.js';
import { formatSetCookie } from './lib/cookie.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from './lib/constants.js';
export { CanvasRoom } from './durable-objects/canvas-room.js';
@@ -9,14 +10,42 @@ const app = new Hono();
// ~64 bytes is generous per pixel JSON object {"x":2047,"y":2047,"color":31}
const MAX_BODY_BYTES = MAX_BATCH_SIZE * 64;
const COOKIE_OPTS = {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 year
};
/** Resolve the singleton CanvasRoom DO stub. */
function room(env) {
return env.CANVAS_ROOM.get(env.CANVAS_ROOM.idFromName('main'));
}
/** GET /api/canvas — full canvas binary, served by the DO directly. */
/** GET /api/canvas — full canvas binary, served by the DO directly. Issues
* the rplace_id cookie on first request so future calls bypass NAT-shared
* IP buckets. */
app.get('/api/canvas', async (c) => {
return room(c.env).fetch('http://do/canvas');
let identity;
try {
identity = await resolveIdentity(c.req.raw, c.env);
} catch (err) {
if (err instanceof NoIdentityError) {
return c.json({ error: 'no_identity' }, 500);
}
throw err;
}
const upstream = await room(c.env).fetch('http://do/canvas');
if (!identity.mintCookieValue) return upstream;
// Attach Set-Cookie without mutating the upstream Response (its body is a
// stream; new Response keeps it linked).
const out = new Response(upstream.body, upstream);
out.headers.append(
'Set-Cookie',
formatSetCookie('rplace_id', identity.mintCookieValue, COOKIE_OPTS),
);
return out;
});
/** POST /api/place — validate at the edge, forward to the DO. */
@@ -57,13 +86,21 @@ app.post('/api/place', async (c) => {
}
}
const userId = await getUserId(c.req.raw);
let identity;
try {
identity = await resolveIdentity(c.req.raw, c.env);
} catch (err) {
if (err instanceof NoIdentityError) {
return c.json({ error: 'no_identity' }, 500);
}
throw err;
}
// DO does cooldown check + pixel write + broadcast in one atomic step.
return room(c.env).fetch('http://do/place', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, pixels }),
body: JSON.stringify({ userId: identity.id, pixels }),
});
});