package protocol import ( "bytes" "encoding/json" "errors" "fmt" "io" "time" ) const ( // Schema is the supported envelope and payload schema version. Schema = 1 // MeasurementRepeatInterval bounds equal retained measurement repeats. MeasurementRepeatInterval = 5 * time.Minute // OnlineRepeatInterval bounds equal online availability repeats. OnlineRepeatInterval = 5 * time.Minute // DeviceContactTimeout bounds the age of device contact evidence. DeviceContactTimeout = 15 * time.Minute ) // 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"` } // Envelope carries one JSON object and its schema and metadata. type Envelope 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 := Envelope{ 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) (Envelope, error) { var envelope Envelope err := decodeObject(data, &envelope) if err != nil { return Envelope{}, fmt.Errorf("decode envelope: %w", err) } err = validateEnvelope(envelope) if err != nil { return Envelope{}, fmt.Errorf("validate envelope: %w", err) } return envelope, nil } func validateEnvelope(envelope Envelope) error { if envelope.Schema != Schema { return fmt.Errorf("unsupported schema %d", envelope.Schema) } if !ValidID(envelope.MessageID) { return fmt.Errorf("message ID must be a canonical ULID") } if envelope.CorrelationID != "" && !ValidID(envelope.CorrelationID) { return fmt.Errorf("correlation ID must be a canonical ULID") } if envelope.CausationID != "" && !ValidID(envelope.CausationID) { return fmt.Errorf("causation ID must be a canonical ULID") } for _, timestamp := range []*time.Time{envelope.SentAt, envelope.ExpiresAt} { if timestamp != nil { _, offset := timestamp.Zone() if offset != 0 { return fmt.Errorf("message times must use UTC") } } } if !isObject(envelope.Payload) { return fmt.Errorf("payload must be a JSON object") } 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) }