fix: claiming a name keeps your account instead of abandoning it

The séance tells guests "claim a name to keep your codex". It was a lie.
register() unconditionally created a brand-new User row with a fresh UUID,
so a wanderer's essence, discovered entities, sightings, ritual/judgment
history and Ghost Log — every one of them foreign-keyed to the guest's
user_id — were silently orphaned the moment they registered. Now that
every visitor starts as a guest, that hit essentially everyone who ever
signed up.

A wanderer hitting /register now renames that same row in place, keeping
all relationships intact. The existing AuthSession stays valid (same
user_id), so claiming a name doesn't even log you out.

Scoped deliberately to wanderers. My first attempt rejected ANY
authenticated caller with a 409, which broke registering a second account
while logged in — a legitimate flow (shared computer, alt account) that
tests/test_device.py::test_device_feed_only_broadcasts_to_the_owning_user
caught immediately: its second register 409'd, its login then failed, and
"user B's" device got paired to user A, silently defeating a
cross-user-isolation assertion. A named caller's cookie is now ignored and
the normal create-a-new-row path runs.

Adds get_optional_current_user (None instead of 401) for endpoints that
behave differently for anonymous vs. authenticated callers but must stay
reachable without auth.

This was one of eight findings from an adversarial audit whose verifier
agents all died on session limits, so nothing was machine-verified — I
confirmed this one by reading the code and then proving it end-to-end.
The other seven remain unchecked.

331 backend tests pass. Verified live: guest 23846555 -> livehunter1, same
id, same session still valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-29 23:39:53 +00:00
parent 1688907971
commit b6d491d563
3 changed files with 141 additions and 3 deletions

View File

@@ -28,3 +28,21 @@ async def get_current_user(
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
return user
async def get_optional_current_user(
qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
db: AsyncSession = Depends(get_db),
) -> User | None:
"""Same lookup as get_current_user, but None instead of a 401 when
there is no valid session — for endpoints (like /auth/register) that
behave differently for an anonymous caller vs. an already-authenticated
one, but must not require auth to be reachable at all."""
if qm_session is None:
return None
token_hash = hash_token(qm_session)
result = await db.execute(select(AuthSession).where(AuthSession.token_hash == token_hash))
session = result.scalar_one_or_none()
if session is None or session.expires_at < datetime.now(timezone.utc):
return None
return await db.get(User, session.user_id)

View File

@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.deps import SESSION_COOKIE_NAME, get_current_user
from app.deps import SESSION_COOKIE_NAME, get_current_user, get_optional_current_user
from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token
from app.models.unlock import UnlockRecord
from app.models.user import User
@@ -27,13 +27,46 @@ guest_limiter = RateLimiter(max_requests=5, window_seconds=3600)
# namespace is effectively exhausted, not that we got unlucky.
_GUEST_NAME_ATTEMPTS = 8
# Shared with the guest-provisioning endpoint below, and with the frontend's
# own username-prefix check (SeancePage's claim-a-name nudge) — a wanderer
# is any user row whose username starts with this.
WANDERER_PREFIX = "wanderer-"
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
async def register(
payload: RegisterRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_optional_current_user),
):
existing = await db.scalar(select(User).where(User.username == payload.username))
if existing is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="username taken")
# A *wanderer* hitting /register is claiming a name for the account they
# already have, not opening a new one — this is what makes the séance's
# "claim a name to keep your codex" nudge true rather than a lie.
# Previously register() always created a brand-new User row, so a
# guest's essence, discovered entities, ritual/judgment history and
# Ghost Log (all foreign-keyed to the guest's user_id) were silently
# abandoned the moment they registered — the exact opposite of what the
# UI promises. Renaming the SAME row in place keeps every one of those
# relationships intact, and the existing AuthSession stays valid (same
# user_id), so claiming a name doesn't log you out.
#
# Deliberately scoped to wanderers only: a caller who already has a real
# name is registering a SECOND account (a legitimate thing to do while
# logged in — shared computer, alt account), so their cookie is ignored
# and the normal create-a-new-row path runs. An earlier version of this
# rejected that case outright and broke exactly that flow.
if current_user is not None and current_user.username.startswith(WANDERER_PREFIX):
current_user.username = payload.username
current_user.password_hash = hash_password(payload.password)
current_user.email = payload.email
await db.commit()
await db.refresh(current_user)
return current_user
user = User(
username=payload.username,
password_hash=hash_password(payload.password),
@@ -61,7 +94,7 @@ async def guest(
)
for _ in range(_GUEST_NAME_ATTEMPTS):
username = f"wanderer-{secrets.token_hex(2)}"
username = f"{WANDERER_PREFIX}{secrets.token_hex(2)}"
existing = await db.scalar(select(User).where(User.username == username))
if existing is None:
break