fix: the test suite was destroying the production database

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>
This commit is contained in:
Indiana
2026-07-30 01:44:01 +00:00
parent 79401338d5
commit 16c8bae00d
13 changed files with 633 additions and 1 deletions

145
frontend/src/lib/camera.ts Normal file
View File

@@ -0,0 +1,145 @@
// The camera as a channel — letting the dead look through the seeker's lens.
//
// Why this is different from every other channel here: the microphone, the
// magnetometer, the RTL-SDR and the ESP32 all produce *numbers* that the
// app interprets. A camera produces a scene, and the chat model already
// configured for this install (minicpm-v4.5:8b) is a vision model — so the
// entity can be shown the room and speak about what is actually in it.
// Nothing is simulated: the frame is a real photograph of wherever the
// seeker is standing.
//
// PRIVACY, and why this module is shaped the way it is:
// - Frames are only ever captured on an explicit, deliberate act (the
// seeker pressing "let it look"). There is no timer, no background
// loop, and no way for this module to grab a frame on its own.
// - A frame is downscaled and JPEG-compressed before it leaves the
// browser, both to keep the request small and to strip incidental
// detail the model does not need.
// - Nothing is stored. The data URL is handed to the caller, sent once,
// and dropped; there is no cache and no history.
// - The live preview never leaves the page unless a frame is captured,
// so "camera on" and "the ghost saw something" stay separate states.
//
// Platform reality: getUserMedia needs a secure context (https or
// localhost). It works on iOS Safari, unlike WebUSB/Web Bluetooth — so this
// is one of the richest channels available on an iPhone.
/** Longest edge of a captured frame, px. Vision models see plenty at this
* size, and it keeps a base64 payload well inside a normal request body —
* a full 1080p still would be megabytes of JSON. */
export const CAPTURE_MAX_EDGE = 768
/** JPEG quality for captures. 0.72 keeps faces and text legible while
* roughly halving the payload versus 0.9. */
export const CAPTURE_QUALITY = 0.72
export type CameraFailure = 'denied' | 'insecure' | 'absent' | 'busy' | 'unknown'
export function isSupported(): boolean {
return (
typeof navigator !== 'undefined' &&
typeof navigator.mediaDevices?.getUserMedia === 'function'
)
}
/** Maps a getUserMedia rejection to a cause, so the UI can say something
* true instead of always blaming the seeker for refusing. Mirrors the
* classification the EVP panel uses. */
export function classifyFailure(err: unknown): CameraFailure {
if (!isSupported()) return 'insecure'
const name = err instanceof DOMException ? err.name : ''
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') return 'denied'
if (name === 'SecurityError') return 'insecure'
if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'absent'
if (name === 'NotReadableError' || name === 'AbortError') return 'busy'
return 'unknown'
}
/**
* Owns one camera stream and can capture single stills from it.
*
* Deliberately does NOT own a <video> element: the caller supplies one so
* React keeps control of the DOM. This class only manages the stream and
* the canvas used for capture.
*/
export class CameraEye {
private stream: MediaStream | null = null
private canvas: HTMLCanvasElement | null = null
/** Bumped by open() and close(); lets an open() suspended on the
* permission prompt detect that it was abandoned. Same hazard the EVP
* listener had: the stream is assigned only after the await, so a close()
* during the prompt would release nothing and the camera would go live
* *after* teardown, leaving the recording light on. */
private generation = 0
get isOpen(): boolean {
return this.stream !== null
}
/** Requests the camera and attaches it to `video`. Throws on refusal;
* use classifyFailure() on the error. */
async open(video: HTMLVideoElement, facing: 'user' | 'environment' = 'environment'): Promise<void> {
if (this.stream) return
if (!isSupported()) throw new DOMException('no camera api', 'SecurityError')
const generation = ++this.generation
const stream = await navigator.mediaDevices.getUserMedia({
// `ideal` rather than `exact`: a laptop has no environment camera, and
// an exact constraint would fail outright instead of falling back to
// the only lens available.
video: { facingMode: { ideal: facing } },
audio: false,
})
if (generation !== this.generation) {
stream.getTracks().forEach((t) => t.stop())
return
}
this.stream = stream
video.srcObject = stream
// iOS Safari will not start a stream without this combination, and
// refuses to autoplay with sound even though we requested none.
video.muted = true
video.playsInline = true
await video.play().catch(() => undefined)
}
/**
* Grabs one frame as a JPEG data URL, or null if the stream isn't ready.
*
* Only ever called from an explicit seeker action — see the module note.
*/
capture(video: HTMLVideoElement): string | null {
if (!this.stream) return null
const w = video.videoWidth
const h = video.videoHeight
if (!w || !h) return null // metadata hasn't arrived yet
const scale = Math.min(1, CAPTURE_MAX_EDGE / Math.max(w, h))
const canvas = (this.canvas ??= document.createElement('canvas'))
canvas.width = Math.max(1, Math.round(w * scale))
canvas.height = Math.max(1, Math.round(h * scale))
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
try {
return canvas.toDataURL('image/jpeg', CAPTURE_QUALITY)
} catch {
// Tainted canvas shouldn't be possible for a same-origin camera
// stream, but a failed capture must not take the séance down.
return null
}
}
close(): void {
this.generation++
this.stream?.getTracks().forEach((t) => t.stop())
this.stream = null
}
}
/** Strips the `data:image/jpeg;base64,` prefix — Ollama wants raw base64. */
export function toBase64(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
return comma === -1 ? dataUrl : dataUrl.slice(comma + 1)
}

