felt/internal/nats/publisher.go
Mikkel Georgsen 16caa12d64 feat(01-01): implement core infrastructure — NATS, LibSQL, WebSocket hub, HTTP server
- Embedded NATS server with JetStream (sync_interval=always per Jepsen 2025)
- AUDIT and STATE JetStream streams for tournament event durability
- NATS publisher with UUID validation to prevent subject injection
- WebSocket hub with JWT auth (query param), tournament-scoped broadcasting
- Origin validation and slow-consumer message dropping
- chi HTTP router with middleware (logger, recoverer, request ID, CORS, body limits)
- Server timeouts: ReadHeader 10s, Read 30s, Write 60s, Idle 120s, MaxHeader 1MB
- MaxBytesReader middleware for request body limits (1MB default)
- JWT auth middleware with HMAC-SHA256 validation
- Role-based access control (admin > floor > viewer)
- Health endpoint reporting all subsystem status (DB, NATS, WebSocket)
- SvelteKit SPA served via go:embed with fallback routing
- Signal-driven graceful shutdown in reverse startup order
- 9 integration tests covering all verification criteria

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:42:42 +01:00

77 lines
2.4 KiB
Go

package nats
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/nats-io/nats.go/jetstream"
)
// uuidRegex validates UUID format (8-4-4-4-12 hex digits).
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// Publisher provides tournament-scoped event publishing to JetStream.
type Publisher struct {
js jetstream.JetStream
}
// NewPublisher creates a new publisher using the given JetStream context.
func NewPublisher(js jetstream.JetStream) *Publisher {
return &Publisher{js: js}
}
// ValidateUUID checks that the given string is a valid UUID and does not
// contain NATS subject wildcards (* > .) that could enable subject injection.
func ValidateUUID(id string) error {
if id == "" {
return fmt.Errorf("empty UUID")
}
// Check for NATS subject wildcards that could cause injection
if strings.ContainsAny(id, "*>.") {
return fmt.Errorf("UUID contains NATS subject wildcards: %q", id)
}
if !uuidRegex.MatchString(id) {
return fmt.Errorf("invalid UUID format: %q", id)
}
return nil
}
// Publish publishes a tournament-scoped event to JetStream.
// The full subject is constructed as: tournament.<tournamentID>.<subject>
//
// The tournamentID is validated as a proper UUID before subject construction
// to prevent subject injection attacks.
func (p *Publisher) Publish(ctx context.Context, tournamentID, subject string, data []byte) (*jetstream.PubAck, error) {
if err := ValidateUUID(tournamentID); err != nil {
return nil, fmt.Errorf("publish: invalid tournament ID: %w", err)
}
if subject == "" {
return nil, fmt.Errorf("publish: empty subject")
}
fullSubject := fmt.Sprintf("tournament.%s.%s", tournamentID, subject)
ack, err := p.js.Publish(ctx, fullSubject, data)
if err != nil {
return nil, fmt.Errorf("publish to %s: %w", fullSubject, err)
}
return ack, nil
}
// PublishAudit is a convenience method for publishing audit events.
// Subject: tournament.<tournamentID>.audit
func (p *Publisher) PublishAudit(ctx context.Context, tournamentID string, data []byte) (*jetstream.PubAck, error) {
return p.Publish(ctx, tournamentID, "audit", data)
}
// PublishState is a convenience method for publishing state change events.
// Subject: tournament.<tournamentID>.state.<stateType>
func (p *Publisher) PublishState(ctx context.Context, tournamentID, stateType string, data []byte) (*jetstream.PubAck, error) {
return p.Publish(ctx, tournamentID, "state."+stateType, data)
}