Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .cursor/rules/110-docs.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ globs: "**/*.md"
- Game mode “how to play” copy: see **32-game-guides** — single source `apps/web/src/content/game-guides.ts` + [docs/GAME-GUIDES.md](../../docs/GAME-GUIDES.md).
- Form field tooltips: see **33-field-tooltips** — `field-tooltips.ts` + [docs/FIELD-TOOLTIPS.md](../../docs/FIELD-TOOLTIPS.md).
- Auth / JWT / API keys: see **34-auth** — [docs/AUTH.md](../../docs/AUTH.md).
- Scale / cache / rate limits: see **35-scale** — [docs/SCALE.md](../../docs/SCALE.md).
- Testing / contracts / visual: see **36-quality-dx**.
- PWA / i18n / scorecard / analytics / API docs: see **37-tier7-extras** — [docs/EXTRAS.md](../../docs/EXTRAS.md).
3 changes: 2 additions & 1 deletion .cursor/rules/33-field-tooltips.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Every **text input**, **select**, **textarea**, and **file picker** in the SPA m
| --------- | ------- |
| `TextField` | `type="text"`, email, number, etc. |
| `SelectField` | `<select>` dropdowns |
| `FileField` | Visible file inputs |
| `FileField` | Visible file inputs — hidden `<input type="file">` + outline **Choose file** button (same affordance as `MediaUpload`) |
| `MediaUpload` | Hidden file input + upload UX |
| `FieldTooltipIcon` | Custom labels (only if wrapping a control manually) |

Expand All @@ -46,6 +46,7 @@ import { FIELD_TOOLTIPS } from '@/content/field-tooltips';
## Don't

- Use placeholder text as the only help (placeholders ≠ tooltips).
- Ship a bare native file input in the UI — always use the hidden input + visible button pattern.
- Ship `title=""` or omit `aria-describedby`.
- Add `@radix-ui/react-tooltip` unless explicitly requested — use shared `form-fields` pattern.

Expand Down
31 changes: 31 additions & 0 deletions .cursor/rules/35-scale.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
description: Scale, caching, rate limits, observability
globs: "apps/api/**/*.ts"
---
# Scale & reliability (Tier 5)

## Rate limiting

- Use `RateLimiter` Durable Object (`RATE_LIMITER` binding) — one DO per IP.
- Do not reintroduce global in-memory Maps for production limits.
- Exempt `/health` and `/openapi.json` from rate limit.

## Session list cache

- Cache only `GET /sessions` via `lib/session-cache.ts` + `CACHE` KV.
- TTL 60s in KV (Cloudflare minimum); HTTP `max-age=15`; invalidate with `bumpSessionListCache()` on `POST` / `PATCH`.
- Set `X-Cache: HIT|MISS` on list responses.

## Logging

- Use `logInfo` / `logWarn` / `logError` from `lib/logger.ts` — JSON only, no raw `console.log` in routes.
- Every response gets `X-Trace-Id`; include `traceId` in 500 JSON body.

## Health

- Shallow: `GET /health` (no DB).
- Readiness: `GET /health?deep=1` — `select 1`; return 503 if DB unreachable.

## Docs

[docs/SCALE.md](../../docs/SCALE.md) · queue/transcode pipeline in [docs/MEDIA.md](../../docs/MEDIA.md)
29 changes: 29 additions & 0 deletions .cursor/rules/36-quality-dx.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Testing, contracts, visual regression, deploy DX
globs: "{apps/web,tests}/**/*.{ts,tsx}"
---
# Quality & DX (Tier 6)

## SPA component tests

- Use **Vitest + Testing Library** in `apps/web/src/**/*.test.tsx` (jsdom).
- Mock `@/lib/api` and hooks; wrap with `QueryClientProvider` (+ `MemoryRouter` when routing matters).
- Mock `@number-flow/react` in `apps/web/src/test/setup.ts`.

## Contract tests

- `tests/contracts/` — OpenAPI paths must match homework + `@oche/shared` Zod schemas stay aligned.
- Run: `npm run test:contracts`

## Visual regression

