diff --git a/PLAN.md b/PLAN.md index 1c05946..3cb1e03 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1387,6 +1387,31 @@ provided cert) at the contour caddy; prod VPN; rollback. `kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern` glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the Users section), a top-level nav entry plus the card deep-links. + - **Round-6 follow-up — UX polish + client-IP fix (this PR):** + - **Client IP through the edge.** The compose caddy now sets `trusted_proxies static + private_ranges`, so the real client IP survives the host-caddy hop (it was logging the + docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's + per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host + caddy → public clients untrusted → real peer used). `peerIP` unit-tested. + - **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte` + — the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod + bundle (code kept for post-release polish). + - **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption). + - **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger + right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's + own controls. + - **Edge-swipe back** (`Screen.svelte`): a left-edge rightward drag navigates to `back` + (touch/pen only, armed only from ≤24px so it never fights the board's gestures; skipped + inside Telegram, which has its own back). + - **Chat soft-keyboard** is a **bottom-sheet** `Modal` lifted above the keyboard by a + `transform` driven by `visualViewport` (compositor-only — the board behind and the sheet + no longer relayout as the keyboard animates). iOS-specific; needs on-device fine-tuning. + The native `Keyboard.setResizeMode('none')` path waits for Capacitor (not yet wired). + - **Tests backfilled** for the merged round-6 work: e2e for the in-game "✓ in friends" item + and a board→board tile relocation; codec units for `last_activity_unix` + `OutgoingRequestList`. + - **Deferred to the next PR (agreed):** #4 enrich the out-of-app "your turn" / game-end push + with the opponent's name, last word and score; #5 let a player hide finished games from + their lobby (swipe + a desktop affordance). ## Deferred TODOs (cross-stage) diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 25a4e20..ba26fbe 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -8,6 +8,14 @@ # ACME and the contour is self-contained. { admin off + # Trust X-Forwarded-For from private-range upstreams so the real client IP survives + # (chat moderation + per-IP rate limiting in the gateway). Test contour: the host caddy + # (a private IP) is trusted, so its forwarded client IP is preserved. Prod (no host caddy): + # clients connect from public IPs, which are NOT trusted, so Caddy uses the real peer — + # the same config is correct (and spoof-safe) in both contours (Stage 17). + servers { + trusted_proxies static private_ranges + } } {$CADDY_SITE_ADDRESS::80} { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd8b691..96154dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,8 +40,9 @@ Three executables plus per-platform side-services: in-memory mock transport (`pnpm start`) runs the whole slice with no backend. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom), - a one-line **announcement banner** under the nav (a client-side mock rotation today — - a server-driven channel later, §10), and a client **board-style** setting (bonus-label + a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off + in the build until polished after release**, Stage 17 — a server-driven channel later, §10), + and a client **board-style** setting (bonus-label mode). The visual/interaction design system is documented in [`UI_DESIGN.md`](UI_DESIGN.md). - **`platform/telegram`** — the Telegram side-service (the "connector", module @@ -608,7 +609,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): (`.gitea/workflows/ci.yaml` → `docker compose up -d --build` on the Gitea runner host, then a `GET /` probe through caddy). The host caddy terminates TLS and forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP - (`CADDY_SITE_ADDRESS=:80`). + (`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from + private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP — + used for chat-moderation logging and the gateway's per-IP rate limiting — survives the + host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the + real peer, so the single config is correct and spoof-safe in both contours (Stage 17). - **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no host caddy, so the contour ships its own caddy terminating TLS — set `CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME. diff --git a/gateway/internal/connectsrv/peerip_test.go b/gateway/internal/connectsrv/peerip_test.go new file mode 100644 index 0000000..f54b1cb --- /dev/null +++ b/gateway/internal/connectsrv/peerip_test.go @@ -0,0 +1,35 @@ +package connectsrv + +import ( + "net/http" + "testing" +) + +// TestPeerIP covers the client-IP extraction the chat-moderation IP and the per-IP rate +// limiter both rely on: the first X-Forwarded-For hop (the real client, once Caddy is +// configured to trust its upstream), falling back to the connection peer (Stage 17). +func TestPeerIP(t *testing.T) { + tests := []struct { + name string + addr string + xff string + want string + }{ + {"xff single", "10.0.0.1:5000", "203.0.113.7", "203.0.113.7"}, + {"xff client then proxies", "10.0.0.1:5000", "203.0.113.7, 172.18.0.3", "203.0.113.7"}, + {"xff trims spaces", "10.0.0.1:5000", " 203.0.113.9 , 10.0.0.2", "203.0.113.9"}, + {"no xff uses peer host", "203.0.113.5:42000", "", "203.0.113.5"}, + {"no xff no port", "203.0.113.6", "", "203.0.113.6"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := http.Header{} + if tc.xff != "" { + h.Set("X-Forwarded-For", tc.xff) + } + if got := peerIP(tc.addr, h); got != tc.want { + t.Errorf("peerIP(%q, xff=%q) = %q, want %q", tc.addr, tc.xff, got, tc.want) + } + }) + } +} diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 511d7c2..7abf0a2 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -139,6 +139,29 @@ test('dropping the game ends it and shows the result', async ({ page }) => { await expect(page.locator('.status .over')).toBeVisible(); }); +test('a placed tile drags from one board cell to another (Stage 17 relocation)', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + const pending = page.locator('[data-cell].pending'); + await expect(pending).toHaveCount(1); + const from = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`; + + const target = page.locator('[data-cell]:not(.filled):not(.pending)').nth(45); + const fb = await pending.first().boundingBox(); + const tb = await target.boundingBox(); + // Pointer-drag the placed tile to a new cell (mouse events synthesise pointer events). + await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2); + await page.mouse.down(); + await page.mouse.move(tb!.x + tb!.width / 2, tb!.y + tb!.height / 2, { steps: 10 }); + await page.mouse.up(); + + // Still exactly one pending tile (relocated, not duplicated), now at a different cell. + await expect(pending).toHaveCount(1); + const to = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`; + expect(to).not.toBe(from); +}); + test('the board-label mode in Settings changes the on-board labels', async ({ page }) => { await openGame(page); // beginner (default) renders split "3× / word" labels. diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index a0ce08b..228eeda 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -122,6 +122,18 @@ test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) await expect(sent).toBeDisabled(); }); +test('game: an opponent who is already a friend shows a disabled "in friends"', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend + await page.locator('.burger').first().click(); + // The in-game friend item is derived from the server's friend list (Stage 17): a friend reads + // a disabled "✓ in friends", not the addable "Add to friends". + const inFriends = page.getByRole('button', { name: /in friends/i }); + await expect(inFriends).toBeVisible(); + await expect(inFriends).toBeDisabled(); + await expect(page.getByRole('button', { name: /Add to friends: Kaya/ })).toHaveCount(0); +}); + test('profile edit disables Save and flags an invalid display name', async ({ page }) => { await loginLobby(page); await page.locator('.burger').first().click(); diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 16587da..64fd2bd 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -74,9 +74,8 @@
{t('landing.tagline')}
{#if tgLink} - -