Changeset 0.32.0 (#206)
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"chat-switchboard/database"
|
||||
)
|
||||
|
||||
const pgNotifyChannel = "switchboard_events"
|
||||
|
||||
25
server/events/ticket_adapter.go
Normal file
25
server/events/ticket_adapter.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user