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} +