GET /api/conditions surfaces what the backend already computes: moon phase, veil thinness, geomagnetic state — junk lon degrades to moon-only instead of a 422, missing NOAA data means fewer lines, never an error. VeilConditions renders it in the séance side column, polling every 10 min. ModeHint: one in-fiction line per mode after 15s of an unused sensor, dismissed forever via localStorage. Error copy in evp/radio now detect-and-redirects (mic denied -> 'the board needs no ear'; no WebUSB -> try EVP) instead of dead-ending. No geolocation prompt from the conditions strip — asking for location from a passive readout would be hostile; ?lon= stays supported for callers that have it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> # Conflicts: # frontend/src/pages/SeancePage.tsx
147 lines
5.6 KiB
Python
147 lines
5.6 KiB
Python
import asyncio
|
|
import contextlib
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import text
|
|
|
|
import app.models # noqa: F401 — registers models on Base.metadata before create_all
|
|
from app.config import settings
|
|
from app.db import Base, async_session_maker, engine
|
|
from app.routes.auth import router as auth_router
|
|
from app.routes.codex import router as codex_router
|
|
from app.routes.conditions import router as conditions_router
|
|
from app.routes.device import router as device_router
|
|
from app.routes.inventory import router as inventory_router
|
|
from app.routes.seances import router as seances_router
|
|
from app.routes.shop import router as shop_router
|
|
from app.session_cleanup import delete_expired_sessions
|
|
from app.ws import AUDIO_DIR
|
|
from app.ws import router as ws_router
|
|
|
|
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
|
|
|
SESSION_CLEANUP_INTERVAL_SECONDS = 30 * 60
|
|
|
|
|
|
async def _session_cleanup_loop() -> None:
|
|
"""Periodically sweeps expired auth_sessions rows so the table doesn't
|
|
grow forever — get_current_user already rejects expired sessions on
|
|
read, this just deletes the rows themselves."""
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS)
|
|
try:
|
|
async with async_session_maker() as db:
|
|
await delete_expired_sessions(db)
|
|
except Exception:
|
|
# A transient DB hiccup shouldn't kill the sweep loop —
|
|
# just try again next interval.
|
|
pass
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
# No Alembic in this repo — `create_all` never alters existing
|
|
# tables, so columns added to live models need a manual, idempotent
|
|
# migration here. Safe to run on every startup.
|
|
await conn.execute(text(
|
|
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
|
|
))
|
|
# Workstream C (character-depth-ghost-log spec) — missing from C's
|
|
# own commit, added by the integrator after Workstream B's report
|
|
# flagged that User.essence had a live model column and application
|
|
# code (auth/me, inventory purchases, summon trickle) but no
|
|
# migration, which would have broken on the real production DB.
|
|
await conn.execute(text(
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS essence INTEGER NOT NULL DEFAULT 0"
|
|
))
|
|
# Workstream B (character-depth-ghost-log spec).
|
|
await conn.execute(text(
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS favor DOUBLE PRECISION NOT NULL DEFAULT 0.0"
|
|
))
|
|
await conn.execute(text(
|
|
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
|
|
))
|
|
# Defense-in-depth: purchase_unlock() already enforces one row per
|
|
# (user, unlock_key) via a row-locked check-then-insert, so this
|
|
# constraint should never actually find a conflict on a live DB.
|
|
# `ADD CONSTRAINT` has no IF NOT EXISTS form, so the guard is a
|
|
# catalog check instead — safe to run on every startup.
|
|
await conn.execute(text(
|
|
"DO $$ BEGIN "
|
|
"IF NOT EXISTS ("
|
|
" SELECT 1 FROM pg_constraint WHERE conname = 'uq_unlocks_user_key'"
|
|
") THEN "
|
|
" ALTER TABLE unlocks ADD CONSTRAINT uq_unlocks_user_key UNIQUE (user_id, unlock_key); "
|
|
"END IF; "
|
|
"END $$;"
|
|
))
|
|
cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
|
try:
|
|
yield
|
|
finally:
|
|
cleanup_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await cleanup_task
|
|
|
|
|
|
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
|
app.include_router(auth_router)
|
|
app.include_router(codex_router)
|
|
app.include_router(conditions_router)
|
|
app.include_router(device_router)
|
|
app.include_router(inventory_router)
|
|
app.include_router(seances_router)
|
|
app.include_router(shop_router)
|
|
app.include_router(ws_router)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz():
|
|
return {"status": "ok"}
|
|
|
|
|
|
app.mount(
|
|
"/assets",
|
|
StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False),
|
|
name="frontend-assets",
|
|
)
|
|
app.mount(
|
|
"/audio",
|
|
StaticFiles(directory=AUDIO_DIR, check_dir=False),
|
|
name="spirit-audio",
|
|
)
|
|
|
|
|
|
@app.get("/{full_path:path}")
|
|
async def serve_spa(full_path: str):
|
|
index_file = FRONTEND_DIST / "index.html"
|
|
if not index_file.exists():
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Frontend not built. Run `npm run build` in frontend/ and restart.",
|
|
)
|
|
|
|
# Vite emits root-level static files (favicon.ico, favicon.svg,
|
|
# apple-touch-icon.png, og-image.png, …) straight into dist/ rather than
|
|
# dist/assets/ — the only mounted static dir. Without this, requests for
|
|
# them fall through to the SPA fallback below and get index.html back
|
|
# instead of the actual file (browsers silently ignore it; social-media
|
|
# link-preview crawlers fetching og:image get an HTML page).
|
|
if full_path:
|
|
dist_root = FRONTEND_DIST.resolve()
|
|
candidate = (dist_root / full_path).resolve()
|
|
if candidate.is_file() and dist_root in candidate.parents:
|
|
return FileResponse(candidate)
|
|
|
|
return FileResponse(index_file)
|