- 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>
225 lines
7.8 KiB
Python
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,
|
|
}
|