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>
106 lines
3.6 KiB
Python
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
|
|
)
|