View File

@@ -0,0 +1,111 @@
// Per-route document metadata.
//
// This is a client-rendered SPA, so every route ships the same static
// <title>, description and canonical from index.html. That is fine for
// humans and bad for search: the genuinely valuable, unique content here is
// the Codex — every spirit is a distinct, LLM-written biography — and a
// crawler that renders the page still sees one shared title for all of
// them, so they compete with each other instead of ranking.
//
// Google does execute JS and picks up title/meta/canonical mutated after
// load, so patching the head per route is enough to make each entity page
// its own result. This deliberately stays a tiny imperative helper rather
// than pulling in react-helmet: three tags, no provider, no dependency.
//
// Not a substitute for SSR. If indexing ever needs to be bulletproof
// (crawlers that don't run JS, richer previews), the honest fix is
// prerendering /codex/:id server-side — this closes most of the gap for a
// fraction of the work.
import { useEffect } from 'react'
const SITE_NAME = 'Quantumancy'
/** Must match backend Settings.public_base_url — absolute canonical URLs
* have to point at the public origin, not whatever host served the app. */
export const PUBLIC_ORIGIN = 'https://spirit.thetempleofdoom.com'
function setMeta(selector: string, attr: string, value: string): void {
let el = document.head.querySelector<HTMLMetaElement>(selector)
if (!el) {
el = document.createElement('meta')
const [, name] = selector.match(/\[(?:name|property)="([^"]+)"\]/) ?? []
if (!name) return
el.setAttribute(selector.includes('property=') ? 'property' : 'name', name)
document.head.appendChild(el)
}
el.setAttribute(attr, value)
}
function setCanonical(href: string): void {
let link = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]')
if (!link) {
link = document.createElement('link')
link.rel = 'canonical'
document.head.appendChild(link)
}
link.href = href
}
export type PageMeta = {
/** Page-specific part of the title; SITE_NAME is appended. Omit on the
* landing page, which owns the bare brand title. */
title?: string
description?: string
/** Path only, e.g. "/codex/abc". Resolved against PUBLIC_ORIGIN. */
path?: string
/** JSON-LD to publish for this page, if any. */
structuredData?: Record<string, unknown>
}
const STRUCTURED_DATA_ID = 'qm-structured-data'
function setStructuredData(data: Record<string, unknown> | undefined): void {
const existing = document.getElementById(STRUCTURED_DATA_ID)
if (!data) {
existing?.remove()
return
}
const script =
(existing as HTMLScriptElement | null) ?? document.createElement('script')
script.id = STRUCTURED_DATA_ID
script.setAttribute('type', 'application/ld+json')
script.textContent = JSON.stringify(data)
if (!existing) document.head.appendChild(script)
}
/**
* Applies page metadata for as long as the component is mounted.
*
* Nothing is restored on unmount: the next route's own hook overwrites it,
* and restoring would briefly flash the previous page's title during
* navigation. Values are stringified defensively because entity personas
* come from an LLM and can contain anything.
*/
export function usePageMeta({ title, description, path, structuredData }: PageMeta): void {
// Callers build structuredData inline, so it is a fresh object on every
// render. Depending on it directly would re-run this effect (and rewrite
// the head) on every render; serializing gives it value semantics.
const structuredKey = structuredData ? JSON.stringify(structuredData) : ''
useEffect(() => {
document.title = title ? `${title} · ${SITE_NAME}` : SITE_NAME
if (description) {
const clean = description.replace(/\s+/g, ' ').trim().slice(0, 300)
setMeta('meta[name="description"]', 'content', clean)
setMeta('meta[property="og:description"]', 'content', clean)
setMeta('meta[name="twitter:description"]', 'content', clean)
}
const ogTitle = title ? `${title} · ${SITE_NAME}` : SITE_NAME
setMeta('meta[property="og:title"]', 'content', ogTitle)
setMeta('meta[name="twitter:title"]', 'content', ogTitle)
if (path) {
const url = `${PUBLIC_ORIGIN}${path}`
setCanonical(url)
setMeta('meta[property="og:url"]', 'content', url)
}
setStructuredData(structuredData)
// structuredKey stands in for structuredData; see above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title, description, path, structuredKey])
}

View File

@@ -4,6 +4,7 @@
import { useEffect, useState, type CSSProperties } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { PUBLIC_ORIGIN, usePageMeta } from '../lib/pageMeta'
import type { CodexEntityDetail } from '../lib/types'
import './CodexEntityPage.css'
@@ -38,6 +39,30 @@ export function CodexEntityPage() {
const { id } = useParams<{ id: string }>()
const [state, setState] = useState<DetailState>({ status: 'loading' })
// Every spirit is a distinct, unique page — give each one its own title,
// description and canonical URL so they rank individually instead of all
// sharing the app's single static title. The persona doubles as the
// description: it is the actual unique prose a searcher would be looking
// for. Marked up as a fictional Person so previews and rich results can
// never imply this is a record of a real dead human.
const detail = state.status === 'ready' ? state.entity : null
usePageMeta({
title: detail ? `${detail.name} — ${detail.epithet}` : undefined,
description: detail?.persona,
path: id ? `/codex/${id}` : undefined,
structuredData: detail
? {
'@context': 'https://schema.org',
'@type': 'Person',
name: detail.name,
alternateName: detail.epithet,
description: detail.persona,
additionalType: 'https://schema.org/FictionalCharacter',
url: `${PUBLIC_ORIGIN}/codex/${id}`,
}
: undefined,
})
useEffect(() => {
if (!id) {
setState({ status: 'notfound' })

View File

@@ -4,6 +4,7 @@
import { useEffect, useState, type CSSProperties } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { usePageMeta } from '../lib/pageMeta'
import type { CodexEntity, CodexListResponse, Rarity, SortOrder } from '../lib/types'
import './CodexPage.css'
@@ -25,6 +26,12 @@ function formatDate(iso: string, locale: string): string {
export function CodexPage() {
const { t, i18n } = useTranslation()
usePageMeta({
title: 'The Codex of Contacted Spirits',
description:
'Every spirit ever reached through Quantumancy — names, epithets, and the lives they left unfinished, recorded as each was contacted.',
path: '/codex',
})
const [rarity, setRarity] = useState<RarityFilter>('all')
const [sort, setSort] = useState<SortOrder>('recent')
const [entities, setEntities] = useState<CodexEntity[]>([])

View File

@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../state/auth'
import { GhostGlyph } from '../components/GhostGlyph'
import type { CodexEntity, CodexListResponse, Mode, Stats } from '../lib/types'
import { PUBLIC_ORIGIN, usePageMeta } from '../lib/pageMeta'
import './LandingPage.css'
const STATS_REFRESH_MS = 15_000
@@ -192,6 +193,24 @@ export function LandingPage() {
const { user } = useAuth()
const navigate = useNavigate()
// The landing page keeps the bare brand title (no suffix) but still needs
// a canonical URL, and WebApplication markup so a search result can show
// what this actually is rather than guessing from the copy.
usePageMeta({
path: '/',
structuredData: {
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: 'Quantumancy',
url: PUBLIC_ORIGIN,
applicationCategory: 'EntertainmentApplication',
operatingSystem: 'Any modern browser',
description:
"A self-hosted séance. Talk to spirits through your microphone, an RTL-SDR dongle, your network's jitter, your phone's motion sensors, or a paired ESP32 device — a locally-run LLM gives each real anomaly a voice.",
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
},
})
const [stats, setStats] = useState<Stats | null>(null)
const [featured, setFeatured] = useState<CodexEntity[] | null>(null)
const [taglineIdx, setTaglineIdx] = useState(0)