2026-08-10 23:53:11 +08:00
|
|
|
// 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 }
|
2026-08-11 15:41:07 +08:00
|
|
|
func (e Event) Sensors() []Sensor { return append([]Sensor(nil), e.shape.Sensors...) }
|
2026-08-10 23:53:11 +08:00
|
|
|
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
|
|
|
|
|
}
|