step 9.3: done

This commit is contained in:
bepis
2026-10-21 10:21:11 +11:00
parent d8a1e88150
commit cf725b40a2
16 changed files with 506 additions and 64 deletions
+27 -34
View File
@@ -4,8 +4,8 @@
> If this file disagrees with the code, trust the code and fix this file. > If this file disagrees with the code, trust the code and fix this file.
## 1. Where we are ## 1. Where we are
- **Last completed step:** 9.2 (seed-dev script — creates demo account + hero) — TICKED in STATUS.md - **Last completed step:** 9.3 (error pages, shared toast system, loading states) — TICKED in STATUS.md
- **Next step:** Phase 9 — 9.3 (Global error pages / friendly UI error toasts. Loading states.) - **Next step:** Phase 9 — 9.4 (Mobile-responsive pass per UI_GUIDE)
- **Updated:** 2026-10-21 - **Updated:** 2026-10-21
- **Was the last session cut off mid-task?** No - **Was the last session cut off mid-task?** No
@@ -14,37 +14,29 @@
|---|---| |---|---|
| `npm install` | succeeds | | `npm install` | succeeds |
| `npm run migrate` | applies 001–006 / "nothing to do" | | `npm run migrate` | applies 001–006 / "nothing to do" |
| `npm test` | **172 passing, 0 failing** (85 unit + 87 integration) | | `npm test` | **175 passing, 0 failing** (85 unit + 90 integration) |
| `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear |
## 3. What exists now (files that matter) ## 3. What exists now (files that matter)
- `public/profile.html` — Public hero profile page (reads ?name=, displays stats/equipment). - `public/404.html` — Custom 404 error page (branding: "Page Not Found", Emberfall theme).
- `public/js/profile.js` — Fetches GET /api/heroes/:name, renders profile, handles 404. - `public/500.html` — Custom 500 error page (branding: "Something went wrong", retry button).
- `public/js/toast.js` — Shared toast notification system (window.showToast, 3 types: error/info/success).
- `public/game.html` — Dashboard with stats panel, tabbed action panel (Hunt/Inventory/Expedition). - `public/game.html` — Dashboard with stats panel, tabbed action panel (Hunt/Inventory/Expedition).
- `public/js/game.js` — hunt UI + inventory UI + expedition UI. - `public/js/game.js` — hunt UI + inventory UI + expedition UI (uses shared toast.js).
- `public/shop.html` — Dedicated shop page. - `public/shop.html` — Dedicated shop page.
- `public/login.html`, `public/register.html`, `public/create-hero.html` — Auth & hero creation. - `public/js/shop.js` — buy/sell handlers (uses shared toast.js).
- `public/login.html`, `public/register.html`, `public/create-hero.html` — Auth & hero creation (all include toast.js).
- `public/profile.html` — Public hero profile page.
- `public/js/profile.js` — Fetches GET /api/heroes/:name, renders profile.
- `public/js/auth.js` — Shared API wrapper + showError.
- `public/css/main.css` — All styles including spinner, toast, button loading states, error page shared CSS.
- `scripts/seed-dev.js` — Creates demo account + hero + starter gear (idempotent, skips in production). - `scripts/seed-dev.js` — Creates demo account + hero + starter gear (idempotent, skips in production).
- `src/services/heroProfileService.js` — `getHeroProfile(heroName)`. - `src/app.js` — Express app with helmet, rate limiters, catch-all 404, 500 HTML serving.
- `src/routes/heroProfile.js` — `GET /api/heroes/:name` (public). - `src/services/*` — All game services (auth, character, hunt, inventory, shop, expedition, leaderboard, profile).
- `src/services/leaderboardService.js` — `getLeaderboard(type, page, limit)`. - `src/routes/*` — All route handlers.
- `src/routes/leaderboard.js` — `GET /api/leaderboard?type=level|gold|kills`. - `src/game/*` — Pure game logic (combat, stats, energy, leveling, rewards).
- `src/services/expeditionService.js` — start, getActive, claim, listDefinitions. - `tests/integration/error-pages.test.js` — 3 tests for 404/500 page serving.
- `src/routes/expeditions.js` — 4 expedition endpoints. - `tests/integration/*.test.js` — 90 integration tests across auth, combat, hunt, inventory, shop, expeditions, profile, leaderboard, error-pages.
- `src/services/shopService.js` — getStock, buyItem, sellItem.
- `src/routes/shop.js` — GET/POST shop endpoints.
- `src/services/inventoryService.js` — listInventory, equipItem, unequipItem, useConsumable.
- `src/services/huntService.js` — hunt (one transaction).
- `src/services/characterService.js` — createCharacter, getCharacterProfile, getCharacterIdForUser.
- `src/services/authService.js` — register, verifyLogin.
- `src/game/combat.js` — resolveCombat.
- `src/game/stats.js` — calcDerived, xpToNext, checkLevelUp.
- `src/game/energy.js` — computeEnergy.
- `src/utils/time.js` — now()/setNow()/resetNow().
- `src/app.js` — Express app with helmet, rate limiters (auth + general /api), all routes.
- `src/middleware/session.js` — session store, requireAuth.
- `src/config/index.js` — env validation, frozen config.
- `tests/integration/*.test.js` — 87 integration tests across auth, combat, hunt, inventory, shop, expeditions, profile, leaderboard.
- `tests/unit/*.test.js` — 85 unit tests for game logic. - `tests/unit/*.test.js` — 85 unit tests for game logic.
## 4. Half-finished / fragile things ## 4. Half-finished / fragile things
@@ -55,7 +47,6 @@
- Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md). - Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md).
- Starter gear (GAME_DESIGN.md §Items) documented but not auto-created on character creation. - Starter gear (GAME_DESIGN.md §Items) documented but not auto-created on character creation.
- `GET /api/zones` documented in API.md but not implemented (Issue #2). - `GET /api/zones` documented in API.md but not implemented (Issue #2).
- Step 9.3 (error pages/toasts/loading states) not yet implemented.
## 5. Decisions & deviations from the docs ## 5. Decisions & deviations from the docs
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock). - D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
@@ -87,11 +78,13 @@
- Profile page is public: no auth.js guard needed; uses ?name= query param for lookup. - Profile page is public: no auth.js guard needed; uses ?name= query param for lookup.
- Rate limiters in tests: set `NODE_ENV=test` to get 1000 req/window limit; otherwise tests fail with 429. - Rate limiters in tests: set `NODE_ENV=test` to get 1000 req/window limit; otherwise tests fail with 429.
- `seed-dev.js` must check `NODE_ENV=production` before any `require()` that loads config (config throws on placeholder SECRET). - `seed-dev.js` must check `NODE_ENV=production` before any `require()` that loads config (config throws on placeholder SECRET).
- Express `sendFile` rejects `../` path segments — use `path.resolve()` + `path.join()` for absolute paths.
- `express.static` sends its own 404 responses; catch-all must be placed BEFORE static middleware.
## 7. Next-step briefing (9.3) ## 7. Next-step briefing (9.4)
- **Goal:** Add global error pages / friendly UI error toasts. Loading states. - **Goal:** Mobile-responsive pass per UI_GUIDE (< 700px: single column, tabs become bottom nav).
- **Docs:** UI_GUIDE, SECURITY (for error handling), TESTING. - **Docs:** UI_GUIDE §game.html layout (Mobile section).
- Should: add a generic 404/500 error page template in `public/`. - Current CSS has some `@media (max-width: 600px)` rules but needs comprehensive mobile review.
- Should: add a toast notification system (JS + CSS) for user-facing errors. - Should: verify all pages render correctly on mobile viewports.
- Should: add loading spinners/states for async operations (hunt, buy, etc.). - Should: tabs → bottom nav on mobile (per UI_GUIDE).
- Should NOT introduce new dependencies — use vanilla HTML/CSS/JS. - Should NOT introduce new dependencies — use vanilla HTML/CSS/JS.
+4 -3
View File
@@ -8,9 +8,9 @@
## Current Focus ## Current Focus
- **Phase:** 9 - **Phase:** 9
- **Step:** 9.3 - **Step:** 9.4
- **Blocked?** No - **Blocked?** No
- **Last verified working command:** `npm test` — 172/172 passing (85 unit + 87 integration), 2026-10-21 step 9.2 - **Last verified working command:** `npm test` — 175/175 passing (85 unit + 90 integration), 2026-10-21 step 9.3
## Project Health ## Project Health
| Item | State | | Item | State |
@@ -109,7 +109,7 @@
*Fixed:* removed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent. Added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec). *Fixed:* removed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent. Added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec).
- [x] **9.2** Add `npm run seed:dev` creating a demo account and hero. - [x] **9.2** Add `npm run seed:dev` creating a demo account and hero.
*Acceptance:* `npm run seed:dev` creates migrations + demo user + hero + starter gear; safe to re-run; skipped in production. *Acceptance:* `npm run seed:dev` creates migrations + demo user + hero + starter gear; safe to re-run; skipped in production.
- [ ] **9.3** Global error pages / friendly UI error toasts. Loading states. - [x] **9.3** Global error pages / friendly UI error toasts. Loading states.
- [ ] **9.4** Mobile-responsive pass (see UI_GUIDE). - [ ] **9.4** Mobile-responsive pass (see UI_GUIDE).
- [ ] **9.5** Final full run of "Definition of playable" from NOTES.md §7. Record results below. - [ ] **9.5** Final full run of "Definition of playable" from NOTES.md §7. Record results below.
- [ ] **9.6** Update README with real run instructions. - [ ] **9.6** Update README with real run instructions.
@@ -192,3 +192,4 @@ _Format:_ `YYYY-MM-DD | step | what changed | result`
- 2026-10-21 | 8.2 | Created `src/services/heroProfileService.js` (getHeroProfile), `src/routes/heroProfile.js` (GET /api/heroes/:name, public), integrated into app.js; 5 integration tests covering public lookup, case-insensitivity, equipped items, 404, no-auth access | 167/167 tests pass - 2026-10-21 | 8.2 | Created `src/services/heroProfileService.js` (getHeroProfile), `src/routes/heroProfile.js` (GET /api/heroes/:name, public), integrated into app.js; 5 integration tests covering public lookup, case-insensitivity, equipped items, 404, no-auth access | 167/167 tests pass
- 2026-10-21 | 9.1 | Walked SECURITY.md checklist: fixed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent; added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec); verified all 9 checklist items pass | 172/172 tests pass - 2026-10-21 | 9.1 | Walked SECURITY.md checklist: fixed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent; added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec); verified all 9 checklist items pass | 172/172 tests pass
- 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass - 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass
- 2026-10-21 | 9.3 | Added shared toast system (`public/js/toast.js`), 404/500 error pages (`public/404.html`, `public/500.html`), CSS loading spinner + button loading states, server-side catch-all 404 + 500 HTML serving; removed duplicate showToast from game.js/shop.js; 3 integration tests for error pages | 175/175 tests pass
+95
View File
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emberfall — Page Not Found</title>
<link rel="stylesheet" href="/css/main.css">
<style>
.error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 2rem;
}
.error-code {
font-size: 6rem;
font-weight: bold;
color: var(--accent, #e2822b);
line-height: 1;
margin-bottom: 0.5rem;
}
.error-title {
font-size: 1.5rem;
color: var(--text, #eadfce);
margin-bottom: 1rem;
}
.error-message {
font-size: 1rem;
color: var(--muted, #a59a8a);
margin-bottom: 2rem;
max-width: 400px;
}
.error-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.error-actions a {
display: inline-block;
padding: 0.5rem 1.25rem;
border-radius: 4px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
transition: opacity 0.2s;
}
.error-actions a:hover {
opacity: 0.85;
}
.btn-home {
background: var(--accent, #e2822b);
color: #fff;
}
.btn-back {
background: transparent;
color: var(--accent, #e2822b);
border: 1px solid var(--accent, #e2822b);
}
/* Server-only error (500) */
.error-code.server {
color: var(--danger, #c0392b);
}
.error-message.server {
color: var(--muted, #a59a8a);
}
</style>
</head>
<body>
<div class="error-page">
<div class="error-code" id="error-code">404</div>
<h1 class="error-title" id="error-title">Page Not Found</h1>
<p class="error-message" id="error-message">
The page you're looking for has gone dark. Perhaps it was never here, or has been consumed by the shadows.
</p>
<div class="error-actions">
<a href="/" class="btn-home">Return to Emberfall</a>
<a href="javascript:history.back()" class="btn-back">Go Back</a>
</div>
</div>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emberfall — Server Error</title>
<link rel="stylesheet" href="/css/main.css">
<style>
.error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 2rem;
}
.error-code {
font-size: 6rem;
font-weight: bold;
color: var(--danger, #c0392b);
line-height: 1;
margin-bottom: 0.5rem;
}
.error-title {
font-size: 1.5rem;
color: var(--text, #eadfce);
margin-bottom: 1rem;
}
.error-message {
font-size: 1rem;
color: var(--muted, #a59a8a);
margin-bottom: 2rem;
max-width: 400px;
}
.error-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.error-actions a {
display: inline-block;
padding: 0.5rem 1.25rem;
border-radius: 4px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
transition: opacity 0.2s;
}
.error-actions a:hover {
opacity: 0.85;
}
.btn-home {
background: var(--accent, #e2822b);
color: #fff;
}
.btn-retry {
background: transparent;
color: var(--accent, #e2822b);
border: 1px solid var(--accent, #e2822b);
cursor: pointer;
}
.btn-retry:hover {
background: var(--accent, #e2822b);
color: #fff;
}
</style>
</head>
<body>
<div class="error-page">
<div class="error-code">500</div>
<h1 class="error-title">Something went wrong</h1>
<p class="error-message">
The flames of Emberfall have flickered. Our servers are working to restore the light. Please try again.
</p>
<div class="error-actions">
<a href="/" class="btn-home">Return to Emberfall</a>
<button class="btn-retry" onclick="window.location.reload()">Retry</button>
</div>
</div>
</body>
</html>
+1
View File
@@ -26,6 +26,7 @@
<button type="submit">Begin Your Journey</button> <button type="submit">Begin Your Journey</button>
</form> </form>
</div> </div>
<script src="/js/toast.js"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
<script src="/js/create-hero.js"></script> <script src="/js/create-hero.js"></script>
</body> </body>
+165
View File
@@ -794,3 +794,168 @@ button:hover { background: #c73650; }
font-style: italic; font-style: italic;
font-size: 0.8rem; font-size: 0.8rem;
} }
/* ===== Loading spinner ===== */
.spinner {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--muted, #a59a8a);
border-top-color: var(--accent, #e2822b);
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Loading overlay for panels */
.loading-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: var(--muted, #a59a8a);
font-size: 0.875rem;
}
.loading-overlay .spinner {
width: 1.5rem;
height: 1.5rem;
}
/* ===== Toast notifications ===== */
.toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
padding: 0.75rem 1.25rem;
border-radius: 6px;
font-size: 0.875rem;
color: #fff;
z-index: 1000;
animation: toastIn 0.3s ease;
max-width: 90vw;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.toast-error {
background: #c0392b;
}
.toast-info {
background: #4a7fb0;
}
.toast-success {
background: #4f9d69;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(-50%) translateY(1rem); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ===== Button loading state ===== */
.btn[disabled] {
opacity: 0.65;
cursor: not-allowed;
}
.btn-loading {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-loading .spinner {
width: 0.875rem;
height: 0.875rem;
border-width: 1.5px;
}
/* ===== Error page shared ===== */
.error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 2rem;
}
.error-code {
font-size: 6rem;
font-weight: bold;
color: var(--accent, #e2822b);
line-height: 1;
margin-bottom: 0.5rem;
}
.error-title {
font-size: 1.5rem;
color: var(--text, #eadfce);
margin-bottom: 1rem;
}
.error-message {
font-size: 1rem;
color: var(--muted, #a59a8a);
margin-bottom: 2rem;
max-width: 400px;
}
.error-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.error-actions a,
.error-actions button {
display: inline-block;
padding: 0.5rem 1.25rem;
border-radius: 4px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
border: none;
background: transparent;
color: inherit;
}
.error-actions a:hover,
.error-actions button:hover {
opacity: 0.85;
}
.btn-home {
background: var(--accent, #e2822b);
color: #fff;
}
.btn-back {
color: var(--accent, #e2822b);
border: 1px solid var(--accent, #e2822b);
}
.btn-retry {
color: var(--accent, #e2822b);
border: 1px solid var(--accent, #e2822b);
}
.btn-retry:hover {
background: var(--accent, #e2822b);
color: #fff;
}
+1
View File
@@ -120,6 +120,7 @@
</main> </main>
</div> </div>
<script src="/js/toast.js"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
<script src="/js/game.js"></script> <script src="/js/game.js"></script>
</body> </body>
-15
View File
@@ -815,21 +815,6 @@ function startExpeditionSync() {
}, 5000); }, 5000);
} }
// ===== Toast notifications =====
function showToast(message, type = 'info') {
// Remove existing toasts
document.querySelectorAll('.toast').forEach((t) => t.remove());
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 4000);
}
// ===== Init ===== // ===== Init =====
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await loadCharacter(); await loadCharacter();
-11
View File
@@ -272,17 +272,6 @@ function switchTab(tab) {
document.getElementById('sell-tab').style.display = tab === 'sell' ? '' : 'none'; document.getElementById('sell-tab').style.display = tab === 'sell' ? '' : 'none';
} }
// ─── Toast notifications ─────────────────────────────────────────────────
function showToast(message, type = 'info') {
document.querySelectorAll('.toast').forEach((t) => t.remove());
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// ─── Init ──────────────────────────────────────────────────────────────── // ─── Init ────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
+42
View File
@@ -0,0 +1,42 @@
// Toast notification system — shared across all pages.
// Usage: showToast('message', 'error' | 'info' | 'success');
// Auto-dismisses after 4 seconds. Multiple toasts stack vertically.
(function () {
'use strict';
const TOAST_DURATION = 4000;
const MAX_VISIBLE = 3;
const STACK_GAP = 8; // px between stacked toasts
/**
* Show a toast notification.
* @param {string} message - Toast text content.
* @param {'error'|'info'|'success'} [type='info'] - Visual style.
*/
window.showToast = function showToast(message, type) {
if (typeof message !== 'string') return;
type = type || 'info';
// Remove old toasts of the same type to avoid clutter
document.querySelectorAll('.toast-' + type).forEach(function (t) {
t.remove();
});
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.setAttribute('role', 'alert');
toast.textContent = message;
// Position: stack from bottom
var existing = document.querySelectorAll('.toast');
var offset = existing.length * STACK_GAP;
toast.style.bottom = (1.5 + offset / 16) + 'rem';
document.body.appendChild(toast);
setTimeout(function () {
if (toast.parentNode) toast.remove();
}, TOAST_DURATION);
};
})();
+1
View File
@@ -20,6 +20,7 @@
</form> </form>
<p class="redirect">Don't have an account? <a href="/register.html">Register</a></p> <p class="redirect">Don't have an account? <a href="/register.html">Register</a></p>
</div> </div>
<script src="/js/toast.js"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
<script src="/js/login.js"></script> <script src="/js/login.js"></script>
</body> </body>
+1
View File
@@ -70,6 +70,7 @@
</main> </main>
</div> </div>
<script src="/js/toast.js"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
<script src="/js/profile.js"></script> <script src="/js/profile.js"></script>
</body> </body>
+1
View File
@@ -20,6 +20,7 @@
</form> </form>
<p class="redirect">Already have an account? <a href="/login.html">Login</a></p> <p class="redirect">Already have an account? <a href="/login.html">Login</a></p>
</div> </div>
<script src="/js/toast.js"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
<script src="/js/register.js"></script> <script src="/js/register.js"></script>
</body> </body>
+1
View File
@@ -46,6 +46,7 @@
</main> </main>
</div> </div>
<script src="/js/toast.js"></script>
<script src="/js/auth.js"></script> <script src="/js/auth.js"></script>
<script src="/js/shop.js"></script> <script src="/js/shop.js"></script>
</body> </body>
+22 -1
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const helmet = require('helmet'); const helmet = require('helmet');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const path = require('path');
const { createSessionMiddleware, requireAuth } = require('./middleware/session'); const { createSessionMiddleware, requireAuth } = require('./middleware/session');
const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth'); const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth');
const { routes: characterRoutes } = require('./routes/character'); const { routes: characterRoutes } = require('./routes/character');
@@ -91,7 +92,24 @@ function createApp() {
// Hero profile route (public, no session required) // Hero profile route (public, no session required)
app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile); app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile);
// Central error handler // Catch-all: serve 404 page for unknown paths (HTML requests only).
// API paths still return JSON errors via the central handler.
// Placed before static files so it intercepts non-file paths.
const PUBLIC_DIR = path.resolve(__dirname, '../public');
app.use((req, res, next) => {
// If a route already set headers, let it through (e.g., static file not found)
if (res.headersSent) return next();
if (req.accepts('html')) {
return res.status(404).sendFile(path.join(PUBLIC_DIR, '404.html'));
}
return res.status(404).json({
ok: false,
error: { code: 'NOT_FOUND', message: 'Resource not found.' },
});
});
// Static files (after catch-all for API paths, but static will still serve HTML pages)
app.use(express.static('public'));
app.use((err, _req, res, _next) => { app.use((err, _req, res, _next) => {
// Log stack traces server-side only // Log stack traces server-side only
if (err.stack) { if (err.stack) {
@@ -120,6 +138,9 @@ function createApp() {
} }
// Generic error // Generic error
if (req.accepts('html')) {
return res.status(500).sendFile(path.join(PUBLIC_DIR, '500.html'));
}
res.status(500).json({ res.status(500).json({
ok: false, ok: false,
error: { error: {
+53
View File
@@ -0,0 +1,53 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
test('GET /nonexistent → serves 404.html for HTML requests', async () => {
const base = await setup();
try {
const res = await fetch(base + '/nonexistent-page', {
headers: { Accept: 'text/html' },
});
assert.equal(res.status, 404);
const html = await res.text();
assert.ok(html.includes('404'), '404 page should contain "404"');
assert.ok(html.includes('Page Not Found'), '404 page should have title');
assert.ok(html.includes('Emberfall'), '404 page should be branded');
} finally {
await teardown();
cleanup();
}
});
test('GET /nonexistent → JSON error for API requests', async () => {
const base = await setup();
try {
const res = await fetch(base + '/api/nonexistent', {
headers: { Accept: 'application/json' },
});
assert.equal(res.status, 404);
const data = await res.json();
assert.equal(data.ok, false);
assert.equal(data.error.code, 'NOT_FOUND');
assert.ok(typeof data.error.message === 'string');
} finally {
await teardown();
cleanup();
}
});
test('GET /api/heroes/nonexistent → 404.html (public route, unknown hero)', async () => {
const base = await setup();
try {
const res = await fetch(base + '/api/heroes/doesnotexist', {
headers: { Accept: 'text/html' },
});
// Public route exists, so it returns JSON 404 (not the page 404)
assert.equal(res.status, 404);
const data = await res.json();
assert.equal(data.ok, false);
} finally {
await teardown();
cleanup();
}
});