Files
qtalker---/backend/app/routes/auth.py
Indiana ff68379772 feat: unlocks, inventory items, sigils, drops, and essence (Workstream C)
Implements the backend REST surface and WS wiring for
docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md's
Workstream C:

- New models: UnlockRecord (unlocks), InventoryItem (inventory_items),
  Sigil (sigils) — brand-new tables, picked up by main.py's existing
  create_all.
- New app/inventory.py: unlock price table, item drop table/odds,
  essence economy constants, sigil design validation, and an atomic
  (row-locked) purchase_unlock() that guards against double-spend races.
- New app/routes/inventory.py: GET unlocks/items/sigils, POST sigils
  (validates the placeholder {points, rune} shape, points capped at 12),
  POST unlocks/{unlock_key} (402 on insufficient essence, 404 on unknown
  key, idempotent re-buy).
- GET /auth/me now includes unlocks: list[str] and essence: int.
- ws.py: wires essence trickle + item_drop rolls into the one trigger
  point that exists in this worktree today (_handle_summon, covering
  every successful summon plus high-rarity summons); the other two
  contract trigger points (correct judgment, successful ritual) belong
  to Workstream B's not-yet-landed ritual/judgment WS handlers, which
  should call app.inventory's same helpers once they land.
- User.essence: int added (Workstream B owns this column per the spec;
  added here per orchestrator instruction so this workstream is
  independently testable — merge controller reconciles the duplicate
  edit).

Also fast-forwarded this worktree's branch onto master (it had fallen
behind several commits) so the files this workstream depends on
(shop.py, ws.py, entities.py, etc.) were actually present to build
against.

Tests: 109 passed (drop-roll statistical sanity with seeded RNG,
inventory/sigil CRUD, purchase success/insufficient-funds/idempotency/
unknown-key paths, /auth/me shape, ws summon-trickle and item-drop
wiring).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 03:02:20 +00:00

106 lines
3.6 KiB
Python

from datetime import datetime, timezone
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
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.models.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token
from app.models.unlock import UnlockRecord
from app.models.user import User
from app.schemas import LoginRequest, RegisterRequest, UserOut
from app.security import hash_password, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
_DUMMY_PASSWORD_HASH = hash_password("dummy-password-for-timing-safety")
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
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")
user = User(
username=payload.username,
password_hash=hash_password(payload.password),
email=payload.email,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/login", response_model=UserOut)
async def login(
payload: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
user = await db.scalar(select(User).where(User.username == payload.username))
if user is None:
verify_password(payload.password, _DUMMY_PASSWORD_HASH)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
if not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
raw_token, token_hash = generate_session_token()
session = AuthSession(
user_id=user.id,
token_hash=token_hash,
expires_at=datetime.now(timezone.utc) + SESSION_TTL,
)
db.add(session)
await db.commit()
# The app is reached two ways: https via the Cloudflare Tunnel (Secure
# required) and plain http on the LAN (a Secure cookie would be dropped
# by the browser entirely, silently breaking the séance socket).
response.set_cookie(
SESSION_COOKIE_NAME,
raw_token,
httponly=True,
samesite="lax",
secure=request.url.scheme == "https",
max_age=int(SESSION_TTL.total_seconds()),
)
return user
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
response: Response,
qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
db: AsyncSession = Depends(get_db),
):
if qm_session is not None:
token_hash = hash_token(qm_session)
session = await db.scalar(select(AuthSession).where(AuthSession.token_hash == token_hash))
if session is not None:
await db.delete(session)
await db.commit()
response.delete_cookie(
SESSION_COOKIE_NAME,
httponly=True,
samesite="lax",
secure=request.url.scheme == "https",
)
@router.get("/me", response_model=UserOut)
async def me(
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(UnlockRecord.unlock_key).where(UnlockRecord.user_id == user.id)
)
unlock_keys = [row[0] for row in result.all()]
return UserOut(
id=user.id, username=user.username, essence=user.essence, unlocks=unlock_keys
)