// Package event assembles and validates immutable Bell event facts. package event import ( "bytes" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "math" "net" "net/url" "regexp" "strings" "time" "github.com/oklog/ulid/v2" jsonschema "github.com/santhosh-tekuri/jsonschema/v6" ) const MaxPayloadBytes = 1 << 20 type ErrorCode string const ( CodeInvalidJSON ErrorCode = "invalid_json" CodePayloadTooLarge ErrorCode = "payload_too_large" CodeUpstreamID ErrorCode = "upstream_id_forbidden" CodeSchema ErrorCode = "schema_invalid" CodeTimeOrder ErrorCode = "time_order_invalid" CodeLatency ErrorCode = "latency_inconsistent" CodeConfidence ErrorCode = "confidence_forbidden" CodeEvidence ErrorCode = "evidence_unsafe" CodePrimarySensor ErrorCode = "primary_sensor_invalid" CodePrivacyDenied ErrorCode = "privacy_denied" CodePrivacyUnavailable ErrorCode = "privacy_unavailable" ) // ValidationError exposes a stable code without returning sensitive payloads. type ValidationError struct { Code ErrorCode Err error } func (e *ValidationError) Error() string { return string(e.Code) } func (e *ValidationError) Unwrap() error { return e.Err } func validationError(code ErrorCode, err error) error { return &ValidationError{Code: code, Err: err} } // IDGenerator is owned by Bell. Upstream candidates are not allowed to carry id. type IDGenerator interface { NewEventID() (string, error) } type ULIDGenerator struct{} func (ULIDGenerator) NewEventID() (string, error) { return "evt_" + ulid.Make().String(), nil } // PrivacyPolicy resolves the authoritative device/Area policy. Implementations // must fail closed when the mapping is missing or stale. type PrivacyPolicy interface { VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error) } // EvidencePolicy checks every evidence/observation URI before persistence. type EvidencePolicy interface { ValidateURI(rawURI string) error } // EvidenceGuard rejects reusable credentials, network endpoints and configured // customer/tenant names from persisted evidence URIs. type EvidenceGuard struct { forbidden []string } func NewEvidenceGuard(forbiddenNames ...string) (*EvidenceGuard, error) { guard := &EvidenceGuard{} for _, name := range forbiddenNames { name = strings.ToLower(strings.TrimSpace(name)) if name == "" { return nil, errors.New("forbidden evidence name cannot be blank") } guard.forbidden = append(guard.forbidden, name) } return guard, nil } var ipv4Like = regexp.MustCompile(`(?:^|[^0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:[^0-9]|$)`) func (g *EvidenceGuard) ValidateURI(rawURI string) error { parsed, err := url.Parse(rawURI) if err != nil || parsed.Scheme == "" { return errors.New("evidence URI is not absolute") } if parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" { return errors.New("evidence URI contains reusable connection material") } if host := parsed.Hostname(); host != "" && net.ParseIP(host) != nil { return errors.New("evidence URI contains an IP address") } lower := strings.ToLower(rawURI) for _, marker := range []string{"password", "passwd", "credential", "secret", "token=", "rtsp://"} { if strings.Contains(lower, marker) { return errors.New("evidence URI contains a forbidden marker") } } if ipv4Like.MatchString(lower) { return errors.New("evidence URI contains an IPv4-like value") } for _, name := range g.forbidden { if strings.Contains(lower, name) { return errors.New("evidence URI contains a configured sensitive name") } } return nil } type Sensor struct { DeviceID int64 `json:"device_id"` Modality string `json:"modality"` Role string `json:"role"` } type storedShape struct { ID string `json:"id"` TenantID int64 `json:"tenant_id"` SiteID int64 `json:"site_id"` DeviceID int64 `json:"device_id"` SourceEventID string `json:"source_event_id"` Sensors []Sensor `json:"sensors"` Kind string `json:"kind"` Severity string `json:"severity"` Confidence *float64 `json:"confidence"` OccurredAt time.Time `json:"occurred_at"` DetectedAt time.Time `json:"detected_at"` LatencySeconds float64 `json:"latency_seconds"` Observation *struct { BBoxSeqURI *string `json:"bbox_seq_uri"` KeypointSeqURI *string `json:"keypoint_seq_uri"` SignalSeqURI *string `json:"signal_seq_uri"` } `json:"observation"` Evidence struct { SnapshotURIs []string `json:"snapshot_uris"` ClipURI *string `json:"clip_uri"` } `json:"evidence"` } // Event is a final, schema-valid immutable fact. JSON returns a defensive copy. type Event struct { shape storedShape payload []byte digest [sha256.Size]byte } func (e Event) ID() string { return e.shape.ID } func (e Event) TenantID() int64 { return e.shape.TenantID } func (e Event) SiteID() int64 { return e.shape.SiteID } func (e Event) DeviceID() int64 { return e.shape.DeviceID } func (e Event) SourceEventID() string { return e.shape.SourceEventID } func (e Event) Kind() string { return e.shape.Kind } func (e Event) Severity() string { return e.shape.Severity } func (e Event) OccurredAt() time.Time { return e.shape.OccurredAt } func (e Event) DetectedAt() time.Time { return e.shape.DetectedAt } func (e Event) Digest() [sha256.Size]byte { return e.digest } func (e Event) JSON() []byte { return bytes.Clone(e.payload) } type Factory struct { schema *jsonschema.Schema ids IDGenerator privacy PrivacyPolicy evidence EvidencePolicy } func NewFactory(schemaJSON []byte, ids IDGenerator, privacy PrivacyPolicy, evidence EvidencePolicy) (*Factory, error) { if ids == nil || privacy == nil || evidence == nil { return nil, errors.New("event factory dependencies are required") } schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON)) if err != nil { return nil, fmt.Errorf("parse event schema: %w", err) } compiler := jsonschema.NewCompiler() compiler.AssertFormat() if err := compiler.AddResource("event-v0.1.schema.json", schemaDoc); err != nil { return nil, fmt.Errorf("register event schema: %w", err) } compiled, err := compiler.Compile("event-v0.1.schema.json") if err != nil { return nil, fmt.Errorf("compile event schema: %w", err) } return &Factory{schema: compiled, ids: ids, privacy: privacy, evidence: evidence}, nil } // Create turns a producer candidate into the final stored v0.1 event. The // candidate must contain every v0.1 field except the Bell-owned id. func (f *Factory) Create(ctx context.Context, candidate []byte) (Event, error) { if len(candidate) > MaxPayloadBytes { return Event{}, validationError(CodePayloadTooLarge, nil) } decoder := json.NewDecoder(bytes.NewReader(candidate)) decoder.UseNumber() var object map[string]any if err := decoder.Decode(&object); err != nil || object == nil { return Event{}, validationError(CodeInvalidJSON, err) } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { return Event{}, validationError(CodeInvalidJSON, errors.New("multiple JSON values")) } if _, exists := object["id"]; exists { return Event{}, validationError(CodeUpstreamID, nil) } id, err := f.ids.NewEventID() if err != nil { return Event{}, fmt.Errorf("generate Bell event id: %w", err) } object["id"] = id payload, err := json.Marshal(object) if err != nil { return Event{}, validationError(CodeInvalidJSON, err) } if len(payload) > MaxPayloadBytes { return Event{}, validationError(CodePayloadTooLarge, nil) } instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(payload)) if err != nil { return Event{}, validationError(CodeInvalidJSON, err) } if err := f.schema.Validate(instance); err != nil { return Event{}, validationError(CodeSchema, nil) } var shape storedShape if err := json.Unmarshal(payload, &shape); err != nil { return Event{}, validationError(CodeInvalidJSON, err) } if err := f.assertSemantics(ctx, shape); err != nil { return Event{}, err } return Event{shape: shape, payload: payload, digest: sha256.Sum256(payload)}, nil } func (f *Factory) assertSemantics(ctx context.Context, shape storedShape) error { if shape.DetectedAt.Before(shape.OccurredAt) { return validationError(CodeTimeOrder, nil) } actual := shape.DetectedAt.Sub(shape.OccurredAt).Seconds() if math.Abs(actual-shape.LatencySeconds) >= 0.1 { return validationError(CodeLatency, nil) } if shape.Confidence != nil { return validationError(CodeConfidence, nil) } primary := 0 for _, sensor := range shape.Sensors { if sensor.Role == "primary" { primary++ if sensor.DeviceID != shape.DeviceID { return validationError(CodePrimarySensor, nil) } } if sensor.Modality == "video" { allowed, err := f.privacy.VideoAllowed(ctx, shape.TenantID, shape.SiteID, sensor.DeviceID) if err != nil { return validationError(CodePrivacyUnavailable, nil) } if !allowed { return validationError(CodePrivacyDenied, nil) } } } if primary != 1 { return validationError(CodePrimarySensor, nil) } var uris []string uris = append(uris, shape.Evidence.SnapshotURIs...) if shape.Evidence.ClipURI != nil { uris = append(uris, *shape.Evidence.ClipURI) } if shape.Observation != nil { for _, value := range []*string{ shape.Observation.BBoxSeqURI, shape.Observation.KeypointSeqURI, shape.Observation.SignalSeqURI, } { if value != nil { uris = append(uris, *value) } } } for _, rawURI := range uris { if err := f.evidence.ValidateURI(rawURI); err != nil { return validationError(CodeEvidence, nil) } } return nil }