diff --git a/HANDOFF.md b/HANDOFF.md index 33aeebf..3bdd515 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,8 +4,8 @@ > If this file disagrees with the code, trust the code and fix this file. ## 1. Where we are -- **Last completed step:** 10.15 — Non-stackable items: one inventory row per unit (shared `grantItem` in `src/game/inventory.js`) -- **Next step:** Phase 10.16 — Make `GET /api/leaderboard` and `GET /api/heroes/:name` public (no login) +- **Last completed step:** 10.16 — Leaderboard made public (removed `requireAuth`); hero profile was already public +- **Next step:** Phase 10.17 — Build `public/leaderboard.html` + `public/js/leaderboard.js`: tabs Level/Gold/Kills, rank, hero name (link to profile.html?name=...), class, value; `textContent` only. Add nav links (Game, Shop, Leaderboard, Logout) to header of every logged-in page. Remove temporary link-checker exception from 10.5. - **Updated:** 2026-10-23 - **Was the last session cut off mid-task?** No @@ -14,15 +14,15 @@ |---|---| | `npm install` | succeeds | | `npm run migrate` | applies 001–007 / "nothing to do" | -| `npm test` | **264 passing, 0 failing** (97 unit + 167 integration incl. 4 non-stackable buy tests) | +| `npm test` | **273 passing, 0 failing** (97 unit + 176 integration incl. 9 public leaderboard/profile tests) | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | | `npm start` + `curl localhost:3000/` | 200 HTML landing page | +| `curl localhost:3000/api/leaderboard?type=level` | 200 JSON (no auth required) | ## 3. What exists now (files that matter) -- `src/game/inventory.js` — NEW: shared `grantItem(s, characterId, item, quantity, now)` — stackable merges, non-stackable creates one row per unit -- `src/services/huntService.js` — now imports `grantItem` from `src/game/inventory.js` (removed local copy) -- `src/services/shopService.js` — now imports `grantItem` from `src/game/inventory.js`; added `stackRow`, `addToStack`, `insertInv` stmt names; `buyItem` uses `grantItem` instead of inline logic -- `tests/integration/step-10.15.test.js` — NEW: 4 integration tests for non-stackable buy behavior +- `src/app.js` — leaderboard route no longer has `requireAuth` (public) +- `tests/integration/step-10.16.test.js` — NEW: 9 tests for public leaderboard + hero profile +- `tests/integration/leaderboard.test.js` — updated "rejects request without authentication" → "is accessible without authentication" ## 4. Half-finished / fragile things - No leaderboard UI yet (stub page exists; real UI in 10.17). @@ -33,7 +33,7 @@ - Characters start with 50g default (DB constraint) — tests must account for this baseline. ## 5. Decisions & deviations from the docs -- D-004..D-007, D-022 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock, material sell prices) +- D-004..D-007, D-014..D-021 in `docs/DECISIONS.md` - **D-014:** Mobile bottom nav uses emoji icons (⚔️ Hunt / 🏕️ Trip / 🎒 Items) instead of full text labels. - **D-015:** Desktop tabs hidden via CSS below 700px; mobile nav shown via CSS; JS `syncMobileNav()` keeps active state in sync. - **D-016:** `applyXpGain` in `src/game/leveling.js` is the single shared function for XP gain logic, used by both `huntService` and `expeditionService`. @@ -87,6 +87,9 @@ - **10.12:** Warrior maxHp at level 1 = 135 (50 + 8*10 + 1*5). - **10.14:** `deleteRowExact` needed for CHECK constraint `quantity >= 1` — when selling exhausts a row, delete by matching quantity instead of decrementing to 0. - **10.15:** When extracting shared `grantItem`, the calling service's stmtCache MUST include `stackRow`, `addToStack`, `insertInv` with the exact same SQL as huntService — otherwise `grantItem` gets `undefined` statements. +- **10.16:** Leaderboard returns `{ ok: true, data: { entries: [...], pagination: {...} } }`. ## 7. Next-step briefing -- **Next step: 10.16** — Make `GET /api/leaderboard` and `GET /api/heroes/:name` public (no login). Keep the `type` allowlist; reject bad `page` values with 400. Tests: anonymous request → 200; response contains no user id/username/password; SQL injection in `type` → 400; `page=-1` and `page=abc` → 400. +- **Next step: 10.17** — Build `public/leaderboard.html` + `public/js/leaderboard.js`: tabs Level/Gold/Kills, rank, hero name (linking to `profile.html?name=...`), class, value; `textContent` only. Add nav links (Game, Shop, Leaderboard, Logout) to the header of every logged-in page. Remove any temporary link-checker exception from 10.5. +- The leaderboard API (`GET /api/leaderboard?type=level|gold|kills&page=1`) is already public and returns `{ entries: [{rank, name, class, level, xp, kills, gold}], pagination: {page, limit, total, totalPages} }`. +- The link-checker test currently has no exceptions (10.5 stub page was already created), so adding the real leaderboard page should make it pass naturally. diff --git a/STATUS.md b/STATUS.md index e0a6073..bf501e8 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,15 +8,15 @@ ## Current Focus - **Phase:** 10 -- **Step:** 10.16 — (next) +- **Step:** 10.17 — (next) - **Blocked?** No -- **Last verified working command:** `npm test` — 264/264 passing (97 unit + 167 integration incl. 4 non-stackable buy tests), 2026-10-23 step 10.15 +- **Last verified working command:** `npm test` — 273/273 passing (97 unit + 176 integration incl. 9 public leaderboard/profile tests), 2026-10-23 step 10.16 ## Project Health | Item | State | |---|---| | App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) | -| Tests pass (`npm test`) | ✅ 260/260 (97 unit + 163 integration incl. 7 material sell price tests) | +| Tests pass (`npm test`) | ✅ 273/273 (97 unit + 176 integration incl. 9 public leaderboard/profile tests) | | Migrations run clean on empty DB | ✅ 001_users, 002_characters, 003_content (also seeds zones/monsters/items), 004_inventory, 006_logs (005 reserved for expeditions) | | Can register + login | ✅ verified by tests | | Data persists across restart | ⚠️ sessions use the real SQLite store (also in tests); a restart test is still missing | @@ -188,7 +188,9 @@ *Acceptance:* tests: buy 3 swords → 3 rows, quantity 1 each; equip one, sell one → remaining state correct; buy 5 potions → one row, quantity 5. *Verified:* extracted shared `grantItem()` to `src/game/inventory.js` (used by both `huntService` and `shopService`); added matching statement names (`stackRow`, `addToStack`, `insertInv`) to shopService stmtCache; 4 new integration tests (3 swords → 4 rows with starter, 2+2 swords → 5 rows, stackable potions merge, 2 armor → 2 rows); 264/264 tests pass. ### F. Public pages -- [ ] **10.16** Make `GET /api/leaderboard` and `GET /api/heroes/:name` public (no login), as in API.md, and keep returning only public fields. Keep the `type` allowlist; reject bad `page` values (non-integer, < 1, absurdly large) with 400. +- [x] **10.16** Make `GET /api/leaderboard` and `GET /api/heroes/:name` public (no login), as in API.md, and keep returning only public fields. Keep the `type` allowlist; reject bad `page` values (non-integer, < 1, absurdly large) with 400. + *Acceptance:* anonymous request → 200; response contains no user id/username/password; SQL injection in `type` → 400; `page=-1` and `page=abc` → 400. + *Verified:* `requireAuth` removed from leaderboard route in `src/app.js`; hero profile was already public; 9 new integration tests (3 leaderboard anonymous access, 1 no-private-fields, 3 validation: SQL injection/page=-1/page=abc, 2 hero profile anonymous access); updated existing leaderboard test "rejects request without authentication" → "is accessible without authentication" (expects 200); 273/273 tests pass. *Acceptance:* tests: anonymous request → 200; response contains no user id / username / password fields; `type=gold;DROP TABLE users` → 400; `page=-1` and `page=abc` → 400. - [ ] **10.17** Build `public/leaderboard.html` + `public/js/leaderboard.js`: tabs Level / Gold / Kills, rank, hero name (linking to `profile.html?name=...`), class, value; `textContent` only. Add nav links (Game, Shop, Leaderboard, Logout) to the header of every logged-in page. Remove any temporary link-checker exception from 10.5. *Acceptance:* link-checker test passes with no exceptions; the leaderboard page works logged-out and logged-in. @@ -237,12 +239,12 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` ## Test Summary | Suite | Passing | Failing | Last run | |---|---|---|---| -| unit | 97 | 0 | 2026-10-23 step 10.15 | -| integration | 167 | 0 | 2026-10-23 step 10.15 (4 non-stackable buy tests) | +| unit | 97 | 0 | 2026-10-23 step 10.16 | +| integration | 176 | 0 | 2026-10-23 step 10.16 (9 public leaderboard/profile tests) | ## Session Log _Format:_ `YYYY-MM-DD | step | what changed | result` -- 2026-10-23 | 10.15 | Extracted shared `grantItem()` to `src/game/inventory.js` (used by huntService + shopService); updated shopService buyItem to use grantItem instead of inline stack logic; added matching statement names to shopService stmtCache; 4 new integration tests for non-stackable buy | 264/264 tests pass +- 2026-10-23 | 10.16 | Removed `requireAuth` from `GET /api/leaderboard` in `src/app.js` (now public); hero profile was already public; updated existing "rejects request without authentication" → "is accessible without authentication" in leaderboard.test.js; 9 new integration tests (anonymous access, no-private-fields, validation: SQL injection/page=-1/page=abc, hero profile anonymous) | 273/273 tests pass - 2026-10-23 | 10.14 | Material sell prices now use item-level formula: `materialSellPrice(rarity, itemMinLevel)` in `shopService.js` replaces old `defaultRate * heroLevel / itemMinLevel`; BALANCE.md table corrected (Drake Scale 90g→112g, Wolf Pelt 5g→4g, Spider Silk 12g→20g, Imp Horn 30g→40g); fix for sell exhausting row (CHECK constraint `quantity >= 1` prevented decrement to 0 — now deletes row when sale qty equals row qty); 7 new integration tests; existing shop tests updated to item-level prices | 260/260 tests pass - 2026-10-23 | 10.8 | Added `POST /api/character/allocate` endpoint: zod `.strictObject` with optional stat fields + `.refine` requiring at least one; service in `characterService.js` validates input, uses `allocatePoints` from `leveling.js`, computes `remainingPoints = character.unspent_points - total`, updates in transaction; 12 new integration tests (6 HTTP + 6 direct service) | 214/214 tests pass - 2026-10-22 | 10.6 | Re-enabled real rate limiters in `src/routes/auth.js` (login: 100/15min, register: 100/1hr in prod; 1000 in test); moved global `/api` limiter (120/min) BEFORE auth routes in `src/app.js`; added 4 integration tests verifying limiter headers and 429 response shape | 197/197 tests pass diff --git a/src/app.js b/src/app.js index 36fc49a..3769205 100644 --- a/src/app.js +++ b/src/app.js @@ -94,8 +94,8 @@ function createApp() { app.get('/api/expeditions/active', requireAuth, expeditionRoutes.getActive); app.post('/api/expeditions/claim', requireAuth, expeditionRoutes.postClaim); - // Leaderboard route (require session) - app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard); + // Leaderboard route (public, no auth required) + app.get('/api/leaderboard', leaderboardRoutes.getLeaderboard); // Hero profile route (public, no session required) app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile); diff --git a/tests/integration/leaderboard.test.js b/tests/integration/leaderboard.test.js index 6741720..fc6dff5 100644 --- a/tests/integration/leaderboard.test.js +++ b/tests/integration/leaderboard.test.js @@ -210,12 +210,13 @@ describe('leaderboard', () => { assert.equal(body.error.code, 'VALIDATION_ERROR'); }); - it('rejects request without authentication', async () => { + it('is accessible without authentication', async () => { const res = await fetch(`${baseUrl}/api/leaderboard?type=level`); const body = await res.json(); - assert.equal(res.status, 401); - assert.equal(body.ok, false); + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.ok(Array.isArray(body.data.entries)); }); // ─── Entry shape ────────────────────────────────────────────────────── diff --git a/tests/integration/step-10.16.test.js b/tests/integration/step-10.16.test.js new file mode 100644 index 0000000..9355245 --- /dev/null +++ b/tests/integration/step-10.16.test.js @@ -0,0 +1,152 @@ +// Step 10.16: Leaderboard public + hero profile public — no login required. +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, cleanup } = require('../helpers'); + +describe('step 10.16 — leaderboard & hero profile public', () => { + let baseUrl; + + before(async () => { + baseUrl = await setup(); + }); + + after(async () => { + await teardown(); + cleanup(); + }); + + // ─── Leaderboard: anonymous access ──────────────────────────────────── + + it('anonymous GET /api/leaderboard?type=level returns 200', async () => { + const res = await fetch(`${baseUrl}/api/leaderboard?type=level`); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + assert.ok(Array.isArray(json.data.entries)); + assert.ok(json.data.pagination); + }); + + it('anonymous GET /api/leaderboard?type=gold returns 200', async () => { + const res = await fetch(`${baseUrl}/api/leaderboard?type=gold`); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + }); + + it('anonymous GET /api/leaderboard?type=kills returns 200', async () => { + const res = await fetch(`${baseUrl}/api/leaderboard?type=kills`); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + }); + + it('leaderboard response contains no user id / username / password', async () => { + const res = await fetch(`${baseUrl}/api/leaderboard?type=level`); + assert.equal(res.status, 200); + const json = await res.json(); + + for (const entry of json.data.entries) { + assert.ok(!('id' in entry), `entry should not have 'id': ${entry.name}`); + assert.ok(!('username' in entry), `entry should not have 'username': ${entry.name}`); + assert.ok(!('password' in entry), `entry should not have 'password': ${entry.name}`); + assert.ok(!('password_hash' in entry), `entry should not have 'password_hash': ${entry.name}`); + assert.ok(!('user_id' in entry), `entry should not have 'user_id': ${entry.name}`); + } + }); + + // ─── Leaderboard: validation ────────────────────────────────────────── + + it('rejects SQL injection in type with 400', async () => { + const res = await fetch( + `${baseUrl}/api/leaderboard?type=level%20OR%201=1`, + ); + const body = await res.json(); + assert.equal(res.status, 400); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'VALIDATION_ERROR'); + }); + + it('rejects page=-1 with 400', async () => { + const res = await fetch(`${baseUrl}/api/leaderboard?type=level&page=-1`); + const body = await res.json(); + assert.equal(res.status, 400); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'VALIDATION_ERROR'); + }); + + it('rejects page=abc with 400', async () => { + const res = await fetch(`${baseUrl}/api/leaderboard?type=level&page=abc`); + const body = await res.json(); + assert.equal(res.status, 400); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'VALIDATION_ERROR'); + }); + + // ─── Hero profile: anonymous access (already public, verify) ────────── + + it('anonymous GET /api/heroes/:name returns 200', async () => { + // Create a hero first (fresh DB has no heroes). + const regRes = await fetch(`${baseUrl}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'pubuser1', password: 'password123' }), + }); + assert.equal(regRes.status, 201); + + const loginRes = await fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'pubuser1', password: 'password123' }), + }); + assert.equal(loginRes.status, 200); + const cookie = loginRes.headers.get('set-cookie').split(';')[0]; + + const charRes = await fetch(`${baseUrl}/api/character`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ name: 'PublicHero16', class: 'warrior' }), + }); + assert.equal(charRes.status, 201); + + // Now access the profile anonymously. + const res = await fetch(`${baseUrl}/api/heroes/PublicHero16`); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + assert.equal(json.data.name, 'PublicHero16'); + }); + + it('hero profile response contains no password / user_id', async () => { + // Create a hero first. + const regRes = await fetch(`${baseUrl}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'pubuser2', password: 'password123' }), + }); + assert.equal(regRes.status, 201); + + const loginRes = await fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'pubuser2', password: 'password123' }), + }); + assert.equal(loginRes.status, 200); + const cookie = loginRes.headers.get('set-cookie').split(';')[0]; + + const charRes = await fetch(`${baseUrl}/api/character`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ name: 'PublicHero17', class: 'mage' }), + }); + assert.equal(charRes.status, 201); + + // Access the profile anonymously. + const res = await fetch(`${baseUrl}/api/heroes/PublicHero17`); + assert.equal(res.status, 200); + const json = await res.json(); + const data = json.data; + assert.ok(!('password' in data), 'should not have password'); + assert.ok(!('password_hash' in data), 'should not have password_hash'); + assert.ok(!('user_id' in data), 'should not have user_id'); + }); +});