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
4 changes: 4 additions & 0 deletions .cursor/rules/110-docs.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ globs: "**/*.md"
- Reviewer-facing docs (`SUBMISSION.md`, checklists, spec mapping): see **111-submission-docs** — sync with [docs/HOMEWORK-SPEC.md](../../docs/HOMEWORK-SPEC.md) when rubric-relevant work ships; professional yet personable tone for 501.
- 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
37 changes: 37 additions & 0 deletions .cursor/rules/34-auth.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
description: JWT auth, API keys, owner switcher, RLS upgrade path
globs: "apps/{api,web}/src/**/*.{ts,tsx}"
---
# Auth & multi-tenant (Tier 4)

## Request principal

Resolve order in `resolvePrincipal()`:

1. `Authorization: Bearer` JWT (or `?token=` for WebSocket)
2. `x-oche-owner` / `?key=` API key (demo, env JSON, or `api_keys` table)

Every DB call still uses `withPrincipal(db, ownerId, fn)` until Neon JWT RLS is enabled — see docs/AUTH.md.

## Secrets

- `OCHE_JWT_SECRET` — Wrangler secret per env; never in the SPA bundle.
- API keys stored **hashed** (SHA-256) in `api_keys`; plaintext returned once on create.
- Demo keys (`demo-key-a/b`) remain for RLS isolation demos.

## SPA

- Use `AuthProvider` + `useAuth()`; never hardcode `demo-key-a` in `api.ts`.
- Owner switcher invalidates TanStack Query cache on venue change.
- API key UI at `/settings/keys`.

## Adding auth to a new route

```typescript
const ownerId = await resolvePrincipal(c, getDb(c.env));
if (!ownerId) return c.json({ error: 'Unauthorised' }, 401);
```

## Neon upgrade

Document only in take-home — do not drop GUC policies without migration plan. Reference: `docs/examples/neon-jwt-rls.sql`.
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This rule now documents that RateLimiter must be SQLite-backed, but the globs field still only targets session-room.ts. The rule won't activate when editing rate-limiter.ts, so future changes there can miss the SQLite constraint. Update the globs to include both files.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .cursor/rules/60-realtime-do.mdc, line 5:

<comment>This rule now documents that `RateLimiter` must be SQLite-backed, but the `globs` field still only targets `session-room.ts`. The rule won't activate when editing `rate-limiter.ts`, so future changes there can miss the SQLite constraint. Update the globs to include both files.</comment>

<file context>
@@ -2,6 +2,6 @@
 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`.
</file context>

- 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.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +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' });
});
});
24 changes: 24 additions & 0 deletions apps/api/src/__tests__/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { signJwt, verifyJwt } from '../lib/jwt.js';

describe('jwt', () => {
const secret = 'test-jwt-secret';

it('signs and verifies a token', async () => {
const sub = '11111111-1111-1111-1111-111111111111';
const token = await signJwt(sub, secret, 60);
const payload = await verifyJwt(token, secret);
expect(payload).toEqual({ sub });
});

it('rejects tampered tokens', async () => {
const token = await signJwt('11111111-1111-1111-1111-111111111111', secret, 60);
const bad = `${token}x`;
expect(await verifyJwt(bad, secret)).toBeNull();
});

it('rejects expired tokens', async () => {
const token = await signJwt('11111111-1111-1111-1111-111111111111', secret, -1);
expect(await verifyJwt(token, secret)).toBeNull();
});
});
16 changes: 13 additions & 3 deletions apps/api/src/__tests__/principal.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { describe, expect, it } from 'vitest';
import { Hono } from 'hono';
import type { Env } from '../env.js';
import { signJwt } from '../lib/jwt.js';
import { resolvePrincipal } from '../principal.js';

function appWithEnv(env: Partial<Env>) {
const app = new Hono<{ Bindings: Env }>();
app.get('/', (c) => c.json({ ownerId: resolvePrincipal(c) }));
app.get('/', async (c) => c.json({ ownerId: await resolvePrincipal(c) }));
return { app, env: env as Env };
}

describe('resolvePrincipal', () => {
it('resolves built-in demo keys', async () => {
const { app, env } = appWithEnv({} as Env);
const { app, env } = appWithEnv({});
const res = await app.request('/', { headers: { 'x-oche-owner': 'demo-key-a' } }, env);
expect(await res.json()).toEqual({ ownerId: '11111111-1111-1111-1111-111111111111' });
});
Expand All @@ -27,8 +28,17 @@ describe('resolvePrincipal', () => {
});

it('returns null for unknown keys', async () => {
const { app, env } = appWithEnv({} as Env);
const { app, env } = appWithEnv({});
const res = await app.request('/', { headers: { 'x-oche-owner': 'invalid' } }, env);
expect(await res.json()).toEqual({ ownerId: null });
});

it('resolves Bearer JWT', async () => {
const secret = 'jwt-test-secret';
const ownerId = '11111111-1111-1111-1111-111111111111';
const token = await signJwt(ownerId, secret, 60);
const { app, env } = appWithEnv({ OCHE_JWT_SECRET: secret } as Env);
const res = await app.request('/', { headers: { Authorization: `Bearer ${token}` } }, env);
expect(await res.json()).toEqual({ ownerId });
});
});
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);
});
});
Loading
Loading