Changeset 0.32.0 (#206)

This commit is contained in:
2026-03-19 18:50:27 +00:00
parent 6668e546fe
commit b1266b0d7c
283 changed files with 2187 additions and 1055 deletions

View File

@@ -115,7 +115,7 @@ func TestRouteFor(t *testing.T) {
{"pong", DirToClient},
// Tool bridge routes
{"tool.call.abc123", DirToClient},
{"tool.result.abc123", DirFromClient},
{"tool.result.abc123", DirBoth}, // v0.32.0: DirBoth for cross-pod WaitFor
// Extension lifecycle
{"extension.loaded", DirLocal},
{"extension.error", DirLocal},
@@ -201,11 +201,14 @@ func TestToolCallRouteToClient(t *testing.T) {
}
}
func TestToolResultRouteFromClient(t *testing.T) {
func TestToolResultRouteBoth(t *testing.T) {
// v0.32.0: tool.result is DirBoth so results cross pods for WaitFor.
// The WS subscriber explicitly filters out tool.result events to
// prevent re-sending to clients (see subscribeToBus in ws.go).
if !ShouldAcceptFromClient("tool.result.abc123") {
t.Error("tool.result.* should be accepted from client")
}
if ShouldSendToClient("tool.result.abc123") {
t.Error("tool.result.* should NOT be sent to client")
if !ShouldSendToClient("tool.result.abc123") {
t.Error("tool.result.* should route DirBoth (filtered by WS subscriber, not routing table)")
}
}

View File

@@ -23,7 +23,7 @@ import (
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database"
"chat-switchboard/database"
)
const pgNotifyChannel = "switchboard_events"

View File

@@ -0,0 +1,25 @@
package events
// v0.32.0: TicketValidatorAdapter bridges the context-aware store.TicketStore
// to the middleware.TicketValidator interface (which has no context parameter).
// Replaces the in-memory TicketStore that lived in this file previously.
import (
"context"
"time"
"chat-switchboard/store"
)
// TicketValidatorAdapter satisfies middleware.TicketValidator using a
// store.TicketStore backend (PG or SQLite).
type TicketValidatorAdapter struct {
Store store.TicketStore
}
// Validate atomically consumes a ticket. Satisfies middleware.TicketValidator.
func (a *TicketValidatorAdapter) Validate(ticketID string) (string, bool) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return a.Store.Validate(ctx, ticketID)
}

View File

@@ -1,118 +0,0 @@
package events
import (
"crypto/rand"
"encoding/hex"
"log"
"sync"
"time"
)
const (
// ticketTTL is how long a ticket remains valid after issuance.
ticketTTL = 30 * time.Second
// ticketReapInterval is how often the background reaper runs.
ticketReapInterval = 15 * time.Second
)
// ticket is a single-use opaque token that maps to a user ID.
type ticket struct {
userID string
expiresAt time.Time
}
// TicketStore manages short-lived, single-use WebSocket authentication tickets.
//
// Flow:
// 1. Client POSTs to /api/v1/ws/ticket (authenticated via JWT).
// 2. Server returns {"ticket": "<opaque>"}.
// 3. Client connects WebSocket with ?ticket=<opaque>.
// 4. Server validates and deletes the ticket atomically (single-use).
//
// This replaces passing the JWT as ?token= in the WebSocket URL, which
// exposed the token in server logs, proxy logs, and browser history.
type TicketStore struct {
mu sync.Mutex
tickets map[string]ticket
stopCh chan struct{}
}
// NewTicketStore creates a store and starts the background reaper.
func NewTicketStore() *TicketStore {
ts := &TicketStore{
tickets: make(map[string]ticket),
stopCh: make(chan struct{}),
}
go ts.reaper()
return ts
}
// Issue creates a new single-use ticket for the given user.
// Returns the opaque ticket string.
func (ts *TicketStore) Issue(userID string) (string, error) {
b := make([]byte, 16) // 128-bit random
if _, err := rand.Read(b); err != nil {
return "", err
}
id := hex.EncodeToString(b)
ts.mu.Lock()
ts.tickets[id] = ticket{
userID: userID,
expiresAt: time.Now().Add(ticketTTL),
}
ts.mu.Unlock()
return id, nil
}
// Validate checks a ticket, deletes it (single-use), and returns the user ID.
// Returns ("", false) if the ticket is invalid, expired, or already consumed.
func (ts *TicketStore) Validate(ticketID string) (string, bool) {
ts.mu.Lock()
defer ts.mu.Unlock()
t, ok := ts.tickets[ticketID]
if !ok {
return "", false
}
delete(ts.tickets, ticketID) // single-use: delete immediately
if time.Now().After(t.expiresAt) {
return "", false
}
return t.userID, true
}
// Stop shuts down the background reaper.
func (ts *TicketStore) Stop() {
close(ts.stopCh)
}
// reaper periodically removes expired tickets that were never validated.
func (ts *TicketStore) reaper() {
ticker := time.NewTicker(ticketReapInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ts.mu.Lock()
now := time.Now()
reaped := 0
for id, t := range ts.tickets {
if now.After(t.expiresAt) {
delete(ts.tickets, id)
reaped++
}
}
ts.mu.Unlock()
if reaped > 0 {
log.Printf("[ws-tickets] reaped %d expired tickets", reaped)
}
case <-ts.stopCh:
return
}
}
}

View File

@@ -9,6 +9,11 @@ type Event struct {
Payload json.RawMessage `json:"payload"`
Ts int64 `json:"ts"`
// v0.32.0: Cross-pod targeted delivery. When set, only connections
// belonging to this user receive the event. Serialized for pg_broadcast
// (harmless if clients see it — they ignore unknown fields).
TargetUserID string `json:"target_user_id,omitempty"`
// Server-side metadata (not serialized to clients)
SenderID string `json:"-"` // user ID of sender, empty for system events
ConnID string `json:"-"` // WebSocket connection ID, empty for server-origin
@@ -60,7 +65,7 @@ var routeTable = map[string]Direction{
"role.fallback": DirToClient,
// Notifications (v0.20.0)
"notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based
"notification.new": DirToClient, // targeted via Hub.PublishToUser, not room-based
"notification.read": DirToClient, // badge sync across tabs
// Workspace (v0.21.5)
@@ -79,7 +84,7 @@ var routeTable = map[string]Direction{
// Tool execution (browser tools bridge)
"tool.call.": DirToClient, // Server → specific client (browser tool invocation)
"tool.result.": DirFromClient, // Client → server (browser tool result)
"tool.result.": DirBoth, // v0.32.0: DirBoth — result must cross pods for WaitFor
// Extension lifecycle
"extension.loaded": DirLocal, // Client-only

View File

@@ -170,9 +170,17 @@ func (h *Hub) IsConnected(userID string) bool {
return false
}
// SendToUser sends an event directly to all WebSocket connections for a user,
// bypassing room filtering. Used for targeted tool.call delivery.
func (h *Hub) SendToUser(userID string, event Event) {
// PublishToUser sends an event to a specific user via the bus.
// v0.32.0: Cross-pod safe — the bus broadcast hook fans out via pg_notify.
// All replicas receive the event; only the one with the user's connection delivers it.
func (h *Hub) PublishToUser(userID string, event Event) {
event.TargetUserID = userID
h.bus.Publish(event)
}
// sendToUserLocal sends an event directly to local WebSocket connections for a user.
// Unexported — callers should use PublishToUser for cross-pod delivery.
func (h *Hub) sendToUserLocal(userID string, event Event) {
data, err := json.Marshal(event)
if err != nil {
return
@@ -226,6 +234,18 @@ func (c *Conn) subscribeToBus() {
return
}
// v0.32.0: Targeted delivery — only deliver to the intended user.
// Targeted events skip room filtering (user-scoped, not room-scoped).
if e.TargetUserID != "" && e.TargetUserID != c.userID {
return
}
// v0.32.0: tool.result travels cross-pod for WaitFor (DirBoth) but
// must not be forwarded to WebSocket clients — they sent it.
if strings.HasPrefix(e.Label, "tool.result.") {
return
}
// Don't echo typing events back to the sender
if e.Label == "chat.typing" || e.ConnID == c.id {
if e.SenderID == c.userID && e.ConnID == c.id {