Files
zeekayandHanzo Dev 03a9f42e10 Serve a spoken conversation over one socket
Audio in, audio out, on an IAM bearer, billed to whoever spoke. speech holds
the models; this holds the conversation.

The wire is the OpenAI realtime protocol so existing clients work unchanged.
hanzoai/go-openai-realtime already carries the event types but is a client, so
it can only unmarshal the server's half; Read supplies the other direction.

A browser cannot put a header on a WebSocket, so the bearer is spent on a POST
and the URL carries a single-use thirty-second ticket naming a principal
already verified here. It has no claims and cannot be verified elsewhere, so a
copy lifted from a log buys nothing.

Replies are synthesised a sentence at a time: 452 ms to first sound against
2267 ms for a whole reply, and every sentence after the first is free because
synthesis outruns speech. Measured end to end at 1.15-1.55 s from the speaker
stopping, of which 500 ms is the silence detector that Turn exists to replace.

Turn, Mind and Meter are the seams. Hush ends a turn on silence alone, which
cuts people off mid-thought and waits through gaps it should have taken; it is
there so the surface works while a detector that reads intent is built.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:37:04 -07:00

68 lines
2.4 KiB
Go

package voice
import (
"fmt"
"strings"
"time"
"github.com/hanzoai/authz/edge"
)
// Who is the caller, as IAM attests them. Nothing here is read from a header
// the client controls: every field comes out of a signature.
type Who struct {
User string // subject — a person, never a key
Org string // the tenant that pays for the audio
Name string
Token string // the caller's own bearer, carried so downstream calls bill them
}
// Gate answers who a bearer belongs to.
//
// Verification is IAM's own SDK rather than a JWT library used directly. That
// is not deference: the org a token authorises is NOT its `owner` claim —
// `owner` names the application the token was minted through, so reading it as
// the tenant bills one org for another org's audio. Claims.Home() reads the
// signed membership set instead, and it is the only correct answer.
type Gate struct{ v *edge.Verifier }
// Keys are cached for a minute: long enough that a conversation never waits on
// IAM, short enough that a rotation lands without a restart.
const keyLife = time.Minute
func NewGate(jwks string, issuers, audiences []string) *Gate {
return &Gate{v: edge.NewVerifier(jwks, issuers, audiences, keyLife)}
}
// Read verifies a bearer and says who holds it.
//
// `selected` is the caller's X-Org-Id, honoured only when the signed membership
// set contains it — EffectiveOrg does that check, so a client cannot bill a
// tenant it does not belong to by asking nicely.
func (g *Gate) Read(bearer, selected string) (Who, error) {
raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(bearer), "Bearer "))
if raw == "" {
return Who{}, fmt.Errorf("no bearer")
}
claims, err := g.v.VerifyRaw(raw)
if err != nil {
return Who{}, fmt.Errorf("bearer rejected: %w", err)
}
// A machine credential carries no subject. Refusing it here is structural
// rather than a list of credential kinds to keep up to date: audio seconds
// have to land on a person for caller-pays billing to mean anything.
if strings.TrimSpace(claims.Subject) == "" {
return Who{}, fmt.Errorf("credential names no subject")
}
org := claims.Home()
if selected = strings.TrimSpace(selected); selected != "" {
if eff, _ := claims.EffectiveOrg(selected); eff != "" {
org = eff
}
}
if org == "" {
return Who{}, fmt.Errorf("credential names no org")
}
return Who{User: claims.Subject, Org: org, Name: claims.Name, Token: raw}, nil
}