package protocol import ( "bytes" "encoding/json" "errors" "fmt" "io" "time" ) // Schema is the supported envelope and payload schema version. const Schema = 1 // Metadata holds message identity and optional UTC times. Retries keep the same // ID. A receiver checks ExpiresAt against its clock before processing. type Metadata struct { MessageID string `json:"message_id"` SentAt *time.Time `json:"sent_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` CorrelationID string `json:"correlation_id,omitempty"` CausationID string `json:"causation_id,omitempty"` } // wireEnvelope carries one JSON object and its schema and metadata. type wireEnvelope struct { Schema int `json:"schema"` Metadata Payload json.RawMessage `json:"payload"` } // encodeEnvelope validates metadata and encodes a schema-1 object payload. func encodeEnvelope(metadata Metadata, payload any) ([]byte, error) { encoded, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("encode payload: %w", err) } envelope := wireEnvelope{ Schema: Schema, Metadata: metadata, Payload: encoded, } err = validateEnvelope(envelope) if err != nil { return nil, fmt.Errorf("validate envelope: %w", err) } encoded, err = json.Marshal(envelope) if err != nil { return nil, fmt.Errorf("encode envelope: %w", err) } return encoded, nil } // parseEnvelope rejects unknown fields, trailing JSON, unsupported schemas, // invalid IDs, non-UTC times, and missing or non-object payloads. func parseEnvelope(data []byte) (wireEnvelope, error) { var parsed wireEnvelope err := decodeObject(data, &parsed) if err != nil { return wireEnvelope{}, fmt.Errorf("decode envelope: %w", err) } err = validateEnvelope(parsed) if err != nil { return wireEnvelope{}, fmt.Errorf("validate envelope: %w", err) } return parsed, nil } func validateEnvelope(envelope wireEnvelope) error { if envelope.Schema != Schema { return fmt.Errorf("unsupported schema %d", envelope.Schema) } err := validateMetadata(envelope.Metadata) if err != nil { return fmt.Errorf("validate metadata: %w", err) } if !isObject(envelope.Payload) { return fmt.Errorf("payload must be a JSON object") } return nil } func validateMetadata(metadata Metadata) error { if !ValidID(metadata.MessageID) { return fmt.Errorf("message ID must be a canonical ULID") } if metadata.CorrelationID != "" && !ValidID(metadata.CorrelationID) { return fmt.Errorf("correlation ID must be a canonical ULID") } if metadata.CausationID != "" && !ValidID(metadata.CausationID) { return fmt.Errorf("causation ID must be a canonical ULID") } for _, timestamp := range []*time.Time{metadata.SentAt, metadata.ExpiresAt} { if timestamp != nil { _, offset := timestamp.Zone() if offset != 0 { return fmt.Errorf("message times must use UTC") } } } return nil } func decodeObject(data []byte, target any) error { if !isObject(data) { return fmt.Errorf("expected one JSON object") } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() err := decoder.Decode(target) if err != nil { return fmt.Errorf("decode object: %w", err) } var extra any err = decoder.Decode(&extra) if !errors.Is(err, io.EOF) { return fmt.Errorf("expected end of JSON object") } return nil } func isObject(data []byte) bool { data = bytes.TrimSpace(data) return len(data) > 0 && data[0] == '{' && json.Valid(data) }