diff --git a/.env.example b/.env.example
index 6f679c1..7cd3233 100644
--- a/.env.example
+++ b/.env.example
@@ -18,3 +18,8 @@ R2_PUBLIC_BASE_PROD="https://oche-media.r2.example"
# --- Cloudflare (CI) ---
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_API_TOKEN=""
+
+# --- Local dev (SPA + API) ---
+VITE_API_BASE="http://localhost:8787"
+APP_ORIGIN="http://localhost:5173"
+MEDIA_SIGNING_SECRET="dev-media-signing-secret-change-in-prod"
diff --git a/.gitignore b/.gitignore
index a20cae8..5056654 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,9 @@ worker-configuration.d.ts
!.env.example
.dev.vars
.dev.vars.*
+!.dev.vars.example
+.oche-keys.json
+!.oche-keys.example.json
*.pem
*.key
*.p12
diff --git a/.oche-keys.example.json b/.oche-keys.example.json
new file mode 100644
index 0000000..e02abfc
--- /dev/null
+++ b/.oche-keys.example.json
@@ -0,0 +1 @@
+
diff --git a/apps/api/.dev.vars.example b/apps/api/.dev.vars.example
new file mode 100644
index 0000000..71df5cd
--- /dev/null
+++ b/apps/api/.dev.vars.example
@@ -0,0 +1,4 @@
+# Wrangler local dev secrets (gitignored).
+# Populated automatically: npm run setup:dev-vars:sync (reads root .env)
+DATABASE_URL=""
+MEDIA_SIGNING_SECRET="dev-media-signing-secret-change-in-prod"
diff --git a/apps/api/src/__tests__/media-policy.test.ts b/apps/api/src/__tests__/media-policy.test.ts
new file mode 100644
index 0000000..3c9172f
--- /dev/null
+++ b/apps/api/src/__tests__/media-policy.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+import {
+ MAX_PHOTO_BYTES,
+ MAX_VIDEO_BYTES,
+ classifyMedia,
+ parseRangeHeader,
+ validateUpload,
+} from '../lib/media-policy.js';
+
+describe('media-policy', () => {
+ it('classifies allowed MIME types', () => {
+ expect(classifyMedia('image/jpeg')).toBe('photo');
+ expect(classifyMedia('video/mp4')).toBe('video');
+ expect(classifyMedia('application/pdf')).toBeNull();
+ });
+
+ it('rejects unsupported or oversized uploads', () => {
+ expect(validateUpload('image/png', 1024)).toEqual({ ok: true, kind: 'photo' });
+ expect(validateUpload('video/mp4', MAX_VIDEO_BYTES)).toEqual({ ok: true, kind: 'video' });
+ expect(validateUpload('video/mp4', MAX_VIDEO_BYTES + 1).ok).toBe(false);
+ expect(validateUpload('image/jpeg', MAX_PHOTO_BYTES + 1).ok).toBe(false);
+ expect(validateUpload('text/plain', 10).ok).toBe(false);
+ expect(validateUpload('image/png', 0).ok).toBe(false);
+ });
+
+ it('parses byte-range headers', () => {
+ expect(parseRangeHeader(undefined, 1000)).toBeNull();
+ expect(parseRangeHeader('bytes=0-99', 1000)).toEqual({ offset: 0, length: 100 });
+ expect(parseRangeHeader('bytes=900-', 1000)).toEqual({ offset: 900, length: 100 });
+ expect(parseRangeHeader('bytes=-50', 1000)).toEqual({ offset: 950, length: 50 });
+ expect(parseRangeHeader('bytes=2000-3000', 1000)).toBeNull();
+ });
+});
diff --git a/apps/api/src/__tests__/media.routes.test.ts b/apps/api/src/__tests__/media.routes.test.ts
new file mode 100644
index 0000000..7da15a7
--- /dev/null
+++ b/apps/api/src/__tests__/media.routes.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createApp } from '../app.js';
+import type { Env } from '../env.js';
+import { signMediaKey } from '../lib/signed-url.js';
+
+function mockMediaStore() {
+ const objects = new Map();
+ return {
+ put: vi.fn(async (key: string, body: ArrayBuffer, opts: { httpMetadata?: { contentType?: string } }) => {
+ objects.set(key, {
+ bytes: body,
+ contentType: opts.httpMetadata?.contentType ?? 'application/octet-stream',
+ });
+ }),
+ head: vi.fn(async (key: string) => {
+ const o = objects.get(key);
+ if (!o) return null;
+ return { size: o.bytes.byteLength, httpMetadata: { contentType: o.contentType } };
+ }),
+ get: vi.fn(async (key: string, opts?: { range?: { offset: number; length: number } }) => {
+ const o = objects.get(key);
+ if (!o) return null;
+ const slice =
+ opts?.range != null
+ ? o.bytes.slice(opts.range.offset, opts.range.offset + opts.range.length)
+ : o.bytes;
+ return { body: slice };
+ }),
+ objects,
+ };
+}
+
+function mockEnv(media = mockMediaStore()): Env {
+ return {
+ DATABASE_URL: 'postgresql://test',
+ APP_ORIGIN: 'http://localhost:5173',
+ ENVIRONMENT: 'development',
+ MEDIA_SIGNING_SECRET: 'test-secret',
+ SESSION_ROOM: {
+ idFromName: () => ({ fetch: vi.fn(async () => new Response('{}')) }),
+ } as unknown as Env['SESSION_ROOM'],
+ MEDIA: media as unknown as Env['MEDIA'],
+ };
+}
+
+describe('media routes', () => {
+ it('POST /media/upload requires auth', async () => {
+ const app = createApp();
+ const res = await app.request(
+ '/media/upload',
+ { method: 'POST', headers: { 'content-type': 'image/png' }, body: new Uint8Array([1, 2, 3]) },
+ mockEnv(),
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it('POST /media/upload stores immutable key', async () => {
+ const store = mockMediaStore();
+ const app = createApp();
+ const bytes = new Uint8Array([137, 80, 78, 71]);
+ const res = await app.request(
+ '/media/upload',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'image/png', 'x-oche-owner': 'demo-key-a' },
+ body: bytes,
+ },
+ mockEnv(store),
+ );
+ expect(res.status).toBe(201);
+ const body = (await res.json()) as { key: string; kind: string; url: string };
+ expect(body.kind).toBe('photo');
+ expect(body.key).toMatch(/^photos\/[a-f0-9]{64}\.png$/);
+ expect(body.url).toContain(body.key);
+ expect(store.put).toHaveBeenCalledOnce();
+ });
+
+ it('GET /media/:key serves bytes with range support', async () => {
+ const store = mockMediaStore();
+ const key = 'videos/demo.mp4';
+ const payload = new Uint8Array(100).map((_, i) => i);
+ store.objects.set(key, { bytes: payload.buffer, contentType: 'video/mp4' });
+
+ const { exp, sig } = await signMediaKey({ MEDIA_SIGNING_SECRET: 'test-secret' }, key);
+ const app = createApp();
+
+ const full = await app.request(`/media/${key}?exp=${exp}&sig=${sig}`, {}, mockEnv(store));
+ expect(full.status).toBe(200);
+ expect(full.headers.get('Accept-Ranges')).toBe('bytes');
+ expect(Number(full.headers.get('Content-Length'))).toBe(100);
+
+ const partial = await app.request(
+ `/media/${key}?exp=${exp}&sig=${sig}`,
+ { headers: { Range: 'bytes=0-9' } },
+ mockEnv(store),
+ );
+ expect(partial.status).toBe(206);
+ expect(partial.headers.get('Content-Range')).toBe('bytes 0-9/100');
+ const chunk = new Uint8Array(await partial.arrayBuffer());
+ expect(chunk).toEqual(payload.slice(0, 10));
+ });
+
+ it('GET /media/:key rejects bad signatures', async () => {
+ const app = createApp();
+ const res = await app.request('/media/videos/x.mp4?exp=9999999999&sig=bad', {}, mockEnv());
+ expect(res.status).toBe(403);
+ });
+});
diff --git a/apps/api/src/__tests__/pagination.test.ts b/apps/api/src/__tests__/pagination.test.ts
new file mode 100644
index 0000000..8f865be
--- /dev/null
+++ b/apps/api/src/__tests__/pagination.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { decodeCursor, encodeCursor } from '../lib/pagination.js';
+
+describe('pagination cursor', () => {
+ it('round-trips a cursor', () => {
+ const cursor = { createdAt: '2026-01-01T12:00:00.000Z', id: '11111111-1111-1111-1111-111111111111' };
+ const encoded = encodeCursor(cursor);
+ expect(decodeCursor(encoded)).toEqual(cursor);
+ });
+
+ it('returns null for invalid cursor', () => {
+ expect(decodeCursor('not-valid')).toBeNull();
+ });
+});
diff --git a/apps/api/src/__tests__/principal.test.ts b/apps/api/src/__tests__/principal.test.ts
new file mode 100644
index 0000000..75edef2
--- /dev/null
+++ b/apps/api/src/__tests__/principal.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { Hono } from 'hono';
+import type { Env } from '../env.js';
+import { resolvePrincipal } from '../principal.js';
+
+function appWithEnv(env: Partial) {
+ const app = new Hono<{ Bindings: Env }>();
+ app.get('/', (c) => c.json({ ownerId: resolvePrincipal(c) }));
+ return { app, env: env as Env };
+}
+
+describe('resolvePrincipal', () => {
+ it('resolves built-in demo keys', async () => {
+ const { app, env } = appWithEnv({} as Env);
+ const res = await app.request('/', { headers: { 'x-oche-owner': 'demo-key-a' } }, env);
+ expect(await res.json()).toEqual({ ownerId: '11111111-1111-1111-1111-111111111111' });
+ });
+
+ it('merges OCHE_API_KEYS from env', async () => {
+ const customKey = 'oche_testkey123';
+ const ownerId = '33333333-3333-3333-3333-333333333333';
+ const { app, env } = appWithEnv({
+ OCHE_API_KEYS: JSON.stringify({ [customKey]: ownerId }),
+ } as Env);
+ const res = await app.request('/', { headers: { 'x-oche-owner': customKey } }, env);
+ expect(await res.json()).toEqual({ ownerId });
+ });
+
+ it('returns null for unknown keys', async () => {
+ const { app, env } = appWithEnv({} as Env);
+ const res = await app.request('/', { headers: { 'x-oche-owner': 'invalid' } }, env);
+ expect(await res.json()).toEqual({ ownerId: null });
+ });
+});
diff --git a/apps/api/src/__tests__/scores.test.ts b/apps/api/src/__tests__/scores.test.ts
new file mode 100644
index 0000000..176998a
--- /dev/null
+++ b/apps/api/src/__tests__/scores.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { applyScoreChange, SCORE_MAX, SCORE_MIN } from '../lib/scores.js';
+
+describe('applyScoreChange', () => {
+ it('applies a positive delta', () => {
+ expect(applyScoreChange(40, { delta: 20 })).toEqual({ ok: true, next: 60, delta: 20 });
+ });
+
+ it('rejects scores below minimum', () => {
+ const r = applyScoreChange(5, { delta: -10 });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toContain(String(SCORE_MIN));
+ });
+
+ it('rejects scores above maximum', () => {
+ const r = applyScoreChange(SCORE_MAX, { delta: 1 });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toContain(String(SCORE_MAX));
+ });
+
+ it('honours absolute set', () => {
+ expect(applyScoreChange(40, { set: 100 })).toEqual({ ok: true, next: 100, delta: 60 });
+ });
+});
diff --git a/apps/api/src/__tests__/session-room.test.ts b/apps/api/src/__tests__/session-room.test.ts
new file mode 100644
index 0000000..f37ae08
--- /dev/null
+++ b/apps/api/src/__tests__/session-room.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import type { WsServerMessage } from '@oche/shared';
+import { SessionRoom } from '../session-room.js';
+
+describe('SessionRoom', () => {
+ it('accepts POST /broadcast and fans out to websockets', async () => {
+ const sent: string[] = [];
+ const ws = { send: (msg: string) => sent.push(msg) } as unknown as WebSocket;
+ const ctx = {
+ getWebSockets: () => [ws],
+ acceptWebSocket: () => {},
+ storage: { setAlarm: async () => {}, deleteAlarm: async () => {} },
+ };
+
+ const room = new SessionRoom(ctx as never, {} as never);
+ const message: WsServerMessage = {
+ type: 'score',
+ playerId: '11111111-1111-1111-1111-111111111111',
+ newScore: 50,
+ delta: 10,
+ };
+
+ const res = await room.fetch(
+ new Request('https://session-room/broadcast', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(message),
+ }),
+ );
+
+ expect(res.status).toBe(200);
+ expect(sent).toHaveLength(1);
+ expect(JSON.parse(sent[0]!)).toEqual(message);
+ });
+});
diff --git a/apps/api/src/__tests__/sessions.routes.test.ts b/apps/api/src/__tests__/sessions.routes.test.ts
new file mode 100644
index 0000000..874944a
--- /dev/null
+++ b/apps/api/src/__tests__/sessions.routes.test.ts
@@ -0,0 +1,190 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createApp } from '../app.js';
+import type { Env } from '../env.js';
+
+const OWNER_A = '11111111-1111-1111-1111-111111111111';
+const OWNER_B = '22222222-2222-2222-2222-222222222222';
+const SESSION_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
+const PLAYER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
+
+const mockSession = {
+ id: SESSION_ID,
+ ownerId: OWNER_A,
+ status: 'active' as const,
+ title: 'Test Session',
+ videoUrl: null,
+ videoPoster: null,
+ hlsUrl: null,
+ createdAt: new Date('2026-06-01T12:00:00.000Z'),
+ updatedAt: new Date('2026-06-01T12:00:00.000Z'),
+};
+
+const mockPlayer = {
+ id: PLAYER_ID,
+ sessionId: SESSION_ID,
+ name: 'Alice',
+ score: 40,
+ photoUrl: null,
+ position: 0,
+};
+
+const mockTx = {
+ select: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ execute: vi.fn(),
+};
+
+vi.mock('@oche/db', () => ({
+ createDb: vi.fn(() => ({})),
+ withPrincipal: vi.fn(async (_db, ownerId, fn) => {
+ if (ownerId === OWNER_B) {
+ const emptyTx = {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ orderBy: vi.fn(() => ({
+ limit: vi.fn(async () => []),
+ })),
+ })),
+ })),
+ })),
+ };
+ return fn(emptyTx);
+ }
+ return fn(mockTx);
+ }),
+ sessions: { id: 'id', createdAt: 'createdAt', ownerId: 'ownerId' },
+ players: { id: 'id', sessionId: 'sessionId', position: 'position' },
+ scoreEvents: { playerId: 'playerId', createdAt: 'createdAt' },
+}));
+
+function chainSelect(rows: unknown[]) {
+ const limit = vi.fn(async () => rows);
+ const orderBy = vi.fn(() => ({ limit, orderBy: vi.fn(() => ({ limit })) }));
+ const where = vi.fn(() => ({ orderBy, limit, where: vi.fn(() => ({ orderBy, limit })) }));
+ const from = vi.fn(() => ({ where, orderBy, limit }));
+ mockTx.select.mockReturnValue({ from });
+ return { where, orderBy, limit };
+}
+
+function mockEnv(): Env {
+ return {
+ DATABASE_URL: 'postgresql://test',
+ APP_ORIGIN: 'http://localhost:5173',
+ ENVIRONMENT: 'development',
+ SESSION_ROOM: {
+ idFromName: () => ({ fetch: vi.fn(async () => new Response(JSON.stringify({ ok: true }))) }),
+ } as unknown as Env['SESSION_ROOM'],
+ MEDIA: {} as Env['MEDIA'],
+ };
+}
+
+describe('sessions API routes', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('GET /health is public', async () => {
+ const app = createApp();
+ const res = await app.request('/health', {}, mockEnv());
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ ok: true });
+ });
+
+ it('GET /openapi.json is public', async () => {
+ const app = createApp();
+ const res = await app.request('/openapi.json', {}, mockEnv());
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { openapi: string };
+ expect(body.openapi).toBe('3.1.0');
+ });
+
+ it('returns 401 without principal header', async () => {
+ const app = createApp();
+ const res = await app.request('/sessions', {}, mockEnv());
+ expect(res.status).toBe(401);
+ });
+
+ it('GET /sessions returns paginated summaries', async () => {
+ chainSelect([mockSession]);
+ mockTx.select.mockImplementationOnce(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ orderBy: vi.fn(() => ({
+ limit: vi.fn(async () => [mockSession]),
+ })),
+ })),
+ })),
+ }));
+ mockTx.select.mockImplementationOnce(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(async () => [mockPlayer]),
+ })),
+ }));
+
+ const app = createApp();
+ const res = await app.request('/sessions', { headers: { 'x-oche-owner': 'demo-key-a' } }, mockEnv());
+ expect(res.status).toBe(200);
+ expect(res.headers.get('Cache-Control')).toContain('max-age=15');
+ const body = (await res.json()) as { data: unknown[]; nextCursor: string | null };
+ expect(body.data).toHaveLength(1);
+ });
+
+ it('GET /sessions/:id returns 404 when hidden by RLS', async () => {
+ chainSelect([]);
+ mockTx.select.mockReturnValue({
+ from: vi.fn(() => ({
+ where: vi.fn(async () => []),
+ })),
+ });
+
+ const app = createApp();
+ const res = await app.request(
+ `/sessions/${SESSION_ID}`,
+ { headers: { 'x-oche-owner': 'demo-key-a' } },
+ mockEnv(),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it('POST /sessions rejects unknown keys (strict Zod)', async () => {
+ const app = createApp();
+ const res = await app.request(
+ '/sessions',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-oche-owner': 'demo-key-a' },
+ body: JSON.stringify({ title: 'x', players: [{ name: 'A' }], extra: true }),
+ },
+ mockEnv(),
+ );
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: string; issues?: unknown[] };
+ expect(body.error).toBe('Invalid body');
+ expect(body.issues?.length).toBeGreaterThan(0);
+ });
+
+ it('PATCH /sessions/:id rejects empty body', async () => {
+ const app = createApp();
+ const res = await app.request(
+ `/sessions/${SESSION_ID}`,
+ {
+ method: 'PATCH',
+ headers: { 'content-type': 'application/json', 'x-oche-owner': 'demo-key-a' },
+ body: JSON.stringify({}),
+ },
+ mockEnv(),
+ );
+ expect(res.status).toBe(422);
+ });
+
+ it('RLS isolation: other owner sees empty list', async () => {
+ const app = createApp();
+ const res = await app.request('/sessions', { headers: { 'x-oche-owner': 'demo-key-b' } }, mockEnv());
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: unknown[] };
+ expect(body.data).toEqual([]);
+ });
+});
diff --git a/apps/api/src/__tests__/signed-url.test.ts b/apps/api/src/__tests__/signed-url.test.ts
new file mode 100644
index 0000000..70f7b38
--- /dev/null
+++ b/apps/api/src/__tests__/signed-url.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { buildSignedMediaUrl, resolveMediaRef, signMediaKey, verifyMediaKey } from '../lib/signed-url.js';
+
+const env = { MEDIA_SIGNING_SECRET: 'test-secret' };
+
+describe('signed-url', () => {
+ it('signs and verifies a media key', async () => {
+ const key = 'videos/abc123.mp4';
+ const { exp, sig } = await signMediaKey(env, key, 3600);
+ expect(await verifyMediaKey(env, key, exp, sig)).toBe(true);
+ expect(await verifyMediaKey(env, key, exp, `${sig}x`)).toBe(false);
+ expect(await verifyMediaKey(env, key, exp - 7200, sig)).toBe(false);
+ });
+
+ it('builds fetchable URLs with path segments preserved', async () => {
+ const url = await buildSignedMediaUrl('http://localhost:8787', env, 'photos/deadbeef.jpg');
+ expect(url).toMatch(/^http:\/\/localhost:8787\/media\/photos\/deadbeef\.jpg\?exp=\d+&sig=[\w-]+$/);
+ });
+
+ it('passes through absolute refs and resolves R2 keys', async () => {
+ const external = 'https://cdn.example/video.mp4';
+ expect(await resolveMediaRef('http://localhost:8787', env, external)).toBe(external);
+ const signed = await resolveMediaRef('http://localhost:8787', env, 'videos/hash.mp4');
+ expect(signed).toContain('/media/videos/hash.mp4?');
+ expect(await resolveMediaRef('http://localhost:8787', env, null)).toBeUndefined();
+ });
+});
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
new file mode 100644
index 0000000..9a2652a
--- /dev/null
+++ b/apps/api/src/app.ts
@@ -0,0 +1,31 @@
+import { Hono } from 'hono';
+import { cors } from 'hono/cors';
+import type { Env } from './env.js';
+import { bodySizeLimit, rateLimit, securityHeaders } from './middleware.js';
+import { openApiDocument } from './openapi/spec.js';
+import { media } from './routes/media.js';
+import { sessions } from './routes/sessions.js';
+
+export function createApp() {
+ const app = new Hono<{ Bindings: Env }>();
+
+ app.use('*', securityHeaders);
+ app.use('*', rateLimit());
+ app.use('*', (c, next) => cors({ origin: c.env.APP_ORIGIN, credentials: true })(c, next));
+
+ app.get('/health', (c) => c.json({ ok: true, env: c.env.ENVIRONMENT }));
+ app.get('/openapi.json', (c) => c.json(openApiDocument));
+
+ app.route('/media', media);
+ app.use('/sessions/*', bodySizeLimit());
+ app.route('/sessions', sessions);
+
+ app.onError((err, c) => {
+ console.error(err);
+ return c.json({ error: 'Internal error' }, 500);
+ });
+
+ return app;
+}
+
+export default createApp();
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
index 9122e39..b43d1d7 100644
--- a/apps/api/src/env.ts
+++ b/apps/api/src/env.ts
@@ -1,9 +1,16 @@
import type { DurableObjectNamespace, Hyperdrive, R2Bucket } from '@cloudflare/workers-types';
export interface Env {
- HYPERDRIVE: Hyperdrive;
+ /** Hyperdrive in staging/production; optional locally. */
+ HYPERDRIVE?: Hyperdrive;
+ /** Direct Neon URL for local wrangler dev (.dev.vars). */
+ DATABASE_URL?: string;
SESSION_ROOM: DurableObjectNamespace;
MEDIA: R2Bucket;
+ /** HMAC secret for signed media URLs (Wrangler secret in staging/prod). */
+ MEDIA_SIGNING_SECRET?: string;
+ /** JSON map of api-key → owner uuid (local dev; from .oche-keys.json). */
+ OCHE_API_KEYS?: string;
APP_ORIGIN: string;
- ENVIRONMENT: 'staging' | 'production';
+ ENVIRONMENT: 'development' | 'staging' | 'production';
}
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 7a8d573..0d07d16 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -1,158 +1,8 @@
/**
- * Oche API — Hono on Cloudflare Workers.
- * Endpoints: GET /sessions, GET /sessions/:id, POST /sessions, PATCH /sessions/:id, WS /sessions/:id/live.
- * Every DB call runs inside withPrincipal(), so RLS enforces per-owner isolation.
+ * Oche API — Cloudflare Worker entry (REST + SessionRoom Durable Object).
*/
-import { createDb, players, scoreEvents, sessions, withPrincipal } from '@oche/db';
-import { CreateSessionInput, PatchSessionInput } from '@oche/shared';
-import { and, desc, eq } from 'drizzle-orm';
-import { Hono } from 'hono';
-import { cors } from 'hono/cors';
-import type { Env } from './env.js';
-import { rateLimit, securityHeaders } from './middleware.js';
-import { resolvePrincipal } from './principal.js';
+import { createApp } from './app.js';
export { SessionRoom } from './session-room.js';
-const app = new Hono<{ Bindings: Env; Variables: { ownerId: string } }>();
-
-app.use('*', securityHeaders);
-app.use('*', rateLimit());
-app.use('*', (c, next) => cors({ origin: c.env.APP_ORIGIN, credentials: true })(c, next));
-
-// Resolve principal once per request.
-app.use('/sessions/*', async (c, next) => {
- const ownerId = resolvePrincipal(c);
- if (!ownerId) return c.json({ error: 'Unauthorised' }, 401);
- c.set('ownerId', ownerId);
- await next();
-});
-
-app.get('/health', (c) => c.json({ ok: true, env: c.env.ENVIRONMENT }));
-
-app.get('/sessions', async (c) => {
- const db = createDb(c.env.HYPERDRIVE.connectionString);
- const rows = await withPrincipal(db, c.get('ownerId'), async (tx) => {
- const list = await tx.select().from(sessions).orderBy(desc(sessions.createdAt)).limit(50);
- return Promise.all(
- list.map(async (s) => {
- const ps = await tx.select().from(players).where(eq(players.sessionId, s.id));
- return { ...s, playerCount: ps.length };
- }),
- );
- });
- return c.json({ data: rows, nextCursor: null });
-});
-
-app.get('/sessions/:id', async (c) => {
- const id = c.req.param('id');
- const db = createDb(c.env.HYPERDRIVE.connectionString);
- const result = await withPrincipal(db, c.get('ownerId'), async (tx) => {
- const [s] = await tx.select().from(sessions).where(eq(sessions.id, id));
- if (!s) return null;
- const ps = await tx.select().from(players).where(eq(players.sessionId, id)).orderBy(players.position);
- const events = ps.length
- ? await tx
- .select()
- .from(scoreEvents)
- .where(eq(scoreEvents.playerId, ps[0]!.id))
- .orderBy(desc(scoreEvents.createdAt))
- .limit(20)
- : [];
- return { ...s, players: ps, scoreEvents: events };
- });
- return result ? c.json(result) : c.json({ error: 'Session not found' }, 404);
-});
-
-app.post('/sessions', async (c) => {
- const parsed = CreateSessionInput.safeParse(await c.req.json().catch(() => null));
- if (!parsed.success) {
- return c.json(
- {
- error: 'Invalid body',
- issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
- },
- 422,
- );
- }
- const db = createDb(c.env.HYPERDRIVE.connectionString);
- const created = await withPrincipal(db, c.get('ownerId'), async (tx) => {
- const [s] = await tx
- .insert(sessions)
- .values({
- ownerId: c.get('ownerId'),
- title: parsed.data.title,
- status: parsed.data.status,
- videoUrl: parsed.data.videoUrl,
- videoPoster: parsed.data.videoPoster,
- })
- .returning();
- await tx.insert(players).values(parsed.data.players.map((p) => ({ ...p, sessionId: s!.id })));
- return s;
- });
- return c.json(created, 201);
-});
-
-app.patch('/sessions/:id', async (c) => {
- const id = c.req.param('id');
- const parsed = PatchSessionInput.safeParse(await c.req.json().catch(() => null));
- if (!parsed.success) {
- return c.json(
- {
- error: 'Invalid body',
- issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
- },
- 422,
- );
- }
- const db = createDb(c.env.HYPERDRIVE.connectionString);
- const updates = await withPrincipal(db, c.get('ownerId'), async (tx) => {
- const [s] = await tx.select().from(sessions).where(eq(sessions.id, id));
- if (!s) return null;
- if (parsed.data.status) {
- await tx
- .update(sessions)
- .set({ status: parsed.data.status, updatedAt: new Date() })
- .where(eq(sessions.id, id));
- }
- const broadcasts: Array<{ playerId: string; newScore: number; delta: number }> = [];
- for (const change of parsed.data.scores ?? []) {
- const [p] = await tx
- .select()
- .from(players)
- .where(and(eq(players.id, change.playerId), eq(players.sessionId, id)));
- if (!p) continue;
- const newScore = change.set ?? p.score + (change.delta ?? 0);
- const delta = newScore - p.score;
- await tx.update(players).set({ score: newScore }).where(eq(players.id, p.id));
- await tx.insert(scoreEvents).values({ playerId: p.id, delta, newScore });
- broadcasts.push({ playerId: p.id, newScore, delta });
- }
- return { status: parsed.data.status, broadcasts };
- });
- if (!updates) return c.json({ error: 'Session not found' }, 404);
-
- // Fan out live updates via the Durable Object.
- const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id));
- for (const b of updates.broadcasts) {
- await stub.fetch('https://do/broadcast', {
- method: 'POST',
- body: JSON.stringify({ type: 'score', ...b }),
- });
- }
- return c.json({ ok: true });
-});
-
-// WebSocket upgrade → routed to the per-session Durable Object.
-app.get('/sessions/:id/live', (c) => {
- const id = c.req.param('id');
- const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id));
- return stub.fetch(c.req.raw);
-});
-
-app.onError((err, c) => {
- console.error(err); // structured log; never returned to the client
- return c.json({ error: 'Internal error' }, 500);
-});
-
-export default app;
+export default createApp();
diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts
new file mode 100644
index 0000000..b6465fe
--- /dev/null
+++ b/apps/api/src/lib/db.ts
@@ -0,0 +1,15 @@
+import { createDb, type Db } from '@oche/db';
+import type { Env } from '../env.js';
+
+/** Runtime DB URL: Hyperdrive in deployed envs, DATABASE_URL from .dev.vars locally. */
+export function getConnectionString(env: Env): string {
+ const url = env.HYPERDRIVE?.connectionString ?? env.DATABASE_URL;
+ if (!url) {
+ throw new Error('Database not configured (set HYPERDRIVE or DATABASE_URL in .dev.vars)');
+ }
+ return url;
+}
+
+export function getDb(env: Env): Db {
+ return createDb(getConnectionString(env));
+}
diff --git a/apps/api/src/lib/enrich-media.ts b/apps/api/src/lib/enrich-media.ts
new file mode 100644
index 0000000..91d86d1
--- /dev/null
+++ b/apps/api/src/lib/enrich-media.ts
@@ -0,0 +1,39 @@
+import type { Player, Session, SessionSummary } from '@oche/shared';
+import type { Env } from '../env.js';
+import { resolveMediaRef } from './signed-url.js';
+
+/** Turn stored R2 keys (or legacy URLs) into signed fetch URLs for the client. */
+export async function enrichSession(origin: string, env: Env, session: Session): Promise {
+ const players = await Promise.all(
+ session.players.map(async (p) => ({
+ ...p,
+ photoUrl: await resolveMediaRef(origin, env, p.photoUrl ?? null),
+ })),
+ );
+
+ return {
+ ...session,
+ videoUrl: await resolveMediaRef(origin, env, session.videoUrl ?? null),
+ videoPoster: await resolveMediaRef(origin, env, session.videoPoster ?? null),
+ hlsUrl: await resolveMediaRef(origin, env, session.hlsUrl ?? null),
+ players,
+ };
+}
+
+export async function enrichSummary(
+ origin: string,
+ env: Env,
+ summary: SessionSummary,
+): Promise {
+ return {
+ ...summary,
+ videoPoster: await resolveMediaRef(origin, env, summary.videoPoster ?? null),
+ };
+}
+
+export async function enrichPlayer(origin: string, env: Env, player: Player): Promise {
+ return {
+ ...player,
+ photoUrl: await resolveMediaRef(origin, env, player.photoUrl ?? null),
+ };
+}
diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts
new file mode 100644
index 0000000..df0c3b2
--- /dev/null
+++ b/apps/api/src/lib/errors.ts
@@ -0,0 +1,18 @@
+import type { Context } from 'hono';
+import type { ZodError } from 'zod';
+
+export function validationError(c: Context, error: ZodError) {
+ return c.json(
+ {
+ error: 'Invalid body',
+ issues: error.issues.map((i) => ({ path: i.path.join('.') || '(root)', message: i.message })),
+ },
+ 422,
+ );
+}
+
+export async function readJsonBody(c: Context): Promise {
+ const ct = c.req.header('content-type') ?? '';
+ if (!ct.includes('application/json')) return null;
+ return c.req.json().catch(() => null);
+}
diff --git a/apps/api/src/lib/media-keys.ts b/apps/api/src/lib/media-keys.ts
new file mode 100644
index 0000000..a546a6f
--- /dev/null
+++ b/apps/api/src/lib/media-keys.ts
@@ -0,0 +1,17 @@
+import { extensionFor } from './media-policy.js';
+
+/** Build an immutable content-addressed R2 key. */
+export async function buildMediaKey(
+ mime: string,
+ bytes: ArrayBuffer,
+ kind: 'photo' | 'video',
+): Promise {
+ const hash = await sha256Hex(bytes);
+ const ext = extensionFor(mime);
+ return `${kind}s/${hash}.${ext}`;
+}
+
+async function sha256Hex(bytes: ArrayBuffer): Promise {
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
+}
diff --git a/apps/api/src/lib/media-policy.ts b/apps/api/src/lib/media-policy.ts
new file mode 100644
index 0000000..912833d
--- /dev/null
+++ b/apps/api/src/lib/media-policy.ts
@@ -0,0 +1,76 @@
+/** Allowed MIME types and size limits for uploads. */
+export const PHOTO_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif']);
+export const VIDEO_MIMES = new Set(['video/mp4', 'video/webm']);
+
+export const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
+export const MAX_VIDEO_BYTES = 100 * 1024 * 1024;
+
+export type MediaKind = 'photo' | 'video';
+
+export function classifyMedia(mime: string): MediaKind | null {
+ if (PHOTO_MIMES.has(mime)) return 'photo';
+ if (VIDEO_MIMES.has(mime)) return 'video';
+ return null;
+}
+
+export function maxBytesFor(kind: MediaKind): number {
+ return kind === 'photo' ? MAX_PHOTO_BYTES : MAX_VIDEO_BYTES;
+}
+
+export function extensionFor(mime: string): string {
+ switch (mime) {
+ case 'image/jpeg':
+ return 'jpg';
+ case 'image/png':
+ return 'png';
+ case 'image/webp':
+ return 'webp';
+ case 'image/avif':
+ return 'avif';
+ case 'video/mp4':
+ return 'mp4';
+ case 'video/webm':
+ return 'webm';
+ default:
+ return 'bin';
+ }
+}
+
+export function validateUpload(
+ mime: string,
+ size: number,
+): { ok: true; kind: MediaKind } | { ok: false; error: string } {
+ const kind = classifyMedia(mime);
+ if (!kind) return { ok: false, error: 'Unsupported media type' };
+ const max = maxBytesFor(kind);
+ if (size <= 0) return { ok: false, error: 'Empty body' };
+ if (size > max) return { ok: false, error: `File exceeds ${kind} limit (${max} bytes)` };
+ return { ok: true, kind };
+}
+
+/** Parse a Range header for video byte-range requests. */
+export function parseRangeHeader(
+ range: string | undefined,
+ size: number,
+): { offset: number; length: number } | null {
+ if (!range?.startsWith('bytes=')) return null;
+ const [startStr, endStr] = range.slice(6).split('-', 2);
+ if (startStr === undefined) return null;
+
+ let start: number;
+ let end: number;
+
+ if (startStr === '') {
+ const suffixLen = Number(endStr);
+ if (!Number.isFinite(suffixLen) || suffixLen <= 0) return null;
+ start = Math.max(0, size - suffixLen);
+ end = size - 1;
+ } else {
+ start = Number(startStr);
+ end = endStr === '' || endStr === undefined ? size - 1 : Number(endStr);
+ }
+
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return null;
+
+ return { offset: start, length: end - start + 1 };
+}
diff --git a/apps/api/src/lib/pagination.ts b/apps/api/src/lib/pagination.ts
new file mode 100644
index 0000000..6b672bd
--- /dev/null
+++ b/apps/api/src/lib/pagination.ts
@@ -0,0 +1,30 @@
+const DEFAULT_LIMIT = 20;
+const MAX_LIMIT = 50;
+
+export function parseListQuery(url: string): { limit: number; cursor: ListCursor | null } {
+ const params = new URL(url).searchParams;
+ const limitRaw = Number(params.get('limit') ?? DEFAULT_LIMIT);
+ const limit = Number.isFinite(limitRaw)
+ ? Math.min(MAX_LIMIT, Math.max(1, Math.floor(limitRaw)))
+ : DEFAULT_LIMIT;
+
+ const cursorRaw = params.get('cursor');
+ const cursor = cursorRaw ? decodeCursor(cursorRaw) : null;
+ return { limit, cursor };
+}
+
+export type ListCursor = { createdAt: string; id: string };
+
+export function encodeCursor(cursor: ListCursor): string {
+ return Buffer.from(JSON.stringify(cursor)).toString('base64url');
+}
+
+export function decodeCursor(raw: string): ListCursor | null {
+ try {
+ const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as ListCursor;
+ if (typeof parsed.createdAt === 'string' && typeof parsed.id === 'string') return parsed;
+ return null;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/api/src/lib/scores.ts b/apps/api/src/lib/scores.ts
new file mode 100644
index 0000000..d7c9447
--- /dev/null
+++ b/apps/api/src/lib/scores.ts
@@ -0,0 +1,15 @@
+/** Score bounds for PATCH updates (501-style cumulative scoring). */
+export const SCORE_MIN = 0;
+export const SCORE_MAX = 999;
+
+export function applyScoreChange(
+ current: number,
+ change: { delta?: number; set?: number },
+): { ok: true; next: number; delta: number } | { ok: false; error: string } {
+ const next = change.set ?? current + (change.delta ?? 0);
+ if (!Number.isInteger(next)) return { ok: false, error: 'Score must be an integer' };
+ if (next < SCORE_MIN || next > SCORE_MAX) {
+ return { ok: false, error: `Score must be between ${SCORE_MIN} and ${SCORE_MAX}` };
+ }
+ return { ok: true, next, delta: next - current };
+}
diff --git a/apps/api/src/lib/serialize.ts b/apps/api/src/lib/serialize.ts
new file mode 100644
index 0000000..ed27f98
--- /dev/null
+++ b/apps/api/src/lib/serialize.ts
@@ -0,0 +1,82 @@
+import type { Player, ScoreEvent, Session, SessionSummary } from '@oche/shared';
+
+type SessionRow = {
+ id: string;
+ status: 'active' | 'completed';
+ title: string;
+ videoUrl: string | null;
+ videoPoster: string | null;
+ hlsUrl: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+};
+
+type PlayerRow = {
+ id: string;
+ name: string;
+ score: number;
+ photoUrl: string | null;
+ position: number;
+};
+
+type ScoreEventRow = {
+ id: string;
+ playerId: string;
+ delta: number;
+ newScore: number;
+ createdAt: Date;
+};
+
+function toIso(d: Date): string {
+ return d.toISOString();
+}
+
+export function serializePlayer(p: PlayerRow): Player {
+ return {
+ id: p.id,
+ name: p.name,
+ score: p.score,
+ photoUrl: p.photoUrl ?? undefined,
+ position: p.position,
+ };
+}
+
+export function serializeScoreEvent(e: ScoreEventRow): ScoreEvent {
+ return {
+ id: e.id,
+ playerId: e.playerId,
+ delta: e.delta,
+ newScore: e.newScore,
+ createdAt: toIso(e.createdAt),
+ };
+}
+
+export function serializeSession(
+ s: SessionRow,
+ players: PlayerRow[],
+ scoreEvents: ScoreEventRow[] = [],
+): Session {
+ return {
+ id: s.id,
+ status: s.status,
+ title: s.title,
+ videoUrl: s.videoUrl ?? undefined,
+ videoPoster: s.videoPoster ?? undefined,
+ hlsUrl: s.hlsUrl ?? undefined,
+ players: players.map(serializePlayer),
+ scoreEvents: scoreEvents.map(serializeScoreEvent),
+ createdAt: toIso(s.createdAt),
+ updatedAt: toIso(s.updatedAt),
+ };
+}
+
+export function serializeSessionSummary(s: SessionRow, playerCount: number): SessionSummary {
+ return {
+ id: s.id,
+ status: s.status,
+ title: s.title,
+ videoPoster: s.videoPoster ?? undefined,
+ createdAt: toIso(s.createdAt),
+ playerCount,
+ };
+}
diff --git a/apps/api/src/lib/signed-url.ts b/apps/api/src/lib/signed-url.ts
new file mode 100644
index 0000000..41a681c
--- /dev/null
+++ b/apps/api/src/lib/signed-url.ts
@@ -0,0 +1,70 @@
+const DEFAULT_TTL_SEC = 3600;
+
+function base64url(bytes: ArrayBuffer): string {
+ const bin = String.fromCharCode(...new Uint8Array(bytes));
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+async function hmac(secret: string, payload: string): Promise {
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ new TextEncoder().encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ );
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
+ return base64url(sig);
+}
+
+export function signingSecret(env: { MEDIA_SIGNING_SECRET?: string }): string {
+ return env.MEDIA_SIGNING_SECRET ?? 'dev-media-signing-secret-change-in-prod';
+}
+
+export async function signMediaKey(
+ env: { MEDIA_SIGNING_SECRET?: string },
+ key: string,
+ ttlSec = DEFAULT_TTL_SEC,
+): Promise<{ exp: number; sig: string }> {
+ const exp = Math.floor(Date.now() / 1000) + ttlSec;
+ const sig = await hmac(signingSecret(env), `${key}:${exp}`);
+ return { exp, sig };
+}
+
+export async function verifyMediaKey(
+ env: { MEDIA_SIGNING_SECRET?: string },
+ key: string,
+ exp: number,
+ sig: string,
+): Promise {
+ if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return false;
+ const expected = await hmac(signingSecret(env), `${key}:${exp}`);
+ return timingSafeEqual(expected, sig);
+}
+
+function timingSafeEqual(a: string, b: string): boolean {
+ if (a.length !== b.length) return false;
+ let out = 0;
+ for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ return out === 0;
+}
+
+export async function buildSignedMediaUrl(
+ origin: string,
+ env: { MEDIA_SIGNING_SECRET?: string },
+ key: string,
+): Promise {
+ const { exp, sig } = await signMediaKey(env, key);
+ return `${origin}/media/${key}?exp=${exp}&sig=${sig}`;
+}
+
+/** Resolve an R2 key or legacy absolute URL to a client-fetchable URL. */
+export async function resolveMediaRef(
+ origin: string,
+ env: { MEDIA_SIGNING_SECRET?: string },
+ ref: string | null | undefined,
+): Promise {
+ if (!ref) return undefined;
+ if (ref.startsWith('http://') || ref.startsWith('https://')) return ref;
+ return buildSignedMediaUrl(origin, env, ref);
+}
diff --git a/apps/api/src/middleware.ts b/apps/api/src/middleware.ts
index 62082a2..1d4535a 100644
--- a/apps/api/src/middleware.ts
+++ b/apps/api/src/middleware.ts
@@ -6,8 +6,18 @@ export const securityHeaders: MiddlewareHandler = async (c, next) => {
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
+ c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'; base-uri 'none'");
};
+/** Reject oversized JSON bodies before parsing. */
+export const bodySizeLimit =
+ (maxBytes = 32_768): MiddlewareHandler =>
+ async (c, next) => {
+ const len = Number(c.req.header('content-length') ?? 0);
+ if (len > maxBytes) return c.json({ error: 'Request body too large' }, 413);
+ await next();
+ };
+
/** Tiny in-memory token bucket per colo. Production: use a Durable Object or KV. */
const buckets = new Map();
export const rateLimit =
diff --git a/apps/api/src/openapi/spec.ts b/apps/api/src/openapi/spec.ts
new file mode 100644
index 0000000..e674a1e
--- /dev/null
+++ b/apps/api/src/openapi/spec.ts
@@ -0,0 +1,303 @@
+/** OpenAPI 3.1 document — kept in sync with @oche/shared Zod schemas. */
+export const openApiDocument = {
+ openapi: '3.1.0',
+ info: {
+ title: 'Oche API',
+ version: '1.0.0',
+ description:
+ 'Game Session Dashboard API. All /sessions routes require header `x-oche-owner` (take-home stand-in for JWT auth).',
+ },
+ servers: [{ url: 'http://localhost:8787', description: 'Local wrangler dev' }],
+ tags: [{ name: 'sessions' }, { name: 'media' }],
+ components: {
+ securitySchemes: {
+ OcheOwner: { type: 'apiKey', in: 'header', name: 'x-oche-owner' },
+ },
+ schemas: {
+ ApiError: {
+ type: 'object',
+ required: ['error'],
+ properties: {
+ error: { type: 'string' },
+ issues: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { path: { type: 'string' }, message: { type: 'string' } },
+ },
+ },
+ },
+ },
+ SessionSummary: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', format: 'uuid' },
+ status: { type: 'string', enum: ['active', 'completed'] },
+ title: { type: 'string' },
+ videoPoster: { type: 'string', format: 'uri' },
+ createdAt: { type: 'string', format: 'date-time' },
+ playerCount: { type: 'integer' },
+ },
+ },
+ Session: {
+ allOf: [
+ { $ref: '#/components/schemas/SessionSummary' },
+ {
+ type: 'object',
+ properties: {
+ videoUrl: { type: 'string', format: 'uri' },
+ hlsUrl: { type: 'string', format: 'uri' },
+ updatedAt: { type: 'string', format: 'date-time' },
+ players: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/Player' },
+ },
+ scoreEvents: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/ScoreEvent' },
+ },
+ },
+ },
+ ],
+ },
+ Player: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', format: 'uuid' },
+ name: { type: 'string' },
+ score: { type: 'integer' },
+ photoUrl: { type: 'string', format: 'uri' },
+ position: { type: 'integer' },
+ },
+ },
+ ScoreEvent: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', format: 'uuid' },
+ playerId: { type: 'string', format: 'uuid' },
+ delta: { type: 'integer' },
+ newScore: { type: 'integer' },
+ createdAt: { type: 'string', format: 'date-time' },
+ },
+ },
+ CreateSessionInput: {
+ type: 'object',
+ required: ['title', 'players'],
+ additionalProperties: false,
+ properties: {
+ title: { type: 'string', minLength: 1, maxLength: 160 },
+ status: { type: 'string', enum: ['active', 'completed'], default: 'active' },
+ videoUrl: { type: 'string', description: 'HTTPS URL or R2 key (photos/… or videos/…)' },
+ videoPoster: { type: 'string', description: 'HTTPS URL or R2 key' },
+ players: {
+ type: 'array',
+ minItems: 1,
+ maxItems: 12,
+ items: {
+ type: 'object',
+ required: ['name'],
+ properties: {
+ name: { type: 'string' },
+ score: { type: 'integer', default: 0 },
+ photoUrl: { type: 'string', description: 'HTTPS URL or R2 key' },
+ position: { type: 'integer', default: 0 },
+ },
+ },
+ },
+ },
+ },
+ MediaUploadResponse: {
+ type: 'object',
+ required: ['key', 'kind', 'url'],
+ properties: {
+ key: { type: 'string' },
+ kind: { type: 'string', enum: ['photo', 'video'] },
+ url: { type: 'string', format: 'uri' },
+ },
+ },
+ PatchSessionInput: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ status: { type: 'string', enum: ['active', 'completed'] },
+ scores: {
+ type: 'array',
+ maxItems: 12,
+ items: {
+ type: 'object',
+ required: ['playerId'],
+ properties: {
+ playerId: { type: 'string', format: 'uuid' },
+ delta: { type: 'integer' },
+ set: { type: 'integer' },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ security: [{ OcheOwner: [] }],
+ paths: {
+ '/health': {
+ get: {
+ security: [],
+ responses: {
+ '200': {
+ description: 'Liveness probe',
+ content: { 'application/json': { schema: { type: 'object' } } },
+ },
+ },
+ },
+ },
+ '/sessions': {
+ get: {
+ tags: ['sessions'],
+ parameters: [
+ { name: 'limit', in: 'query', schema: { type: 'integer', default: 20, maximum: 50 } },
+ { name: 'cursor', in: 'query', schema: { type: 'string' } },
+ ],
+ responses: {
+ '200': {
+ description: 'Paginated session list (owner-scoped via RLS)',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ data: { type: 'array', items: { $ref: '#/components/schemas/SessionSummary' } },
+ nextCursor: { type: 'string', nullable: true },
+ },
+ },
+ },
+ },
+ },
+ '401': {
+ description: 'Missing/invalid principal',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ post: {
+ tags: ['sessions'],
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateSessionInput' } } },
+ },
+ responses: {
+ '201': {
+ description: 'Created session',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/Session' } } },
+ },
+ '422': {
+ description: 'Validation error',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ },
+ '/sessions/{id}': {
+ get: {
+ tags: ['sessions'],
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
+ responses: {
+ '200': {
+ description: 'Session detail',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/Session' } } },
+ },
+ '404': {
+ description: 'Not found or not visible (RLS)',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ patch: {
+ tags: ['sessions'],
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/PatchSessionInput' } } },
+ },
+ responses: {
+ '200': {
+ description: 'Updated',
+ content: {
+ 'application/json': { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } },
+ },
+ },
+ '404': {
+ description: 'Not found',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ '422': {
+ description: 'Validation error',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ },
+ '/sessions/{id}/live': {
+ get: {
+ tags: ['sessions'],
+ summary: 'WebSocket upgrade for live score/status stream',
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
+ responses: { '101': { description: 'Switching Protocols' } },
+ },
+ },
+ '/media/upload': {
+ post: {
+ tags: ['media'],
+ summary: 'Upload photo or video to R2 (content-addressed key)',
+ requestBody: {
+ required: true,
+ content: {
+ 'image/jpeg': { schema: { type: 'string', format: 'binary' } },
+ 'image/png': { schema: { type: 'string', format: 'binary' } },
+ 'image/webp': { schema: { type: 'string', format: 'binary' } },
+ 'video/mp4': { schema: { type: 'string', format: 'binary' } },
+ 'video/webm': { schema: { type: 'string', format: 'binary' } },
+ },
+ },
+ responses: {
+ '201': {
+ description: 'Uploaded',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/MediaUploadResponse' } } },
+ },
+ '401': {
+ description: 'Unauthorised',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ '422': {
+ description: 'Validation error',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ },
+ '/media/{key}': {
+ get: {
+ tags: ['media'],
+ security: [],
+ summary: 'Serve media from R2 (signed URL required)',
+ parameters: [
+ { name: 'key', in: 'path', required: true, schema: { type: 'string' } },
+ { name: 'exp', in: 'query', required: true, schema: { type: 'integer' } },
+ { name: 'sig', in: 'query', required: true, schema: { type: 'string' } },
+ { name: 'Range', in: 'header', schema: { type: 'string' } },
+ ],
+ responses: {
+ '200': { description: 'Full object' },
+ '206': { description: 'Partial content (byte-range)' },
+ '403': {
+ description: 'Invalid signature',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ '404': {
+ description: 'Not found',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ },
+ },
+} as const;
diff --git a/apps/api/src/principal.ts b/apps/api/src/principal.ts
index eb0a659..e741925 100644
--- a/apps/api/src/principal.ts
+++ b/apps/api/src/principal.ts
@@ -1,4 +1,5 @@
import type { Context } from 'hono';
+import type { Env } from './env.js';
/**
* Resolve the request principal (owner id).
@@ -12,7 +13,24 @@ const DEMO_OWNERS: Record = {
'demo-key-b': '22222222-2222-2222-2222-222222222222',
};
-export function resolvePrincipal(c: Context): string | null {
- const key = c.req.header('x-oche-owner') ?? 'demo-key-a';
- return DEMO_OWNERS[key] ?? null;
+function extraOwners(env: Env): Record {
+ if (!env.OCHE_API_KEYS) return {};
+ try {
+ const parsed = JSON.parse(env.OCHE_API_KEYS) as unknown;
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
+ return Object.fromEntries(
+ Object.entries(parsed).filter(
+ ([k, v]) => typeof k === 'string' && typeof v === 'string' && k.length > 0 && v.length > 0,
+ ),
+ );
+ } catch {
+ return {};
+ }
+}
+
+export function resolvePrincipal(c: Context<{ Bindings: Env }>): string | null {
+ const key = c.req.header('x-oche-owner') ?? c.req.query('key');
+ if (!key) return null;
+ const owners = { ...DEMO_OWNERS, ...extraOwners(c.env) };
+ return owners[key] ?? null;
}
diff --git a/apps/api/src/routes/media.ts b/apps/api/src/routes/media.ts
new file mode 100644
index 0000000..84afa39
--- /dev/null
+++ b/apps/api/src/routes/media.ts
@@ -0,0 +1,70 @@
+import { Hono } from 'hono';
+import { buildMediaKey } from '../lib/media-keys.js';
+import { parseRangeHeader, validateUpload } from '../lib/media-policy.js';
+import { buildSignedMediaUrl, verifyMediaKey } from '../lib/signed-url.js';
+import { resolvePrincipal } from '../principal.js';
+import type { Env } from '../env.js';
+
+const media = new Hono<{ Bindings: Env }>();
+
+/** Upload photo/video to R2 (immutable hashed key). Requires principal header. */
+media.post('/upload', async (c) => {
+ const ownerId = resolvePrincipal(c);
+ if (!ownerId) return c.json({ error: 'Unauthorised' }, 401);
+
+ const mime = c.req.header('content-type')?.split(';')[0]?.trim() ?? '';
+ const bytes = await c.req.arrayBuffer();
+ const checked = validateUpload(mime, bytes.byteLength);
+ if (!checked.ok) return c.json({ error: checked.error }, 422);
+
+ const key = await buildMediaKey(mime, bytes, checked.kind);
+ await c.env.MEDIA.put(key, bytes, {
+ httpMetadata: {
+ contentType: mime,
+ cacheControl: 'public, max-age=31536000, immutable',
+ },
+ customMetadata: { ownerId, kind: checked.kind },
+ });
+
+ const origin = new URL(c.req.url).origin;
+ const url = await buildSignedMediaUrl(origin, c.env, key);
+
+ return c.json({ key, kind: checked.kind, url }, 201);
+});
+
+/** Serve media from R2 with byte-range support; requires valid signed query params. */
+media.get('/:key{.+}', async (c) => {
+ const key = c.req.param('key');
+ const exp = Number(c.req.query('exp'));
+ const sig = c.req.query('sig') ?? '';
+ const ok = await verifyMediaKey(c.env, key, exp, sig);
+ if (!ok) return c.json({ error: 'Invalid or expired media URL' }, 403);
+
+ const head = await c.env.MEDIA.head(key);
+ if (!head) return c.json({ error: 'Not found' }, 404);
+
+ const size = head.size;
+ const range = parseRangeHeader(c.req.header('range'), size);
+
+ const object = range
+ ? await c.env.MEDIA.get(key, { range: { offset: range.offset, length: range.length } })
+ : await c.env.MEDIA.get(key);
+
+ if (!object) return c.json({ error: 'Not found' }, 404);
+
+ const headers = new Headers();
+ headers.set('Content-Type', head.httpMetadata?.contentType ?? 'application/octet-stream');
+ headers.set('Cache-Control', 'public, max-age=31536000, immutable');
+ headers.set('Accept-Ranges', 'bytes');
+
+ if (range) {
+ headers.set('Content-Range', `bytes ${range.offset}-${range.offset + range.length - 1}/${size}`);
+ headers.set('Content-Length', String(range.length));
+ return new Response(object.body, { status: 206, headers });
+ }
+
+ headers.set('Content-Length', String(size));
+ return new Response(object.body, { status: 200, headers });
+});
+
+export { media };
diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts
new file mode 100644
index 0000000..46f4fd1
--- /dev/null
+++ b/apps/api/src/routes/sessions.ts
@@ -0,0 +1,79 @@
+import { CreateSessionInput, PatchSessionInput } from '@oche/shared';
+import { Hono } from 'hono';
+import type { Env } from '../env.js';
+import { enrichSession, enrichSummary } from '../lib/enrich-media.js';
+import { readJsonBody, validationError } from '../lib/errors.js';
+import { getDb } from '../lib/db.js';
+import { parseListQuery } from '../lib/pagination.js';
+import {
+ createSession,
+ getSession,
+ listSessions,
+ notifySessionRoom,
+ patchSession,
+} from '../services/sessions.js';
+import { resolvePrincipal } from '../principal.js';
+
+export type ApiVariables = { ownerId: string };
+
+const sessions = new Hono<{ Bindings: Env; Variables: ApiVariables }>();
+
+sessions.use('*', async (c, next) => {
+ const ownerId = resolvePrincipal(c);
+ if (!ownerId) return c.json({ error: 'Unauthorised' }, 401);
+ c.set('ownerId', ownerId);
+ await next();
+});
+
+sessions.get('/', async (c) => {
+ const { limit, cursor } = parseListQuery(c.req.url);
+ const db = getDb(c.env);
+ const result = await listSessions(db, c.get('ownerId'), { limit, cursor });
+ const origin = new URL(c.req.url).origin;
+ const data = await Promise.all(result.data.map((s) => enrichSummary(origin, c.env, s)));
+ c.header('Cache-Control', 'private, max-age=15');
+ return c.json({ data, nextCursor: result.nextCursor });
+});
+
+/** Must be registered before `/:id` so `live` is not captured as an id. */
+sessions.get('/:id/live', (c) => {
+ const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(c.req.param('id')));
+ return stub.fetch(c.req.raw);
+});
+
+sessions.get('/:id', async (c) => {
+ const db = getDb(c.env);
+ const session = await getSession(db, c.get('ownerId'), c.req.param('id'));
+ if (!session) return c.json({ error: 'Session not found' }, 404);
+ const origin = new URL(c.req.url).origin;
+ return c.json(await enrichSession(origin, c.env, session));
+});
+
+sessions.post('/', async (c) => {
+ const parsed = CreateSessionInput.safeParse(await readJsonBody(c));
+ if (!parsed.success) return validationError(c, parsed.error);
+
+ const db = getDb(c.env);
+ const created = await createSession(db, c.get('ownerId'), parsed.data);
+ const origin = new URL(c.req.url).origin;
+ return c.json(await enrichSession(origin, c.env, created), 201);
+});
+
+sessions.patch('/:id', async (c) => {
+ const parsed = PatchSessionInput.safeParse(await readJsonBody(c));
+ if (!parsed.success) return validationError(c, parsed.error);
+
+ const id = c.req.param('id');
+ const db = getDb(c.env);
+ const result = await patchSession(db, c.get('ownerId'), id, parsed.data);
+
+ if (result === null) return c.json({ error: 'Session not found' }, 404);
+ if ('error' in result) return c.json({ error: result.error }, result.status);
+
+ const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id));
+ await notifySessionRoom(stub, result.broadcasts);
+
+ return c.json({ ok: true as const });
+});
+
+export { sessions };
diff --git a/apps/api/src/services/sessions.ts b/apps/api/src/services/sessions.ts
new file mode 100644
index 0000000..8f8f9ad
--- /dev/null
+++ b/apps/api/src/services/sessions.ts
@@ -0,0 +1,165 @@
+import { players, scoreEvents, sessions, withPrincipal, type Db } from '@oche/db';
+import type { CreateSessionInput, PatchSessionInput, WsServerMessage } from '@oche/shared';
+import { and, desc, eq, inArray, lt, or } from 'drizzle-orm';
+import { encodeCursor, type ListCursor } from '../lib/pagination.js';
+import { applyScoreChange } from '../lib/scores.js';
+import { serializeSession, serializeSessionSummary } from '../lib/serialize.js';
+
+export async function listSessions(
+ db: Db,
+ ownerId: string,
+ opts: { limit: number; cursor: ListCursor | null },
+) {
+ return withPrincipal(db, ownerId, async (tx) => {
+ const cursorDate = opts.cursor ? new Date(opts.cursor.createdAt) : null;
+ const cursorFilter =
+ opts.cursor && cursorDate
+ ? or(
+ lt(sessions.createdAt, cursorDate),
+ and(eq(sessions.createdAt, cursorDate), lt(sessions.id, opts.cursor!.id)),
+ )
+ : undefined;
+
+ const list = await tx
+ .select()
+ .from(sessions)
+ .where(cursorFilter)
+ .orderBy(desc(sessions.createdAt), desc(sessions.id))
+ .limit(opts.limit + 1);
+
+ const page = list.slice(0, opts.limit);
+ const hasMore = list.length > opts.limit;
+
+ const data = await Promise.all(
+ page.map(async (s) => {
+ const ps = await tx.select().from(players).where(eq(players.sessionId, s.id));
+ return serializeSessionSummary(s, ps.length);
+ }),
+ );
+
+ const last = page.at(-1);
+ const nextCursor =
+ hasMore && last ? encodeCursor({ createdAt: last.createdAt.toISOString(), id: last.id }) : null;
+
+ return { data, nextCursor };
+ });
+}
+
+export async function getSession(db: Db, ownerId: string, sessionId: string) {
+ return withPrincipal(db, ownerId, async (tx) => {
+ const [s] = await tx.select().from(sessions).where(eq(sessions.id, sessionId));
+ if (!s) return null;
+
+ const ps = await tx
+ .select()
+ .from(players)
+ .where(eq(players.sessionId, sessionId))
+ .orderBy(players.position);
+
+ const playerIds = ps.map((p) => p.id);
+ const events =
+ playerIds.length > 0
+ ? await tx
+ .select()
+ .from(scoreEvents)
+ .where(inArray(scoreEvents.playerId, playerIds))
+ .orderBy(desc(scoreEvents.createdAt))
+ .limit(50)
+ : [];
+
+ return serializeSession(s, ps, events);
+ });
+}
+
+export async function createSession(db: Db, ownerId: string, input: CreateSessionInput) {
+ return withPrincipal(db, ownerId, async (tx) => {
+ const [s] = await tx
+ .insert(sessions)
+ .values({
+ ownerId,
+ title: input.title,
+ status: input.status,
+ videoUrl: input.videoUrl ?? null,
+ videoPoster: input.videoPoster ?? null,
+ })
+ .returning();
+
+ const inserted = await tx
+ .insert(players)
+ .values(input.players.map((p) => ({ ...p, sessionId: s!.id, photoUrl: p.photoUrl ?? null })))
+ .returning();
+
+ return serializeSession(s!, inserted, []);
+ });
+}
+
+export type PatchResult = {
+ broadcasts: WsServerMessage[];
+};
+
+export async function patchSession(
+ db: Db,
+ ownerId: string,
+ sessionId: string,
+ input: PatchSessionInput,
+): Promise {
+ return withPrincipal(db, ownerId, async (tx) => {
+ const [s] = await tx.select().from(sessions).where(eq(sessions.id, sessionId));
+ if (!s) return null;
+
+ const broadcasts: WsServerMessage[] = [];
+
+ if (input.status && input.status !== s.status) {
+ await tx
+ .update(sessions)
+ .set({ status: input.status, updatedAt: new Date() })
+ .where(eq(sessions.id, sessionId));
+ broadcasts.push({ type: 'status', status: input.status });
+ }
+
+ for (const change of input.scores ?? []) {
+ const [p] = await tx
+ .select()
+ .from(players)
+ .where(and(eq(players.id, change.playerId), eq(players.sessionId, sessionId)));
+ if (!p) continue;
+
+ const result = applyScoreChange(p.score, change);
+ if (!result.ok) return { error: result.error, status: 422 as const };
+
+ if (result.delta === 0) continue;
+
+ await tx.update(players).set({ score: result.next }).where(eq(players.id, p.id));
+ await tx.insert(scoreEvents).values({
+ playerId: p.id,
+ delta: result.delta,
+ newScore: result.next,
+ });
+ broadcasts.push({
+ type: 'score',
+ playerId: p.id,
+ newScore: result.next,
+ delta: result.delta,
+ });
+ }
+
+ if (input.scores?.length || input.status) {
+ await tx.update(sessions).set({ updatedAt: new Date() }).where(eq(sessions.id, sessionId));
+ }
+
+ return { broadcasts };
+ });
+}
+
+export async function notifySessionRoom(
+ stub: { fetch(input: RequestInfo, init?: RequestInit): Promise },
+ messages: WsServerMessage[],
+): Promise {
+ for (const message of messages) {
+ await stub.fetch('https://session-room/broadcast', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(message),
+ });
+ }
+}
diff --git a/apps/api/src/session-room.ts b/apps/api/src/session-room.ts
index 1670cc0..d6ca2ba 100644
--- a/apps/api/src/session-room.ts
+++ b/apps/api/src/session-room.ts
@@ -1,10 +1,9 @@
/**
- * SessionRoom — a SQLite-backed Durable Object (free-tier eligible) using
- * hibernatable WebSockets. One instance per session id. Broadcasts typed score/status
- * messages and can self-drive a "simulate" mode so reviewers see live updates.
+ * SessionRoom — SQLite-backed Durable Object (free-tier) with hibernatable WebSockets.
+ * One instance per session id. Broadcasts typed score/status messages; supports simulate mode.
*/
import type { DurableObjectState } from '@cloudflare/workers-types';
-import type { WsServerMessage } from '@oche/shared';
+import { WsServerMessage } from '@oche/shared';
import type { Env } from './env.js';
export class SessionRoom {
@@ -14,23 +13,32 @@ export class SessionRoom {
) {}
async fetch(req: Request): Promise {
+ const url = new URL(req.url);
+
+ if (req.method === 'POST' && url.pathname === '/broadcast') {
+ const raw = await req.json().catch(() => null);
+ const parsed = WsServerMessage.safeParse(raw);
+ if (!parsed.success) return Response.json({ error: 'Invalid broadcast payload' }, { status: 422 });
+ await this.broadcast(parsed.data);
+ return Response.json({ ok: true });
+ }
+
if (req.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 });
}
+
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
- // Hibernation API: accept on the DO context, not server.accept().
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
- /** Called by the Worker (via RPC/fetch) after a score PATCH. */
async broadcast(message: WsServerMessage): Promise {
const payload = JSON.stringify(message);
for (const ws of this.ctx.getWebSockets()) ws.send(payload);
}
- async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise {
+ async webSocketMessage(_ws: WebSocket, raw: string | ArrayBuffer): Promise {
if (typeof raw !== 'string') return;
try {
const msg = JSON.parse(raw) as { type: string; enabled?: boolean };
diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml
index f74c341..eb53ccc 100644
--- a/apps/api/wrangler.toml
+++ b/apps/api/wrangler.toml
@@ -3,6 +3,11 @@ main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
+# Local dev defaults — DATABASE_URL goes in apps/api/.dev.vars (see scripts/wrangler-dev.mjs)
+[vars]
+ENVIRONMENT = "development"
+APP_ORIGIN = "http://localhost:5173"
+
# Durable Object: SQLite backend is required for the free plan.
[[durable_objects.bindings]]
name = "SESSION_ROOM"
@@ -12,6 +17,11 @@ class_name = "SessionRoom"
tag = "v1"
new_sqlite_classes = ["SessionRoom"]
+# Local R2 (wrangler dev simulates the bucket)
+[[r2_buckets]]
+binding = "MEDIA"
+bucket_name = "oche-media-dev"
+
# ---------------- Staging ----------------
[env.staging]
name = "oche-api-staging"
@@ -20,11 +30,11 @@ routes = [{ pattern = "oche-api-staging.humza-butt.space", custom_domain = true
[[env.staging.hyperdrive]]
binding = "HYPERDRIVE"
-id = "" # -> Neon `staging` branch
+id = "28996c3eb80d497bac9e39b295e776d4" # -> Neon `staging` branch
[[env.staging.r2_buckets]]
binding = "MEDIA"
-bucket_name = "oche-media-staging"
+bucket_name = "oche-media-r2-staging"
[[env.staging.durable_objects.bindings]]
name = "SESSION_ROOM"
@@ -38,11 +48,11 @@ routes = [{ pattern = "oche-api.humza-butt.space", custom_domain = true }]
[[env.production.hyperdrive]]
binding = "HYPERDRIVE"
-id = "" # -> Neon `main` branch
+id = "d126821baab0488cae059c168ab5c8bc" # -> Neon `main` branch
[[env.production.r2_buckets]]
binding = "MEDIA"
-bucket_name = "oche-media"
+bucket_name = "oche-media-r2-prod"
[[env.production.durable_objects.bindings]]
name = "SESSION_ROOM"
diff --git a/apps/web/src/components/PlayerAvatar.tsx b/apps/web/src/components/PlayerAvatar.tsx
new file mode 100644
index 0000000..935db55
--- /dev/null
+++ b/apps/web/src/components/PlayerAvatar.tsx
@@ -0,0 +1,32 @@
+/** Player photo with responsive sizing, lazy load, and initials fallback. */
+export function PlayerAvatar({ name, photoUrl }: { name: string; photoUrl?: string | null }) {
+ const initials = name
+ .split(/\s+/)
+ .map((w) => w[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase();
+
+ if (photoUrl) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {initials}
+
+ );
+}
diff --git a/apps/web/src/hooks/useLiveSession.ts b/apps/web/src/hooks/useLiveSession.ts
index 8c03bfc..6539a1c 100644
--- a/apps/web/src/hooks/useLiveSession.ts
+++ b/apps/web/src/hooks/useLiveSession.ts
@@ -1,14 +1,16 @@
-import { WsServerMessage } from '@oche/shared';
-import { useEffect, useRef } from 'react';
+import { WsServerMessage, type WsServerMessage as WsMessage } from '@oche/shared';
+import { useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
/**
* Subscribe to a session's live updates via WebSocket, with auto-reconnect.
- * Falls back to TanStack Query polling if the socket can't connect (handled by caller).
+ * Returns `connected` so callers can enable TanStack Query polling as a fallback
+ * when the socket is down (see docs/PERFORMANCE.md).
*/
-export function useLiveSession(sessionId: string, onMessage: (m: WsServerMessage) => void) {
+export function useLiveSession(sessionId: string, onMessage: (m: WsMessage) => void) {
const onMsg = useRef(onMessage);
onMsg.current = onMessage;
+ const [connected, setConnected] = useState(false);
useEffect(() => {
let ws: WebSocket | null = null;
@@ -17,21 +19,30 @@ export function useLiveSession(sessionId: string, onMessage: (m: WsServerMessage
const connect = () => {
ws = new WebSocket(api.wsUrl(sessionId));
- ws.onopen = () => (retry = 0);
+ ws.onopen = () => {
+ retry = 0;
+ setConnected(true);
+ };
ws.onmessage = (e) => {
- const parsed = WsServerMessage.safeParse(JSON.parse(e.data));
+ const parsed = WsServerMessage.safeParse(JSON.parse(String(e.data)));
if (parsed.success) onMsg.current(parsed.data);
};
ws.onclose = () => {
+ setConnected(false);
if (closed) return;
retry = Math.min(retry + 1, 5);
- setTimeout(connect, retry * 1000); // backoff; caller polls in the meantime
+ setTimeout(connect, retry * 1000);
};
+ ws.onerror = () => ws?.close();
};
+
connect();
return () => {
closed = true;
+ setConnected(false);
ws?.close();
};
}, [sessionId]);
+
+ return { connected };
}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 42d043f..6505fa4 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -19,5 +19,5 @@ export const api = {
getSession: (id: string) => req(`/sessions/${id}`),
patchScores: (id: string, scores: Array<{ playerId: string; delta?: number; set?: number }>) =>
req<{ ok: true }>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify({ scores }) }),
- wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live`,
+ wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live?key=demo-key-a`,
};
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index c4e6c62..a451717 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,22 +1,51 @@
import { QueryClientProvider } from '@tanstack/react-query';
+import { lazy, Suspense } from 'react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
import { queryClient } from './lib/query';
-import { History } from './routes/History';
-import { Overview } from './routes/Overview';
-import { SessionDetail } from './routes/SessionDetail';
+
+const Overview = lazy(() => import('./routes/Overview').then((m) => ({ default: m.Overview })));
+const History = lazy(() => import('./routes/History').then((m) => ({ default: m.History })));
+const SessionDetail = lazy(() =>
+ import('./routes/SessionDetail').then((m) => ({ default: m.SessionDetail })),
+);
+
+function Page({ children }: { children: React.ReactNode }) {
+ return Loading…
}>{children};
+}
const router = createBrowserRouter([
{
path: '/',
element: ,
children: [
- { index: true, element: },
- { path: 'history', element: },
- { path: 'history/:id', element: },
+ {
+ index: true,
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'history',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'history/:id',
+ element: (
+
+
+
+ ),
+ },
],
},
]);
diff --git a/apps/web/src/routes/History.tsx b/apps/web/src/routes/History.tsx
index d58dd8b..45071c7 100644
--- a/apps/web/src/routes/History.tsx
+++ b/apps/web/src/routes/History.tsx
@@ -2,23 +2,53 @@ import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
-/** Match history: a list of past sessions; click loads detail + plays the video. */
+function formatWhen(iso: string) {
+ return new Date(iso).toLocaleDateString(undefined, {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+}
+
+/** Match history: paginated list; each row links to detail + video. */
export function History() {
const { data, isLoading, error } = useQuery({ queryKey: ['sessions'], queryFn: api.listSessions });
+
if (isLoading) return Loading history…
;
if (error) return Couldn’t load history.
;
+ if (!data?.data.length) return No completed sessions yet.
;
return (
- {data!.data.map((s) => (
+ {data.data.map((s) => (
-
- {s.title}
-
- {s.playerCount} players · {s.status}
+ {s.videoPoster ? (
+
+ ) : (
+
+ No video
+
+ )}
+
+ {s.title}
+
+ {formatWhen(s.createdAt)} · {s.playerCount} players · {s.status}
+
diff --git a/apps/web/src/routes/Overview.tsx b/apps/web/src/routes/Overview.tsx
index d40fa42..d2e1abb 100644
--- a/apps/web/src/routes/Overview.tsx
+++ b/apps/web/src/routes/Overview.tsx
@@ -1,6 +1,7 @@
import type { Session } from '@oche/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
+import { PlayerAvatar } from '@/components/PlayerAvatar';
import { useLiveSession } from '@/hooks/useLiveSession';
import { api } from '@/lib/api';
@@ -20,15 +21,26 @@ export function Overview() {
function SessionPanel({ id }: { id: string }) {
const qc = useQueryClient();
- const { data: session } = useQuery({ queryKey: ['session', id], queryFn: () => api.getSession(id) });
const [pulse, setPulse] = useState(null);
+ const [saveError, setSaveError] = useState(null);
- useLiveSession(id, (m) => {
+ const { connected } = useLiveSession(id, (m) => {
if (m.type === 'score') {
setPulse(m.playerId);
qc.invalidateQueries({ queryKey: ['session', id] });
}
+ if (m.type === 'status') {
+ qc.invalidateQueries({ queryKey: ['session', id] });
+ qc.invalidateQueries({ queryKey: ['sessions'] });
+ }
});
+
+ const { data: session } = useQuery({
+ queryKey: ['session', id],
+ queryFn: () => api.getSession(id),
+ refetchInterval: connected ? false : 5_000,
+ });
+
useEffect(() => {
if (!pulse) return;
const t = setTimeout(() => setPulse(null), 600);
@@ -36,7 +48,7 @@ function SessionPanel({ id }: { id: string }) {
}, [pulse]);
async function bump(playerId: string, delta: number, current: Session) {
- // optimistic
+ setSaveError(null);
qc.setQueryData(['session', id], {
...current,
players: current.players.map((p) => (p.id === playerId ? { ...p, score: p.score + delta } : p)),
@@ -45,33 +57,54 @@ function SessionPanel({ id }: { id: string }) {
await api.patchScores(id, [{ playerId, delta }]);
} catch {
qc.invalidateQueries({ queryKey: ['session', id] });
+ setSaveError('Score update failed — reverted.');
}
}
if (!session) return null;
+
return (
-
-
{session.title}
+
+
+
{session.title}
+
+ {connected ? 'Live' : 'Polling every 5s (WebSocket reconnecting…)'}
+
+
{session.status}
-
+
+ {saveError ? (
+
+ {saveError}
+
+ ) : null}
+
+
{session.players.map((p) => (
-
- {p.name}
+
+
+ {p.name}
+
- {p.score}
+
+ {p.score}
+