# openalice-persona

> OpenAlice Persona — Rust avatar runtime + Next.js renderer: VRM avatars, pose WS, lip-sync, BGM, spatial audio, lounge chat, AI moderation, YouTube OAuth, and Lyria 3 music gen.

## At a glance
| field | value |
|---|---|
| kind | `service` |
| priority | `—` |
| default branch | `main` |
| last activity | 2026-07-06T08:44:01+00:00 |
| features catalogued | **304** |
| runtime deps declared | **9** |

## Manifest
`.atlas-deps.yml` — parsed cleanly: **yes**

### Runtime deps
- **`openalice-auth`** · `shared-jwt` · via `SHARED_JWT_SECRET` — Verify oa_session JWTs (HS256, issuer=openalice-auth) on every authenticated API route.
- `elevenlabs` (external) · `http` · via `ELEVENLABS_API_KEY` · _optional_ — ElevenLabs v3 TTS API for server-side speech synthesis with emotion injection (POST /v1/text-to-speech/:voice_id/stream).
- `google-oauth` (external) · `http` · via `GOOGLE_OAUTH_CLIENT_ID` · _optional_ — Google OAuth 2.0 for YouTube account linking — token exchange, force-refresh, and revoke flows in youtube_oauth.rs / youtube_streamkey.rs.
- **`openalice`** · `http` · via `NEXT_PUBLIC_AGENT_URL` · _optional_ — persona-web points its AI chat stream at the main openalice agent (POST /v1/chat/stream) for SSE-streamed assistant replies.
- **`openalice-cdn`** · `http` · via `CDN_BASE_URL` · _optional_ — Serve uploaded audio tracks, VRM files, fonts, and sounds; CDN_BASE_URL prefixes all public asset URLs returned by the persona API.
- **`openalice-chat-bridge`** · `http` · via `CHAT_BRIDGE_URL` · _optional_ — YouTube proxy routes (youtube_proxy.rs) forward moderation actions (delete, ban, timeout) to chat-bridge's internal REST endpoints.
- **`openalice-radio`** · `ws` · via `DOMAIN` · _optional_ — radio_bridge subscribes to wss://radio.${DOMAIN}/v1/radio/channels/:slug/subscribe and re-emits now_playing frames onto the PersonaEvent SSE bus when bgm_source=radio:<slug>.
- **`openalice-rt`** · `ws` · via `RT_URL` · _optional_ — Publish persona state mutations (emotion, animation, scene) to the real-time state bus after each agent reply.
- `openrouter` (external) · `http` · via `OPENROUTER_API_KEY` · _optional_ — OpenRouter /api/v1/chat/completions to drive Google Lyria 3 Pro music gen ($0.08/song, model=google/lyria-3-pro-preview).

### Consumed env
- `DATABASE_URL`
- `SHARED_JWT_SECRET`
- `PERSONA_JWT_SECRET`
- `OA_SECRETS_MASTER_KEY`
- `INTERNAL_SECRET`
- `INTERNAL_BROADCAST_SECRET`
- `PERSONA_PORT`
- `PERSONA_BIND`
- `PERSONA_PUBLIC_BASE_URL`
- `PERSONA_PUBLIC_URL`
- `PERSONA_API_KEY`
- `PERSONA_ADMIN_TOKEN`
- `PERSONA_CORS_ORIGINS`
- `PERSONA_RATE_LIMIT`
- `PERSONA_CHAT_RATE_LIMIT`
- `PERSONA_DEV`
- `OA_ENV`
- `PERSONA_AUDIO_DIR`
- `PERSONA_VRM_DIR`
- `PERSONA_FONT_DIR`
- `PERSONA_SOUND_DIR`
- `PERSONA_DEFAULT_VRM_URL`
- `PERSONA_MAX_ROOMS`
- `PERSONA_EVENT_CHANNEL_CAPACITY`
- `PERSONA_ARCHIVE_AFTER_DAYS`
- `PERSONA_ARCHIVE_INTERVAL_HOURS`
- `PERSONA_ARCHIVE_KEEP_RECENT`
- `PERSONA_ARCHIVE_OVER_COUNT`
- `ELEVENLABS_API_KEY`
- `ELEVENLABS_VOICE_ID`
- `ELEVENLABS_MODEL_ID`
- `OPENROUTER_API_KEY`
- `GOOGLE_OAUTH_CLIENT_ID`
- `GOOGLE_OAUTH_CLIENT_SECRET`
- `OPENAI_API_KEY`
- `LLM_API_KEY`
- `LLM_BASE_URL`
- `LLM_MODEL`
- `STT_PROVIDER`
- `OA_AI_MOD_AGENT_MOCK`
- `RT_URL`
- `CDN_BASE_URL`
- `CHAT_BRIDGE_URL`
- `STT_URL`
- `AI_MOD_AGENT_URL`
- `DOMAIN`
- `OA_VOICE_CACHE_MAX_ENTRIES`
- `OA_VOICE_CACHE_TTL_DAYS`
- `OA_VOICE_MODULATE_EVERY`
- `OA_IDLE_TICK_INTERVAL_SECS`
- `NEXT_PUBLIC_PERSONA_URL`
- `NEXT_PUBLIC_PERSONA_PUBLIC_URL`
- `NEXT_PUBLIC_AGENT_URL`
- `NEXT_PUBLIC_RT_URL`
- `NEXT_PUBLIC_DEFAULT_DOMAIN`
- `NEXT_PUBLIC_DEFAULT_VRM_URL`
- `NEXT_PUBLIC_A2A_URL`
- `NEXT_PUBLIC_A2A_API_KEY`
- `NEXT_PUBLIC_DEBUG`
- `NEXT_PUBLIC_OA_LOG_LEVEL`
- `NEXT_PUBLIC_VOICE_URL`
- `AGENT_URL`
- `A2A_URL`
- `YOUTUBE_OAUTH_CLIENT_ID`
- `YOUTUBE_OAUTH_CLIENT_SECRET`

### Owns tables
- `agent_overrides`
- `agent_soundboard_settings`
- `ai_mod_feedback`
- `ai_mod_settings`
- `audio_tracks`
- `chat_messages`
- `command_log`
- `fonts`
- `idle_config`
- `lounge_bans`
- `lounge_messages`
- `lounge_messages_archive`
- `lounge_mutes`
- `lounge_reports`
- `lounge_reports_archive`
- `lounge_room_moderators`
- `lounge_room_settings`
- `lounge_room_settings_history`
- `mod_actions_log`
- `music_gen_budgets`
- `music_generation_jobs`
- `oauth_audit_log`
- `persona_state`
- `polls`
- `poll_votes`
- `redemption_triggers`
- `room_participants`
- `rooms`
- `sessions`
- `sounds`
- `static_mod_rules`
- `usage_log`
- `vrms`
- `world_configs`
- `youtube_broadcasts`
- `youtube_mod_policies`
- `youtube_oauth_tokens`

### Events published
- `persona.emotion_changed`
- `persona.expression_changed`
- `persona.animation_started`
- `persona.scene_changed`
- `persona.voice_queued`
- `persona.voice_finished`
- `persona.text_displayed`
- `persona.look_changed`
- `persona.state_changed`
- `room.chat`
- `room.voice`
- `radio.now_playing`

### Exposes
- `docker_image` — openalice-persona-persona-server
- `docker_image` — openalice-persona-persona-web
- `http_endpoint` — https://persona.blal.pro/persona/*
- `ws_endpoint` — wss://persona.blal.pro/persona/rooms/:id/ws
- `sse_endpoint` — https://persona.blal.pro/persona/events
- `npm_package` — @openalicelabs/persona-engine
- `npm_package` — @openalicelabs/persona-multiplayer
- `npm_package` — @openalicelabs/persona-core
- `npm_package` — @openalicelabs/persona-ui

## Env drift

**Code reads, manifest omits (3):**
- `PERSONA_RADIO_BASE`
- `PERSONA_RADIO_DOMAIN`
- `PERSONA_TTS_STREAMING`

## Live reachability
- last probe: **down** · `https://persona.blal.pro` · HTTP 404 · 25ms
- uptime 24h: **0.0%**

## Features (304)

### persona/a2a
- **`persona.a2a.agent-card`** · _ga_ — GET /persona/card — A2A Agent Card describing persona's capabilities (VRM avatar + emotions + animations + scenes + voice + lipsync, plus audience-engagement skills — polls, channel-point redemption triggers, and the radio/BGM bridge). Discovery payload for upstream A2A consumers.  
  *server/src/persona_routes.rs:163*

### persona/agent
- **`persona.agent.bridge`** — Routes chat messages to registered agent URLs via AI-SDK SSE streaming. Builds last-20-message history, POSTs to /v1/chat/stream, saves the assistant reply to DB, broadcasts to room via PersonaEngine, detects emotion for SetEmotion command, and publishes state updates to the RT service. Supports multi-agent rooms via per-participant agent_url. Agent Router Bridge — routes chat messages to registered agents. When a user sends a message in a room, this module: 1. Queries DB for agent participants with `agent_url` set. 2. Builds chat history from the last 20 messages. 3. POSTs to agent's /v1/chat/stream (AI SDK format). 4. Parses the SSE stream response. 5. Saves the response to DB as an assistant message. 6. Broadcasts the response to the room via persona engine. 7. Detects emotion and sends SetEmotion command. 8. Publishes persona state update to RT.  
  *server/src/agent_bridge.rs:1*
- **`persona.agent.connector`** · _beta_ · since 0.1.0 — Pluggable agent transport (REST, WebSocket, MCP) for sending messages and streaming replies to a Persona.  
  *packages/core/src/agent/AgentConnector.ts:20*
- **`persona.agent.emotion-detect`** — Keyword heuristic for real-time emotion detection from agent response text. Maps lowercased token patterns to (emotion_name, intensity) tuples for PersonaCommand::SetEmotion — drives the VRM expression blendshape without an LLM round-trip on the hot response path. Keyword-based emotion detection for agent responses. Simple heuristic: scan lowercased text for emotion indicator words. Returns (emotion_name, intensity) for use with PersonaCommand::SetEmotion.  
  *server/src/emotion.rs:1*
- **`persona.agent.idle-injector`** — Keeps 24h+ broadcasts feeling alive by emitting PersonaEvents when silence thresholds are crossed: idle.prompt (mood_check / thought / chat_filler / scene_drift), idle.scene_change, idle.bgm_change. Pure emitter — the runtime and live-page are the consumers. Thresholds are per-(tenant, agent) in idle_config (migration 0021). Idle activity injector. Keeps long-running broadcasts (24h+) feeling alive when neither viewers nor the agent are driving activity. Monitors every active room and emits one of three PersonaEvent variants when the configured silence thresholds are crossed: * `idle.prompt`        — the agent should generate an idle utterance. Sub-kinds: mood_check / thought / chat_filler / scene_drift. * `idle.scene_change`  — auto-cycle scene_mode after N min. * `idle.bgm_change`    — rotate BGM track after N min. The injector is the **emitter** only — it doesn't decide what the agent says or which scene/BGM comes next. That's the consumer's job: the runtime (Alice / Hermes / custom) listens for `idle.prompt` and feeds a turn; the live scene page listens for `idle.scene_change` + `idle.bgm_change` and crossfades. ## Configuration Per-(tenant, agent) thresholds live in `idle_config` (migration 0021). Resolution precedence mirrors `agent_overrides`: 1. (tenant_id, agent_id = $aid) — agent row. 2. (tenant_id, agent_id IS NULL) — tenant default row. 3. Hardcoded module defaults below.  
  *server/src/idle_injector.rs:1*
- **`persona.agent.protocol_connector`** · _beta_ · since 0.5.0 — Persona-side adapter that drives the OpenAlice agent contract (ConnectorEvent in / ToolCall out) so any AgentRuntime — Alice, Hermes, custom — can power a Persona without code changes in the embed.  
  *packages/core/src/agent/ProtocolAgentConnector.ts:14*
- **`persona.agent.rest_runtime`** · _beta_ · since 0.5.0 — REST-backed AgentRuntime adapter for Persona — wraps a "POST text → get text" backend so the AgentRuntime contract works today without server-side contract changes. Re-export of the protocol package's canonical impl.  
  *packages/core/src/agent/RestAgentRuntime.ts:7*
- **`persona.web.protocol_agent_hook`** · _beta_ · since 0.5.0 — React hook wiring ProtocolAgentConnector + RestAgentRuntime + agent_config fetching for the Persona embed. Drop-in replacement for raw /api/chat fetch — once Alice ships a contract endpoint, only this hook's runtime changes.  
  *apps/web/src/hooks/useProtocolAgent.ts:64*

### persona/agent-bridge
- **`persona.agent-bridge.parse-ai-sdk-stream`** · _ga_ — Vercel AI SDK stream parser — extracts text chunks from `0:` lines, ignores `d:` (done) + `2:`/`e:` metadata lines. Used by both the agent_bridge fan-out and the AI moderation evaluator.  
  *server/src/agent_bridge.rs:445*
- **`persona.agent-bridge.route-to-agents`** · _ga_ — Fan-out @mention routing — spawns one task per matched agent, POSTs the user content to each agent_url (A2A endpoint), accumulates the streamed reply via `parse_ai_sdk_stream`, broadcasts reply + TTS audio back to the room via the per-tenant voice cache.  
  *server/src/agent_bridge.rs:70*

### persona/agents
- **`persona.agent_idle_config.patch`** · _beta_ · since 0.11.2 — PATCH /v1/me/agents/:agent_id/idle-config — per-agent idle behaviour thresholds + kind toggles. Backs the Hub's Idle behaviour section on /account/agents/[id]/settings.  
  *server/src/idle_config_routes.rs:31*
- **`persona.agent_overrides.patch`** · _beta_ · since 0.11.1 — Per-agent voice + avatar override PATCH endpoint. Lets the Hub edit voice_id / stability / similarity_boost / speed / vrm_id / display_name without rolling an openalice-auth migration. Tenant-scoped via the bearer JWT.  
  *server/src/agent_overrides.rs:52*
- **`persona.agents.detail`** · _beta_ · since 0.11.0 — Return the full per-agent settings (soundboard, voice stub, persona stub) for one agent_id. 404 when the id doesn't belong to the caller's tenant.  
  *server/src/agents.rs:86*
- **`persona.agents.get-detail`** · _ga_ — GET /v1/me/agents/:agent_id — full per-agent detail (overrides, soundboard, VRM + sound counts) for the Hub `/account/agents/[id]/settings` page; tenant-isolated 404 when the agent_id is not in the caller's set.  
  *server/src/agents.rs:298*
- **`persona.agents.list`** · _ga_ — GET /v1/me/agents — synthesise a multi-agent roster for the Hub AgentPicker by enumerating distinct agent_ids across agent_soundboard_settings + sounds + the primary tenant agent, resolving soundboard_enabled + vrm_count + sound_count per row.  
  *server/src/agents.rs:276*
- **`persona.agents.patch-overrides`** · _ga_ — PATCH /v1/me/agents/:agent_id/overrides — upsert per-agent voice + VRM avatar override row consumed by the Hub `/account/agents/[id]/settings` Voice/Avatar editors.  
  *server/src/agent_overrides.rs:255*

### persona/ai-moderation
- **`persona.ai-mod.build-prompt`** · _beta_ — Render the AI moderation agent's system + user prompt (platform / author handle / recent history / latest message). Locked one-line JSON output schema so unit tests can pin the wire format.  
  *server/src/ai_moderation.rs:677*
- **`persona.ai-mod.evaluate`** · _beta_ — POST /internal/ai-mod-evaluate — layer-2 context-aware moderation verdict. Builds the agent prompt, calls the AI SDK stream, charges per-tenant spend cap, inserts the decision row, returns verdict + confidence + reason for chat-bridge to enact.  
  *server/src/ai_moderation.rs:752*
- **`persona.ai-mod.get-settings`** · _beta_ — GET /v1/me/ai-mod-settings — Hub `/account/ai-moderation` config (enabled flag, confidence_threshold, daily_spend_cap_cents, prompt_overrides) for the current tenant.  
  *server/src/ai_moderation.rs:975*
- **`persona.ai-mod.list-actions`** · _beta_ — GET /v1/me/ai-mod-actions — recent AI moderation decisions (verdict + confidence + status) for the Hub "Pending review" panel.  
  *server/src/ai_moderation.rs:1153*
- **`persona.ai-mod.list-feedback`** · _beta_ — GET /v1/me/ai-mod-feedback — list streamer-submitted AI-moderation feedback rows (right/wrong with optional note) for prompt-tuning iteration.  
  *server/src/ai_moderation.rs:1216*
- **`persona.ai-mod.parse-decision`** · _beta_ — Defensive parser for the AI moderation agent's reply — strips fenced code / leading prose / trailing commentary before JSON decode. Returns None on failure (caller falls back to allow + confidence 0).  
  *server/src/ai_moderation.rs:718*
- **`persona.ai-mod.patch-settings`** · _beta_ — PATCH /v1/me/ai-mod-settings — partial update of the Hub AI-moderation settings (toggle, threshold, spend cap, prompt overrides).  
  *server/src/ai_moderation.rs:992*
- **`persona.ai-mod.submit-feedback`** · _beta_ — POST /v1/me/ai-mod-feedback — streamer marks an AI verdict right/wrong WITHOUT undoing it; seeds the future prompt-tweak surface.  
  *server/src/ai_moderation.rs:1184*
- **`persona.ai-mod.undo-action`** · _beta_ — POST /v1/me/ai-mod-actions/:id/undo — streamer "undo" within the per-tenant window: flips action row to 'undone' + auto-records `was_wrong=true` feedback. 409 on already finalized or undone rows.  
  *server/src/ai_moderation.rs:1081*

### persona/api
- **`persona.api.animations`** — Animation manifest API: serves built-in ANIMATION_PRESETS from @openalicelabs/persona-core plus custom animations from a local JSON manifest under public/animations/custom/. Supports CRUD for custom animation entries (add, delete) without a DB — file-system manifest.  
  *apps/web/src/app/api/animations/route.ts:1*
- **`persona.api.chat`** — BFF chat proxy: validates agent URL with isSafeUrl (SSRF guard), forwards user message to the configured OpenAlice agent endpoint, and streams back the SSE response to the browser. 60s max duration for streaming agents.  
  *apps/web/src/app/api/chat/route.ts:1*
- **`persona.api.emotion-enricher`** — LLM-powered emotion enrichment: injects ElevenLabs v3 emotion tags into plain agent text, detects primary VRM expression, and suggests body pose/ gesture. Used in Mode B (local enrichment) when the upstream agent doesn't provide structured emotion data. Supports any OpenAI-compatible API.  
  *apps/web/src/app/api/enrich/route.ts:1*
