Commit Graph

24 Commits

Author SHA1 Message Date
Indiana
c22b2f9c08 fix: reward guards now survive a reconnect
The three replay guards shipped earlier lived on the in-memory SeanceState,
which made them per-CONNECTION. I flagged that as an open residual at the
time: drop the socket and reconnect — or just open a second one — and the
client got a fresh empty guard and could be paid again for the same spirit.
summon_limiter bounded the rate of that, never the total.

`award_claims` is the durable form: one row per (seeker, presence,
milestone), with a UNIQUE constraint doing the actual enforcement. The claim
is a bare INSERT and losing the race raises IntegrityError, which is caught
and read as "already paid" — a check-then-insert would let two sockets both
read "unclaimed" and both pay. `crossing` is claimed by BOTH roads, so a
spirit crosses once whichever road arrives first.

Measured with the durable claim disabled: 5 reconnects paid 75 extra essence
on the ritual, 60 on a verdict, 140 on the passage, and two simultaneous
sockets paid 30 for one 15-essence ritual.

The four tests that were failing were the tests, not the guard. They compared
raw balances across reconnects, but re-opening a channel IS a summon, and
SUMMON_ESSENCE_TRICKLE is paid per summon by design (inventory.py:42, bounded
by summon_limiter rather than by any once-per-presence rule). The expected
trickle is now stated explicitly so the assertion speaks about the milestone
it is actually testing. Favor has no trickle, so it must not move at all —
asserted separately.

Anti-overshoot covered in both directions: a genuinely fresh presence still
pays in full across a reconnect, a corrected verdict still pays on a second
connection, and `test` stays freely repeatable since it never touches the
ledger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:37:32 +00:00
Indiana
00b0a17203 fix: verify all six unproven audit findings — four real, two not
The audit that produced these had every verifier agent die, so none were
confirmed. Checked each against the running system rather than guessing.

1. COOKIE Secure FLAG — REAL, fixed. The Cloudflare Tunnel runs OFF this box
   (observed source 10.30.20.67, 155 requests in the journal) and uvicorn
   only honours X-Forwarded-* from --forwarded-allow-ips, default 127.0.0.1.
   Proven by hitting the LAN IP with X-Forwarded-Proto: https and watching
   Secure vanish from Set-Cookie. Every internet visitor's session cookie
   was going out without it.
   Fixed in the unit drop-in with --proxy-headers and an allow-list scoped
   to the tunnel host — NOT "*", because trusting that header from anywhere
   would let a LAN client forge the IP the per-IP limiters key on. Verified
   both directions: trusted source + header gets Secure, plain LAN http
   correctly does not, and a spoof from an untrusted host is ignored.

2. DOUBLE GUEST ON REMOUNT — REAL but narrow, left alone. The guestAttempted
   ref already covers StrictMode's double-effect (refs survive it). The only
   hole is unmounting during the in-flight request, which needs navigating
   away and back inside ~200ms and costs one unused row. Not worth
   complicating the open door's happy path for.