- Playwright `@visual` tests in `tests/e2e/visual.spec.ts`; snapshots under `tests/e2e/__snapshots__/`.
- Run: `npm run test:visual` · update baselines: `npx playwright test --grep @visual --update-snapshots`

## Deploy

- **Staging full pipeline:** `npm run deploy:staging:full` (migrate → force-rls → rls check → seed → deploy).

## Wrangler

- Project uses **Wrangler 4** (`apps/api`, `apps/web`). Run wrangler from `apps/api` for API/KV/DO commands.
36 changes: 36 additions & 0 deletions .cursor/rules/37-tier7-extras.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
description: PWA, i18n, scorecard export, analytics, API docs (Tier 7)
globs: "{apps/web,scripts}/**/*.{ts,tsx,mjs,html,json,svg}"
---
# Nice extras (Tier 7)

## PWA / offline read-only

- **vite-plugin-pwa** in `apps/web/vite.config.ts` — Workbox `NetworkFirst` for GET `/sessions*`.
- **React Query persistence** — `PersistQueryClientProvider` in `main.tsx`; keys `sessions` + `session` only.
- **Offline banner** — `OfflineBanner` in `App.tsx`; hide upload/mutations when offline.
- Mutations stay `networkMode: 'online'` — no offline score edits.

## Scorecard export

- `apps/web/src/lib/export-scorecard.ts` — print-ready HTML → browser Save as PDF.
- Button on `SessionDetail` header only (history detail).

## i18n

- **i18next** + single `en` locale: `apps/web/src/i18n/`.
- Nav + session detail + scorecard labels use `useTranslation()`.
- Game guides / field tooltips stay English in source files until a second locale ships.

## Analytics

- Optional `VITE_PLAUSIBLE_DOMAIN` or `VITE_CF_WEB_ANALYTICS_TOKEN` — `initAnalytics()` in `main.tsx`.
- No custom events with session IDs or PII.

## OpenAPI docs

- Source: `apps/api/src/openapi/spec.ts` → `npm run export:openapi` → `apps/web/public/openapi.json`.
- Static Redoc: `/docs/` (`apps/web/public/docs/index.html`).
- Live JSON also at `{API}/openapi.json`.

Human index: [docs/EXTRAS.md](../../docs/EXTRAS.md)
3 changes: 2 additions & 1 deletion .cursor/rules/40-api-hono.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ globs: "apps/api/**/*.ts"
- Endpoints: GET /sessions, GET /sessions/:id, POST /sessions, PATCH /sessions/:id, WS /sessions/:id/live.
- Validate bodies with Zod `.strict()`; respond `{ error, issues? }` on failure (422), never a stack trace.
- Resolve the principal once per request; wrap every DB call in `withPrincipal()`.
- CORS allowlist = env `APP_ORIGIN`. Security headers + rate limit on all routes.
- CORS allowlist = env `APP_ORIGIN`. Security headers + DO rate limit on all routes (except `/health`, `/openapi.json`).
- Structured JSON logging + `X-Trace-Id`; deep readiness at `GET /health?deep=1`. See **35-scale**.
2 changes: 1 addition & 1 deletion .cursor/rules/60-realtime-do.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
description: Durable Object realtime conventions
globs: "apps/api/src/session-room.ts"
---
- `SessionRoom` is SQLite-backed (`new_sqlite_classes`) — required for the free plan.
- `SessionRoom` and `RateLimiter` are SQLite-backed (`new_sqlite_classes`) — required for the free plan.
- Use hibernatable WebSockets (`ctx.acceptWebSocket`, `webSocketMessage`, `webSocketClose`).
- Messages are typed from `@oche/shared`. Keep frames small. Simulate mode via `storage.setAlarm`.
3 changes: 3 additions & 0 deletions .cursor/rules/80-testing.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ description: Testing expectations
globs: "**/*.{test,spec}.{ts,tsx}"
---
- Many small, focused unit tests covering every branch + error path.
- SPA: Vitest + Testing Library in `apps/web/src/**/*.test.tsx` — mock `api`, wrap with QueryClient.
- Contract: `npm run test:contracts` — OpenAPI paths + Zod schema alignment.
- Visual: `npm run test:visual` — Playwright `@visual` snapshots (1280×720 viewport).
- `test:rls` must prove owner A cannot read owner B's rows (select/update/delete).
- Coverage thresholds: db/shared/api >= 90%, web >= 80%. Deterministic; fake timers for DO/simulate.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ CLOUDFLARE_API_TOKEN=""
VITE_API_BASE="http://localhost:8787"
VITE_API_BASE_STAGING="https://oche-api-staging.humza-butt.space"
VITE_API_BASE_PROD="https://oche-api.humza-butt.space"
# Optional privacy-preserving analytics (leave unset locally)
# VITE_PLAUSIBLE_DOMAIN="oche.humza-butt.space"
# VITE_CF_WEB_ANALYTICS_TOKEN=""
APP_ORIGIN="http://localhost:5173"
MEDIA_SIGNING_SECRET="dev-media-signing-secret-change-in-prod"
OCHE_JWT_SECRET="dev-jwt-secret-change-in-prod"
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,26 @@ Vite + React 19 SPA (TanStack Query, Tailwind v4 + shadcn) · Hono on Cloudflare