- **`persona.api.internal`** — X-Internal-Secret-gated internal routes: GDPR data export slice for the openalice-tenants export-job worker (rooms + VRM metadata, no bytes), and manual archive trigger (mirrors the 6h scheduled pass). Returns 503 when INTERNAL_SECRET env var is unset — refuses to expose without auth. Internal endpoints — GDPR data export + archive trigger. Extracted from `main.rs` (2026-05-07 split). These routes are gated by `X-Internal-Secret` (see `middleware::verify_internal_secret_env`); the gate refuses to expose them (503) when `INTERNAL_SECRET` / `INTERNAL_BROADCAST_SECRET` env vars are unset. * `internal_me_export` — GDPR fan-out slice for the openalice-tenants export-job worker. Returns the rooms + VRM references for one tenant; no VRM bytes (metadata only). * `internal_archive_now` — manual archive pass; mirrors the scheduled 6h archive task in `archive.rs`.  
  *server/src/internal_routes.rs:1*
- **`persona.api.persona-routes`** — Core REST + SSE endpoints for the avatar state machine: GET /persona/state, POST /persona/command, GET /persona/stream (SSE), GET /persona/card (A2A Agent Card), and per-room equivalents. SSE includes an initial snapshot plus 15s keep-alive heartbeat. Wires both default-room and multi-room (rooms/:id) surfaces. Core persona REST + SSE endpoints. Extracted from `main.rs` (2026-05-07 split). Owns the eight base handlers that drive the avatar state machine: * `/persona/state` (GET) — default-room snapshot * `/persona/command` (POST) — execute on default room * `/persona/stream` (GET) — default-room SSE * `/persona/card` (GET) — A2A Agent Card * `/persona/rooms` (GET) — list rooms * `/persona/rooms/:id/state` (GET) — per-room snapshot * `/persona/rooms/:id/command` (POST) — per-room execute * `/persona/rooms/:id/stream` (GET) — per-room SSE Auth is the same `PERSONA_API_KEY` gate via `check_auth`; the SSE streams include the initial snapshot before live events and a 15s keep-alive heartbeat for proxy-friendly idle connections. The `agent_id` field is forwarded into the SSE payload only when set (multi-agent rooms), keeping the byte-shape identical to legacy single-agent clients.  
  *server/src/persona_routes.rs:1*
- **`persona.api.stt-proxy`** — Server-side STT proxy: accepts webm/opus audio blob (multipart), transcribes via Whisper (default) or ElevenLabs STT based on STT_PROVIDER env var. API keys stay server-side. Returns {text: string}.  
  *apps/web/src/app/api/stt/route.ts:1*
- **`persona.api.tts-proxy`** — Server-side ElevenLabs TTS proxy: keeps API key out of the browser (SEC-6 fix). Accepts {text, voiceId, modelId, stability, similarity_boost, style} and streams back audio/mpeg. Returns 503 when ELEVENLABS_API_KEY is unset rather than leaking a 401 from the upstream.  
  *apps/web/src/app/api/tts/route.ts:1*

### persona/archive
- **`persona.archive.pass`** · _ga_ — One archive sweep — moves old `lounge_messages` + `lounge_reports` rows into archive tables via age-based (`PERSONA_ARCHIVE_AFTER_DAYS`) + count-based (`PERSONA_ARCHIVE_OVER_COUNT`, keep N recent) sweeps. Idempotent.  
  *server/src/archive.rs:48*
- **`persona.archive.spawn-task`** · _ga_ — Background tokio task that runs `archive_pass` every `PERSONA_ARCHIVE_INTERVAL_HOURS` (default 6h) — the retention engine that keeps lounge_messages bounded.  
  *server/src/archive.rs:155*

### persona/assets
- **`persona.assets.fonts`** — Per-tenant brand-font upload: validates woff2/woff magic bytes, stores at PERSONA_FONT_DIR/<tid>/<uuid>.<ext>, inserts into fonts table. Public URL is consumed by the live page's @font-face rule. Mirrors vrms.rs and audio.rs for consistent asset-management patterns. Per-tenant custom font upload handlers (P2.8, 2026-05-03). Mirrors `vrms.rs` for `.woff2` / `.woff` files. Pre-P2.8 streamers could only reference web-safe / Google-Fonts-style stacks in their `StreamTheme.{body,display,mono}_font` fields. This module is the brand-font flow: 1. Studio POSTs the font bytes (multipart/form-data, field "file") to `/v1/me/fonts` with the user's bearer JWT. 2. We extract `tid` from the JWT (same rule the VRM upload uses). 3. We validate magic bytes — a `.woff2` starts with `wOF2`, a `.woff` with `wOFF`. Anything else is rejected with 400 so a streamer can't smuggle TTF / OTF / arbitrary bytes through the upload (TTF/OTF live in a follow-up because the @font-face `format(...)` token differs). 4. The bytes land at `<PERSONA_FONT_DIR>/<tenant_id>/<font_uuid>.<ext>`. 5. We INSERT one row into `fonts` (id, tenant_id, family_name, original_filename, format, size_bytes, sha256, created_at) and respond with `{font_id, public_url, family_name, format, size_bytes, sha256}` where `public_url` is `https://persona.${DOMAIN}/font/<tenant_id>/<font_uuid>.<ext>`. `public_url` is unsigned because uploaded fonts are not secrets — they're pulled by the live page's `@font-face` rule, which the browser fetches cross-origin. The static-serve route below sets `Access-Control-Allow-Origin: *` so cross-origin font fetches succeed without a CORS proxy. `family_name` is the CSS-visible identifier the streamer references in `StreamTheme.body_font` etc. We default it to the filename stem on upload and let the streamer rename via PATCH.  
  *server/src/fonts.rs:1*

