mirror of
https://github.com/hanzoai/world-zap.git
synced 2026-10-30 14:55:43 +00:00
Real-time Hanzo World feeds over ZAP binary protocol (9-byte header +
JSON payload) fronted by WebSockets at wss://zap.world.hanzo.ai/zap.
IAM bearer auth via Authorization header or ?token= query param.
Validator caches /oauth/userinfo for 5 minutes.
Topics (10): world.events.{all,conflicts,earthquakes,fires},
world.markets.{quotes,crypto}, world.ships.ais, world.aviation.opensky,
world.news.live, world.weather.alerts. Minimum plan enforced per topic.
Tools: subscribe, unsubscribe, list_topics. Admin-only publish for
internal service accounts.
Ingest: streams events from worldmonitor GET /v1/world/events?stream=1,
fans out via bounded-channel hub with drop-on-overflow semantics.
Per-user token bucket rate limits: free 30/min, pro 3000/min, team
15000/min, enterprise 60000/min.
Tests: all packages pass -race (proto, auth, hub, ratelimit, ingest,
handlers). Multi-arch image build wired to hanzoai/.github docker-build
workflow.
428 lines
11 KiB
Go
428 lines
11 KiB
Go
// Package handlers wires ZAP frames to hub and auth operations for a single
|
|
// websocket session. One Session per connection.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
"github.com/hanzoai/world-zap/auth"
|
|
"github.com/hanzoai/world-zap/hub"
|
|
"github.com/hanzoai/world-zap/proto"
|
|
"github.com/hanzoai/world-zap/ratelimit"
|
|
)
|
|
|
|
// Deps are the shared runtime dependencies a session needs.
|
|
type Deps struct {
|
|
Hub *hub.Hub
|
|
Auth *auth.Validator
|
|
Limiter *ratelimit.Limiter
|
|
Logger *slog.Logger
|
|
WriteWait time.Duration
|
|
PingEvery time.Duration
|
|
}
|
|
|
|
// Session manages a single websocket connection.
|
|
type Session struct {
|
|
conn *websocket.Conn
|
|
principal auth.Principal
|
|
deps Deps
|
|
|
|
mu sync.Mutex
|
|
subs map[string]*hub.Subscription // topic -> sub
|
|
writeMu sync.Mutex // serializes writes to conn
|
|
}
|
|
|
|
// New constructs a Session.
|
|
func New(conn *websocket.Conn, p auth.Principal, deps Deps) *Session {
|
|
if deps.WriteWait <= 0 {
|
|
deps.WriteWait = 10 * time.Second
|
|
}
|
|
if deps.PingEvery <= 0 {
|
|
deps.PingEvery = 30 * time.Second
|
|
}
|
|
return &Session{conn: conn, principal: p, deps: deps, subs: map[string]*hub.Subscription{}}
|
|
}
|
|
|
|
// Run is the session event loop. It terminates when ctx is cancelled, the
|
|
// connection closes, or a protocol error occurs.
|
|
func (s *Session) Run(ctx context.Context) {
|
|
defer s.shutdown()
|
|
|
|
// Send INIT_ACK immediately so clients can confirm the handshake.
|
|
if err := s.sendFrame(proto.TypeINITACK, map[string]any{
|
|
"user_id": s.principal.UserID,
|
|
"org": s.principal.Org,
|
|
"plan": s.principal.Plan,
|
|
"capacity": ratelimit.PlanCapacity(s.principal.Plan),
|
|
}); err != nil {
|
|
s.deps.Logger.Warn("zap: init_ack failed", "err", err.Error())
|
|
return
|
|
}
|
|
|
|
// Pump outbound PUSH frames from each subscription.
|
|
go s.pumpSubs(ctx)
|
|
|
|
// Application-layer keepalive.
|
|
ticker := time.NewTicker(s.deps.PingEvery)
|
|
defer ticker.Stop()
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
_ = s.sendFrame(proto.TypePING, nil)
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
_, msg, err := s.conn.ReadMessage()
|
|
if err != nil {
|
|
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
|
s.deps.Logger.Debug("zap: read", "err", err.Error())
|
|
}
|
|
return
|
|
}
|
|
f, _, err := proto.Decode(msg)
|
|
if err != nil {
|
|
_ = s.sendError("bad_frame", err.Error())
|
|
return
|
|
}
|
|
if err := s.handle(ctx, f); err != nil {
|
|
_ = s.sendError("handler_error", err.Error())
|
|
if errors.Is(err, errFatal) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handle dispatches a single decoded frame.
|
|
func (s *Session) handle(ctx context.Context, f proto.Frame) error {
|
|
switch f.Type {
|
|
case proto.TypeINIT:
|
|
// Re-INIT simply re-acks.
|
|
return s.sendFrame(proto.TypeINITACK, map[string]any{"ok": true})
|
|
case proto.TypePING:
|
|
return s.sendFrame(proto.TypePONG, nil)
|
|
case proto.TypePONG:
|
|
return nil
|
|
case proto.TypeListTools:
|
|
return s.handleListTools()
|
|
case proto.TypeCallTool:
|
|
return s.handleCallTool(ctx, f.Payload)
|
|
default:
|
|
return fmt.Errorf("unsupported frame: %s", proto.TypeName(f.Type))
|
|
}
|
|
}
|
|
|
|
// handleListTools responds with the tool catalog. Currently the only session
|
|
// tools are subscribe/unsubscribe/list_topics and (admin only) publish.
|
|
func (s *Session) handleListTools() error {
|
|
tools := []map[string]any{
|
|
{
|
|
"name": "subscribe",
|
|
"description": "Subscribe to a world topic. Emits PUSH frames until unsubscribed.",
|
|
"inputSchema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"topic": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"topic"},
|
|
},
|
|
},
|
|
{
|
|
"name": "unsubscribe",
|
|
"description": "Cancel a prior subscribe.",
|
|
"inputSchema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"topic": map[string]any{"type": "string"}},
|
|
"required": []string{"topic"},
|
|
},
|
|
},
|
|
{
|
|
"name": "list_topics",
|
|
"description": "List available topics and required plan.",
|
|
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
|
|
},
|
|
}
|
|
if s.principal.IsAdmin {
|
|
tools = append(tools, map[string]any{
|
|
"name": "publish",
|
|
"description": "Publish an event to a topic (admin only).",
|
|
"inputSchema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"topic": map[string]any{"type": "string"},
|
|
"payload": map[string]any{"type": "object"},
|
|
},
|
|
"required": []string{"topic", "payload"},
|
|
},
|
|
})
|
|
}
|
|
return s.sendFrame(proto.TypeListTools, map[string]any{"tools": tools})
|
|
}
|
|
|
|
// errFatal marks an error that should terminate the session.
|
|
var errFatal = errors.New("fatal")
|
|
|
|
// callToolPayload is the on-the-wire shape of a CALL_TOOL frame's payload.
|
|
type callToolPayload struct {
|
|
Name string `json:"name"`
|
|
Args json.RawMessage `json:"args"`
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
func (s *Session) handleCallTool(ctx context.Context, raw []byte) error {
|
|
gate := s.deps.Limiter.TryAcquire(s.principal.UserID, s.principal.Plan)
|
|
if !gate.Allowed {
|
|
return s.sendFrame(proto.TypeERROR, map[string]any{
|
|
"code": "rate_limited",
|
|
"plan": s.principal.Plan,
|
|
"capacity": ratelimit.PlanCapacity(s.principal.Plan),
|
|
"retry_ms": gate.RetryIn.Milliseconds(),
|
|
})
|
|
}
|
|
|
|
var p callToolPayload
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return fmt.Errorf("call_tool: decode: %w", err)
|
|
}
|
|
|
|
switch p.Name {
|
|
case "subscribe":
|
|
return s.toolSubscribe(ctx, p)
|
|
case "unsubscribe":
|
|
return s.toolUnsubscribe(p)
|
|
case "list_topics":
|
|
return s.toolListTopics(p)
|
|
case "publish":
|
|
if !s.principal.IsAdmin {
|
|
return s.sendCallResult(p.ID, map[string]any{"error": "forbidden"}, true)
|
|
}
|
|
return s.toolPublish(p)
|
|
default:
|
|
return s.sendCallResult(p.ID, map[string]any{"error": "unknown tool: " + p.Name}, true)
|
|
}
|
|
}
|
|
|
|
func planLevel(p string) int {
|
|
switch p {
|
|
case "enterprise":
|
|
return 4
|
|
case "team":
|
|
return 3
|
|
case "pro":
|
|
return 2
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func (s *Session) toolSubscribe(_ context.Context, call callToolPayload) error {
|
|
var args struct {
|
|
Topic string `json:"topic"`
|
|
}
|
|
if err := json.Unmarshal(call.Args, &args); err != nil {
|
|
return s.sendCallResult(call.ID, map[string]any{"error": "bad args: " + err.Error()}, true)
|
|
}
|
|
required := hub.TopicPlan(args.Topic)
|
|
if required == "" {
|
|
return s.sendCallResult(call.ID, map[string]any{"error": "unknown topic"}, true)
|
|
}
|
|
if planLevel(s.principal.Plan) < planLevel(required) {
|
|
return s.sendCallResult(call.ID, map[string]any{
|
|
"error": "plan_too_low",
|
|
"required": required,
|
|
"current": s.principal.Plan,
|
|
}, true)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
if _, exists := s.subs[args.Topic]; exists {
|
|
s.mu.Unlock()
|
|
return s.sendCallResult(call.ID, map[string]any{"already_subscribed": true, "topic": args.Topic}, false)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
sub, err := s.deps.Hub.Subscribe(args.Topic)
|
|
if err != nil {
|
|
return s.sendCallResult(call.ID, map[string]any{"error": err.Error()}, true)
|
|
}
|
|
s.mu.Lock()
|
|
s.subs[args.Topic] = sub
|
|
s.mu.Unlock()
|
|
return s.sendCallResult(call.ID, map[string]any{"subscribed": true, "topic": args.Topic}, false)
|
|
}
|
|
|
|
func (s *Session) toolUnsubscribe(call callToolPayload) error {
|
|
var args struct {
|
|
Topic string `json:"topic"`
|
|
}
|
|
if err := json.Unmarshal(call.Args, &args); err != nil {
|
|
return s.sendCallResult(call.ID, map[string]any{"error": "bad args: " + err.Error()}, true)
|
|
}
|
|
s.mu.Lock()
|
|
sub, ok := s.subs[args.Topic]
|
|
if ok {
|
|
delete(s.subs, args.Topic)
|
|
}
|
|
s.mu.Unlock()
|
|
if !ok {
|
|
return s.sendCallResult(call.ID, map[string]any{"error": "not subscribed"}, true)
|
|
}
|
|
s.deps.Hub.Unsubscribe(sub)
|
|
return s.sendCallResult(call.ID, map[string]any{"unsubscribed": true, "topic": args.Topic}, false)
|
|
}
|
|
|
|
func (s *Session) toolListTopics(call callToolPayload) error {
|
|
names := hub.TopicNames()
|
|
list := make([]map[string]string, 0, len(names))
|
|
for _, n := range names {
|
|
list = append(list, map[string]string{"topic": n, "required_plan": hub.TopicPlan(n)})
|
|
}
|
|
return s.sendCallResult(call.ID, map[string]any{"topics": list}, false)
|
|
}
|
|
|
|
func (s *Session) toolPublish(call callToolPayload) error {
|
|
var args struct {
|
|
Topic string `json:"topic"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
if err := json.Unmarshal(call.Args, &args); err != nil {
|
|
return s.sendCallResult(call.ID, map[string]any{"error": "bad args: " + err.Error()}, true)
|
|
}
|
|
if args.Topic == "" || len(args.Payload) == 0 {
|
|
return s.sendCallResult(call.ID, map[string]any{"error": "topic and payload required"}, true)
|
|
}
|
|
if err := s.deps.Hub.Publish(hub.Message{Topic: args.Topic, Payload: []byte(args.Payload)}); err != nil {
|
|
return s.sendCallResult(call.ID, map[string]any{"error": err.Error()}, true)
|
|
}
|
|
return s.sendCallResult(call.ID, map[string]any{"published": true, "topic": args.Topic}, false)
|
|
}
|
|
|
|
// pumpSubs forwards hub messages to the client as PUSH frames.
|
|
func (s *Session) pumpSubs(ctx context.Context) {
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
// Snapshot current subs.
|
|
s.mu.Lock()
|
|
subs := make([]*hub.Subscription, 0, len(s.subs))
|
|
for _, sub := range s.subs {
|
|
subs = append(subs, sub)
|
|
}
|
|
s.mu.Unlock()
|
|
if len(subs) == 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
continue
|
|
}
|
|
// Non-blocking drain of each subscription.
|
|
for _, sub := range subs {
|
|
select {
|
|
case msg, ok := <-sub.Channel():
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := s.sendPush(msg); err != nil {
|
|
return
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) sendPush(msg hub.Message) error {
|
|
body, err := json.Marshal(map[string]any{
|
|
"topic": msg.Topic,
|
|
"event": json.RawMessage(msg.Payload),
|
|
"ts": time.Now().UTC().Format(time.RFC3339Nano),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.writeFrame(proto.TypePUSH, body)
|
|
}
|
|
|
|
// sendFrame JSON-encodes v into a frame and writes it.
|
|
func (s *Session) sendFrame(t byte, v any) error {
|
|
b, err := proto.EncodeJSON(t, v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.writeRaw(b)
|
|
}
|
|
|
|
func (s *Session) sendError(code, detail string) error {
|
|
return s.sendFrame(proto.TypeERROR, map[string]string{"code": code, "detail": detail})
|
|
}
|
|
|
|
// sendCallResult packages a tool response as a RESOLVE frame.
|
|
func (s *Session) sendCallResult(id string, result any, isError bool) error {
|
|
body, err := json.Marshal(result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.sendFrame(proto.TypeRESOLVE, map[string]any{
|
|
"id": id,
|
|
"error": isError,
|
|
"content": json.RawMessage(body),
|
|
})
|
|
}
|
|
|
|
// writeFrame assembles a frame from a precomputed body and sends it.
|
|
func (s *Session) writeFrame(t byte, body []byte) error {
|
|
b, err := proto.Encode(proto.Frame{Type: t, Payload: body})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.writeRaw(b)
|
|
}
|
|
|
|
func (s *Session) writeRaw(b []byte) error {
|
|
s.writeMu.Lock()
|
|
defer s.writeMu.Unlock()
|
|
_ = s.conn.SetWriteDeadline(time.Now().Add(s.deps.WriteWait))
|
|
return s.conn.WriteMessage(websocket.BinaryMessage, b)
|
|
}
|
|
|
|
// shutdown tears down all subscriptions and closes the connection.
|
|
func (s *Session) shutdown() {
|
|
s.mu.Lock()
|
|
subs := make([]*hub.Subscription, 0, len(s.subs))
|
|
for _, sub := range s.subs {
|
|
subs = append(subs, sub)
|
|
}
|
|
s.subs = nil
|
|
s.mu.Unlock()
|
|
for _, sub := range subs {
|
|
s.deps.Hub.Unsubscribe(sub)
|
|
}
|
|
_ = s.conn.Close()
|
|
}
|