app/rate_limit.py: an in-process sliding-window limiter (single-worker
backend, so no Redis needed -- flagged for if that changes). /register and
/login are capped at 10 attempts / minute / client IP (X-Forwarded-For
aware), returning 429 + Retry-After past that. Blunts credential
brute-force, which the backend had no protection against.
- conftest.py resets the limiter between tests (every TestClient request
looks like one IP).
- tests/test_rate_limit.py: threshold, shared bucket, per-key window. 77 passed.
CORS reviewed while here: already an explicit origin allowlist
(settings.CORS_ORIGINS), not "*" -- and the Godot client isn't a browser
so it doesn't apply there anyway. Left as-is.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
POST /api/auth/register now requires a real date_of_birth (ISO YYYY-MM-DD).
The server computes age (auth.calculate_age) and rejects under-18s with 403;
a future/nonsensical date is 400. The DOB is stored and surfaced only to
that user (/api/auth/me).
Still self-attested (a user can enter a false DOB) -- but a concrete,
stored, legally-meaningful data point and the seam for a future ID-check
pass. Encryption-at-rest for the column and the Tier-2 hook are not done;
the limitation is stated plainly in docs/architecture/content_moderation.md 5.2.
- auth.py: RegisterRequest.date_of_birth (date); MIN_AGE=18; register()
computes + enforces; UserResponse.date_of_birth typed `date | None`
(the old `str | None` would have 500'd /me once a DOB was actually stored).
- api_client.gd / main_menu.gd: the Over18 checkbox is now a
"Date of birth (YYYY-MM-DD)" field with a client-side sanity check.
- tests: test_auth_age_gate.py rewritten around the endpoint + age math
(adult/minor/exactly-18/future-dob/stored); ~25 over_18 references across
6 backend test files + 6 game smoke tests updated. Backend 74 passed;
game smokes green (auth 6/6, menu_full_flow 12/12, e2e 4/4, etc.);
fresh-clone boot PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- backend/tests/conftest.py: pytest_configure brings up an ephemeral
Postgres via docker compose + runs migrations when DATABASE_URL isn't
already reachable, and tears it down after. `cd backend && pytest` now
works from a clean checkout with zero manual setup (72 passed in ~28s);
no docker and no DB -> the whole suite skips with a clear message instead
of erroring 60+ times.
- scripts/verify_fresh_clone.sh: exports a pristine tree (git archive, so no
.godot), imports from scratch, headless-boots the main menu + game scene,
fails on any SCRIPT ERROR / failed autoload. Catches the stale-class-cache
class of bug. PASS.
- run_checkpoint_tests.py runs both the 2p multiplayer test and the
fresh-clone check.
- .forgejo/workflows/checkpoint.yml + docs/ci.md: the CI workflow (runs the
checkpoint suite on push/PR) and how to enable it -- it needs a Forgejo
Actions `host` runner registered on the AGP box, a one-time operator step.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- backend/app/services/game_servers.py: an in-process supervisor. One
headless Godot dedicated-server process per world, keyed by world_id,
spawned on demand from the join endpoint, port-allocated from a
configured range, with a background reaper that kills a world's server
once its status file reports 0 players for the idle timeout. Off by
default (GAME_SERVERS_ENABLED); the checkpoint/playtest launcher enables it.
- POST /api/worlds/{id}/join -> {world_id, host, port}; 404 for
unknown/hidden worlds, 503 when orchestration is disabled. Auth required.
- main.py lifespan starts/stops the supervisor.
- config.py: GODOT_BIN, GAME_PROJECT_DIR, GAME_SERVER_HOST, port range,
idle timeout.
- tests/test_world_join.py (auth / 404 / visibility / 503) -- suite 72 passed.
- checkpoint runner also runs the 2p multiplayer shell test.
Verified end to end: enabled orchestration, POST /join spawned the Godot
server, a real client connected to the returned address and moved; backend
shutdown reaped the process.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Made the multiplayer core actually work (it had never run a real sim):
- project.godot: physics pinned to 30 Hz -- the shared sim/snapshot/prediction rate.
- server.gd: loads the world's level (real ground + collision), spawns a
server Player per peer at the level's PlayerSpawn, broadcasts a full
snapshot every physics frame with each peer's state AND last_seq (the last
input seq applied for that peer). Accepts `--port/--world/--status-file`
args (status file = player count for a future idle reaper); defaults keep
`godot res://scenes/server.tscn` working standalone.
- player_spawner.gd (server Player): rewrote -- drains its whole input queue
each physics frame in seq order, integrates gravity/walk/jump with the
shared constants, move_and_slide(), tracks last_applied_seq. No more no-op
process_server_input().
- network_client.gd: rewrote reconciliation -- drops inputs the server has
acked (last_seq), re-simulates the still-in-flight ones from the
authoritative state, snaps only on real drift. Exposes get_remote_states()
(peer_id -> latest snapshot state) for the game to render other players.
predict_and_send() now also sends body yaw.
- proxies trimmed to the RPC set the rewrite actually uses.
- scripts/mp_test_client.gd + run_multiplayer_2p_test.sh: two real client
processes vs one real server -- client A sees client B and its own
movement round-trips. PASS.
- run_checkpoint_tests.py: also run the 2p shell test.
smoke_test_network_client 4/4, player 15/15, avatar_in_world 10/10, all green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
New avatar_loadouts table (one row per user, loadout stored as a single
JSONB document matching the client's AvatarLoadout.to_json() shape) and
GET/PUT /api/avatar/loadout.
The server has no copy of the asset catalog, so it validates only the
envelope (JSON object, non-empty base_body, object slots, size cap) --
which-asset-in-which-slot validation stays client-side. New accounts get a
server default (base_human_male_01, no slots) with is_default:true so the
client can tell "never customised" from "customised back to base".
- models/avatar_loadout.py + 0004 migration + User.avatar_loadout relation
- routers/avatar.py wired into main.py
- tests/test_avatar_loadout.py (7 tests: default, save/read, replace,
validation, auth) -- full suite 68 passed
- lifted the duplicated _override_db fixture from 3 test files into
conftest.py (new test files need it too)
- docs/architecture/avatar.md §9a: the persistence design
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
The register form collected and validated an actual date of birth just to
throw it away (backend never stored it beyond a nullable column). Per the
roadmap note: don't collect/store DOB right now, just get a client-side
"I am 18 or older" confirmation. RegisterRequest.date_of_birth (str) is now
over_18 (bool); the endpoint rejects over_18=false with 400 same as before.
calculate_age() is kept (unused by /register now) since real DOB collection
is coming back in a future pass and deleting it broke test_auth_age_gate.py's
import for no benefit.
Frontend: DOBEdit LineEdit replaced with an Over18Check CheckBox; api_client.gd's
register() now takes a bool instead of a date string. All Godot callers
(smoke_test_auth/e2e/world_list/main_menu_ui/main_menu_full_flow) and backend
test files updated to match. Full suite verified against a live backend:
61/61 pytest, 6/6 Godot smoke tests (33 individual assertions), all passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
Resolves conflict in main_menu.gd's play button: kept this branch's real
world_list.tscn transition over main's stale TODO placeholder (main's
version predates this fix; nothing else has touched the play button
since). Brings in PR #37's /auth/refresh endpoint and its tests cleanly,
no other conflicts.
- auth.py: _create_access_token had no jti claim, so two refreshes
within the same wall-clock second produced byte-identical access
tokens (same failure mode already fixed for refresh tokens earlier)
- test_refresh_endpoint.py: usernames (jwttoken, refreshuser, etc.)
collided with test_auth_endpoints.py's own test usernames once run
in the same pytest session against a shared DB with no per-test
isolation -- exactly how run_checkpoint_tests.py invokes pytest.
Namespaced this file's usernames with a refep_ prefix.
Full backend suite (63 tests) passes clean end-to-end against a real
Postgres with real migrations applied.
- main_menu.gd: Play button now navigates to world_list.tscn
- world_list.gd: fix invalid theme_override_constants property-path syntax
(use add_theme_constant_override), fix @onready node paths missing
/HeaderHBox/, cast Variant dict values to String to avoid type-inference
errors when cards actually render
- world_list.tscn: wire up Back/Refresh button signal connections (scene
had zero [connection] blocks, buttons were dead)
- backend: fix real bug where World.visibility enum sent member names
("PUBLIC") to Postgres instead of lowercase values, breaking both GET
and POST /api/worlds/ for any real query
- backend: fix WorldResponse.created_at typed as str instead of datetime,
causing a second 500 on GET once the enum bug was fixed
- add smoke_test_world_list.gd: headless scene-level smoke test that
registers a user, instantiates the real scene, and confirms world cards
render from live backend data (not just an API-level check)
Verified end-to-end before this fix: the actual deliverable (login/register
UI + ApiClient wiring) already worked correctly -- confirmed via the real
Godot headless smoke test (scripts/smoke_test_auth.gd), 6/6 passing against
a live backend. This commit only fixes the bonus backend integration test
file the task also added, which had 4 separate real bugs:
1. Imported override_get_db/SessionLocal from app.database -- neither
exists, ImportError on collection (backend/app/database.py only ever
exposed get_db + the async engine/session factory).
2. Base.metadata.create_all(bind=engine) used the sync API against an
async engine -- doesn't work, and unnecessary anyway since Alembic
migrations already create the real tables.
3. Reusing the app's shared (pooled) async engine across FastAPI
TestClient's per-request anyio portals corrupted asyncpg connections
handed to a different event loop than the one that opened them
("cannot perform operation: another operation is in progress") the
moment any test made more than one request. Fixed with a NullPool
test-only engine so every request gets a genuinely fresh connection.
4. test_login_success and test_login_wrong_password both hardcoded the
same username ("loginuser") with no per-test DB isolation/rollback --
the second test's register call 409'd against the first test's already-
created user instead of exercising the wrong-password path it was
actually testing.
Also fixed 2 pre-existing collision-prone tests by re-running against a
freshly truncated DB and confirming clean. Full suite: 49/49 passing
(38 pre-existing + 11 new).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
passlib 1.7.4 (last release, unmaintained since ~2020) detects its bcrypt
backend via bcrypt.__about__.__version__, which bcrypt>=4.1 removed. Every
hash_password/verify_password call was raising 'password cannot be longer
than 72 bytes' -- a misleading downstream symptom of the real
AttributeError: module 'bcrypt' has no attribute '__about__'.
Without this pin, pip install -r requirements.txt silently grabs whatever
the latest bcrypt is and breaks register/login outright -- confirmed this
broke 3 separate pieces of work this session (manual register curl tests,
and now a real Godot smoke test) purely from environment drift, not from
any actual code bug. Verified the fix works from a genuinely clean venv +
pip install, not just patching the already-installed shared venv.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
world_visibility and asset_type were each declared twice: once explicitly
via `.create(checkfirst=True)`, and again implicitly by SQLAlchemy when
create_table() compiled the inline postgresql.ENUM() column type (which
defaults create_type=True). On a genuinely empty database this raises
asyncpg.DuplicateObjectError mid-transaction and the migration never
completes -- verified this was true 100% of the time from a fresh
container, meaning the backend has never successfully bootstrapped from
scratch. Fixed by passing create_type=False on the inline column
declarations, since the type is already created explicitly above.
Also parameterized both services' host ports (POSTGRES_HOST_PORT /
REDIS_HOST_PORT, defaulting to the original 5432/6379) so concurrent
agent workspaces sharing this same docker-compose.yml can run isolated
databases without colliding on the host port.
Requesting an explicit ?visibility=PRIVATE (or FRIENDS_ONLY) skipped the
ownership check entirely — any authenticated user could list every other
user's private worlds just by passing the filter. Only the no-filter
default path enforced ownership. Now the ownership check applies whenever
a non-PUBLIC visibility is requested, explicit or not.
Also fixed current_user: CurrentUser = Depends() in assets.py (redundant
empty Depends() on an already-Annotated dependency, inconsistent with
every other endpoint) to plain current_user: CurrentUser per the review
feedback, and reordered params since that made it a non-default arg
following a defaulted one (SyntaxError otherwise).
Verified: py_compile clean, full app imports and loads in a clean venv.
config.py's DATABASE_URL uses postgresql+asyncpg but requirements.txt
only listed the sync psycopg2-binary driver, causing ModuleNotFoundError
on app startup.