### persona/audio
- **`persona.audio.bgm-upload`** — Per-tenant BGM audio upload: validates MP3/OGG magic bytes, stores at PERSONA_AUDIO_DIR/<tid>/<uuid>.<ext>, inserts into audio_tracks table. Generated tracks (music_gen.rs) land in the same table tagged source='generated' — the Hub BGM picker and live BgmPlayer handle both paths identically. Per-tenant background-music audio upload handlers (Live BGM engine, 2026-05-03). Mirrors `fonts.rs` for `.mp3` / `.ogg` files. Pre-BGM-engine the Live player surfaced a `bgm_track` string ONLY as a "♪ Now playing" placard pill — no actual audio playback. This module is the background-music asset flow: 1. Studio (Hub `account/streams` → ThemePicker → Music & Audio section) POSTs the audio bytes (multipart/form-data, field "file") to `/v1/me/audio` with the user's bearer JWT. 2. We extract `tid` from the JWT (same rule the font upload uses). 3. We validate magic bytes — an `.mp3` starts with `ID3` (id3v2 tagged), `\xFF\xFB` / `\xFF\xF3` / `\xFF\xF2` (raw MPEG sync), or after a small skip; an `.ogg` starts with `OggS`. Anything else is rejected with 400 so a streamer can't smuggle .wav / .flac / arbitrary bytes through the upload (those formats live in a follow-up because the audio element's container support differs across browsers). 4. The bytes land at `<PERSONA_AUDIO_DIR>/<tenant_id>/<audio_uuid>.<ext>`. 5. We INSERT one row into `audio_tracks` (id, tenant_id, title, original_filename, format, size_bytes, sha256, duration_seconds, created_at) and respond with `{audio_id, public_url, title, format, size_bytes, sha256}` where `public_url` is `https://persona.${DOMAIN}/audio/<tid>/<aid>.<ext>`. `public_url` is unsigned because uploaded BGM tracks are not secrets — they're pulled by the live page's hidden `<audio>` element, which the broadcaster Chromium loads in the same origin as the scene page. The static-serve route below sets `Access-Control-Allow-Origin: *` so cross-origin audio fetches succeed without a CORS proxy (the live page already loads from `live.${DOMAIN}` and the asset is served from `persona.${DOMAIN}`). `title` is the user-visible label the streamer sees in the picker UI and the placard's "♪ Now playing" pill. Defaults to the filename stem on upload; the streamer can rename via PATCH. ## Module composition (Wave 13-C) `mod.rs` is the public surface — `routes.rs` (top-level) reaches the handlers through `audio::*` re-exports below. Concern-specific submodules: - `common`   — magic-byte sniffer, validators, paths, atomic write. `sniff_audio_format` + `validate_title` are crate-pub because `music_gen.rs` and the sounds module lean on them. - `presets`  — bundled royalty-free preset catalogue + CDN URL resolver. - `routes`   — multipart upload + list + rename + delete + public asset.  
  *server/src/audio/mod.rs:1*
- **`persona.audio.delete`** · _ga_ — DELETE /v1/me/audio/:id — tenant-scoped row + on-disk file removal.  
  *server/src/audio/routes.rs:279*
- **`persona.audio.list`** · _ga_ — GET /v1/me/audio — list tenant audio tracks (uploads + Lyria-generated) merged with the bundled royalty-free preset catalogue, decorated with public_url per row.  
  *server/src/audio/routes.rs:163*
- **`persona.audio.music-gen`** — AI background-music generation via Google Lyria 3 Pro on OpenRouter ($0.08/song flat). Natural-language prompts produce MP3s inserted into the same audio_tracks table as user uploads — tagged source='generated' so the Hub UI can decorate them without special-casing the BGM player pipeline. AI music generation via Google Lyria 3 Pro (`google/lyria-3-pro-preview`) over OpenRouter's chat-completions endpoint (2026-05-07). Streamers describe music in natural language ("lofi study beats with rain, melancholic, 2 minutes") and we synthesise an MP3 that lands in the same `audio_tracks` table as user-uploaded BGM. The existing Hub Music & Audio picker + live BgmPlayer pipeline picks generated tracks up with zero extra wiring — they're tagged `source='generated'` so the UI can decorate them with a "Generated · Lyria 3" pill but otherwise the path is the same. Wire format (verified against OpenRouter on 2026-05-07): ```text POST https://openrouter.ai/api/v1/chat/completions Authorization: Bearer ${OPENROUTER_API_KEY} Content-Type: application/json { "model": "google/lyria-3-pro-preview", "messages": [{"role": "user", "content": "<prompt>"}], "modalities": ["audio"], "stream": true } ``` The response is a Server-Sent-Events stream. The audio arrives in a single chunk shaped like: ```json ``` Followed by `finish_reason: "stop"` and a `usage` chunk that reports the actual cost in USD (`usage.cost`, e.g. `0.08` for a full song). We base64-decode the `data` field straight into the on-disk MP3 (it starts with the literal bytes `ID3` for Lyria's ID3v2-tagged output) and parse `usage.cost * 100` rounded to int for the budget counter. Asynchronicity: Lyria takes 10-30s to render a track. The route handler `POST /v1/me/audio/generate` inserts a 'pending' job row, spawns a tokio task, and returns 202 immediately. The task flips the job through running → succeeded/failed; the Hub polls `GET /v1/me/audio/generate/:job_id` every 5s. This protects the Axum request executor from blocking on a 30s upstream call and lets the streamer leave the page while a track renders. Budget guard: every successful generation costs Lyria's flat $0.08 (8 cents). Before kicking off a generation we check the tenant's `music_gen_budgets` row — pre-flight returns 402 if the projected spend would exceed the cap. After Lyria responds with the actual cost we increment the counter atomically.  
  *server/src/music_gen.rs:1*
- **`persona.audio.radio-bridge`** — WebSocket bridge from openalice-radio into the PersonaEvent broadcast bus. When bgm_source is radio:<slug>, a tokio task subscribes to now_playing frames under topic radio.now_playing. BridgeHandle aborts on drop; reconnects with exponential back-off (1s → 30s cap). Forwarder: radio.${DOMAIN} WS → PersonaEvent bus. When the agent's `bgm_source` is `radio:<slug>`, persona-server subscribes to the radio service's WS endpoint and re-emits each `now_playing` frame on the existing PersonaEvent broadcast bus under topic `radio.now_playing`. The live page already trusts that bus (SSE/WS subscribers connect once per agent room) so the page receives radio frames without opening a second connection to the radio host. Lifecycle: - `start_bridge(slug, tx)` spawns a tokio task that connects to `wss://radio.${DOMAIN}/v1/radio/channels/:slug/subscribe`, forwarding every received frame onto `tx`. - The task reconnects with exponential back-off on disconnect (1s, 2s, 5s, 10s, 30s capped). - The returned `BridgeHandle` aborts the task when dropped, so the caller (typically the agent_overrides handler) can stop a bridge cleanly when the streamer switches off the radio source. Wire shape: the WS protocol matches `openalice-radio/src/handlers/ws.rs`: ```json { "type": "now_playing", "channel_slug": "lo-fi-24-7", "track_id": "...", "title": "...", "audio_url": "...", ... } ``` We forward the entire JSON object as the PersonaEvent payload. Subscribers parse it on the client. The PersonaEvent `topic` is always `radio.now_playing`; downstream consumers key off that string to discriminate from regular persona events. TODO(open-question): the `bgm_source = "radio:<slug>"` agent override that drives `start_bridge` is not yet a column on `agent_overrides` (the table is voice + avatar only as of today). When the schema lands the handler in `agent_overrides.rs` should call `start_bridge(slug, default_radio_base(), state.tx.clone())` and store the returned `BridgeHandle` on `AppState` keyed by `(tenant_id, agent_id)` so a follow-up PATCH (or a switch back to `bgm_source = "cdn:..."`) can drop the old handle. The API surface here (start_bridge + BridgeHandle Drop + topic constant) is stable from this commit so the future wiring is a one-line drop-in. See `.atlas-deps.yml` (openalice-radio target) for the cross-repo contract.  
  *server/src/radio_bridge.rs:1*
- **`persona.audio.rename`** · _ga_ — PATCH /v1/me/audio/:id — tenant-scoped rename of an audio track's user-visible title.  
  *server/src/audio/routes.rs:223*
- **`persona.audio.serve-public`** · _ga_ — GET /audio/:tenant_id/:file_name — public unauthenticated audio asset endpoint with `audio/mpeg`|`audio/ogg` MIME, `ACAO: *`, immutable cache; row-format guard defends against cross-tenant enumeration.  
  *server/src/audio/routes.rs:345*
- **`persona.audio.soundboard`** — Neuro-sama-style agent soundboard: upload tagged MP3/OGG clips, bind to agent or tenant-wide; play_sound("name") broadcasts a play_sound PersonaEvent so the live page fires an oa-sfx-play window event and the SfxPlayer plays the URL. Supports category + tags for LLM-driven selection of contextually appropriate clips. Per-tenant agent soundboard — Neuro-sama-style SFX (2026-05-03). Streamers upload short audio clips (mp3/ogg) and tag them; the agent invokes `play_sound("name")` mid-response and the audience hears the clip through the live broadcast. Mirrors the `audio.rs` BGM module with two extra dimensions: `agent_id` (sounds may bind to a single agent or live tenant-wide) and `category + tags` (so the LLM picks the right sound for the moment). Routes added by `main.rs`: ```text POST   /v1/me/sounds              multipart{file, name, category, agent_id?, tags?[]} → 200 { sound_id, name, category, tags, agent_id, public_url, ... } GET    /v1/me/sounds?agent_id=... → 200 { sounds: SoundEntry[] } PATCH  /v1/me/sounds/:id          json{name?, category?, tags?, agent_id? | null} → 200 SoundEntry DELETE /v1/me/sounds/:id → 204 POST   /v1/me/sounds/play         json{name, agent_id?, volume?} → 200 { sound_id, name, public_url, volume, duration_ms } side-effect: broadcasts a `play_sound` PersonaEvent on the bus → live page picks it up via SSE/WS and dispatches an `oa-sfx-play` window event → SfxPlayer plays the URL. GET    /sound/<tenant_id>/<sound_id>.<ext> → public unsigned asset (Cache-Control: immutable, ACAO: *) ``` Bytes land at `<PERSONA_SOUND_DIR>/<tenant_id>/<sound_id>.<ext>`. Magic-byte validation reuses `audio::sniff_audio_format` so only mp3/ogg are admitted. Same per-tenant scoping rule as VRMs / fonts / BGM tracks: every query filters by `tenant_id`. ## Module composition (Wave 13-C) `mod.rs` is the public surface — `routes.rs` (top-level) reaches the handlers through `sounds::*` re-exports below. Concern-specific submodules: - `common`    — validators, path helpers, response helpers, atomic write. - `duration`  — MP3 frame-walk duration sniffer (pure parsing). - `routes`    — multipart upload + catalog CRUD + playback trigger + public asset. - `settings`  — soundboard kill-switch (per-agent / tenant-wide).  
  *server/src/sounds/mod.rs:1*
- **`persona.audio.upload`** · _ga_ — POST /v1/me/audio — multipart MP3/OGG BGM upload (≤20MB), magic-byte format sniff, sha256 dedupe, INSERT with file-rollback on DB error.  
  *server/src/audio/routes.rs:26*

### persona/auth
- **`persona.auth.room-token`** — JWT room access tokens: encode/decode RoomTokenClaims (sub, room, exp, role, can_chat, can_voice). Public viewers (no token) get anonymous read-only access; participants with valid can_chat=true tokens get full interaction. Role-based permission model enforced by the WS handler. JWT room access tokens for persona rooms. Tokens grant access to persona rooms with role-based permissions. - Public viewers (no token): read-only, can see avatar + chat, cannot send messages. - Participants (valid token with can_chat=true): full interaction.  
  *server/src/token.rs:1*
- **`persona.auth.tenant-from-jwt`** · _ga_ — Universal JWT → tenant_id extractor — decodes the Bearer token against SHARED_JWT_SECRET, returns `tid` falling back to `sub` (legacy). Refuses tokens with role=refresh. Used by every /v1/me/* handler in the server.  
  *server/src/auth.rs:65*

### persona/avatar
- **`persona.avatar.animation-library`** — Gender-categorized Mixamo animation presets: female (hair flips, delicate waves), male (broad gestures, power poses), and general (gender-neutral). Categories: idle, gesture, emote, dance, greeting, action. Metadata drives the Hub Animation Panel UI selector.  
  *packages/core/src/vrm/AnimationLibrary.ts:1*
- **`persona.avatar.emotion-detector`** — Keyword-based emotion detection for the core package: maps lowercased text to VRMExpression + intensity. Covers happy/sad/angry/surprised/relaxed/ love/neutral with emoji patterns. Used by AvatarModel and PersonaEmbed to drive SetExpression without an LLM round-trip.  
  *packages/core/src/emotion/EmotionDetector.ts:1*
- **`persona.avatar.emotion-reactor`** — InstancedMesh particle system for emotion-reactive 3D visual effects. Single draw call for 56 particles (ring + effect layers). Emotion-specific burst (surprised), floating orbs (relaxed). Smooth color lerp between states.  
  *apps/web/src/components/EmotionReactor.tsx:1*
- **`persona.avatar.expression-controller`** — Advanced facial expression manager: smooth cross-fade with configurable speed, automatic randomized blink cycle, idle micro-expressions (subtle idle drift), multi-layer blending (base emotion + blink + lipsync), and priority-based expression queue.  
  *packages/core/src/vrm/ExpressionController.ts:1*
- **`persona.avatar.model`** — Core VRM avatar driver: loads VRM via VRMManager, runs ExpressionController (blink, idle micro-expressions, lipsync blendshapes), AvatarAnimator (Mixamo retargeted animations), PoseStream (60Hz WS pose override), and StreamingTtsPlayer (MSE-based utterance audio). All state flows through useAvatarStore.  
  *apps/web/src/components/AvatarModel.tsx:1*
- **`persona.avatar.scene`** — Root R3F Canvas for the persona avatar: manages OrbitControls, HDR environment (with error boundary fallback), ContactShadows, and hosts the AvatarModel within a Suspense boundary. Entry point for the single- avatar view rendered inside the persona iframe.  
  *apps/web/src/components/AvatarScene.tsx:1*
- **`persona.avatar.vrm`** · _stable_ · since 0.1.0 — Load and drive VRM avatars — expressions, mouth lipsync, tick updates via three-vrm + GLTFLoader.  
  *packages/core/src/vrm/VRMManager.ts:31*
- **`persona.avatar.vrm-upload`** — Per-tenant VRM file upload and gallery management. Multipart POST to /v1/me/vrms stores bytes at PERSONA_VRM_DIR/<tid>/<uuid>.vrm, inserts into vrms table (sha256, size), and returns a public_url at impractical for v1 without signed URLs. Per-tenant VRM upload handlers (P2.7, 2026-05-02). Pre-P2.7 persona stored avatars only as URL strings on `room_participants.vrm_url`; tenants pasted URLs pointing to Cloudinary / GitHub raw / wherever, and persona was just a passthrough. This module is the real product flow: 1. Studio POSTs the .vrm bytes (multipart/form-data, field "file") to `/v1/me/vrms` with the user's bearer JWT. 2. We extract `tid` from the JWT (falling back to `sub` for legacy tokens that pre-date the multi-tenant pivot, the same rule openalice-axum-common's `AuthUser::tenant_id()` uses). 3. The bytes land at `<PERSONA_VRM_DIR>/<tenant_id>/<vrm_uuid>.vrm`. 4. We INSERT one row into `vrms` (id, tenant_id, filename, size_bytes, sha256, created_at) and respond with `{vrm_id, public_url}` where `public_url` is `https://persona.${DOMAIN}/vrm/<tenant_id>/<vrm_uuid>.vrm`. `public_url` is unsigned because VRM avatar files are not secrets — they're shown alongside every chat reply on a public scene page. If we ever ship private avatars (only-friends rooms etc.) the route below grows a signed-URL TTL knob; for v1 the per-tenant subdir + UUID-shaped filename is enough to keep random walk-by enumeration impractical.  
  *server/src/vrms.rs:1*
- **`persona.vrm.delete`** · _ga_ — DELETE /v1/me/vrms/:vrm_id — tenant-scoped row + on-disk file removal; cross-tenant deletes get a clean 404 via the SQL WHERE clause.  
  *server/src/vrms.rs:312*
- **`persona.vrm.effective-default`** · _ga_ — Resolve the platform default-VRM URL with explicit-override (`PERSONA_DEFAULT_VRM_URL`) wins, explicit empty = opt-out, and DOMAIN-derived bundled-CDN fallback. Consumed by /v1/me/vrms + downstream live page preloader.  
  *server/src/vrms.rs:123*
- **`persona.vrm.list`** · _ga_ — GET /v1/me/vrms — list this tenant's uploaded VRMs (decorated with public_url) + the platform default-VRM URL surfaced when the tenant has zero uploads.  
  *server/src/vrms.rs:251*
- **`persona.vrm.serve-public`** · _ga_ — GET /vrm/:tenant_id/:file_name — public unauthenticated VRM asset endpoint with `model/gltf-binary` content type, `ACAO: *` and `Cache-Control: immutable`; DB-row probe defends against cross-tenant enumeration.  
  *server/src/vrms.rs:364*
- **`persona.vrm.upload`** · _ga_ — POST /v1/me/vrms — multipart upload of a .vrm avatar (≤50MB), sha256 + UUID-shaped storage path, INSERT vrms row, atomic write with INSERT rollback on failure.  
  *server/src/vrms.rs:147*

### persona/billing
- **`persona.billing.usage`** — Per-room, per-user usage tracking for billing: TTS characters, STT seconds, LLM tokens (input + output), voice minutes, storage bytes. Each atomic event (one TTS call, one LLM response) is a single log row; the summary endpoint aggregates over a time period for Hub dashboards and billing integrations. Usage tracking for billing and monitoring. Tracks per-room, per-user usage of: - TTS characters generated (ElevenLabs billing = per character) - STT seconds transcribed - LLM tokens (input + output from Alice responses) - Voice minutes (WebRTC connection duration) - Storage (file uploads, bytes) Each log entry is a single atomic event (e.g. one TTS call, one LLM response). The summary endpoint aggregates over a time period.  
  *server/src/usage.rs:1*

### persona/core
- **`persona.core.state`** — Central state types and room registry: PersonaState, PersonaCommand, PersonaEvent, NpcState, PersonaEngine (per-room state machine), and RoomManager (multi-room registry). Re-exported at crate root so all sibling modules use crate::PersonaEvent etc. without path changes. Core state types + room manager + engine for persona-server. Extracted from `main.rs` (3712 → split, 2026-05-07). This module owns the data model (`PersonaState`, `PersonaCommand`, `PersonaEvent`, `NpcState`), the per-room state machine (`PersonaEngine`), the multi-room registry (`RoomManager`), and the top-level `ServerState` / `AppState` alias. All public items here are re-exported at the crate root in `main.rs` so existing call sites (`crate::AppState`, `crate::PersonaEvent`, …) keep working with no edits required in sibling modules. ## Module composition (Wave 13-C) - `types`       — wire-format structs (PersonaState + sub-states + PersonaCommand + PersonaEvent + NpcState) + serde defaults. - `validation`  — emotion / lighting / SSRF allow-lists + `validate` + `validate_voice_url`. - `engine`      — `PersonaEngine` state machine (`execute` chokepoint). - `rooms`       — `RoomEntry` + `RoomManager` multi-room registry. - `server`      — `ServerState`, `AppState`, `BridgeRegistry`, IdleBus impl.  
  *server/src/state/mod.rs:1*

### persona/core/vrm
- **`persona.core.avatar-animator`** · _beta_ — Procedural VRM idle + gesture engine — breathing, head sway, body sway, speaking nods, and a discrete gesture library (nod / shake / tilt / wave / think / shrug / bow) driven by sine-based primitives, no Mixamo dependency.  
  *packages/core/src/vrm/AvatarAnimator.ts:14*

### persona/db
- **`persona.db.archive`** — Scheduled archival of high-write lounge tables: moves rows older than 30d (or excess of 100k/room) from lounge_messages + lounge_reports into *_archive siblings. Runs every 6h (tokio task) + on-demand via POST /internal/admin/archive-now. Keeps hot tables bounded for 24h+ streaming. Scheduled archival of high-write lounge tables. Moves rows older than 30 days (or in excess of 100k per room) from `lounge_messages` and `lounge_reports` into the `*_archive` siblings (migration 0020). The hot tables stay bounded so 24h+ continuous operation doesn't slowly slow the "last 50 on join" query path. ## Cadence `spawn_archiver` starts a tokio task that runs every 6h (configurable via `PERSONA_ARCHIVE_INTERVAL_HOURS`). The first pass runs 60 s after process boot so a freshly-deployed pod doesn't immediately bang on the DB; subsequent passes follow the configured cadence. ## Admin endpoint `POST /internal/admin/archive-now` triggers an out-of-band run against the same `archive_pass` helper. X-Internal-Secret gated; reuses the standard auth helper. See `internal_archive_now` in `main.rs`.  
  *server/src/archive.rs:1*
- **`persona.db.connect-and-migrate`** · _ga_ — Boot-time PostgreSQL connect (pool size 20 / acquire 5s / idle 600s) + sequential raw-SQL migration runner across every migrations/*.sql file in numerical order.  
  *server/src/db/mod.rs:60*
- **`persona.db.connection`** · _ga_ — PostgreSQL connection pool wrapper (sqlx::PgPool, max 20 conns) — the single DB handle every persona module clones for sqlx queries. Persists chat history, command audit, participants, sounds, fonts, audio, VRMs, polls, redemption triggers, YT tokens, AI moderation rows.  
  *server/src/db/mod.rs:41*

### persona/embed
- **`persona.embed.avatar`** — Embeddable React component for the PersonaAvatar: self-contained R3F Canvas with VRM loading, ExpressionController, AvatarAnimator, and usePersonaState (SSE-driven emotion/animation sync). Drop the component into any Next.js or React app to embed an animated persona without the full Persona web shell.  
  *packages/embed/src/PersonaAvatar.tsx:1*

### persona/emotion
- **`persona.emotion.detect`** · _ga_ — Lightweight keyword + punctuation emotion classifier — maps text to one of the canonical emotion labels (joy/sadness/anger/fear/…) + confidence. Drives TTS v3 tag injection.  
  *server/src/emotion.rs:15*

### persona/engagement
- **`persona.engagement.polls`** — Viewer engagement polls: streamers create question + 2-6 options + duration; chat-bridge tallies votes as viewers type "1"/"A" etc.; live page renders PollOverlay with live tallies. Background task auto-closes after duration_seconds and broadcasts poll_closed with final results. Engagement polls (migration 0019, 2026-05-07, YT Tier 1 wave). Streamers create a poll (question + 2-6 options + duration); chat-bridge tallies votes as viewers type "1", "2", "A", "B" into chat; the live page paints a PollOverlay with live tallies and closes the poll on the duration_seconds tick. Routes wired by `main.rs`: ```text POST   /v1/me/polls → 200 Poll GET    /v1/me/polls/:id → 200 Poll POST   /internal/poll-vote → 200 { counted, tally: {idx: count} } ``` On poll create, the dispatcher broadcasts a `poll_opened` PersonaEvent on the (room_id || default) bus. On vote, it broadcasts `poll_vote` with the running tally. A background `poll_closed` with final results JSONB.  
  *server/src/polls.rs:1*
- **`persona.engagement.redemption-triggers`** — Channel-point / SuperChat / subscription gift triggers: maps platform redemption keys to PersonaEvents (play animation, fire SFX, set scene, etc.). Chat-bridge POSTs to /internal/redemption-received; a redemption_triggered event paints a toast overlay on the live page. Supports agent-scoped and tenant-wide trigger rows with agent precedence. Channel-point / SuperChat redemption triggers (2026-05-07). Streamers bind a platform-specific redemption (Twitch channel-point reward, YouTube SuperChat tier, Kick subscription tier, TikTok gift) to a live action on the agent's stream. When chat-bridge ingests the redemption it POSTs to `/internal/redemption-received` here; the dispatcher resolves the matching trigger row, broadcasts the configured PersonaEvent (so the live page fires the action), plus a `redemption_triggered` event so the RedemptionOverlay can paint a "ViewerName redeemed Evil Laugh" toast. Closes the Neuro-sama-tier audience engagement loop. Mirrors `sounds.rs` for tenant scoping + agent precedence (agent-scoped row wins, tenant-wide fallback). All bytes-on-disk semantics are N/A here — triggers are pure metadata. Routes added by `main.rs`: ```text GET    /v1/me/redemption-triggers?agent_id=... → 200 { triggers: RedemptionTrigger[] } POST   /v1/me/redemption-triggers agent_id?, enabled? } → 200 RedemptionTrigger PATCH  /v1/me/redemption-triggers/:id → 200 RedemptionTrigger DELETE /v1/me/redemption-triggers/:id → 204 POST   /internal/redemption-received viewer_handle, viewer_id?, amount?, currency?, message? } → 200 { matched, action_kind?, room_id? } ``` Closed enums (validated server-side): * `platform` ∈ {twitch_points, youtube_superchat, kick_subscription, tiktok_gift} * `action_kind` ∈ {play_sound, scene_change, gesture, tts_shout, expression} `action_payload` schemas keyed off `action_kind`: * `play_sound`   → `{ "sound_name": "evil laugh", "volume"?: 0.0..1.0 }` * `scene_change` → `{ "scene_mode": "vrm-corner-bottomright" }` * `gesture`      → `{ "gesture_id": "wave", "duration_ms"?: <i32> }` * `tts_shout`    → `{ "message": "thanks {{viewer}}", "voice_id"?: "default" }` * `expression`   → `{ "expression": "joy", "intensity"?: 0.0..1.0 }`  
  *server/src/redemption_triggers.rs:1*

### persona/engine
- **`persona.engine.adaptive-quality`** — Runtime adaptive quality: four levels (ultra/high/medium/low) controlling bloom, chromatic aberration, shadow map resolution, vegetation density, and vignette. Auto-downgrades on frame-rate drops; exposes manual override. Reduces GPU load on mid-range hardware without manual tuning.  
  *packages/engine/src/quality/adaptive.ts:1*
- **`persona.engine.particles`** — GPU-instanced particle system (InstancedMesh + custom ShaderMaterial) for OpenAlice World: 10K+ particles in one draw call. Styles: plasma, ink, spark, dust — all with anime flat colors + additive blending for glow. Simplex noise in fragment shader, per-particle instanceColor, UnrealBloomPass compatible.  
  *packages/engine/src/particles/engine.ts:1*
- **`persona.engine.sound-manager`** — Procedural audio engine for OpenAlice World: all sounds generated via Web Audio API (no external files). Preloads synthesis buffers once; play(), loop(), stop() API. Singleton accessed via getSoundManager(). Used for world ambient sounds, footsteps, UI feedback, and jump SFX.  
  *packages/engine/src/audio/soundManager.ts:1*

### persona/engine/audio
- **`persona.engine.procedural-audio`** · _beta_ — Zero-asset Web Audio engine for weather + ambient + player sound layers — rain / wind / ambient / thunder graphs with crossfades, driven by WeatherInput (type / intensity / windSpeed / timeOfDay) so weather changes are audible without preloading files.  
  *packages/engine/src/audio/proceduralAudio.ts:7*

### persona/font
- **`persona.font.delete`** · _ga_ — DELETE /v1/me/fonts/:id — tenant-scoped row + on-disk file removal (looks up format extension first).  
  *server/src/fonts.rs:449*
- **`persona.font.list`** · _ga_ — GET /v1/me/fonts — list tenant fonts decorated with computed public_url per row for the studio Theme picker.  
  *server/src/fonts.rs:334*
- **`persona.font.rename`** · _ga_ — PATCH /v1/me/fonts/:id — rename the CSS family identifier with strict allowlist validation, returns the updated row with public_url.  
  *server/src/fonts.rs:392*
- **`persona.font.serve-public`** · _ga_ — GET /font/:tenant_id/:file_name — public unauthenticated font asset endpoint with `font/woff2`|`font/woff` MIME, `ACAO: *`, immutable cache; row-format guard defends against cross-tenant enumeration.  
  *server/src/fonts.rs:513*
- **`persona.font.upload`** · _ga_ — POST /v1/me/fonts — multipart .woff2/.woff brand-font upload (≤5MB), magic-byte format sniff, family_name validation, sha256 dedupe, INSERT with file-rollback on DB error.  
  *server/src/fonts.rs:207*

### persona/history
- **`persona.history.chat`** · _ga_ — GET /persona/history/:room_id — paginated chat history (DB-backed when DATABASE_URL set) for the room. Supports limit query param.  
  *server/src/legacy_routes.rs:34*
- **`persona.history.commands`** · _ga_ — GET /persona/commands/:room_id — paginated PersonaCommand audit log for the room (every emotion/animation/scene change persisted).  
  *server/src/legacy_routes.rs:65*

### persona/idle
- **`persona.idle-config.get`** · _ga_ — GET /v1/me/agents/:agent_id/idle-config — per-agent idle behaviour row (silence / scene / BGM thresholds + kind toggles) consumed by the Hub Idle Behaviour panel. Returns synthesised defaults on first load.  
  *server/src/idle_config_routes.rs:105*
- **`persona.idle-config.patch`** · _ga_ — PATCH /v1/me/agents/:agent_id/idle-config — upsert per-agent idle thresholds + kind toggles. Body validated for sane ranges; the idle_injector reads this row each tick.  
  *server/src/idle_config_routes.rs:132*
- **`persona.idle-injector.evaluate`** · _ga_ — Pure decision engine — given a room's ActivityWatermark + IdleThresholds, decides whether to emit a prompt / scene_change / bgm_change idle event. Synthetic-clock-friendly for unit tests.  
  *server/src/idle_injector.rs:155*
- **`persona.idle-injector.payload-for`** · _ga_ — Render the PersonaEvent (topic + JSON payload) the idle injector emits for a given IdleDecision. Topics: `idle.prompt`, `idle.scene_change`, `idle.bgm_change`.  
  *server/src/idle_injector.rs:227*
- **`persona.idle-injector.pick-kind`** · _ga_ — Round-robin selector for idle-prompt kinds (MoodCheck → Thought → ChatFiller → SceneDrift) — skips kinds disabled per IdleThresholds and the most-recently-emitted kind to avoid back-to-back repeats.  
  *server/src/idle_injector.rs:196*
- **`persona.idle-injector.resolve-tick-interval`** · _ga_ — Resolve the idle-injector loop cadence from `OA_IDLE_TICK_INTERVAL_SECS` with 60s default; rejects 0 (busy-loop guard).  
  *server/src/idle_injector.rs:370*
- **`persona.idle-injector.spawn-task`** · _ga_ — Spawn the long-running idle-injector tokio task — wakes every `OA_IDLE_TICK_INTERVAL_SECS` (default 60s), evaluates every active room, broadcasts idle events when thresholds cross.  
  *server/src/idle_injector.rs:452*
- **`persona.idle-injector.tick-once`** · _ga_ — Single pass — snapshot watermarks, evaluate every active room, broadcast `idle.*` events for rooms that crossed a threshold. Returns emitted-event count for tests + metrics.  
  *server/src/idle_injector.rs:402*
- **`persona.idle-injector.warn-misconfigured`** · _ga_ — Boot-time soft warn if the idle tick interval is ≥ smallest threshold (which would never fire). Lets the operator override for testing without blocking startup.  
  *server/src/idle_injector.rs:495*

### persona/internal
- **`persona.internal.archive-now`** · _ga_ — POST /internal/admin/archive-now — manual trigger for the same archive sweep the 6h scheduled task runs. Lets ops force the retention policy after a migration / load test. X-Internal-Secret gated.  
  *server/src/internal_routes.rs:125*
- **`persona.internal.me-export`** · _ga_ — GET /internal/me/:tenant_id/export.json — GDPR data-export slice consumed by openalice-tenants' export-job worker. Returns VRM references + room metadata (no bytes). X-Internal-Secret gated; unknown tenant returns 200 with empty arrays.  
  *server/src/internal_routes.rs:38*

### persona/lounge
- **`persona.lounge.add-agent`** · _ga_ — POST /persona/rooms/:id/add-agent — host-only insert of an AI agent participant (agent_url + VRM + voice), enforces max_agents, broadcasts `room.presence agent_added`.  
  *server/src/lounge/participants.rs:247*
- **`persona.lounge.create-room`** · _ga_ — POST /persona/rooms — mint a tenant-scoped lounge room (private/public/invite), clamp user/agent caps, auto-join creator as host, materialise the in-memory PersonaEngine for SSE subscribers.  
  *server/src/lounge/rooms.rs:47*
- **`persona.lounge.get-room`** · _ga_ — GET /persona/rooms/:id — return room metadata + participant snapshot. Tenant-scoped with public-room cross-tenant read-through.  
  *server/src/lounge/rooms.rs:159*
- **`persona.lounge.join`** · _ga_ — POST /persona/rooms/:id/join — authenticated user joins a lounge room as viewer (idempotent re-join), enforces max_users cap, broadcasts `room.presence joined` over SSE.  
  *server/src/lounge/participants.rs:48*
- **`persona.lounge.leave`** · _ga_ — POST /persona/rooms/:id/leave — remove the caller's participant row and broadcast `room.presence left`.  
  *server/src/lounge/participants.rs:187*
- **`persona.lounge.list-my-rooms`** · _ga_ — GET /persona/lounge/rooms — list the caller's owned lounge rooms for the Hub "My Rooms" page.  
  *server/src/lounge/rooms.rs:210*
- **`persona.lounge.list-participants`** · _ga_ — GET /persona/rooms/:id/participants — tenant-scoped (or public) participant roster with type (user/agent) + role + name + avatar.  
  *server/src/lounge/participants.rs:458*
- **`persona.lounge.remove-agent`** · _ga_ — POST /persona/rooms/:id/remove-agent — host-only removal of an AI agent from a lounge room, refuses to target human user rows, broadcasts `room.presence agent_removed`.  
  *server/src/lounge/participants.rs:385*
- **`persona.lounge.update-participant`** · _ga_ — PATCH /persona/rooms/:id/participants/:user_id — host-only role change (speaker↔viewer) + optional rename, broadcasts `room.presence role_changed`. Host minting is deliberately blocked.  
  *server/src/lounge/participants.rs:519*

### persona/lounge-chat
- **`persona.lounge-chat.is-moderator`** · _ga_ — Authorization gate — checks whether a user is the room owner or has a lounge_moderators row. Used by every WS moderator-action frame before any mutation. Re-queries DB per frame so live grants/revokes take effect immediately.  
  *server/src/lounge_chat/gate.rs:33*
- **`persona.lounge-chat.mod-ban`** · _ga_ — WS `mod_ban` frame handler — persists a row in lounge_bans (optional duration), broadcasts `ban_target` + `system_event`. Targeted user gets `you_are_banned` before disconnect.  
  *server/src/lounge_chat/mod_actions.rs:97*
- **`persona.lounge-chat.mod-delete-message`** · _ga_ — WS `mod_delete_message` frame handler — soft-deletes a chat row (sets deleted_at + deleted_by + reason), broadcasts `message_deleted` so every client redacts the row.  
  *server/src/lounge_chat/mod_actions.rs:315*
- **`persona.lounge-chat.mod-grant`** · _ga_ — WS `mod_grant_mod` frame handler — owner-only; upserts a lounge_moderators row, broadcasts `mod_granted` so all clients re-render permission badges.  
  *server/src/lounge_chat/mod_actions.rs:484*
- **`persona.lounge-chat.mod-kick`** · _ga_ — WS `mod_kick` frame handler — moderator boots a user from the room; broadcasts `kick_target` + `system_event` so the targeted socket gets a `you_are_kicked` farewell frame before disconnect.  
  *server/src/lounge_chat/mod_actions.rs:51*
- **`persona.lounge-chat.mod-mute`** · _ga_ — WS `mod_mute` frame handler — upserts lounge_mutes (duration-based, default 3600s), broadcasts `mute_target` + `system_event`. Targeted user gets `you_are_muted`.  
  *server/src/lounge_chat/mod_actions.rs:203*
- **`persona.lounge-chat.mod-only-mode`** · _ga_ — WS `mod_mod_only_mode` frame handler — toggles mod-only-mode in lounge_room_settings (only mods can send), appends audit row, broadcasts `room_settings_updated`.  
  *server/src/lounge_chat/mod_actions.rs:424*
- **`persona.lounge-chat.mod-revoke`** · _ga_ — WS `mod_revoke_mod` frame handler — owner-only; deletes a lounge_moderators row, broadcasts `mod_revoked`.  
  *server/src/lounge_chat/mod_actions.rs:534*
- **`persona.lounge-chat.mod-slow-mode`** · _ga_ — WS `mod_slow_mode` frame handler — persists slow_mode_seconds (0..=3600) into lounge_room_settings, appends settings-history audit row, broadcasts `room_settings_updated`.  
  *server/src/lounge_chat/mod_actions.rs:359*
- **`persona.lounge-chat.mod-unban`** · _ga_ — WS `mod_unban` frame handler — removes a lounge_bans row, broadcasts `unban_target` + `system_event`.  
  *server/src/lounge_chat/mod_actions.rs:164*
- **`persona.lounge-chat.mod-unmute`** · _ga_ — WS `mod_unmute` frame handler — removes a lounge_mutes row, broadcasts `unmute_target` + `system_event`.  
  *server/src/lounge_chat/mod_actions.rs:276*
- **`persona.lounge-chat.parse-mentions`** · _ga_ — Extract `@name` mentions from a chat message body — handles trailing punctuation, unicode dot stripping, returns raw names as typed for downstream resolver.  
  *server/src/lounge_chat/mentions.rs:36*
- **`persona.lounge-chat.report-rate-limit`** · _ga_ — Sliding-window rate-limit gate for chat reports — returns Some(retry_after_seconds) when the caller exceeded `limit` reports per `window_secs`, else None. Prevents report-spam DoS.  
  *server/src/lounge_chat/reports.rs:104*
- **`persona.lounge-chat.resolve-mentions`** · _ga_ — Map raw `@name` mentions to known agent_ids in the room — case-insensitive on agent display name AND participant id (so both @alice and @alice-uuid hit). De-duped, order-preserving.  
  *server/src/lounge_chat/mentions.rs:108*
- **`persona.lounge-chat.validate-report-kind`** · _ga_ — Validate the report `kind` field against the canonical allowlist (spam/harassment/hate/sexual/violence/other), returns the lowercase canonical form or None.  
  *server/src/lounge_chat/reports.rs:49*
- **`persona.lounge-chat.ws`** · _ga_ — GET /persona/rooms/:id/chat/ws — Lounge chat WebSocket upgrade. Authenticates via Bearer JWT query param, runs `chat_loop` (send/edit/delete/react frames + @mention agent routing + moderation frames), broadcasts `lounge_chat.*` PersonaEvents to room subscribers.  
  *server/src/lounge_chat/ws.rs:95*

### persona/lounge-moderation
- **`persona.lounge.audit-timeline`** · _ga_ — GET /persona/rooms/:id/audit — Phase 6 unified moderation timeline merging bans/mutes/message deletes/report resolutions/mod grants/slow-mode + mod-only toggles. Supports since/until/kind/limit filtering.  
  *server/src/lounge/audit.rs:42*
- **`persona.lounge.grant-moderator`** · _ga_ — POST /persona/rooms/:id/moderators — owner-only REST mirror of WS `mod_grant_mod`, grants moderator role to a user without requiring the chat WS to be open.  
  *server/src/lounge/moderation.rs:88*
- **`persona.lounge.list-reports`** · _ga_ — GET /persona/rooms/:id/reports?status=open|resolved|dismissed|actioned|all — owner/mod-only paginated reports list joined with reported message + reporter handle.  
  *server/src/lounge/reports.rs:32*
- **`persona.lounge.moderation-snapshot`** · _ga_ — GET /persona/rooms/:id/moderation — owner/mod-only snapshot bundling slow_mode + mod_only_mode + bans + mutes + moderator roster for the Hub Mods tab.  
  *server/src/lounge/moderation.rs:35*
- **`persona.lounge.resolve-report`** · _ga_ — POST /persona/rooms/:id/reports/:report_id/resolve — owner/mod resolves a report with dismiss/delete_message/mute_author/ban_author, runs the side-effect, flips the row, broadcasts both `lounge_chat.*` events.  
  *server/src/lounge/reports.rs:103*
- **`persona.lounge.revoke-moderator`** · _ga_ — DELETE /persona/rooms/:id/moderators/:user_id — owner-only revoke of moderator role.  
  *server/src/lounge/moderation.rs:133*
- **`persona.lounge.set-mod-only-mode`** · _ga_ — POST /persona/rooms/:id/mod_only_mode — owner/mod-only REST mirror of WS `mod_mod_only_mode`, persists the flag, appends settings-history audit row, broadcasts `lounge_chat.room_settings_updated`.  
  *server/src/lounge/moderation.rs:325*
- **`persona.lounge.set-slow-mode`** · _ga_ — POST /persona/rooms/:id/slow_mode — owner/mod-only REST mirror of WS `mod_slow_mode`, persists slow_mode_seconds, appends settings-history audit row, broadcasts `lounge_chat.room_settings_updated`.  
  *server/src/lounge/moderation.rs:249*
- **`persona.lounge.unban-user`** · _ga_ — DELETE /persona/rooms/:id/bans/:user_id — owner/mod-only ban removal REST mirror.  
  *server/src/lounge/moderation.rs:171*
- **`persona.lounge.unmute-user`** · _ga_ — DELETE /persona/rooms/:id/mutes/:user_id — owner/mod-only mute removal REST mirror.  
  *server/src/lounge/moderation.rs:209*

### persona/middleware
- **`persona.middleware.build-cors`** · _ga_ — Build the CORS layer from `PERSONA_CORS_ORIGINS` (CSV allowlist). Falls back to `AllowOrigin::any()` with a warn when unset so dev boxes work without explicit config.  
  *server/src/middleware.rs:173*
- **`persona.middleware.check-auth`** · _ga_ — PERSONA_API_KEY gate — constant-time compare against `X-API-Key` or `Authorization: Bearer <key>`. Open access when the env is unset (dev). Defends against timing-side-channels via `subtle`.  
  *server/src/middleware.rs:43*
- **`persona.middleware.http`** — HTTP middleware: PERSONA_API_KEY gate (check_auth), X-Internal-Secret gate (verify_internal_secret_env — returns 503 when env var unset), per-IP sliding-window rate limiter (RateLimiter), and CORS layer sourced from PERSONA_CORS_ORIGINS env var. HTTP middleware + auth helpers for persona-server. Extracted from `main.rs` (2026-05-07 split). Owns: * `check_auth` — `PERSONA_API_KEY` Bearer / X-API-Key gate, constant- time compare. Used by every state-mutating handler. * `verify_internal_secret_env` — `X-Internal-Secret` gate for the `/internal/*` endpoints (GDPR export, archive-now, redemption, poll-vote, static-mod, ai-moderation, etc.). Returns 503 if the env var is unset (refuse to expose with no auth) and 403 on mismatch. * `RateLimiter` — simple per-IP sliding window applied to every POST request via `axum::middleware::from_fn` in `main`. * `build_cors` — CORS layer with origins from `PERSONA_CORS_ORIGINS`.  
  *server/src/middleware.rs:1*
- **`persona.middleware.jwt-auth`** — Shared JWT auth helpers: Bearer token extraction, HS256 verification against SHARED_JWT_SECRET, issuer check (openalice-auth), refresh-token rejection, and tenant_id resolution (tid claim → sub fallback for legacy tokens). Replaces 16+ verbatim copies that existed across handler modules before the 2026-05-07 consolidation. Shared JWT-auth helpers for every persona-server handler module. Background: every CRUD module (`agents.rs`, `audio.rs`, `youtube_oauth.rs`, `polls.rs`, `lounge.rs`, etc.) declared its own `fn tenant_from_jwt(...)` + `struct StandardAccessClaims`, all verbatim. A QA audit (2026-05-07) flagged 16+ exact duplications; consolidating them here makes the auth contract a single-file edit and lets us add new fields (audience, scopes, expiry skew) once instead of touching every module. Contract: * Bearer token is read from the `Authorization` header. * Signed with `SHARED_JWT_SECRET` env var (HS256 default). * Issuer claim must be `openalice-auth`. * Audience is intentionally NOT validated — multi-service persona shares one access-token across BFFs. * `role: "refresh"` tokens are rejected (refresh tokens must only redeem at openalice-auth, never at downstream APIs). * Tenant id resolves to `tid` claim when set, else `sub`. This mirrors the one-user-one-tenant fallback for legacy tokens issued before the `tid` claim landed. Returns `None` on ANY failure (missing env var, malformed header, invalid signature, refresh-role) so the caller can render a single 401 path. Callers that need to distinguish failure modes can add a separate parsed-result helper later.  
  *server/src/auth.rs:1*
- **`persona.middleware.rate-limiter`** · _ga_ — Per-IP sliding-window rate limiter (Mutex<HashMap<IpAddr, Vec<Instant>>>). Mounted as the global POST-request limiter; returns retry-after-seconds via Err so the Axum layer can ship 429.  
  *server/src/middleware.rs:122*
- **`persona.middleware.verify-internal-secret`** · _ga_ — X-Internal-Secret gate for `/internal/*` endpoints — constant-time compare against `INTERNAL_BROADCAST_SECRET` (with INTERNAL_SECRET legacy fallback). 503 when unset (refuse to expose unauthed), 403 on mismatch.  
  *server/src/middleware.rs:81*

### persona/moderation
- **`persona.moderation.ai`** — Layer 2 of the moderation stack: after static rules pass, evaluates messages via a Codex-backed moderation agent that returns structured {verdict, confidence, reason}. Executes delete/ban/timeout actions above the confidence threshold; logs everything to the shared mod-actions table regardless of outcome. Budget guard short-circuits to allow when monthly usage is exhausted. AI moderation — layer 2 of the moderation stack (migration 0025, 2026-05-07, AI Moderation wave). Layer 1 (chat-bridge's `static_mod` engine + the `youtube_mod_policies` toggles) is deterministic — link blocker, caps-lock, banned phrases, rate limits. Cheap and fast, no LLM round-trip. This module is layer 2. After a message survives the static rules, chat-bridge (or any other caller) POSTs the message + recent per-author history to `/internal/ai-mod-evaluate`. We: 1. Look up the tenant's `ai_mod_settings`. If AI mod is disabled or the monthly budget is exhausted we short-circuit to `verdict = allow` so the surface always returns a verdict and chat-bridge can keep the flow uniform. 2. POST to an internal "moderation agent" (a Codex-backed agent whose system prompt asks for a strict JSON verdict shape). The agent URL is `AI_MOD_AGENT_URL` (default reuses the tenant's first registered agent if no env override). 3. Parse the agent's structured JSON reply: `{ verdict: "allow"|"warn"|"delete"|"timeout"|"ban", suggested_duration_seconds?: int }`. 4. If `verdict != allow` AND `confidence >= settings.threshold`: execute the action via the existing YouTube moderation REST routes (`youtube_proxy::moderate_delete` / `moderate_ban` through chat-bridge). On a miss-threshold or `allow` we still log the row but with status `finalized` so the streamer can still see what the AI thought without an action having fired. 5. Always append to `mod_actions_log` with `source = 'ai'`. The row carries enough metadata for the Hub's "Pending review" panel to render: verdict + confidence + reason + the message body + the author handle. 6. Charge the tenant's `current_month_spent_cents` by the estimated cost (input + output token count × price/1k). Undo + feedback loop: * Rows are inserted with `status = 'pending_review'` when the verdict triggered an actual mod action (Delete / Timeout / Ban). The streamer has `undo_window_seconds` (default 300s) to POST to `/v1/me/mod-actions-log/:id/undo` and reverse the YT-side action (best-effort: delete-ban, lift-timeout, restore-message). On undo we capture the streamer's note in `ai_mod_feedback` so the Hub's training-data pane can surface "things the AI keeps getting wrong". * Rows that never get undone flip to `status = 'finalized'` after the window expires — handled lazily on read so we don't need a background scheduler.  
  *server/src/ai_moderation.rs:1*
- **`persona.moderation.static`** — Nightbot-tier static moderation rules CRUD + shared mod-actions audit log. Chat-bridge fetches rules on boot and listens for static_mod_rules_changed SSE events on /internal/static-mod-rules/events to hot-reload without restart. Both the static engine and the AI-moderation agent write to the same mod-actions table for a single chronological audit feed in the Hub. Static (Nightbot-tier) moderation rules + the shared mod-action audit log (2026-05-07, auto-mod wave). Persona-server owns the rule storage + the audit-log table. Chat-bridge owns the actual rule engine (`src/static_mod.rs` there) — it fetches rules from this module on boot and re-fetches whenever it observes a `static_mod_rules_changed` event on the internal SSE channel exposed here. Routes wired in `main.rs`: ```text GET    /v1/me/static-mod-rules              (bearer JWT) POST   /v1/me/static-mod-rules              (bearer JWT) PATCH  /v1/me/static-mod-rules/{id}         (bearer JWT) DELETE /v1/me/static-mod-rules/{id}         (bearer JWT) GET    /v1/me/mod-actions-log               (bearer JWT) GET    /internal/static-mod-rules           (X-Internal-Secret) GET    /internal/static-mod-rules/events    (X-Internal-Secret SSE) POST   /internal/mod-actions-log            (X-Internal-Secret) ``` The audit log is a shared surface: both the static engine in chat-bridge AND the AI-moderation agent insert rows here so the Hub Action-log page shows one chronological feed.  
  *server/src/static_mod.rs:1*

### persona/multiplayer
- **`persona.multiplayer.interpolation`** — Entity interpolation for remote players: 100ms render-delay buffer absorbs jitter and packet loss. Linear interpolation between known positions; extrapolation (capped 2×) for missing updates; animation state picked at interpolation midpoint. Inspired by Source Engine + Overwatch netcode.  
  *packages/multiplayer/src/useInterpolation.ts:1*
- **`persona.multiplayer.prediction`** — Client-side prediction with server reconciliation: inputs applied immediately (no round-trip lag), history stored with sequence numbers, server corrections replay unconfirmed inputs from confirmation point. Corrections interpolated over ~200ms to avoid jarring position snaps.  
  *packages/multiplayer/src/usePrediction.ts:1*

### persona/music-gen
- **`persona.music-gen.get-job`** · _beta_ — GET /v1/me/audio/generate/:job_id — poll the async Lyria 3 job until status flips to succeeded (audio_track_id set) or failed (error_message set). Tenant-scoped 404.  
  *server/src/music_gen.rs:450*
- **`persona.music-gen.list-jobs`** · _beta_ — GET /v1/me/audio/generate — list recent music-generation jobs for the Hub history panel (clamped 1..100).  
  *server/src/music_gen.rs:476*
- **`persona.music-gen.post-generate`** · _beta_ — POST /v1/me/audio/generate — async AI music generation via Lyria 3 (OpenRouter `google/lyria-3-pro-preview`, $0.08/song). Validates prompt/duration/tags/budget, spawns the tokio job, persists the result into audio_tracks with source='generated' so the BGM picker auto-surfaces it.  
  *server/src/music_gen.rs:324*

### persona/oauth-audit
- **`persona.oauth-audit.get-feed`** · _ga_ — GET /v1/me/youtube/audit — paginated audit-log feed (newest-first, limit clamped 1..200, `before` cursor) for the Hub `/account/youtube` security card.  
  *server/src/oauth_audit.rs:190*
- **`persona.oauth-audit.record`** · _ga_ — Best-effort insert of one YouTube OAuth audit row (op + status + ip + user-agent + error + extras). Never fails the calling operation — DB hiccup logs via tracing and continues.  
  *server/src/oauth_audit.rs:121*

### persona/ops
- **`persona.ops.health`** · _ga_ — GET /health — liveness probe (returns "ok"). Consumed by Traefik + monitoring sidecars.  
  *server/src/metrics_routes.rs:26*
- **`persona.ops.metrics`** — Prometheus metrics exposition at /metrics: uptime, command count, room count, TTS-cache hit/miss/eviction, secrets-cipher counters, boot-migration counters, YouTube OAuth + stream-key event counters. /health returns static 200 "ok" for load-balancer liveness checks. Ops endpoints — `/health` + `/metrics`. Extracted from `main.rs` (2026-05-07 split). Health is a static 200 OK with body "ok"; metrics is a hand-rolled Prometheus text exposition covering uptime, command count, room count, TTS-cache hit / miss / eviction, secrets-cipher encrypt / decrypt / decrypt-failure, the boot-migration counters left over from migrations 0023+0027, and the YouTube OAuth + stream-key counters added in the 2026-05-07 SOTA-security wave.  
  *server/src/metrics_routes.rs:1*
- **`persona.ops.metrics`** · _ga_ — GET /metrics — Prometheus exposition (commands_total, voice_cache_hits/misses/evictions, secrets encrypt/decrypt counters, AI moderation cost). Scraped by openalice-monitoring's Prometheus.  
  *server/src/metrics_routes.rs:38*

### persona/polls
- **`persona.polls.auto-close-task`** · _ga_ — Background tokio task that calls `auto_close_tick` every 5s — the auto-close engine that fires `poll_closed` when a poll's `duration_seconds` elapses.  
  *server/src/polls.rs:705*
- **`persona.polls.auto-close-tick`** · _ga_ — Single-iteration sweep that finds open polls whose `created_at + duration_seconds < now` and closes them (persist final tally, broadcast `poll_closed`). Returns closed count for observability + test assertions.  
  *server/src/polls.rs:668*
- **`persona.polls.close`** · _ga_ — POST /v1/me/polls/:id/close — manual close from the Hub; idempotent, persists final tally, broadcasts `poll_closed` PersonaEvent. Same outcome as the 5s auto-close sweep.  
  *server/src/polls.rs:544*
- **`persona.polls.create`** · _ga_ — POST /v1/me/polls — engagement-poll create from the Hub (question + options + duration). Persists the row + broadcasts `poll_opened` PersonaEvent so live overlays render the bar immediately.  
  *server/src/polls.rs:449*
- **`persona.polls.get`** · _ga_ — GET /v1/me/polls/:id — fetch a single poll with current tally for the Hub poll-detail page. Tenant-scoped 404 enforced.  
  *server/src/polls.rs:517*
- **`persona.polls.internal-vote`** · _ga_ — POST /internal/poll-vote — chat-bridge posts a viewer vote here after matching chat text against the open poll's options. X-Internal-Secret gated; upserts the vote row and broadcasts `poll_vote` PersonaEvent.  
  *server/src/polls.rs:725*
- **`persona.polls.list`** · _ga_ — GET /v1/me/polls?broadcast_id=&limit= — list recent polls (clamped 1..200) for the JWT tenant; drives the Hub Polls history tab.  
  *server/src/polls.rs:578*
- **`persona.polls.list-active-internal`** · _ga_ — GET /internal/polls/active — chat-bridge containers poll this on a 5s cadence to keep their in-memory `active_polls` map in sync with persona truth. X-Internal-Secret gated; returns every open poll across all tenants.  
  *server/src/polls.rs:619*

### persona/radio-bridge
- **`persona.radio-bridge.start`** · _alpha_ — Spawn a WS bridge to `<radio_base>/ws/<slug>` that forwards every radio frame as a PersonaEvent on the room's broadcast bus. Powers the `bgm_source: "radio:<slug>"` agent override. Drop the returned BridgeHandle to stop.  
  *server/src/radio_bridge.rs:137*
- **`persona.radio-bridge.translate-frame`** · _alpha_ — Pure translator — wrap a raw radio-room JSON payload into a `radio.frame`-topic PersonaEvent with current timestamp. Test-friendly entry point that doesn't require a live WS.  
  *server/src/radio_bridge.rs:252*

### persona/redemption
- **`persona.redemption.create-trigger`** · _ga_ — POST /v1/me/redemption-triggers — bind a (platform, redemption_key) pair to one live action (play_sound / scene_change / gesture / tts_shout / expression). Per-tenant, optionally per-agent.  
  *server/src/redemption_triggers.rs:334*
- **`persona.redemption.delete-trigger`** · _ga_ — DELETE /v1/me/redemption-triggers/:id — tenant-scoped delete of one redemption-trigger binding.  
  *server/src/redemption_triggers.rs:657*
- **`persona.redemption.dispatch`** · _ga_ — POST /internal/redemption-received — the dispatcher chat-bridge hits when a viewer redeems on Twitch/YT. Resolves the matching (platform, key) trigger, broadcasts the configured PersonaEvent + a `redemption_triggered` overlay event. X-Internal-Secret gated.  
  *server/src/redemption_triggers.rs:720*
- **`persona.redemption.list-triggers`** · _ga_ — GET /v1/me/redemption-triggers?agent_id= — list channel-point / SuperChat redemption-trigger bindings (platform + key → action) with agent-scoped + tenant-wide fallback.  
  *server/src/redemption_triggers.rs:286*
- **`persona.redemption.patch-trigger`** · _ga_ — PATCH /v1/me/redemption-triggers/:id — partial update of a trigger binding (action, enabled flag, agent rebind via explicit null).  
  *server/src/redemption_triggers.rs:422*
- **`persona.redemption.test-trigger`** · _ga_ — POST /v1/me/redemption-triggers/:id/test — Hub "test fire" button. Fabricates a fake RedemptionEvent and dispatches through the same broadcast path as /internal/redemption-received so the live page sees a byte-identical event.  
  *server/src/redemption_triggers.rs:547*

### persona/render
- **`persona.web.avatar-render-modes`** · _beta_ · since 0.9.0 — Screen-space post-processing render modes for the VRM avatar (3d / 2d-toon / 2d-flat). Applies a custom EffectComposer chain — toon posterize, sobel outline, chromatic aberration, scan lines, bloom-tinted hologram, CRT flicker — over the rendered scene WITHOUT touching the VRM's materials. The 3D path collapses to a null composer (zero cost).  
  *apps/web/src/components/AvatarRenderConfig.ts:29*

### persona/room
- **`persona.room.lounge-chat`** — Real-time text chat (Phase 3) + moderation (Phase 4) for Discord-Style Lounge. WebSocket at /persona/rooms/:id/chat/ws. Frames: join, send (body + reply_to), typing client→server; joined (backfill), message, typing, presence, error server→client. Mentions (@user), agent AI responses, mod actions (delete, timeout, ban) all wired through this module. Discord-Style Lounge — Phase 3 (Real-time Text Chat) + Phase 4 (Moderation). This module owns the lounge chat surface end-to-end: ```text client          server                 db                       agent ┌────────┐  send  ┌─────────┐ INSERT  ┌────────────────┐         ┌─────────┐ │  panel │──────▶ │ chat ws │────────▶│ lounge_messages│         │ /v1/chat│ └────────┘        └─────────┘         └────────────────┘         └─────────┘ ▲     message    ▲                                              │ └────────────────┘                                              │ ▲     message     ◀────────  agent_response  ◀───── http stream ┘ ``` ### Wire protocol — Phase 3 `GET /persona/rooms/:id/chat/ws` upgrades to WebSocket. Auth is a `?token=<jwt>` query parameter using `SHARED_JWT_SECRET` — same posture as the lounge HTTP endpoints in `lounge.rs`. All frames are JSON-encoded text WS messages with a `type` tag discriminator. Client→server frames: ```text { "type": "join"   }                              // first frame, optional explicit { "type": "send",  "body": "...", "reply_to": "<uuid>"? } { "type": "typing" } ``` Server→client frames: ```text { "type": "joined",   "user_id":"…", "history":[{...}, ...], "participants":[…] } { "type": "message",  "id":"<uuid>", "author_id":"…", "author_name":"…", "author_type":"user|agent|system", "body":"…", "mentions":["…"], "reply_to":"<uuid>"?, "created_at":"…" } { "type": "typing",   "author_id":"…", "author_name":"…" } { "type": "presence", "joined":[…], "left":[…] } { "type": "error",    "code":"…", "message":"…" } ``` ### Wire protocol — Phase 4 (Moderation) Inbound (authenticated moderator client only — owner OR row in `lounge_room_moderators`): ```text { "type": "mod_kick",            "user_id":"…", "reason":"…"? } { "type": "mod_ban",             "user_id":"…", "reason":"…"?, "duration_seconds":3600? } { "type": "mod_unban",           "user_id":"…" } { "type": "mod_mute",            "user_id":"…", "duration_seconds":60, "reason":"…"? } { "type": "mod_unmute",          "user_id":"…" } { "type": "mod_delete_message",  "message_id":"<uuid>", "reason":"…"? } { "type": "mod_slow_mode",       "seconds":10 } { "type": "mod_mod_only_mode",   "enabled":true } { "type": "mod_grant_mod",       "user_id":"…" } { "type": "mod_revoke_mod",      "user_id":"…" } { "type": "mod_view_deleted",    "message_id":"<uuid>" }   // Phase 5 ``` Outbound: ```text { "type": "system_event",          "kind":"…", "target_user_id":"…"?, "by_user_id":"…", "reason":"…"?, "ts":"…" } { "type": "message_deleted",       "message_id":"<uuid>", "by_user_id":"…", "ts":"…" } { "type": "room_settings_updated", "slow_mode_seconds":10, "mod_only_mode":false, "ts":"…" } { "type": "you_are_kicked",        "reason":"…"?, "ts":"…" } { "type": "you_are_banned",        "reason":"…"?, "expires_at":"…"?, "ts":"…" } { "type": "you_are_muted",         "duration_seconds":60, "expires_at":"…", "reason":"…"?, "ts":"…" } { "type": "mod_deleted_revealed",  "message_id":"<uuid>", "original_body":"…", "deleted_by":"…"?, "delete_reason":"…"?, "deleted_at":"…"? }  // Phase 5 ``` ### Wire protocol — Phase 5 (Reports) Inbound (any participant, NOT mod-only): ```text { "type": "report_message", "message_id":"<uuid>", "kind":"spam|harassment|nsfw|off_topic|other", "reason":"…"? } ``` Outbound (delivered ONLY to mods/owner via the per-socket filter in `chat_loop`): ```text { "type": "report_received", "report_id":"<uuid>", "reporter_user_id":"…", "reported_message_id":"<uuid>", "kind":"…", "reason":"…"?, "ts":"…" } { "type": "report_resolved", "report_id":"<uuid>", "resolved_by":"…", "resolution":"…", "ts":"…" } ``` ### Wire protocol — Phase 6 (Hub Reports live subscription) The Hub's per-room Reports page (openalice-app `/account/lounge/:id/reports`) opens the same chat WS to receive `report_received` / `report_resolved` frames live instead of polling REST. To make the moderator subscription explicit (so the client can distinguish "I'm connected" from "I'm connected AND I'll see reports"), the client sends a one-shot subscribe frame after `joined`: ```text { "type": "subscribe_mods" } ``` The server gates by `is_moderator(room, user)` and replies with a private frame addressed to the asking socket only (via the same `private_mod` mechanism `mod_view_deleted` uses): ```text { "type": "mods_subscribed", "room_id":"…", "ts":"…" }        // success { "type": "error", "code":"not_moderator", "message":"…" }   // refusal ``` The actual filtering of `report_received` / `report_resolved` still happens at fan-out time (the `chat_loop` send branch checks `is_moderator` on every event), so a moderator grant mid-session lands on the next fanned-out report without a re-subscribe. `subscribe_mods` is therefore an *acknowledgement* frame, not a "Live" indicator. ### Send-gate (every inbound `send`) 1. **Banned?** Reject with `error { kind: "banned" }`, close WS. 2. **Muted?** Reject with `error { kind: "muted", expires_at }`. 3. **Slow-mode?** Reject if user posted within `slow_mode_seconds`. 4. **Mod-only-mode?** Reject when `!is_moderator(room, user)`. Posts that pass the gate update the per-(room,user) last-post timestamp used by the slow-mode check. ### @mention semantics On every `send` frame the server scans the body for `@<name>` tokens (case-insensitive against the agent's stored `name`) and resolves each to a `room_participants.user_id` of type `agent`. The resolved ids land in `lounge_messages.mentions`. For each mentioned agent we kick an async HTTP call to the agent's `/v1/chat/stream` endpoint; the response is persisted as another `lounge_messages` row (author_type=`agent`) and broadcast via the same WS channel. When *no* agent is mentioned, no agent call is made — that's the deliberate Phase 3 routing rule (everyone-by-default would have N agents speaking on every user message). A future flag could re-enable broadcast routing per room.  
  *server/src/lounge_chat/mod.rs:1*
- **`persona.room.lounge-chat-ws`** — Typed WebSocket client for the Discord-Style Lounge Phase 3 chat surface. Handles join/send/typing frames client→server; joined/message/typing/ presence/error frames server→client. Auth via ?token= query param (only reliable WS auth in browsers). Auto-reconnect on close with backoff.  
  *apps/web/src/lib/loungeChatWs.ts:1*
- **`persona.room.lounge-crud`** — Discord-Style Lounge Phase 2: room CRUD (create/list/get/delete) and participant management (add agent, kick user, role promotion). Access Tenant-scoped — every query includes tenant_id so cross-tenant access is impossible regardless of guessed room IDs. Discord-Style Lounge — Phase 2 endpoints (Participant Management). Phase 1 of the lounge feature shipped the multi-agent 3D scene (`MultiAgentScene` + per-agent `agentStore` slices on the web side); this module is the missing CRUD that lets a tenant actually CREATE a lounge room, hand out an invite link, and ADD agents to populate the scene. ## Auth + tenant scoping Every handler in this module extracts the calling tenant via `tenant_from_jwt` (same SHARED_JWT_SECRET rule the VRM / font / audio uploads use). The DB queries always include `tenant_id` in the WHERE clause so a Tenant B caller cannot reach Tenant A's rooms even with a guessed room_id. `public` rooms are visible cross-tenant for read + join; `private` and `invite` rooms are tenant-only. ## Role rules - `host`     — the room creator (auto-assigned on create_room). Can add/remove agents, kick users, promote viewers. - `speaker`  — has voice + chat permissions. Default role for agents added by the host. - `viewer`   — chat-only, no voice / management. Default role for users joining a public room. The `PATCH /persona/rooms/:id/participants/:uid` endpoint is host-only — it's the only way to promote a viewer to speaker (or demote a speaker back to viewer). ## SSE presence Mutations (create/join/leave/add-agent/remove-agent/role-change) broadcast a `room.presence` event on the room's PersonaEngine channel so other connected clients update their participant sidebars in real time. The web `usePersonaConnector` hook listens for these events and patches `agentStore`. The same channel is already used by Phase 1's per-agent emotion/animation events so we don't add new transport here. ## Module composition (Wave 13-B) `mod.rs` is the public crate surface — `routes.rs` reaches the handlers through `lounge::*` re-exports below. Concern-specific submodules: - `common`        — auth + validators + role gates + SSE helper. - `rooms`         — room CRUD (create / get / list). - `participants`  — join / leave / list / role update + add-agent / remove-agent. - `moderation`    — Phase 4/5 REST mirror (mods, bans, mutes, slow_mode, mod_only). - `reports`       — Phase 5 list + resolve report. - `audit`         — Phase 6 unified moderation timeline.  
  *server/src/lounge/mod.rs:1*
- **`persona.room.world-sync`** · _beta_ · since 0.1.0 — Multiplayer presence — direct WebSocket to RT service, broadcasts position at 10fps, returns remote players.  
  *packages/multiplayer/src/useWorldSync.ts:40*

### persona/room-ws
- **`persona.room-ws.handle-loop`** · _ga_ — Per-connection WS frame loop — multiplexes chat, position updates (validated via PositionValidator), weather pulls, ping/pong, and engine event fan-out from the PersonaEngine broadcast channel.  
  *server/src/room_ws.rs:129*
- **`persona.room-ws.upgrade`** · _ga_ — GET /persona/rooms/:id/ws — WS upgrade for the legacy 3D-world realtime channel (chat + position updates + presence). Resolves room token permissions, attaches PositionValidator + ServerWeather + WorldConfig.  
  *server/src/room_ws.rs:68*

### persona/scene
- **`persona.scene.multi-agent`** — Renders multiple VRM agents in a semi-circle layout for Discord Lounge mode. Each agent gets an independent AgentAvatar with its own emotion, gesture, lipsync, and position state from useAgentStore. Agent count is the Zustand primitive dependency to avoid getSnapshot array instability.  
  *apps/web/src/components/MultiAgentScene.tsx:1*
- **`persona.scene.splitscreen`** — Multi-cell splitscreen scene: ×2 and ×4 layouts with independent camera presets per cell. Supports oa-cell-camera and oa-cell-focus postMessages for agent-driven cell spotlight. Hosts both the primary AvatarModel and the MultiAgentScene in a single R3F Canvas with per-cell viewport transforms.  
  *apps/web/src/components/SplitscreenScene.tsx:1*

### persona/secrets
- **`persona.secrets.cipher`** · _ga_ — AES-256-GCM envelope cipher for long-lived DB secrets (YT refresh_token / access_token / stream_key + future Twitch tokens). Mounted on AppState, Arc-cloned for fan-out. Boots from `OA_SECRETS_MASTER_KEY` (prod) or HKDF-derived dev key.  
  *server/src/secrets.rs:178*
- **`persona.secrets.cipher-from-env`** · _ga_ — Boot-time Cipher constructor — in prod `OA_SECRETS_MASTER_KEY` (64-hex) MUST be set or process panics; in dev unset falls back to HKDF-SHA256(PERSONA_JWT_SECRET) with loud warning.  
  *server/src/secrets.rs:200*
- **`persona.secrets.derive-dev-key`** · _ga_ — Dev-only key derivation — HKDF-SHA256(PERSONA_JWT_SECRET → 32 bytes) with fixed `oa-secrets-dev` salt. Lets dev/lab boot without an explicit master key while keeping the production code path unchanged.  
  *server/src/secrets.rs:374*

### persona/security
- **`persona.security.oauth-audit`** — SOTA-security audit trail for all OAuth-adjacent operations: connect, disconnect, re-consent, refresh, stream-key create/transition/complete, revoke. Backed by oauth_audit_log table (migration 0030). Paginated read at GET /v1/me/youtube/audit; counters exported to /metrics. Provider- neutral design: future Twitch/Kick OAuth rides the same table. OAuth audit log — SOTA-security trail for every YouTube-OAuth- adjacent operation (connect, disconnect, re-consent, refresh, stream-key create / transition / complete, revoke). Migration 0030 (2026-05-07) backs this module with the `oauth_audit_log` table. # Wire types Operations are open strings (no DB CHECK constraint) so a new operation kind doesn't need a migration. The constants on [`AuditOp`] cover what we record today; future Twitch/Kick OAuth work rides the same table by setting `provider` to a different value (today everything is `"youtube"`). # Routes ```text GET   /v1/me/youtube/audit?limit=&before=   (bearer JWT) ``` The audit feed is paginated (cursor on `created_at`). Hub `/account/youtube` renders the most recent ~50 rows in a card at the bottom of the page. Operators get the same data via the `/metrics` counters added by the audit-bumping handlers.  
  *server/src/oauth_audit.rs:1*
- **`persona.security.secrets-cipher`** — AES-256-GCM envelope encryption for long-lived secrets at rest (YouTube OAuth refresh tokens, stream keys, future Twitch credentials). Threat model: stolen pg_dump reveals only ciphertext without the OA_SECRETS_MASTER_KEY env var. Cipher is initialized once at boot; counters exposed via /metrics for encrypt/decrypt/decrypt-failure events. Envelope encryption of long-lived secrets stored at rest. 2026-05-16 shipped `youtube_oauth_tokens.refresh_token` as plaintext (migration 0022, the YT Tier 1 wave). NAO flagged the obvious DB-leak risk, so this module wraps everything we'd lose sleep over if Postgres ever leaked: YouTube OAuth refresh tokens today, Twitch refresh tokens when Twitch returns, stream-keys, whatever future long-lived credential we add. # Threat model - Attacker steals a `pg_dump` of the persona database. Without the master key in env they can't recover any encrypted column. - Attacker reads the running container's memory. AES-GCM doesn't help here — they'd see the master key in memory and the plaintexts on the heap during the request that uses them. This module isn't a defence against live RCE; it's a defence against stolen backups + DB compromise. - Attacker has only DB access (e.g. read-replica leak via misconfig). No master key → all `refresh_token_ciphertext` columns are noise. # Crypto choices - **AES-256-GCM** via the `aes-gcm` crate. Standard authenticated AEAD, hardware-accelerated on x86_64/aarch64, the same primitive AWS KMS / GCP KMS use internally so future migration is a swap of the `encrypt` / `decrypt` body, not of the wire format. - **96-bit random nonce per call** via `OsRng`. Acceptable for the (low) write volume of this module — birthday risk on 2^32 encryptions with the same key, which is generations of writes away. Rotation flow below covers the once-a-decade ceiling. - **Version byte** so a future key rotation can: 1. bump `CURRENT_VERSION` to 2, 2. add a `version=1` decrypt path that uses the old key, 3. re-encrypt every row to version 2 on read, 4. drop the v1 key once the metric `legacy_plaintext_reads_total` (and an equivalent `legacy_v1_decrypts_total` once we add it) hits zero for a sustained window. # Env var - `OA_SECRETS_MASTER_KEY` — 64-char lowercase hex string (32 raw bytes) of the AES-256 master key. Generate with `openssl rand -hex 32`. **Required in production.** - When the env var is absent, dev boxes derive a deterministic 32-byte key from `PERSONA_JWT_SECRET` via HKDF-SHA256 with the fixed salt `"oa-secrets-dev"`. This is INSECURE — the JWT secret is committed to repo / docker-compose defaults — and is gated behind the absence of the prod env: `OA_ENV != "prod"` (or unset). When `OA_ENV=prod` and `OA_SECRETS_MASTER_KEY` is missing or malformed, `Cipher::from_env` panics at boot. Better to fail loud than to silently encrypt prod secrets under a key derived from the JWT secret. # Wire format A successful `encrypt` returns an [`EncryptedValue`] with three base64-encoded fields and a `version: u8`: ```text { "ciphertext_b64": "...", "nonce_b64": "...", "version": 1 } ``` Stored on disk as three separate columns (see migration 0023): `refresh_token_ciphertext TEXT`, `refresh_token_nonce TEXT`, `refresh_token_version INTEGER`. The version column is integer, not text, because Postgres + sqlx round-trip integers cleaner; the wire JSON keeps it numeric for the same reason. # Future KMS migration `Cipher` is the only seam — once we wire KMS, swap the body of `encrypt` / `decrypt` to call out to the KMS client (envelope: per-row DEK encrypted under the KMS KEK, store the wrapped DEK alongside the ciphertext). The `EncryptedValue` shape doesn't change; the version byte distinguishes "self-managed AES key" from "KMS-wrapped DEK". Callers (`youtube_oauth::db_*_tokens`) never touch the byte layout — they just pass through the `EncryptedValue` shape.  
  *server/src/secrets.rs:1*

### persona/server
- **`persona.server.entrypoint`** · _ga_ — Top-level Axum bin — wires PersonaEngine + Postgres pool + auth + middleware + every feature module (lounge, sounds, ai_moderation, polls, music_gen, youtube_oauth, radio_bridge, idle_injector, archive cron) and launches the HTTP/SSE/WS surface on PERSONA_PORT.  
  *server/src/main.rs:9*
- **`persona.server.main`** · _ga_ — tokio entrypoint — initialises tracing, builds the AppState (DB + Cipher + caches + rate limiters), runs boot migrations, spawns background tasks (archiver / polls auto-close / idle injector), constructs the Axum Router, and binds on `PERSONA_BIND_ADDR`.  
  *server/src/main.rs:112*
- **`persona.server.router`** · _ga_ — Single Axum Router that wires every persona REST/SSE/WS endpoint to its handler module, then layers rate-limit + body-size cap + CORS + tracing — the canonical place to discover every exposed route.  
  *server/src/routes.rs:18*

### persona/server/db
- **`persona.db.core`** · _ga_ — PostgreSQL persistence root for persona-server — single `Db` struct with submodule-extended `impl` blocks (agents / audio / fonts / idle / lounge / redemption / reports / sounds / vrms), owns connect / migration / cross-cutting helpers.  
  *server/src/db/mod.rs:17*
- **`persona.db.lounge`** · _beta_ — Postgres persistence for Discord-style lounge rooms — rooms / messages / bans / mutes / moderators / per-room settings + history, plus the audit-timeline cross-reference reader that fuses mod actions and deleted messages into one moderator stream.  
  *server/src/db/lounge.rs:11*

### persona/server/lounge_chat
- **`persona.lounge-chat.ws`** · _beta_ — Per-connection lounge chat WebSocket — JWT auth on upgrade, send-gate + slow-mode + mod-only checks, @mention parsing with agent-response dispatch, full mod-action dispatch (ban/mute/delete/kick/slow/mod-only/grant-revoke), report flow, broadcast fan-out to subscribed mods.  
  *server/src/lounge_chat/ws.rs:7*

### persona/server/youtube
- **`persona.youtube.oauth-handlers`** · _beta_ — REST surface for per-tenant YouTube OAuth — store / read / delete encrypted refresh+access tokens (AES-256-GCM via OA_SECRETS_MASTER_KEY), force-refresh, channel snapshot pull, and mod-policy CRUD; emits prometheus counters for refresh / revoke success+failure.  
  *server/src/youtube_oauth/handlers.rs:15*

### persona/sound
- **`persona.soundboard.list-settings`** · _ga_ — GET /v1/me/soundboard/settings — soundboard kill-switch toggle rows per (tenant, agent) with synthesised tenant-default fallback so the Hub renders correctly without a second round-trip.  
  *server/src/sounds/settings.rs:32*
- **`persona.soundboard.upsert-setting`** · _ga_ — PUT /v1/me/soundboard/settings — upsert one (tenant, agent) kill-switch row; omitted agent_id sets the tenant-wide default. Consumed by `play_sound` gate.  
  *server/src/sounds/settings.rs:84*
- **`persona.sound.delete`** · _ga_ — DELETE /v1/me/sounds/:id — tenant-scoped row + on-disk file removal for the soundboard.  
  *server/src/sounds/routes.rs:393*
- **`persona.sound.list`** · _ga_ — GET /v1/me/sounds?agent_id= — list sounds with agent-scoped + tenant-wide fallback merging; rows decorated with public_url.  
  *server/src/sounds/routes.rs:219*
- **`persona.sound.patch`** · _ga_ — PATCH /v1/me/sounds/:id — partial update of sound metadata (name, category, tags) or rebind to a different agent / tenant-wide bank.  
  *server/src/sounds/routes.rs:309*
- **`persona.sound.play`** · _ga_ — POST /v1/me/sounds/play — agent-triggered SFX dispatch (Neuro-sama-style). Consults soundboard kill-switch gate, looks up sound by name, broadcasts `play_sound` PersonaEvent to the room's SSE/WS subscribers for the SfxPlayer iframe to play.  
  *server/src/sounds/routes.rs:474*
- **`persona.sound.serve-public`** · _ga_ — GET /sound/:tenant_id/:file_name — public unauthenticated sound asset endpoint with audio MIME, `ACAO: *`, immutable cache; row-existence guard defends against cross-tenant enumeration.  
  *server/src/sounds/routes.rs:596*
- **`persona.sound.upload`** · _ga_ — POST /v1/me/sounds — multipart SFX upload (Neuro-sama-style soundboard) with optional agent binding, category + tags, magic-byte format sniff, atomic file write.  
  *server/src/sounds/routes.rs:28*

### persona/state
- **`persona.command.default`** · _ga_ — POST /persona/command — execute a PersonaCommand against the default room (emotion/animation/scene/tts/lipsync). Auth-gated for write commands; broadcasts the resulting state change on the room's SSE bus.  
  *server/src/persona_routes.rs:59*
- **`persona.command.room`** · _ga_ — POST /persona/rooms/:id/command — execute a PersonaCommand against one specific room. Auth-gated; broadcasts the resulting state change on the room's SSE bus.  
  *server/src/persona_routes.rs:239*
- **`persona.engine.execute`** · _ga_ — Apply one PersonaCommand atomically — validates (incl. SSRF for PlayVoice), mutates PersonaState under a write lock, bumps `version`, emits a PersonaEvent on the broadcast channel.  
  *server/src/state/engine.rs:93*
- **`persona.room-manager.get-or-create`** · _ga_ — Fast-path read + slow-path write to fetch or materialise a room's PersonaEngine. Runs TTL eviction (>1h idle) before reject-on-capacity. Returns Arc<PersonaEngine>.  
  *server/src/state/rooms.rs:103*
- **`persona.rooms.list-active`** · _ga_ — GET /persona/rooms — debug/dev surface listing active in-memory rooms (room ids + counts). Distinct from the JWT-gated tenant-scoped `/persona/lounge/rooms`.  
  *server/src/persona_routes.rs:204*
- **`persona.server-state.new`** · _ga_ — Boot-time constructor — connects to PostgreSQL (DATABASE_URL required), runs migrations via `db::Db::connect`, builds RoomManager + TtsCache + Cipher (OA_SECRETS_MASTER_KEY) + UsageTracker + chat-limiter + RulesChangedBus.  
  *server/src/state/server.rs:94*
- **`persona.state.get-default`** · _ga_ — GET /persona/state — snapshot of the default room's PersonaState (emotion + animation + scene + tts + lipsync). Back-compat single-agent surface for legacy callers.  
  *server/src/persona_routes.rs:47*
- **`persona.state.get-room`** · _ga_ — GET /persona/rooms/:id/state — room-scoped PersonaState snapshot. Auto-materialises the in-memory PersonaEngine on first hit.  
  *server/src/persona_routes.rs:217*
- **`persona.state.persona-engine`** · _ga_ — Per-room state machine — owns the PersonaState RwLock + the broadcast::Sender<PersonaEvent>. Every command + side-effect goes through `execute` which validates + mutates + emits a PersonaEvent.  
  *server/src/state/engine.rs:23*
- **`persona.state.persona-event`** · _ga_ — Topic-prefixed broadcast envelope (`topic` + `payload` + `timestamp` + optional `agent_id` for multi-agent rooms). The single unit every SSE/WS subscriber receives from a PersonaEngine.  
  *server/src/state/types.rs:190*
- **`persona.state.persona-state`** · _ga_ — The canonical per-room PersonaState aggregate (emotion + expression blendshapes + animation + scene + voice + avatar look + props). Serialised over SSE/WS to every viewer.  
  *server/src/state/types.rs:62*
- **`persona.state.room-manager`** · _ga_ — Multi-room registry — holds the in-memory PersonaEngine + ServerWeather + WorldConfig + PositionValidator per room. Capacity-bounded via PERSONA_MAX_ROOMS with TTL eviction (>1h idle). Owns the idle-injector WatermarkMap.  
  *server/src/state/rooms.rs:39*
- **`persona.state.server-state`** · _ga_ — Top-level Axum `State<AppState>` — owns RoomManager, DB pool, UsageTracker, ChatRateLimiter, per-tenant TtsCache, AES-256-GCM Cipher for DB secrets, YT API trait object, and the RulesChangedBus. Built once at boot in `ServerState::new()`.  
  *server/src/state/server.rs:27*
- **`persona.state.validate-command`** · _ga_ — Validate any incoming PersonaCommand — emotion against the canonical allowlist, intensity in [0,1], lighting against allowlist, urls non-empty, etc. Returns a human-readable error string on rejection.  
  *server/src/state/validation.rs:25*
- **`persona.state.validate-voice-url`** · _ga_ — SSRF guard for PlayVoice URLs — blocks private/loopback/link-local/AWS-metadata IPs and non-HTTPS hosts. Allows localhost only in dev mode.  
  *server/src/state/validation.rs:90*
- **`persona.stream.default-sse`** · _ga_ — GET /persona/stream — SSE channel for the default room — ships initial state snapshot + every PersonaEvent emitted thereafter; 15s heartbeat keeps Traefik from culling idle streams.  
  *server/src/persona_routes.rs:97*
- **`persona.stream.room-sse`** · _ga_ — GET /persona/rooms/:id/stream — room-scoped SSE channel — initial snapshot + every PersonaEvent thereafter; 15s heartbeat.  
  *server/src/persona_routes.rs:282*

### persona/static-mod
- **`persona.static-mod.create-rule`** · _ga_ — POST /v1/me/static-mod-rules — create a new static moderation rule and notify the chat-bridge static_mod_bus so listeners refetch immediately.  
  *server/src/static_mod.rs:574*
- **`persona.static-mod.delete-rule`** · _ga_ — DELETE /v1/me/static-mod-rules/:id — tenant-scoped delete of a static moderation rule and chat-bridge notify.  
  *server/src/static_mod.rs:643*
- **`persona.static-mod.internal-list-rules`** · _ga_ — GET /internal/static-mod-rules?tenant_id=&broadcast_id= — chat-bridge fetches rules for one tenant on boot + on every rules-changed SSE event. X-Internal-Secret gated.  
  *server/src/static_mod.rs:708*
- **`persona.static-mod.internal-log-action`** · _ga_ — POST /internal/mod-actions-log — append-only audit-log write shared between the static moderation engine and the AI moderation agent so the Hub Action-log surface is unified. X-Internal-Secret gated.  
  *server/src/static_mod.rs:726*
- **`persona.static-mod.internal-rules-events`** · _ga_ — GET /internal/static-mod-rules/events — SSE stream of `static_mod_rules_changed` events; chat-bridge subscribes and refetches rules for the published tenant_id. X-Internal-Secret gated.  
  *server/src/static_mod.rs:766*
- **`persona.static-mod.list-log`** · _ga_ — GET /v1/me/mod-actions-log — unified moderation action-log feed (static + AI mod sources) for the Hub Action-log panel. Supports since + limit filters.  
  *server/src/static_mod.rs:673*
- **`persona.static-mod.list-rules`** · _ga_ — GET /v1/me/static-mod-rules?broadcast_id= — list this tenant's Nightbot-tier static moderation rules (keyword/regex/repeat triggers → delete/timeout/ban). Powers the Hub `/account/youtube/auto-mod` editor.  
  *server/src/static_mod.rs:556*
- **`persona.static-mod.patch-rule`** · _ga_ — PATCH /v1/me/static-mod-rules/:id — partial update of a static moderation rule and notify the chat-bridge static_mod_bus.  
  *server/src/static_mod.rs:611*

### persona/store
- **`persona.store.agents`** — Zustand store for multi-agent room state: each agent has independent VRM URL, emotion, gesture queue, lipsync state, and 3D scene position. Used by MultiAgentScene and SplitscreenScene. Agents are keyed by ID; position is computed in a semi-circle layout when added via addAgent().  
  *apps/web/src/stores/agentStore.ts:1*

### persona/token
- **`persona.token.generate`** · _ga_ — Mint a per-room JWT capability token — bakes room_id + permission bits (can_chat / can_voice / can_admin) + expiry. Signed with `PERSONA_JWT_SECRET`. Backs the shareable-URL flow.  
  *server/src/token.rs:67*
- **`persona.token.resolve-url`** · _ga_ — GET /persona/resolve/:token — URL-token resolver (`persona.${DOMAIN}/<token>` shape) returning room_id + permission bits so the frontend can mount the right scene without a separate query.  
  *server/src/legacy_routes.rs:405*
- **`persona.token.room-mint`** · _ga_ — POST /persona/rooms/:id/token — mint a per-room JWT capability token (chat/voice/admin permission bits). Auth-gated; used to hand out shareable URLs.  
  *server/src/legacy_routes.rs:311*
- **`persona.token.room-validate`** · _ga_ — GET /persona/rooms/:id/validate-token — validate a per-room JWT, returns the embedded permission bits + room_id. Live page calls this before opening the WS.  
  *server/src/legacy_routes.rs:357*
- **`persona.token.validate`** · _ga_ — Decode + verify a per-room JWT against `PERSONA_JWT_SECRET`; returns RoomTokenClaims (room_id + perm bits + expiry) or human-readable error.  
  *server/src/token.rs:105*
- **`persona.token.validate-for-room`** · _ga_ — Stricter variant — decode + verify a per-room JWT AND assert the embedded `room_id` matches the requested room. Defends against token-substitution across rooms.  
  *server/src/token.rs:130*

### persona/tts
- **`persona.tts.auto-inject-v3-tags`** · _ga_ — Sprinkle ElevenLabs v3 emotion tags ([happy], [sad], [excited], …) into TTS input at sentence boundaries based on the detected emotion. Caps at one tag per sentence to avoid over-tagging.  
  *server/src/tts.rs:62*
- **`persona.tts-cache`** · _ga_ — Per-tenant LRU+TTL TTS audio cache (default 1000 entries × 7d, env-tunable). Keyed by `voice_id|emotion|normalised_text`. Hit/miss/evict counters feed Prometheus `persona_voice_cache_*` metrics; eats most of the ElevenLabs spend.  
  *server/src/tts_cache.rs:84*
- **`persona.tts.generate-speech`** · _ga_ — ElevenLabs v3 TTS round-trip — auto-injects v3 emotion tags and POSTs to /v1/text-to-speech/:voice_id, returns raw MP3 bytes for downstream caching + base64 encoding.  
  *server/src/tts.rs:182*
- **`persona.tts.generate-speech-cached`** · _ga_ — Cached ElevenLabs TTS path used by agent_bridge — consults the per-tenant LRU+TTL `TtsCache` before paying the round-trip, stores raw MP3 bytes and base64-encodes at read time.  
  *server/src/tts.rs:264*
- **`persona.tts.modulate-voice-settings`** · _ga_ — Deterministic per-utterance jitter on ElevenLabs voice_settings (stability/similarity/style/speed) to avoid robotic monotone over long streams; jitter applies every `modulate_every` utterances.  
  *server/src/tts.rs:305*
- **`persona.tts-stream.chunk-sentences`** · _ga_ — Sentence-boundary splitter for streaming TTS — returns complete sentences ready to synth + a trailing remainder to keep buffering.  
  *server/src/tts_stream.rs:87*
- **`persona.tts-stream.consume-llm-sse`** · _ga_ — End-to-end streaming pipeline — pipes the agent's AI-SDK SSE deltas through a sentence-boundary chunker into the streaming TTS path, sentence-by-sentence, while accumulating the full text for the legacy text broadcast + DB save.  
  *server/src/tts_stream.rs:318*
- **`persona.tts-stream.end-sentinel`** · _ga_ — Sentinel `room.voice_chunk` event with `total` set + empty `audio_base64` so the client closes its MediaSource cleanly.  
  *server/src/tts_stream.rs:268*
- **`persona.tts-stream.flag`** · _ga_ — Reads `PERSONA_TTS_STREAMING=1` env var to gate the low-latency streaming path. Default off until measured.  
  *server/src/tts_stream.rs:66*
- **`persona.tts-stream.synthesize-sentence`** · _ga_ — One-sentence streaming TTS — opens ElevenLabs `/stream`, pipes mp3 bytes_stream() frames straight into `room.voice_chunk` events as they arrive, logs first-byte latency.  
  *server/src/tts_stream.rs:143*

### persona/usage
- **`persona.usage.summary`** · _ga_ — GET /persona/usage?room_id=&period=day|week|month — usage summary (TTS chars / commands / chat msgs) for cost + quota tracking.  
  *server/src/legacy_routes.rs:100*
- **`persona.usage.tracker`** · _ga_ — Per-tenant usage accounting (TTS chars / commands / chat messages by period day|week|month). Backs GET /persona/usage and feeds future cost / quota dashboards.  
  *server/src/usage.rs:55*

### persona/voice
- **`persona.voice.chat`** · _beta_ · since 0.1.0 — WebRTC voice chat for Persona rooms — push-to-talk, mute/deafen, screen share, webcam, VAD.  
  *packages/voice/src/voiceChat.ts:109*
- **`persona.voice.elevenlabs-tts`** — ElevenLabs TTS via server-side proxy (/api/tts) so the API key never reaches the browser. Two modes: speak() fire-and-forget, and prepare() which fetches audio and returns a play() function so caller can sync emotion and gesture with the exact playback start.  
  *apps/web/src/hooks/useElevenLabsTts.ts:1*
- **`persona.voice.input`** — Voice Activity Detection + STT via the /api/stt server proxy. Web Audio RMS energy VAD auto-starts MediaRecorder recording when speech is detected; stops after silence timeout (default 1.5s); POSTs Opus/WebM to STT proxy. States: idle → listening → speaking → processing → idle.  
  *packages/voice/src/voiceInput.ts:1*
- **`persona.voice.lipsync`** — FFT-based realtime lipsync: maps Web Audio frequency bands to VRM viseme weights (aa/oh/ee/ih/ou/sil). Two modes: real audio (FFT) and simulated sine-wave for browser TTS. Module-level weights are read each useFrame by AvatarModel for zero-copy blendshape application.  
  *apps/web/src/hooks/useLipsync.ts:1*
- **`persona.voice.spatial`** · _experimental_ · since 0.2.0 — Distance-based 3D audio for remote users — Web Audio PannerNodes follow player positions in scene. Singleton AudioContext shared across hook callers.  
  *packages/multiplayer/src/spatialAudio.ts:57*
- **`persona.voice.streaming-lipsync`** — Frame-locked viseme scheduling for the streaming-TTS pipeline. Receives oa-voice-chunk postMessages with per-token audio offsets, merges timelines across multiple chunks (keyed by utterance_id), and fires visemes 100ms ahead of audio so avatar mouth pre-shapes before the sound emerges.  
  *apps/web/src/hooks/useStreamingLipsync.ts:1*
- **`persona.voice.streaming-tts-player`** — Per-utterance MediaSource Extensions (MSE) player for the streaming-TTS pipeline. Creates a hidden <audio> element backed by MediaSource; appends audio/mpeg chunks as they arrive. Handles sequence gaps (abort + fallback to single-blob audio), SourceBuffer errors, concurrent utterances via utterance_id keying, and auto-dispose on playback end.  
  *apps/web/src/lib/StreamingTtsPlayer.ts:1*
- **`persona.voice.tts`** — ElevenLabs v3 TTS — injects emotion tags (sighs, giggles, pauses), calls ElevenLabs v3 API, returns audio bytes for broadcast. Voice modulation varies subtly per-request via a monotonic counter so repeat phrases don't sound identical over a 24h stream. ElevenLabs v3 TTS integration for server-side speech generation. When Alice responds, this module: 1. Injects v3 emotion tags (sighs, giggles, pauses) into the text 2. Calls ElevenLabs v3 TTS API to generate audio 3. Returns audio bytes for WebSocket broadcast Configuration via environment variables: - `ELEVENLABS_API_KEY` — required for TTS (if unset, TTS is silently skipped) - `ELEVENLABS_VOICE_ID` — default: "Iduk5qRebfio4TewEnQg" (Weronika) - `ELEVENLABS_MODEL_ID` — default: "eleven_v3"  
  *server/src/tts.rs:1*
- **`persona.voice.tts-cache`** — In-process LRU + TTL cache for ElevenLabs TTS audio. Keys on (tenant, voice_id, emotion, text); evicts per TTL (default 7d) and per-tenant LRU cap (default 1000 entries). Saves ~$0.30/1k chars on repeat phrases common in 24h streams. Per-tenant TTS audio cache (LRU + TTL). ElevenLabs generations are expensive (~$0.30/1k chars) and the agent re-says many phrases ("welcome back!", greetings, idle filler) over a 24h stream. Caching by (tenant, voice_id, emotion, text) shrinks the spend dramatically — most repeat phrases land in cache after the first generation. ## Eviction Two-level cap: 1. **TTL** — every entry expires after `OA_VOICE_CACHE_TTL_DAYS` days (default 7). Expired entries are evicted lazily on `get` and proactively on every `put`. 2. **LRU per tenant** — when a tenant has more than `OA_VOICE_CACHE_MAX_ENTRIES` live entries (default 1000), the least-recently-used is dropped on insert. The cache is intentionally in-process — we don't want a Redis round-trip on the hot speech path. The cache survives the life of one persona-server process; on restart it cold-starts and rewarms naturally. ## Metrics Three Prometheus counters fed by [`Metrics`]: * `persona_voice_cache_hits_total` * `persona_voice_cache_misses_total` * `persona_voice_cache_evictions_total` Plus two gauges: * `persona_voice_cache_size` — total live entries across all tenants. * `persona_voice_cache_tenants` — distinct tenant buckets.  
  *server/src/tts_cache.rs:1*
- **`persona.voice.tts-streaming`** — Low-latency streaming TTS path — pipes ElevenLabs `/stream` HTTP mp3 chunks straight into `room.voice_chunk` PersonaEvents as they arrive, so the client's MSE/SourceBuffer pipeline can start playback within ~800ms of the LLM's first sentence token. Gated behind `PERSONA_TTS_STREAMING=1`. Where the latency lives today and how this module fixes it ---------------------------------------------------------- The legacy path in `agent_bridge::call_agent`: 1. POSTs to the agent's `/v1/chat/stream` and calls `response.text().await` — **waits for ALL** SSE deltas to flush before TTS can begin. With a 200-word reply that's ~2-4s wall clock. 2. Then calls `generate_speech` which hits ElevenLabs `/stream` but does `response.bytes().await` — **waits for ALL** mp3 bytes before returning (~200-1000ms). 3. Base64-encodes the full blob, broadcasts as one `room.voice` PersonaEvent. The client then `audio.play()`s after the whole blob arrived. No incremental playback. The streaming path here: * Synthesises one sentence at a time — `synthesize_sentence_streaming`. * Opens the ElevenLabs `/stream` endpoint with `reqwest::Response::bytes_stream()`. * Each ~2-8KB mp3 frame that arrives gets broadcast immediately as a `room.voice_chunk` event with `{utterance_id, sequence, total?, audio_base64, mime}`. The first byte from ElevenLabs typically lands within ~200-300ms. * After the last mp3 byte we send a sentinel event with the final `total` so the client's `StreamingTtsPlayer` knows to close the MediaSource. The client side — `apps/web/src/app/page.tsx` — translates each `room.voice_chunk` event into a blob ObjectURL + dispatches an `oa-voice-chunk` postMessage, re-using the existing MSE pipeline in `apps/web/src/lib/StreamingTtsPlayer.ts` that the openalice-voice integration already drives. First-byte latency target: **<800ms** from the agent's first LLM token to the first PCM sample reaching the listener's speaker. Today's path is ~2-3s end-to-end. Measured via `tracing::info!(elapsed_ms, "tts first byte")` on the server side; the client logs receipt timing through its existing `StreamingTtsPlayer` logger.  
  *server/src/tts_stream.rs:1*
- **`persona.voice.viseme-mapper`** — IPA phoneme → VRM viseme mapping (aa/ih/ou/ee/oh/sil) with smooth interpolation, coarticulation (blend overlapping phonemes), and ElevenLabs viseme event consumption. Shared by both useLipsync (FFT mode) and useStreamingLipsync (timeline mode).  
  *packages/core/src/speech/VisemeMapper.ts:1*
- **`persona.voice.voice-message`** — Voice message pipeline: client uploads Opus/WebM audio, server calls STT, transcribed text is stored as a chat message with voice metadata (voice: true, audio_url, duration_ms) and broadcast to the room. Allows voice interaction in lounge chat rooms without dedicated WebRTC voice infra. Voice messages — client records audio, sends to STT, gets text back. Voice messages are regular chat messages with metadata: - `voice: true` — indicates this was originally spoken - `audio_url: "..."` — URL to the audio file for playback - `duration_ms: N` — duration of the recording Flow: 1. Client records audio (Opus/WebM in browser, or native) 2. Client uploads audio → gets URL back (via platform /api/upload) 3. Client sends POST /api/voice-message with { audio_url, room_id } 4. This module calls STT endpoint to transcribe 5. Transcribed text is stored as a chat message with voice metadata 6. Broadcast to room via PersonaEngine  
  *server/src/voice_message.rs:1*

### persona/voice-message
- **`persona.voice-message.process`** · _ga_ — End-to-end voice-message pipeline — transcribe audio_url via STT, broadcast transcript as a chat message PersonaEvent into the room. Backs POST /api/voice-message from the live page push-to-talk.  
  *server/src/voice_message.rs:118*
- **`persona.voice-message.route`** · _ga_ — POST /api/voice-message — STT entry-point from the live page push-to-talk button; transcribes the audio_url and broadcasts the transcript as a chat message PersonaEvent.  
  *server/src/legacy_routes.rs:439*
- **`persona.voice-message.transcribe`** · _ga_ — Generic STT round-trip — POSTs `{audio_url}` to the configured STT endpoint, returns the `text` field. Pluggable for ElevenLabs Scribe / Whisper / Deepgram.  
  *server/src/voice_message.rs:84*

### persona/vrm
- **`persona.pose-stream`** · _alpha_ · since 0.3.0 — WebSocket client for openalice-anim — receives synthesized pose + blendshape frames at 60 Hz and applies them to the AvatarAnimator. When connected, suppresses the procedural idle path so the two don't fight for bones.  
  *packages/core/src/vrm/PoseStream.ts:61*

### persona/web/app
- **`persona.web.studio-page`** · _beta_ — Persona Shell main page — composes ControlBar, StatusIndicator, AvatarScene canvas, chat conversation thread, voice (speech-recog + ElevenLabs TTS), lip-sync, emotion enrichment, and keyboard shortcuts into the public persona.blal.pro/<user> studio surface.  
  *apps/web/src/app/page.tsx:4*

### persona-web/component
- **`persona-web.component.vrm-picker`** · _stable_ · since 0.6.0 — VRM gallery — list, upload (drag-drop + file picker), select active, delete with confirm. Uses Atelier primitives.  
  *apps/web/src/components/VrmPicker.tsx:73*

### persona/web/components
- **`persona.web.avatar-render-composer`** · _beta_ — Screen-space EffectComposer chain — Sobel outline + bloom + hologram (flicker / scan / chroma) post-passes over the VRM render, conditionally mounted via isComposerNeeded so zero GPU cost in the default pure-3D path; rebuilds on uniform-crosses-zero.  
  *apps/web/src/components/AvatarRenderComposer.tsx:11*
- **`persona.web.keyboard-shortcuts-dialog`** · _beta_ — Discoverable keyboard-shortcuts reference for the persona-web shell. Renders the canonical PERSONA_SHORTCUTS list (single source of truth shared with useKeyboardShortcuts) so the help never drifts from the real bindings. Opened from the ? button in PersonaControlBar.  
  *apps/web/src/components/KeyboardShortcutsDialog.tsx:4*
- **`persona.web.lounge-chat-panel`** · _beta_ — Persona-web client surface for lounge chat — persistent WS to /persona/rooms/:id/chat/ws, history backfill + live append, @mention autocomplete + send, mod actions UI (ban / delete / view-deleted), auto-scroll pin heuristics, typing indicator.  
  *apps/web/src/components/LoungeChatPanel.tsx:14*
- **`persona.web.settings-dialog`** · _beta_ — Tabbed settings modal for the persona-web shell — Voice (TTS provider + voice ID), Avatar (VRM picker, environment preset), Appearance (palette), Connection (WS/SSE health), Model (LLM picker), Debug tools.  
  *apps/web/src/components/SettingsDialog.tsx:4*

### persona-web/lib
- **`persona-web.lib.lounge-api`** · _stable_ · since 0.7.0 — Typed client for Discord-Style Lounge Phase 2 — room CRUD + participant management.  
  *apps/web/src/lib/loungeApi.ts:25*
- **`persona-web.lib.vrms-api`** · _stable_ · since 0.6.0 — Typed client for /v1/me/vrms GET/POST/DELETE — bearer JWT, multipart upload, public URL decoration.  
  *apps/web/src/lib/vrmsApi.ts:15*

### persona/web/overlay
- **`persona.web.now-playing-chip`** · _beta_ — "Now playing" radio chip on the persona scene page. Consumes the room-bus `radio.now_playing` topic (forwarded by server/src/radio_bridge.rs from the openalice-radio WS when the agent's bgm_source is `radio:<slug>`) and shows a small glass pill with the current track title + artist.  
  *apps/web/src/components/NowPlaying.tsx:4*
- **`persona.web.poll-overlay`** · _beta_ — Live poll overlay on the persona scene page. Consumes the room-bus `poll_opened` / `poll_vote` / `poll_closed` topics (broadcast by server/src/polls.rs) and renders an animated bar-chart with running tally + a final-result state. Display-only — voting happens via chat on the streaming platform.  
  *apps/web/src/components/PollOverlay.tsx:4*
- **`persona.web.redemption-toast`** · _beta_ — Channel-point / reward redemption toast overlay on the persona scene page. Consumes the room-bus `redemption_triggered` topic (broadcast by server/src/redemption_triggers.rs when a viewer redeems on Twitch/YouTube) and shows a stacked, auto-dismissing toast naming the viewer + reward.  
  *apps/web/src/components/RedemptionToast.tsx:4*

### persona/world
- **`persona.world.chat-rate-limiter`** · _ga_ — Per-user sliding-window chat rate limiter (max N messages per 60s). Backs the WS chat path's anti-flood gate; thread-safe via parking_lot::Mutex.  
  *server/src/position_validator.rs:405*
- **`persona.world.config`** — Server-authoritative world config: terrain seed, spawn points, portal definitions (teleport circles to other rooms), gameplay rules. Client receives config on WS connect and renders accordingly — no local overrides, same seed = same terrain for every viewer in the room. Server-authoritative world configuration for 3D world rooms. Every terrain/gameplay parameter is decided by the server. The client receives the config on connect and renders accordingly. This ensures: - All clients in a room see the same world (same seed = same terrain) - Server controls spawn points, portals, game rules - No client-side cheating via config manipulation  
  *server/src/world_config.rs:1*
- **`persona.world.config-get`** · _ga_ — GET /persona/rooms/:id/world-config — return the server-authoritative WorldConfig snapshot (terrain seed/size, spawn, portals, game rules) for new client mounts.  
  *server/src/world_routes.rs:32*
- **`persona.world.config-set`** · _ga_ — POST /persona/rooms/:id/world-config — admin-only update of the server-authoritative WorldConfig (terrain, spawn, portals, game rules). Broadcasts the change so live viewers reload.  
  *server/src/world_routes.rs:61*
- **`persona.world.emotes`** — Keyboard emote system: number keys 1-4 trigger wave/dance/sit/thumbs-up. Returns current emote + timestamp; caller broadcasts via RT WebSocket. Used by PlayerAvatar to drive emote animations on both local and remote player representations.  
  *packages/world/src/hooks/useEmotes.ts:1*
- **`persona.world.hud`** · _beta_ — In-world HUD overlay for the persona 3D world — players list, mini-map, in-room chat box, voice/screen-share/video toggles, weather + time-of-day picker, quality / settings buttons.  
  *packages/world/src/WorldHUD.tsx:4*
- **`persona.world.island-scene`** — Main 3D world scene: procedurally generated multi-biome island terrain (seed-deterministic via world_config), vegetation, underwater objects, rocks, grass blades. Integrates multiplayer (useWorldSync), weather system, remote players with interpolation, and VR controls. Portal circles link to other rooms.  
  *packages/world/src/IslandScene.tsx:1*
- **`persona.world.position-validator`** — MMO-grade server-authoritative anti-cheat: detects speed hacking, teleport hacking, fly hacking, out-of-bounds, and rate flooding. On violation, snaps/clamps position or kicks the player after accumulated violations. Tuned for 1200×1200 Persona World terrain (height scale 40). Server-authoritative position validation and anti-cheat system. MMO-level movement validation inspired by Source Engine / Overwatch / VRChat. The server tracks each player's authoritative state and validates every position update from clients. Detects: - Speed hacking (moving faster than sprint + tolerance) - Teleport hacking (sudden large jumps without portal authorization) - Fly hacking (exceeding jump velocity or terminal velocity) - Out-of-bounds (outside world boundaries or below terrain) - Rate flooding (sending updates faster than allowed tick rate) On violation, the server either corrects the position (snap/clamp) or kicks the player after too many accumulated violations.  
  *server/src/position_validator.rs:1*
- **`persona.world.position-validator`** · _ga_ — Per-room server-authoritative position validator — tracks ValidatedPlayer rows, enforces max-speed + teleport-allowlist + per-second update-rate gates, returns Valid / Corrected / RateLimited / Kicked verdicts to the WS handler.  
  *server/src/position_validator.rs:113*
- **`persona.world.position-validator.validate`** · _ga_ — Validate one incoming PlayerState update — applies speed cap, teleport-vs-walk distinction (allowlist), rate-limit window, and tracks violation count for kick decisions. Returns the canonical ValidationResult variant.  
  *server/src/position_validator.rs:212*
- **`persona.world.server-weather`** · _ga_ — Server-authoritative day/night + weather simulation per room — ticks every second, advances day_progress, runs random clear/cloudy/rain/snow/storm/fog transitions with crossfade, broadcasts state via PersonaEvent so all WS viewers see identical atmospherics.  
  *server/src/weather.rs:22*
- **`persona.world.server-weather.tick`** · _ga_ — Single dt step — advances day_progress, decrements the next-change timer, kicks off random weather transitions with 30s crossfade, drifts snow_coverage in/out by weather type.  
  *server/src/weather.rs:86*
- **`persona.world.time-set`** · _ga_ — POST /persona/rooms/:id/time — admin-only override of the room's day_progress + time_speed; pauses/resumes the day/night cycle for scene staging.  
  *server/src/world_routes.rs:240*
- **`persona.world.weather`** — Server-authoritative weather + day/night cycle for 3D world rooms. Each room runs its own ServerWeather instance (ticks every second), advancing day_progress and randomly transitioning weather types (clear/cloudy/rain/ snow/storm/fog). State broadcast to all WS clients via PersonaEvent so every viewer sees identical atmospheric conditions. Server-authoritative weather and day/night cycle for 3D world rooms. Each room gets its own `ServerWeather` instance that ticks every second, advancing day progress and randomly changing weather. State is broadcast to all WebSocket clients in the room via `PersonaEvent`.  
  *server/src/weather.rs:1*
- **`persona.world.weather-get`** · _ga_ — GET /persona/rooms/:id/weather — current weather + day-progress snapshot from the room's ServerWeather instance.  
  *server/src/world_routes.rs:126*
- **`persona.world.weather-set`** · _ga_ — POST /persona/rooms/:id/weather — admin-only manual weather override (clear/cloudy/rain/snow/storm/fog); pauses auto-weather rotation until explicitly re-enabled.  
  *server/src/world_routes.rs:180*
- **`persona.world.world-config`** · _ga_ — Server-authoritative per-room WorldConfig — terrain seed/size, spawn pose, portals, game rules. Persisted per room, broadcast to clients on change so the 3D scene reloads in sync.  
  *server/src/world_config.rs:46*

### persona/world/movement
- **`persona.world.use-agent-movement`** · _alpha_ · since 0.6.0 — Persona-side runtime for agent move_to / teleport_to tool calls. Decodes URL params on mount + listens to postMessage for runtime hot-swap, then lerps the avatar's camera position over duration_ms (or snaps for teleport). Coordinates with useAvatarMovement via an `isAgentDriving` ref so player keys are dampened during the autonomous walk.  
  *packages/world/src/hooks/useAgentMovement.ts:22*

### persona/ws
- **`persona.ws.room`** — WebSocket handler for /persona/rooms/:id/ws — upgrades HTTP, validates optional JWT room token, then streams PersonaEvents out and routes incoming chat/typing/ping/position_update frames through the broadcast bus, server-authoritative position validator, chat rate limiter, and the agent bridge. Supports anonymous viewer mode (no token). WebSocket chat handler for `/persona/rooms/:id/ws`. Extracted from `main.rs` (2026-05-07 split). Owns: * `WsPermissions` — JWT-derived per-connection permissions (user_name, role, can_chat, can_voice) with an `anonymous` default for token-less viewers. * `room_ws` — the upgrade handler. Resolves the room, validates the optional `token` query param against the JWT room secret, then hands control to `handle_ws`. * `handle_ws` — the long-lived task. Streams `PersonaEvent`s out to the socket and routes incoming `chat` / `typing` / `ping` / `position_update` frames back through the room broadcast bus, server-authoritative position validator, chat rate limiter, and the agent bridge. All wire formats match the pre-split main.rs byte-for-byte; this module is a mechanical extraction with no behaviour changes.  
  *server/src/room_ws.rs:1*

### persona/youtube
- **`persona.youtube.complete-broadcast`** · _ga_ — POST /v1/me/youtube/stream-key/:broadcast_id/complete — calls liveBroadcasts.transition(broadcastStatus=complete) on YT, flips local row status=complete, audit-logs op=stream_key_complete.  
  *server/src/youtube_streamkey.rs:823*
- **`persona.youtube.create-stream-key`** · _ga_ — POST /v1/me/youtube/stream-key — creates a YouTube liveBroadcast + liveStream on the streamer's behalf via the YT Data API, encrypts + persists the stream-key, audit-logs op=stream_key_create. Gated on the broader `youtube` scope.  
  *server/src/youtube_streamkey.rs:629*
- **`persona.youtube.delete-tokens`** · _ga_ — DELETE /v1/me/youtube/oauth-tokens — disconnect YT channel; best-effort revoke at Google via oauth2.revoke + local row delete; audit-logs op=disconnect + revoke status independently.  
  *server/src/youtube_oauth/handlers.rs:202*
- **`persona.youtube.get-active-stream-key`** · _ga_ — GET /v1/me/youtube/stream-key/active — fetch the tenant's current live broadcast + decrypted stream-key for the Hub `/account/streams` "Auto-fetch" panel.  
  *server/src/youtube_streamkey.rs:751*
- **`persona.youtube.get-policy`** · _ga_ — GET /v1/me/youtube/mod-policy — tenant's YT moderation defaults (auto_ban_threshold, escalation rules) consumed by chat-bridge + AI moderation.  
  *server/src/youtube_oauth/handlers.rs:684*
- **`persona.youtube.get-tokens`** · _ga_ — GET /v1/me/youtube/oauth-tokens — fetch the connected YT channel metadata + scope + token-expiry timestamps (decrypted, but the raw secrets are NOT surfaced to the client; chat-bridge reads them via the internal endpoint).  
  *server/src/youtube_oauth/handlers.rs:159*
- **`persona.youtube.google-client`** · _ga_ — Reqwest-backed Google OAuth + Data API client — implements refresh_token grant, oauth2.revoke best-effort, and channels.list?mine=true snapshot fetch. Built once at boot from `GOOGLE_OAUTH_CLIENT_ID/SECRET`, shared via AppState.  
  *server/src/youtube_oauth/google_client.rs:112*
- **`persona.youtube.oauth`** — YouTube OAuth token storage with AES-256-GCM at rest (migrations 0022 → 0028). Hub BFF POSTs freshly-exchanged refresh + access tokens; chat-bridge reads them via /internal/users/.../chat-sources. Routes: token CRUD, access refresh, channel snapshot refresh, mod-policy GET/PATCH. Legacy plaintext columns removed after zero-reads confirmation on 2026-05-17. YouTube OAuth + per-tenant moderation policy store. Migration 0022 (2026-05-16, YT Tier 1 wave — Hub `/account/youtube`) introduced this module with **plaintext** `refresh_token` + `access_token` columns. Migrations 0023 + 0027 retrofitted AES-256-GCM envelope encryption (`secrets::Cipher`) and kept the legacy plaintext columns as a safety hatch. Migrations 0026 + 0028 (flipped on 2026-05-17) dropped the legacy plaintext columns once every row's ciphertext columns were populated and the legacy-reads metrics stayed at zero for a full streaming session. The legacy fallback code paths in this module were removed in the same commit — there is no plaintext column to fall back to any more. The Hub's BFF OAuth callback POSTs the freshly-exchanged `refresh_token` + `access_token` + granted scopes here; the chat-bridge boots later and reads them via the existing `/internal/users/.../chat-sources` path on openalice-auth. This module is the single tenant-scoped writer. Routes wired in `main.rs`: ```text POST   /v1/me/youtube/oauth-tokens             (bearer JWT) GET    /v1/me/youtube/oauth-tokens             (bearer JWT) DELETE /v1/me/youtube/oauth-tokens             (bearer JWT) POST   /v1/me/youtube/refresh-access           (bearer JWT) POST   /v1/me/youtube/refresh-channel-snapshot (bearer JWT) GET    /v1/me/youtube/mod-policy               (bearer JWT) PATCH  /v1/me/youtube/mod-policy               (bearer JWT) ``` Module split (2026-05-18): ```text types          — wire types, /metrics counters, validators google_client  — GoogleOAuthClient trait + HTTP impl db             — DB helpers (encryption interleaved with sqlx) handlers       — axum HTTP handlers (compose the above three) tests          — unit tests + DB-backed integration tests ```  
  *server/src/youtube_oauth/mod.rs:1*
- **`persona.youtube.parse-channel-snapshot`** · _ga_ — Defensive parser for the YT Data API channels.list response — peels channel_id from items[0].id, falls back from `snippet.title` to `brandingSettings.channel.title` for Brand Accounts whose snippet ships empty.  
  *server/src/youtube_oauth/google_client.rs:253*
- **`persona.youtube.patch-policy`** · _ga_ — PATCH /v1/me/youtube/mod-policy — partial update of the tenant's YT moderation policy from the Hub.  
  *server/src/youtube_oauth/handlers.rs:701*
- **`persona.youtube.post-tokens`** · _ga_ — POST /v1/me/youtube/oauth-tokens — Hub BFF stashes the streamer's encrypted refresh_token + access_token after the OAuth dance. Per-tenant upsert, AES-256-GCM via OA_SECRETS_MASTER_KEY, audit-logs op=connect.  
  *server/src/youtube_oauth/handlers.rs:53*
- **`persona.youtube.proxy`** — Proxies YouTube Data API calls (superchat tiers, moderate/delete, moderate/ban) from the Hub through to the chat-bridge container. Tenant boundary stays on persona-server JWT; chat-bridge uses only the X-Internal-Secret shared channel so YT OAuth creds are never exposed to the Hub directly. YouTube Data API proxy (2026-05-07, YT Tier 1 wave). Persona-server proxies the Hub's `GET /v1/me/yt/superchat-tiers`, `POST /v1/me/yt/moderate/delete`, and `POST /v1/me/yt/moderate/ban` through to the chat-bridge container that's serving this tenant's YouTube connector. The chat-bridge container owns the OAuth bearer token (refreshed on the chat poll cycle); persona is intentionally ignorant of the YT creds and just forwards. Why proxy instead of letting the Hub talk to chat-bridge directly: * Tenant boundary lives on persona-server's JWT — chat-bridge only knows the X-Internal-Secret shared with every co-located service. Letting the Hub talk straight to chat-bridge would require us to validate JWTs there too, splitting the secret surface. * The Hub already proxies every persona call through `/api/persona` (the BFF catch-all). Wiring `/v1/me/yt/*` onto persona keeps the same posture: same-origin, single audit trail, single rate-limit middleware. Env config: * `CHAT_BRIDGE_URL`            (default `http://chat-bridge:3978`) * `INTERNAL_BROADCAST_SECRET`  (already used by /internal/* on persona; forwarded as X-Internal-Secret).  
  *server/src/youtube_proxy.rs:1*
- **`persona.youtube.refresh-access`** · _ga_ — POST /v1/me/youtube/refresh-access — force-refresh: re-run the refresh_token grant against Google, persist new encrypted access_token + expiry. Recovery path for chat-bridge 401s + Hub "Force refresh" button. Audit-logs op=refresh.  
  *server/src/youtube_oauth/handlers.rs:323*
- **`persona.youtube.refresh-channel-snapshot`** · _ga_ — POST /v1/me/youtube/refresh-channel-snapshot — re-fetch channel snippet + brandingSettings from YT Data API (mine=true) and COALESCE channel_id / channel_name. Recovers Brand Accounts that ship empty on first connect. Audit-logs op=channel_snapshot.  
  *server/src/youtube_oauth/handlers.rs:482*
- **`persona.youtube.streamkey`** — Auto-fetch YouTube stream key via YouTube Data API: creates live broadcast + live stream, binds them, encrypts and persists the RTMP stream-key with AES-256-GCM. Routes: create broadcast, get active, transition-to-live, complete. Scope-checks stored tokens before every call. YouTube stream-key auto-fetch (2026-05-07). With the broader `youtube` scope (added to the OAuth start flow on 2026-05-07) the streamer no longer has to copy-paste their stream key from YouTube Studio. This module exposes: POST   /v1/me/youtube/stream-key                    (bearer JWT) Creates a live broadcast + live stream, binds them, persists the encrypted stream-key, returns the RTMP URL + stream-key. GET    /v1/me/youtube/stream-key/active             (bearer JWT) Returns the most-recent live (or ready) broadcast for this tenant; decrypts the stream-key for the response. POST   /v1/me/youtube/stream-key/{id}/transition-to-live Flips the broadcast.status to 'live' on YT + 'live' in the `youtube_broadcasts` row. POST   /v1/me/youtube/stream-key/{id}/complete Flips broadcast.status to 'complete' on YT + 'complete' here. Encryption posture: stream-keys are AES-256-GCM-enveloped at rest, same `Cipher` as the OAuth tokens. The plaintext only exists in process memory during the API call that returns it. Scope gate: every route checks the stored tokens' scopes string for `https://www.googleapis.com/auth/youtube`. Tenants missing it get 403 with a hint pointing at the Hub re-consent banner. All operations are audit-logged via `oauth_audit::record`. # Testing The Google Data API client is hidden behind an injectable [`YtApiClient`] trait so unit tests can pass a stub that returns canned responses. The real implementation lives in [`HttpYtApi`] and uses `reqwest`. DB-backed integration tests gate on `DATABASE_URL` the same way the rest of the persona tests do.  
  *server/src/youtube_streamkey.rs:1*
- **`persona.youtube.transition-to-live`** · _ga_ — POST /v1/me/youtube/stream-key/:broadcast_id/transition-to-live — calls liveBroadcasts.transition(broadcastStatus=live) on YT, flips local row status=live, audit-logs op=stream_key_transition.  
  *server/src/youtube_streamkey.rs:768*

### persona/youtube-db
- **`persona.youtube-db.delete-tokens`** · _ga_ — Tenant-scoped DELETE of the youtube_oauth_tokens row (disconnect flow). Returns row count so the handler distinguishes "removed" from "wasn't connected".  
  *server/src/youtube_oauth/db.rs:311*
- **`persona.youtube-db.update-access-token`** · _ga_ — Re-encrypt + persist a fresh access_token from the refresh-token grant; returns the new `access_token_expires_at` so the handler surfaces it without a re-read.  
  *server/src/youtube_oauth/db.rs:103*
- **`persona.youtube-db.update-channel-snapshot`** · _ga_ — COALESCE-style UPDATE of channel_id + channel_name from a YT Data API channels.list call; partial response (branding-only) doesn't NULL out previously-stored fields.  
  *server/src/youtube_oauth/db.rs:144*
- **`persona.youtube-db.upsert-policy`** · _ga_ — UPSERT of the tenant's YT moderation policy (auto-ban threshold + escalation rules) consumed by chat-bridge + AI moderation.  
  *server/src/youtube_oauth/db.rs:359*
- **`persona.youtube-db.upsert-tokens`** · _ga_ — AES-256-GCM-encrypt the refresh_token + (optional) access_token via the Cipher and UPSERT the youtube_oauth_tokens row. `expires_in` translated to `access_token_expires_at = now() + expires_in`.  
  *server/src/youtube_oauth/db.rs:34*

### persona/youtube-proxy
- **`persona.youtube.proxy-chat-recent`** · _ga_ — GET /v1/me/yt/chat-recent — proxy to chat-bridge's YT chat ring buffer. Polled every 3s by the Hub live mod view.  
  *server/src/youtube_proxy.rs:194*
- **`persona.youtube.proxy-moderate-ban`** · _ga_ — POST /v1/me/yt/moderate/ban — proxy to chat-bridge for YT chat ban/timeout (optional `seconds` for timeout vs permanent ban), sourced from the Hub chat-mod panel.  
  *server/src/youtube_proxy.rs:252*
- **`persona.youtube.proxy-moderate-delete`** · _ga_ — POST /v1/me/yt/moderate/delete — proxy to chat-bridge for one-click YT chat message delete, sourced from the Hub `/account/streams/[id]/chat-mod` panel.  
  *server/src/youtube_proxy.rs:166*
- **`persona.youtube.proxy-superchat-tiers`** · _ga_ — GET /v1/me/yt/superchat-tiers — proxy to chat-bridge `/yt/superchat-tiers`; surfaces SuperChat tier table for the Hub redemption-trigger picker, with fallback_usd shape on connector failure.  
  *server/src/youtube_proxy.rs:86*

---

*Generated by [openalice-atlas](https://atlas.blal.pro) — single source of truth for the openalicelabs ecosystem.*
