package com.sptmobile.voice import java.security.MessageDigest import kotlinx.coroutines.CancellationException // [impl->REQ-VOICE-PIPE] /** * The webhook receiver's decision core (DESIGN.md §Voice pipe semantics * step 1), separated from the socket loop so every branch is JVM-testable. * * The 200-unconditionally invariant (REQ-HAZARD-DICTATION-LOSS) is about * DOWNSTREAM state: an authorized Pebble POST never fails because links are * down or the forwarder is behind — the row goes to the durable spool and * the 200 stands behind that commit. It does NOT extend to: * - the token gate (401, nothing spooled — keeps other local apps from * injecting into the star; Pebble surfaces the failure so the user fixes * the header config), * - a durable-enqueue failure (500 — a false 200 here would BE the * dictation loss; Pebble's retry-on-next-recording is the recovery path). */ class WebhookHandler( private val spool: VoiceSpool, private val expectedToken: () -> String?, private val now: () -> Long = System::currentTimeMillis, ) { data class Response(val status: Int, val body: String) { val statusLine: String get() = when (status) { 200 -> "200 OK" 401 -> "401 Unauthorized" 405 -> "405 Method Not Allowed" 413 -> "413 Content Too Large" 500 -> "500 Internal Server Error" else -> "400 Bad Request" } } fun handle(request: WebhookRequest.Parsed): Response { if (request.method != "POST") return Response(405, "POST only") val expected = expectedToken() ?: return Response(500, "receiver has no token yet") val provided = request.headers[TOKEN_HEADER] // Constant-time compare: the token gate must not leak by timing. if (provided == null || !MessageDigest.isEqual(expected.toByteArray(), provided.toByteArray()) ) return Response(401, "bad or missing $TOKEN_HEADER") val fields = try { WebhookRequest.fields(request.headers["content-type"], request.body) } catch (e: WebhookRequest.Malformed) { return Response(400, e.message ?: "malformed multipart") } // v1 payload mode is transcription-only: an audio-only POST is a // Pebble-side config choice, not a loss event — answer 200 so Pebble // is done with it, spool nothing. val transcription = fields["transcription"]?.trim().orEmpty() if (transcription.isEmpty()) return Response(200, "no transcription; ignored") val recordedAt = fields["recordedAt"]?.trim()?.toLongOrNull() ?: now() // [impl->REQ-HAZARD-DICTATION-LOSS] The durability line: enqueue // returns only after the spool commit, and the 200 is only built // from a successful return — the caller writes it strictly after. val row = try { spool.enqueue(transcription, recordedAt) } catch (e: RuntimeException) { if (e is CancellationException) throw e return Response(500, "spool write failed: ${e.message}") } return Response(200, """{"spooled":true,"msg-id":"${row.msgId}"}""") } companion object { /** DESIGN.md §Pebble webhook contract: user-configured header auth. */ const val TOKEN_HEADER = "x-widget-token" } }