Files
qtalker---/backend/app/routes/codex.py
Indiana 605f32f20e fix: blank inbox previews, two unauthenticated 500s, and the missing social tests
- Every conversation row in the inbox rendered an empty preview: the
  frontend read `last_body`, which the API has never sent. It sends
  `excerpt` plus `truncated`.
- `GET /api/codex?limit=-1` passed a negative LIMIT to Postgres -> 500.
  Now bounded by Query(ge=1, le=200) -> 422.
- Registering with a >255 char email hit the VARCHAR(255) column and
  surfaced as a 500. PATCH /api/profile already validated this; only
  /auth/register was open.

The social layer had zero tests, despite routes/messages.py citing
"tests/test_messages.py's cross-user leak tests" and routes/profile.py
claiming "privacy rules that the tests pin". Neither file existed. Both now
do: auth required on every route, email only on /api/profile/me, no id or
password_hash on any public payload, third-party thread reads empty,
payload-supplied sender inert, veiled profiles 404 and stay off the roster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:45:04 +00:00

225 lines
7.8 KiB
Python

"""Public Codex endpoints — the browsable registry of every spirit ever
contacted, shared across all users (spec §4)."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models.contact_session import ContactSession
from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting
from app.models.event import Event
from app.models.user import User
router = APIRouter(prefix="/api", tags=["codex"])
# How many distinct hunters the dossier names. Everyone else is still
# counted in `total_encounters` — the roster is a window, not the truth.
ROSTER_LIMIT = 20
def _user_column(name: str):
"""The profile columns land in `users` via a parallel workstream (the
hunter-profiles spec §"Data model"). Until that migration exists this
module must not reference them in SQL at all, or every Codex request
500s on an unknown column. Resolve them by name and fall back to a
literal default, so the same code path works before and after.
"""
return getattr(User, name, None)
def _hunter_entry(
username: str,
display_name: str | None,
avatar_form: str | None,
avatar_hue: int | None,
profile_public: bool | None,
times: int,
last_seen,
) -> dict:
# A hidden profile is still a hunter who was there: counted, named
# nowhere. `public` is what the UI keys the link off.
public = True if profile_public is None else bool(profile_public)
return {
"username": username,
"display_name": display_name or username,
"avatar_form": avatar_form,
"avatar_hue": avatar_hue,
"public": public,
"times_contacted": times,
"last_seen": last_seen.isoformat() if last_seen else None,
}
async def _encounters(db: AsyncSession, entity_id: uuid.UUID) -> tuple[list[dict], int]:
"""The roster of hunters who have contacted this spirit, newest-first.
ONE aggregate query — group the sightings by hunter and carry the count
and latest contact out of the same scan. Never a lookup per hunter.
"""
display_name = _user_column("display_name")
avatar_form = _user_column("avatar_form")
avatar_hue = _user_column("avatar_hue")
profile_public = _user_column("profile_public")
last_seen = func.max(EntitySighting.seen_at).label("last_seen")
times = func.count(EntitySighting.id).label("times")
columns = [User.username, times, last_seen]
optional = [display_name, avatar_form, avatar_hue, profile_public]
columns.extend(col for col in optional if col is not None)
rows = (
await db.execute(
select(*columns)
.join(User, User.id == EntitySighting.user_id)
.where(EntitySighting.entity_id == entity_id)
.group_by(User.id)
.order_by(desc(last_seen))
.limit(ROSTER_LIMIT)
)
).all()
roster = [
_hunter_entry(
row.username,
getattr(row, "display_name", None) if display_name is not None else None,
getattr(row, "avatar_form", None) if avatar_form is not None else None,
getattr(row, "avatar_hue", None) if avatar_hue is not None else None,
getattr(row, "profile_public", None) if profile_public is not None else None,
row.times,
row.last_seen,
)
for row in rows
]
# Distinct hunters, not sighting rows (`sightings` already reports those)
# — so the UI can honestly say "and N more" past the roster window.
total = (
await db.scalar(
select(func.count(func.distinct(EntitySighting.user_id))).where(
EntitySighting.entity_id == entity_id
)
)
or 0
)
return roster, total
def _entity_card(entity: Entity, discoverer: str | None) -> dict:
return {
"id": str(entity.id),
"name": entity.name,
"epithet": entity.epithet,
"rarity": entity.rarity_tier,
"visual": entity.visual_profile,
"quotes": entity.sample_quotes,
"contact_count": entity.contact_count,
"discovered_at": entity.discovered_at.isoformat(),
"discovered_by": discoverer,
}
@router.get("/codex")
async def list_codex(
rarity: str | None = None,
sort: str = "recent",
# Bounded at the edge: an unvalidated `?limit=-1` reached Postgres as a
# negative LIMIT and returned a 500 to an unauthenticated caller.
limit: int = Query(default=60, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
query = select(Entity)
if rarity:
query = query.where(Entity.rarity_tier == rarity)
if sort == "contacted":
query = query.order_by(desc(Entity.contact_count))
else:
query = query.order_by(desc(Entity.discovered_at))
query = query.limit(min(limit, 200))
entities = (await db.execute(query)).scalars().all()
discoverers = {
user.id: user.username
for user in (
await db.execute(
select(User).where(
User.id.in_({e.discovered_by for e in entities if e.discovered_by})
)
)
).scalars()
}
return {
"entities": [
_entity_card(e, discoverers.get(e.discovered_by)) for e in entities
]
}
@router.get("/codex/{entity_id}")
async def get_entity(entity_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
entity = await db.get(Entity, entity_id)
if entity is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such spirit in the codex")
discoverer = None
discoverer_entry = None
if entity.discovered_by:
user = await db.get(User, entity.discovered_by)
if user is not None:
discoverer = user.username
discoverer_entry = _hunter_entry(
user.username,
getattr(user, "display_name", None),
getattr(user, "avatar_form", None),
getattr(user, "avatar_hue", None),
getattr(user, "profile_public", None),
0,
None,
)
sightings = await db.scalar(
select(func.count(EntitySighting.id)).where(EntitySighting.entity_id == entity.id)
)
roster, total_encounters = await _encounters(db, entity.id)
card = _entity_card(entity, discoverer)
card["persona"] = entity.persona
card["voice"] = entity.voice_profile
card["sightings"] = sightings or 0
# The summoner, with enough to render a glyph and decide on a link.
# `times_contacted`/`last_seen` come from their roster row when they
# have one — discovery alone doesn't imply a surviving sighting row.
if discoverer_entry is not None:
for hunter in roster:
if hunter["username"] == discoverer_entry["username"]:
discoverer_entry["times_contacted"] = hunter["times_contacted"]
discoverer_entry["last_seen"] = hunter["last_seen"]
break
card["discoverer"] = discoverer_entry
card["discoverer_public"] = (
discoverer_entry["public"] if discoverer_entry else False
)
card["encounters"] = roster
card["total_encounters"] = total_encounters
return card
@router.get("/stats")
async def veil_stats(db: AsyncSession = Depends(get_db)):
"""Live counters for the landing page's 'veil activity' ticker."""
return {
"entities": await db.scalar(select(func.count(Entity.id))) or 0,
"sessions": await db.scalar(select(func.count(ContactSession.id))) or 0,
"utterances": await db.scalar(
select(func.count(Event.id)).where(Event.kind == "utterance")
)
or 0,
"anomalies": await db.scalar(
select(func.count(Event.id)).where(Event.kind == "anomaly")
)
or 0,
}