{"openapi":"3.1.0","info":{"title":"Llais API","version":"0.0.0","description":"Welsh text-to-speech backend. Powers the Llais embeddable widget.\n\nAuth: `X-Llais-Key` — public, origin-locked API key for the widget (`pk_(live|test)_[A-Za-z0-9]{16,64}`).\n\nThe `Authorization: Bearer` path was reserved for a future Chrome extension and has been removed until that client lands (DIST-2, roadmap Q1 2027) and a JWT-validation contract is finalised.","contact":{"name":"Llais","url":"https://llais.capsiynau.com"}},"servers":[{"url":"https://llais.capsiynau.com","description":"Production"},{"url":"http://localhost:8788","description":"Local dev"}],"tags":[{"name":"core","description":"Synthesis and voice catalogue"},{"name":"beacon","description":"Signed usage beacons (HMAC, replay-protected)"},{"name":"utility","description":"Normalisation and debug helpers"},{"name":"auth","description":"Extension OAuth (stub in v0)"},{"name":"admin","description":"Operator-only state introspection"},{"name":"health","description":"Liveness and metadata"}],"paths":{"/healthz":{"get":{"tags":["health"],"summary":"Liveness check","responses":{"200":{"description":"Service is up","content":{"application/json":{"example":{"status":"ok","service":"llais-backend","time":"2026-05-19T07:00:00.000Z"}}}}}}},"/v1/voices":{"get":{"tags":["core"],"summary":"List available Welsh voices","responses":{"200":{"description":"Voice catalogue with per-voice availability (derived from current backend env vars).","content":{"application/json":{"example":{"voices":[{"id":"nia","label":"Nia (Welsh, north)","engine":"azure","resolvedEngine":"piper","available":true,"language":"cy-GB","gender":"female","azureName":"cy-GB-NiaNeural"},{"id":"alloy","label":"Alloy (neutral, balanced)","engine":"openai","resolvedEngine":"openai","available":true,"language":"multi","gender":"neutral"}]}}}}}}},"/v1/speak":{"post":{"tags":["core"],"summary":"Synthesise Welsh text to audio","description":"Returns audio bytes (Opus via Azure, WAV via dev-mock) on success. Cache hits return the same bytes with `x-llais-cache: hit` and do not consume quota. Cache misses synthesise via the active engine and consume `chars` against the per-key daily quota.","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpeakRequest"},"examples":{"short":{"value":{"text":"Bore da, sut wyt ti?","voice":"nia","speed":1}},"aled":{"value":{"text":"Pnawn da","voice":"aled","speed":0.9}}}}}},"responses":{"200":{"description":"Audio bytes. See `x-llais-cache`, `x-llais-engine`, `x-llais-beacon`, `x-llais-quota-*` headers.","headers":{"x-llais-engine":{"schema":{"type":"string","enum":["azure","piper","openai","orpheus","dev-mock"]},"description":"Synthesis engine used"},"x-llais-cache":{"schema":{"type":"string","enum":["hit","miss"]}},"x-llais-beacon":{"schema":{"type":"string"},"description":"Signed beacon token to POST to /v1/beacon on playback `ended`. Only present when HMAC_SECRET is configured."},"x-llais-quota-used":{"schema":{"type":"integer"}},"x-llais-quota-cap":{"schema":{"type":"integer"}},"x-llais-quota-remaining":{"schema":{"type":"integer"}},"x-llais-quota-reset":{"schema":{"type":"integer"},"description":"Unix-ms epoch of next reset"}},"content":{"audio/ogg":{"schema":{"type":"string","format":"binary"},"example":"<opus stream bytes>"},"audio/wav":{"schema":{"type":"string","format":"binary"},"example":"<wav bytes (dev-mock only)>"}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing auth header","content":{"application/json":{"example":{"error":"missing_auth"}}}},"403":{"description":"Key shape invalid or origin not allowed","content":{"application/json":{"example":{"error":"invalid_key_or_origin"}}}},"429":{"description":"Daily quota exhausted. `retry-after` header points at next reset.","content":{"application/json":{"example":{"error":"quota_exceeded","message":"Daily character cap reached (100000 chars).","resetAt":1779235200000}}}},"502":{"description":"Upstream synthesis engine error (Azure 5xx, etc.)","content":{"application/json":{"example":{"error":"upstream_error","message":"azure_503"}}}},"503":{"description":"Synthesis engine unavailable (no Azure key, etc.)","content":{"application/json":{"example":{"error":"engine_unavailable"}}}}}}},"/v1/beacon":{"post":{"tags":["beacon"],"summary":"Record a playback-completed beacon","description":"The client POSTs the `x-llais-beacon` token (received from `/v1/speak`) once playback fires `ended`. Server verifies HMAC + freshness (≤60 s) + non-replay (token fingerprint single-use within 70 s). 204 on success, 400 with structured reason on rejection.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string","description":"The opaque beacon token from the x-llais-beacon response header"},"latencyMs":{"type":"integer","minimum":0,"maximum":60000,"description":"Optional: ms between speak request and ended event"}}},"example":{"token":"v1.eyJrZXlIYXNoIjoi…","latencyMs":1850}}}},"responses":{"204":{"description":"Beacon recorded; ledger entry appended"},"400":{"description":"Invalid beacon (malformed, bad signature, stale, future, replay)","content":{"application/json":{"example":{"error":"invalid_beacon","reason":"replay"}}}},"503":{"description":"Beacons disabled (HMAC_SECRET not set)"}}}},"/v1/normalise":{"post":{"tags":["utility"],"summary":"Run the Welsh normaliser without synthesis","description":"Debug helper. Returns what the normaliser would emit before passing to the synthesis engine: numerals expanded (`23` → `dau ddeg tri`), abbreviations resolved (`Dr.` → `Doctor`, `Chwef.` → `Chwefror`), HTML stripped, whitespace and dashes collapsed. Auth required (same as /v1/speak) but does not consume quota.","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string","minLength":1,"maxLength":5000}}},"example":{"text":"Cafodd Dr. Jones ei eni yn 1957."}}}},"responses":{"200":{"description":"Normalised text","content":{"application/json":{"example":{"input":"Cafodd Dr. Jones ei eni yn 1957.","normalised":"Cafodd Doctor Jones ei eni yn mil naw cant pum deg saith.","changed":true,"lengthBefore":32,"lengthAfter":57}}}}}}},"/v1/auth/extension":{"post":{"tags":["auth"],"summary":"Extension OAuth PKCE exchange","description":"Stub in v0 — returns 501 with the request shape validated. Real implementation will accept the OAuth code + code-verifier and return an access token + refresh token.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code","codeVerifier"],"properties":{"code":{"type":"string","minLength":1},"codeVerifier":{"type":"string","minLength":43,"maxLength":128}}}}}},"responses":{"501":{"description":"Not implemented yet","content":{"application/json":{"example":{"error":"not_implemented","message":"Extension OAuth PKCE exchange not wired up yet. Request shape validated."}}}}}}},"/v1/admin/cache/stats":{"get":{"tags":["admin"],"summary":"Audio cache statistics","security":[{"adminToken":[]}],"responses":{"200":{"description":"Stats","content":{"application/json":{"example":{"engine":"dev-mock","cache":{"backend":"redis","entries":-1,"bytes":-1,"byteCap":-1,"hits":247,"misses":41}}}}},"403":{"description":"Wrong admin token"},"404":{"description":"Admin endpoints disabled (ADMIN_TOKEN not set)"}}}},"/v1/admin/quota":{"get":{"tags":["admin"],"summary":"Per-key daily quota state","security":[{"adminToken":[]}],"responses":{"200":{"description":"Top consumers for the current UTC day","content":{"application/json":{"example":{"enforced":true,"backend":"redis","cap":100000,"day":"2026-05-19","tracked":3,"top":[{"keyHash":"ipWw8d0G-_rs0hUI","used":50}]}}}}}}},"/v1/admin/ledger":{"get":{"tags":["admin"],"summary":"Recent ledger entries (synth + beacon)","security":[{"adminToken":[]}],"parameters":[{"in":"query","name":"limit","schema":{"type":"integer","minimum":1,"maximum":500,"default":50}}],"responses":{"200":{"description":"Newest-first list","content":{"application/json":{"example":{"beaconEnabled":true,"stats":{"capacity":1000,"size":4,"synthCount":3,"beaconCount":1,"totalChars":14},"entries":[{"kind":"beacon","ts":1779170020216,"keyHash":"ipWw8d0G-_rs0hUI","chars":14,"voice":"nia","cached":false,"engine":"dev-mock","latencyMs":1131}]}}}}}}},"/v1/admin/keys":{"get":{"tags":["admin"],"summary":"List issued API keys (without secrets)","security":[{"adminToken":[]}],"responses":{"200":{"description":"Newest-first list. Each record shows ghosted prefix only; the full key is never returned after creation.","content":{"application/json":{"example":{"count":2,"keys":[{"id":"YjJlYWZj","prefix":"pk_live_aB3x","label":"capsiynau.com prod","origins":["https://capsiynau.com"],"createdAt":1779175000000,"revokedAt":null}]}}}}}},"post":{"tags":["admin"],"summary":"Issue a new API key","description":"Generates a fresh `pk_(live|test)_<32 random chars>` key. The full key is returned ONCE in the response body and never recoverable after — server stores only its SHA-256 hash. Use the `origins` allowlist to bind the key to specific domains (empty = wildcard).","security":[{"adminToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label"],"properties":{"label":{"type":"string","minLength":1,"maxLength":200},"origins":{"type":"array","items":{"type":"string","format":"uri"},"maxItems":20},"mode":{"type":"string","enum":["live","test"],"default":"test"}}},"example":{"label":"capsiynau.com prod","origins":["https://capsiynau.com"],"mode":"live"}}}},"responses":{"201":{"description":"Key issued. `key` field appears here and only here.","content":{"application/json":{"example":{"id":"YjJlYWZj","key":"pk_live_aB3xK9pQ4r2sV7uX1yZ0wA8bC3dE5fG6","prefix":"pk_live_aB3x","label":"capsiynau.com prod","origins":["https://capsiynau.com"],"createdAt":1779175000000,"revokedAt":null}}}},"503":{"description":"Redis not configured (issuance requires the key store)"}}}},"/v1/admin/keys/{id}":{"delete":{"tags":["admin"],"summary":"Revoke an API key","description":"Soft-delete — record retained with `revokedAt` set. Further requests with this key return 403 `key_revoked`.","security":[{"adminToken":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"},"description":"Key id returned from POST /v1/admin/keys"}],"responses":{"204":{"description":"Revoked"},"404":{"description":"Key not found"},"409":{"description":"Already revoked"},"503":{"description":"Redis not configured"}}}},"/v1/admin/dashboard":{"get":{"tags":["admin"],"summary":"HTML dashboard","description":"Returns a self-contained HTML page that fetches `/v1/admin/cache/stats`, `/v1/admin/quota`, and `/v1/admin/ledger` and renders them with auto-refresh. The HTML itself contains no secrets — the JS prompts for the admin token (or reads it from `#token=…` URL fragment) and stores it in sessionStorage.","responses":{"200":{"description":"Dashboard HTML","content":{"text/html":{}}},"404":{"description":"Admin endpoints disabled (ADMIN_TOKEN not set)"}}}}},"components":{"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"X-Llais-Key","description":"Widget API key (public, origin-locked). Format: `pk_(live|test)_[A-Za-z0-9]{16,64}`"},"adminToken":{"type":"apiKey","in":"header","name":"X-Admin-Token","description":"Server-side admin token from `ADMIN_TOKEN` env var"}},"schemas":{"SpeakRequest":{"type":"object","required":["text"],"properties":{"text":{"type":"string","minLength":1,"maxLength":5000},"voice":{"type":"string","enum":["nia","aled","bangor","bangor-pro","alloy","echo","fable","onyx","nova","shimmer"],"default":"nia","description":"Welsh-native voices `nia`/`aled` route to Azure cy-GB-Neural (or Piper/Bangor fallback). `bangor` is Bangor LTU's Piper voice. `bangor-pro` is Bangor's Orpheus 3B fine-tune (techiaith/orpheus3b-cy-en) via an HTTP endpoint, with Piper Bangor as graceful fallback. OpenAI voices `alloy`/`echo`/`fable`/`onyx`/`nova`/`shimmer` route to OpenAI tts-1. Call `GET /v1/voices` for the live catalogue with per-voice availability."},"speed":{"type":"number","minimum":0.8,"maximum":1.4,"default":1},"format":{"type":"string","enum":["opus","mp3"],"default":"opus"},"dialect":{"type":"string","enum":["north","south"]}}},"Error":{"type":"object","properties":{"error":{"type":"string"},"message":{"type":"string"},"reason":{"type":"string"},"issues":{"type":"array","items":{"type":"object"}}}}}}}