From 25f43ec4e281a8744086e8e6f9b685546a2b876d Mon Sep 17 00:00:00 2001 From: emil Date: Fri, 15 May 2026 21:28:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(dm-dashboard):=20Wave=201=20=E2=80=94=20au?= =?UTF-8?q?th=20fixes,=20DB=20schema,=20Open5e=20interfaces,=20AI=20client?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix auth anti-patterns (JWT, OAuth, logout, middleware, url.origin) - Extend DB schema with tier, npcs, counters, translations, notes, initiative - Expand Open5e TypeScript interfaces (Monster, Spell) to full V2 - Build OpenRouter API client for FREE tier - Build Kimi API client for PRO tier - Add comprehensive tests for all modules --- .sisyphus/boulder.json | 56 +- .../notepads/dm-dashboard-ai/learnings.md | 45 + .sisyphus/plans/dm-dashboard-ai.md | 1858 +++++++++++++++++ .../ses_1d389e69bffedksVYoEM6rnr6t.json | 10 + .../ses_1d3921469ffeQox08ZjNWWJq7u.json | 10 + src/db/__mocks__/client.ts | 13 + src/db/schema.ts | 61 +- src/lib/ai/kimi.test.ts | 295 +++ src/lib/ai/kimi.ts | 244 +++ src/lib/ai/openrouter.test.ts | 252 +++ src/lib/ai/openrouter.ts | 162 ++ src/lib/ai/types.ts | 55 + src/lib/auth/auth.test.ts | 225 ++ src/lib/auth/env.ts | 28 + src/lib/auth/jwt.ts | 5 +- src/lib/auth/oauth.ts | 11 +- src/lib/open5e/client.ts | 46 +- src/middleware/index.ts | 4 +- src/pages/api/auth/callback/vk.ts | 3 +- src/pages/api/auth/callback/yandex.ts | 3 +- src/pages/api/auth/login/vk.ts | 5 +- src/pages/api/auth/login/yandex.ts | 5 +- src/pages/api/auth/logout.ts | 12 +- tests/mocks/astro-middleware.ts | 3 + tests/open5e-client.test.ts | 16 +- tests/open5e-types.test.ts | 63 + tests/schema.test.ts | 193 ++ tests/setup.ts | 15 +- vitest.config.ts | 1 + 29 files changed, 3649 insertions(+), 50 deletions(-) create mode 100644 .sisyphus/notepads/dm-dashboard-ai/learnings.md create mode 100644 .sisyphus/plans/dm-dashboard-ai.md create mode 100644 .sisyphus/run-continuation/ses_1d389e69bffedksVYoEM6rnr6t.json create mode 100644 .sisyphus/run-continuation/ses_1d3921469ffeQox08ZjNWWJq7u.json create mode 100644 src/db/__mocks__/client.ts create mode 100644 src/lib/ai/kimi.test.ts create mode 100644 src/lib/ai/kimi.ts create mode 100644 src/lib/ai/openrouter.test.ts create mode 100644 src/lib/ai/openrouter.ts create mode 100644 src/lib/ai/types.ts create mode 100644 src/lib/auth/auth.test.ts create mode 100644 src/lib/auth/env.ts create mode 100644 tests/mocks/astro-middleware.ts create mode 100644 tests/open5e-types.test.ts create mode 100644 tests/schema.test.ts diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json index 218f1cb..3ffa0d8 100644 --- a/.sisyphus/boulder.json +++ b/.sisyphus/boulder.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "active_work_id": "dm-dashboard-redesign-a1bfaddc", + "active_work_id": "dm-dashboard-ai-c738b11e", "works": { "critical-refactor-f063daf0": { "work_id": "critical-refactor-f063daf0", @@ -387,19 +387,59 @@ }, "agent": "atlas", "task_sessions": {} + }, + "dm-dashboard-ai-c738b11e": { + "work_id": "dm-dashboard-ai-c738b11e", + "active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md", + "plan_name": "dm-dashboard-ai", + "status": "active", + "started_at": "2026-05-15T17:58:09.859Z", + "updated_at": "2026-05-15T18:24:29.818Z", + "session_ids": [ + "ses_1d3921469ffeQox08ZjNWWJq7u" + ], + "session_origins": { + "ses_1d3921469ffeQox08ZjNWWJq7u": "direct" + }, + "agent": "atlas", + "task_sessions": { + "final-wave:f1": { + "task_key": "final-wave:f1", + "task_label": "F1", + "task_title": "**Plan Compliance Audit** — `oracle`", + "session_id": "ses_1d333a383ffeOqEVZptN3EbGAY", + "agent": "Sisyphus-Junior", + "category": "quick", + "updated_at": "2026-05-15T18:24:29.818Z", + "started_at": "2026-05-15T18:24:29.818Z", + "status": "running" + } + } } }, - "active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-redesign.md", - "started_at": "2026-05-15T12:32:10.008Z", + "active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md", + "started_at": "2026-05-15T17:58:09.859Z", "status": "active", - "updated_at": "2026-05-15T12:32:10.008Z", + "updated_at": "2026-05-15T18:24:29.818Z", "session_ids": [ - "ses_1d6b615cbffe5pqd0AObdil6OK" + "ses_1d3921469ffeQox08ZjNWWJq7u" ], "session_origins": { - "ses_1d6b615cbffe5pqd0AObdil6OK": "direct" + "ses_1d3921469ffeQox08ZjNWWJq7u": "direct" + }, + "plan_name": "dm-dashboard-ai", + "task_sessions": { + "final-wave:f1": { + "task_key": "final-wave:f1", + "task_label": "F1", + "task_title": "**Plan Compliance Audit** — `oracle`", + "session_id": "ses_1d333a383ffeOqEVZptN3EbGAY", + "agent": "Sisyphus-Junior", + "category": "quick", + "updated_at": "2026-05-15T18:24:29.818Z", + "started_at": "2026-05-15T18:24:29.818Z", + "status": "running" + } }, - "plan_name": "dm-dashboard-redesign", - "task_sessions": {}, "agent": "atlas" } \ No newline at end of file diff --git a/.sisyphus/notepads/dm-dashboard-ai/learnings.md b/.sisyphus/notepads/dm-dashboard-ai/learnings.md new file mode 100644 index 0000000..dab4cfa --- /dev/null +++ b/.sisyphus/notepads/dm-dashboard-ai/learnings.md @@ -0,0 +1,45 @@ +# Auth Anti-Pattern Fixes — Learnings + +## Date: 2026-05-15 + +### Patterns Applied + +1. **Fail-fast env validation with Zod** + - Created `src/lib/auth/env.ts` with a `z.object()` schema that validates `JWT_SECRET`, `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, and `PUBLIC_APP_URL` at module load time. + - `process.env.JWT_SECRET!` non-null assertion replaced with `authEnv.JWT_SECRET` which throws a descriptive error if the env var is missing. + - `requireEnv()` helper provides typed access to validated env values. + +2. **SameSite cookie fix** + - Changed `sameSite: 'strict'` to `sameSite: 'lax'` in `setAuthCookie()` (`src/lib/auth/jwt.ts`). + - `SameSite=Strict` breaks cross-site OAuth redirects because the browser doesn't send the cookie on the redirect back from the identity provider. `Lax` + `Secure` is the correct combination for OAuth session cookies. + +3. **Removed all `|| ''` fallbacks** + - `src/lib/auth/oauth.ts` had 5 instances of `process.env.XXX || ''`. + - Replaced with `authEnv.XXX` references. Empty-string fallbacks silently produce invalid OAuth requests (e.g., `client_id: ''` causes 400 errors from VK/Yandex). + +4. **Logout session cleanup** + - `src/pages/api/auth/logout.ts` now reads the `auth_token` cookie and calls `deleteSession(token)` before clearing the cookie. + - Previously the session row remained in the DB indefinitely. + +5. **Middleware stale cookie cleanup** + - `src/middleware/index.ts` now calls `context.cookies.delete('auth_token', { path: '/' })` in two places: + - When `verifyToken()` throws (invalid/expired JWT) + - When `getSession()` returns `null` (session expired or deleted from DB) + - Previously the stale cookie stayed in the browser, causing repeated failed auth attempts on every request. + +6. **PUBLIC_APP_URL exclusivity** + - `src/pages/api/auth/callback/vk.ts`, `yandex.ts`, and the login handlers (`login/vk.ts`, `login/yandex.ts`) all used `process.env.PUBLIC_APP_URL || url.origin`. + - Replaced with `authEnv.PUBLIC_APP_URL` exclusively. + - `url.origin` breaks behind nginx reverse proxy because it returns `http://localhost:4321` instead of the public HTTPS URL. + +### Testing Notes + +- happy-dom strips `Set-Cookie` headers from `Response` objects (both via `Headers` instance and array entries). This is a known limitation — cookie clearing behavior must be tested via integration/Playwright instead of unit tests. +- `vi.mock` with alias paths (`@/...`) does not reliably match dynamically imported modules after `vi.resetModules()` in vitest. Using `__mocks__` directories or avoiding deep DB mocking in unit tests is more stable. +- `crypto.subtle` needs Node.js `webcrypto` for `jose` to work in vitest. Added `node:crypto` webcrypto to `tests/setup.ts`. +- `astro:middleware` is a virtual module that doesn't resolve in vitest. Added an alias in `vitest.config.ts` pointing to `tests/mocks/astro-middleware.ts`. + +### Verification + +- `npx tsc --noEmit`: 0 errors +- `npx vitest run src/lib/auth/`: 18/18 tests passed diff --git a/.sisyphus/plans/dm-dashboard-ai.md b/.sisyphus/plans/dm-dashboard-ai.md new file mode 100644 index 0000000..835349d --- /dev/null +++ b/.sisyphus/plans/dm-dashboard-ai.md @@ -0,0 +1,1858 @@ +# DM Dashboard — AI-Powered NPC Generator & Infrastructure + +## TL;DR + +> **Quick Summary**: Build an AI-powered NPC generator for the DM Dashboard with tiered access (FREE/PRO), Open5e reference balancing, Open5e content translations, migrate notes/initiative to DB for PRO users, and fix existing auth anti-patterns. +> +> **Deliverables**: +> - NPC generator API (OpenRouter for FREE, Kimi for PRO) with Open5e reference balancing +> - Tier system with Boosty verification and rate limiting (7/hour FREE, 100/hour PRO) +> - Open5e content translation service with DB cache +> - Notes & initiative DB persistence for PRO users with localStorage fallback for FREE +> - Fixed auth anti-patterns (JWT validation, cookie SameSite, logout session cleanup, url.origin, env fallbacks) +> - UI: AI tab, generator form, result card, history panel, quota/tier badges +> +> **Estimated Effort**: Large (~25 tasks, 6 waves) +> **Parallel Execution**: YES — 6 waves with 3-5 tasks each +> **Critical Path**: Auth fixes → DB schema → Tier system → AI clients → NPC API → NPC UI → Final QA + +--- + +## Context + +### Original Request +Build AI-powered NPC generation for the DM Dashboard (dm.randify.pro) as part of randify.pro. Three access tiers: FREE no-account (no AI), FREE with account (7 gen/hour via OpenRouter), PRO (100 gen/hour via Kimi API + Boosty subscription). Include Open5e reference balancing, translations, and fix auth issues. + +### Interview Summary +**Key Discussions**: +- **Scope**: MVP = NPC generator + infrastructure. Other generators (encounter, quest, spell, weapon, item) are future releases. +- **Theme**: Keep current orange theme (`#E87722`). Do NOT adopt purple from mockup. +- **Tests**: TDD for critical parts (API routes, rate limiting, auth, Boosty). UI tests-after. +- **Data**: Notes and initiative move to DB for PRO. FREE keeps localStorage/sessionStorage. +- **No hard deadlines** — quality over speed. + +**Research Findings**: +- DM Dashboard UI fully built (dice, initiative, Open5e reference, notes, auth panel, design system). +- Auth works but has 6 anti-patterns: JWT_SECRET! assertion, sameSite strict, empty-string OAuth fallbacks, logout doesn't delete DB session, stale cookies not cleared, url.origin instead of PUBLIC_APP_URL. +- DB has only `users` and `sessions`. Missing: npcs, generation_counters, translations, notes, initiative_sessions, user tier. +- No AI/LLM code exists — greenfield implementation. +- Open5e already uses V2 but TypeScript interfaces (`Monster`, `Spell`) are dangerously incomplete (5 and 4 fields). +- `DmSidebar` accepts `user` prop but `index.astro` never passes it — auth propagation broken. +- Data migration keys confirmed: notes = `dm-notes` (localStorage), initiative = `dm-initiative` (sessionStorage) with `it-combatants` fallback. + +### Metis Review +**Identified Gaps** (addressed): +- Auth fixes must include `url.origin` → `PUBLIC_APP_URL` (was missing from initial list). +- DmSidebar user prop fix must be explicitly scoped. +- Open5e V2 baseline clarified: current code already uses V2, needs interface expansion only. +- MCP Server is reference-only, not integration scope. +- MUST NOT build generic AI framework — hardcode NPC generator. +- Rate limiting must be AI-route-only, not global. +- AI API keys must never leave server-side. + +--- + +## Work Objectives + +### Core Objective +Build an AI-powered NPC generator for the DM Dashboard with tiered access (FREE/PRO), Open5e reference balancing, Open5e content translations, and migrate notes/initiative to DB for PRO users, while fixing existing auth anti-patterns. + +### Concrete Deliverables +- `src/db/schema.ts` — extended schema with tier, npcs, generation_counters, translations, notes, initiative_sessions +- `src/lib/auth/` — fixed auth (JWT validation, cookie SameSite, logout cleanup, env fallbacks, url.origin) +- `src/lib/ai/` — OpenRouter client (FREE) and Kimi client (PRO) +- `src/lib/boosty.ts` — Boosty verification service with 5-min cache +- `src/lib/open5e/client.ts` — expanded Monster/Spell interfaces to full V2 schema +- `src/pages/api/dm/ai/generate.ts` — NPC generation API route +- `src/pages/api/dm/ai/history.ts` — Generation history API route +- `src/pages/api/dm/translate.ts` — Open5e content translation API route +- `src/components/dm/AiNpcForm.astro` — NPC generator form +- `src/components/dm/AiNpcResult.astro` — NPC result card +- `src/components/dm/GenerationHistory.astro` — History panel +- `src/components/dm/TierBadge.astro` + quota display +- `src/components/dm/NotesPanel.astro` — updated with DB persistence for PRO +- `src/components/dm/InitiativeTracker.astro` — updated with DB persistence for PRO + +### Definition of Done +- [ ] All auth anti-patterns fixed and verified by tests +- [ ] DB migrations applied successfully +- [ ] NPC generation works end-to-end for both FREE (OpenRouter) and PRO (Kimi) tiers +- [ ] Rate limiting enforced: 7/hour FREE, 100/hour PRO +- [ ] Boosty verification correctly assigns PRO tier +- [ ] Translations cached in DB and served instantly on repeat requests +- [ ] Notes/initiative persist to DB for PRO, fall back to localStorage for FREE +- [ ] All new UI components render correctly in DmLayout +- [ ] Zero auth regressions on existing OAuth flow + +### Must Have +- [ ] NPC generator with Open5e reference balancing +- [ ] Three-tier access system (FREE no-account, FREE with account, PRO) +- [ ] Rate limiting per tier with hourly reset +- [ ] Boosty subscription verification with cache +- [ ] Open5e content translation with DB cache +- [ ] Auth anti-pattern fixes (all 6 issues) +- [ ] DB schema migrations for all new tables +- [ ] Server-side AI API calls (keys never exposed client-side) +- [ ] DmSidebar user prop fix for tier badge rendering +- [ ] Open5e TypeScript interface expansion + +### Must NOT Have (Guardrails) +- [ ] Generic AI generator framework — hardcode NPC only +- [ ] Client-side AI API key exposure +- [ ] Global rate limiting (only AI routes) +- [ ] Purple theme adoption from mockup +- [ ] Campaign management or session saving (future release) +- [ ] Other generator types (encounter, quest, spell, weapon, item) +- [ ] MCP Server integration (reference only) +- [ ] Server-side dice history storage +- [ ] Persisting InitiativeTracker activeIndex (keep in-memory) +- [ ] `innerHTML` with unsanitized AI output — always sanitize + +--- + +## Verification Strategy + +> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions. + +### Test Decision +- **Infrastructure exists**: YES (Vitest + Playwright configured) +- **Automated tests**: TDD for critical parts, tests-after for UI +- **Framework**: Vitest (unit), Playwright (E2E) +- **If TDD**: Each critical task follows RED (failing test) → GREEN (minimal impl) → REFACTOR + +### QA Policy +Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`. + +- **API/Backend**: Bash (curl) — Send requests, assert status + response fields +- **Frontend/UI**: Playwright — Navigate, interact, assert DOM, screenshot +- **Auth**: Playwright — Login flow, cookie inspection, logout verification +- **DB**: Bash (psql or drizzle query) — Verify schema, data insertion, constraints + +--- + +## Execution Strategy + +### Parallel Execution Waves + +``` +Wave 1 (Foundation — 5 tasks): +├── Task 1: Fix auth anti-patterns (jwt.ts, oauth.ts, logout.ts, middleware, url.origin) [quick] +├── Task 2: Extend DB schema (tier, npcs, counters, translations, notes, initiative) [quick] +├── Task 4: Expand Open5e TypeScript interfaces (Monster, Spell) [quick] +├── Task 6: Build OpenRouter API client (FREE tier) [quick] +└── Task 7: Build Kimi API client (PRO tier) [quick] + +Wave 2 (Migration + CORS + Sidebar — 3 tasks): +├── Task 3: Generate & apply Drizzle migration [quick] +├── Task 9: Fix DmSidebar user prop + add tier badge support [quick] +└── Task 13: Add CORS configuration for API routes [quick] + +Wave 3 (Tier system + Data APIs — 2 tasks): +├── Task 5: Build Boosty verification service + tier assignment [unspecified-high] +└── Task 14: Build notes/initiative DB API routes [unspecified-high] + +Wave 4 (Rate limiting + Translation API + Navigation — 3 tasks): +├── Task 8: Build rate limiting service/middleware + quota query [unspecified-high] +├── Task 12: Build translation API route (/api/dm/translate) [unspecified-high] +└── Task 15: Add AI tab to navigation (DmTabs + DmSidebar) [quick] + +Wave 5 (Core generation + History + Badges + Translate button — 4 tasks): +├── Task 10: Build NPC generation API route (/api/dm/ai/generate) [deep] +├── Task 11: Build generation history API route (/api/dm/ai/history) [unspecified-high] +├── Task 19: Build quota/tier badge header components [visual-engineering] +└── Task 23: Add translate button to Open5e reference cards [visual-engineering] + +Wave 6 (UI Components + Data Migration + Translation wiring — 6 tasks): +├── Task 16: Build NPC generator form component [visual-engineering] +├── Task 17: Build NPC result card component [visual-engineering] +├── Task 18: Build generation history panel component [visual-engineering] +├── Task 20: Migrate notes to DB for PRO + localStorage fallback [unspecified-high] +├── Task 21: Migrate initiative to DB for PRO + sessionStorage fallback [unspecified-high] +└── Task 24: Wire translation service to UI [quick] + +Wave 7 (Import flow — 1 task, dependency-forced): +└── Task 22: Add FREE → PRO data import flow [unspecified-high] + +Wave FINAL (After ALL tasks — 4 parallel reviews, then user okay): +├── Task F1: Plan compliance audit (oracle) +├── Task F2: Code quality review (unspecified-high) +├── Task F3: Real manual QA (unspecified-high + playwright skill) +└── Task F4: Scope fidelity check (deep) +-> Present results -> Get explicit user okay + +Critical Path: T1 → T2 → T3 → T5 → T8 → T10 → T16 → T22 → F1-F4 → user okay +Parallel Speedup: ~75% faster than sequential +Max Concurrent: 6 (Wave 6) +``` + +### Dependency Matrix + +| Task | Depends On | Blocks | +|------|-----------|--------| +| 1 (Auth fixes) | — | 5, 9, F1-F4 | +| 2 (DB schema) | — | 3, 5, 10, 11, 12, 14, 20, 21 | +| 3 (Migration) | 2 | 5, 8, 10, 11, 12, 14, 20, 21 | +| 4 (Open5e interfaces) | — | 10 | +| 5 (Boosty + tier) | 1, 2, 3 | 8, 10, 19, 20, 21 | +| 6 (OpenRouter client) | — | 10, 12 | +| 7 (Kimi client) | — | 10 | +| 8 (Rate limiting) | 2, 3, 5 | 10, 19 | +| 9 (DmSidebar fix) | 1 | 15 | +| 10 (NPC generate API) | 4, 5, 6, 7, 8 | 16, 17 | +| 11 (History API) | 2, 3 | 18 | +| 12 (Translation API) | 2, 3, 6 | 23, 24 | +| 13 (CORS) | — | — | +| 14 (Notes/Initiative APIs) | 2, 3 | 20, 21 | +| 15 (AI tab nav) | 9 | 16 | +| 16 (NPC form) | 10, 15 | — | +| 17 (NPC result) | 10 | — | +| 18 (History panel) | 11 | — | +| 19 (Quota/tier badges) | 5, 8 | — | +| 20 (Notes DB) | 5, 14 | 22 | +| 21 (Initiative DB) | 5, 14 | 22 | +| 22 (Import flow) | 20, 21 | — | +| 23 (Translate button) | 12 | 24 | +| 24 (Translate wiring) | 12, 23 | — | +| F1-F4 | ALL above | — | + +### Agent Dispatch Summary + +- **Wave 1**: **5** — T1 → `quick`, T2 → `quick`, T4 → `quick`, T6 → `quick`, T7 → `quick` +- **Wave 2**: **3** — T3 → `quick`, T9 → `quick`, T13 → `quick` +- **Wave 3**: **2** — T5 → `unspecified-high`, T14 → `unspecified-high` +- **Wave 4**: **3** — T8 → `unspecified-high`, T12 → `unspecified-high`, T15 → `quick` +- **Wave 5**: **4** — T10 → `deep`, T11 → `unspecified-high`, T19 → `visual-engineering`, T23 → `visual-engineering` +- **Wave 6**: **6** — T16 → `visual-engineering`, T17 → `visual-engineering`, T18 → `visual-engineering`, T20 → `unspecified-high`, T21 → `unspecified-high`, T24 → `quick` +- **Wave 7**: **1** — T22 → `unspecified-high` +- **FINAL**: **4** — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep` + +--- + +## TODOs + +- [x] **1. Fix Auth Anti-Patterns** + + **What to do**: + - Fix `src/lib/auth/jwt.ts:4`: Replace `process.env.JWT_SECRET!` with explicit validation (fail fast if missing). + - Fix `src/lib/auth/jwt.ts:28`: Change `sameSite: 'strict'` to `sameSite: 'lax'` in `setAuthCookie`. + - Fix `src/lib/auth/oauth.ts:3,33,34,41,42`: Remove all `|| ''` fallbacks. Create `requireEnv()` helper that throws descriptive errors. + - Fix `src/pages/api/auth/logout.ts:6-14`: After clearing cookie, call `deleteSession(token)` to invalidate DB session. + - Fix `src/middleware/index.ts:45-48`: In catch block for invalid tokens, delete the `auth_token` cookie (clear stale cookies). + - Fix OAuth callbacks (`callback/vk.ts` and `callback/yandex.ts`): Replace `url.origin` with `process.env.PUBLIC_APP_URL` for redirect URIs. + - Add startup env validation using zod schema for all required env vars. + - Write tests for each fix: JWT secret validation, cookie attributes, logout session deletion, middleware stale cookie clearing. + + **Must NOT do**: + - Remove `setAuthCookie`/`clearAuthCookie` without checking — mark `@deprecated` if unused. + - Change JWT signing algorithm or token format (keep backward compatibility). + - Modify OAuth provider URLs or scopes. + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Targeted fixes in known files with exact line numbers from audit. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 1) + - **Parallel Group**: Wave 1 + - **Blocks**: Task 5 (Boosty + tier), Task 9 (DmSidebar), F1-F4 + - **Blocked By**: None + + **References**: + - `src/lib/auth/jwt.ts:4,28` — Current JWT cookie implementation + - `src/lib/auth/oauth.ts:3,33,34,41,42` — OAuth config with fallbacks + - `src/pages/api/auth/logout.ts:6-14` — Current logout (no DB cleanup) + - `src/middleware/index.ts:45-48` — Middleware token validation + - `src/pages/api/auth/callback/vk.ts` and `yandex.ts` — OAuth callbacks + - AGENTS.md anti-patterns section — Reference for all fixes + + **Acceptance Criteria**: + - [ ] `npm test src/lib/auth/jwt.test.ts` → PASS (secret validation, cookie attributes) + - [ ] `npm test src/pages/api/auth/logout.test.ts` → PASS (session deletion) + - [ ] `npm test src/middleware/index.test.ts` → PASS (stale cookie clearing) + - [ ] `npm test src/lib/auth/oauth.test.ts` → PASS (env validation) + - [ ] `tsc --noEmit` → zero errors + + **QA Scenarios**: + ``` + Scenario: JWT secret missing at startup + Tool: Bash (node) + Preconditions: Remove JWT_SECRET from .env + Steps: + 1. Run `npm run dev` + 2. Assert: App fails to start with descriptive error mentioning JWT_SECRET + Expected Result: Process exits with non-zero code and clear error message + Evidence: .sisyphus/evidence/task-1-jwt-secret-missing.log + + Scenario: Logout deletes DB session + Tool: Playwright + Preconditions: User is logged in (auth_token cookie set, DB session exists) + Steps: + 1. Navigate to /dm/ + 2. Click logout button + 3. Query DB: SELECT * FROM sessions WHERE token = '...' + 4. Assert: Query returns zero rows + 5. Assert: auth_token cookie is cleared + Expected Result: DB session deleted AND cookie cleared + Evidence: .sisyphus/evidence/task-1-logout-session.png + + Scenario: Stale cookie is cleared by middleware + Tool: Bash (curl) + Preconditions: Set invalid auth_token cookie + Steps: + 1. curl -H "Cookie: auth_token=invalid_token" http://localhost:4321/dm/ -v + 2. Assert: Response headers include Set-Cookie clearing auth_token + Expected Result: Stale cookie is removed by middleware + Evidence: .sisyphus/evidence/task-1-stale-cookie.log + ``` + + **Commit**: YES + - Message: `fix(auth): resolve all auth anti-patterns` + - Files: `src/lib/auth/jwt.ts`, `src/lib/auth/oauth.ts`, `src/pages/api/auth/logout.ts`, `src/middleware/index.ts`, `src/pages/api/auth/callback/vk.ts`, `src/pages/api/auth/callback/yandex.ts` + - Pre-commit: `npm test src/lib/auth/` + +- [x] **2. Extend DB Schema** + + **What to do**: + - Add `tier` column to `users` table: `varchar('tier', { length: 20 }).default('free')` with check constraint `'free' | 'pro'`. + - Add `boostyVerifiedAt` timestamp column to `users` (nullable). + - Create `npcs` table: `id`, `userId` (FK → users, ON DELETE CASCADE), `name`, `race`, `role`, `level`, `tone`, `content` (JSONB), `createdAt`. + - Create `generationCounters` table: `id`, `userId` (FK → users, ON DELETE CASCADE), `hourWindow` (timestamp), `count` (integer), `model` ('openrouter' | 'kimi'). + - Create `translations` table: `id`, `slug` (unique), `type` ('creature' | 'spell' | 'item'), `language` (default 'ru'), `content` (JSONB), `createdAt`, `updatedAt`. + - Create `notes` table: `id`, `userId` (FK → users, ON DELETE CASCADE), `title`, `content` (text), `createdAt`, `updatedAt`. + - Create `initiativeSessions` table: `id`, `userId` (FK → users, ON DELETE CASCADE), `name`, `participants` (JSONB), `createdAt`, `updatedAt`. + - Add composite unique index on `generationCounters(userId, hourWindow, model)`. + - Add index on `translations(slug, type, language)`. + - Write Drizzle schema tests verifying table creation and constraints. + + **Must NOT do**: + - Add nullable columns to `users` without defaults (breaks existing rows). + - Create separate `pro_users` table (use tier column). + - Add `encounters` or `campaigns` tables (future release). + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Schema definition is declarative, well-understood scope. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 1) + - **Parallel Group**: Wave 1 + - **Blocks**: Task 3 (migration), Task 5 (Boosty), Task 8 (rate limiting), Task 10+ (all DB-dependent tasks) + - **Blocked By**: None + + **References**: + - `src/db/schema.ts` — Current schema (users, sessions) + - `src/db/client.ts` — Drizzle client setup + - `drizzle/0000_short_warpath.sql` — Initial migration for reference + + **Acceptance Criteria**: + - [ ] Schema file compiles: `tsc --noEmit` → zero errors + - [ ] Test: `npm test src/db/schema.test.ts` → PASS (verifies all tables and constraints) + - [ ] All FK constraints use ON DELETE CASCADE + - [ ] `users.tier` has default 'free' and check constraint + + **QA Scenarios**: + ``` + Scenario: Schema has all required tables + Tool: Bash (psql) + Preconditions: PostgreSQL running, DATABASE_URL set + Steps: + 1. Run `npm run db:generate` + 2. Inspect generated SQL in drizzle/ + 3. Assert: SQL contains CREATE TABLE for npcs, generation_counters, translations, notes, initiative_sessions + 4. Assert: SQL contains ALTER TABLE for users adding tier column + Expected Result: All 6 new tables/columns present in migration + Evidence: .sisyphus/evidence/task-2-schema-tables.log + + Scenario: users.tier defaults to 'free' + Tool: Bash (psql) + Preconditions: Migration applied + Steps: + 1. INSERT INTO users (vk_id, email, name) VALUES ('123', 'test@test.com', 'Test') + 2. SELECT tier FROM users WHERE vk_id = '123' + 3. Assert: tier = 'free' + Expected Result: New users default to free tier + Evidence: .sisyphus/evidence/task-2-tier-default.log + ``` + + **Commit**: YES (grouped with Task 3) + - Message: `feat(db): add AI and PRO tables` + - Files: `src/db/schema.ts` + +- [ ] **3. Generate & Apply Drizzle Migration** + + **What to do**: + - Run `npm run db:generate` to create migration from updated schema. + - Review generated SQL for correctness (cascades, indexes, defaults). + - Run `npm run db:migrate` locally to verify application. + - Ensure migration is reversible (down migration generated). + - Test migration against empty DB and DB with existing data. + - Update `docker-compose.yml` if needed (no changes expected for this migration). + + **Must NOT do**: + - Manually edit generated SQL unless absolutely necessary (prefer schema changes). + - Apply migration to production DB during planning. + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Standard Drizzle workflow. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 2) + - **Parallel Group**: Wave 2 + - **Blocks**: All DB-dependent tasks (5, 8, 10, 11, 12, 14, 20, 21) + - **Blocked By**: Task 2 (schema) + + **References**: + - `package.json` scripts — db:generate, db:migrate + - `drizzle/` directory — Existing migrations + + **Acceptance Criteria**: + - [ ] `npm run db:generate` completes without errors + - [ ] `npm run db:migrate` applies cleanly to local dev DB + - [ ] `npm run db:migrate` applies cleanly to test DB with existing users/sessions + - [ ] `drizzle-kit` down migration exists + + **QA Scenarios**: + ``` + Scenario: Migration applies cleanly + Tool: Bash + Preconditions: PostgreSQL running with existing users/sessions data + Steps: + 1. Run `npm run db:migrate` + 2. Assert: Exit code 0 + 3. Query: SELECT column_name FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'tier' + 4. Assert: Query returns 1 row + Expected Result: Migration applies without errors, new columns exist + Evidence: .sisyphus/evidence/task-3-migration.log + ``` + + **Commit**: YES (grouped with Task 2) + - Message: `feat(db): add AI and PRO tables with migration` + - Files: `src/db/schema.ts`, `drizzle/` + - Pre-commit: `npm run db:generate && npm run db:migrate` + +- [x] **4. Expand Open5e TypeScript Interfaces** + + **What to do**: + - Expand `Monster` interface in `src/lib/open5e/client.ts` to include ALL fields accessed in detail views and needed for AI prompts: + - `speed` (object or string), `actions` (array), `special_abilities`, `legendary_actions`, `senses`, `languages`, `challenge_rating_decimal`, `hit_points`, `armor_class`, `strength`, `dexterity`, `constitution`, `intelligence`, `wisdom`, `charisma`, `size`, `type`, `subtype`, `alignment`, `damage_immunities`, `damage_resistances`, `damage_vulnerabilities`, `condition_immunities`. + - Expand `Spell` interface to include all detail fields: + - `casting_time`, `range`, `components`, `duration`, `desc` (array of strings), `higher_level`, `ritual`, `concentration`, `school`, `level`, `classes`. + - Add cache schema versioning: prefix localStorage keys with `v2:` (e.g., `open5e:v2:monster:${key}`). + - Ensure no `any` types remain in Open5e client. + - Write type tests verifying interface completeness. + + **Must NOT do**: + - Change existing `fields` parameter in search endpoints (keeps responses small). + - Modify existing UI behavior of Open5e reference component. + - Add new API endpoints — only expand types. + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Type expansion with known field list from Metis audit. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 1) + - **Parallel Group**: Wave 1 + - **Blocks**: Task 10 (NPC generate API needs full types for prompts) + - **Blocked By**: None + + **References**: + - `src/lib/open5e/client.ts` — Current Monster/Spell interfaces (5 and 4 fields) + - `src/components/dm/Open5eReference.astro` — Detail view accessing un-typed fields + - Open5e V2 API docs: https://api.open5e.com/v2/ — Field reference + - `src/lib/open5e/cache.ts` — Cache key generation + + **Acceptance Criteria**: + - [ ] `tsc --noEmit` → zero errors after interface expansion + - [ ] `npm test src/lib/open5e/client.test.ts` → PASS (verifies all expected fields exist) + - [ ] Cache keys include `v2:` prefix + - [ ] No `any` types in Open5e client module + + **QA Scenarios**: + ``` + Scenario: Monster interface has all required fields + Tool: Bash (node REPL) + Preconditions: TypeScript compiled + Steps: + 1. Import Monster type + 2. Create object with all fields: speed, actions, hit_points, armor_class, challenge_rating_decimal, etc. + 3. Assert: TypeScript compiles without "Property does not exist" errors + Expected Result: All fields are valid on Monster type + Evidence: .sisyphus/evidence/task-4-monster-interface.log + + Scenario: Cache keys use v2 prefix + Tool: Bash (node) + Preconditions: Client code updated + Steps: + 1. Call searchMonsters("goblin") + 2. Inspect localStorage keys + 3. Assert: Keys start with "open5e:v2:" + Expected Result: New cache entries use v2 prefix + Evidence: .sisyphus/evidence/task-4-cache-version.log + ``` + + **Commit**: YES + - Message: `fix(open5e): expand Monster and Spell interfaces to full V2 schema` + - Files: `src/lib/open5e/client.ts`, `src/lib/open5e/cache.ts` + - Pre-commit: `tsc --noEmit` + +- [ ] **5. Build Boosty Verification Service + Tier Assignment** + + **What to do**: + - Create `src/lib/boosty.ts`: Service that verifies Boosty subscription token via Boosty API. + - **Evidence**: User requirement confirmed in interview: "Проверка Boosty токена для PRO, кэш 5 минут в памяти". Executor must research exact Boosty API endpoint for subscription verification before implementation. + - Implement 5-minute in-memory cache (Map or simple object with TTL). + - Cache structure: `{ tier: 'pro' | 'free', verifiedAt: Date, expiresAt: Date }`. + - On cache miss: HTTP request to Boosty API with 5-second timeout. + - On Boosty API failure: serve stale cache if available, otherwise assume unchanged tier. + - Never log or expose Boosty tokens in errors. + - Add tier assignment to OAuth callback flow: after user upsert, check Boosty token (if env var present), update `users.tier` to 'pro' or 'free'. + - Add middleware extension: attach `tier` to `Astro.locals.user` (requires updating middleware types). + - Write TDD tests: cache hit, cache miss, API timeout, stale fallback, token privacy. + + **Must NOT do**: + - Store Boosty tokens in DB (only verification result + timestamp). + - Build subscription management UI or webhook handling. + - Check Boosty on every request — only on auth events and cache miss. + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - Reason: External API integration with caching, timeout, and error handling logic. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 3) + - **Parallel Group**: Wave 3 + - **Blocks**: Task 8 (rate limiting needs tier info), Task 10 (generate API needs tier), Task 19 (tier badges), Task 20/21 (PRO persistence) + - **Blocked By**: Task 1 (auth fixes), Task 2/3 (DB schema + migration) + + **References**: + - `src/db/schema.ts` — users table with tier column + - `src/middleware/index.ts` — Where Astro.locals.user is populated + - `src/pages/api/auth/callback/vk.ts` and `yandex.ts` — Where to add tier assignment + - Boosty API docs (research via web if needed) + + **Acceptance Criteria**: + - [ ] Boosty API endpoint for subscription verification is identified and documented in code comments + - [ ] `npm test src/lib/boosty.test.ts` → PASS (cache hit, miss, timeout, stale) + - [ ] OAuth callback sets correct tier on user upsert + - [ ] Middleware attaches tier to Astro.locals.user + - [ ] Boosty token never appears in logs or error messages + + **QA Scenarios**: + ``` + Scenario: Boosty verification with cache hit + Tool: Bash (curl) + Preconditions: User logged in, Boosty cache warm + Steps: + 1. Call /api/dm/ai/generate twice in rapid succession + 2. Assert: Second request does NOT trigger Boosty API call (check server logs or mock) + Expected Result: Cache serves second request instantly + Evidence: .sisyphus/evidence/task-5-boosty-cache.log + + Scenario: Boosty API timeout falls back to stale cache + Tool: Bash (curl) + Preconditions: Boosty API is unreachable (mock timeout) + Steps: + 1. Warm cache with 'pro' tier + 2. Wait for cache expiry + 3. Call generate endpoint + 4. Assert: Request succeeds with 'pro' tier (stale cache served) + Expected Result: Graceful degradation on Boosty API failure + Evidence: .sisyphus/evidence/task-5-boosty-timeout.log + ``` + + **Commit**: YES + - Message: `feat(auth): add Boosty verification and tier assignment` + - Files: `src/lib/boosty.ts`, `src/middleware/index.ts`, `src/pages/api/auth/callback/vk.ts`, `src/pages/api/auth/callback/yandex.ts` + - Pre-commit: `npm test src/lib/boosty.test.ts` + +- [x] **6. Build OpenRouter API Client (FREE Tier)** + + **What to do**: + - Create `src/lib/ai/openrouter.ts`: Client for OpenRouter API. + - Model: `llama-3.3-70b:free` (default for FREE tier). + - Function signature: `generateNPC(params: NPCParams, reference?: Open5eMonster): Promise`. + - Build structured prompt: system prompt "valid JSON only, no markdown", user prompt with params + reference stat block. + - Include JSON schema in prompt to constrain output. + - Handle API errors: timeout (10s), rate limit (429), invalid JSON response. + - Validate AI response with zod schema before returning. + - Write TDD tests: successful generation, invalid JSON handling, API error handling. + + **Must NOT do**: + - Expose OpenRouter API key client-side. + - Hardcode fallback models (handle via config if needed later). + - Store raw prompts in DB. + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Single-purpose API client with known endpoint and format. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 1, parallel with Task 7) + - **Parallel Group**: Wave 1 + - **Blocks**: Task 10 (generate API), Task 12 (translation API) + - **Blocked By**: None + + **References**: + - OpenRouter docs: https://openrouter.ai/docs + - `src/lib/open5e/client.ts` — For reference monster fetching + - Existing random utilities: `src/lib/client/random.ts` + + **Acceptance Criteria**: + - [ ] `npm test src/lib/ai/openrouter.test.ts` → PASS + - [ ] Client validates AI response JSON with zod + - [ ] API key read from `process.env.OPENROUTER_API_KEY` + - [ ] Timeout set to 10 seconds + + **QA Scenarios**: + ``` + Scenario: Generate NPC via OpenRouter + Tool: Bash (node REPL) + Preconditions: OPENROUTER_API_KEY set + Steps: + 1. Import openrouter client + 2. Call generateNPC({race: 'elf', role: 'wizard', level: 5, tone: 'dark'}) + 3. Assert: Returns object with name, race, role, hp, ac, cr, speed, appearance, trait, motivation, secret, history + Expected Result: Valid NPC object with all required fields + Evidence: .sisyphus/evidence/task-6-openrouter-npc.log + + Scenario: Handle invalid JSON from AI + Tool: Bash (node REPL) + Preconditions: Mock OpenRouter returning markdown instead of JSON + Steps: + 1. Call generateNPC with mocked client + 2. Assert: Throws descriptive error about invalid JSON + Expected Result: Graceful error, no crash + Evidence: .sisyphus/evidence/task-6-invalid-json.log + ``` + + **Commit**: YES (grouped with Task 7) + - Message: `feat(ai): add OpenRouter and Kimi API clients` + - Files: `src/lib/ai/openrouter.ts` + +- [x] **7. Build Kimi API Client (PRO Tier)** + + **What to do**: + - Create `src/lib/ai/kimi.ts`: Client for Kimi API (api.moonshot.ai). + - Model: default Kimi model (check docs for latest, e.g., `moonshot-v1-8k`). + - Same interface as OpenRouter client: `generateNPC(params, reference?)`. + - Same structured prompt approach (system + user + JSON schema). + - Same error handling and zod validation. + - Write TDD tests mirroring OpenRouter client tests. + + **Must NOT do**: + - Expose Kimi API key client-side. + - Use different response format than OpenRouter (keep interface uniform). + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Parallel implementation with Task 6, same pattern. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 1, parallel with Task 6) + - **Parallel Group**: Wave 1 + - **Blocks**: Task 10 (generate API needs both clients) + - **Blocked By**: None + + **References**: + - Kimi API docs: https://platform.moonshot.ai/docs + - `src/lib/ai/openrouter.ts` — Pattern to follow (parallel task) + + **Acceptance Criteria**: + - [ ] `npm test src/lib/ai/kimi.test.ts` → PASS + - [ ] Client validates AI response JSON with zod + - [ ] API key read from `process.env.KIMI_API_KEY` + - [ ] Same NPCResult interface as OpenRouter client + + **QA Scenarios**: + ``` + Scenario: Generate NPC via Kimi + Tool: Bash (node REPL) + Preconditions: KIMI_API_KEY set + Steps: + 1. Import kimi client + 2. Call generateNPC({race: 'dwarf', role: 'fighter', level: 3, tone: 'heroic'}) + 3. Assert: Returns valid NPC object matching NPCResult interface + Expected Result: Valid NPC with same schema as OpenRouter + Evidence: .sisyphus/evidence/task-7-kimi-npc.log + ``` + + **Commit**: YES (grouped with Task 6) + - Message: `feat(ai): add OpenRouter and Kimi API clients` + - Files: `src/lib/ai/kimi.ts` + - Pre-commit: `npm test src/lib/ai/` + +- [ ] **8. Build Rate Limiting Service/Middleware** + + **What to do**: + - Create `src/lib/rate-limit.ts`: Service for per-user generation counters. + - Counter resets at top of each hour (not rolling window): `hourWindow = date_trunc('hour', now())`. + - FREE tier: 7 generations/hour. PRO tier: 100 generations/hour. + - Check counter BEFORE AI API call (don't waste provider quota on blocked requests). + - Increment counter AFTER successful AI response (don't charge for failures). + - Add `getRemainingQuota(userId, tier)` function to query remaining generations for the current hour. + - Expose quota via lightweight endpoint or middleware (consumed by Task 19 quota badge). + - Unauthenticated users: return 401 (AI requires auth). + - Rate limit response: HTTP 429 with `Retry-After` header (seconds until next hour). + - Write TDD tests: within limit, at limit, over limit, counter reset, tier differentiation, quota query. + + **Must NOT do**: + - Apply rate limiting to non-AI routes (dice, initiative, reference, notes). + - Use rolling window (keep hourly reset for simplicity). + - Hard-block PRO at exactly 100 (allow minor burst, advisory behavior). + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - Reason: Critical correctness requirement (affects revenue and user experience). + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 4) + - **Parallel Group**: Wave 4 + - **Blocks**: Task 10 (generate API needs rate limiting), Task 19 (quota badge needs quota query) + - **Blocked By**: Task 2/3 (DB schema + migration), Task 5 (tier system) + + **References**: + - `src/db/schema.ts` — generationCounters table + - `src/middleware/index.ts` — Where user auth is validated + - `src/lib/boosty.ts` — Tier information source + + **Acceptance Criteria**: + - [ ] `npm test src/lib/rate-limit.test.ts` → PASS (7 test cases) + - [ ] FREE user gets 429 on 8th request within same hour + - [ ] PRO user gets 429 on 101st request within same hour + - [ ] 429 response includes Retry-After header + - [ ] Counter resets at top of next hour + - [ ] `getRemainingQuota()` returns correct remaining count for current hour + + **QA Scenarios**: + ``` + Scenario: FREE user hits rate limit + Tool: Bash (curl) + Preconditions: FREE user authenticated + Steps: + 1. Send 8 POST requests to /api/dm/ai/generate within 1 minute + 2. Assert: Requests 1-7 return 200 + 3. Assert: Request 8 returns 429 with Retry-After header > 0 + 4. Assert: Retry-After value <= 3600 + Expected Result: 7 successful, 8th blocked with proper headers + Evidence: .sisyphus/evidence/task-8-rate-limit-free.log + + Scenario: Counter resets at new hour + Tool: Bash (curl) + Preconditions: FREE user exhausted 7/7 limit + Steps: + 1. Wait until top of next hour (or mock time) + 2. Send POST to /api/dm/ai/generate + 3. Assert: Returns 200 (counter reset) + Expected Result: Counter resets hourly + Evidence: .sisyphus/evidence/task-8-rate-limit-reset.log + ``` + + **Commit**: YES + - Message: `feat(api): add rate limiting service for AI generation` + - Files: `src/lib/rate-limit.ts` + - Pre-commit: `npm test src/lib/rate-limit.test.ts` + +- [ ] **9. Fix DmSidebar User Prop + Add Tier Badge Support** + + **What to do**: + - Fix `src/pages/dm/index.astro`: Pass `user={Astro.locals.user}` to ``. + - Update `src/components/dm/DmSidebar.astro`: Render user block when `user` prop is present. + - Add tier badge to sidebar user block: "FREE" (outline) or "PRO" (solid orange). + - Add quota display to sidebar or header (remaining generations this hour). + - Ensure tier badge updates without page reload (fetch quota endpoint or use HTMX). + - Add conditional rendering: AI generator list in sidebar only when user is authenticated. + - Update `src/i18n/dm-translations.ts` with new strings for tier badges and quota. + + **Must NOT do**: + - Modify existing dice/initiative/reference/notes sidebar items. + - Add AI generator navigation for unauthenticated users. + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: UI wiring and conditional rendering with existing components. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 2) + - **Parallel Group**: Wave 2 + - **Blocks**: Task 15 (AI tab navigation) + - **Blocked By**: Task 1 (auth fixes) + + **References**: + - `src/pages/dm/index.astro` — Where DmSidebar is rendered without user prop + - `src/components/dm/DmSidebar.astro` — Sidebar component accepting user prop + - `src/components/dm/AuthPanel.astro` — Existing auth UI for reference + - `src/i18n/dm-translations.ts` — Translation strings + + **Acceptance Criteria**: + - [ ] Playwright test: sidebar shows user avatar/name when logged in + - [ ] Playwright test: sidebar shows "FREE" or "PRO" tier badge + - [ ] Playwright test: AI generator list hidden when logged out + - [ ] `tsc --noEmit` → zero errors + + **QA Scenarios**: + ``` + Scenario: Sidebar shows tier badge for logged-in user + Tool: Playwright + Preconditions: User logged in with FREE tier + Steps: + 1. Navigate to /dm/ + 2. Assert: Sidebar contains user name and avatar + 3. Assert: Sidebar contains "FREE" badge + 4. Login as PRO user + 5. Assert: Sidebar contains "PRO" badge + Expected Result: Tier badge renders correctly for both tiers + Evidence: .sisyphus/evidence/task-9-tier-badge.png + ``` + + **Commit**: YES + - Message: `fix(ui): wire DmSidebar user prop and add tier badges` + - Files: `src/pages/dm/index.astro`, `src/components/dm/DmSidebar.astro`, `src/i18n/dm-translations.ts` + - Pre-commit: `npm run build` + +- [ ] **10. Build NPC Generation API Route** + + **What to do**: + - Create `src/pages/api/dm/ai/generate.ts`: POST endpoint for NPC generation. + - Request body: `{ race, role, level, tone, useOpen5eReference?: boolean }`. + - Flow: + 1. Validate auth (401 if unauthenticated). + 2. Check rate limit (429 if exceeded, with Retry-After). + 3. If `useOpen5eReference`, fetch matching monster from Open5e V2 API (by CR/role similarity). + 4. Select AI client: OpenRouter for FREE tier, Kimi for PRO tier. + 5. Call AI client with params + reference (if fetched). + 6. Validate AI response with zod schema. + 7. Save generated NPC to `npcs` table (for history). + 8. Increment generation counter. + 9. Return `{ npc: NPCResult, reference?: Open5eMonster }`. + - Handle errors at each step with appropriate HTTP codes. + - Write TDD tests: happy path, rate limited, invalid auth, Open5e fetch failure, invalid AI response. + + **Must NOT do**: + - Expose AI API keys or raw prompts in response. + - Call AI API before rate limit check. + - Save failed generations to npcs table. + + **Recommended Agent Profile**: + - **Category**: `deep` + - Reason: Complex multi-step API route with external dependencies, error handling, and data flow. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 5) + - **Parallel Group**: Wave 5 + - **Blocks**: Task 16 (NPC form), Task 17 (NPC result) + - **Blocked By**: Task 4 (Open5e interfaces), Task 5 (Boosty/tier), Task 6/7 (AI clients), Task 8 (rate limiting) + + **References**: + - `src/lib/ai/openrouter.ts` — FREE tier AI client + - `src/lib/ai/kimi.ts` — PRO tier AI client + - `src/lib/open5e/client.ts` — Open5e reference fetching + - `src/lib/rate-limit.ts` — Rate limiting service + - `src/db/schema.ts` — npcs table, generationCounters table + - `src/middleware/index.ts` — Auth validation pattern + + **Acceptance Criteria**: + - [ ] `npm test src/pages/api/dm/ai/generate.test.ts` → PASS (5 test cases) + - [ ] `curl` with valid auth + params → returns 200 with valid NPC JSON + - [ ] `curl` without auth → returns 401 + - [ ] `curl` with FREE user after 7 requests → returns 429 with Retry-After + - [ ] AI response validated by zod before returning + + **QA Scenarios**: + ``` + Scenario: Generate NPC with Open5e reference (FREE tier) + Tool: Bash (curl) + Preconditions: FREE user authenticated, rate limit not exceeded + Steps: + 1. curl -X POST http://localhost:4321/api/dm/ai/generate \ + -H "Cookie: auth_token=FREE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"race":"elf","role":"wizard","level":5,"tone":"dark","useOpen5eReference":true}' + 2. Assert: Status 200 + 3. Assert: JSON contains npc.name, npc.hp, npc.ac, npc.cr + 4. Assert: JSON contains reference object (Open5e monster stats) + 5. Assert: DB npcs table has new row for this user + Expected Result: Valid NPC generated with reference, saved to history + Evidence: .sisyphus/evidence/task-10-generate-npc.log + + Scenario: Rate limit blocks FREE user + Tool: Bash (curl) + Preconditions: FREE user has used 7/7 generations this hour + Steps: + 1. curl -X POST ... (8th request) + 2. Assert: Status 429 + 3. Assert: Header Retry-After present and > 0 + Expected Result: Request blocked with proper rate limit headers + Evidence: .sisyphus/evidence/task-10-rate-limit-429.log + + Scenario: Unauthenticated user cannot generate + Tool: Bash (curl) + Preconditions: No auth_token cookie + Steps: + 1. curl -X POST ... (no cookie) + 2. Assert: Status 401 + Expected Result: Auth required for AI generation + Evidence: .sisyphus/evidence/task-10-unauthorized.log + ``` + + **Commit**: YES + - Message: `feat(api): add NPC generation endpoint` + - Files: `src/pages/api/dm/ai/generate.ts` + - Pre-commit: `npm test src/pages/api/dm/ai/generate.test.ts` + +- [ ] **11. Build Generation History API Route** + + **What to do**: + - Create `src/pages/api/dm/ai/history.ts`: GET endpoint for user's generation history. + - Query params: `?limit=20&offset=0`. + - Returns: `{ items: NPCResult[], total: number }`. + - For FREE users: return only session history (from `sessionStorage` or in-memory — decision needed). Actually, per requirements, FREE users see only session history. But history API is server-side. For MVP, simplify: return DB history for all authenticated users, but FREE users only see their own (no cross-device). The session-only requirement for FREE can be implemented client-side. + - Actually, re-reading requirements: "History panel shows last 20 generated NPCs". For FREE: "История сохраняется только для PRO. Бесплатные генерации видны до конца сессии." So FREE history is client-side only. The history API should only return DB-stored history (which means PRO only). But we save NPCs to DB for all users... Let me think. The plan says "Save generated NPC to npcs table (for history)" — this could be for all users. But the requirement says PRO saves to DB. For MVP, let's save all NPCs to DB but only PRO users see cross-session history. FREE users see client-side session history. + - Pagination with `limit` and `offset`. + - Order by `createdAt DESC`. + - Write TDD tests: list history, pagination, empty state. + + **Must NOT do**: + - Return other users' NPCs (strict user_id filtering). + - Allow unauthenticated access. + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - Reason: Standard CRUD API route with pagination. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 5) + - **Parallel Group**: Wave 5 + - **Blocks**: Task 18 (history panel UI) + - **Blocked By**: Task 2/3 (DB schema + migration) + + **References**: + - `src/db/schema.ts` — npcs table + - `src/pages/api/dm/ai/generate.ts` — Creates NPC data + - `src/middleware/index.ts` — Auth pattern + + **Acceptance Criteria**: + - [ ] `npm test src/pages/api/dm/ai/history.test.ts` → PASS + - [ ] `curl` with auth → returns user's NPC history + - [ ] `curl` without auth → returns 401 + - [ ] Pagination works (limit/offset) + + **QA Scenarios**: + ``` + Scenario: Retrieve generation history + Tool: Bash (curl) + Preconditions: User has generated 3 NPCs + Steps: + 1. curl "http://localhost:4321/api/dm/ai/history?limit=10" -H "Cookie: auth_token=TOKEN" + 2. Assert: Status 200 + 3. Assert: JSON items array length = 3 + 4. Assert: Each item has name, race, createdAt + Expected Result: Returns user's NPC history + Evidence: .sisyphus/evidence/task-11-history-list.log + ``` + + **Commit**: YES + - Message: `feat(api): add generation history endpoint` + - Files: `src/pages/api/dm/ai/history.ts` + - Pre-commit: `npm test src/pages/api/dm/ai/history.test.ts` + +- [ ] **12. Build Translation API Route** + + **What to do**: + - Create `src/pages/api/dm/translate.ts`: GET endpoint for Open5e content translation. + - Query params: `?slug=monster-name&type=creature`. + - Flow: + 1. Check `translations` table: if cache hit, return immediately. + 2. If cache miss, fetch original content from Open5e API. + 3. Call OpenRouter `llama-3.3-70b:free` to translate to Russian. + 4. Save translation to `translations` table. + 5. Return `{ translated: object, cached: boolean }`. + - Available to ALL users including unauthenticated (no auth required). + - Translation prompt: structured, preserve JSON format, translate text fields only. + - Write TDD tests: cache hit, cache miss, Open5e fetch failure, translation API failure. + + **Must NOT do**: + - Use Kimi API for translations (use OpenRouter free model for all). + - Rate limit translation requests. + - Return raw AI prompt or API key in response. + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - Reason: External API integration with caching, available to unauthenticated users. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 4) + - **Parallel Group**: Wave 4 + - **Blocks**: Task 23 (translate button), Task 24 (translation UI wiring) + - **Blocked By**: Task 2/3 (DB schema), Task 6 (OpenRouter client) + + **References**: + - `src/lib/ai/openrouter.ts` — Translation via OpenRouter + - `src/lib/open5e/client.ts` — Fetch original content + - `src/db/schema.ts` — translations table + + **Acceptance Criteria**: + - [ ] `npm test src/pages/api/dm/translate.test.ts` → PASS + - [ ] `curl` without auth → returns 200 with translation + - [ ] First request hits OpenRouter, second request hits DB cache + - [ ] Translation preserves original JSON structure + + **QA Scenarios**: + ``` + Scenario: Translation cache miss then hit + Tool: Bash (curl) + Preconditions: Translation not in DB + Steps: + 1. curl "/api/dm/translate?slug=goblin&type=creature" + 2. Assert: Status 200, cached=false + 3. curl "/api/dm/translate?slug=goblin&type=creature" (same request) + 4. Assert: Status 200, cached=true + 5. Assert: Response time < 100ms (cache hit) + Expected Result: First request generates, second serves from cache + Evidence: .sisyphus/evidence/task-12-translation-cache.log + ``` + + **Commit**: YES + - Message: `feat(api): add Open5e translation endpoint` + - Files: `src/pages/api/dm/translate.ts` + - Pre-commit: `npm test src/pages/api/dm/translate.test.ts` + +- [ ] **13. Add CORS Configuration for API Routes** + + **What to do**: + - Add CORS headers to all new DM API routes (`/api/dm/*`). + - Allowed origins: `randify.pro`, `dm.randify.pro`, `localhost:4321`. + - Allowed methods: `GET`, `POST`, `OPTIONS`. + - Allowed headers: `Content-Type`, `Authorization` (if needed). + - Credentials: `true` (for cookies). + - Add `OPTIONS` preflight handler. + - Write tests verifying CORS headers on all DM API routes. + + **Must NOT do**: + - Allow wildcard `*` origin. + - Apply CORS globally to non-DM routes. + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Standard middleware configuration. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 2) + - **Parallel Group**: Wave 2 + - **Blocks**: None (all API routes can use it) + - **Blocked By**: None + + **References**: + - Astro API route docs for CORS handling + - `src/pages/api/health.ts` — Existing API route for reference + + **Acceptance Criteria**: + - [ ] `curl -I -X OPTIONS http://localhost:4321/api/dm/ai/generate` → returns 204 with CORS headers + - [ ] `curl` with Origin header → response includes Access-Control-Allow-Origin matching origin + - [ ] CORS headers present on all `/api/dm/*` routes + + **QA Scenarios**: + ``` + Scenario: CORS preflight request + Tool: Bash (curl) + Preconditions: Server running + Steps: + 1. curl -X OPTIONS -H "Origin: http://dm.randify.pro" http://localhost:4321/api/dm/ai/generate -v + 2. Assert: Status 204 + 3. Assert: Header Access-Control-Allow-Origin contains dm.randify.pro + 4. Assert: Header Access-Control-Allow-Credentials: true + Expected Result: Proper CORS preflight response + Evidence: .sisyphus/evidence/task-13-cors-preflight.log + ``` + + **Commit**: YES (grouped with nearest API route commit) + +- [ ] **14. Build Notes/Initiative DB API Routes** + + **What to do**: + - Create `src/pages/api/dm/notes.ts`: GET/POST/PUT/DELETE for notes. + - GET: returns user's notes array. + - POST: create new note. + - PUT: update note by ID. + - DELETE: delete note by ID. + - Create `src/pages/api/dm/initiative.ts`: GET/POST/PUT/DELETE for initiative sessions. + - GET: returns user's initiative sessions. + - POST: create new session. + - PUT: update session by ID. + - DELETE: delete session by ID. + - Both routes: 401 for unauthenticated. + - Validate request body with zod. + - Write TDD tests for all CRUD operations. + + **Must NOT do**: + - Allow access to other users' notes/sessions. + - Modify existing localStorage/sessionStorage logic (keep for FREE fallback). + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - Reason: Standard CRUD API routes with auth. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 3) + - **Parallel Group**: Wave 3 + - **Blocks**: Task 20 (notes DB UI), Task 21 (initiative DB UI) + - **Blocked By**: Task 2/3 (DB schema) + + **References**: + - `src/db/schema.ts` — notes, initiativeSessions tables + - `src/middleware/index.ts` — Auth pattern + + **Acceptance Criteria**: + - [ ] `npm test src/pages/api/dm/notes.test.ts` → PASS + - [ ] `npm test src/pages/api/dm/initiative.test.ts` → PASS + - [ ] All CRUD operations work with proper auth + - [ ] Cross-user access blocked (404 or 403) + + **QA Scenarios**: + ``` + Scenario: Notes CRUD operations + Tool: Bash (curl) + Preconditions: Authenticated user + Steps: + 1. POST /api/dm/notes -d '{"title":"Test","content":"Hello"}' → 201 + 2. GET /api/dm/notes → 200, items[0].title = "Test" + 3. PUT /api/dm/notes/:id -d '{"title":"Updated"}' → 200 + 4. DELETE /api/dm/notes/:id → 204 + 5. GET /api/dm/notes → 200, items = [] + Expected Result: Full CRUD lifecycle works + Evidence: .sisyphus/evidence/task-14-notes-crud.log + ``` + + **Commit**: YES + - Message: `feat(api): add notes and initiative DB endpoints` + - Files: `src/pages/api/dm/notes.ts`, `src/pages/api/dm/initiative.ts` + - Pre-commit: `npm test src/pages/api/dm/notes.test.ts src/pages/api/dm/initiative.test.ts` + +- [ ] **15. Add AI Tab to Navigation** + + **What to do**: + - Update `src/components/dm/DmTabs.astro`: Add "ИИ" tab with icon (✦) as 5th tab after dice, initiative, reference, notes. + - Update `src/components/dm/DmSidebar.astro`: When AI tab is active, show AI generator list (NPC, placeholder for future generators). + - Update hash-based routing: `#ai` routes to AI generator section. + - For unauthenticated users: AI tab visible but shows "Sign in to use AI" CTA instead of generator form. + - Update `src/i18n/dm-translations.ts` with new tab labels and CTA strings. + - Update `src/pages/dm/index.astro` to render AI section conditionally. + + **Must NOT do**: + - Remove existing dice/initiative/reference/notes tabs. + - Show AI generator form to unauthenticated users. + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Navigation updates using existing tab system. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 4) + - **Parallel Group**: Wave 4 + - **Blocks**: Task 16 (NPC form needs tab to render in) + - **Blocked By**: Task 9 (DmSidebar fix) + + **References**: + - `src/components/dm/DmTabs.astro` — Hash-based tab navigation + - `src/components/dm/DmSidebar.astro` — Sidebar with conditional rendering + - `src/pages/dm/index.astro` — Main DM page layout + - `src/i18n/dm-translations.ts` — Translation strings + + **Acceptance Criteria**: + - [ ] Playwright: AI tab visible in navigation + - [ ] Playwright: Clicking AI tab shows AI section + - [ ] Playwright: Unauthenticated user sees "Sign in" CTA + - [ ] Playwright: Authenticated user sees generator form + + **QA Scenarios**: + ``` + Scenario: AI tab navigation + Tool: Playwright + Preconditions: User on /dm/ + Steps: + 1. Assert: Tab bar contains "ИИ" tab + 2. Click "ИИ" tab + 3. Assert: URL hash changes to #ai + 4. Assert: AI generator section is visible + Expected Result: AI tab navigates to AI section + Evidence: .sisyphus/evidence/task-15-ai-tab.png + ``` + + **Commit**: YES + - Message: `feat(ui): add AI tab to DM navigation` + - Files: `src/components/dm/DmTabs.astro`, `src/components/dm/DmSidebar.astro`, `src/pages/dm/index.astro`, `src/i18n/dm-translations.ts` + +- [ ] **16. Build NPC Generator Form Component** + + **What to do**: + - Create `src/components/dm/AiNpcForm.astro`: Form for NPC generation parameters. + - Fields: + - Race: dropdown (Human, Elf, Dwarf, Halfling, Gnome, Half-Elf, Half-Orc, Tiefling, Dragonborn, Custom). + - Role/Class: dropdown (Fighter, Wizard, Rogue, Cleric, Ranger, Paladin, Barbarian, Bard, Druid, Monk, Sorcerer, Warlock, Custom). + - Party Level: number input (1-20, default 5). + - Tone: dropdown (Heroic, Dark, Mysterious, Comic, Tragic, Noble, Sinister, Custom). + - "Use Open5e Reference" checkbox (default checked). + - Submit button: "✦ Сгенерировать персонажа" (disabled during generation). + - Show loading state during generation (spinner + "Генерация..."). + - On success: emit event or call callback with NPC result. + - On error: show error message (rate limit, auth error, generation failed). + - Use existing `DmInput.astro` and `DmButton.astro` components. + - Style with orange theme accents. + + **Must NOT do**: + - Use purple color from mockup (keep orange theme). + - Call AI API directly from client (always use server endpoint). + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - Reason: UI component with forms, state, and interactions. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 6) + - **Parallel Group**: Wave 6 + - **Blocks**: None (standalone component) + - **Blocked By**: Task 10 (generate API must exist), Task 15 (AI tab nav) + + **References**: + - `src/components/dm/DmInput.astro` — Input component + - `src/components/dm/DmButton.astro` — Button component + - `src/components/dm/DmCard.astro` — Card container + - `src/i18n/dm-translations.ts` — Form labels + + **Acceptance Criteria**: + - [ ] Playwright: All form fields render and accept input + - [ ] Playwright: Submit button disabled during generation + - [ ] Playwright: Loading state visible during generation + - [ ] Playwright: Error message shown on API failure + + **QA Scenarios**: + ``` + Scenario: Generate NPC through form + Tool: Playwright + Preconditions: Authenticated FREE user on AI tab + Steps: + 1. Select Race: "Elf" + 2. Select Role: "Wizard" + 3. Input Level: 5 + 4. Select Tone: "Dark" + 5. Check "Use Open5e Reference" + 6. Click "Сгенерировать персонажа" + 7. Assert: Button disabled, spinner visible + 8. Assert: After ~5-10s, result card appears + Expected Result: Form submits successfully and displays result + Evidence: .sisyphus/evidence/task-16-form-submit.png + + Scenario: Rate limit error in form + Tool: Playwright + Preconditions: FREE user exhausted 7/7 limit + Steps: + 1. Click "Сгенерировать персонажа" + 2. Assert: Error message "Лимит генераций исчерпан" visible + 3. Assert: Retry-after time displayed + Expected Result: User-friendly rate limit error + Evidence: .sisyphus/evidence/task-16-rate-limit-error.png + ``` + + **Commit**: YES + - Message: `feat(ui): add NPC generator form component` + - Files: `src/components/dm/AiNpcForm.astro` + +- [ ] **17. Build NPC Result Card Component** + + **What to do**: + - Create `src/components/dm/AiNpcResult.astro`: Rich card displaying generated NPC. + - Layout: + - Header: Name + meta (race, class, level, CR) + action buttons (regenerate, copy JSON, favorite). + - Stats row: HP, AC, Speed, CR (4-column grid). + - Attributes: Appearance, Trait, Motivation, Secret (list with labels). + - Story block: Backstory/history (italic, left orange border). + - Tags: Skills, abilities, senses (pill chips). + - Action buttons: + - Regenerate: re-submit same params. + - Copy JSON: copy raw NPC JSON to clipboard. + - Favorite/Save: save to history (PRO only, shows upsell for FREE). + - Sanitize all AI-generated text before rendering (use textContent or DOMPurify, NEVER innerHTML). + - Responsive design: stack on mobile. + - Style with orange theme. + + **Must NOT do**: + - Use `innerHTML` for AI-generated content (XSS risk). + - Use purple from mockup. + - Show "favorite" button as functional for FREE users (show upsell instead). + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - Reason: Complex UI component with multiple sections and interactions. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 6) + - **Parallel Group**: Wave 6 + - **Blocks**: None + - **Blocked By**: Task 11 (history API) + + **References**: + - `src/components/dm/DmCard.astro` — Card container + - `src/components/dm/DmButton.astro` — Action buttons + - Mockup reference: `/home/emil/Downloads/dm-dashboard-ai-mockup.html` — Layout inspiration + + **Acceptance Criteria**: + - [ ] Playwright: Result card renders with all sections + - [ ] Playwright: Copy JSON button copies valid JSON + - [ ] Playwright: Regenerate button re-submits form + - [ ] Playwright: No innerHTML used for AI content (inspect DOM) + + **QA Scenarios**: + ``` + Scenario: NPC result card displays correctly + Tool: Playwright + Preconditions: NPC generated successfully + Steps: + 1. Assert: Name visible in header + 2. Assert: Stats row shows HP, AC, Speed, CR + 3. Assert: Appearance, Trait, Motivation, Secret visible + 4. Assert: Story block has orange left border + 5. Assert: Tags rendered as pill chips + Expected Result: Complete NPC card with all sections + Evidence: .sisyphus/evidence/task-17-result-card.png + + Scenario: Copy NPC JSON to clipboard + Tool: Playwright + Preconditions: NPC result visible + Steps: + 1. Click "Copy JSON" button + 2. Assert: Toast "Скопировано" appears + 3. Read clipboard content + 4. Assert: Content is valid JSON with npc fields + Expected Result: JSON copied to clipboard + Evidence: .sisyphus/evidence/task-17-copy-json.png + ``` + + **Commit**: YES + - Message: `feat(ui): add NPC result card component` + - Files: `src/components/dm/AiNpcResult.astro` + +- [ ] **18. Build Generation History Panel Component** + + **What to do**: + - Create `src/components/dm/GenerationHistory.astro`: Right-panel list of generated NPCs. + - For PRO users: fetch from `/api/dm/ai/history`, show last 20 items with name + timestamp. + - For FREE users: show session-only history (from client-side array/sessionStorage). + - FREE empty state: dashed border box with "История сохраняется только для PRO. Бесплатные генерации видны до конца сессии." + CTA to upgrade. + - Clicking history item: loads that NPC into result card. + - Active item highlighting. + - Auto-update when new generation completes. + - Style with orange theme. + + **Must NOT do**: + - Fetch DB history for FREE users. + - Show upgrade CTA to PRO users. + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - Reason: Panel with data fetching, conditional states, and interactions. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 6) + - **Parallel Group**: Wave 6 + - **Blocks**: None + - **Blocked By**: Task 11 (history API) + + **References**: + - `src/components/dm/DmCard.astro` — Panel container + - `src/pages/api/dm/ai/history.ts` — History data source + - Mockup reference: `/home/emil/Downloads/dm-dashboard-ai-mockup.html` — History panel layout + + **Acceptance Criteria**: + - [ ] Playwright: PRO user sees DB history list + - [ ] Playwright: FREE user sees session history or empty state with upsell + - [ ] Playwright: Clicking history item loads NPC into result card + - [ ] Playwright: New generation appends to history + + **QA Scenarios**: + ``` + Scenario: PRO user sees persisted history + Tool: Playwright + Preconditions: PRO user with 3 generated NPCs in DB + Steps: + 1. Navigate to AI tab + 2. Assert: History panel shows 3 items + 3. Assert: Each item shows name and timestamp + Expected Result: PRO history loads from DB + Evidence: .sisyphus/evidence/task-18-pro-history.png + + Scenario: FREE user sees upsell empty state + Tool: Playwright + Preconditions: FREE user, no session history + Steps: + 1. Navigate to AI tab + 2. Assert: History panel shows dashed border upsell box + 3. Assert: Text contains "только для PRO" + Expected Result: FREE user sees upgrade prompt + Evidence: .sisyphus/evidence/task-18-free-upsell.png + ``` + + **Commit**: YES + - Message: `feat(ui): add generation history panel` + - Files: `src/components/dm/GenerationHistory.astro` + +- [ ] **19. Build Quota/Tier Badge Header Components** + + **What to do**: + - Create `src/components/dm/QuotaBadge.astro`: Shows remaining generations this hour (e.g., "5 / 7"). + - Create `src/components/dm/TierBadge.astro`: Shows "FREE" or "PRO" with appropriate styling. + - Add to `DmHeader.astro` or `AuthPanel.astro`: render badges next to user avatar. + - Quota badge: query remaining quota from `src/lib/rate-limit.ts` (Task 8) via API endpoint or middleware injection. + - Update quota after each generation (without page reload). + - Color coding: FREE = gray outline, PRO = orange solid. + - Update `src/i18n/dm-translations.ts` with badge labels. + + **Must NOT do**: + - Show quota to unauthenticated users. + - Show PRO badge to FREE users. + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - Reason: Small UI components with dynamic data. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 5) + - **Parallel Group**: Wave 5 + - **Blocks**: None + - **Blocked By**: Task 5 (tier system), Task 8 (rate limiting) + + **References**: + - `src/components/dm/AuthPanel.astro` — User avatar/name display + - `src/components/dm/DmHeader.astro` — Header bar + - Mockup reference: `/home/emil/Downloads/dm-dashboard-ai-mockup.html` — Badge styling + + **Acceptance Criteria**: + - [ ] Playwright: FREE user sees "FREE" badge and "X / 7" quota + - [ ] Playwright: PRO user sees "PRO" badge and "X / 100" quota + - [ ] Playwright: Quota updates after generation without reload + - [ ] Playwright: Unauthenticated user sees no badges + + **QA Scenarios**: + ``` + Scenario: Quota badge updates after generation + Tool: Playwright + Preconditions: FREE user with 6/7 remaining + Steps: + 1. Assert: Quota badge shows "6 / 7" + 2. Generate NPC + 3. Assert: Quota badge updates to "5 / 7" without page reload + Expected Result: Quota decrements in real-time + Evidence: .sisyphus/evidence/task-19-quota-update.png + ``` + + **Commit**: YES + - Message: `feat(ui): add quota and tier badge components` + - Files: `src/components/dm/QuotaBadge.astro`, `src/components/dm/TierBadge.astro`, `src/components/dm/AuthPanel.astro` + +- [ ] **20. Migrate Notes to DB for PRO + localStorage Fallback** + + **What to do**: + - Update `src/components/dm/NotesPanel.astro`: Add tier-aware persistence. + - For PRO users: load notes from `/api/dm/notes` on mount, save to API on change (debounced 500ms). + - For FREE users: keep existing localStorage logic (`dm-notes` key). + - Graceful handling: if API fails, fall back to localStorage with warning toast. + - Add loading state while fetching from DB. + - Update `src/lib/client/notes.ts`: Add server sync functions. + - Write Playwright tests: PRO loads from DB, FREE uses localStorage, fallback on API error. + + **Must NOT do**: + - Delete localStorage data when PRO user loads from DB (keep as backup). + - Modify notes UI appearance (only persistence layer changes). + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - Reason: Complex dual-mode persistence with fallback and sync. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 6) + - **Parallel Group**: Wave 6 + - **Blocks**: Task 22 (import flow) + - **Blocked By**: Task 5 (tier system), Task 14 (notes API) + + **References**: + - `src/components/dm/NotesPanel.astro` — Current localStorage-only implementation + - `src/lib/client/notes.ts` — Notes utilities + - `src/pages/api/dm/notes.ts` — Notes API (Task 14) + - Data migration key: `dm-notes` (localStorage) + + **Acceptance Criteria**: + - [ ] Playwright: PRO user loads notes from DB on page open + - [ ] Playwright: FREE user loads notes from localStorage + - [ ] Playwright: Notes save to DB for PRO, localStorage for FREE + - [ ] Playwright: API failure shows warning and falls back to localStorage + + **QA Scenarios**: + ``` + Scenario: PRO user notes persist to DB + Tool: Playwright + Preconditions: PRO user logged in + Steps: + 1. Type "Test note content" in notes textarea + 2. Wait 1 second (debounce) + 3. Reload page + 4. Assert: Notes textarea contains "Test note content" + 5. Query DB: SELECT content FROM notes WHERE user_id = ... + 6. Assert: DB contains "Test note content" + Expected Result: PRO notes saved to and loaded from DB + Evidence: .sisyphus/evidence/task-20-pro-notes-db.png + + Scenario: FREE user notes stay in localStorage + Tool: Playwright + Preconditions: FREE user logged in + Steps: + 1. Type "Free note" in notes textarea + 2. Reload page + 3. Assert: Notes textarea contains "Free note" + 4. Query DB: SELECT * FROM notes WHERE user_id = ... + 5. Assert: Query returns zero rows + Expected Result: FREE notes stay client-side only + Evidence: .sisyphus/evidence/task-20-free-notes-local.png + ``` + + **Commit**: YES + - Message: `feat(pro): add notes DB persistence for PRO users` + - Files: `src/components/dm/NotesPanel.astro`, `src/lib/client/notes.ts` + +- [ ] **21. Migrate Initiative to DB for PRO + sessionStorage Fallback** + + **What to do**: + - Update `src/components/dm/InitiativeTracker.astro`: Add tier-aware persistence. + - For PRO users: save/load initiative sessions from `/api/dm/initiative`. + - For FREE users: keep existing sessionStorage logic (`dm-initiative` key with `it-combatants` fallback). + - Do NOT persist `activeIndex` (keep in-memory as per decision). + - Add "Save Session" button for PRO users (manual save, not auto-save like notes). + - Add session list for PRO: load previous sessions, switch between them. + - Graceful fallback: if API fails, keep using sessionStorage. + - Write Playwright tests: PRO saves/loads sessions, FREE uses sessionStorage. + + **Must NOT do**: + - Persist `activeIndex` to DB (product decision: keep current behavior). + - Auto-save initiative (too frequent, manual save only for PRO). + - Delete sessionStorage data on PRO save. + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - Reason: Complex stateful component with dual persistence modes. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 6) + - **Parallel Group**: Wave 6 + - **Blocks**: Task 22 (import flow) + - **Blocked By**: Task 5 (tier system), Task 14 (initiative API) + + **References**: + - `src/components/dm/InitiativeTracker.astro` — Current sessionStorage implementation + - `src/lib/client/initiative.ts` — Initiative utilities + - `src/pages/api/dm/initiative.ts` — Initiative API (Task 14) + - Data migration keys: `dm-initiative`, `it-combatants` (legacy fallback) + + **Acceptance Criteria**: + - [ ] Playwright: PRO user saves initiative session to DB + - [ ] Playwright: PRO user loads previous session from list + - [ ] Playwright: FREE user uses sessionStorage exclusively + - [ ] Playwright: `activeIndex` resets on page reload (not persisted) + + **QA Scenarios**: + ``` + Scenario: PRO user saves initiative session + Tool: Playwright + Preconditions: PRO user with 3 combatants in tracker + Steps: + 1. Click "Save Session" button + 2. Enter session name: "Boss Fight" + 3. Click Save + 4. Reload page + 5. Assert: Session list shows "Boss Fight" + 6. Click "Boss Fight" + 7. Assert: 3 combatants loaded with correct initiative values + Expected Result: PRO sessions persist to DB + Evidence: .sisyphus/evidence/task-21-pro-initiative.png + ``` + + **Commit**: YES + - Message: `feat(pro): add initiative DB persistence for PRO users` + - Files: `src/components/dm/InitiativeTracker.astro`, `src/lib/client/initiative.ts` + +- [ ] **22. Add FREE → PRO Data Import Flow** + + **What to do**: + - Create import modal/dialog: when FREE user subscribes to PRO (tier changes to 'pro'), show "Import existing data?" prompt. + - Read localStorage (`dm-notes`) and sessionStorage (`dm-initiative`) for current user. + - Upload data to DB via existing API routes. + - Show progress: "Importing notes... Done. Importing initiative sessions... Done." + - Handle conflicts: if DB already has data, ask "Keep both / Replace / Skip". + - Dismissible: user can skip import and keep localStorage data. + - Write Playwright test: simulate tier upgrade, verify import flow. + + **Must NOT do**: + - Delete localStorage/sessionStorage data during import (keep as backup). + - Force import on every login (only on tier upgrade event). + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - Reason: User-facing flow with data migration and conflict handling. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 7) + - **Parallel Group**: Wave 7 + - **Blocks**: None + - **Blocked By**: Task 20 (notes DB), Task 21 (initiative DB) + + **References**: + - `src/components/dm/NotesPanel.astro` — localStorage key: `dm-notes` + - `src/components/dm/InitiativeTracker.astro` — sessionStorage keys: `dm-initiative`, `it-combatants` + - `src/pages/api/dm/notes.ts`, `src/pages/api/dm/initiative.ts` — Import targets + + **Acceptance Criteria**: + - [ ] Playwright: Import modal shows on tier upgrade + - [ ] Playwright: Notes imported from localStorage to DB + - [ ] Playwright: Initiative imported from sessionStorage to DB + - [ ] Playwright: Skip import keeps data in localStorage + + **QA Scenarios**: + ``` + Scenario: FREE user upgrades to PRO and imports data + Tool: Playwright + Preconditions: FREE user with localStorage notes and sessionStorage initiative + Steps: + 1. Simulate tier upgrade (set user.tier = 'pro' in DB) + 2. Reload page + 3. Assert: Import modal visible + 4. Click "Import" + 5. Assert: Progress messages appear + 6. Assert: "Import complete" message + 7. Query DB: verify notes and initiative sessions exist + Expected Result: Data migrated from client storage to DB + Evidence: .sisyphus/evidence/task-22-import-flow.png + ``` + + **Commit**: YES + - Message: `feat(pro): add FREE to PRO data import flow` + - Files: `src/components/dm/ImportModal.astro` (new), related updates + +- [ ] **23. Add Translate Button to Open5e Reference Cards** + + **What to do**: + - Update `src/components/dm/Open5eReference.astro`: Add "Перевести" button to monster/spell/item detail cards. + - Button state: "Перевести" → loading "Перевод..." → "Переведено" (after success). + - Click: call `/api/dm/translate?slug={slug}&type={type}`. + - On success: replace English text fields with Russian translation in the card. + - On error: show toast "Ошибка перевода". + - Available to ALL users including unauthenticated. + - Cache translated content client-side (to avoid re-fetching on re-open). + - Update `src/i18n/dm-translations.ts` with button labels. + + **Must NOT do**: + - Require auth for translation. + - Rate limit translation requests. + - Auto-translate on card open (manual button only). + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - Reason: UI modification to existing component with API integration. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 5) + - **Parallel Group**: Wave 5 + - **Blocks**: Task 24 (wiring) + - **Blocked By**: Task 12 (translation API) + + **References**: + - `src/components/dm/Open5eReference.astro` — Detail card component + - `src/pages/api/dm/translate.ts` — Translation API (Task 12) + - `src/i18n/dm-translations.ts` — Button labels + + **Acceptance Criteria**: + - [ ] Playwright: "Перевести" button visible on detail card + - [ ] Playwright: Clicking button shows loading state then "Переведено" + - [ ] Playwright: Translated text appears in card + - [ ] Playwright: Works without authentication + + **QA Scenarios**: + ``` + Scenario: Translate monster card + Tool: Playwright + Preconditions: Open5e detail card open for "Goblin" + Steps: + 1. Assert: "Перевести" button visible + 2. Click button + 3. Assert: Button text changes to "Перевод..." + 4. Wait 3-5 seconds + 5. Assert: Button text changes to "Переведено" + 6. Assert: Description text is in Russian + Expected Result: Monster card translated to Russian + Evidence: .sisyphus/evidence/task-23-translate-button.png + + Scenario: Translation cache works + Tool: Playwright + Preconditions: Goblin already translated + Steps: + 1. Close and reopen Goblin detail card + 2. Click "Перевести" + 3. Assert: Translation appears instantly (< 500ms) + Expected Result: Cached translation served quickly + Evidence: .sisyphus/evidence/task-23-translation-cache.png + ``` + + **Commit**: YES + - Message: `feat(ui): add translate button to Open5e reference cards` + - Files: `src/components/dm/Open5eReference.astro` + +- [ ] **24. Wire Translation Service to UI** + + **What to do**: + - Create `src/lib/client/translation.ts`: Client-side translation cache and API caller. + - Cache structure: Map or object keyed by `slug:type`. + - Functions: `translateEntity(slug, type)`, `getCachedTranslation(slug, type)`, `clearTranslationCache()`. + - Integrate with `Open5eReference.astro` (called by Task 23). + - Handle errors gracefully: network failure, API error, invalid response. + - Add cross-tab sync: if translation is fetched in one tab, other tabs should know (BroadcastChannel or storage event). + - Write unit tests for client cache. + + **Must NOT do**: + - Call translation API directly from component (use service layer). + - Store translations in localStorage (keep in memory only; DB is server cache). + + **Recommended Agent Profile**: + - **Category**: `quick` + - Reason: Thin client service with caching. + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (within Wave 6) + - **Parallel Group**: Wave 6 + - **Blocks**: None + - **Blocked By**: Task 12 (translation API), Task 23 (translate button) + + **References**: + - `src/pages/api/dm/translate.ts` — Translation API + - `src/lib/open5e/cache.ts` — Client caching pattern to follow + - `src/components/dm/Open5eReference.astro` — Integration point + + **Acceptance Criteria**: + - [ ] `npm test src/lib/client/translation.test.ts` → PASS + - [ ] Client cache returns hit instantly + - [ ] API call only on cache miss + - [ ] Error handling works gracefully + + **QA Scenarios**: + ``` + Scenario: Client translation cache hit + Tool: Bash (node REPL) + Preconditions: Cache warmed with goblin translation + Steps: + 1. Call getCachedTranslation('goblin', 'creature') + 2. Assert: Returns cached object instantly + 3. Assert: No network request made + Expected Result: Client cache serves without API call + Evidence: .sisyphus/evidence/task-24-client-cache.log + ``` + + **Commit**: YES + - Message: `feat(ui): wire translation service to Open5e reference` + - Files: `src/lib/client/translation.ts` + - Pre-commit: `npm test src/lib/client/translation.test.ts` + +--- + +## Final Verification Wave + +> 4 review agents run in PARALLEL. ALL must APPROVE. Present consolidated results to user and get explicit "okay" before completing. + +- [ ] F1. **Plan Compliance Audit** — `oracle` + Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan. + Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT` + +- [ ] F2. **Code Quality Review** — `unspecified-high` + Run `tsc --noEmit` + linter + `npm test`. Review all changed files for: `as any`/`@ts-ignore`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp). + Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT` + +- [ ] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` skill) + Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together, not isolation). Test edge cases: empty state, invalid input, rapid actions. Save to `.sisyphus/evidence/final-qa/`. + Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` + +- [ ] F4. **Scope Fidelity Check** — `deep` + For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes. + Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT` + +--- + +## Commit Strategy + +- **Wave 1**: `fix(auth): resolve all auth anti-patterns` — jwt.ts, oauth.ts, logout.ts, middleware/index.ts +- **Wave 2**: `feat(db): add AI and PRO tables with migration` — schema.ts, drizzle/ +- **Wave 3**: `feat(ai): add OpenRouter and Kimi API clients` — src/lib/ai/ +- **Wave 4**: `feat(api): add NPC generation, history, and translation routes` — src/pages/api/dm/ +- **Wave 5**: `feat(ui): add AI generator components and navigation` — src/components/dm/ +- **Wave 6**: `feat(pro): add notes and initiative DB persistence for PRO` — src/components/dm/, src/pages/api/dm/ +- **Wave FINAL**: `chore(qa): final verification and fixes` — various + +--- + +## Success Criteria + +### Verification Commands +```bash +# Auth fixes +npm test src/lib/auth/ # Expected: all auth tests pass + +# DB migration +npm run db:migrate # Expected: applies cleanly, zero errors + +# NPC generation API (FREE tier) +curl -X POST http://localhost:4321/api/dm/ai/generate \ + -H "Content-Type: application/json" \ + -H "Cookie: auth_token=..." \ + -d '{"race":"elf","role":"wizard","level":5,"tone":"dark"}' \ + | jq '.npc.name, .npc.hp, .npc.ac' # Expected: valid JSON with name, hp, ac fields + +# Rate limiting +curl -w "%{http_code}" http://localhost:4321/api/dm/ai/generate \ + -H "Cookie: auth_token=FREE_USER_TOKEN" # Run 8 times. Expected: 429 on 8th + +# Translation cache +curl "http://localhost:4321/api/dm/translate?slug=goblin&type=creature" \ + | jq '.translated' # Expected: Russian text + +# Type check +npm run build # Expected: zero TypeScript errors +``` + +### Final Checklist +- [ ] All "Must Have" present +- [ ] All "Must NOT Have" absent +- [ ] All tests pass +- [ ] Auth regression tests pass (login/logout flow intact) +- [ ] No AI API keys in client bundles +- [ ] Rate limiting correctly enforces tier limits +- [ ] Boosty verification cache works (5 min TTL) +- [ ] DB migrations are reversible +- [ ] Evidence files exist for all QA scenarios diff --git a/.sisyphus/run-continuation/ses_1d389e69bffedksVYoEM6rnr6t.json b/.sisyphus/run-continuation/ses_1d389e69bffedksVYoEM6rnr6t.json new file mode 100644 index 0000000..0a3871a --- /dev/null +++ b/.sisyphus/run-continuation/ses_1d389e69bffedksVYoEM6rnr6t.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1d389e69bffedksVYoEM6rnr6t", + "updatedAt": "2026-05-15T16:29:01.586Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-15T16:29:01.586Z" + } + } +} \ No newline at end of file diff --git a/.sisyphus/run-continuation/ses_1d3921469ffeQox08ZjNWWJq7u.json b/.sisyphus/run-continuation/ses_1d3921469ffeQox08ZjNWWJq7u.json new file mode 100644 index 0000000..3963511 --- /dev/null +++ b/.sisyphus/run-continuation/ses_1d3921469ffeQox08ZjNWWJq7u.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_1d3921469ffeQox08ZjNWWJq7u", + "updatedAt": "2026-05-15T16:20:30.027Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-05-15T16:20:30.027Z" + } + } +} \ No newline at end of file diff --git a/src/db/__mocks__/client.ts b/src/db/__mocks__/client.ts new file mode 100644 index 0000000..d4f7c16 --- /dev/null +++ b/src/db/__mocks__/client.ts @@ -0,0 +1,13 @@ +import { vi } from 'vitest'; + +export const db = { + insert: vi.fn(() => ({ values: vi.fn(() => Promise.resolve()) })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + })), + })), + delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) })), +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index c0a2ab7..4ce22e8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,5 @@ -import { pgTable, serial, varchar, timestamp, integer } from 'drizzle-orm/pg-core'; +import { pgTable, serial, varchar, timestamp, integer, text, jsonb, index, uniqueIndex, check } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; export const users = pgTable('users', { id: serial('id').primaryKey(), @@ -7,8 +8,12 @@ export const users = pgTable('users', { email: varchar('email', { length: 255 }), name: varchar('name', { length: 255 }).notNull(), avatar: varchar('avatar', { length: 500 }), + tier: varchar('tier', { length: 20 }).default('free'), + boostyVerifiedAt: timestamp('boosty_verified_at'), createdAt: timestamp('created_at').defaultNow().notNull(), -}); +}, (table) => [ + check('users_tier_check', sql`${table.tier} IN ('free', 'pro')`), +]); export const sessions = pgTable('sessions', { id: serial('id').primaryKey(), @@ -16,3 +21,55 @@ export const sessions = pgTable('sessions', { token: varchar('token', { length: 500 }).notNull(), expiresAt: timestamp('expires_at').notNull(), }); + +export const npcs = pgTable('npcs', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + name: varchar('name', { length: 255 }).notNull(), + race: varchar('race', { length: 100 }), + role: varchar('role', { length: 100 }), + level: integer('level'), + tone: varchar('tone', { length: 100 }), + content: jsonb('content'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const generationCounters = pgTable('generation_counters', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + hourWindow: timestamp('hour_window').notNull(), + count: integer('count').notNull(), + model: varchar('model', { length: 50 }).notNull(), +}, (table) => [ + uniqueIndex('generation_counters_user_window_model_idx').on(table.userId, table.hourWindow, table.model), +]); + +export const translations = pgTable('translations', { + id: serial('id').primaryKey(), + slug: varchar('slug', { length: 255 }).notNull().unique(), + type: varchar('type', { length: 100 }).notNull(), + language: varchar('language', { length: 10 }).default('ru').notNull(), + content: jsonb('content'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => [ + index('translations_slug_type_language_idx').on(table.slug, table.type, table.language), +]); + +export const notes = pgTable('notes', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + title: varchar('title', { length: 255 }).notNull(), + content: text('content'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const initiativeSessions = pgTable('initiative_sessions', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + name: varchar('name', { length: 255 }).notNull(), + participants: jsonb('participants'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); diff --git a/src/lib/ai/kimi.test.ts b/src/lib/ai/kimi.test.ts new file mode 100644 index 0000000..edb8c20 --- /dev/null +++ b/src/lib/ai/kimi.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateNPC, KimiClientError } from "./kimi"; +import type { NPCParams, Open5eMonster } from "./types"; + +const validNPCResponse = { + name: "Tharok Stonehelm", + race: "dwarf", + role: "fighter", + level: 3, + hp: 45, + ac: 18, + cr: "2", + speed: "25 ft.", + appearance: "A stout dwarf with a braided red beard and burn scars across his forearms.", + trait: "Never breaks a promise, no matter the cost.", + motivation: "Seeking to reclaim his ancestral forge from a fire giant.", + secret: "He forged the weapon that killed his own brother by accident.", + history: "Once a royal smith, exiled after a catastrophic forging accident.", +}; + +function mockFetchResponse(body: unknown, status = 200) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: status === 429 ? "Too Many Requests" : "Error", + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body), + } as Response); +} + +function mockFetchTextResponse(text: string, status = 200) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: "OK", + text: () => Promise.resolve(text), + json: () => Promise.resolve(text).then((t) => JSON.parse(t)), + } as Response); +} + +describe("generateNPC", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = { ...originalEnv, KIMI_API_KEY: "test-api-key" }; + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it("generates an NPC successfully", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchResponse({ + choices: [ + { + message: { + content: JSON.stringify(validNPCResponse), + }, + }, + ], + }) + ); + + const params: NPCParams = { + race: "dwarf", + role: "fighter", + level: 3, + tone: "heroic", + }; + + const result = await generateNPC(params); + + expect(result).toEqual(validNPCResponse); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + + const callArgs = vi.mocked(globalThis.fetch).mock.calls[0]; + const requestInit = callArgs[1] as RequestInit; + const body = JSON.parse(requestInit.body as string); + + expect(body.model).toBe("moonshot-v1-8k"); + expect(body.messages[0].role).toBe("system"); + expect(body.messages[1].role).toBe("user"); + expect(body.response_format.type).toBe("json_schema"); + expect(body.temperature).toBe(0.7); + expect(requestInit.headers).toMatchObject({ + "Content-Type": "application/json", + Authorization: "Bearer test-api-key", + }); + }); + + it("includes Open5e reference in the prompt when provided", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchResponse({ + choices: [ + { + message: { + content: JSON.stringify(validNPCResponse), + }, + }, + ], + }) + ); + + const reference: Open5eMonster = { + name: "Dwarf Warrior", + size: "Medium", + type: "humanoid", + armor_class: 16, + hit_points: 30, + challenge_rating_decimal: 1, + speed: { walk: "25 ft." }, + actions: [{ name: "War Pick", desc: "Melee Weapon Attack: +4 to hit" }], + }; + + const params: NPCParams = { + race: "dwarf", + role: "fighter", + level: 3, + tone: "heroic", + }; + + await generateNPC(params, reference); + + const callArgs = vi.mocked(globalThis.fetch).mock.calls[0]; + const body = JSON.parse((callArgs[1] as RequestInit).body as string); + const userContent = body.messages[1].content as string; + + expect(userContent).toContain("Dwarf Warrior"); + expect(userContent).toContain("AC: 16"); + expect(userContent).toContain("HP: 30"); + expect(userContent).toContain("War Pick"); + }); + + it("throws KimiClientError when KIMI_API_KEY is missing", async () => { + delete process.env.KIMI_API_KEY; + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow(KimiClientError); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow("KIMI_API_KEY is not configured"); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("throws KimiClientError on API error (500)", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchResponse({ error: { message: "Internal server error" } }, 500) + ); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow(KimiClientError); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow("Kimi API error: 500 Error"); + }); + + it("throws KimiClientError on rate limit (429)", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchResponse({ error: { message: "Rate limit exceeded" } }, 429) + ); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow(KimiClientError); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow("Kimi API rate limit exceeded"); + }); + + it("throws KimiClientError when API returns invalid JSON", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchTextResponse("This is not json") + ); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow("Kimi API returned invalid JSON"); + }); + + it("throws KimiClientError when response fails zod validation", async () => { + const invalidResponse = { ...validNPCResponse, name: 123 }; + + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchResponse({ + choices: [ + { + message: { + content: JSON.stringify(invalidResponse), + }, + }, + ], + }) + ); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow(KimiClientError); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow("validation failed"); + }); + + it("throws KimiClientError when API returns empty content", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchResponse({ + choices: [ + { + message: { + content: null, + }, + }, + ], + }) + ); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow("Kimi API returned empty content"); + }); + + it("throws KimiClientError on request timeout", async () => { + vi.mocked(globalThis.fetch).mockImplementation( + () => + new Promise((_, reject) => { + setTimeout(() => reject(new Error("AbortError")), 50); + }) + ); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow(KimiClientError); + }); + + it("throws KimiClientError when API returns error in response body", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchResponse({ + error: { message: "Invalid API key" }, + }) + ); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow("Invalid API key"); + }); + + it("validates params with zod before making request", async () => { + await expect( + generateNPC({ + race: "elf", + role: "wizard", + level: 25, + tone: "dark", + } as NPCParams) + ).rejects.toThrow(); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("handles markdown-wrapped JSON gracefully", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchTextResponse( + '```json\n' + JSON.stringify(validNPCResponse) + '\n```' + ) + ); + + await expect( + generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }) + ).rejects.toThrow("Kimi API returned invalid JSON"); + }); + + it("preserves statusCode on API errors", async () => { + vi.mocked(globalThis.fetch).mockReturnValue( + mockFetchResponse({ error: { message: "Bad request" } }, 400) + ); + + try { + await generateNPC({ race: "elf", role: "wizard", level: 5, tone: "dark" }); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(KimiClientError); + expect((error as KimiClientError).statusCode).toBe(400); + } + }); +}); diff --git a/src/lib/ai/kimi.ts b/src/lib/ai/kimi.ts new file mode 100644 index 0000000..24b5bc9 --- /dev/null +++ b/src/lib/ai/kimi.ts @@ -0,0 +1,244 @@ +import { z } from "zod"; +import { + npcParamsSchema, + npcResultSchema, + type NPCParams, + type NPCResult, + type Open5eMonster, +} from "./types"; + +const KIMI_API_URL = "https://api.moonshot.ai/v1/chat/completions"; +const KIMI_MODEL = "moonshot-v1-8k"; +const REQUEST_TIMEOUT_MS = 10_000; + +const npcJsonSchema = { + type: "object" as const, + properties: { + name: { type: "string" }, + race: { type: "string" }, + role: { type: "string" }, + level: { type: "integer" }, + hp: { type: "integer" }, + ac: { type: "integer" }, + cr: { type: "string" }, + speed: { type: "string" }, + appearance: { type: "string" }, + trait: { type: "string" }, + motivation: { type: "string" }, + secret: { type: "string" }, + history: { type: "string" }, + }, + required: [ + "name", + "race", + "role", + "level", + "hp", + "ac", + "cr", + "speed", + "appearance", + "trait", + "motivation", + "secret", + "history", + ], +}; + +function buildSystemPrompt(): string { + return [ + "You are a Dungeons & Dragons NPC generator.", + "You MUST respond with valid JSON only. Do NOT wrap the response in markdown code blocks.", + "Do NOT include any explanatory text outside the JSON object.", + "The JSON must exactly match the provided schema.", + ].join(" "); +} + +function formatMonsterReference(monster: Open5eMonster): string { + const parts: string[] = [`Open5e Reference Monster: ${monster.name}`]; + if (monster.size) parts.push(`Size: ${monster.size}`); + if (monster.type) parts.push(`Type: ${monster.type}`); + if (monster.alignment) parts.push(`Alignment: ${monster.alignment}`); + if (monster.armor_class != null) parts.push(`AC: ${monster.armor_class}`); + if (monster.hit_points != null) parts.push(`HP: ${monster.hit_points}`); + if (monster.challenge_rating_decimal != null) + parts.push(`CR: ${monster.challenge_rating_decimal}`); + if (monster.speed) { + const speedText = + typeof monster.speed === "string" + ? monster.speed + : Object.entries(monster.speed) + .map(([k, v]) => `${k} ${v}`) + .join(", "); + parts.push(`Speed: ${speedText}`); + } + if (monster.languages) parts.push(`Languages: ${monster.languages}`); + if (monster.senses) parts.push(`Senses: ${monster.senses}`); + if (monster.actions?.length) { + parts.push( + `Actions: ${monster.actions.map((a) => `${a.name} (${a.desc})`).join("; ")}` + ); + } + if (monster.special_abilities?.length) { + parts.push( + `Special Abilities: ${monster.special_abilities.map((a) => `${a.name} (${a.desc})`).join("; ")}` + ); + } + return parts.join("\n"); +} + +function buildUserPrompt(params: NPCParams, reference?: Open5eMonster): string { + const lines: string[] = [ + `Generate a D&D NPC with the following parameters:`, + `- Race: ${params.race}`, + `- Role/Class: ${params.role}`, + `- Level: ${params.level}`, + `- Tone: ${params.tone}`, + ]; + + if (reference) { + lines.push(""); + lines.push("Use the following Open5e monster as a mechanical reference for balancing stats:"); + lines.push(formatMonsterReference(reference)); + } + + lines.push(""); + lines.push("Respond with a single JSON object matching this schema:"); + lines.push(JSON.stringify(npcJsonSchema, null, 2)); + + return lines.join("\n"); +} + +export class KimiClientError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly responseBody?: string + ) { + super(message); + this.name = "KimiClientError"; + } +} + +export async function generateNPC( + params: NPCParams, + reference?: Open5eMonster +): Promise { + npcParamsSchema.parse(params); + + const apiKey = process.env.KIMI_API_KEY; + if (!apiKey) { + throw new KimiClientError("KIMI_API_KEY is not configured"); + } + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(KIMI_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: KIMI_MODEL, + messages: [ + { role: "system", content: buildSystemPrompt() }, + { role: "user", content: buildUserPrompt(params, reference) }, + ], + response_format: { + type: "json_schema", + json_schema: { + name: "npc_result", + strict: true, + schema: npcJsonSchema, + }, + }, + temperature: 0.7, + }), + signal: abortController.signal, + }); + + if (!response.ok) { + const body = await response.text().catch(() => undefined); + if (response.status === 429) { + throw new KimiClientError( + "Kimi API rate limit exceeded", + response.status, + body + ); + } + throw new KimiClientError( + `Kimi API error: ${response.status} ${response.statusText}`, + response.status, + body + ); + } + + const responseText = await response.text(); + + let data: { + choices?: Array<{ + message?: { + content?: string | null; + }; + }>; + error?: { + message?: string; + }; + }; + try { + data = JSON.parse(responseText); + } catch { + throw new KimiClientError( + "Kimi API returned invalid JSON", + undefined, + responseText + ); + } + + if (data.error?.message) { + throw new KimiClientError(`Kimi API error: ${data.error.message}`); + } + + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new KimiClientError("Kimi API returned empty content"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new KimiClientError( + "Kimi API returned invalid JSON", + undefined, + content + ); + } + + const validation = npcResultSchema.safeParse(parsed); + if (!validation.success) { + throw new KimiClientError( + `Kimi API response validation failed: ${validation.error.message}`, + undefined, + content + ); + } + + return validation.data; + } catch (error) { + if (error instanceof KimiClientError) { + throw error; + } + if (error instanceof Error && error.name === "AbortError") { + throw new KimiClientError("Kimi API request timed out after 10s"); + } + throw new KimiClientError( + error instanceof Error ? error.message : "Unknown Kimi API error" + ); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/src/lib/ai/openrouter.test.ts b/src/lib/ai/openrouter.test.ts new file mode 100644 index 0000000..2e43f01 --- /dev/null +++ b/src/lib/ai/openrouter.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + generateNPC, + type NPCParams, + type NPCResult, +} from "./openrouter"; + +describe("generateNPC", () => { + const validNPCResponse: NPCResult = { + name: "Gorath the Grim", + race: "Half-Orc", + role: "Mercenary Captain", + hp: 45, + ac: 16, + cr: "2", + speed: "30 ft.", + appearance: "Scarred face, heavy armor, carries a battleaxe.", + trait: "Suspicious of everyone but fiercely loyal to allies.", + motivation: "Amassing gold to buy land and retire.", + secret: "He betrayed his previous company for a bag of gold.", + history: "Born in the slums, fought his way up through underground pits.", + }; + + beforeEach(() => { + vi.stubEnv("OPENROUTER_API_KEY", "test-api-key"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("returns parsed NPC on success", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + choices: [ + { + message: { + content: JSON.stringify(validNPCResponse), + }, + }, + ], + }), + }) + ) as unknown as typeof fetch; + + const params: NPCParams = { theme: "dark fantasy", role: "villain" }; + const result = await generateNPC(params); + + expect(result).toEqual(validNPCResponse); + expect(global.fetch).toHaveBeenCalledTimes(1); + const callArgs = (global.fetch as ReturnType).mock + .calls[0]; + expect(callArgs[0]).toBe( + "https://openrouter.ai/api/v1/chat/completions" + ); + expect(callArgs[1].method).toBe("POST"); + expect(callArgs[1].headers.Authorization).toBe( + "Bearer test-api-key" + ); + + const body = JSON.parse(callArgs[1].body); + expect(body.model).toBe("llama-3.3-70b:free"); + expect(body.messages[0].content).toContain("valid JSON only"); + expect(body.messages[1].content).toContain("dark fantasy"); + expect(body.messages[1].content).toContain("villain"); + }); + + it("includes reference monster in prompt", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + choices: [ + { + message: { + content: JSON.stringify(validNPCResponse), + }, + }, + ], + }), + }) + ) as unknown as typeof fetch; + + const reference = { + name: "Goblin", + key: "goblin", + challenge_rating_decimal: "0.25", + type: "humanoid", + hit_points: 7, + armor_class: 15, + }; + + await generateNPC({ role: "minion" }, reference); + + const body = JSON.parse( + (global.fetch as ReturnType).mock.calls[0][1].body + ); + expect(body.messages[1].content).toContain("Goblin"); + expect(body.messages[1].content).toContain("HP: 7"); + expect(body.messages[1].content).toContain("AC: 15"); + expect(body.messages[1].content).toContain("CR: 0.25"); + }); + + it("throws on missing API key", async () => { + vi.unstubAllEnvs(); + vi.stubEnv("OPENROUTER_API_KEY", ""); + + await expect(generateNPC({})).rejects.toThrow( + "OPENROUTER_API_KEY is not set" + ); + }); + + it("throws on rate limit (429)", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 429, + statusText: "Too Many Requests", + text: () => Promise.resolve("Rate limited"), + }) + ) as unknown as typeof fetch; + + await expect(generateNPC({})).rejects.toThrow( + "Rate limited by OpenRouter (429)" + ); + }); + + it("throws on API error", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: () => Promise.resolve("Server error"), + }) + ) as unknown as typeof fetch; + + await expect(generateNPC({})).rejects.toThrow( + "OpenRouter API error 500" + ); + }); + + it("throws on invalid JSON response", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + choices: [ + { + message: { + content: "This is not JSON", + }, + }, + ], + }), + }) + ) as unknown as typeof fetch; + + await expect(generateNPC({})).rejects.toThrow( + "Invalid JSON in OpenRouter response" + ); + }); + + it("throws on schema validation failure", async () => { + const invalidNPC = { name: "Only Name", race: "Human" }; + + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + choices: [ + { + message: { + content: JSON.stringify(invalidNPC), + }, + }, + ], + }), + }) + ) as unknown as typeof fetch; + + await expect(generateNPC({})).rejects.toThrow( + "NPC schema validation failed" + ); + }); + + it("throws on timeout", async () => { + vi.useFakeTimers(); + + global.fetch = vi.fn((_url, options) => { + return new Promise((_resolve, reject) => { + const onAbort = () => { + const err = new Error("The operation was aborted"); + err.name = "AbortError"; + reject(err); + }; + if (options.signal) { + if (options.signal.aborted) { + onAbort(); + } else { + options.signal.addEventListener("abort", onAbort, { once: true }); + } + } + }); + }) as unknown as typeof fetch; + + const promise = generateNPC({}); + vi.advanceTimersByTime(11_000); + + await expect(promise).rejects.toThrow( + "OpenRouter request timed out after 10s" + ); + vi.useRealTimers(); + }); + + it("allows custom model override", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + choices: [ + { + message: { + content: JSON.stringify(validNPCResponse), + }, + }, + ], + }), + }) + ) as unknown as typeof fetch; + + await generateNPC({}, undefined, "custom-model:free"); + + const body = JSON.parse( + (global.fetch as ReturnType).mock.calls[0][1].body + ); + expect(body.model).toBe("custom-model:free"); + }); +}); diff --git a/src/lib/ai/openrouter.ts b/src/lib/ai/openrouter.ts new file mode 100644 index 0000000..1abde3b --- /dev/null +++ b/src/lib/ai/openrouter.ts @@ -0,0 +1,162 @@ +import { z } from "zod"; +import type { Monster } from "@/lib/open5e/client"; + +export type Open5eMonster = Monster; + +export interface NPCParams { + theme?: string; + setting?: string; + role?: string; + level?: number; + race?: string; +} + +export interface NPCResult { + name: string; + race: string; + role: string; + hp: number; + ac: number; + cr: string; + speed: string; + appearance: string; + trait: string; + motivation: string; + secret: string; + history: string; +} + +const npcResultSchema = z.object({ + name: z.string(), + race: z.string(), + role: z.string(), + hp: z.number(), + ac: z.number(), + cr: z.string(), + speed: z.string(), + appearance: z.string(), + trait: z.string(), + motivation: z.string(), + secret: z.string(), + history: z.string(), +}); + +const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"; +const DEFAULT_MODEL = "llama-3.3-70b:free"; +const TIMEOUT_MS = 10_000; + +function buildSystemPrompt(): string { + return "You are a creative D&D NPC generator. Respond with valid JSON only, no markdown, no code fences, no explanatory text."; +} + +function buildUserPrompt(params: NPCParams, reference?: Monster): string { + const schema = JSON.stringify({ + name: "string (unique name)", + race: "string (e.g. Human, Elf, Orc)", + role: "string (e.g. Merchant, Bandit, Wizard)", + hp: "number (hit points)", + ac: "number (armor class)", + cr: "string (challenge rating, e.g. '1/4', '5')", + speed: "string (e.g. '30 ft.')", + appearance: "string (2-3 sentences)", + trait: "string (personality trait)", + motivation: "string (what drives them)", + secret: "string (a hidden secret)", + history: "string (1-2 sentence backstory)", + }); + + let prompt = `Generate a D&D NPC with the following parameters:\n`; + if (params.theme) prompt += `- Theme: ${params.theme}\n`; + if (params.setting) prompt += `- Setting: ${params.setting}\n`; + if (params.role) prompt += `- Role: ${params.role}\n`; + if (params.level) prompt += `- Level/CR range: around ${params.level}\n`; + if (params.race) prompt += `- Race: ${params.race}\n`; + + if (reference) { + prompt += `\nUse this reference monster stat block for balance:\n`; + prompt += `- Name: ${reference.name}\n`; + prompt += `- Type: ${reference.type}\n`; + prompt += `- HP: ${reference.hit_points}\n`; + prompt += `- AC: ${reference.armor_class}\n`; + prompt += `- CR: ${reference.challenge_rating_decimal}\n`; + } + + prompt += `\nRespond with valid JSON matching this schema:\n${schema}`; + return prompt; +} + +export async function generateNPC( + params: NPCParams, + reference?: Monster, + model: string = DEFAULT_MODEL +): Promise { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY is not set"); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const response = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": process.env.PUBLIC_APP_URL || "https://randify.pro", + "X-Title": "Randify.pro DM Dashboard", + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: buildSystemPrompt() }, + { role: "user", content: buildUserPrompt(params, reference) }, + ], + temperature: 0.8, + }), + signal: controller.signal, + }); + + if (response.status === 429) { + throw new Error("Rate limited by OpenRouter (429)"); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenRouter API error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error("Empty response from OpenRouter"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error(`Invalid JSON in OpenRouter response: ${content}`); + } + + const validated = npcResultSchema.safeParse(parsed); + if (!validated.success) { + throw new Error( + `NPC schema validation failed: ${validated.error.message}` + ); + } + + return validated.data; + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error("OpenRouter request timed out after 10s"); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts new file mode 100644 index 0000000..becddbb --- /dev/null +++ b/src/lib/ai/types.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const npcParamsSchema = z.object({ + race: z.string(), + role: z.string(), + level: z.number().int().min(1).max(20), + tone: z.string(), +}); + +export type NPCParams = z.infer; + +export const npcResultSchema = z.object({ + name: z.string(), + race: z.string(), + role: z.string(), + level: z.number().int(), + hp: z.number().int(), + ac: z.number().int(), + cr: z.string(), + speed: z.string(), + appearance: z.string(), + trait: z.string(), + motivation: z.string(), + secret: z.string(), + history: z.string(), +}); + +export type NPCResult = z.infer; + +export interface Open5eMonster { + name: string; + size?: string; + type?: string; + subtype?: string; + alignment?: string; + armor_class?: number; + hit_points?: number; + speed?: Record | string; + strength?: number; + dexterity?: number; + constitution?: number; + intelligence?: number; + wisdom?: number; + charisma?: number; + challenge_rating_decimal?: number; + languages?: string; + senses?: string; + damage_immunities?: string; + damage_resistances?: string; + damage_vulnerabilities?: string; + condition_immunities?: string; + actions?: Array<{ name: string; desc: string }>; + special_abilities?: Array<{ name: string; desc: string }>; + legendary_actions?: Array<{ name: string; desc: string }>; +} diff --git a/src/lib/auth/auth.test.ts b/src/lib/auth/auth.test.ts new file mode 100644 index 0000000..245e471 --- /dev/null +++ b/src/lib/auth/auth.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const baseEnv = { + JWT_SECRET: 'test-secret-that-is-long-enough-for-hs256-algorithm', + VK_CLIENT_ID: 'vk-test-id', + VK_CLIENT_SECRET: 'vk-test-secret', + YANDEX_CLIENT_ID: 'ya-test-id', + YANDEX_CLIENT_SECRET: 'ya-test-secret', + PUBLIC_APP_URL: 'https://test.example.com', +}; + +describe('Auth Environment Validation', () => { + beforeEach(() => { + vi.resetModules(); + Object.assign(process.env, baseEnv); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should validate required env vars and expose authEnv', async () => { + const { authEnv, requireEnv } = await import('./env'); + expect(authEnv.JWT_SECRET).toBe(baseEnv.JWT_SECRET); + expect(authEnv.VK_CLIENT_ID).toBe(baseEnv.VK_CLIENT_ID); + expect(authEnv.PUBLIC_APP_URL).toBe(baseEnv.PUBLIC_APP_URL); + expect(requireEnv('PUBLIC_APP_URL')).toBe(baseEnv.PUBLIC_APP_URL); + }); + + it('should throw at module load if JWT_SECRET is missing', async () => { + delete process.env.JWT_SECRET; + await expect(import('./env')).rejects.toThrow('JWT_SECRET'); + }); + + it('should throw at module load if PUBLIC_APP_URL is invalid', async () => { + process.env.PUBLIC_APP_URL = 'not-a-url'; + await expect(import('./env')).rejects.toThrow('PUBLIC_APP_URL'); + }); + + it('should throw at module load if VK_CLIENT_ID is missing', async () => { + delete process.env.VK_CLIENT_ID; + await expect(import('./env')).rejects.toThrow('VK_CLIENT_ID'); + }); +}); + +describe('JWT Token Operations', () => { + beforeEach(() => { + vi.resetModules(); + Object.assign(process.env, baseEnv); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create and verify a token', async () => { + const { createToken, verifyToken } = await import('./jwt'); + const token = await createToken('user123'); + const payload = await verifyToken(token); + expect(payload.sub).toBe('user123'); + }); + + it('should reject an invalid token', async () => { + const { verifyToken } = await import('./jwt'); + await expect(verifyToken('invalid.token.here')).rejects.toThrow(); + }); + + it('should set auth cookie with sameSite=lax', async () => { + const { setAuthCookie } = await import('./jwt'); + const setMock = vi.fn(); + const mockContext = { cookies: { set: setMock } } as unknown as import('astro').APIContext; + setAuthCookie('test-token', mockContext); + expect(setMock).toHaveBeenCalledWith('auth_token', 'test-token', expect.objectContaining({ + sameSite: 'lax', + httpOnly: true, + secure: true, + })); + }); + + it('should clear auth cookie', async () => { + const { clearAuthCookie } = await import('./jwt'); + const deleteMock = vi.fn(); + const mockContext = { cookies: { delete: deleteMock } } as unknown as import('astro').APIContext; + clearAuthCookie(mockContext); + expect(deleteMock).toHaveBeenCalledWith('auth_token', { path: '/' }); + }); +}); + +describe('OAuth Helpers', () => { + beforeEach(() => { + vi.resetModules(); + Object.assign(process.env, baseEnv); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should generate code verifier and challenge', async () => { + const { generateCodeVerifier, generateCodeChallenge } = await import('./oauth'); + const verifier = generateCodeVerifier(); + expect(verifier).toBeTruthy(); + expect(verifier.length).toBeGreaterThan(0); + const challenge = await generateCodeChallenge(verifier); + expect(challenge).toBeTruthy(); + expect(challenge.length).toBeGreaterThan(0); + }); + + it('should generate state', async () => { + const { generateState } = await import('./oauth'); + const state = generateState(); + expect(state).toBeTruthy(); + expect(state.length).toBeGreaterThan(0); + }); + + it('should parse cookie value', async () => { + const { getCookieValue } = await import('./oauth'); + expect(getCookieValue('auth_token=abc123; other=xyz', 'auth_token')).toBe('abc123'); + expect(getCookieValue(null, 'auth_token')).toBeNull(); + expect(getCookieValue('other=xyz', 'auth_token')).toBeNull(); + }); + + it('should create and verify session token', async () => { + const { createSessionToken, verifySessionToken } = await import('./oauth'); + const token = await createSessionToken(42); + const result = await verifySessionToken(token); + expect(result).not.toBeNull(); + expect(result!.userId).toBe(42); + }); + + it('should return null for invalid session token', async () => { + const { verifySessionToken } = await import('./oauth'); + const result = await verifySessionToken('totally.invalid.token'); + expect(result).toBeNull(); + }); + + it('should use validated env values without empty fallbacks', async () => { + const { vkOAuthConfig, yandexOAuthConfig } = await import('./oauth'); + expect(vkOAuthConfig.clientId).toBe(baseEnv.VK_CLIENT_ID); + expect(vkOAuthConfig.clientSecret).toBe(baseEnv.VK_CLIENT_SECRET); + expect(yandexOAuthConfig.clientId).toBe(baseEnv.YANDEX_CLIENT_ID); + expect(yandexOAuthConfig.clientSecret).toBe(baseEnv.YANDEX_CLIENT_SECRET); + expect(vkOAuthConfig.clientId).not.toBe(''); + expect(yandexOAuthConfig.clientId).not.toBe(''); + }); +}); + +describe('Logout Route', () => { + beforeEach(() => { + vi.resetModules(); + Object.assign(process.env, baseEnv); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should redirect on logout with token present', async () => { + const { GET } = await import('@/pages/api/auth/logout'); + const request = new Request('http://test', { + headers: { cookie: 'auth_token=abc123' }, + }); + const response = await GET({ request } as unknown as import('astro').APIContext); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/dm/'); + }); + + it('should redirect on logout without token', async () => { + const { GET } = await import('@/pages/api/auth/logout'); + const request = new Request('http://test'); + const response = await GET({ request } as unknown as import('astro').APIContext); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/dm/'); + }); +}); + +describe('Middleware', () => { + beforeEach(() => { + vi.resetModules(); + Object.assign(process.env, baseEnv); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should clear stale auth_token cookie when token is invalid', async () => { + const { onRequest } = await import('@/middleware/index'); + const deleteMock = vi.fn(); + const nextMock = vi.fn(() => Promise.resolve(new Response('ok'))); + + const mockContext = { + locals: { user: null }, + request: { headers: { get: () => 'auth_token=badtoken' } }, + cookies: { + get: (name: string) => name === 'auth_token' ? { value: 'badtoken' } : undefined, + delete: deleteMock, + }, + } as unknown as Parameters[0]; + + await onRequest(mockContext, nextMock); + expect(deleteMock).toHaveBeenCalledWith('auth_token', { path: '/' }); + }); + + it('should clear auth_token cookie when session is missing in DB', async () => { + const { createToken } = await import('./jwt'); + const { onRequest } = await import('@/middleware/index'); + + const token = await createToken('123'); + const deleteMock = vi.fn(); + const nextMock = vi.fn(() => Promise.resolve(new Response('ok'))); + + const mockContext = { + locals: { user: null }, + request: { headers: { get: () => `auth_token=${token}` } }, + cookies: { + get: (name: string) => name === 'auth_token' ? { value: token } : undefined, + delete: deleteMock, + }, + } as unknown as Parameters[0]; + + await onRequest(mockContext, nextMock); + expect(deleteMock).toHaveBeenCalledWith('auth_token', { path: '/' }); + }); +}); diff --git a/src/lib/auth/env.ts b/src/lib/auth/env.ts new file mode 100644 index 0000000..4701605 --- /dev/null +++ b/src/lib/auth/env.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), + VK_CLIENT_ID: z.string().min(1, 'VK_CLIENT_ID is required'), + VK_CLIENT_SECRET: z.string().min(1, 'VK_CLIENT_SECRET is required'), + YANDEX_CLIENT_ID: z.string().min(1, 'YANDEX_CLIENT_ID is required'), + YANDEX_CLIENT_SECRET: z.string().min(1, 'YANDEX_CLIENT_SECRET is required'), + PUBLIC_APP_URL: z.string().url('PUBLIC_APP_URL must be a valid URL'), +}); + +function validateAuthEnv() { + const raw = { + JWT_SECRET: process.env.JWT_SECRET, + VK_CLIENT_ID: process.env.VK_CLIENT_ID, + VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET, + YANDEX_CLIENT_ID: process.env.YANDEX_CLIENT_ID, + YANDEX_CLIENT_SECRET: process.env.YANDEX_CLIENT_SECRET, + PUBLIC_APP_URL: process.env.PUBLIC_APP_URL, + }; + return envSchema.parse(raw); +} + +export const authEnv = validateAuthEnv(); + +export function requireEnv(name: keyof typeof envSchema.shape): string { + return authEnv[name]; +} diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts index 80fb9a0..6da087c 100644 --- a/src/lib/auth/jwt.ts +++ b/src/lib/auth/jwt.ts @@ -1,7 +1,8 @@ import { SignJWT, jwtVerify } from 'jose'; import type { APIContext } from 'astro'; +import { authEnv } from './env'; -const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!); +const SECRET = new TextEncoder().encode(authEnv.JWT_SECRET); export async function createToken(userId: string): Promise { return new SignJWT({ sub: userId }) @@ -25,7 +26,7 @@ export function setAuthCookie(token: string, context: APIContext): void { context.cookies.set('auth_token', token, { httpOnly: true, secure: true, - sameSite: 'strict', + sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, path: '/', }); diff --git a/src/lib/auth/oauth.ts b/src/lib/auth/oauth.ts index dfbfa77..5f4dc03 100644 --- a/src/lib/auth/oauth.ts +++ b/src/lib/auth/oauth.ts @@ -1,6 +1,7 @@ import { SignJWT, jwtVerify } from 'jose'; +import { authEnv } from './env'; -const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || ''); +const JWT_SECRET = new TextEncoder().encode(authEnv.JWT_SECRET); export function generateCodeVerifier(): string { const array = new Uint8Array(64); @@ -30,16 +31,16 @@ export function generateState(): string { } export const vkOAuthConfig = { - clientId: process.env.VK_CLIENT_ID || '', - clientSecret: process.env.VK_CLIENT_SECRET || '', + clientId: authEnv.VK_CLIENT_ID, + clientSecret: authEnv.VK_CLIENT_SECRET, authUrl: 'https://id.vk.ru/authorize', tokenUrl: 'https://id.vk.ru/oauth2/auth', scope: 'email phone', }; export const yandexOAuthConfig = { - clientId: process.env.YANDEX_CLIENT_ID || '', - clientSecret: process.env.YANDEX_CLIENT_SECRET || '', + clientId: authEnv.YANDEX_CLIENT_ID, + clientSecret: authEnv.YANDEX_CLIENT_SECRET, authUrl: 'https://oauth.yandex.com/authorize', tokenUrl: 'https://oauth.yandex.com/token', scope: 'login:email login:info login:avatar', diff --git a/src/lib/open5e/client.ts b/src/lib/open5e/client.ts index bfe98b7..7badac0 100644 --- a/src/lib/open5e/client.ts +++ b/src/lib/open5e/client.ts @@ -5,6 +5,11 @@ import { getCached, setCached } from "./cache"; const API_BASE = "https://api.open5e.com/v2/"; +export interface MonsterAction { + name: string; + desc?: string; +} + export interface Monster { name: string; key: string; @@ -12,6 +17,30 @@ export interface Monster { type: string; hit_points: number; armor_class: number; + speed?: Record; + actions?: MonsterAction[]; + special_abilities?: MonsterAction[]; + legendary_actions?: MonsterAction[]; + senses?: Record; + languages?: string; + strength?: number; + dexterity?: number; + constitution?: number; + intelligence?: number; + wisdom?: number; + charisma?: number; + size?: string; + subtype?: string; + alignment?: string; + damage_immunities?: string[]; + damage_resistances?: string[]; + damage_vulnerabilities?: string[]; + condition_immunities?: string[]; +} + +export interface SpellClass { + name: string; + slug?: string; } export interface Spell { @@ -19,6 +48,15 @@ export interface Spell { key: string; level: number; school: string; + casting_time?: string; + range?: string; + components?: string; + duration?: string; + desc?: string[]; + higher_level?: string[]; + ritual?: boolean; + concentration?: boolean; + classes?: SpellClass[]; } interface SearchFilters { @@ -52,7 +90,7 @@ export async function searchMonsters( query: string, filters?: SearchFilters ): Promise { - const cacheKey = `open5e:monsters:${query}`; + const cacheKey = `open5e:v2:monsters:${query}`; const cached = getCached(cacheKey); if (cached) return cached; @@ -75,7 +113,7 @@ export async function searchMonsters( } export async function getMonster(key: string): Promise { - const cacheKey = `open5e:monster:${key}`; + const cacheKey = `open5e:v2:monster:${key}`; const cached = getCached(cacheKey); if (cached) return cached; @@ -96,7 +134,7 @@ export async function searchSpells( query: string, filters?: SearchFilters ): Promise { - const cacheKey = `open5e:spells:${query}`; + const cacheKey = `open5e:v2:spells:${query}`; const cached = getCached(cacheKey); if (cached) return cached; @@ -116,7 +154,7 @@ export async function searchSpells( } export async function getSpell(key: string): Promise { - const cacheKey = `open5e:spell:${key}`; + const cacheKey = `open5e:v2:spell:${key}`; const cached = getCached(cacheKey); if (cached) return cached; diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 6187356..9d9e885 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -24,6 +24,7 @@ export const onRequest = defineMiddleware(async (context, next) => { const session = await getSession(token); if (!session) { console.log('[Middleware] Session validation failed: no session in DB'); + context.cookies.delete('auth_token', { path: '/' }); return next(); } @@ -41,10 +42,11 @@ export const onRequest = defineMiddleware(async (context, next) => { console.log('[Middleware] User attached to locals:', { userId: user.id, name: user.name }); } else { console.log('[Middleware] No user found for session'); + context.cookies.delete('auth_token', { path: '/' }); } } catch { console.log('[Middleware] Session validation failed: invalid token'); - /* ignore invalid tokens */ + context.cookies.delete('auth_token', { path: '/' }); } return next(); diff --git a/src/pages/api/auth/callback/vk.ts b/src/pages/api/auth/callback/vk.ts index 2ee4cf3..6857485 100644 --- a/src/pages/api/auth/callback/vk.ts +++ b/src/pages/api/auth/callback/vk.ts @@ -9,6 +9,7 @@ import { createSessionToken, COOKIE_NAME, } from '@/lib/auth/oauth'; +import { authEnv } from '@/lib/auth/env'; export const prerender = false; @@ -47,7 +48,7 @@ export const GET: APIRoute = async ({ url, request }) => { }); } - const redirectUri = `${process.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/vk`; + const redirectUri = `${authEnv.PUBLIC_APP_URL}/api/auth/callback/vk`; const tokenBody = new URLSearchParams({ client_id: vkOAuthConfig.clientId, diff --git a/src/pages/api/auth/callback/yandex.ts b/src/pages/api/auth/callback/yandex.ts index 9f1b130..4684480 100644 --- a/src/pages/api/auth/callback/yandex.ts +++ b/src/pages/api/auth/callback/yandex.ts @@ -9,6 +9,7 @@ import { createSessionToken, COOKIE_NAME, } from '@/lib/auth/oauth'; +import { authEnv } from '@/lib/auth/env'; export const prerender = false; @@ -27,7 +28,7 @@ export const GET: APIRoute = async ({ url, request }) => { }); } - const redirectUri = `${process.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/yandex`; + const redirectUri = `${authEnv.PUBLIC_APP_URL}/api/auth/callback/yandex`; const tokenRes = await fetch(yandexOAuthConfig.tokenUrl, { method: 'POST', diff --git a/src/pages/api/auth/login/vk.ts b/src/pages/api/auth/login/vk.ts index ee1d783..28770cc 100644 --- a/src/pages/api/auth/login/vk.ts +++ b/src/pages/api/auth/login/vk.ts @@ -1,14 +1,15 @@ import type { APIRoute } from 'astro'; import { generateCodeVerifier, generateCodeChallenge, generateState, vkOAuthConfig, VERIFIER_COOKIE_NAME } from '@/lib/auth/oauth'; +import { authEnv } from '@/lib/auth/env'; export const prerender = false; -export const GET: APIRoute = async ({ url }) => { +export const GET: APIRoute = async () => { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = generateState(); - const redirectUri = `${process.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/vk`; + const redirectUri = `${authEnv.PUBLIC_APP_URL}/api/auth/callback/vk`; const params = new URLSearchParams({ client_id: vkOAuthConfig.clientId, diff --git a/src/pages/api/auth/login/yandex.ts b/src/pages/api/auth/login/yandex.ts index a032efb..21a0eb2 100644 --- a/src/pages/api/auth/login/yandex.ts +++ b/src/pages/api/auth/login/yandex.ts @@ -1,14 +1,15 @@ import type { APIRoute } from 'astro'; import { generateCodeVerifier, generateCodeChallenge, generateState, yandexOAuthConfig, VERIFIER_COOKIE_NAME } from '@/lib/auth/oauth'; +import { authEnv } from '@/lib/auth/env'; export const prerender = false; -export const GET: APIRoute = async ({ url }) => { +export const GET: APIRoute = async () => { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = generateState(); - const redirectUri = `${process.env.PUBLIC_APP_URL || url.origin}/api/auth/callback/yandex`; + const redirectUri = `${authEnv.PUBLIC_APP_URL}/api/auth/callback/yandex`; const params = new URLSearchParams({ client_id: yandexOAuthConfig.clientId, diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts index f1714b9..a916be2 100644 --- a/src/pages/api/auth/logout.ts +++ b/src/pages/api/auth/logout.ts @@ -1,9 +1,17 @@ import type { APIRoute } from 'astro'; -import { COOKIE_NAME } from '@/lib/auth/oauth'; +import { COOKIE_NAME, getCookieValue } from '@/lib/auth/oauth'; +import { deleteSession } from '@/lib/auth/session'; export const prerender = false; -export const GET: APIRoute = async () => { +export const GET: APIRoute = async ({ request }) => { + const cookieHeader = request.headers.get('cookie'); + const token = getCookieValue(cookieHeader, COOKIE_NAME); + + if (token) { + await deleteSession(token); + } + const headers = new Headers(); headers.set('Location', '/dm/'); headers.append('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/`); diff --git a/tests/mocks/astro-middleware.ts b/tests/mocks/astro-middleware.ts new file mode 100644 index 0000000..5259218 --- /dev/null +++ b/tests/mocks/astro-middleware.ts @@ -0,0 +1,3 @@ +export function defineMiddleware(fn: unknown) { + return fn; +} diff --git a/tests/open5e-client.test.ts b/tests/open5e-client.test.ts index 9b88ada..b608136 100644 --- a/tests/open5e-client.test.ts +++ b/tests/open5e-client.test.ts @@ -197,7 +197,7 @@ describe("Open5e Client", () => { }, ]; mockStorage.setItem( - "open5e:monsters:dragon", + "open5e:v2:monsters:dragon", JSON.stringify({ data: cached, timestamp: Date.now() }) ); @@ -230,7 +230,7 @@ describe("Open5e Client", () => { ) as unknown as typeof fetch; await searchMonsters("new"); - const stored = mockStorage.getItem("open5e:monsters:new"); + const stored = mockStorage.getItem("open5e:v2:monsters:new"); expect(stored).not.toBeNull(); const parsed = JSON.parse(stored!); expect(parsed.data).toEqual(mockData.results); @@ -249,7 +249,7 @@ describe("Open5e Client", () => { }, ]; mockStorage.setItem( - "open5e:monsters:dragon", + "open5e:v2:monsters:dragon", JSON.stringify({ data: cached, timestamp: Date.now() }) ); @@ -277,7 +277,7 @@ describe("Open5e Client", () => { }, ]; mockStorage.setItem( - "open5e:monsters:dragon", + "open5e:v2:monsters:dragon", JSON.stringify({ data: staleData, timestamp: Date.now() - 25 * 60 * 60 * 1000 }) ); @@ -318,10 +318,10 @@ describe("Open5e Client", () => { await searchSpells("fire"); await getSpell("fireball"); - expect(mockStorage.getItem("open5e:monsters:dragon")).not.toBeNull(); - expect(mockStorage.getItem("open5e:monster:goblin")).not.toBeNull(); - expect(mockStorage.getItem("open5e:spells:fire")).not.toBeNull(); - expect(mockStorage.getItem("open5e:spell:fireball")).not.toBeNull(); + expect(mockStorage.getItem("open5e:v2:monsters:dragon")).not.toBeNull(); + expect(mockStorage.getItem("open5e:v2:monster:goblin")).not.toBeNull(); + expect(mockStorage.getItem("open5e:v2:spells:fire")).not.toBeNull(); + expect(mockStorage.getItem("open5e:v2:spell:fireball")).not.toBeNull(); }); }); }); diff --git a/tests/open5e-types.test.ts b/tests/open5e-types.test.ts new file mode 100644 index 0000000..ad552bc --- /dev/null +++ b/tests/open5e-types.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import type { Monster, Spell } from "../src/lib/open5e/client"; + +// Type-level assertions: verify interfaces contain all required fields. +// If a listed key is missing from the interface, TypeScript will error at compile time. +type AssertKeys = K; + +type _MonsterHasAllFields = AssertKeys< + Monster, + | "name" + | "key" + | "challenge_rating_decimal" + | "type" + | "hit_points" + | "armor_class" + | "speed" + | "actions" + | "special_abilities" + | "legendary_actions" + | "senses" + | "languages" + | "strength" + | "dexterity" + | "constitution" + | "intelligence" + | "wisdom" + | "charisma" + | "size" + | "subtype" + | "alignment" + | "damage_immunities" + | "damage_resistances" + | "damage_vulnerabilities" + | "condition_immunities" +>; + +type _SpellHasAllFields = AssertKeys< + Spell, + | "name" + | "key" + | "level" + | "school" + | "casting_time" + | "range" + | "components" + | "duration" + | "desc" + | "higher_level" + | "ritual" + | "concentration" + | "classes" +>; + +// Runtime dummy test so Vitest recognizes this file. +describe("Open5e type assertions", () => { + it("Monster interface contains all required keys at compile time", () => { + expect(true).toBe(true); + }); + + it("Spell interface contains all required keys at compile time", () => { + expect(true).toBe(true); + }); +}); diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..ed0c301 --- /dev/null +++ b/tests/schema.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; +import { getTableConfig } from 'drizzle-orm/pg-core'; +import { + users, + sessions, + npcs, + generationCounters, + translations, + notes, + initiativeSessions, +} from '../src/db/schema'; + +describe('Database Schema', () => { + describe('users table', () => { + it('has all required columns', () => { + const config = getTableConfig(users); + const columnNames = config.columns.map((c) => c.name); + expect(columnNames).toEqual( + expect.arrayContaining([ + 'id', + 'vk_id', + 'yandex_id', + 'email', + 'name', + 'avatar', + 'tier', + 'boosty_verified_at', + 'created_at', + ]) + ); + }); + + it('has tier column with default free', () => { + const config = getTableConfig(users); + const tier = config.columns.find((c) => c.name === 'tier'); + expect(tier).toBeDefined(); + }); + + it('has boosty_verified_at column', () => { + const config = getTableConfig(users); + const col = config.columns.find((c) => c.name === 'boosty_verified_at'); + expect(col).toBeDefined(); + }); + + it('has tier check constraint', () => { + const config = getTableConfig(users); + const checkNames = config.checks.map((c) => c.name); + expect(checkNames).toContain('users_tier_check'); + }); + }); + + describe('sessions table', () => { + it('has required columns', () => { + const config = getTableConfig(sessions); + const columnNames = config.columns.map((c) => c.name); + expect(columnNames).toEqual( + expect.arrayContaining(['id', 'user_id', 'token', 'expires_at']) + ); + }); + }); + + describe('npcs table', () => { + it('has required columns', () => { + const config = getTableConfig(npcs); + const columnNames = config.columns.map((c) => c.name); + expect(columnNames).toEqual( + expect.arrayContaining([ + 'id', + 'user_id', + 'name', + 'race', + 'role', + 'level', + 'tone', + 'content', + 'created_at', + ]) + ); + }); + + it('has cascade foreign key to users', () => { + const config = getTableConfig(npcs); + const fk = config.foreignKeys.find((fk) => + fk.reference().columns.some((c) => c.name === 'user_id') + ); + expect(fk).toBeDefined(); + }); + }); + + describe('generationCounters table', () => { + it('has required columns', () => { + const config = getTableConfig(generationCounters); + const columnNames = config.columns.map((c) => c.name); + expect(columnNames).toEqual( + expect.arrayContaining([ + 'id', + 'user_id', + 'hour_window', + 'count', + 'model', + ]) + ); + }); + + it('has cascade foreign key to users', () => { + const config = getTableConfig(generationCounters); + const fk = config.foreignKeys.find((fk) => + fk.reference().columns.some((c) => c.name === 'user_id') + ); + expect(fk).toBeDefined(); + }); + + it('has composite unique index on userId, hourWindow, model', () => { + const config = getTableConfig(generationCounters); + const idxNames = config.indexes.map((i) => i.config.name); + expect(idxNames).toContain('generation_counters_user_window_model_idx'); + }); + }); + + describe('translations table', () => { + it('has required columns', () => { + const config = getTableConfig(translations); + const columnNames = config.columns.map((c) => c.name); + expect(columnNames).toEqual( + expect.arrayContaining([ + 'id', + 'slug', + 'type', + 'language', + 'content', + 'created_at', + 'updated_at', + ]) + ); + }); + + it('has index on slug, type, language', () => { + const config = getTableConfig(translations); + const idxNames = config.indexes.map((i) => i.config.name); + expect(idxNames).toContain('translations_slug_type_language_idx'); + }); + }); + + describe('notes table', () => { + it('has required columns', () => { + const config = getTableConfig(notes); + const columnNames = config.columns.map((c) => c.name); + expect(columnNames).toEqual( + expect.arrayContaining([ + 'id', + 'user_id', + 'title', + 'content', + 'created_at', + 'updated_at', + ]) + ); + }); + + it('has cascade foreign key to users', () => { + const config = getTableConfig(notes); + const fk = config.foreignKeys.find((fk) => + fk.reference().columns.some((c) => c.name === 'user_id') + ); + expect(fk).toBeDefined(); + }); + }); + + describe('initiativeSessions table', () => { + it('has required columns', () => { + const config = getTableConfig(initiativeSessions); + const columnNames = config.columns.map((c) => c.name); + expect(columnNames).toEqual( + expect.arrayContaining([ + 'id', + 'user_id', + 'name', + 'participants', + 'created_at', + 'updated_at', + ]) + ); + }); + + it('has cascade foreign key to users', () => { + const config = getTableConfig(initiativeSessions); + const fk = config.foreignKeys.find((fk) => + fk.reference().columns.some((c) => c.name === 'user_id') + ); + expect(fk).toBeDefined(); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 7aaf9a6..d3094fd 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,14 +1,5 @@ -import "vitest"; +import { webcrypto } from 'node:crypto'; -Object.defineProperty(globalThis, "crypto", { - value: { - getRandomValues: (arr: Uint8Array) => { - for (let i = 0; i < arr.length; i++) { - arr[i] = Math.floor(Math.random() * 256); - } - return arr; - }, - subtle: {}, - randomUUID: () => "00000000-0000-0000-0000-000000000000", - }, +Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, }); diff --git a/vitest.config.ts b/vitest.config.ts index 533049b..dab7f79 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src"), + "astro:middleware": path.resolve(import.meta.dirname, "./tests/mocks/astro-middleware.ts"), }, }, test: {