## Quick start

Works on **macOS, Linux, and Windows** (PowerShell). No `cp` or `psql` required — use the npm scripts.

```bash
npm run setup # install + .env / .dev.vars templates
# Fill DATABASE_URL in .env, then:
npm run setup:dev-vars:sync
npm run db:prepare # migrate + force-rls + seed
# Fill DATABASE_URL (+ DATABASE_URL_STAGING for deploy) in .env, then:
npm run setup:dev-vars:sync # sync secrets into apps/api/.dev.vars
npm run db:prepare # migrate + force-rls + seed (one command)
npm run dev # web :5173 + api :8787
npm run check # typecheck + lint + unit tests + bundle budget
npm run db:rls:check # confirm RLS is enabled + forced everywhere
```

**First deploy to staging (full pipeline):**

```bash
npm run deploy:staging:full # migrate + force-rls + rls check + seed + deploy
```

See [docs/ENVIRONMENTS.md](docs/ENVIRONMENTS.md) for staging vs production URLs and secrets.

## API

| Method | Path | Purpose |
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241200.0",
"wrangler": "^3.95.0"
"wrangler": "^4.24.0"
}
}
39 changes: 39 additions & 0 deletions apps/api/src/__tests__/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest';
import { createApp } from '../app.js';
import type { Env } from '../env.js';

vi.mock('../lib/db.js', () => ({
getDb: vi.fn(() => ({
execute: vi.fn(async () => [{ ok: 1 }]),
})),
getConnectionString: vi.fn(),
}));

function mockEnv(): Env {
return {
DATABASE_URL: 'postgresql://test',
APP_ORIGIN: 'http://localhost:5173',
ENVIRONMENT: 'development',
SESSION_ROOM: {
idFromName: () => ({ fetch: vi.fn(async () => new Response('{}')) }),
} as unknown as Env['SESSION_ROOM'],
MEDIA: {} as Env['MEDIA'],
};
}

describe('health', () => {
it('GET /health returns liveness', async () => {
const app = createApp();
const res = await app.request('/health', {}, mockEnv());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ ok: true, env: 'development' });
expect(res.headers.get('X-Trace-Id')).toBeTruthy();
});

it('GET /health?deep=1 pings the database', async () => {
const app = createApp();
const res = await app.request('/health?deep=1', {}, mockEnv());
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ ok: true, db: 'ok' });
});
});
44 changes: 44 additions & 0 deletions apps/api/src/__tests__/rate-limiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { RateLimiter } from '../rate-limiter.js';

describe('RateLimiter', () => {
it('allows requests within the bucket', async () => {
const storage = new Map<string, unknown>();
const ctx = {
storage: {
get: async (k: string) => storage.get(k),
put: async (k: string, v: unknown) => storage.set(k, v),
},
};
const limiter = new RateLimiter(ctx as never, {} as never);

const res = await limiter.fetch(
new Request('https://rate-limiter/check', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ limit: 2, windowMs: 60_000 }),
}),
);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ allowed: true, remaining: 1 });
});