3. SILENT REDIRECT WHEN RATE-LIMITED — REAL, fixed. A visitor whose guest
   provisioning was refused got bounced to /enter with no explanation — and
   at 5/hour/IP a household or cafe behind one NAT reaches that easily. The
   failure reason (the backend's own in-fiction line) now rides along in
   router state and /enter shows it, so nobody is silently handed a login
   form they never asked for.

4. RATE LIMITER KEYS NEVER EVICTED — REAL, fixed. defaultdict entries
   survived forever even once their hit list emptied. The open door made
   this materially worse: every visitor is now a real account, so every
   visitor permanently added a key across eleven limiter instances. Added an
   opportunistic sweep every 512 admitted calls — no background task, cost
   lands on whoever generates the load. Three tests; verified they catch it
   by disabling the sweep and watching one fail.

5. SUMMON RACE vs TELEMETRY — REAL, fixed. Nothing serialised summoning.
   _handle_anomaly checks `state.entity is None` then awaits a summon
   containing a multi-second LLM mint, and the ESP32's HTTP ingestion path
   calls _handle_anomaly on the SAME SeanceState — which is the entire point
   of the device integration. Both could pass the check: two entities
   minted, two essence credits, two item rolls, state.entity clobbered by
   whichever finished last. Now guarded by a per-session asyncio.Lock.

6. LEGACY ENTITIES STUCK AT DEFAULT TRAITS — mechanism REAL, zero rows
   affected here. The ALTER defaults traits to '{}' with no backfill and
   roll_traits only runs at mint, so a pre-migration spirit would read 0.5
   for everything — making `trust` always correct and `cross_over`
   unreachable. This install has 0 such rows. Added a signature-seeded
   backfill anyway, guarded to empty-traits rows so it can never touch a
   spirit that already has a real nature.

(A seventh claim from the same batch — that iOS EMF is silently dead — was
refuted earlier and deliberately left untouched.)

34 targeted tests pass; deployed and verified live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 03:13:46 +00:00
Indiana
bacfb852b8 feat: hunter profiles, ranks, whispers, and the encounter record
All five agents died mid-flight (three on session limits, two on 529s), but
their worktrees held real work — 17 files. Salvaged everything, wrote the
missing pieces, and finished the integration by hand.

PROFILES + RANK
User gains display_name, bio, gender, avatar_form, avatar_hue and
profile_public — all nullable, so every existing row including the guest
`wanderer-` accounts stays valid with no backfill. The avatar is procedural
(a GhostForm plus a hue, drawn by the same GhostGlyph that renders
entities): no uploads means no moderation surface, no EXIF and no blob
storage, and an `avatar_url` still slots in later without changing anything.

rank.py converts encounters, essence and favor into one "standing" currency
and maps it onto six one-word titles. An encounter is worth ten points to
ten essence's one, because contact is what the app is about — a seeker who
only buys unlocks climbs very slowly. Negative essence and favor floor at
zero rather than subtracting, so a bad judgment can never demote you: rank
is a record of what you have done. Level 1 costs exactly one encounter, so a
new hunter sees the bar move after their first séance.

Privacy invariants, verified live rather than assumed:
- `email` is returned by GET /api/profile/me and by nothing else. Confirmed
  against the running server: zero occurrences in both public payloads.
- A hidden profile 404s rather than 403s — confirming the account exists
  would leak exactly what hiding it was meant to prevent.

WHISPERS BETWEEN HUNTERS
Plain text, no attachments, no editing. Guests can RECEIVE but not send:
that gives registering a felt purpose beyond keeping a codex, and closes the
obvious spam vector since guest accounts are free and automatic. Verified
live: alice→bob delivers, a guest send returns 403, and a third party's
conversation list comes back empty — no cross-user leak.

Message bodies are rendered as text nodes, never as HTML, and wrap with
overflow-wrap:anywhere so a long unbroken string can't blow out the layout.

THE ENCOUNTER RECORD
The Codex already knew all of this — Entity.discovered_by has always been
recorded and every contact was already an entity_sightings row. Nobody ever
showed it. Now an entity page names its summoner and lists every hunter who
has met it. Hunters who opted out of a public profile are still COUNTED but
not linkable: an anonymous contact is still a contact, so a spirit's history
stays honest without exposing anyone.

Live on production data: Mabel Crump, discovered by Charly, 1 encounter;
Charly ranks channeler (level 2) from 5 real sightings — all computed from
data that was already sitting there.

385 frontend tests pass; i18n parity holds across both languages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 02:50:00 +00:00
Indiana
16c8bae00d 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>
2026-07-30 01:44:01 +00:00
Indiana
2a67684df4 chore: dead-code + duplicate sweep; fix moon-flaky re-contact test
Dead code: removed an unused `settings` import (main.py), an unused
`RITUAL_HOLD_MS` import (RitualPanel), and an unused `beforeEach`
(SigilDesigner test). The `_refs` keep-alive in sdr.ts is deliberate
(holds hardware-pass constants) and stays; the orphaned .evp-scope /
.radio-waterfall CSS was already removed in an earlier lint pass.

Duplicate: coldSpot.ts and baseline.ts each carried the same time-aware
EMA alpha formula. Extracted it as baseline.emaAlpha(dtMs, tauMs) and
pointed both at it. coldSpot's pure-function core is deliberately NOT
merged into the stateful ThresholdBaseline class — different contract
(immutable-state-threaded vs internal-threshold), and forcing them
together would be an overhaul that risks the tested cold-spot logic.

Determinism fix: test_familiar_presence_answers_again_on_a_known_channel
pinned RETURN_CHANCE=1.0 but not the sky. Since the astronomy wiring made
the real return chance RETURN_CHANCE*(1 - veil_thinness*PULL), and
veil_thinness reads the *actual current moon phase*, a full-moon test run
dragged the effective chance to ~0.55 and the test failed ~45% of the
time. Now also pins VEIL_THINNESS_PULL=0 to isolate re-contact from the
veil influence (which has its own tests). Verified 12/12 consecutive
passes; it was ~7/12 before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:14:39 +00:00
Indiana
0132c8a5bf feat: legibility — conditions, hints, kinder errors (Workstream B)
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
2026-07-28 19:19:44 +00:00
Indiana
eaa28b20e8 feat: legibility wave — veil conditions, first-run hints, kinder errors
Workstream B of the usability wave (#2 hints, #3 conditions, #4 errors):

- GET /api/conditions (new backend/app/routes/conditions.py): composes
  celestial veil_thinness with the cached NOAA Kp reading; lenient lon
  parsing (junk degrades to moon-only, never 422); geomagnetic may be
  null on a cold cache. Route tests stub the cache — no live NOAA calls.
- VeilConditions strip in the séance side column: moon glyph + phase,
  % lit, veil-thinness phrase, Kp line only when data exists. Polls
  every 10 min; renders nothing while loading; no error state.
- ModeHint: per-mode in-fiction one-liner after ~15s idle, suppressed
  once the mode's sensor runs this session, dismissal persisted in
  localStorage (qm_hint_<mode>). One mount line per panel.
- Error copy upgraded to detect-and-redirect: mic denied points at site
  settings and the ouija board/wire; WebUSB-unsupported suggests EVP.
- i18n en/es parity for every new string; coverage-check template
  domains extended for the new template-key call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:31:10 +00:00
Indiana
3b33b5d6f6 feat: presence beyond the tab — PWA manifest and the Ghost Log recap
Usability wave Workstream C (#5, #7):
- manifest.webmanifest with existing 192/512/180 icons, linked in
  index.html with theme-color aligned to #07070d; no service worker.
- GET /api/seances/recent: last 12 of the seeker's own séances with
  entity, per-kind event counts (one GROUP BY) and up to 3 spirit
  echoes (one windowed query) — no per-session N+1.
- /log Ghost Log page: cards with GhostGlyph, relative in-fiction
  timestamps, counts and echo lines; LOG link in the séance topbar;
  en/es i18n parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:19:55 +00:00
Indiana
f8ca4bbd9e fix: unbounded essence farming, WS crash, and two economy races
Unbounded essence/item farming: every other reward trigger (summon,
question, fragment) had both a per-user and per-IP limiter, but
ritual_start and judgment had none at all — and judgment has no
"already resolved" state either. A scripted client could replay
{"type":"judgment","verdict":"cross_over"} in a tight loop and mint
CROSS_OVER_ESSENCE (25) plus a 20% item roll every iteration, forever.
Same for ritual_start -> 4x ritual_step. Added ritual/judgment limiters
in both flavors, matching the existing pattern.

WS session crash: _handle_question did `if state.entity is None:
await _handle_summon(state)` then `assert state.entity is not None`.
_handle_summon returns early *without* setting state.entity when the
seeker is rate-limited, so the assert fired unhandled — and the message
loop only catches WebSocketDisconnect, so it killed the whole connection.
Reachable with no malice: click summon a few times impatiently, then ask a
question. Now returns cleanly (the rate_limited frame was already sent).

Essence double-spend: purchase_unlock() deliberately uses SELECT ... FOR
UPDATE to serialize concurrent purchases, but the three credit_essence
call sites in ws.py did an unlocked db.get() read-modify-write. An
unlocked read doesn't block on a row lock, so a reward computed from a
pre-purchase balance could be written after the purchase committed,
silently reverting the deduction — user keeps the unlock and the essence.
All three now lock the row the same way.

Entity mint collision: _summon does a racy check-then-insert against
Entity.signature and Entity.name, both DB-unique, with no IntegrityError
handling — a concurrent mint of the same signature crashed the session.
Forceable by a user with two accounts (anomaly frequency/magnitude are
client-controlled), and plausible without malice in wire mode, where
sample_network() reads host-wide /proc/net/dev counters so two idle
sessions genuinely measure the same traffic. Now retries once, which
re-runs the match against whatever the winner committed.

Also added a unique constraint on unlocks(user_id, unlock_key) as
defense-in-depth, with an idempotent catalog-guarded migration.

221 backend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:30:21 +00:00
Indiana
f09e077199 firmware: add I2S MEMS microphone driver; fix missing essence migration
Mic: confirmed via its pinout (L/R, WS, SCK, SD, VCC, GND) that the third
target module is a standard I2S digital MEMS mic (INMP441-family). Added
mems_mic.c/h using ESP-IDF's current driver/i2s_std.h API — reports RMS
audio level in dBFS as sensor_type "evp" rather than attempting on-device
voice-band FFT (the browser EVP mode's approach); the backend's existing
statistical anomaly detector handles spike detection from the raw level,
same as it already does for temperature/pressure/presence.

Also fixes a real gap Workstream B's report flagged: User.essence (a live
model column used throughout merged code — /auth/me, inventory purchases,
summon trickle) had no migration line in main.py's lifespan, which would
have broken on the actual production Postgres database.
2026-07-24 21:52:03 +00:00
Indiana
36c4a9e6e4 feat: ritual + judgment + favor + cross-over (Workstream B)
Implements Workstream B of the character-depth-ghost-log spec:
ritual_start/ritual_step/judgment WS handlers, the pure judgment.py
logic module, and the User.favor / Entity.at_peace columns + migration.

- app/judgment.py: pure ritual success roll (base 65%, floored at 30%,
  driven by an entity's power+deceptiveness difficulty), the "stuck
  spirit" cross_over rule (alignment >= 0.5 and volatility > 0.6, ~20%
  of entities), judgment correctness/favor-delta/essence-delta/
  consequence resolution for all four verdicts, favor clamping, the
  favor-to-trait-roll bias applied at mint time, and tell-line
  generation (opaque behavioral flavor text, never a raw stat).
- app/ws.py: wires ritual_start/ritual_step/judgment frames, emits
  ritual_complete/tell/judgment_result/item_drop per the spec's
  Contract; traits are added to serialize_entity for internal
  server-side use but stripped from the outbound `entity` frame via a
  new _public_entity helper so hidden ground truth never reaches the
  client outside ritual_complete; _summon excludes at-peace entities
  from signature re-contact and mints a fresh (salted-signature) entity
  instead; new entities' traits are nudged by the discovering user's
  favor before being persisted.
- models/user.py, models/entity.py, main.py: User.favor and
  Entity.at_peace columns plus their idempotent ADD COLUMN IF NOT
  EXISTS migration lines in lifespan, alongside the existing ones.
- tests/test_judgment.py, tests/test_ws_ritual_judgment.py: 56 new
  tests covering the ritual/judgment correctness matrix, favor
  clamping/bias, essence crediting, at_peace persistence + re-contact,
  and the entity-frame trait leak guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:27:30 +00:00
Indiana
ec3bdc086e Merge Workstream G: device pairing + ingestion + live broadcast
Resolved conflict in main.py: combined both workstreams' router imports
and registrations (device_router from G, inventory_router from C).
143/143 backend tests pass after cleaning stray pollution from an
earlier parallel workstream run against the shared test DB.
2026-07-24 14:57:10 +00:00
Indiana
f633408a16 Merge Workstream C: unlocks + inventory items + sigils + essence 2026-07-24 09:41:55 +00:00
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
Indiana
c88fbc843a feat: device pairing, telemetry ingestion, live dashboard WS (Workstream G)
Implements the backend half of the ESP32-P4 sensor node spec's pairing,
ingestion, and live-broadcast contract:

- New Device model (backend/app/models/device.py): id, user_id FK, name,
  token_hash (unique+indexed), created_at, last_seen_at. Reuses
  generate_session_token()/hash_token() from auth_session.py verbatim for
  the one-time raw pairing token / stored hash.
- POST /api/device, GET /api/device (session-cookie authenticated REST
  pairing endpoints) and POST /api/device/telemetry (device bearer-token
  authenticated ingestion, per-device rate limited, 16KB body cap, 64
  reading cap, strict shape validation — never a 500 on garbage input) in
  backend/app/routes/device.py.
- /ws/device-feed live dashboard WS (qm_session cookie authenticated),
  fanning out ingested readings to the owning user's connected dashboard
  sockets via an in-process dict[user_id, connections] registry, each with
  its own send-queue + single sender task (mirrors app.ws's
  SeanceState/_sender convention).
- last_seen_at updates on every successful ingestion.
- _process_reading(device, reading) left as an explicit no-op handoff point
  for Workstream K's summon-pipeline integration.

Backend suite: 102 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 01:12:21 +00:00
Indiana
e5bc253105 feat: add hidden entity traits (Workstream A) + idempotent migration
Entity.traits (alignment/power/volatility/deceptiveness, 0.0-1.0 each) is
rolled once at mint time in entities.py, seeded from the entity's
signature via random.Random(f"traits:{signature}") — a separate rng
namespace from normalize_profile's existing "norm:" rng, and never fed
into mint_prompt, so persona text stays fully decoupled from ground
truth. normalize_profile now includes "traits" in its returned dict;
fallback_profile inherits it for free since it already delegates to
normalize_profile.

Adds the new JSONB column to the Entity model (default {}) and the
idempotent `ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits ...`
migration line to main.py's lifespan, per the live-Postgres migration
convention this spec introduces (no Alembic in this repo).

Tests cover trait value ranges, signature-determinism, and
persona/trait independence (same persona template pairs with a wide
spread of alignment rolls across signatures), plus a regression check
that mint_prompt's signature never grows a traits parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:15:25 +00:00
Indiana
761d6171b5 fix: actually strip stale SESSION_SECRET mentions from README
The gap-g-readme merge commit (57a8914) staged this fix but never
re-staged it after editing, so the merge landed with the pre-fix content —
the working tree had the correction but git didn't. No functional change,
just closing the gap between what was intended and what was committed.
2026-07-23 03:32:21 +00:00
Indiana
7f0775c8c3 feat: periodically sweep expired auth sessions
auth_sessions rows were never deleted after expiry, only rejected
on read. Adds a background sweep (every 30 min) in the app lifespan,
plus a tested pure delete_expired_sessions() function.
2026-07-21 03:43:34 +00:00
Indiana
0756e677b9 Add EMF field mode, Armory shop/waitlist, and ambient haunting layer
Three self-contained features, verified complete and cross-wired
end-to-end (audited: backend 54/54 tests, frontend 110/110 tests,
tsc --noEmit clean, i18n coverage script clean):

- EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing,
  a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own
  fragment prompt persona and full frontend gauge UI.
- Armory (shop/waitlist): pre-order capture page for the future
  Ultimate Quantum Box hardware line, rate-limited public endpoint,
  explicitly no payment collection.
- Haunting layer: ambient possession effects (dread-bed audio, title
  glitching, idle-paced whispers/manifests), respects
  prefers-reduced-motion, mounted once at the app root.

Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID,
explicit selectConfiguration, isSecureContext gate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
2026-07-21 02:28:14 +00:00
Indiana
b9110f45de feat: spirit engine — seance WS, entity minting/Codex, Piper TTS voices, wire telemetry
- WS /ws/session: modes, summon, anomaly fragments, streaming direct contact,
  passive wire-ghost ambient loop, per-user rate limits, event transcript
- entities: anomaly-signature fingerprinting, LLM minting + procedural
  fallback, Codex matching with contact counts and sightings
- tts: 8 local Piper voices (EN/ES), per-entity voice profiles, numpy
  effects chain (pitch/rate/bitcrush/echo/static)
- llm: streaming client, submit_stream in bounded queue, SpiritService
  with offline fallbacks for every channel
- routes: public /api/codex, /api/codex/{id}, /api/stats; /audio static mount
- models: Entity, EntitySighting, Event, ContactSession(entity_id, language)
2026-07-20 20:13:28 +00:00
Indiana
cd9fc49eed fix: prevent app crash when frontend build is missing
StaticFiles defaults to check_dir=True, which raises at import time if
frontend/dist/assets is missing on restart — taking down /healthz and
/auth/* along with the frontend. Pass check_dir=False so the mount never
crashes the app, and make the SPA fallback return a clear 503 instead of
an unhandled 500 when index.html is absent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
2026-07-20 17:52:43 +00:00
Indiana
bd0c162172 feat: serve built frontend from backend with SPA fallback 2026-07-20 17:48:07 +00:00
Indiana
cfffc7ff59 feat: add user model and registration endpoint 2026-07-20 15:14:31 +00:00
Indiana
1736dd021b chore: scaffold backend venv, health check, systemd unit 2026-07-20 14:52:53 +00:00