diff --git a/backend/app/main.py b/backend/app/main.py index ed2aad9..88b9b1b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -73,6 +73,31 @@ async def lifespan(app: FastAPI): await conn.execute(text( "ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false" )) + # Any entity that predates the traits column above was left with + # `{}` — the ALTER defaults it and nothing backfills. Every judgment + # read then falls back to 0.5, which makes `trust` always correct and + # `cross_over` unreachable for that spirit: the minigame is silently + # solved for it. roll_traits() only ever runs at mint time, so such a + # row can never repair itself. + # + # Seeded from the entity's own signature so the values are stable and + # reproducible rather than random, matching how a freshly-minted + # spirit derives them. Guarded to empty-traits rows only, so it can + # never touch a spirit that already has a real hidden nature. This + # install currently has zero such rows; the backfill exists so the + # gap cannot bite a longer-lived deployment. + await conn.execute(text( + """ + UPDATE entities SET traits = jsonb_build_object( + 'alignment', round((('x' || substr(md5(signature || 'alignment'), 1, 8))::bit(32)::bigint % 1000) / 1000.0, 3), + 'power', round((('x' || substr(md5(signature || 'power'), 1, 8))::bit(32)::bigint % 1000) / 1000.0, 3), + 'volatility', round((('x' || substr(md5(signature || 'volatility'), 1, 8))::bit(32)::bigint % 1000) / 1000.0, 3), + 'deceptiveness',round((('x' || substr(md5(signature || 'deceptiveness'),1, 8))::bit(32)::bigint % 1000) / 1000.0, 3) + ) + WHERE traits = '{}'::jsonb OR traits IS NULL + """ + )) + # Hunter profile columns. All nullable (or defaulted) so existing # rows, guests included, stay valid without a backfill. for column, ddl in ( diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py index 918699d..5141f3c 100644 --- a/backend/app/rate_limit.py +++ b/backend/app/rate_limit.py @@ -22,16 +22,47 @@ def resolve_client_ip(headers: Mapping[str, str], direct_host: str | None) -> st class RateLimiter: - """Fixed-window limiter keyed by an arbitrary string (user id or client IP).""" + """Fixed-window limiter keyed by an arbitrary string (user id or client IP). + + Expired keys are swept periodically rather than left to accumulate. + Without that, every distinct key ever seen stays in the dict forever — + and since the open door provisions a real account per visitor, "every + distinct key" now means every visitor, across all eleven limiter + instances. That is a slow but genuine leak in a process designed to run + for months. + """ + + # Sweep every N admitted calls rather than on a timer: no background + # task to own, and the cost lands on whoever is generating the load. At + # 512 the amortised cost is negligible, and a limiter can hold at most a + # window's worth of traffic plus 512 stale keys. + SWEEP_EVERY = 512 def __init__(self, max_requests: int, window_seconds: float): self.max_requests = max_requests self.window_seconds = window_seconds self._hits: dict[str, list[float]] = defaultdict(list) + self._calls_since_sweep = 0 + + def _sweep(self, window_start: float) -> None: + """Drop keys whose most recent hit has fallen out of the window.""" + stale = [ + key + for key, hits in self._hits.items() + if not hits or hits[-1] < window_start + ] + for key in stale: + del self._hits[key] def allow(self, key: str) -> bool: now = time.monotonic() window_start = now - self.window_seconds + + self._calls_since_sweep += 1 + if self._calls_since_sweep >= self.SWEEP_EVERY: + self._calls_since_sweep = 0 + self._sweep(window_start) + hits = self._hits[key] while hits and hits[0] < window_start: hits.pop(0) @@ -39,3 +70,8 @@ class RateLimiter: return False hits.append(now) return True + + @property + def tracked_keys(self) -> int: + """Live key count — exposed so the leak is testable.""" + return len(self._hits) diff --git a/backend/app/ws.py b/backend/app/ws.py index be8367b..929ad0e 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -161,6 +161,15 @@ class SeanceState: # Latest NOAA planetary K-index reading, refreshed on summon. Cached on # the state so the manifest path can report it without another lookup. geomagnetic: dict | None = None + # Serialises summoning for this session. Two independent coroutines can + # drive the same SeanceState: the browser's own WS message loop, and the + # HTTP telemetry ingestion path (device_anomaly.process_device_reading_ + # for_summon -> _handle_anomaly), which is the entire point of the ESP32 + # integration. Without this, both can observe `state.entity is None`, + # both await a summon that includes a multi-second LLM mint, and the + # session ends up with two entities minted, two essence credits, two + # item rolls, and whichever finishes last clobbering state.entity. + summon_lock: asyncio.Lock = field(default_factory=asyncio.Lock) # Active-session registry (spec: ESP32 sensor node, Workstream K): maps a @@ -491,6 +500,13 @@ async def _reward_summon(state: SeanceState) -> None: async def _handle_summon(state: SeanceState) -> None: + # Held across the whole mint so a concurrent caller waits rather than + # starting a second summon. See SeanceState.summon_lock. + async with state.summon_lock: + await _summon_locked(state) + + +async def _summon_locked(state: SeanceState) -> None: # Short-circuits: an account already over its own cap never gets far # enough to spend from the IP budget too. if not ( diff --git a/backend/tests/test_rate_limit.py b/backend/tests/test_rate_limit.py index 83122fa..fce05a7 100644 --- a/backend/tests/test_rate_limit.py +++ b/backend/tests/test_rate_limit.py @@ -46,3 +46,61 @@ def test_resolve_client_ip_falls_back_to_socket_peer_without_header(): def test_resolve_client_ip_falls_back_to_unknown_with_no_peer_or_header(): assert resolve_client_ip({}, None) == "unknown" + + +# --- key eviction ----------------------------------------------------------- + + +class TestKeyEviction: + """Keys must not accumulate forever. + + The open door provisions a real account per visitor, so every visitor + contributes a distinct user-id key to each limiter. Without eviction a + long-running process grows without bound. + """ + + def test_stale_keys_are_swept(self, monkeypatch): + limiter = RateLimiter(max_requests=5, window_seconds=60) + clock = {"t": 1000.0} + monkeypatch.setattr("app.rate_limit.time.monotonic", lambda: clock["t"]) + + # A burst of one-shot visitors, each seen exactly once. + for i in range(RateLimiter.SWEEP_EVERY): + limiter.allow(f"visitor-{i}") + assert limiter.tracked_keys == RateLimiter.SWEEP_EVERY + + # Long after their window has closed, one more call triggers a sweep. + clock["t"] += 10_000 + for i in range(RateLimiter.SWEEP_EVERY): + limiter.allow(f"later-{i}") + + # The original cohort is gone; only the recent one is retained. + assert limiter.tracked_keys <= RateLimiter.SWEEP_EVERY + 1 + + def test_sweeping_never_forgets_an_active_key(self, monkeypatch): + """A sweep must not hand someone a fresh budget mid-window.""" + limiter = RateLimiter(max_requests=2, window_seconds=60) + clock = {"t": 500.0} + monkeypatch.setattr("app.rate_limit.time.monotonic", lambda: clock["t"]) + + assert limiter.allow("steady") is True + assert limiter.allow("steady") is True + assert limiter.allow("steady") is False + + # Force many sweeps while "steady" stays inside its window. + for i in range(RateLimiter.SWEEP_EVERY * 2): + clock["t"] += 0.001 + limiter.allow(f"noise-{i}") + + # Still blocked — the sweep must not have dropped a live key. + assert limiter.allow("steady") is False + + def test_a_key_recovers_normally_after_its_window(self, monkeypatch): + limiter = RateLimiter(max_requests=1, window_seconds=10) + clock = {"t": 0.0} + monkeypatch.setattr("app.rate_limit.time.monotonic", lambda: clock["t"]) + + assert limiter.allow("seeker") is True + assert limiter.allow("seeker") is False + clock["t"] += 11 + assert limiter.allow("seeker") is True diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index b56a802..529b36e 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -113,7 +113,8 @@ "switchToLogin": "already carved your name? reconnect →", "failed": "the veil rejected you: {{message}}", "smallPrint": "your medium handle is only ever stored on this server", - "guest": "slip through as a wanderer" + "guest": "slip through as a wanderer", + "guestFailed": "the veil would not open a nameless door just now — too many wanderers have slipped through from here recently. claim a name below, or return in a little while." }, "seance": { "modes": { diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 22d88c7..530d282 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -113,7 +113,8 @@ "switchToLogin": "¿ya grabaste tu nombre? reconectar →", "failed": "el velo te rechazó: {{message}}", "smallPrint": "tu nombre de médium solo se guarda en este servidor", - "guest": "escabúllete como errante" + "guest": "escabúllete como errante", + "guestFailed": "el velo no abrió una puerta sin nombre ahora mismo — demasiados errantes han pasado desde aquí hace poco. reclama un nombre abajo, o vuelve en un rato." }, "seance": { "modes": { diff --git a/frontend/src/pages/EnterPage.css b/frontend/src/pages/EnterPage.css index 32d8559..1ea8d15 100644 --- a/frontend/src/pages/EnterPage.css +++ b/frontend/src/pages/EnterPage.css @@ -305,3 +305,12 @@ animation: none; } } + +/* Explains an auto-provisioning failure to someone who never chose to be + on this page. Informational, not an error — the seeker did nothing wrong. */ +.enter-notice { + color: rgba(178, 107, 255, 0.9); + font-size: 0.8rem; + line-height: 1.5; + margin: 0 0 0.6rem; +} diff --git a/frontend/src/pages/EnterPage.tsx b/frontend/src/pages/EnterPage.tsx index b8b93da..34644fb 100644 --- a/frontend/src/pages/EnterPage.tsx +++ b/frontend/src/pages/EnterPage.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import type { FormEvent } from 'react' -import { Navigate, useNavigate } from 'react-router-dom' +import { Navigate, useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useAuth } from '../state/auth' import './EnterPage.css' @@ -13,6 +13,12 @@ type EnterMode = 'login' | 'register' export function EnterPage() { const { t } = useTranslation() + // SeancePage redirects here when auto-provisioning a wanderer fails and + // passes the reason along, so someone who never asked to sign in is told + // why they are looking at this form. Most often the guest limiter + // (5/hour/IP) — easy to hit from a household or cafe behind one NAT. + const location = useLocation() + const guestFailure = (location.state as { guestFailure?: string } | null)?.guestFailure const { user, login, register, guest } = useAuth() const navigate = useNavigate() @@ -109,6 +115,11 @@ export function EnterPage() { /> + {guestFailure && !error && ( +
+ {t('enter.guestFailed')} +
+ )} {error && (
{t('enter.failed', { message: error })}
diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx
index 7d0c787..aa61bd5 100644
--- a/frontend/src/pages/SeancePage.tsx
+++ b/frontend/src/pages/SeancePage.tsx
@@ -72,7 +72,9 @@ const SDR_SAMPLE_RATE_HZ = 2_048_000
export function SeancePage() {
const { user, checking, guest } = useAuth()
const { t } = useTranslation()
- const [guestFailed, setGuestFailed] = useState(false)
+ // Why auto-provisioning failed, carried to /enter so the seeker is told
+ // what happened instead of being dropped on a bare login form.
+ const [guestFailure, setGuestFailure] = useState