package api import ( "crypto/rand" "encoding/base64" "sync" "time" "github.com/google/uuid" ) // wsTicketStore mints short-lived single-use tickets that authorise a // WebSocket handshake. The plan calls this option 3 in Block B: cookies // don't reach the WS handshake on cross-origin setups and a JWT in the URL // would leak to logs; a one-shot ticket sidesteps both. // // Block B keeps this in-process. When the API runs more than one replica // this needs to move to Redis (Block C territory). type wsTicketStore struct { mu sync.Mutex entries map[string]wsTicketEntry ttl time.Duration now func() time.Time } type wsTicketEntry struct { userID uuid.UUID eventID uuid.UUID expiresAt time.Time } func newWSTicketStore(ttl time.Duration) *wsTicketStore { return &wsTicketStore{ entries: make(map[string]wsTicketEntry), ttl: ttl, now: time.Now, } } // Mint returns a fresh URL-safe ticket bound to userID + eventID. func (s *wsTicketStore) Mint(userID, eventID uuid.UUID) (string, time.Time, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", time.Time{}, err } tok := base64.RawURLEncoding.EncodeToString(buf) exp := s.now().Add(s.ttl) s.mu.Lock() defer s.mu.Unlock() s.sweepLocked() s.entries[tok] = wsTicketEntry{userID: userID, eventID: eventID, expiresAt: exp} return tok, exp, nil } // Consume removes the ticket and returns the bound (userID, eventID) if it // was valid. A ticket is single-use — replaying it fails. func (s *wsTicketStore) Consume(token string) (uuid.UUID, uuid.UUID, bool) { s.mu.Lock() defer s.mu.Unlock() entry, ok := s.entries[token] if !ok { return uuid.Nil, uuid.Nil, false } delete(s.entries, token) if s.now().After(entry.expiresAt) { return uuid.Nil, uuid.Nil, false } return entry.userID, entry.eventID, true } // sweepLocked drops expired entries opportunistically. Cheap because we // usually only hold dozens of tickets at a time. func (s *wsTicketStore) sweepLocked() { now := s.now() for k, v := range s.entries { if now.After(v.expiresAt) { delete(s.entries, k) } } }