it('returns 429 when bucket is empty', async () => {
const storage = new Map<string, unknown>([['bucket', { tokens: 0, ts: Date.now() }]]);
const ctx = {
storage: {
get: async (k: string) => storage.get(k),
put: async (k: string, v: unknown) => storage.set(k, v),
},
};
const limiter = new RateLimiter(ctx as never, {} as never);

const res = await limiter.fetch(
new Request('https://rate-limiter/check', {
method: 'POST',
body: JSON.stringify({ limit: 60, windowMs: 60_000 }),
}),
);
expect(res.status).toBe(429);
});
});
38 changes: 38 additions & 0 deletions apps/api/src/__tests__/session-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
bumpSessionListCache,
getCachedSessionList,
getListCacheVersion,
putCachedSessionList,
} from '../lib/session-cache.js';

function mockKv() {
const store = new Map<string, string>();
return {
get: async (key: string) => store.get(key) ?? null,
put: async (key: string, value: string) => store.set(key, value),
store,
};
}

describe('session-cache', () => {
const ownerId = '11111111-1111-1111-1111-111111111111';
const payload = { data: [], nextCursor: null };

it('stores and retrieves list payloads', async () => {
const kv = mockKv();
await putCachedSessionList(kv as never, ownerId, 'start', 20, payload);
const hit = await getCachedSessionList(kv as never, ownerId, 'start', 20);
expect(hit).toEqual(payload);
});

it('invalidates cached lists after bump', async () => {
const kv = mockKv();
await putCachedSessionList(kv as never, ownerId, 'start', 20, payload);
const before = await getListCacheVersion(kv as never, ownerId);
await bumpSessionListCache(kv as never, ownerId);
const after = await getListCacheVersion(kv as never, ownerId);
expect(after).not.toBe(before);
expect(await getCachedSessionList(kv as never, ownerId, 'start', 20)).toBeNull();
});
});
14 changes: 9 additions & 5 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { Env } from './env.js';
import { bodySizeLimit, rateLimit, securityHeaders } from './middleware.js';
import { logError } from './lib/logger.js';
import { bodySizeLimit, healthHandler, rateLimit, securityHeaders, traceMiddleware } from './middleware.js';
import { openApiDocument } from './openapi/spec.js';
import { auth } from './routes/auth.js';
import { media } from './routes/media.js';
import { sessions } from './routes/sessions.js';

export type AppVariables = { traceId: string };

export function createApp() {
const app = new Hono<{ Bindings: Env }>();
const app = new Hono<{ Bindings: Env; Variables: AppVariables }>();

app.use('*', traceMiddleware);
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('/health', healthHandler);
app.get('/openapi.json', (c) => c.json(openApiDocument));

app.route('/auth', auth);
Expand All @@ -23,8 +27,8 @@ export function createApp() {
app.route('/sessions', sessions);

app.onError((err, c) => {
console.error(err);
return c.json({ error: 'Internal error' }, 500);
logError(c, err);
return c.json({ error: 'Internal error', traceId: c.get('traceId') }, 500);
});

return app;
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { DurableObjectNamespace, Hyperdrive, R2Bucket } from '@cloudflare/workers-types';
import type { DurableObjectNamespace, Hyperdrive, KVNamespace, R2Bucket } from '@cloudflare/workers-types';

export interface Env {
/** Hyperdrive in staging/production; optional locally. */
HYPERDRIVE?: Hyperdrive;
/** Direct Neon URL for local wrangler dev (.dev.vars). */
DATABASE_URL?: string;
SESSION_ROOM: DurableObjectNamespace;
/** Per-IP token bucket (Tier 5). Optional in unit tests. */
RATE_LIMITER?: DurableObjectNamespace;
/** Edge cache for hot reads (session list). Optional in unit tests. */
CACHE?: KVNamespace;
MEDIA: R2Bucket;
/** HMAC secret for signed media URLs (Wrangler secret in staging/prod). */
MEDIA_SIGNING_SECRET?: string;
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
import { createApp } from './app.js';

export { SessionRoom } from './session-room.js';
export { RateLimiter } from './rate-limiter.js';

export default createApp();
Loading
Loading