The worst bug of the session. tests/conftest.py built its engine from settings.database_url — the live database — and an autouse fixture calls drop_all() before EVERY test. So every backend run silently annihilated the real install: accounts, discovered spirits, Ghost Logs, devices, all of it. Found it because /sitemap.xml listed zero entities minutes after I had watched live séances mint real ones. Tests now use TEST_DATABASE_URL, or `<configured-db>_test` derived from it, and refuse to start at all if that ever resolves back to the production URL — this box both serves the app and holds the repo, so "don't run tests in prod" is not a workable guard. Proven: inserted a canary row into production, ran 50 tests, canary survived. Before this it would have been dropped. Also in this commit: SEO (routes/seo.py, lib/pageMeta.ts) - Live /sitemap.xml generated from real entity rows, and /robots.txt, both registered BEFORE the SPA catch-all or they'd be served index.html. Crawlers are disallowed from /seance specifically because the open door provisions a guest on arrival — a crawler would fill the users table with wanderers who never existed. - Per-route <title>, description, canonical and JSON-LD. The Codex is the indexable asset here (every spirit is unique long-form prose) and all of it previously shared one static title, so entities competed with each other instead of ranking. Entities are marked up as fictional Persons so a rich result can never imply a record of a real dead human. - public_base_url setting: absolute URLs for crawlers can't be derived from the request, since behind the tunnel the app only sees an internal host. Camera channel, first half (lib/camera.ts, llm scry path) - OllamaClient.generate() now accepts `images`; the configured chat model (minicpm-v4.5:8b) is vision-capable, so the entity can speak about what the seeker's camera actually shows. Verified against a synthetic room image: it named the pale column and the small red cube, then misread them as oak in a farmhouse parlor — real perception, in character. - Frames are captured only on an explicit act, downscaled to 768px and JPEG-compressed, never stored, and the prompt forbids describing faces or guessing identity. CameraEye carries the same generation guard as the EVP listener so closing during the permission prompt can't leave the camera live after teardown. 338 backend tests pass; 375 frontend; i18n parity holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
5.8 KiB
Python
150 lines
5.8 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.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.seo import router as seo_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)
|
|
# Registered before the SPA catch-all below, or /robots.txt and
|
|
# /sitemap.xml would be served index.html instead.
|
|
app.include_router(seo_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)
|