mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-15 04:18:56 +00:00
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:
1 parent
3c1263add6
commit
42d1ca19ee
7 files changed
+283
-42
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCookie, formatSetCookie } from '../../src/lib/cookie.js';
|
||||
|
||||
describe('parseCookie', () => {
|
||||
it('returns empty Map for null/undefined header', () => {
|
||||
expect(parseCookie(null).size).toBe(0);
|
||||
expect(parseCookie(undefined).size).toBe(0);
|
||||
expect(parseCookie('').size).toBe(0);
|
||||
});
|
||||
|
||||
it('parses a single name=value pair', () => {
|
||||
const m = parseCookie('rplace_id=abc');
|
||||
expect(m.get('rplace_id')).toBe('abc');
|
||||
expect(m.size).toBe(1);
|
||||
});
|
||||
|
||||
it('parses multiple cookies separated by ;', () => {
|
||||
const m = parseCookie('a=1; b=2; c=3');
|
||||
expect(m.get('a')).toBe('1');
|
||||
expect(m.get('b')).toBe('2');
|
||||
expect(m.get('c')).toBe('3');
|
||||
});
|
||||
|
||||
it('trims whitespace around names and values', () => {
|
||||
const m = parseCookie(' a = 1 ; b=2');
|
||||
expect(m.get('a')).toBe('1');
|
||||
expect(m.get('b')).toBe('2');
|
||||
});
|
||||
|
||||
it('skips malformed entries (no equals)', () => {
|
||||
const m = parseCookie('a; b=2');
|
||||
expect(m.has('a')).toBe(false);
|
||||
expect(m.get('b')).toBe('2');
|
||||
});
|
||||
|
||||
it('handles values with embedded equals', () => {
|
||||
const m = parseCookie('token=abc=def=');
|
||||
expect(m.get('token')).toBe('abc=def=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSetCookie', () => {
|
||||
it('formats minimum required attributes', () => {
|
||||
expect(formatSetCookie('a', '1')).toBe('a=1');
|
||||
});
|
||||
|
||||
it('emits Path, Max-Age, HttpOnly, Secure, SameSite in expected order', () => {
|
||||
const s = formatSetCookie('rplace_id', 'uuid', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 31536000,
|
||||
});
|
||||
expect(s).toBe('rplace_id=uuid; Path=/; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax');
|
||||
});
|
||||
|
||||
it('omits attributes that are not set', () => {
|
||||
expect(formatSetCookie('a', '1', { secure: true })).toBe('a=1; Secure');
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getUserId } from '../../src/lib/get-user-id.js';
|
||||
import { resolveIdentity, NoIdentityError } from '../../src/lib/get-user-id.js';
|
||||
|
||||
/** Helper to create a mock Request with headers */
|
||||
/** Helper to build a mock Request with selected headers. */
|
||||
function mockRequest(headers = {}) {
|
||||
return new Request('http://localhost', {
|
||||
headers: new Headers(headers),
|
||||
});
|
||||
return new Request('http://localhost', { headers: new Headers(headers) });
|
||||
}
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('returns anon: prefix', async () => {
|
||||
const id = await getUserId(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
|
||||
expect(id).toMatch(/^anon:/);
|
||||
describe('resolveIdentity — cookie-first', () => {
|
||||
it('returns cookie:<uuid> when rplace_id cookie is present', async () => {
|
||||
const cookie = '12345678-1234-1234-1234-123456789abc';
|
||||
const r = await resolveIdentity(mockRequest({ cookie: `rplace_id=${cookie}` }));
|
||||
expect(r.id).toBe(`cookie:${cookie}`);
|
||||
expect(r.mintCookieValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns deterministic ID for same IP', async () => {
|
||||
const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
|
||||
const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
|
||||
expect(id1).toBe(id2);
|
||||
it('rejects malformed cookie values and falls back to IP', async () => {
|
||||
const r = await resolveIdentity(mockRequest({
|
||||
cookie: 'rplace_id=" OR 1=1 --"',
|
||||
'cf-connecting-ip': '1.2.3.4',
|
||||
}));
|
||||
expect(r.id).toMatch(/^ip:[0-9a-f]{16}$/);
|
||||
expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it('returns different IDs for different IPs', async () => {
|
||||
const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
|
||||
const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
|
||||
expect(id1).not.toBe(id2);
|
||||
it('cookie wins over IP when both present', async () => {
|
||||
const cookie = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
const r = await resolveIdentity(mockRequest({
|
||||
cookie: `rplace_id=${cookie}`,
|
||||
'cf-connecting-ip': '1.2.3.4',
|
||||
}));
|
||||
expect(r.id).toBe(`cookie:${cookie}`);
|
||||
expect(r.mintCookieValue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a shared dev bucket when header is missing', async () => {
|
||||
const id = await getUserId(mockRequest({}));
|
||||
expect(id).toBe('anon:dev');
|
||||
// Deterministic for missing header
|
||||
expect(await getUserId(mockRequest({}))).toBe(id);
|
||||
describe('resolveIdentity — IP fallback', () => {
|
||||
it('returns ip:<hash> with mintCookieValue when only IP is present', async () => {
|
||||
const r = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
|
||||
expect(r.id).toMatch(/^ip:[0-9a-f]{16}$/);
|
||||
expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it('uses 16-hex-char (8-byte) suffix from SHA-256', async () => {
|
||||
const id = await getUserId(mockRequest({ 'cf-connecting-ip': '203.0.113.45' }));
|
||||
expect(id).toMatch(/^anon:[0-9a-f]{16}$/);
|
||||
it('IP hash is deterministic per IP', async () => {
|
||||
const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
|
||||
const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
|
||||
expect(a.id).toBe(b.id);
|
||||
});
|
||||
|
||||
it('different IPs produce different hashes', async () => {
|
||||
const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
|
||||
const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it('mintCookieValue is unique per call', async () => {
|
||||
const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
|
||||
const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
|
||||
expect(a.mintCookieValue).not.toBe(b.mintCookieValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveIdentity — no cookie, no IP', () => {
|
||||
it('falls back to dev:shared in non-production env', async () => {
|
||||
const r = await resolveIdentity(mockRequest({}), { ENVIRONMENT: 'development' });
|
||||
expect(r.id).toBe('dev:shared');
|
||||
expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it('falls back to dev:shared when env is undefined', async () => {
|
||||
const r = await resolveIdentity(mockRequest({}));
|
||||
expect(r.id).toBe('dev:shared');
|
||||
});
|
||||
|
||||
it('throws NoIdentityError in production', async () => {
|
||||
await expect(resolveIdentity(mockRequest({}), { ENVIRONMENT: 'production' }))
|
||||
.rejects.toBeInstanceOf(NoIdentityError);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user