Compare commits

...
Author SHA1 Message Date
QiuSW 04c5deecfb feat: 实现可靠事件与证据 connector (#153) 2026-08-31 14:57:48 +08:00
ila cf00d73436 Merge pull request '#163' from docs/151-machine-identity-acceptance
docs: 同步 #151 机器身份与安全传输 v1
2026-08-31 11:24:10 +08:00
QiuSW ae9bd015c2 docs: 记录三项目机器身份与安全传输 v1 (#151) 2026-08-31 11:23:52 +08:00
ila 06e0790f00 Merge pull request '#162' from task/151-machine-identity
feat: 建立三项目机器身份与安全传输 v1 (#151)
2026-08-31 11:14:52 +08:00
QiuSW 009dc3cca0 feat: 建立三项目机器身份与安全传输 v1 (#151) 2026-08-31 10:53:51 +08:00
ila 573113eb3b Merge pull request '#161' from docs/150-contract-acceptance
docs: 同步 #150 标准事件与证据 v1 契约
2026-08-31 10:05:00 +08:00
QiuSW b548b05874 docs: 记录标准事件与证据 v1 契约 (#150) 2026-08-31 10:04:39 +08:00
ila 23a85278cb Merge pull request '#158' from feature/150-event-evidence-contract into dev
冻结 Sense/Brain→Bell 标准事件与证据引用契约 v1(#150)。
2026-08-31 09:55:35 +08:00
ila 96777a948f Merge pull request '#160' from docs/148-149-contract-acceptance into dev
同步 #148、#149 冻结契约的 Wiki 核心镜像。
2026-08-31 09:41:48 +08:00
QiuSW c2b023c9fe docs: 记录冻结的 Sense Brain v1 契约 (#148 #149) 2026-08-31 09:41:33 +08:00
ila 4c35da9ef6 Merge pull request '#157' from feature/149-runtime-status-contract into dev
冻结 Brain→Sense 推理运行与健康状态契约 v1(#149)。
2026-08-31 09:34:58 +08:00
ila 30c43aa8d7 Merge pull request '#159' from feature/148-source-config-contract into dev
冻结 Sense→Brain 媒体源与区域规则配置契约 v1(#148)。
2026-08-31 09:34:43 +08:00
QiuSW 359c553452 fix: align evidence success state (#150) 2026-08-31 08:57:25 +08:00
QiuSW 2e61167500 fix: 统一源配置契约字段命名 (#148) 2026-08-31 08:53:22 +08:00
QiuSW 67391acb16 feat: 冻结源配置契约 v1 (#148) 2026-08-31 08:48:18 +08:00
QiuSW 2a395aa126 feat: freeze event evidence contract v1 (#150) 2026-08-31 08:45:57 +08:00
103 changed files with 8140 additions and 27 deletions
@@ -0,0 +1,117 @@
package event_ingress
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gorm.io/gorm"
"go-admin/app/bell/integration/machine_identity"
)
type EvidenceClient struct {
Endpoint string
Signer machine_identity.Signer
HTTP interface {
Do(*http.Request) (*http.Response, error)
}
}
func NewEvidenceClient(endpoint string, signer machine_identity.Signer) (*EvidenceClient, error) {
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("Sense evidence endpoint must be an HTTPS origin without userinfo")
}
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second}
return &EvidenceClient{Endpoint: strings.TrimRight(endpoint, "/"), Signer: signer, HTTP: &http.Client{Transport: transport, Timeout: 8 * time.Second}}, nil
}
func (c EvidenceClient) Refresh(ctx context.Context, db *gorm.DB, status EvidenceStatus) error {
path := "/v1/evidence/" + status.EvidenceID
token, err := c.Signer.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
if err != nil {
return c.degrade(db, status, "unavailable", "machine_identity_error")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoint+path, nil)
if err != nil {
return c.degrade(db, status, "unavailable", "invalid_request")
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("X-Request-ID", newCorrelationID())
response, err := c.HTTP.Do(request)
if err != nil {
code := "evidence_unavailable"
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
code = "evidence_timeout"
}
return c.degrade(db, status, "unavailable", code)
}
defer response.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
if readErr != nil || len(body) > 64*1024 {
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
}
if response.StatusCode == http.StatusNotFound {
return c.degrade(db, status, "unavailable", "evidence_not_found")
}
if response.StatusCode == http.StatusGone {
return c.degrade(db, status, "expired", "evidence_expired")
}
if response.StatusCode != http.StatusOK {
return c.degrade(db, status, "unavailable", "evidence_unavailable")
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var evidence Evidence
if err = decoder.Decode(&evidence); err != nil || evidence.EvidenceID != status.EvidenceID || evidence.OwnerID != status.OwnerID || validateEvidence(evidence) != nil {
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
}
canonical, err := canonicalJSON(body)
if err != nil {
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
}
now := time.Now().UTC()
var expiresAt *time.Time
if evidence.ExpiresAt != "" {
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
if parseErr != nil {
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
}
parsedExpiry = parsedExpiry.UTC()
expiresAt = &parsedExpiry
}
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"status": evidence.Status, "resolution": "current", "current_payload": canonical, "last_error": "", "expires_at": expiresAt, "checked_at": now, "updated_at": now}).Error
}
func (c EvidenceClient) degrade(db *gorm.DB, status EvidenceStatus, resolution, code string) error {
now := time.Now().UTC()
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"resolution": resolution, "last_error": code, "checked_at": now, "updated_at": now}).Error
}
func newCorrelationID() string {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "request-id-fallback"
}
return base64.RawURLEncoding.EncodeToString(raw)
}
func LoadEvidenceClient(getenv func(string) string) (*EvidenceClient, error) {
key, err := machine_identity.LoadPrivateKey(getenv("BELL_SENSE_PRIVATE_KEY_PATH"))
if err != nil {
return nil, fmt.Errorf("load Bell evidence key: %w", err)
}
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("BELL_SENSE_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("BELL_SENSE_KEY_ID")), PrivateKey: key}
return NewEvidenceClient(strings.TrimSpace(getenv("BELL_SENSE_EVIDENCE_ENDPOINT")), signer)
}
@@ -0,0 +1,107 @@
package event_ingress
import (
"errors"
"io"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/bell/integration/machine_identity"
)
const MaxRequestBytes = 64 * 1024
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
type Handler struct {
DB *gorm.DB
Verifier machine_identity.Verifier
Enabled bool
Resolver EvidenceRefresher
}
func (h Handler) Post(c *gin.Context) {
if !h.Enabled {
writeProblem(c, http.StatusServiceUnavailable, "connector_disabled", "event connector is disabled", "")
return
}
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
} else if !requestIDPattern.MatchString(requestID) {
writeProblem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID must be an opaque 16-128 character value", "")
return
}
c.Header("X-Request-ID", requestID)
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != "/v1/events" {
writeProblem(c, http.StatusBadRequest, "invalid_request_target", "event request target must be the normalized /v1/events path", "")
return
}
if relayHeader := c.GetHeader("X-YoVision-Relay-ID"); relayHeader != "" {
relayID := strings.TrimSpace(relayHeader)
if relayID != relayHeader || !validID(relayID) {
writeProblem(c, http.StatusBadRequest, "invalid_event", "relay identity header is invalid", "")
return
}
}
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxRequestBytes))
if err != nil {
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large", "")
return
}
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
if err != nil {
writeProblem(c, http.StatusUnauthorized, machineErrorCode(err), "machine identity was rejected", "")
return
}
if _, err = h.Verifier.Verify(token, "yovision-bell", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
status := http.StatusUnauthorized
code := machineErrorCode(err)
if code == "machine_scope_denied" || code == "machine_audience_denied" {
status = http.StatusForbidden
}
writeProblem(c, status, code, "machine identity was rejected", "")
return
}
parsed, err := ParseEvent(body)
if err != nil {
if errors.Is(err, ErrUnsupportedSchema) {
writeProblem(c, http.StatusUnprocessableEntity, "unsupported_schema_version", "event schema version is unsupported", "")
return
}
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload failed validation", "")
return
}
result, err := (Service{DB: h.DB, Resolver: h.Resolver}).Ingest(c.Request.Context(), parsed)
if errors.Is(err, ErrIdempotencyConflict) {
writeProblem(c, http.StatusConflict, "idempotency_conflict", "idempotency key is already bound to another payload", result.EventID)
return
}
if err != nil {
writeProblem(c, http.StatusServiceUnavailable, "ingest_unavailable", "event ingest is temporarily unavailable", "")
return
}
status := http.StatusCreated
if result.Disposition == "duplicate" {
status = http.StatusOK
}
c.JSON(status, result)
}
func writeProblem(c *gin.Context, status int, code, message, existing string) {
c.Header("Content-Type", "application/problem+json")
c.JSON(status, Problem{Code: code, Message: message, ExistingEventID: existing})
}
func machineErrorCode(err error) string {
var machineErr *machine_identity.Error
if errors.As(err, &machineErr) {
return machineErr.Code
}
return "machine_token_invalid"
}
@@ -0,0 +1,116 @@
package event_ingress
import (
"encoding/json"
"time"
)
const (
EventSchemaVersion = "yovision.event/v1"
EvidenceSchemaVersion = "yovision.evidence-reference/v1"
)
type Event struct {
SchemaVersion string `json:"schema_version"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
SiteRef string `json:"site_ref"`
DeviceRef string `json:"device_ref"`
ProfileRef string `json:"profile_ref"`
EventType string `json:"event_type"`
OccurredAt string `json:"occurred_at"`
Severity string `json:"severity"`
Rule Rule `json:"rule"`
Model Model `json:"model"`
Observation Observation `json:"observation"`
Region Region `json:"region"`
Evidence []Evidence `json:"evidence"`
}
type Rule struct {
RuleID string `json:"rule_id"`
Version string `json:"version"`
}
type Model struct {
Name string `json:"name"`
Version string `json:"version"`
}
type Observation struct {
TrackID string `json:"track_id"`
Category string `json:"category"`
Confidence float64 `json:"confidence"`
BBoxNormalized []float64 `json:"bbox_normalized,omitempty"`
}
type Region struct {
RegionID string `json:"region_id"`
Kind string `json:"kind"`
CrossingDirection string `json:"crossing_direction,omitempty"`
}
type Evidence struct {
SchemaVersion string `json:"schema_version"`
EvidenceID string `json:"evidence_id"`
OwnerID string `json:"owner_id"`
Type string `json:"type"`
Status string `json:"status"`
CapturedAt string `json:"captured_at"`
StatusUpdatedAt string `json:"status_updated_at"`
ExpiresAt string `json:"expires_at,omitempty"`
ContentType string `json:"content_type,omitempty"`
Integrity *EvidenceIntegrity `json:"integrity,omitempty"`
Failure *EvidenceFailure `json:"failure,omitempty"`
}
type EvidenceIntegrity struct {
Algorithm string `json:"algorithm"`
Digest string `json:"digest"`
SizeBytes int64 `json:"size_bytes"`
}
type EvidenceFailure struct {
Code string `json:"code"`
Retryable bool `json:"retryable"`
}
type IngestResult struct {
EventID string `json:"event_id"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
Disposition string `json:"disposition"`
PayloadSHA256 string `json:"payload_sha256"`
}
type Problem struct {
Code string `json:"code"`
Message string `json:"message"`
Field string `json:"field,omitempty"`
ExistingEventID string `json:"existing_event_id,omitempty"`
}
type ParsedEvent struct {
Event Event
Canonical json.RawMessage
Digest string
Occurred time.Time
}
// EvidenceStatus is mutable Bell-owned resolution metadata kept separately
// from the immutable Event and from Alert acknowledgement/close facts.
type EvidenceStatus struct {
EventID string `gorm:"type:uuid;primaryKey"`
EvidenceID string `gorm:"size:128;primaryKey"`
OwnerID string `gorm:"size:128;not null;index"`
Status string `gorm:"size:16;not null"`
Resolution string `gorm:"size:16;not null;index"`
CurrentPayload json.RawMessage `gorm:"column:current_payload;type:jsonb;not null"`
LastError string `gorm:"size:64;not null;default:''"`
ExpiresAt *time.Time `gorm:"index"`
CheckedAt *time.Time
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}
func (EvidenceStatus) TableName() string { return "bell_evidence_status" }
@@ -0,0 +1,42 @@
package event_ingress
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ReplayToken is Bell-owned security state. It is intentionally separate from
// business Receipt idempotency and remains effective across process restarts.
type ReplayToken struct {
Principal string `gorm:"size:128;primaryKey"`
TokenID string `gorm:"size:64;primaryKey"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time `gorm:"not null"`
}
func (ReplayToken) TableName() string { return "bell_machine_token_replays" }
type PersistentReplayStore struct{ DB *gorm.DB }
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
if s.DB == nil {
return false
}
accepted := false
err := s.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
return err
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{
Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC(),
})
if result.Error != nil {
return result.Error
}
accepted = result.RowsAffected == 1
return nil
})
return err == nil && accepted
}
@@ -0,0 +1,40 @@
package event_ingress
import (
"fmt"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"go-admin/app/bell/integration/machine_identity"
)
func RegisterRuntime(engine *gin.Engine) error {
enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVENT_INGRESS_ENABLED")), "true")
if !enabled {
return nil
}
db := sdk.Runtime.GetDbByKey("")
if db == nil {
return fmt.Errorf("Bell event ingress database is unavailable")
}
if !db.Migrator().HasTable(&ReplayToken{}) || !db.Migrator().HasTable(&EvidenceStatus{}) {
return fmt.Errorf("Bell event ingress migration is required")
}
registry, err := machine_identity.LoadRegistry(os.Getenv("BELL_MACHINE_PRINCIPAL_REGISTRY"), "yovision-bell")
if err != nil {
return fmt.Errorf("load Bell machine identity registry: %w", err)
}
var resolver EvidenceRefresher
if strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVIDENCE_RESOLVER_ENABLED")), "true") {
resolver, err = LoadEvidenceClient(os.Getenv)
if err != nil {
return fmt.Errorf("load Bell evidence resolver: %w", err)
}
}
handler := Handler{DB: db, Enabled: true, Resolver: resolver, Verifier: machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}}
engine.POST("/v1/events", handler.Post)
return nil
}
@@ -0,0 +1,125 @@
package event_ingress
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"go-admin/app/bell/event"
"go-admin/app/bell/receipt"
)
var ErrIdempotencyConflict = errors.New("idempotency_conflict")
type EvidenceRefresher interface {
Refresh(context.Context, *gorm.DB, EvidenceStatus) error
}
type Service struct {
DB *gorm.DB
Resolver EvidenceRefresher
}
func (s Service) Ingest(ctx context.Context, parsed ParsedEvent) (IngestResult, error) {
if s.DB == nil {
return IngestResult{}, errors.New("event database is unavailable")
}
var output IngestResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if tx.Dialector.Name() == "postgres" {
key := fmt.Sprintf("%d:%s:%s", len(parsed.Event.ProducerID), parsed.Event.ProducerID, parsed.Event.SourceEventID)
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; err != nil {
return err
}
}
var existing struct {
EventID string
PayloadSHA256 string
}
err := tx.Model(&receipt.Receipt{}).Clauses(clause.Locking{Strength: "UPDATE"}).
Select("event_id", "payload_sha256").Where("producer_id = ? AND source_event_id = ?", parsed.Event.ProducerID, parsed.Event.SourceEventID).First(&existing).Error
if err == nil {
if existing.PayloadSHA256 != parsed.Digest {
output.EventID = existing.EventID
return ErrIdempotencyConflict
}
output = IngestResult{EventID: existing.EventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "duplicate", PayloadSHA256: parsed.Digest}
return tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeReplay, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
now := time.Now().UTC()
eventID := uuid.NewString()
var evidenceRef *string
if len(parsed.Event.Evidence) > 0 {
value := parsed.Event.Evidence[0].EvidenceID
evidenceRef = &value
}
item := event.Event{ID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID,
EventType: parsed.Event.EventType, OccurredAt: parsed.Occurred, Location: parsed.Event.SiteRef + "/" + parsed.Event.DeviceRef,
Severity: parsed.Event.Severity, EvidenceRef: evidenceRef, NormalizedPayload: parsed.Canonical, PayloadSHA256: parsed.Digest, ReceivedAt: now}
receiptItem := receipt.Receipt{ID: uuid.NewString(), EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, AcceptedAt: now}
if err := tx.Create(&item).Error; err != nil {
return err
}
if err := tx.Create(&receiptItem).Error; err != nil {
return err
}
for _, evidence := range parsed.Event.Evidence {
payload, marshalErr := json.Marshal(evidence)
if marshalErr != nil {
return marshalErr
}
canonical, canonicalErr := canonicalJSON(payload)
if canonicalErr != nil {
return canonicalErr
}
var expiresAt *time.Time
if evidence.ExpiresAt != "" {
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
if parseErr != nil {
return parseErr
}
parsedExpiry = parsedExpiry.UTC()
expiresAt = &parsedExpiry
}
status := EvidenceStatus{EventID: eventID, EvidenceID: evidence.EvidenceID, OwnerID: evidence.OwnerID,
Status: evidence.Status, Resolution: "snapshot", CurrentPayload: canonical, ExpiresAt: expiresAt, CreatedAt: now, UpdatedAt: now}
if err := tx.Create(&status).Error; err != nil {
return err
}
}
if err := tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeAccepted, ActorID: 0, CreatedAt: now}).Error; err != nil {
return err
}
output = IngestResult{EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "created", PayloadSHA256: parsed.Digest}
return nil
})
if errors.Is(err, ErrIdempotencyConflict) {
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeConflict, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
if auditErr != nil {
return IngestResult{}, fmt.Errorf("record conflict audit: %w", auditErr)
}
return output, ErrIdempotencyConflict
}
if err != nil || s.Resolver == nil {
return output, err
}
var statuses []EvidenceStatus
if err = s.DB.WithContext(ctx).Where("event_id = ?", output.EventID).Find(&statuses).Error; err != nil {
return IngestResult{}, err
}
for _, status := range statuses {
// Evidence lookup is supplementary. The immutable Event/Receipt boundary
// remains accepted even when Sense is unavailable.
_ = s.Resolver.Refresh(ctx, s.DB.WithContext(ctx), status)
}
return output, nil
}
@@ -0,0 +1,217 @@
package event_ingress
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"time"
)
var (
ErrInvalidEvent = errors.New("invalid_event")
ErrUnsupportedSchema = errors.New("unsupported_schema_version")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
hexDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
func ParseEvent(raw []byte) (ParsedEvent, error) {
var event Event
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&event); err != nil {
return ParsedEvent{}, fmt.Errorf("%w: malformed or unknown member", ErrInvalidEvent)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return ParsedEvent{}, fmt.Errorf("%w: trailing JSON value", ErrInvalidEvent)
}
if event.SchemaVersion == "" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.SchemaVersion != EventSchemaVersion {
return ParsedEvent{}, ErrUnsupportedSchema
}
occurred, err := time.Parse("2006-01-02T15:04:05.000Z", event.OccurredAt)
if err != nil || !validID(event.ProducerID) || !validID(event.SourceEventID) || !validID(event.SiteRef) ||
!validID(event.DeviceRef) || !validID(event.ProfileRef) || !validID(event.Rule.RuleID) ||
!validID(event.Observation.TrackID) || !validID(event.Region.RegionID) || event.Rule.Version == "" ||
len(event.Rule.Version) > 64 || event.Model.Name == "" || len(event.Model.Name) > 128 ||
event.Model.Version == "" || len(event.Model.Version) > 64 || event.Observation.Confidence < 0 ||
event.Observation.Confidence > 1 || math.IsNaN(event.Observation.Confidence) || math.IsInf(event.Observation.Confidence, 0) {
return ParsedEvent{}, ErrInvalidEvent
}
if event.EventType != "dangerous_area_entered" && event.EventType != "directional_line_crossed" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Severity != "low" && event.Severity != "medium" && event.Severity != "high" && event.Severity != "critical" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Observation.Category != "person" && event.Observation.Category != "vehicle" && event.Observation.Category != "other" {
return ParsedEvent{}, ErrInvalidEvent
}
if len(event.Observation.BBoxNormalized) != 0 && len(event.Observation.BBoxNormalized) != 4 {
return ParsedEvent{}, ErrInvalidEvent
}
for _, value := range event.Observation.BBoxNormalized {
if value < 0 || value > 1 || math.IsNaN(value) || math.IsInf(value, 0) {
return ParsedEvent{}, ErrInvalidEvent
}
}
if (event.EventType == "dangerous_area_entered" && (event.Region.Kind != "area" || event.Region.CrossingDirection != "")) ||
(event.EventType == "directional_line_crossed" && (event.Region.Kind != "line" || (event.Region.CrossingDirection != "a_to_b" && event.Region.CrossingDirection != "b_to_a"))) {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Evidence == nil || len(event.Evidence) > 8 {
return ParsedEvent{}, ErrInvalidEvent
}
seenEvidence := map[string]bool{}
for _, evidence := range event.Evidence {
evidenceJSON, marshalErr := json.Marshal(evidence)
canonicalEvidence, canonicalErr := canonicalJSON(evidenceJSON)
if err := validateEvidence(evidence); err != nil || marshalErr != nil || canonicalErr != nil || seenEvidence[string(canonicalEvidence)] {
return ParsedEvent{}, ErrInvalidEvent
}
seenEvidence[string(canonicalEvidence)] = true
}
canonical, err := canonicalJSON(raw)
if err != nil {
return ParsedEvent{}, ErrInvalidEvent
}
if containsExplicitNull(raw) {
return ParsedEvent{}, fmt.Errorf("%w: optional members must be omitted", ErrInvalidEvent)
}
digest := sha256.Sum256(canonical)
return ParsedEvent{Event: event, Canonical: canonical, Digest: hex.EncodeToString(digest[:]), Occurred: occurred}, nil
}
func validateEvidence(value Evidence) error {
if value.SchemaVersion != EvidenceSchemaVersion || !validID(value.EvidenceID) || !validID(value.OwnerID) ||
(value.Type != "snapshot" && value.Type != "clip") {
return ErrInvalidEvent
}
if _, err := time.Parse(time.RFC3339Nano, value.CapturedAt); err != nil {
return ErrInvalidEvent
}
if _, err := time.Parse(time.RFC3339Nano, value.StatusUpdatedAt); err != nil {
return ErrInvalidEvent
}
if value.ExpiresAt != "" {
if _, err := time.Parse(time.RFC3339Nano, value.ExpiresAt); err != nil {
return ErrInvalidEvent
}
}
switch value.Status {
case "pending", "processing":
if value.ContentType != "" || value.Integrity != nil || value.Failure != nil {
return ErrInvalidEvent
}
case "success":
if value.Integrity == nil || value.Failure != nil || (value.ContentType != "image/jpeg" && value.ContentType != "image/png" && value.ContentType != "video/mp4") ||
value.Integrity.Algorithm != "sha256" || !hexDigestPattern.MatchString(value.Integrity.Digest) || value.Integrity.SizeBytes < 0 {
return ErrInvalidEvent
}
case "failed":
if value.Failure == nil || value.ContentType != "" || value.Integrity != nil ||
(value.Failure.Code != "capture_failed" && value.Failure.Code != "processing_failed" && value.Failure.Code != "expired" && value.Failure.Code != "unavailable") {
return ErrInvalidEvent
}
default:
return ErrInvalidEvent
}
return nil
}
func canonicalJSON(raw []byte) ([]byte, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
value, err := normalizeJCSNumbers(value)
if err != nil {
return nil, err
}
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return nil, err
}
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
return canonical, nil
}
func normalizeJCSNumbers(value any) (any, error) {
switch typed := value.(type) {
case json.Number:
number, err := strconv.ParseFloat(string(typed), 64)
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
return nil, errors.New("JSON number is outside the RFC 8785 domain")
}
if number == 0 {
return float64(0), nil
}
return number, nil
case []any:
for index, item := range typed {
normalized, err := normalizeJCSNumbers(item)
if err != nil {
return nil, err
}
typed[index] = normalized
}
case map[string]any:
for key, item := range typed {
normalized, err := normalizeJCSNumbers(item)
if err != nil {
return nil, err
}
typed[key] = normalized
}
}
return value, nil
}
func containsExplicitNull(raw []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if decoder.Decode(&value) != nil {
return true
}
return hasNull(value)
}
func hasNull(value any) bool {
switch typed := value.(type) {
case nil:
return true
case []any:
for _, item := range typed {
if hasNull(item) {
return true
}
}
case map[string]any:
for _, item := range typed {
if hasNull(item) {
return true
}
}
}
return false
}
func validID(value string) bool {
return identifierPattern.MatchString(value) && !strings.ContainsAny(strings.ToLower(value), "\\/@")
}
@@ -0,0 +1,68 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"strings"
)
type registryDocument struct {
Version string `json:"version"`
Audience string `json:"audience"`
Principals []registryPrincipal `json:"principals"`
}
type registryPrincipal struct {
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
Keys []registryKey `json:"keys"`
}
type registryKey struct {
KeyID string `json:"kid"`
PublicKey string `json:"public_key_base64url"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
}
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
return nil, errors.New("machine principal registry path and audience are required")
}
raw, err := os.ReadFile(filePath)
if err != nil {
return nil, errors.New("read machine principal registry")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var document registryDocument
if err = decoder.Decode(&document); err != nil {
return nil, errors.New("invalid machine principal registry")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid machine principal registry")
}
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
return nil, errors.New("invalid machine principal registry")
}
records := make([]KeyRecord, 0)
for _, principal := range document.Principals {
if len(principal.Keys) == 0 {
return nil, errors.New("invalid machine principal registry")
}
for _, key := range principal.Keys {
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
return nil, errors.New("invalid machine principal registry")
}
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
}
}
return NewRegistry(records...)
}
@@ -0,0 +1,363 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"sync"
"time"
)
var (
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
)
const (
Version = "yovision.machine-identity/v1"
TokenType = "YOVISION-MACHINE+JWT"
MaxLifetime = 5 * time.Minute
AllowedSkew = 30 * time.Second
MaxKeyOverlap = 24 * time.Hour
)
type Error struct{ Code string }
func (e *Error) Error() string { return e.Code }
func codeError(code string) error { return &Error{Code: code} }
// BearerToken deliberately has no cookie or query fallback.
func BearerToken(authorization string) (string, error) {
parts := strings.Split(authorization, " ")
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
return "", codeError("machine_token_missing")
}
return parts[1], nil
}
type Claims struct {
Version string `json:"ver"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Scopes []string `json:"scope"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
TokenID string `json:"jti"`
Method string `json:"htm"`
Path string `json:"htu"`
BodySHA256 string `json:"body_sha256"`
}
type protectedHeader struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
Version string `json:"ver"`
}
type KeyRecord struct {
Principal string
KeyID string
PublicKey ed25519.PublicKey
Audience string
Scopes []string
Enabled bool
Revoked bool
}
type Registry struct {
mu sync.RWMutex
keys map[string]KeyRecord
}
func NewRegistry(records ...KeyRecord) (*Registry, error) {
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
for _, record := range records {
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
return nil, errors.New("invalid machine key record")
}
if _, exists := r.keys[record.KeyID]; exists {
return nil, errors.New("duplicate machine key id")
}
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
r.keys[record.KeyID] = record
}
return r, nil
}
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
record, ok := r.keys[keyID]
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
return record, ok
}
func (r *Registry) Revoke(keyID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.keys[keyID]
if !ok {
return false
}
record.Revoked = true
r.keys[keyID] = record
return true
}
type ReplayStore struct {
mu sync.Mutex
used map[string]time.Time
}
// ReplayCache must atomically persist accepted (principal, jti) pairs until
// expiry. ReplayStore is process-local and intended for tests or a single
// uninterrupted process; connector implementations inject a durable store.
type ReplayCache interface {
Consume(principal, tokenID string, expiresAt, now time.Time) bool
}
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, expiry := range s.used {
if !expiry.After(now) {
delete(s.used, key)
}
}
key := principal + "\x00" + tokenID
if _, exists := s.used[key]; exists {
return false
}
s.used[key] = expiresAt
return true
}
type Signer struct {
Principal string
KeyID string
PrivateKey ed25519.PrivateKey
Now func() time.Time
}
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("machine private key path is required")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read machine private key")
}
block, rest := pem.Decode(raw)
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("parse machine private key")
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok || len(key) != ed25519.PrivateKeySize {
return nil, errors.New("machine private key is not Ed25519")
}
return slices.Clone(key), nil
}
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
return "", errors.New("incomplete machine signer configuration")
}
normalizedPath, err := normalizePath(requestPath)
if err != nil {
return "", err
}
method = strings.ToUpper(method)
if !allowedMethod(method) {
return "", errors.New("unsupported machine request method")
}
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
tokenID, err := randomTokenID()
if err != nil {
return "", err
}
digest := sha256.Sum256(body)
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
return signingInput + "." + rawBase64(signature), nil
}
type Verifier struct {
Registry *Registry
Replay ReplayCache
Now func() time.Time
}
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
if v.Registry == nil || v.Replay == nil {
return Claims{}, codeError("machine_token_invalid")
}
parts := strings.Split(token, ".")
if len(parts) != 3 || strings.Contains(token, "=") {
return Claims{}, codeError("machine_token_invalid")
}
headerBytes, err := decodeRaw(parts[0])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var header protectedHeader
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
return Claims{}, codeError("machine_token_invalid")
}
record, ok := v.Registry.Lookup(header.KeyID)
if !ok {
return Claims{}, codeError("machine_token_invalid")
}
signature, err := decodeRaw(parts[2])
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
return Claims{}, codeError("machine_token_invalid")
}
if !record.Enabled || record.Revoked {
return Claims{}, codeError("machine_identity_revoked")
}
claimsBytes, err := decodeRaw(parts[1])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var claims Claims
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
return Claims{}, codeError("machine_token_invalid")
}
now := time.Now().UTC()
if v.Now != nil {
now = v.Now().UTC()
}
nowUnix := now.Unix()
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_invalid")
}
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_expired")
}
if claims.Audience != audience || record.Audience != audience {
return Claims{}, codeError("machine_audience_denied")
}
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
return Claims{}, codeError("machine_scope_denied")
}
normalizedPath, err := normalizePath(requestPath)
digest := sha256.Sum256(body)
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
return Claims{}, codeError("machine_token_invalid")
}
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
return Claims{}, codeError("machine_token_replayed")
}
return claims, nil
}
func decodeClosed(raw []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("trailing JSON value")
}
return err
}
return nil
}
func validClaimsShape(claims Claims) bool {
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
return false
}
if !validScopeList(claims.Scopes) {
return false
}
_, err := hex.DecodeString(claims.BodySHA256)
return err == nil
}
func validScopeList(scopes []string) bool {
if len(scopes) == 0 || len(scopes) > 4 {
return false
}
seen := map[string]bool{}
for _, scope := range scopes {
if !validScopes[scope] || seen[scope] {
return false
}
seen[scope] = true
}
return true
}
func normalizePath(value string) (string, error) {
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
}
return parsed.EscapedPath(), nil
}
func allowedMethod(method string) bool {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
func randomTokenID() (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate machine token id: %w", err)
}
return rawBase64(raw), nil
}
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
func decodeRaw(value string) ([]byte, error) {
return base64.RawURLEncoding.Strict().DecodeString(value)
}
@@ -0,0 +1,219 @@
package machine_identity
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
type crossLanguageVector struct {
PublicKey string `json:"public_key_base64url"`
Token string `json:"token"`
Now int64 `json:"now"`
Audience string `json:"audience"`
Scope string `json:"required_scope"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body_base64"`
}
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0).UTC()
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
if err != nil {
t.Fatal(err)
}
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
}
func errorCode(t *testing.T, err error) string {
t.Helper()
var coded *Error
if !errors.As(err, &coded) {
t.Fatalf("expected coded error, got %v", err)
}
return coded.Code
}
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
t.Fatal(err)
}
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
t.Fatalf("accepted %q", value)
}
}
}
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
mint := func() string {
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
return token
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
token := mint()
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
t.Fatal(err)
}
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
t.Fatal(err)
}
}
func TestExpiryRevocationAndRotation(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte("{}")
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
t.Fatal(err)
}
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
rotation, err := NewRegistry(
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
)
if err != nil {
t.Fatal(err)
}
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if !rotation.Revoke("brain-old-0001") {
t.Fatal("old key was not revoked")
}
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
t.Fatal(err)
}
}
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
if err := safe.Validate(); err != nil {
t.Fatal(err)
}
unsafe := safe
unsafe.VerifyHostname = false
if err := unsafe.Validate(); err == nil {
t.Fatal("unsafe hostname policy accepted")
}
unsafe = safe
unsafe.TLSMinVersion = tls.VersionTLS11
if err := unsafe.Validate(); err == nil {
t.Fatal("TLS 1.1 accepted")
}
}
func TestVerifiesCrossLanguageVector(t *testing.T) {
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
raw, err := os.ReadFile(vectorPath)
if err != nil {
t.Fatal(err)
}
var vector crossLanguageVector
if err = json.Unmarshal(raw, &vector); err != nil {
t.Fatal(err)
}
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
if err != nil {
t.Fatal(err)
}
body, err := base64.StdEncoding.DecodeString(vector.Body)
if err != nil {
t.Fatal(err)
}
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != "yv:brain:vector" {
t.Fatalf("unexpected issuer: %s", claims.Issuer)
}
}
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
document := map[string]any{
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
}}}},
}
raw, _ := json.Marshal(document)
file := filepath.Join(t.TempDir(), "principals.json")
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
registry, err := LoadRegistry(file, "yovision-bell")
if err != nil {
t.Fatal(err)
}
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
t.Fatal("registry record missing")
}
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
t.Fatal("wrong registry audience accepted")
}
}
@@ -0,0 +1,39 @@
package machine_identity
import (
"crypto/tls"
"errors"
"net/http"
"time"
)
type TransportPolicy struct {
TLSMinVersion uint16
VerifyCertificate bool
VerifyHostname bool
ConnectTimeout time.Duration
ResponseHeaderTimeout time.Duration
RequestTimeout time.Duration
MaxRequestBytes int64
}
func (p TransportPolicy) Validate() error {
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
return errors.New("machine transport policy is unsafe")
}
return nil
}
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
if err := p.Validate(); err != nil {
return nil, err
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
TLSHandshakeTimeout: p.ConnectTimeout,
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
}
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
}
+4
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/config"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/integration/event_ingress"
"go-admin/app/bell/synthetic"
"go-admin/common/middleware"
)
@@ -32,6 +33,9 @@ func InitRouter() {
for _, register := range registrars {
register(v1, authMiddleware)
}
if err := event_ingress.RegisterRuntime(engine); err != nil {
log.Errorf("Bell event ingress init error: %v", err)
}
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
registerSyntheticRouter(v1, authMiddleware)
}
@@ -0,0 +1,29 @@
package version
import (
"runtime"
"go-admin/app/bell/integration/event_ingress"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventIngress)
}
func migrateBellEventIngress(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(
&event_ingress.ReplayToken{},
&event_ingress.EvidenceStatus{},
); err != nil {
return err
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,65 @@
package version
import (
"testing"
"time"
"go-admin/app/bell/integration/event_ingress"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestBellEventIngressMigrationIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
const version = "2026083112000"
for attempt := 0; attempt < 2; attempt++ {
if err = migrateBellEventIngress(db, version); err != nil {
t.Fatalf("migration attempt %d: %v", attempt+1, err)
}
}
for name, model := range map[string]any{
"replay tokens": &event_ingress.ReplayToken{},
"evidence statuses": &event_ingress.EvidenceStatus{},
} {
if !db.Migrator().HasTable(model) {
t.Fatalf("%s table missing", name)
}
var count int64
if err = db.Model(model).Count(&count).Error; err != nil {
t.Fatalf("count %s: %v", name, err)
}
if count != 0 {
t.Fatalf("migration inserted %d %s fixtures", count, name)
}
}
if !db.Migrator().HasIndex(&event_ingress.ReplayToken{}, "ExpiresAt") {
t.Fatal("replay expiry index missing")
}
now := time.Now().UTC()
token := event_ingress.ReplayToken{Principal: "brain", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
if err = db.Create(&token).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&token).Error; err == nil {
t.Fatal("duplicate replay token accepted")
}
var applied int64
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if applied != 1 {
t.Fatalf("migration records=%d, want 1", applied)
}
}
@@ -0,0 +1,404 @@
package event_ingress_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"go-admin/app/bell/event"
"go-admin/app/bell/integration/event_ingress"
"go-admin/app/bell/integration/machine_identity"
"go-admin/app/bell/receipt"
)
func TestPostgresConcurrentBusinessAndSecurityIdempotency(t *testing.T) {
dsn := os.Getenv("BELL_EVENT_INGRESS_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set BELL_EVENT_INGRESS_TEST_DATABASE_URL to run PostgreSQL concurrency verification")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
t.Fatal(err)
}
parsed, err := event_ingress.ParseEvent(fixture(t, "dangerous-area.json"))
if err != nil {
t.Fatal(err)
}
const workers = 12
var created, duplicate, failures atomic.Int32
var wait sync.WaitGroup
for range workers {
wait.Add(1)
go func() {
defer wait.Done()
result, ingestErr := (event_ingress.Service{DB: db}).Ingest(context.Background(), parsed)
if ingestErr != nil {
failures.Add(1)
return
}
if result.Disposition == "created" {
created.Add(1)
} else if result.Disposition == "duplicate" {
duplicate.Add(1)
}
}()
}
wait.Wait()
if created.Load() != 1 || duplicate.Load() != workers-1 || failures.Load() != 0 {
t.Fatalf("concurrent ingest created=%d duplicate=%d failures=%d", created.Load(), duplicate.Load(), failures.Load())
}
now := time.Now().UTC()
var consumed atomic.Int32
for range workers {
wait.Add(1)
go func() {
defer wait.Done()
if (event_ingress.PersistentReplayStore{DB: db}).Consume("yv:sense:school-a", "concurrent-token-id-0001", now.Add(time.Minute), now) {
consumed.Add(1)
}
}()
}
wait.Wait()
if consumed.Load() != 1 {
t.Fatalf("concurrent replay consume accepted %d requests", consumed.Load())
}
}
func TestRuntimeRegistrationIsOptionalAndMigrationGated(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
disabled := gin.New()
if err := event_ingress.RegisterRuntime(disabled); err != nil || len(disabled.Routes()) != 0 {
t.Fatalf("disabled runtime err=%v routes=%#v", err, disabled.Routes())
}
db, err := gorm.Open(sqlite.Open("file:bell-runtime?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sdk.Runtime.SetDb("", db)
t.Cleanup(func() { sdk.Runtime.SetDb("", nil) })
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
if err = event_ingress.RegisterRuntime(gin.New()); err == nil {
t.Fatal("enabled runtime started without formal migration")
}
if err = db.AutoMigrate(&event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
t.Fatal(err)
}
registryPath := writeRegistry(t, "yovision-bell", "yv:sense:school-a", "sense-key-0001")
t.Setenv("BELL_MACHINE_PRINCIPAL_REGISTRY", registryPath)
registered := gin.New()
if err = event_ingress.RegisterRuntime(registered); err != nil {
t.Fatalf("enabled runtime did not register after migration: %v", err)
}
routes := registered.Routes()
if len(routes) != 1 || routes[0].Method != http.MethodPost || routes[0].Path != "/v1/events" {
t.Fatalf("unexpected ingress routes: %#v", routes)
}
}
func TestContractFixtureIdempotencyConflictAndReplayPersistence(t *testing.T) {
gin.SetMode(gin.TestMode)
databasePath := filepath.Join(t.TempDir(), "bell-ingress.sqlite")
db := openDatabasePath(t, databasePath)
body := fixture(t, "dangerous-area.json")
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
registry, err := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
newHandler := func() event_ingress.Handler {
return event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
}
firstToken := mint(t, signer, body)
first := request(t, newHandler(), body, firstToken)
if first.Code != http.StatusCreated {
t.Fatalf("first ingest status=%d body=%s", first.Code, first.Body.String())
}
var created event_ingress.IngestResult
decode(t, first, &created)
if created.Disposition != "created" || created.PayloadSHA256 != "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1" {
t.Fatalf("unexpected created result: %+v", created)
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
if err = sqlDatabase.Close(); err != nil {
t.Fatal(err)
}
db = openDatabasePath(t, databasePath)
// A new process-local Handler and replay store still reject the old token,
// proving that security replay state is durable rather than in-memory.
replayedToken := request(t, newHandler(), body, firstToken)
if replayedToken.Code != http.StatusUnauthorized || !strings.Contains(replayedToken.Body.String(), "machine_token_replayed") {
t.Fatalf("token replay status=%d body=%s", replayedToken.Code, replayedToken.Body.String())
}
duplicate := request(t, newHandler(), body, mint(t, signer, body))
if duplicate.Code != http.StatusOK {
t.Fatalf("business duplicate status=%d body=%s", duplicate.Code, duplicate.Body.String())
}
var duplicateResult event_ingress.IngestResult
decode(t, duplicate, &duplicateResult)
if duplicateResult.Disposition != "duplicate" || duplicateResult.EventID != created.EventID {
t.Fatalf("duplicate did not retain event identity: %+v", duplicateResult)
}
numericVariant := bytes.Replace(body, []byte(`0.93`), []byte(`0.930`), 1)
numericDuplicate := request(t, newHandler(), numericVariant, mint(t, signer, numericVariant))
if numericDuplicate.Code != http.StatusOK || !strings.Contains(numericDuplicate.Body.String(), created.PayloadSHA256) {
t.Fatalf("JCS-equivalent numeric payload was not a duplicate: %d %s", numericDuplicate.Code, numericDuplicate.Body.String())
}
var changed map[string]any
if err = json.Unmarshal(body, &changed); err != nil {
t.Fatal(err)
}
changed["severity"] = "critical"
conflicting, _ := json.Marshal(changed)
conflict := request(t, newHandler(), conflicting, mint(t, signer, conflicting))
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "idempotency_conflict") || !strings.Contains(conflict.Body.String(), created.EventID) {
t.Fatalf("conflict status=%d body=%s", conflict.Code, conflict.Body.String())
}
assertCount(t, db, &event.Event{}, 1)
assertCount(t, db, &receipt.Receipt{}, 1)
assertCount(t, db, &receipt.IngestAudit{}, 4)
}
func TestEvidenceDegradationIdentityErrorsAndDisabledConnector(t *testing.T) {
db := openDatabase(t)
publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader)
registry, _ := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
signer := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
handler := event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
pending := fixture(t, "dangerous-area.json")
if response := requestWithID(t, handler, pending, mint(t, signer, pending), "short"); response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request_id") {
t.Fatalf("invalid request id status=%d body=%s", response.Code, response.Body.String())
}
missingRequestID := requestWithID(t, handler, pending, mint(t, signer, pending), "")
if missingRequestID.Code != http.StatusCreated || !requestIDPatternForTest(missingRequestID.Header().Get("X-Request-ID")) {
t.Fatalf("trusted hop did not create a request id: %d %s", missingRequestID.Code, missingRequestID.Body.String())
}
queryResponse := requestTarget(t, handler, pending, mint(t, signer, pending), "/v1/events?debug=true")
if queryResponse.Code != http.StatusBadRequest || !strings.Contains(queryResponse.Body.String(), "invalid_request_target") {
t.Fatalf("query target was accepted: %d %s", queryResponse.Code, queryResponse.Body.String())
}
if response := request(t, handler, pending, mint(t, signer, pending)); response.Code != http.StatusOK {
t.Fatalf("pending evidence rejected: %d %s", response.Code, response.Body.String())
}
failed := fixture(t, "directional-line-crossed.json")
if response := request(t, handler, failed, mint(t, signer, failed)); response.Code != http.StatusCreated {
t.Fatalf("failed evidence rejected: %d %s", response.Code, response.Body.String())
}
assertCount(t, db, &event.Event{}, 2)
wrongAudienceToken, err := signer.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", pending)
if err != nil {
t.Fatal(err)
}
if response := request(t, handler, pending, wrongAudienceToken); response.Code != http.StatusForbidden {
t.Fatalf("wrong audience was not forbidden: %d %s", response.Code, response.Body.String())
}
if response := request(t, event_ingress.Handler{Enabled: false}, pending, "none"); response.Code != http.StatusServiceUnavailable {
t.Fatalf("disabled connector status=%d", response.Code)
}
}
func TestEvidenceResolverCurrentMissingExpiredAndTimeout(t *testing.T) {
db := openDatabase(t)
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
status := event_ingress.EvidenceStatus{EventID: "event-1", EvidenceID: "ev-school-east-0001", OwnerID: "sense-school-a", Status: "pending", Resolution: "snapshot", CurrentPayload: json.RawMessage(`{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"pending","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:01.125Z"}`), CreatedAt: now, UpdatedAt: now}
if err := db.Create(&status).Error; err != nil {
t.Fatal(err)
}
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
signer := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
response := func(code int, body string) *http.Response {
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
}
client := event_ingress.EvidenceClient{Endpoint: "https://sense.example", Signer: signer, HTTP: doFunc(func(*http.Request) (*http.Response, error) {
return response(http.StatusNotFound, `{}`), nil
})}
if err := client.Refresh(context.Background(), db, status); err != nil {
t.Fatal(err)
}
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_not_found" {
t.Fatalf("missing resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
}
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusGone, `{}`), nil })
if err := client.Refresh(context.Background(), db, status); err != nil {
t.Fatal(err)
}
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "expired" {
t.Fatalf("expired resolution=%s db=%v", status.Resolution, err)
}
current := `{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"success","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:02.125Z","content_type":"image/jpeg","integrity":{"algorithm":"sha256","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":1}}`
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusOK, current), nil })
if err := client.Refresh(context.Background(), db, status); err != nil {
t.Fatal(err)
}
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "current" || status.Status != "success" || status.LastError != "" {
t.Fatalf("current status=%s resolution=%s error=%s db=%v", status.Status, status.Resolution, status.LastError, err)
}
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })
if err := client.Refresh(context.Background(), db, status); err != nil {
t.Fatal(err)
}
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_timeout" {
t.Fatalf("timeout resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
}
}
func openDatabase(t *testing.T) *gorm.DB {
return openDatabasePath(t, filepath.Join(t.TempDir(), "bell-ingress.sqlite"))
}
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
t.Fatal(err)
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDatabase.Close() })
return db
}
func fixture(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", name)
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return body
}
func writeRegistry(t *testing.T, audience, principal, keyID string) string {
t.Helper()
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
document := map[string]any{
"version": "yovision.machine-principal-registry/v1", "audience": audience,
"principals": []any{map[string]any{
"principal_id": principal, "enabled": true,
"keys": []any{map[string]any{
"kid": keyID, "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
"status": "active", "scopes": []string{"events:ingest"},
}},
}},
}
encoded, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "registry.json")
if err = os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
return path
}
func mint(t *testing.T, signer machine_identity.Signer, body []byte) string {
t.Helper()
token, err := signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
if err != nil {
t.Fatal(err)
}
return token
}
func request(t *testing.T, handler event_ingress.Handler, body []byte, token string) *httptest.ResponseRecorder {
return requestWithID(t, handler, body, token, "request-id-0000001")
}
func requestWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, requestID string) *httptest.ResponseRecorder {
return requestTargetWithID(t, handler, body, token, "/v1/events", requestID)
}
func requestTarget(t *testing.T, handler event_ingress.Handler, body []byte, token, target string) *httptest.ResponseRecorder {
return requestTargetWithID(t, handler, body, token, target, "request-id-0000001")
}
func requestTargetWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, target, requestID string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("X-Request-ID", requestID)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
handler.Post(context)
return response
}
func requestIDPatternForTest(value string) bool {
if len(value) < 16 || len(value) > 128 {
return false
}
for index, r := range value {
if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || index > 0 && strings.ContainsRune("._:-", r)) {
return false
}
}
return true
}
func decode(t *testing.T, response *httptest.ResponseRecorder, target any) {
t.Helper()
if err := json.Unmarshal(response.Body.Bytes(), target); err != nil {
t.Fatal(err)
}
}
func assertCount(t *testing.T, db *gorm.DB, model any, expected int64) {
t.Helper()
var count int64
if err := db.Model(model).Count(&count).Error; err != nil || count != expected {
t.Fatalf("count %T=%d expected=%d err=%v", model, count, expected, err)
}
}
type doFunc func(*http.Request) (*http.Response, error)
func (function doFunc) Do(request *http.Request) (*http.Response, error) { return function(request) }
+1 -1
View File
@@ -8,7 +8,7 @@ version = "0.1.0"
description = "Headless inference delivery unit for YoVision"
readme = "README.md"
requires-python = "==3.11.*"
dependencies = []
dependencies = ["cryptography==50.0.1"]
[project.optional-dependencies]
# The wheel backend is selected by the official PyTorch index documented in
+5 -2
View File
@@ -11,6 +11,7 @@ from yovision_brain.config import ConfigError
from yovision_brain.decode import DecoderError
from yovision_brain.events import JsonLinesSink
from yovision_brain.input import InputError
from yovision_brain.integration.event_export import build_event_export_sink
from yovision_brain.rules import RuleConfigError
from .runner import run_pipeline
@@ -38,14 +39,16 @@ def main(argv: list[str] | None = None) -> int:
stream = sys.stdout
owned_stream = None
try:
if args.output != "-":
export_sink = build_event_export_sink(raw.get("event_export"), base_dir=config_path.parent)
if export_sink is None and args.output != "-":
try:
owned_stream = Path(args.output).open("w", encoding="utf-8", newline="\n")
except OSError:
print(json.dumps({"status": "error", "message": "event output cannot be opened"}), file=sys.stderr)
return 2
stream = owned_stream
summary = run_pipeline(raw, JsonLinesSink(stream), base_dir=config_path.parent)
sink = export_sink if export_sink is not None else JsonLinesSink(stream)
summary = run_pipeline(raw, sink, base_dir=config_path.parent)
except (ConfigError, DecoderError, InputError, RuleConfigError, RuntimeError, ValueError) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 3
@@ -0,0 +1,30 @@
"""Map Brain-internal candidates to the frozen anonymous event contract."""
from .mapper import (
EVENT_SCHEMA_VERSION,
EVIDENCE_SCHEMA_VERSION,
EventExportError,
canonical_json,
canonical_json_bytes,
export_event,
payload_sha256,
)
from .replay import SQLiteReplayCache
from .client import DeliveryResult, EventDeliveryError, HTTPSMachineIdentitySender
from .runtime import EventExportSink, build_event_export_sink
__all__ = [
"EVENT_SCHEMA_VERSION",
"EVIDENCE_SCHEMA_VERSION",
"EventExportError",
"EventDeliveryError",
"DeliveryResult",
"EventExportSink",
"HTTPSMachineIdentitySender",
"SQLiteReplayCache",
"canonical_json",
"canonical_json_bytes",
"export_event",
"payload_sha256",
"build_event_export_sink",
]
@@ -0,0 +1,167 @@
"""Synchronous, request-bound HTTPS delivery for Brain event exports."""
from __future__ import annotations
import hashlib
import json
import re
import secrets
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Protocol
from urllib.parse import urlsplit
from yovision_brain.integration.machine_identity import Signer, TransportPolicy
EVENT_PATH = "/v1/events"
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
class EventDeliveryError(RuntimeError):
"""An event was not accepted; callers must retain or reproduce the fact."""
def __init__(self, code: str, *, terminal: bool) -> None:
super().__init__(code)
self.code = code
self.terminal = terminal
@dataclass(frozen=True, slots=True)
class DeliveryResult:
disposition: str
request_id: str
class _Headers(Protocol):
def get(self, name: str, default: str | None = None) -> str | None: ...
class _Response(Protocol):
status: int
headers: _Headers
def read(self, amount: int = -1) -> bytes: ...
def close(self) -> None: ...
class _Opener(Protocol):
def open(self, request: urllib.request.Request, timeout: float) -> _Response: ...
class HTTPSMachineIdentitySender:
def __init__(
self,
endpoint: str,
signer: Signer,
policy: TransportPolicy,
*,
opener: _Opener | None = None,
) -> None:
parsed = urlsplit(endpoint)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
):
raise ValueError("event export endpoint must be an HTTPS origin")
policy.validate()
self._endpoint = endpoint.rstrip("/")
self._signer = signer
self._policy = policy
self._opener = opener or urllib.request.build_opener(
urllib.request.HTTPSHandler(context=policy.ssl_context())
)
def send(self, body: bytes) -> DeliveryResult:
if len(body) > self._policy.max_request_bytes:
raise EventDeliveryError("event_request_too_large", terminal=True)
request_id = "req-" + secrets.token_urlsafe(16)
if not _REQUEST_ID.fullmatch(request_id): # pragma: no cover - defensive invariant
raise RuntimeError("generated request id is invalid")
token = self._signer.mint(
"yovision-sense", ("events:ingest",), "POST", EVENT_PATH, body
)
request = urllib.request.Request(
self._endpoint + EVENT_PATH,
data=body,
method="POST",
headers={
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
"X-Request-ID": request_id,
},
)
try:
response = self._opener.open(
request, timeout=self._policy.request_timeout_ms / 1000
)
except urllib.error.HTTPError as exc:
response_body = exc.read(64 * 1024 + 1)
code = _problem_code(response_body) or "event_delivery_rejected"
raise EventDeliveryError(
code,
terminal=400 <= exc.code < 500 and exc.code != 429,
) from None
except (OSError, TimeoutError, urllib.error.URLError):
raise EventDeliveryError("event_delivery_unavailable", terminal=False) from None
try:
response_body = response.read(64 * 1024 + 1)
if len(response_body) > 64 * 1024:
raise EventDeliveryError("event_response_invalid", terminal=False)
if response.status not in {200, 201, 202}:
raise EventDeliveryError(
"event_delivery_rejected",
terminal=400 <= response.status < 500 and response.status != 429,
)
response_request_id = response.headers.get("X-Request-ID")
if response_request_id != request_id:
raise EventDeliveryError("event_response_invalid", terminal=False)
disposition = _disposition(response_body, response.status, body)
return DeliveryResult(disposition=disposition, request_id=request_id)
finally:
response.close()
def _problem_code(body: bytes) -> str | None:
try:
value = json.loads(body)
except (UnicodeDecodeError, json.JSONDecodeError):
return None
code = value.get("code") if isinstance(value, dict) else None
return code if isinstance(code, str) and re.fullmatch(r"[a-z][a-z0-9_]{0,63}", code) else None
def _disposition(body: bytes, status: int, request_body: bytes) -> str:
try:
value = json.loads(body)
except (UnicodeDecodeError, json.JSONDecodeError):
raise EventDeliveryError("event_response_invalid", terminal=False) from None
disposition = value.get("disposition") if isinstance(value, dict) else None
allowed = {"accepted", "created", "duplicate"}
if disposition not in allowed:
raise EventDeliveryError("event_response_invalid", terminal=False)
if status == 202 and disposition != "accepted":
raise EventDeliveryError("event_response_invalid", terminal=False)
try:
sent = json.loads(request_body)
response_identity = (
value["producer_id"],
value["source_event_id"],
value["payload_sha256"],
)
expected_identity = (
sent["producer_id"],
sent["source_event_id"],
hashlib.sha256(request_body).hexdigest(),
)
except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError):
raise EventDeliveryError("event_response_invalid", terminal=False) from None
if response_identity != expected_identity:
raise EventDeliveryError("event_response_invalid", terminal=False)
return disposition
@@ -0,0 +1,378 @@
"""Safe, deterministic Brain producer mapping for ``yovision.event/v1``."""
from __future__ import annotations
import hashlib
import json
import math
import re
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Mapping, Sequence
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
EVENT_SCHEMA_VERSION = "yovision.event/v1"
EVIDENCE_SCHEMA_VERSION = "yovision.evidence-reference/v1"
_REFERENCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
_URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
_WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
_SENSITIVE_NAMES = frozenset(
{
"path",
"url",
"uri",
"password",
"secret",
"token",
"credential",
"signed_url",
"camera_url",
"face",
"face_id",
"face_template",
"alert",
"ack",
"close",
"notification",
}
)
_EVIDENCE_FIELDS = frozenset(
{
"schema_version",
"evidence_id",
"owner_id",
"type",
"status",
"captured_at",
"status_updated_at",
"expires_at",
"content_type",
"integrity",
"failure",
}
)
class EventExportError(ValueError):
"""The internal candidate cannot safely satisfy the frozen contract."""
def export_event(
candidate: InternalEventCandidate,
*,
producer_id: str,
site_ref: str,
device_ref: str | None = None,
severity: str,
evidence: Sequence[Mapping[str, object]] = (),
region_ref: str | None = None,
crossing_direction: str | None = None,
category: str | None = None,
) -> dict[str, object]:
"""Return a new closed v1 payload without mutating the internal candidate.
The candidate's already stable ``event_id`` is the source identity. Callers
must persist and retry the returned payload unchanged; transport attempts do
not participate in either identity field.
"""
if candidate.schema != INTERNAL_EVENT_SCHEMA:
raise EventExportError("unsupported internal event candidate schema")
producer_id = _reference("producer_id", producer_id)
source_event_id = _reference("source_event_id", candidate.event_id)
site_ref = _reference("site_ref", site_ref)
device_ref = _reference("device_ref", device_ref or candidate.logical_input_id)
profile_ref = _reference("profile_ref", candidate.profile_id)
rule_id = _reference("rule.rule_id", candidate.rule_id)
region_id = _reference("region.region_id", region_ref or candidate.rule_id)
track_id = _reference("observation.track_id", candidate.track_id)
event_type = {
"danger_area_entered": "dangerous_area_entered",
"dangerous_area_entered": "dangerous_area_entered",
"directional_line_crossed": "directional_line_crossed",
}.get(candidate.event_type)
if event_type is None:
raise EventExportError("unsupported event type")
if severity not in {"low", "medium", "high", "critical"}:
raise EventExportError("unsupported severity")
observation = _observation(candidate, track_id=track_id, category=category)
region: dict[str, object] = {
"region_id": region_id,
"kind": "area" if event_type == "dangerous_area_entered" else "line",
}
if event_type == "directional_line_crossed":
if crossing_direction not in {"a_to_b", "b_to_a"}:
raise EventExportError("line events require a contract crossing_direction")
region["crossing_direction"] = crossing_direction
elif crossing_direction is not None:
raise EventExportError("area events cannot carry crossing_direction")
if len(evidence) > 8:
raise EventExportError("at most eight evidence references are allowed")
mapped_evidence = [_evidence_reference(item) for item in evidence]
if len({canonical_json_bytes(item) for item in mapped_evidence}) != len(mapped_evidence):
raise EventExportError("duplicate evidence references are not allowed")
payload: dict[str, object] = {
"schema_version": EVENT_SCHEMA_VERSION,
"producer_id": producer_id,
"source_event_id": source_event_id,
"site_ref": site_ref,
"device_ref": device_ref,
"profile_ref": profile_ref,
"event_type": event_type,
"occurred_at": _event_timestamp(candidate.occurred_at_ns),
"severity": severity,
"rule": {"rule_id": rule_id, "version": _bounded_text("rule.version", candidate.rule_version, 64)},
"model": {
"name": _bounded_text("model.name", candidate.model_name, 128),
"version": _bounded_text("model.version", candidate.model_version, 64),
},
"observation": observation,
"region": region,
"evidence": mapped_evidence,
}
_reject_unsafe(payload)
canonical_json_bytes(payload) # Reject non-finite or unsupported values now.
return payload
def canonical_json(value: object) -> str:
"""Serialize the closed event-domain JCS subset used by frozen fixtures.
Contract values use JSON strings, containers, booleans, integers and finite
ordinary decimals. Integer-valued floats are normalized to their JSON number
form; the checked-in RFC 8785 vector fixes cross-language digest behavior.
"""
return _encode_jcs(_normalize_numbers(value))
def canonical_json_bytes(value: object) -> bytes:
return canonical_json(value).encode("utf-8")
def payload_sha256(event: Mapping[str, object]) -> str:
return hashlib.sha256(canonical_json_bytes(event)).hexdigest()
def _observation(
candidate: InternalEventCandidate, *, track_id: str, category: str | None
) -> dict[str, object]:
internal = candidate.observation
confidence = internal.get("confidence")
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
raise EventExportError("observation confidence must be numeric")
confidence = float(confidence)
if not math.isfinite(confidence) or not 0 <= confidence <= 1:
raise EventExportError("observation confidence must be finite and between zero and one")
internal_category = internal.get("category")
exported_category = category or {
"anonymous_target": "person",
"person": "person",
"vehicle": "vehicle",
"other": "other",
}.get(internal_category)
if exported_category not in {"person", "vehicle", "other"}:
raise EventExportError("observation category requires an explicit anonymous contract mapping")
result: dict[str, object] = {
"track_id": track_id,
"category": exported_category,
"confidence": confidence,
}
box = internal.get("box")
if box is not None:
if not isinstance(box, Mapping) or set(box) != {"left", "top", "right", "bottom"}:
raise EventExportError("internal observation box is malformed")
if candidate.frame_width <= 0 or candidate.frame_height <= 0:
raise EventExportError("frame dimensions must be positive")
coordinates = (box["left"], box["top"], box["right"], box["bottom"])
if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in coordinates):
raise EventExportError("box coordinates must be numeric")
normalized = [
float(coordinates[0]) / candidate.frame_width,
float(coordinates[1]) / candidate.frame_height,
float(coordinates[2]) / candidate.frame_width,
float(coordinates[3]) / candidate.frame_height,
]
if any(not math.isfinite(value) or not 0 <= value <= 1 for value in normalized):
raise EventExportError("normalized box coordinates must be finite and between zero and one")
result["bbox_normalized"] = normalized
return result
def _evidence_reference(source: Mapping[str, object]) -> dict[str, object]:
if not isinstance(source, Mapping):
raise EventExportError("evidence reference must be an object")
unknown = set(source) - _EVIDENCE_FIELDS
if unknown:
raise EventExportError(f"evidence reference contains forbidden fields: {sorted(unknown)!r}")
required = {
"schema_version",
"evidence_id",
"owner_id",
"type",
"status",
"captured_at",
"status_updated_at",
}
missing = required - set(source)
if missing:
raise EventExportError(f"evidence reference is missing fields: {sorted(missing)!r}")
result = dict(source)
if result["schema_version"] != EVIDENCE_SCHEMA_VERSION:
raise EventExportError("unsupported evidence schema version")
_reference("evidence.evidence_id", result["evidence_id"])
_reference("evidence.owner_id", result["owner_id"])
if result["type"] not in {"snapshot", "clip"}:
raise EventExportError("unsupported evidence type")
status = result["status"]
if status not in {"pending", "processing", "success", "failed"}:
raise EventExportError("unsupported evidence status")
for field in ("captured_at", "status_updated_at", "expires_at"):
if field in result:
_date_time(field, result[field])
if status in {"pending", "processing"}:
if any(field in result for field in ("content_type", "integrity", "failure")):
raise EventExportError(f"{status} evidence cannot claim content or failure")
elif status == "success":
if "failure" in result or "content_type" not in result or "integrity" not in result:
raise EventExportError("successful evidence requires content metadata and no failure")
if result["content_type"] not in {"image/jpeg", "image/png", "video/mp4"}:
raise EventExportError("unsupported evidence content type")
integrity = result["integrity"]
if not isinstance(integrity, Mapping) or set(integrity) != {"algorithm", "digest", "size_bytes"}:
raise EventExportError("evidence integrity is malformed")
if integrity["algorithm"] != "sha256" or not isinstance(integrity["digest"], str) or not _SHA256.fullmatch(integrity["digest"]):
raise EventExportError("evidence integrity must contain a SHA-256 digest")
if isinstance(integrity["size_bytes"], bool) or not isinstance(integrity["size_bytes"], int) or integrity["size_bytes"] < 0:
raise EventExportError("evidence size must be a non-negative integer")
else:
if "content_type" in result or "integrity" in result or "failure" not in result:
raise EventExportError("failed evidence requires only failure metadata")
failure = result["failure"]
if not isinstance(failure, Mapping) or set(failure) != {"code", "retryable"}:
raise EventExportError("evidence failure is malformed")
if failure["code"] not in {"capture_failed", "processing_failed", "expired", "unavailable"}:
raise EventExportError("unsupported evidence failure code")
if not isinstance(failure["retryable"], bool):
raise EventExportError("evidence retryable must be boolean")
_reject_unsafe(result)
return result
def _reference(name: str, value: object) -> str:
if not isinstance(value, str) or not _REFERENCE.fullmatch(value):
raise EventExportError(f"{name} is not a valid logical reference")
return value
def _bounded_text(name: str, value: object, maximum: int) -> str:
if not isinstance(value, str) or not 1 <= len(value) <= maximum:
raise EventExportError(f"{name} must be 1..{maximum} characters")
return value
def _event_timestamp(nanoseconds: int) -> str:
if isinstance(nanoseconds, bool) or not isinstance(nanoseconds, int) or nanoseconds < 0:
raise EventExportError("occurred_at_ns must be a non-negative integer")
seconds, remainder = divmod(nanoseconds, 1_000_000_000)
value = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(
seconds=seconds, milliseconds=remainder // 1_000_000
)
return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
def _date_time(name: str, value: object) -> None:
if not isinstance(value, str):
raise EventExportError(f"evidence {name} must be a date-time string")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise EventExportError(f"evidence {name} must be a valid date-time") from exc
if parsed.tzinfo is None:
raise EventExportError(f"evidence {name} must include a timezone")
def _reject_unsafe(value: object, *, key: str = "") -> None:
if isinstance(value, Mapping):
for child_key, child in value.items():
lowered = str(child_key).lower()
if lowered in _SENSITIVE_NAMES or lowered.endswith("_path") or lowered.endswith("_url"):
raise EventExportError(f"sensitive field {child_key!r} is forbidden")
_reject_unsafe(child, key=lowered)
elif isinstance(value, (list, tuple)):
for child in value:
_reject_unsafe(child, key=key)
elif isinstance(value, str):
if _URL.match(value) or _WINDOWS_PATH.match(value) or value.startswith(("/", "\\\\")):
raise EventExportError(f"path or URL value in {key or 'payload'} is forbidden")
def _normalize_numbers(value: object) -> object:
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
if not math.isfinite(value):
raise EventExportError("canonical JSON rejects non-finite numbers")
if value == 0:
return 0
return value
if isinstance(value, Mapping):
if any(not isinstance(key, str) for key in value):
raise EventExportError("canonical JSON object keys must be strings")
return {key: _normalize_numbers(child) for key, child in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize_numbers(child) for child in value]
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
def _encode_jcs(value: object) -> str:
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if isinstance(value, str):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return _jcs_float(value)
if isinstance(value, list):
return "[" + ",".join(_encode_jcs(item) for item in value) + "]"
if isinstance(value, Mapping):
# Frozen contract keys are ASCII. Sorting them is therefore identical
# to RFC 8785's UTF-16 code-unit ordering without accepting extensions.
return "{" + ",".join(
_encode_jcs(key) + ":" + _encode_jcs(value[key]) for key in sorted(value)
) + "}"
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
def _jcs_float(value: float) -> str:
if not math.isfinite(value):
raise EventExportError("canonical JSON rejects non-finite numbers")
if value == 0:
return "0"
rendered = repr(value).lower()
absolute = abs(value)
if 1e-6 <= absolute < 1e21 and "e" in rendered:
return format(Decimal(rendered), "f")
if "e" in rendered:
mantissa, exponent = rendered.split("e", 1)
sign = ""
if exponent.startswith(("+", "-")):
sign, exponent = exponent[0], exponent[1:]
exponent = exponent.lstrip("0") or "0"
rendered = mantissa + "e" + sign + exponent
return rendered[:-2] if rendered.endswith(".0") else rendered
@@ -0,0 +1,69 @@
"""Durable atomic replay protection owned by the Brain connector."""
from __future__ import annotations
import sqlite3
from pathlib import Path
class SQLiteReplayCache:
"""SQLite implementation of the machine-identity ``ReplayCache`` protocol.
A primary key makes consumption atomic across threads and processes. Entries
remain durable across connector restarts until their verifier expiry passes.
"""
def __init__(self, database: str | Path, *, timeout_seconds: float = 5.0) -> None:
self._database = str(Path(database))
self._timeout_seconds = timeout_seconds
if timeout_seconds <= 0:
raise ValueError("SQLite replay timeout must be positive")
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS machine_token_replay (
principal TEXT NOT NULL,
token_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (principal, token_id)
) WITHOUT ROWID
"""
)
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
if not principal or not token_id:
raise ValueError("replay identity must be non-empty")
if any(isinstance(value, bool) or not isinstance(value, int) for value in (expires_at, now)):
raise ValueError("replay timestamps must be integers")
if expires_at <= now:
return False
connection = self._connect()
try:
connection.execute("BEGIN IMMEDIATE")
connection.execute("DELETE FROM machine_token_replay WHERE expires_at <= ?", (now,))
try:
connection.execute(
"INSERT INTO machine_token_replay (principal, token_id, expires_at) VALUES (?, ?, ?)",
(principal, token_id, expires_at),
)
except sqlite3.IntegrityError:
connection.rollback()
return False
connection.commit()
return True
except BaseException:
connection.rollback()
raise
finally:
connection.close()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(
self._database,
timeout=self._timeout_seconds,
isolation_level=None,
)
connection.execute(f"PRAGMA busy_timeout={int(self._timeout_seconds * 1000)}")
return connection
@@ -0,0 +1,134 @@
"""Closed runtime configuration and event sink for Brain-to-Sense export."""
from __future__ import annotations
from pathlib import Path
from typing import Mapping
from yovision_brain.events import InternalEventCandidate
from yovision_brain.integration.machine_identity import (
Signer,
TransportPolicy,
load_private_key,
)
from .client import HTTPSMachineIdentitySender
from .mapper import canonical_json_bytes, export_event
class EventExportSink:
def __init__(
self,
sender: HTTPSMachineIdentitySender,
*,
producer_id: str,
site_ref: str,
severity: str,
region_refs: Mapping[str, str],
crossing_directions: Mapping[str, str],
) -> None:
self._sender = sender
self._producer_id = producer_id
self._site_ref = site_ref
self._severity = severity
self._region_refs = dict(region_refs)
self._crossing_directions = dict(crossing_directions)
def write(self, candidate: InternalEventCandidate) -> None:
event = export_event(
candidate,
producer_id=self._producer_id,
site_ref=self._site_ref,
severity=self._severity,
region_ref=self._region_refs.get(candidate.rule_id),
crossing_direction=self._crossing_directions.get(candidate.rule_id),
)
# Mapping and serialization happen before minting, so the exact bytes are
# bound to the machine token and remain unchanged for this delivery.
self._sender.send(canonical_json_bytes(event))
def build_event_export_sink(raw: object, *, base_dir: Path) -> EventExportSink | None:
if raw is None:
return None
config = _object("event_export", raw)
_closed(
"event_export",
config,
{
"enabled", "endpoint", "producer_id", "site_ref", "severity",
"region_refs", "crossing_directions", "machine_identity", "transport",
},
)
enabled = config.get("enabled", False)
if not isinstance(enabled, bool):
raise ValueError("event_export.enabled must be a boolean")
if not enabled:
if set(config) != {"enabled"}:
raise ValueError("disabled event_export may only contain enabled")
return None
identity = _object("event_export.machine_identity", config.get("machine_identity"))
_closed(
"event_export.machine_identity",
identity,
{"principal", "key_id", "private_key_path"},
)
transport_raw = _object("event_export.transport", config.get("transport"))
_closed(
"event_export.transport",
transport_raw,
{
"tls_min_version", "verify_certificate", "verify_hostname",
"connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms",
"max_request_bytes",
},
)
policy = TransportPolicy(**transport_raw) # type: ignore[arg-type]
policy.validate()
key_path = _string("private_key_path", identity.get("private_key_path"))
resolved_key_path = Path(key_path)
if not resolved_key_path.is_absolute():
resolved_key_path = base_dir / resolved_key_path
signer = Signer(
_string("principal", identity.get("principal")),
_string("key_id", identity.get("key_id")),
load_private_key(resolved_key_path),
)
return EventExportSink(
HTTPSMachineIdentitySender(
_string("endpoint", config.get("endpoint")), signer, policy
),
producer_id=_string("producer_id", config.get("producer_id")),
site_ref=_string("site_ref", config.get("site_ref")),
severity=_string("severity", config.get("severity")),
region_refs=_string_map("region_refs", config.get("region_refs", {})),
crossing_directions=_string_map(
"crossing_directions", config.get("crossing_directions", {})
),
)
def _object(name: str, value: object) -> Mapping[str, object]:
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
raise ValueError(f"{name} must be an object")
return value
def _closed(name: str, value: Mapping[str, object], allowed: set[str]) -> None:
unknown = set(value) - allowed
if unknown:
raise ValueError(f"{name} contains unsupported fields")
def _string(name: str, value: object) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"event_export.{name} must be a non-empty string")
return value
def _string_map(name: str, value: object) -> Mapping[str, str]:
mapping = _object(f"event_export.{name}", value)
if any(not isinstance(item, str) or not item for item in mapping.values()):
raise ValueError(f"event_export.{name} values must be non-empty strings")
return mapping # type: ignore[return-value]
@@ -0,0 +1,37 @@
"""Independent service-to-service machine identity for Brain connectors."""
from .token import (
ALLOWED_SKEW_SECONDS,
MAX_KEY_OVERLAP_SECONDS,
MAX_LIFETIME_SECONDS,
VERSION,
Claims,
KeyRecord,
MachineIdentityError,
Registry,
ReplayStore,
Signer,
Verifier,
load_private_key,
load_registry,
bearer_token,
)
from .transport import TransportPolicy
__all__ = [
"ALLOWED_SKEW_SECONDS",
"MAX_KEY_OVERLAP_SECONDS",
"MAX_LIFETIME_SECONDS",
"VERSION",
"Claims",
"KeyRecord",
"MachineIdentityError",
"Registry",
"ReplayStore",
"Signer",
"TransportPolicy",
"Verifier",
"load_private_key",
"load_registry",
"bearer_token",
]
@@ -0,0 +1,336 @@
"""Ed25519 request-bound machine tokens.
This module never accepts browser cookies, GoAdmin JWTs, query tokens, or
shared secrets. HTTP adapters must obtain the compact token exclusively from
the Authorization bearer header and pass the request body unchanged.
"""
from __future__ import annotations
import base64
import hashlib
import json
import posixpath
import re
import secrets
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Protocol
from urllib.parse import urlsplit
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
VERSION = "yovision.machine-identity/v1"
TOKEN_TYPE = "YOVISION-MACHINE+JWT"
MAX_LIFETIME_SECONDS = 300
ALLOWED_SKEW_SECONDS = 30
MAX_KEY_OVERLAP_SECONDS = 24 * 60 * 60
_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
_AUDIENCES = frozenset({"yovision-sense", "yovision-brain", "yovision-bell"})
_SCOPES = frozenset({"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"})
_PRINCIPAL = re.compile(r"^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$")
_KEY_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
_TOKEN_ID = re.compile(r"^[A-Za-z0-9_-]{22,64}$")
class MachineIdentityError(ValueError):
"""A stable, non-secret authentication failure."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def bearer_token(authorization: str) -> str:
"""Extract only an Authorization bearer token; there is no cookie/query fallback."""
parts = authorization.split(" ")
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1] or any(character in parts[1] for character in " \t\r\n,"):
raise MachineIdentityError("machine_token_missing")
return parts[1]
@dataclass(frozen=True)
class Claims:
ver: str
iss: str
sub: str
aud: str
scope: tuple[str, ...]
iat: int
nbf: int
exp: int
jti: str
htm: str
htu: str
body_sha256: str
@dataclass(frozen=True)
class KeyRecord:
principal: str
key_id: str
public_key: Ed25519PublicKey
audience: str
scopes: frozenset[str]
enabled: bool = True
revoked: bool = False
class Registry:
def __init__(self, records: Iterable[KeyRecord]) -> None:
self._lock = threading.RLock()
self._records: dict[str, KeyRecord] = {}
for record in records:
if not _KEY_ID.fullmatch(record.key_id) or not _PRINCIPAL.fullmatch(record.principal) or record.audience not in _AUDIENCES or not _valid_scopes(record.scopes):
raise ValueError("invalid machine key record")
if record.key_id in self._records:
raise ValueError("duplicate machine key id")
self._records[record.key_id] = record
def lookup(self, key_id: str) -> KeyRecord | None:
with self._lock:
return self._records.get(key_id)
def revoke(self, key_id: str) -> bool:
with self._lock:
record = self._records.get(key_id)
if record is None:
return False
self._records[key_id] = KeyRecord(
principal=record.principal,
key_id=record.key_id,
public_key=record.public_key,
audience=record.audience,
scopes=record.scopes,
enabled=record.enabled,
revoked=True,
)
return True
class ReplayStore:
"""Process-local replay cache for tests or one uninterrupted process."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._used: dict[tuple[str, str], int] = {}
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
with self._lock:
self._used = {key: expiry for key, expiry in self._used.items() if expiry > now}
key = (principal, token_id)
if key in self._used:
return False
self._used[key] = expires_at
return True
class ReplayCache(Protocol):
"""Connector implementations provide an atomic durable implementation."""
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool: ...
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
if not str(path).strip():
raise ValueError("machine private key path is required")
try:
raw = Path(path).read_bytes()
key = serialization.load_pem_private_key(raw, password=None)
except (OSError, ValueError, TypeError) as exc:
raise ValueError("invalid machine private key file") from exc
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("machine private key is not Ed25519")
return key
def load_registry(path: str | Path, expected_audience: str) -> Registry:
if not str(path).strip() or expected_audience not in _AUDIENCES:
raise ValueError("machine principal registry path and audience are required")
try:
document = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("invalid machine principal registry") from exc
if not isinstance(document, dict) or set(document) != {"version", "audience", "principals"} or document["version"] != "yovision.machine-principal-registry/v1" or document["audience"] != expected_audience or not isinstance(document["principals"], list) or not document["principals"]:
raise ValueError("invalid machine principal registry")
records: list[KeyRecord] = []
try:
for principal in document["principals"]:
if not isinstance(principal, dict) or set(principal) != {"principal_id", "enabled", "keys"} or not isinstance(principal["enabled"], bool) or not isinstance(principal["keys"], list) or not principal["keys"]:
raise ValueError
for key in principal["keys"]:
if not isinstance(key, dict) or set(key) != {"kid", "public_key_base64url", "status", "scopes"} or key["status"] not in {"active", "revoked"} or not isinstance(key["scopes"], list):
raise ValueError
public_key = Ed25519PublicKey.from_public_bytes(_b64decode(key["public_key_base64url"]))
records.append(KeyRecord(principal["principal_id"], key["kid"], public_key, expected_audience, frozenset(key["scopes"]), principal["enabled"], key["status"] == "revoked"))
except (KeyError, TypeError, ValueError):
raise ValueError("invalid machine principal registry") from None
return Registry(records)
class Signer:
def __init__(
self,
principal: str,
key_id: str,
private_key: Ed25519PrivateKey,
*,
clock: Callable[[], int] | None = None,
) -> None:
if not _PRINCIPAL.fullmatch(principal) or not _KEY_ID.fullmatch(key_id) or not isinstance(private_key, Ed25519PrivateKey):
raise ValueError("incomplete machine signer configuration")
self._principal = principal
self._key_id = key_id
self._private_key = private_key
self._clock = clock or (lambda: int(time.time()))
def mint(self, audience: str, scopes: Iterable[str], method: str, request_path: str, body: bytes) -> str:
normalized_path = _normalize_path(request_path)
normalized_method = method.upper()
scope_values = tuple(scopes)
if audience not in _AUDIENCES or not _valid_scopes(scope_values) or normalized_method not in _METHODS:
raise ValueError("invalid machine token request")
now = int(self._clock())
header = {"alg": "EdDSA", "typ": TOKEN_TYPE, "kid": self._key_id, "ver": VERSION}
claims = {
"ver": VERSION,
"iss": self._principal,
"sub": self._principal,
"aud": audience,
"scope": list(scope_values),
"iat": now,
"nbf": now,
"exp": now + MAX_LIFETIME_SECONDS,
"jti": secrets.token_urlsafe(16),
"htm": normalized_method,
"htu": normalized_path,
"body_sha256": hashlib.sha256(body).hexdigest(),
}
encoded_header = _encode_json(header)
encoded_claims = _encode_json(claims)
signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii")
signature = self._private_key.sign(signing_input)
return f"{encoded_header}.{encoded_claims}.{_b64encode(signature)}"
class Verifier:
def __init__(
self,
registry: Registry,
replay_store: ReplayCache,
*,
clock: Callable[[], int] | None = None,
) -> None:
self._registry = registry
self._replay_store = replay_store
self._clock = clock or (lambda: int(time.time()))
def verify(
self,
token: str,
audience: str,
required_scope: str,
method: str,
request_path: str,
body: bytes,
) -> Claims:
parts = token.split(".")
if len(parts) != 3 or "=" in token:
raise MachineIdentityError("machine_token_invalid")
header = _decode_object(parts[0], {"alg", "typ", "kid", "ver"})
if header.get("alg") != "EdDSA" or header.get("typ") != TOKEN_TYPE or header.get("ver") != VERSION or not isinstance(header.get("kid"), str) or not _KEY_ID.fullmatch(header["kid"]):
raise MachineIdentityError("machine_token_invalid")
record = self._registry.lookup(header["kid"])
if record is None:
raise MachineIdentityError("machine_token_invalid")
try:
record.public_key.verify(_b64decode(parts[2]), f"{parts[0]}.{parts[1]}".encode("ascii"))
except (InvalidSignature, ValueError):
raise MachineIdentityError("machine_token_invalid") from None
if not record.enabled or record.revoked:
raise MachineIdentityError("machine_identity_revoked")
raw = _decode_object(parts[1], {"ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"})
claims = _claims_from_object(raw)
if claims.iss != record.principal or claims.sub != record.principal:
raise MachineIdentityError("machine_token_invalid")
now = int(self._clock())
if claims.exp - claims.iat <= 0 or claims.exp - claims.iat > MAX_LIFETIME_SECONDS or claims.nbf < claims.iat or claims.nbf > claims.exp or claims.iat > now + ALLOWED_SKEW_SECONDS:
raise MachineIdentityError("machine_token_invalid")
if claims.nbf > now + ALLOWED_SKEW_SECONDS or claims.exp < now - ALLOWED_SKEW_SECONDS:
raise MachineIdentityError("machine_token_expired")
if claims.aud != audience or record.audience != audience:
raise MachineIdentityError("machine_audience_denied")
if required_scope not in claims.scope or required_scope not in record.scopes:
raise MachineIdentityError("machine_scope_denied")
if claims.htm != method.upper() or claims.htu != _normalize_path(request_path) or claims.body_sha256 != hashlib.sha256(body).hexdigest():
raise MachineIdentityError("machine_token_invalid")
if not self._replay_store.consume(claims.iss, claims.jti, claims.exp + ALLOWED_SKEW_SECONDS, now):
raise MachineIdentityError("machine_token_replayed")
return claims
def _claims_from_object(value: dict[str, object]) -> Claims:
try:
scope = value["scope"]
if not isinstance(scope, list) or not _valid_scopes(scope):
raise ValueError
integer_fields = ("iat", "nbf", "exp")
if any(not isinstance(value[field], int) or isinstance(value[field], bool) for field in integer_fields):
raise ValueError
string_fields = ("ver", "iss", "sub", "aud", "jti", "htm", "htu", "body_sha256")
if any(not isinstance(value[field], str) for field in string_fields):
raise ValueError
claims = Claims(scope=tuple(scope), **{key: value[key] for key in string_fields + integer_fields})
if claims.ver != VERSION or not _PRINCIPAL.fullmatch(claims.iss) or claims.iss != claims.sub or claims.aud not in _AUDIENCES or not _TOKEN_ID.fullmatch(claims.jti) or claims.htm not in _METHODS or len(claims.body_sha256) != 64:
raise ValueError
bytes.fromhex(claims.body_sha256)
_normalize_path(claims.htu)
return claims
except (KeyError, TypeError, ValueError):
raise MachineIdentityError("machine_token_invalid") from None
def _normalize_path(value: str) -> str:
split = urlsplit(value)
if not value.startswith("/") or split.scheme or split.netloc or split.query or split.fragment or "\\" in split.path or "//" in split.path or posixpath.normpath(split.path) != split.path:
raise ValueError("machine request path must be normalized and contain no query or fragment")
return split.path
def _valid_scopes(scopes: Iterable[str]) -> bool:
values = tuple(scopes)
return 1 <= len(values) <= 4 and len(set(values)) == len(values) and all(scope in _SCOPES for scope in values)
def _encode_json(value: dict[str, object]) -> str:
return _b64encode(json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8"))
def _decode_object(value: str, expected_keys: set[str]) -> dict[str, object]:
try:
decoded = json.loads(_b64decode(value).decode("utf-8"))
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
raise MachineIdentityError("machine_token_invalid") from None
if not isinstance(decoded, dict) or set(decoded) != expected_keys:
raise MachineIdentityError("machine_token_invalid")
return decoded
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
if not value or "=" in value:
raise ValueError("invalid base64url")
decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
if _b64encode(decoded) != value:
raise ValueError("non-canonical base64url")
return decoded
@@ -0,0 +1,37 @@
"""Fail-closed HTTPS transport policy for Brain connectors."""
from __future__ import annotations
import ssl
from dataclasses import dataclass
@dataclass(frozen=True)
class TransportPolicy:
tls_min_version: str
verify_certificate: bool
verify_hostname: bool
connect_timeout_ms: int
response_header_timeout_ms: int
request_timeout_ms: int
max_request_bytes: int
def validate(self) -> None:
if (
self.tls_min_version not in {"1.2", "1.3"}
or not self.verify_certificate
or not self.verify_hostname
or not 100 <= self.connect_timeout_ms <= 30_000
or not 100 <= self.response_header_timeout_ms <= 30_000
or not 100 <= self.request_timeout_ms <= 60_000
or not 1 <= self.max_request_bytes <= 10 * 1024 * 1024
):
raise ValueError("machine transport policy is unsafe")
def ssl_context(self) -> ssl.SSLContext:
self.validate()
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
context.minimum_version = ssl.TLSVersion.TLSv1_3 if self.tls_min_version == "1.3" else ssl.TLSVersion.TLSv1_2
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
return context
@@ -0,0 +1,254 @@
from __future__ import annotations
import io
import hashlib
import json
import re
import urllib.error
from pathlib import Path
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
import yovision_brain.app.__main__ as cli
import yovision_brain.integration.event_export.runtime as export_runtime
from yovision_brain.integration.event_export import (
EventDeliveryError,
HTTPSMachineIdentitySender,
)
from yovision_brain.integration.machine_identity import (
KeyRecord,
Registry,
ReplayStore,
Signer,
TransportPolicy,
Verifier,
bearer_token,
)
FIXTURE = Path(__file__).parents[1] / "fixtures" / "events" / "area.json"
def _policy() -> TransportPolicy:
return TransportPolicy(
tls_min_version="1.2",
verify_certificate=True,
verify_hostname=True,
connect_timeout_ms=1_000,
response_header_timeout_ms=1_000,
request_timeout_ms=2_000,
max_request_bytes=64 * 1024,
)
class _Response:
status = 202
def __init__(self, body: bytes, request_id: str) -> None:
self.body = body
self.headers = {"X-Request-ID": request_id}
self.closed = False
def read(self, amount: int = -1) -> bytes:
return self.body[:amount] if amount >= 0 else self.body
def close(self) -> None:
self.closed = True
class _VerifyingOpener:
def __init__(self, verifier: Verifier) -> None:
self.verifier = verifier
self.requests = []
def open(self, request, timeout: float) -> _Response: # noqa: ANN001
self.requests.append((request, timeout))
body = request.data
self.verifier.verify(
bearer_token(request.get_header("Authorization")),
"yovision-sense",
"events:ingest",
request.method,
"/v1/events",
body,
)
event = json.loads(body)
return _Response(json.dumps({
"producer_id": event["producer_id"],
"source_event_id": event["source_event_id"],
"payload_sha256": hashlib.sha256(body).hexdigest(),
"disposition": "accepted",
}).encode(), request.get_header("X-request-id"))
def test_sender_binds_exact_path_body_and_safe_request_id() -> None:
private_key = Ed25519PrivateKey.generate()
signer = Signer("yv:brain:school-a", "brain-key-0001", private_key, clock=lambda: 100)
registry = Registry(
[
KeyRecord(
principal="yv:brain:school-a",
key_id="brain-key-0001",
public_key=private_key.public_key(),
audience="yovision-sense",
scopes=frozenset({"events:ingest"}),
)
]
)
opener = _VerifyingOpener(Verifier(registry, ReplayStore(), clock=lambda: 100))
sender = HTTPSMachineIdentitySender(
"https://sense.example:8443", signer, _policy(), opener=opener
)
result = sender.send(
b'{"producer_id":"brain-school-a","schema_version":"yovision.event/v1",'
b'"source_event_id":"evt-1"}'
)
request, timeout = opener.requests[0]
assert request.full_url == "https://sense.example:8443/v1/events"
assert request.method == "POST"
assert request.get_header("Content-type") == "application/json"
assert re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{15,127}", result.request_id)
assert request.get_header("X-request-id") == result.request_id
assert (result.disposition, timeout) == ("accepted", 2.0)
def test_sender_rejects_plaintext_and_marks_conflict_terminal() -> None:
key = Ed25519PrivateKey.generate()
signer = Signer("yv:brain:school-a", "brain-key-0001", key)
with pytest.raises(ValueError, match="HTTPS origin"):
HTTPSMachineIdentitySender("http://sense.example", signer, _policy())
class ConflictOpener:
def open(self, request, timeout: float): # noqa: ANN001, ARG002
raise urllib.error.HTTPError(
request.full_url,
409,
"Conflict",
{},
io.BytesIO(b'{"code":"event_identity_conflict"}'),
)
sender = HTTPSMachineIdentitySender(
"https://sense.example", signer, _policy(), opener=ConflictOpener()
)
with pytest.raises(EventDeliveryError) as caught:
sender.send(b"{}")
assert (caught.value.code, caught.value.terminal) == (
"event_identity_conflict",
True,
)
def test_disabled_connector_keeps_existing_json_lines_output(tmp_path: Path) -> None:
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
raw["event_export"] = {"enabled": False}
config = tmp_path / "brain.json"
config.write_text(json.dumps(raw), encoding="utf-8")
output = tmp_path / "events.jsonl"
assert cli.main(["--config", str(config), "--output", str(output)]) == 0
assert json.loads(output.read_text(encoding="utf-8"))["schema"] == (
"brain.internal.event-candidate/v1"
)
def test_delivery_failure_is_nonzero_and_not_silently_reported_as_success(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
class FailingSink:
def write(self, candidate) -> None: # noqa: ANN001, ARG002
raise EventDeliveryError("event_delivery_unavailable", terminal=False)
monkeypatch.setattr(cli, "build_event_export_sink", lambda raw, base_dir: FailingSink())
unused_output = tmp_path / "disabled-json-lines-target"
unused_output.write_text("must remain unchanged", encoding="utf-8")
result = cli.main(
["--config", str(FIXTURE), "--output", str(unused_output)]
)
assert result == 3
error = json.loads(capsys.readouterr().err.splitlines()[0])
assert error == {"status": "error", "message": "event_delivery_unavailable"}
assert unused_output.read_text(encoding="utf-8") == "must remain unchanged"
def test_inline_private_key_material_is_rejected_without_echo(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
marker = "INLINE-PRIVATE-MATERIAL-MUST-NOT-LEAK"
raw["event_export"] = {
"enabled": True,
"endpoint": "https://sense.example",
"producer_id": "brain-school-a",
"site_ref": "site-school-a",
"severity": "high",
"region_refs": {},
"crossing_directions": {},
"machine_identity": {
"principal": "yv:brain:school-a",
"key_id": "brain-key-0001",
"private_key_path": "external.pem",
"private_key": marker,
},
"transport": {},
}
config = tmp_path / "brain.json"
config.write_text(json.dumps(raw), encoding="utf-8")
assert cli.main(["--config", str(config)]) == 3
assert marker not in capsys.readouterr().err
def test_enabled_config_loads_machine_key_only_from_external_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
key_path = tmp_path / "brain-machine.pem"
key_path.write_bytes(
Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
)
captured = {}
class Sender:
def __init__(self, endpoint, signer, policy) -> None: # noqa: ANN001
captured.update(endpoint=endpoint, signer=signer, policy=policy)
monkeypatch.setattr(export_runtime, "HTTPSMachineIdentitySender", Sender)
sink = export_runtime.build_event_export_sink(
{
"enabled": True,
"endpoint": "https://sense.example",
"producer_id": "brain-school-a",
"site_ref": "site-school-a",
"severity": "high",
"region_refs": {},
"crossing_directions": {},
"machine_identity": {
"principal": "yv:brain:school-a",
"key_id": "brain-key-0001",
"private_key_path": key_path.name,
},
"transport": {
"tls_min_version": "1.2",
"verify_certificate": True,
"verify_hostname": True,
"connect_timeout_ms": 1_000,
"response_header_timeout_ms": 1_000,
"request_timeout_ms": 2_000,
"max_request_bytes": 64 * 1024,
},
},
base_dir=tmp_path,
)
assert sink is not None
assert captured["endpoint"] == "https://sense.example"
@@ -0,0 +1,148 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
from yovision_brain.integration.event_export import EventExportError, canonical_json, export_event, payload_sha256
ROOT = Path(__file__).resolve().parents[4]
def _candidate(**changes: object) -> InternalEventCandidate:
values: dict[str, object] = {
"schema": INTERNAL_EVENT_SCHEMA,
"event_id": "evt-area-20260831-0001",
"logical_input_id": "camera-east-gate",
"event_type": "danger_area_entered",
"occurred_at_ns": 1_788_134_401_125_000_000,
"rule_id": "rule-east-danger",
"rule_version": "3",
"model_name": "anonymous-detector",
"model_version": "2026.08",
"profile_id": "profile-main-stream",
"frame_width": 100,
"frame_height": 100,
"track_id": "track-0042",
"observation": {
"category": "anonymous_target",
"confidence": 0.93,
"box": {"left": 12, "top": 20, "right": 31, "bottom": 74},
"anchor": {"x": 0.21, "y": 0.74},
},
"reason": "entered polygon",
}
values.update(changes)
return InternalEventCandidate(**values) # type: ignore[arg-type]
def _fixture(relative: str) -> dict[str, object]:
return json.loads((ROOT / relative).read_text(encoding="utf-8"))
def test_brain_mapper_matches_frozen_producer_fixture_and_jcs_digest() -> None:
pending = _fixture("contracts/evidence/v1/examples/pending.json")
expected = _fixture("contracts/events/v1/examples/dangerous-area.json")
event = export_event(
_candidate(),
producer_id="brain-school-a",
site_ref="site-school-a",
severity="high",
evidence=[pending],
region_ref="region-east-danger",
)
assert event == expected
assert payload_sha256(event) == "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
def test_retry_mapping_preserves_original_identity_and_payload() -> None:
candidate = _candidate()
arguments = {
"producer_id": "brain-school-a",
"site_ref": "site-school-a",
"severity": "high",
"evidence": [_fixture("contracts/evidence/v1/examples/pending.json")],
"region_ref": "region-east-danger",
}
first = export_event(candidate, **arguments)
retry = export_event(candidate, **arguments)
assert (first["producer_id"], first["source_event_id"]) == (
"brain-school-a",
candidate.event_id,
)
assert retry == first
assert payload_sha256(retry) == payload_sha256(first)
@pytest.mark.parametrize("fixture", ["pending.json", "failed.json"])
def test_evidence_degradation_states_are_exported_unchanged(fixture: str) -> None:
evidence = _fixture(f"contracts/evidence/v1/examples/{fixture}")
candidate = _candidate()
if fixture == "failed.json":
candidate = _candidate(
event_id="evt-line-20260831-0002",
logical_input_id="camera-north-corridor",
event_type="directional_line_crossed",
occurred_at_ns=1_788_134_590_000_000_000,
rule_id="rule-north-one-way",
rule_version="1",
track_id="track-0088",
observation={"category": "anonymous_target", "confidence": 0.88},
)
event = export_event(
candidate,
producer_id="brain-school-a",
site_ref="site-school-a",
severity="medium" if fixture == "failed.json" else "high",
evidence=[evidence],
region_ref="line-north-one-way" if fixture == "failed.json" else "region-east-danger",
crossing_direction="b_to_a" if fixture == "failed.json" else None,
)
assert event["evidence"] == [evidence]
@pytest.mark.parametrize(
"unsafe",
[
{"local_path": "D:/captures/frame.jpg"},
{"url": "https://example.invalid/signed"},
{"token": "not-a-real-token"},
],
)
def test_evidence_rejects_paths_urls_and_sensitive_fields(unsafe: dict[str, object]) -> None:
evidence = _fixture("contracts/evidence/v1/examples/pending.json")
evidence.update(unsafe)
with pytest.raises(EventExportError, match="forbidden"):
export_event(
_candidate(),
producer_id="brain-school-a",
site_ref="site-school-a",
severity="high",
evidence=[evidence],
region_ref="region-east-danger",
)
def test_failed_evidence_cannot_claim_success_content() -> None:
evidence = _fixture("contracts/evidence/v1/examples/failed.json")
evidence["content_type"] = "video/mp4"
with pytest.raises(EventExportError, match="failed evidence"):
export_event(
_candidate(),
producer_id="brain-school-a",
site_ref="site-school-a",
severity="high",
evidence=[evidence],
region_ref="region-east-danger",
)
def test_jcs_normalizes_number_lexemes_and_negative_zero() -> None:
assert canonical_json({"small": 1e-7, "fixed": 1e20, "zero": -0.0}) == (
'{"fixed":100000000000000000000,"small":1e-7,"zero":0}'
)
@@ -0,0 +1,56 @@
from __future__ import annotations
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import pytest
from yovision_brain.integration.event_export import SQLiteReplayCache
from yovision_brain.integration.machine_identity import (
KeyRecord,
MachineIdentityError,
Registry,
Signer,
Verifier,
)
def test_replay_cache_rejects_same_jti_after_connector_restart(tmp_path) -> None:
now = 1_800_000_000
private_key = Ed25519PrivateKey.generate()
registry = Registry(
[
KeyRecord(
principal="yv:brain:node-a",
key_id="brain-key-0001",
public_key=private_key.public_key(),
audience="yovision-sense",
scopes=frozenset({"events:ingest"}),
)
]
)
token = Signer(
"yv:brain:node-a",
"brain-key-0001",
private_key,
clock=lambda: now,
).mint("yovision-sense", ["events:ingest"], "POST", "/v1/events", b"{}")
database = tmp_path / "machine-replay.sqlite3"
first_process = Verifier(registry, SQLiteReplayCache(database), clock=lambda: now)
claims = first_process.verify(
token, "yovision-sense", "events:ingest", "POST", "/v1/events", b"{}"
)
assert claims.iss == "yv:brain:node-a"
restarted_process = Verifier(registry, SQLiteReplayCache(database), clock=lambda: now)
with pytest.raises(MachineIdentityError, match="machine_token_replayed") as caught:
restarted_process.verify(
token, "yovision-sense", "events:ingest", "POST", "/v1/events", b"{}"
)
assert caught.value.code == "machine_token_replayed"
def test_replay_cache_atomically_reuses_expired_identity(tmp_path) -> None:
cache = SQLiteReplayCache(tmp_path / "machine-replay.sqlite3")
assert cache.consume("yv:brain:node-a", "jti-one", expires_at=110, now=100)
assert not cache.consume("yv:brain:node-a", "jti-one", expires_at=110, now=101)
assert cache.consume("yv:brain:node-a", "jti-one", expires_at=130, now=110)
@@ -0,0 +1,187 @@
package bell_connector
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
)
type HTTPDoer interface {
Do(*http.Request) (*http.Response, error)
}
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
type Client struct {
Endpoint string
RelayID string
Signer machine_identity.Signer
HTTP HTTPDoer
Enabled bool
MaxRequestBytes int64
}
func NewClient(endpoint, relayID string, signer machine_identity.Signer, policy machine_identity.TransportPolicy) (*Client, error) {
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("Bell connector endpoint must be an HTTPS origin")
}
httpClient, err := policy.HTTPClient()
if err != nil {
return nil, err
}
return &Client{Endpoint: strings.TrimRight(endpoint, "/"), RelayID: relayID, Signer: signer, HTTP: httpClient, Enabled: true, MaxRequestBytes: policy.MaxRequestBytes}, nil
}
func (c Client) Send(ctx context.Context, body []byte) (IngestResult, error) {
if !c.Enabled {
return IngestResult{}, &DeliveryError{Code: "connector_disabled", Detail: "Bell connector is disabled", Terminal: true}
}
if c.HTTP == nil || !json.Valid(body) || (strings.TrimSpace(c.RelayID) != "" && !safeIdentifier(c.RelayID)) {
return IngestResult{}, errors.New("Bell connector is not configured")
}
maximum := c.MaxRequestBytes
if maximum == 0 {
maximum = MaxInboundBytes
}
if maximum < 1 || int64(len(body)) > maximum {
return IngestResult{}, &DeliveryError{Code: "event_request_too_large", Detail: "event exceeds the configured request limit", Terminal: true}
}
requestID, err := newRequestID()
if err != nil {
return IngestResult{}, fmt.Errorf("generate request correlation id: %w", err)
}
token, err := c.Signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
if err != nil {
return IngestResult{}, fmt.Errorf("mint Bell machine token: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.Endpoint, "/")+"/v1/events", bytes.NewReader(body))
if err != nil {
return IngestResult{}, err
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", requestID)
if strings.TrimSpace(c.RelayID) != "" {
request.Header.Set("X-YoVision-Relay-ID", c.RelayID)
}
response, err := c.HTTP.Do(request)
if err != nil {
return IngestResult{}, fmt.Errorf("deliver event to Bell: %w", err)
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
if err != nil || len(responseBody) > 64*1024 {
return IngestResult{}, errors.New("Bell response is invalid")
}
if response.StatusCode == http.StatusCreated || response.StatusCode == http.StatusOK {
if response.Header.Get("X-Request-ID") != requestID {
return IngestResult{}, errors.New("Bell response request id is invalid")
}
var result IngestResult
if json.Unmarshal(responseBody, &result) != nil || result.EventID == "" || result.PayloadSHA256 == "" || (result.Disposition != "created" && result.Disposition != "duplicate") {
return IngestResult{}, errors.New("Bell response is invalid")
}
identity, parseErr := parseEventIdentity(body)
if parseErr != nil || result.ProducerID != identity.ProducerID || result.SourceEventID != identity.SourceEventID {
return IngestResult{}, errors.New("Bell response changed event identity")
}
canonical, canonicalErr := canonicalPayload(body)
if canonicalErr != nil {
return IngestResult{}, errors.New("delivered event cannot be canonicalized")
}
digest := sha256.Sum256(canonical)
if result.PayloadSHA256 != hex.EncodeToString(digest[:]) {
return IngestResult{}, errors.New("Bell response payload digest does not match the delivered event")
}
return result, nil
}
var problem Problem
_ = json.Unmarshal(responseBody, &problem)
terminal := response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusTooManyRequests
if problem.Code == "" {
problem.Code = "bell_unavailable"
}
return IngestResult{}, &DeliveryError{Code: problem.Code, Detail: problem.Message, Terminal: terminal}
}
func newRequestID() (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", err
}
value := base64.RawURLEncoding.EncodeToString(raw)
if !requestIDPattern.MatchString(value) {
return "", errors.New("generated request id is invalid")
}
return value, nil
}
func canonicalPayload(raw []byte) ([]byte, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
value, err := normalizeJCSNumbers(value)
if err != nil {
return nil, err
}
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return nil, err
}
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
return canonical, nil
}
func normalizeJCSNumbers(value any) (any, error) {
switch typed := value.(type) {
case json.Number:
number, err := strconv.ParseFloat(string(typed), 64)
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
return nil, errors.New("JSON number is outside the RFC 8785 domain")
}
if number == 0 {
return float64(0), nil
}
return number, nil
case []any:
for index, item := range typed {
normalized, err := normalizeJCSNumbers(item)
if err != nil {
return nil, err
}
typed[index] = normalized
}
case map[string]any:
for key, item := range typed {
normalized, err := normalizeJCSNumbers(item)
if err != nil {
return nil, err
}
typed[key] = normalized
}
}
return value, nil
}
@@ -0,0 +1,144 @@
package bell_connector
import (
"errors"
"io"
"net/http"
"regexp"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
)
const MaxInboundBytes = 64 * 1024
var inboundRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
type IngressHandler struct {
DB *gorm.DB
Verifier machine_identity.Verifier
EvidenceOwnerID string
Now func() time.Time
}
func (h IngressHandler) Post(c *gin.Context) {
if !prepareMachineRequest(c, "/v1/events") {
return
}
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxInboundBytes))
if err != nil {
problem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large")
return
}
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
if err != nil {
problem(c, http.StatusUnauthorized, machineCode(err), "machine identity was rejected")
return
}
if _, err = h.Verifier.Verify(token, "yovision-sense", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
status := http.StatusUnauthorized
if code := machineCode(err); code == "machine_scope_denied" || code == "machine_audience_denied" {
status = http.StatusForbidden
}
problem(c, status, machineCode(err), "machine identity was rejected")
return
}
now := time.Now().UTC()
if h.Now != nil {
now = h.Now().UTC()
}
result, err := AcceptEvent(c.Request.Context(), h.DB, body, h.EvidenceOwnerID, now)
if errors.Is(err, ErrInboundConflict) {
problem(c, http.StatusConflict, "idempotency_conflict", "event identity is bound to another payload")
return
}
if err != nil {
problem(c, http.StatusBadRequest, "invalid_event", "event payload was rejected")
return
}
status := http.StatusAccepted
if result.Disposition == "duplicate" {
status = http.StatusOK
}
c.JSON(status, result)
}
type EvidenceHandler struct {
DB *gorm.DB
Verifier machine_identity.Verifier
Now func() time.Time
}
func (h EvidenceHandler) Get(c *gin.Context) {
path := c.Request.URL.EscapedPath()
if !prepareMachineRequest(c, path) || !safeIdentifier(c.Param("evidence_id")) {
if !c.Writer.Written() {
problem(c, http.StatusBadRequest, "evidence_not_found", "evidence reference is invalid")
}
return
}
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
if err != nil {
problem(c, http.StatusUnauthorized, machineCode(err), "machine identity was rejected")
return
}
if _, err = h.Verifier.Verify(token, "yovision-sense", "evidence:read", c.Request.Method, path, nil); err != nil {
status := http.StatusUnauthorized
if code := machineCode(err); code == "machine_scope_denied" || code == "machine_audience_denied" {
status = http.StatusForbidden
}
problem(c, status, machineCode(err), "machine identity was rejected")
return
}
var record EvidenceRecord
if err = h.DB.WithContext(c.Request.Context()).First(&record, "evidence_id = ?", c.Param("evidence_id")).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
problem(c, http.StatusNotFound, "evidence_not_found", "evidence reference is unknown")
return
}
problem(c, http.StatusServiceUnavailable, "evidence_unavailable", "evidence metadata is temporarily unavailable")
return
}
now := time.Now().UTC()
if h.Now != nil {
now = h.Now().UTC()
}
if record.ExpiresAt != nil && !record.ExpiresAt.After(now) {
problem(c, http.StatusGone, "evidence_expired", "evidence reference has expired")
return
}
c.Data(http.StatusOK, "application/json", record.Payload)
}
func prepareMachineRequest(c *gin.Context, expectedPath string) bool {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
} else if !inboundRequestIDPattern.MatchString(requestID) {
problem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID is invalid")
return false
}
c.Header("X-Request-ID", requestID)
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != expectedPath {
problem(c, http.StatusBadRequest, "invalid_request_target", "request target is invalid")
return false
}
return true
}
func machineCode(err error) string {
var machineErr *machine_identity.Error
if errors.As(err, &machineErr) {
return machineErr.Code
}
return "machine_token_invalid"
}
func problem(c *gin.Context, status int, code, message string) {
c.Header("Content-Type", "application/problem+json")
c.JSON(status, Problem{Code: code, Message: message})
}
@@ -0,0 +1,130 @@
package bell_connector
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrInboundConflict = errors.New("idempotency_conflict")
func AcceptEvent(ctx context.Context, db *gorm.DB, payload []byte, evidenceOwnerID string, now time.Time) (AcceptResult, error) {
identity, err := parseEventIdentity(payload)
if err != nil {
return AcceptResult{}, err
}
canonical, err := canonicalPayload(payload)
if err != nil {
return AcceptResult{}, err
}
digestBytes := sha256.Sum256(canonical)
digest := hex.EncodeToString(digestBytes[:])
result := AcceptResult{ProducerID: identity.ProducerID, SourceEventID: identity.SourceEventID, PayloadSHA256: digest}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if tx.Dialector.Name() == "postgres" {
key := fmt.Sprintf("%d:%s:%s", len(identity.ProducerID), identity.ProducerID, identity.SourceEventID)
if lockErr := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; lockErr != nil {
return lockErr
}
}
var existing InboundEvent
lookup := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("producer_id = ? AND source_event_id = ?", identity.ProducerID, identity.SourceEventID).First(&existing).Error
if lookup == nil {
if existing.PayloadSHA256 != digest {
return ErrInboundConflict
}
result.Disposition = "duplicate"
return nil
}
if !errors.Is(lookup, gorm.ErrRecordNotFound) {
return lookup
}
fact := InboundEvent{ID: uuid.NewString(), ProducerID: identity.ProducerID, SourceEventID: identity.SourceEventID, Payload: append([]byte(nil), canonical...), PayloadSHA256: digest, ReceivedAt: now.UTC()}
if err := tx.Create(&fact).Error; err != nil {
return err
}
if _, err := EnqueueEvent(tx, canonical, now); err != nil {
return err
}
if err := retainEvidence(tx, canonical, evidenceOwnerID, now); err != nil {
return err
}
result.Disposition = "accepted"
return nil
})
return result, err
}
func retainEvidence(tx *gorm.DB, payload []byte, ownerID string, now time.Time) error {
var envelope struct {
Evidence []json.RawMessage `json:"evidence"`
}
decoder := json.NewDecoder(bytes.NewReader(payload))
if err := decoder.Decode(&envelope); err != nil {
return err
}
for _, raw := range envelope.Evidence {
var metadata struct {
EvidenceID string `json:"evidence_id"`
OwnerID string `json:"owner_id"`
Status string `json:"status"`
ExpiresAt string `json:"expires_at"`
}
if err := json.Unmarshal(raw, &metadata); err != nil || !safeIdentifier(metadata.EvidenceID) || !safeIdentifier(metadata.OwnerID) {
return errors.New("invalid evidence metadata")
}
if ownerID != "" && metadata.OwnerID != ownerID {
continue
}
canonical, err := canonicalPayload(raw)
if err != nil {
return err
}
var expiresAt *time.Time
if metadata.ExpiresAt != "" {
parsed, parseErr := time.Parse(time.RFC3339Nano, metadata.ExpiresAt)
if parseErr != nil {
return parseErr
}
parsed = parsed.UTC()
expiresAt = &parsed
}
record := EvidenceRecord{EvidenceID: metadata.EvidenceID, OwnerID: metadata.OwnerID, Status: metadata.Status, Payload: canonical, ExpiresAt: expiresAt, UpdatedAt: now.UTC()}
var existing EvidenceRecord
lookup := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&existing, "evidence_id = ?", metadata.EvidenceID).Error
if lookup == nil {
if existing.OwnerID != metadata.OwnerID || !validEvidenceTransition(existing.Status, metadata.Status) {
return errors.New("evidence metadata transition is invalid")
}
} else if !errors.Is(lookup, gorm.ErrRecordNotFound) {
return lookup
}
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "evidence_id"}}, DoUpdates: clause.AssignmentColumns([]string{"status", "payload", "expires_at", "updated_at"})}).Create(&record).Error; err != nil {
return err
}
}
return nil
}
func validEvidenceTransition(from, to string) bool {
if from == to {
return true
}
switch from {
case "pending":
return to == "processing" || to == "success" || to == "failed"
case "processing":
return to == "success" || to == "failed"
default:
return false
}
}
@@ -0,0 +1,79 @@
package bell_connector
import (
"encoding/json"
"time"
)
const OutboxType = "bell_event_v1"
type eventIdentity struct {
SchemaVersion string `json:"schema_version"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
}
type IngestResult struct {
EventID string `json:"event_id"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
Disposition string `json:"disposition"`
PayloadSHA256 string `json:"payload_sha256"`
}
type Problem struct {
Code string `json:"code"`
Message string `json:"message"`
ExistingEventID string `json:"existing_event_id,omitempty"`
}
type DeliveryError struct {
Code string
Detail string
Terminal bool
RetryAfter time.Duration
}
func (e *DeliveryError) Error() string { return e.Code + ": " + e.Detail }
// ReplayToken is Sense-owned verification state for authenticated evidence
// requests. It is never shared with Bell's replay table or business receipts.
type ReplayToken struct {
Principal string `gorm:"size:128;primaryKey"`
TokenID string `gorm:"size:64;primaryKey"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time `gorm:"not null"`
}
func (ReplayToken) TableName() string { return "sense_machine_token_replays" }
// InboundEvent is the Sense-owned local fact for an event received from Brain.
// The immutable payload and Bell Outbox row are created in one transaction.
type InboundEvent struct {
ID string `gorm:"size:36;primaryKey"`
ProducerID string `gorm:"size:128;not null;uniqueIndex:sense_inbound_event_key"`
SourceEventID string `gorm:"size:128;not null;uniqueIndex:sense_inbound_event_key"`
Payload json.RawMessage `gorm:"column:payload;type:jsonb;not null"`
PayloadSHA256 string `gorm:"type:char(64);not null"`
ReceivedAt time.Time `gorm:"not null"`
}
func (InboundEvent) TableName() string { return "sense_inbound_events" }
type EvidenceRecord struct {
EvidenceID string `gorm:"size:128;primaryKey"`
OwnerID string `gorm:"size:128;not null;index"`
Status string `gorm:"size:16;not null;index"`
Payload json.RawMessage `gorm:"column:payload;type:jsonb;not null"`
ExpiresAt *time.Time `gorm:"index"`
UpdatedAt time.Time `gorm:"not null"`
}
func (EvidenceRecord) TableName() string { return "sense_evidence_metadata" }
type AcceptResult struct {
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
Disposition string `json:"disposition"`
PayloadSHA256 string `json:"payload_sha256"`
}
@@ -0,0 +1,166 @@
package bell_connector
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
)
func EnqueueEvent(tx *gorm.DB, payload []byte, now time.Time) (outbox.Message, error) {
identity, err := parseEventIdentity(payload)
if err != nil {
return outbox.Message{}, err
}
keyDigest := sha256.Sum256([]byte(identity.ProducerID + "\x00" + identity.SourceEventID))
return outbox.Enqueue(tx, outbox.EnqueueInput{
InternalType: OutboxType,
BusinessRef: identity.SourceEventID,
IdempotencyKey: "bell-event-v1:" + hex.EncodeToString(keyDigest[:]),
PayloadJSON: append([]byte(nil), payload...),
}, now)
}
func parseEventIdentity(payload []byte) (eventIdentity, error) {
decoder := json.NewDecoder(bytes.NewReader(payload))
var identity eventIdentity
if err := decoder.Decode(&identity); err != nil || !json.Valid(payload) || identity.SchemaVersion != "yovision.event/v1" ||
!safeIdentifier(identity.ProducerID) || !safeIdentifier(identity.SourceEventID) {
return eventIdentity{}, errors.New("invalid yovision.event/v1 payload")
}
return identity, nil
}
func safeIdentifier(value string) bool {
if value == "" || len(value) > 128 || strings.ContainsAny(value, "\\/@\x00\r\n") {
return false
}
for index, r := range value {
allowed := r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || (index > 0 && strings.ContainsRune("._:-", r))
if !allowed {
return false
}
}
return true
}
type Relay struct {
DB *gorm.DB
Client *Client
Now func() time.Time
Backoff func(int) time.Duration
}
func (r Relay) DeliverBatch(ctx context.Context, worker string, limit int) (int, error) {
if r.DB == nil || r.Client == nil || strings.TrimSpace(worker) == "" || limit < 1 || limit > 100 {
return 0, errors.New("invalid Bell relay configuration")
}
items, err := r.claim(worker, limit)
if err != nil {
return 0, err
}
delivered := 0
queueRelay := outbox.NewRelay(r.DB)
queueRelay.Now = r.now
if r.Backoff != nil {
queueRelay.Backoff = r.Backoff
}
for _, item := range items {
_, deliveryErr := r.Client.Send(ctx, []byte(item.PayloadJSON))
if deliveryErr == nil {
if err = queueRelay.MarkSuccess(item.ID, worker); err != nil {
return delivered, err
}
delivered++
continue
}
var classified *DeliveryError
if errors.As(deliveryErr, &classified) && classified.Terminal {
if err = r.markTerminal(item, worker, classified.Code); err != nil {
return delivered, err
}
continue
}
if err = queueRelay.MarkFailure(item.ID, worker, deliveryErr.Error()); err != nil {
return delivered, err
}
}
return delivered, nil
}
func (r Relay) claim(worker string, limit int) ([]outbox.Message, error) {
now := r.now()
leaseUntil := now.Add(30 * time.Second)
claimed := make([]outbox.Message, 0, limit)
err := r.DB.Transaction(func(tx *gorm.DB) error {
var candidates []outbox.Message
query := tx.Where("internal_type = ? AND (((state IN ?) AND available_at <= ?) OR (state = ? AND lease_until < ?))", OutboxType, []string{outbox.StatePending, outbox.StateRetry}, now, outbox.StateProcessing, now).Order("available_at, created_at").Limit(limit)
if tx.Dialector.Name() == "postgres" {
query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"})
}
if err := query.Find(&candidates).Error; err != nil {
return err
}
for _, item := range candidates {
result := tx.Model(&outbox.Message{}).Where("id = ? AND version = ?", item.ID, item.Version).Updates(map[string]any{"state": outbox.StateProcessing, "lease_owner": worker, "lease_until": leaseUntil, "version": gorm.Expr("version + 1"), "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 1 {
item.State, item.LeaseOwner, item.LeaseUntil, item.Version = outbox.StateProcessing, worker, &leaseUntil, item.Version+1
claimed = append(claimed, item)
}
}
return nil
})
return claimed, err
}
func (r Relay) markTerminal(item outbox.Message, worker, detail string) error {
now := r.now()
return r.DB.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&outbox.Message{}).Where("id = ? AND state = ? AND lease_owner = ?", item.ID, outbox.StateProcessing, worker).Updates(map[string]any{
"state": outbox.StateDead, "attempt_count": gorm.Expr("attempt_count + 1"), "last_error": detail,
"lease_owner": "", "lease_until": nil, "version": gorm.Expr("version + 1"), "updated_at": now,
})
if result.Error != nil || result.RowsAffected != 1 {
if result.Error != nil {
return result.Error
}
return errors.New("Bell outbox lease lost")
}
return tx.Create(&outbox.Attempt{MessageID: item.ID, Number: item.AttemptCount + 1, Outcome: outbox.StateDead, Detail: detail, Worker: worker, CreatedAt: now}).Error
})
}
func (r Relay) now() time.Time {
if r.Now != nil {
return r.Now().UTC()
}
return time.Now().UTC()
}
func PreserveIdentity(before, after []byte) error {
left, err := parseEventIdentity(before)
if err != nil {
return err
}
right, err := parseEventIdentity(after)
if err != nil {
return err
}
if left.ProducerID != right.ProducerID || left.SourceEventID != right.SourceEventID {
return fmt.Errorf("relay changed original event identity")
}
return nil
}
@@ -0,0 +1,29 @@
package bell_connector
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PersistentReplayStore struct{ DB *gorm.DB }
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
if s.DB == nil {
return false
}
accepted := false
err := s.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
return err
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC()})
if result.Error != nil {
return result.Error
}
accepted = result.RowsAffected == 1
return nil
})
return err == nil && accepted
}
@@ -0,0 +1,89 @@
package bell_connector
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
)
type Environment func(string) string
func StartRuntime(ctx context.Context, engine *gin.Engine, db *gorm.DB, getenv Environment) error {
if getenv == nil {
getenv = os.Getenv
}
ingressEnabled := enabled(getenv("SENSE_EVENT_INGRESS_ENABLED"))
relayEnabled := enabled(getenv("SENSE_BELL_CONNECTOR_ENABLED"))
if !ingressEnabled && !relayEnabled {
return nil
}
if engine == nil || db == nil {
return errors.New("Sense connector runtime requires engine and database")
}
for _, model := range []any{&InboundEvent{}, &EvidenceRecord{}, &ReplayToken{}, &outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{}} {
if !db.Migrator().HasTable(model) {
return fmt.Errorf("Sense connector migration is not applied for %T", model)
}
}
if ingressEnabled {
registry, err := machine_identity.LoadRegistry(getenv("SENSE_MACHINE_PRINCIPAL_REGISTRY"), "yovision-sense")
if err != nil {
return fmt.Errorf("load Sense machine identity registry: %w", err)
}
verifier := machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}
engine.POST("/v1/events", (IngressHandler{DB: db, Verifier: verifier, EvidenceOwnerID: strings.TrimSpace(getenv("SENSE_EVIDENCE_OWNER_ID"))}).Post)
engine.GET("/v1/evidence/:evidence_id", (EvidenceHandler{DB: db, Verifier: verifier}).Get)
}
if !relayEnabled {
return nil
}
privateKey, err := machine_identity.LoadPrivateKey(getenv("SENSE_BELL_PRIVATE_KEY_PATH"))
if err != nil {
return err
}
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("SENSE_BELL_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("SENSE_BELL_KEY_ID")), PrivateKey: privateKey}
policy := machine_identity.TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true, ConnectTimeout: 5 * time.Second, ResponseHeaderTimeout: 10 * time.Second, RequestTimeout: 15 * time.Second, MaxRequestBytes: MaxInboundBytes}
client, err := NewClient(strings.TrimSpace(getenv("SENSE_BELL_ENDPOINT")), strings.TrimSpace(getenv("SENSE_RELAY_ID")), signer, policy)
if err != nil {
return err
}
interval := 2 * time.Second
if raw := strings.TrimSpace(getenv("SENSE_BELL_RELAY_INTERVAL_MS")); raw != "" {
milliseconds, parseErr := strconv.Atoi(raw)
if parseErr != nil || milliseconds < 100 || milliseconds > 60000 {
return errors.New("SENSE_BELL_RELAY_INTERVAL_MS must be between 100 and 60000")
}
interval = time.Duration(milliseconds) * time.Millisecond
}
go runRelay(ctx, Relay{DB: db, Client: client}, interval)
return nil
}
func runRelay(ctx context.Context, relay Relay, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := relay.DeliverBatch(ctx, "sense-bell-runtime", 50); err != nil && ctx.Err() == nil {
log.Printf("Sense Bell connector delivery failed: %v", err)
}
}
}
}
func enabled(value string) bool { return strings.EqualFold(strings.TrimSpace(value), "true") }
@@ -0,0 +1,68 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"strings"
)
type registryDocument struct {
Version string `json:"version"`
Audience string `json:"audience"`
Principals []registryPrincipal `json:"principals"`
}
type registryPrincipal struct {
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
Keys []registryKey `json:"keys"`
}
type registryKey struct {
KeyID string `json:"kid"`
PublicKey string `json:"public_key_base64url"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
}
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
return nil, errors.New("machine principal registry path and audience are required")
}
raw, err := os.ReadFile(filePath)
if err != nil {
return nil, errors.New("read machine principal registry")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var document registryDocument
if err = decoder.Decode(&document); err != nil {
return nil, errors.New("invalid machine principal registry")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid machine principal registry")
}
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
return nil, errors.New("invalid machine principal registry")
}
records := make([]KeyRecord, 0)
for _, principal := range document.Principals {
if len(principal.Keys) == 0 {
return nil, errors.New("invalid machine principal registry")
}
for _, key := range principal.Keys {
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
return nil, errors.New("invalid machine principal registry")
}
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
}
}
return NewRegistry(records...)
}
@@ -0,0 +1,363 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"sync"
"time"
)
var (
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
)
const (
Version = "yovision.machine-identity/v1"
TokenType = "YOVISION-MACHINE+JWT"
MaxLifetime = 5 * time.Minute
AllowedSkew = 30 * time.Second
MaxKeyOverlap = 24 * time.Hour
)
type Error struct{ Code string }
func (e *Error) Error() string { return e.Code }
func codeError(code string) error { return &Error{Code: code} }
// BearerToken deliberately has no cookie or query fallback.
func BearerToken(authorization string) (string, error) {
parts := strings.Split(authorization, " ")
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
return "", codeError("machine_token_missing")
}
return parts[1], nil
}
type Claims struct {
Version string `json:"ver"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Scopes []string `json:"scope"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
TokenID string `json:"jti"`
Method string `json:"htm"`
Path string `json:"htu"`
BodySHA256 string `json:"body_sha256"`
}
type protectedHeader struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
Version string `json:"ver"`
}
type KeyRecord struct {
Principal string
KeyID string
PublicKey ed25519.PublicKey
Audience string
Scopes []string
Enabled bool
Revoked bool
}
type Registry struct {
mu sync.RWMutex
keys map[string]KeyRecord
}
func NewRegistry(records ...KeyRecord) (*Registry, error) {
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
for _, record := range records {
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
return nil, errors.New("invalid machine key record")
}
if _, exists := r.keys[record.KeyID]; exists {
return nil, errors.New("duplicate machine key id")
}
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
r.keys[record.KeyID] = record
}
return r, nil
}
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
record, ok := r.keys[keyID]
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
return record, ok
}
func (r *Registry) Revoke(keyID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.keys[keyID]
if !ok {
return false
}
record.Revoked = true
r.keys[keyID] = record
return true
}
type ReplayStore struct {
mu sync.Mutex
used map[string]time.Time
}
// ReplayCache must atomically persist accepted (principal, jti) pairs until
// expiry. ReplayStore is process-local and intended for tests or a single
// uninterrupted process; connector implementations inject a durable store.
type ReplayCache interface {
Consume(principal, tokenID string, expiresAt, now time.Time) bool
}
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, expiry := range s.used {
if !expiry.After(now) {
delete(s.used, key)
}
}
key := principal + "\x00" + tokenID
if _, exists := s.used[key]; exists {
return false
}
s.used[key] = expiresAt
return true
}
type Signer struct {
Principal string
KeyID string
PrivateKey ed25519.PrivateKey
Now func() time.Time
}
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("machine private key path is required")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read machine private key")
}
block, rest := pem.Decode(raw)
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("parse machine private key")
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok || len(key) != ed25519.PrivateKeySize {
return nil, errors.New("machine private key is not Ed25519")
}
return slices.Clone(key), nil
}
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
return "", errors.New("incomplete machine signer configuration")
}
normalizedPath, err := normalizePath(requestPath)
if err != nil {
return "", err
}
method = strings.ToUpper(method)
if !allowedMethod(method) {
return "", errors.New("unsupported machine request method")
}
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
tokenID, err := randomTokenID()
if err != nil {
return "", err
}
digest := sha256.Sum256(body)
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
return signingInput + "." + rawBase64(signature), nil
}
type Verifier struct {
Registry *Registry
Replay ReplayCache
Now func() time.Time
}
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
if v.Registry == nil || v.Replay == nil {
return Claims{}, codeError("machine_token_invalid")
}
parts := strings.Split(token, ".")
if len(parts) != 3 || strings.Contains(token, "=") {
return Claims{}, codeError("machine_token_invalid")
}
headerBytes, err := decodeRaw(parts[0])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var header protectedHeader
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
return Claims{}, codeError("machine_token_invalid")
}
record, ok := v.Registry.Lookup(header.KeyID)
if !ok {
return Claims{}, codeError("machine_token_invalid")
}
signature, err := decodeRaw(parts[2])
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
return Claims{}, codeError("machine_token_invalid")
}
if !record.Enabled || record.Revoked {
return Claims{}, codeError("machine_identity_revoked")
}
claimsBytes, err := decodeRaw(parts[1])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var claims Claims
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
return Claims{}, codeError("machine_token_invalid")
}
now := time.Now().UTC()
if v.Now != nil {
now = v.Now().UTC()
}
nowUnix := now.Unix()
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_invalid")
}
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_expired")
}
if claims.Audience != audience || record.Audience != audience {
return Claims{}, codeError("machine_audience_denied")
}
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
return Claims{}, codeError("machine_scope_denied")
}
normalizedPath, err := normalizePath(requestPath)
digest := sha256.Sum256(body)
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
return Claims{}, codeError("machine_token_invalid")
}
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
return Claims{}, codeError("machine_token_replayed")
}
return claims, nil
}
func decodeClosed(raw []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("trailing JSON value")
}
return err
}
return nil
}
func validClaimsShape(claims Claims) bool {
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
return false
}
if !validScopeList(claims.Scopes) {
return false
}
_, err := hex.DecodeString(claims.BodySHA256)
return err == nil
}
func validScopeList(scopes []string) bool {
if len(scopes) == 0 || len(scopes) > 4 {
return false
}
seen := map[string]bool{}
for _, scope := range scopes {
if !validScopes[scope] || seen[scope] {
return false
}
seen[scope] = true
}
return true
}
func normalizePath(value string) (string, error) {
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
}
return parsed.EscapedPath(), nil
}
func allowedMethod(method string) bool {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
func randomTokenID() (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate machine token id: %w", err)
}
return rawBase64(raw), nil
}
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
func decodeRaw(value string) ([]byte, error) {
return base64.RawURLEncoding.Strict().DecodeString(value)
}
@@ -0,0 +1,219 @@
package machine_identity
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
type crossLanguageVector struct {
PublicKey string `json:"public_key_base64url"`
Token string `json:"token"`
Now int64 `json:"now"`
Audience string `json:"audience"`
Scope string `json:"required_scope"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body_base64"`
}
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0).UTC()
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
if err != nil {
t.Fatal(err)
}
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
}
func errorCode(t *testing.T, err error) string {
t.Helper()
var coded *Error
if !errors.As(err, &coded) {
t.Fatalf("expected coded error, got %v", err)
}
return coded.Code
}
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
t.Fatal(err)
}
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
t.Fatalf("accepted %q", value)
}
}
}
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
mint := func() string {
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
return token
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
token := mint()
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
t.Fatal(err)
}
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
t.Fatal(err)
}
}
func TestExpiryRevocationAndRotation(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte("{}")
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
t.Fatal(err)
}
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
rotation, err := NewRegistry(
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
)
if err != nil {
t.Fatal(err)
}
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if !rotation.Revoke("brain-old-0001") {
t.Fatal("old key was not revoked")
}
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
t.Fatal(err)
}
}
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
if err := safe.Validate(); err != nil {
t.Fatal(err)
}
unsafe := safe
unsafe.VerifyHostname = false
if err := unsafe.Validate(); err == nil {
t.Fatal("unsafe hostname policy accepted")
}
unsafe = safe
unsafe.TLSMinVersion = tls.VersionTLS11
if err := unsafe.Validate(); err == nil {
t.Fatal("TLS 1.1 accepted")
}
}
func TestVerifiesCrossLanguageVector(t *testing.T) {
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
raw, err := os.ReadFile(vectorPath)
if err != nil {
t.Fatal(err)
}
var vector crossLanguageVector
if err = json.Unmarshal(raw, &vector); err != nil {
t.Fatal(err)
}
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
if err != nil {
t.Fatal(err)
}
body, err := base64.StdEncoding.DecodeString(vector.Body)
if err != nil {
t.Fatal(err)
}
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != "yv:brain:vector" {
t.Fatalf("unexpected issuer: %s", claims.Issuer)
}
}
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
document := map[string]any{
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
}}}},
}
raw, _ := json.Marshal(document)
file := filepath.Join(t.TempDir(), "principals.json")
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
registry, err := LoadRegistry(file, "yovision-bell")
if err != nil {
t.Fatal(err)
}
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
t.Fatal("registry record missing")
}
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
t.Fatal("wrong registry audience accepted")
}
}
@@ -0,0 +1,39 @@
package machine_identity
import (
"crypto/tls"
"errors"
"net/http"
"time"
)
type TransportPolicy struct {
TLSMinVersion uint16
VerifyCertificate bool
VerifyHostname bool
ConnectTimeout time.Duration
ResponseHeaderTimeout time.Duration
RequestTimeout time.Duration
MaxRequestBytes int64
}
func (p TransportPolicy) Validate() error {
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
return errors.New("machine transport policy is unsafe")
}
return nil
}
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
if err := p.Validate(); err != nil {
return nil, err
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
TLSHandshakeTimeout: p.ConnectTimeout,
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
}
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
}
+4 -4
View File
@@ -8,12 +8,12 @@ import (
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
)
// CreateWithOutbox commits the local candidate and its internal delivery
// record atomically. The payload remains Sense-internal and is not a Bell or
// Brain contract.
// record atomically. The payload is the frozen anonymous event contract; it
// never carries the candidate's internal evidence path or delivery attempts.
func CreateWithOutbox(ctx context.Context, db *gorm.DB, candidate EventCandidate, payload map[string]interface{}, now time.Time) error {
encoded, err := json.Marshal(payload)
if err != nil {
@@ -23,7 +23,7 @@ func CreateWithOutbox(ctx context.Context, db *gorm.DB, candidate EventCandidate
if err := tx.Create(&candidate).Error; err != nil {
return fmt.Errorf("create local event candidate: %w", err)
}
_, err = outbox.Enqueue(tx, outbox.EnqueueInput{InternalType: "local_event_candidate", BusinessRef: candidate.ID, IdempotencyKey: "local-event:" + candidate.ID + ":v1", PayloadJSON: encoded}, now)
_, err = bell_connector.EnqueueEvent(tx, encoded, now)
return err
})
}
@@ -2,6 +2,9 @@ package local_event
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
@@ -9,6 +12,7 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
)
@@ -24,7 +28,18 @@ func TestCreateWithOutboxCommitsAndRollsBackAtomically(t *testing.T) {
}
now := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
candidate := EventCandidate{ID: uuid.NewString(), OccurredAt: now, SourceRef: "SEN-CAM-01", RuleRef: "rule-1", RuleName: "区域闯入", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStatePending, RetainUntil: now.Add(24 * time.Hour)}
if err = CreateWithOutbox(context.Background(), db, candidate, map[string]interface{}{"eventId": candidate.ID}, now); err != nil {
fixturePath := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", "dangerous-area.json")
fixture, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatal(err)
}
var payload map[string]interface{}
if err = json.Unmarshal(fixture, &payload); err != nil {
t.Fatal(err)
}
payload["producer_id"] = "sense-local"
payload["source_event_id"] = candidate.ID
if err = CreateWithOutbox(context.Background(), db, candidate, payload, now); err != nil {
t.Fatal(err)
}
var candidates, messages int64
@@ -33,9 +48,13 @@ func TestCreateWithOutboxCommitsAndRollsBackAtomically(t *testing.T) {
if candidates != 1 || messages != 1 {
t.Fatalf("candidates=%d messages=%d", candidates, messages)
}
var message outbox.Message
if err = db.First(&message).Error; err != nil || message.InternalType != bell_connector.OutboxType {
t.Fatalf("local event did not enqueue Bell contract delivery: type=%s err=%v", message.InternalType, err)
}
duplicate := candidate
duplicate.ID = candidate.ID
if err = CreateWithOutbox(context.Background(), db, duplicate, map[string]interface{}{"eventId": duplicate.ID}, now); err == nil {
if err = CreateWithOutbox(context.Background(), db, duplicate, payload, now); err == nil {
t.Fatal("expected duplicate transaction failure")
}
db.Model(&EventCandidate{}).Count(&candidates)
+119
View File
@@ -0,0 +1,119 @@
package api
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
)
func TestBellConnectorRuntimeWiringIsOptionalAndMigrationGated(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:sense-api-bell-connector?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
engine := gin.New()
disabled := func(string) string { return "" }
if err = startBellConnectorRuntime(context.Background(), engine, db, disabled); err != nil {
t.Fatalf("disabled connector prevented Sense startup: %v", err)
}
if len(engine.Routes()) != 0 {
t.Fatalf("disabled connector registered routes: %#v", engine.Routes())
}
enabled := func(key string) string {
if key == "SENSE_EVENT_INGRESS_ENABLED" {
return "true"
}
return ""
}
if err = startBellConnectorRuntime(context.Background(), gin.New(), db, enabled); err == nil {
t.Fatal("enabled connector started without its formal migration")
}
if err = db.AutoMigrate(
&bell_connector.InboundEvent{}, &bell_connector.EvidenceRecord{}, &bell_connector.ReplayToken{},
&outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{},
); err != nil {
t.Fatal(err)
}
registryPath := writeSenseRegistry(t)
runtimeEnvironment := func(key string) string {
switch key {
case "SENSE_EVENT_INGRESS_ENABLED":
return "true"
case "SENSE_MACHINE_PRINCIPAL_REGISTRY":
return registryPath
default:
return ""
}
}
registered := gin.New()
if err = startBellConnectorRuntime(context.Background(), registered, db, runtimeEnvironment); err != nil {
t.Fatalf("enabled connector did not register after migration: %v", err)
}
routes := registered.Routes()
if len(routes) != 2 || routes[0].Path != "/v1/events" || routes[1].Path != "/v1/evidence/:evidence_id" {
t.Fatalf("unexpected connector routes: %#v", routes)
}
}
func TestConnectorRuntimeUsesOnlyTheExplicitDefaultDatabase(t *testing.T) {
defaultDB, err := gorm.Open(sqlite.Open("file:sense-default-runtime?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
secondaryDB, err := gorm.Open(sqlite.Open("file:sense-secondary-runtime?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sdk.Runtime.SetDb("", defaultDB)
sdk.Runtime.SetDb("analytics", secondaryDB)
t.Cleanup(func() {
sdk.Runtime.SetDb("", nil)
sdk.Runtime.SetDb("analytics", nil)
})
if selected := defaultRuntimeDatabase(); selected != defaultDB {
t.Fatalf("runtime selected a non-default database: %p", selected)
}
}
func writeSenseRegistry(t *testing.T) string {
t.Helper()
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
document := map[string]any{
"version": "yovision.machine-principal-registry/v1",
"audience": "yovision-sense",
"principals": []any{map[string]any{
"principal_id": "yv:brain:school-a", "enabled": true,
"keys": []any{map[string]any{
"kid": "brain-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
"status": "active", "scopes": []string{"events:ingest"},
}},
}},
}
encoded, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "sense-registry.json")
if err = os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
return path
}
+20 -6
View File
@@ -17,9 +17,11 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/router"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
@@ -88,15 +90,19 @@ func run() error {
}
runtimeCtx, runtimeCancel := context.WithCancel(context.Background())
defer runtimeCancel()
var runtimeDBFound bool
for _, db := range sdk.Runtime.GetDb() {
runtimeDBFound = true
db := defaultRuntimeDatabase()
if db != nil {
engine, engineOK := sdk.Runtime.GetEngine().(*gin.Engine)
if !engineOK || engine == nil {
return errors.New("Sense connector runtime requires Gin engine")
}
if err := startBellConnectorRuntime(runtimeCtx, engine, db, os.Getenv); err != nil {
return fmt.Errorf("Bell connector runtime unavailable: %w", err)
}
if err := media.StartRuntime(runtimeCtx, db); err != nil {
return fmt.Errorf("MediaMTX runtime unavailable: %w", err)
}
break
}
if !runtimeDBFound {
} else {
log.Error("MediaMTX runtime unavailable: Sense database is not initialized")
}
@@ -166,6 +172,14 @@ func run() error {
return nil
}
func startBellConnectorRuntime(ctx context.Context, engine *gin.Engine, db *gorm.DB, getenv bell_connector.Environment) error {
return bell_connector.StartRuntime(ctx, engine, db, getenv)
}
func defaultRuntimeDatabase() *gorm.DB {
return sdk.Runtime.GetDbByKey("")
}
//var Router runtime.Router
func tip() {
@@ -0,0 +1,30 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseBellConnector)
}
func migrateSenseBellConnector(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(
&bell_connector.InboundEvent{},
&bell_connector.EvidenceRecord{},
&bell_connector.ReplayToken{},
); err != nil {
return err
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,66 @@
package version
import (
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestSenseBellConnectorMigrationIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
const version = "2026083112000"
for attempt := 0; attempt < 2; attempt++ {
if err = migrateSenseBellConnector(db, version); err != nil {
t.Fatalf("migration attempt %d: %v", attempt+1, err)
}
}
for name, model := range map[string]any{
"inbound events": &bell_connector.InboundEvent{},
"evidence records": &bell_connector.EvidenceRecord{},
"replay tokens": &bell_connector.ReplayToken{},
} {
if !db.Migrator().HasTable(model) {
t.Fatalf("%s table missing", name)
}
var count int64
if err = db.Model(model).Count(&count).Error; err != nil {
t.Fatalf("count %s: %v", name, err)
}
if count != 0 {
t.Fatalf("migration inserted %d %s fixtures", count, name)
}
}
if !db.Migrator().HasIndex(&bell_connector.ReplayToken{}, "ExpiresAt") {
t.Fatal("replay expiry index missing")
}
now := time.Now().UTC()
token := bell_connector.ReplayToken{Principal: "sense", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
if err = db.Create(&token).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&token).Error; err == nil {
t.Fatal("duplicate replay token accepted")
}
var applied int64
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if applied != 1 {
t.Fatalf("migration records=%d, want 1", applied)
}
}
@@ -0,0 +1,235 @@
package bell_connector_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
)
func TestPersistentOutboxRetriesWithoutChangingOriginalIdentity(t *testing.T) {
db, databasePath := openDatabase(t)
body := fixture(t)
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC) }}
var attempts atomic.Int32
var received [][]byte
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
payload := make([]byte, request.ContentLength)
_, _ = request.Body.Read(payload)
received = append(received, payload)
requestID := request.Header.Get("X-Request-ID")
writer.Header().Set("X-Request-ID", requestID)
if request.Header.Get("X-YoVision-Relay-ID") != "sense-school-a" || !strings.HasPrefix(request.Header.Get("Authorization"), "Bearer ") || len(requestID) < 16 || len(requestID) > 128 {
http.Error(writer, "missing relay identity", http.StatusUnauthorized)
return
}
if attempts.Add(1) == 1 {
writer.WriteHeader(http.StatusServiceUnavailable)
_, _ = writer.Write([]byte(`{"code":"ingest_unavailable","message":"temporarily unavailable"}`))
return
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"event_id":"bell-event-1","producer_id":"brain-school-a","source_event_id":"evt-area-20260831-0001","disposition":"created","payload_sha256":"4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"}`))
}))
defer server.Close()
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
if _, err := bell_connector.EnqueueEvent(db, body, now); err != nil {
t.Fatal(err)
}
client := &bell_connector.Client{Endpoint: server.URL, RelayID: "sense-school-a", Signer: signer, HTTP: server.Client(), Enabled: true}
relay := bell_connector.Relay{DB: db, Client: client, Now: func() time.Time { return now }, Backoff: func(int) time.Duration { return 0 }}
if delivered, err := relay.DeliverBatch(context.Background(), "worker-1", 10); err != nil || delivered != 0 {
t.Fatalf("unavailable delivery=%d err=%v", delivered, err)
}
var queued outbox.Message
if err := db.First(&queued).Error; err != nil || queued.State != outbox.StateRetry {
t.Fatalf("outbox was not retained for retry: state=%s err=%v", queued.State, err)
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
if err = sqlDatabase.Close(); err != nil {
t.Fatal(err)
}
db = openDatabasePath(t, databasePath)
if err = db.First(&queued).Error; err != nil {
t.Fatal(err)
}
// Constructing a new relay simulates restart; the durable row is claimed
// and delivered with its original bytes and business identity.
restarted := bell_connector.Relay{DB: db, Client: client, Now: func() time.Time { return now }, Backoff: func(int) time.Duration { return 0 }}
if delivered, err := restarted.DeliverBatch(context.Background(), "worker-2", 10); err != nil || delivered != 1 {
t.Fatalf("recovery delivery=%d err=%v", delivered, err)
}
if len(received) != 2 || string(received[0]) != string(body) || string(received[1]) != string(body) || bell_connector.PreserveIdentity(received[0], received[1]) != nil {
t.Fatal("relay changed the frozen payload or original identity")
}
if err := db.First(&queued).Error; err != nil || queued.State != outbox.StateDelivered {
t.Fatalf("outbox not delivered: state=%s err=%v", queued.State, err)
}
}
func TestTerminalConflictDisabledTimeoutAndReplayRestart(t *testing.T) {
db, databasePath := openDatabase(t)
body := fixture(t)
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey}
policy := machine_identity.TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true, ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: time.Second, MaxRequestBytes: 64 * 1024}
if _, err := bell_connector.NewClient("https://user:pass@bell.example", "sense-school-a", signer, policy); err == nil || !strings.Contains(err.Error(), "HTTPS origin") {
t.Fatalf("endpoint userinfo was not rejected: %v", err)
}
conflictServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusConflict)
_, _ = writer.Write([]byte(`{"code":"idempotency_conflict","message":"conflict","existing_event_id":"bell-1"}`))
}))
defer conflictServer.Close()
if _, err := bell_connector.EnqueueEvent(db, body, time.Now()); err != nil {
t.Fatal(err)
}
client := &bell_connector.Client{Endpoint: conflictServer.URL, Signer: signer, HTTP: conflictServer.Client(), Enabled: true}
if _, err := (bell_connector.Relay{DB: db, Client: client}).DeliverBatch(context.Background(), "worker", 1); err != nil {
t.Fatal(err)
}
var message outbox.Message
if err := db.First(&message).Error; err != nil || message.State != outbox.StateDead {
t.Fatalf("terminal conflict was retried: state=%s err=%v", message.State, err)
}
if _, err := (bell_connector.Client{Enabled: false}).Send(context.Background(), body); err == nil || !strings.Contains(err.Error(), "connector_disabled") {
t.Fatalf("disabled connector error=%v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := client.Send(cancelled, body); err == nil {
t.Fatal("cancelled/timeout request unexpectedly succeeded")
}
now := time.Now().UTC()
firstStore := bell_connector.PersistentReplayStore{DB: db}
if !firstStore.Consume("yv:bell:school-a", "abcdefghijklmnopqrstuv", now.Add(time.Minute), now) {
t.Fatal("first replay consume failed")
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
if err = sqlDatabase.Close(); err != nil {
t.Fatal(err)
}
db = openDatabasePath(t, databasePath)
restartedStore := bell_connector.PersistentReplayStore{DB: db}
if restartedStore.Consume("yv:bell:school-a", "abcdefghijklmnopqrstuv", now.Add(time.Minute), now) {
t.Fatal("replay was accepted after store restart")
}
}
func TestBrainIngressAndBellEvidenceEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
db, _ := openDatabase(t)
brainPublic, brainPrivate, _ := ed25519.GenerateKey(rand.Reader)
bellPublic, bellPrivate, _ := ed25519.GenerateKey(rand.Reader)
registry, err := machine_identity.NewRegistry(
machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: brainPublic, Audience: "yovision-sense", Scopes: []string{"events:ingest"}, Enabled: true},
machine_identity.KeyRecord{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PublicKey: bellPublic, Audience: "yovision-sense", Scopes: []string{"evidence:read"}, Enabled: true},
)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
verifier := machine_identity.Verifier{Registry: registry, Replay: bell_connector.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}
body := fixture(t)
brainSigner := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: brainPrivate, Now: func() time.Time { return now }}
token, err := brainSigner.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/v1/events", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("X-Request-ID", "request-id-0000001")
response := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(response)
ginContext.Request = request
(bell_connector.IngressHandler{DB: db, Verifier: verifier, EvidenceOwnerID: "sense-school-a", Now: func() time.Time { return now }}).Post(ginContext)
if response.Code != http.StatusAccepted {
t.Fatalf("Brain ingress status=%d body=%s", response.Code, response.Body.String())
}
var evidence bell_connector.EvidenceRecord
if err = db.First(&evidence, "evidence_id = ?", "ev-school-east-0001").Error; err != nil {
t.Fatal(err)
}
bellSigner := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: bellPrivate, Now: func() time.Time { return now }}
path := "/v1/evidence/ev-school-east-0001"
token, err = bellSigner.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
if err != nil {
t.Fatal(err)
}
getRequest := httptest.NewRequest(http.MethodGet, path, nil)
getRequest.Header.Set("Authorization", "Bearer "+token)
getRequest.Header.Set("X-Request-ID", "request-id-0000002")
getResponse := httptest.NewRecorder()
getContext, _ := gin.CreateTestContext(getResponse)
getContext.Request = getRequest
getContext.Params = gin.Params{{Key: "evidence_id", Value: "ev-school-east-0001"}}
(bell_connector.EvidenceHandler{DB: db, Verifier: verifier, Now: func() time.Time { return now }}).Get(getContext)
if getResponse.Code != http.StatusOK || !bytes.Equal(getResponse.Body.Bytes(), evidence.Payload) {
t.Fatalf("evidence lookup status=%d body=%s", getResponse.Code, getResponse.Body.String())
}
}
func openDatabase(t *testing.T) (*gorm.DB, string) {
databasePath := filepath.Join(t.TempDir(), "sense-bell-connector.sqlite")
return openDatabasePath(t, databasePath), databasePath
}
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{}, &bell_connector.ReplayToken{}, &bell_connector.InboundEvent{}, &bell_connector.EvidenceRecord{}); err != nil {
t.Fatal(err)
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDatabase.Close() })
return db
}
func fixture(t *testing.T) []byte {
t.Helper()
path := filepath.Join("..", "..", "..", "..", "contracts", "events", "v1", "examples", "dangerous-area.json")
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var event map[string]any
if json.Unmarshal(body, &event) != nil {
t.Fatal("invalid fixture")
}
return body
}
@@ -0,0 +1,72 @@
module git.ilapage.cn/ila/yovision/Sense/tests/integration/bell_connector
go 1.26.5
require (
git.ilapage.cn/ila/yovision/Sense/server v0.0.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
)
require (
dario.cat/mergo v1.0.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/andeya/ameda v1.5.3 // indirect
github.com/andeya/goutil v1.1.2 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bytedance/go-tagexpr/v2 v2.9.11 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 // indirect
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2 // indirect
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-sqlite3 v1.14.49 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nyaruka/phonenumbers v1.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.30.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
replace git.ilapage.cn/ila/yovision/Sense/server => ../../../server
@@ -0,0 +1,204 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andeya/ameda v1.5.3 h1:SvqnhQPZwwabS8HQTRGfJwWPl2w9ZIPInHAw9aE1Wlk=
github.com/andeya/ameda v1.5.3/go.mod h1:FQDHRe1I995v6GG+8aJ7UIUToEmbdTJn/U26NCPIgXQ=
github.com/andeya/goutil v1.0.1/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
github.com/andeya/goutil v1.1.2 h1:RiFWFkL/9yXh2SjQkNWOHqErU1x+RauHmeR23eNUzSg=
github.com/andeya/goutil v1.1.2/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/go-tagexpr/v2 v2.9.11 h1:jJgmoDKPKacGl0llPYbYL/+/2N+Ng0vV0ipbnVssXHY=
github.com/bytedance/go-tagexpr/v2 v2.9.11/go.mod h1:UAyKh4ZRLBPGsyTRFZoPqTni1TlojMdOJXQnEIPCX84=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 h1:K62Lb6bsgLOB++z/VAvRvtiEBdNCuMfmQGTGGWMdPpM=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99/go.mod h1:9+sJ9zvvkXC5sPjPEZM3Jpb9n2Q2VtcrGZly0UHYF5I=
github.com/chanxuehong/util v0.0.0-20200304121633-ca8141845b13/go.mod h1:XEYt99iTxMqkv+gW85JX/DdUINHUe43Sbe5AtqSaDAQ=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd h1:v3JNsFZmplLO/Cmiyr/rGvR7lW1ld9lB+d5h4yR0MTI=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd/go.mod h1:mysjrtCs9MmN8hqDf4/mc4eQ26Rt9s1p5oO+fhJlLB4=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 h1:UI9ppj+iWl/0Y6kp9w06dwGUdLsdxWRC3p0J0gLHApI=
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4/go.mod h1:uWX7fPisJ6DluUP9vR3m3818RkDpZb/4dnwbZdmZN6Q=
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2 h1:cPTLzpvvyh8kyB24jblB+2W0QZBugP+8VYN3R34Pb4s=
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2/go.mod h1:ejtJ3aohd6EznZ9Q+KZVA3NwPU/2qIm0gayIGM3tIXw=
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 h1:gU2OBSCsfSrqWId2monoEQhPKXZqhZ8W9ol9f7A4DAE=
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4/go.mod h1:va1lNEXHGnV161Avr0lzi5gnT8OazJ/wmN9xnsY9N/s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U=
github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY=
github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+58
View File
@@ -0,0 +1,58 @@
# Standard event contract v1
`yovision.event/v1` is the only shared representation of an anonymous safety event. It is an immutable fact, not a Bell Alert. Bell owns all rule matching, Alert, acknowledgement, close, notification and user/audit state.
## Identity and idempotency
The permanent idempotency key is the exact UTF-8 pair `(producer_id, source_event_id)`. `producer_id` always names the original producer. A Sense gateway/relay sends its own authenticated transport identity and optional `X-YoVision-Relay-ID`, but it must forward both key fields and the business payload unchanged. A retry is not a new event.
After schema validation, calculate `payload_sha256` from the RFC 8785 JSON Canonicalization Scheme representation of the complete Event. The checked-in vector fixes the expected digest for supported implementations. Bell stores key, digest and Bell `event_id` permanently:
- absent key: atomically create Event/Receipt and return `201` with `disposition=created`;
- same key and digest: return the original `event_id` and digest with `200`, `disposition=duplicate`;
- same key but another digest: return `409 idempotency_conflict`, append an audit fact, and mutate neither Event nor Alert;
- identity lookup and insert must share a transaction/unique constraint so concurrent duplicates have the same result.
Canonical timestamps in Event v1 are UTC RFC 3339 with exactly three fractional digits and `Z`. Optional members are omitted, never sent as `null`. Producers must reject non-finite numbers before canonicalization.
## Mapper responsibilities
| Role | Required responsibility | Must not do |
|---|---|---|
| Brain producer mapper | Convert `brain.internal.event-candidate/v1` into stable original identity, logical site/device/profile/rule/region refs, model version and anonymous observation; generate one `source_event_id` once and persist/reuse it across retries. | Expose internal candidate fields, face/person identity, camera credentials, file paths, Alert state, or regenerate identity during retry. |
| Sense producer/evidence mapper | When Sense originates an event, apply the same original-identity rule; map its internal evidence record to a logical evidence reference and own later status resolution. | Put local path, RTSP URL, signed URL, credential or Outbox attempt ID into Event. |
| Sense relay | Authenticate as a transport hop, preserve original `producer_id`, `source_event_id` and payload, retain retry/audit state outside the Event, and return Bell's response unchanged enough for deterministic retry handling. | Replace producer identity, create a new source ID, enrich/reorder semantics, or treat `409`/`422` as a transient retry. |
| Bell consumer mapper | Validate before persistence; canonicalize; enforce permanent idempotency; map the immutable shared Event into Bell's private Event/Receipt and then independently evaluate rules to create an Alert. Unknown evidence becomes degraded evidence, not a rejected Event. | Persist arbitrary extension fields, import producer internals, or accept shared ack/close/notification/user state. |
Field ownership is deliberately narrow:
| Contract fields | Authoritative writer | Relay/Bell responsibility |
|---|---|---|
| `schema_version`, `producer_id`, `source_event_id` | Original Brain or Sense producer mapper | Relay preserves; Bell uses version gate and permanent idempotency key. |
| `site_ref`, `device_ref`, `profile_ref` | Producer mapper from versioned logical configuration | Relay preserves; Bell treats as opaque external refs. |
| `event_type`, `occurred_at`, `severity`, `rule`, `model`, `observation`, `region` | Brain/Sense mapper at the detection decision | Relay preserves; Bell validates and stores the immutable snapshot. |
| `evidence[]` identity and initial status | Evidence-owning producer, normally Sense | Relay preserves; Bell stores the Event snapshot and resolves current metadata separately. |
| `X-YoVision-Relay-ID` | Authenticated Sense transport hop | Bell audits transport metadata outside the immutable Event. |
| `event_id`, `disposition`, `payload_sha256` | Bell ingest boundary | Producer/relay retain the receipt for deterministic retries. |
## Errors, compatibility and fallback
- `400 invalid_event`: schema, canonical form, or sensitive/unknown member violation. Terminal until the producer fixes the payload.
- `409 idempotency_conflict`: same permanent key with a different payload. Terminal and audited; never overwrite the first Event.
- `422 unsupported_schema_version`: unknown major/revision. Terminal for that payload.
- Evidence `pending`, `processing`, `success` and `failed` are valid Event states. Bell keeps the Event and resolves/degrades evidence independently.
v1 is closed (`additionalProperties=false`). Producers may enable a compatible revision only after all relays and Bell validate it. Any removed/renamed required field, changed meaning, enum narrowing, identity/canonicalization change, or new required member publishes a new major path such as `/v2`. During the compatibility window Bell keeps the previous version endpoint. Rollback disables the new producer version and resumes the last accepted version; it does not delete Event, Receipt, Outbox or audit facts.
Unknown-version fallback is explicit: Bell returns `422`; relay records the terminal rejection without rewriting the payload; producer may remap the same internal candidate into a supported v1 payload only if it has not previously assigned that `(producer_id, source_event_id)` to a different canonical payload. Otherwise it must stop and require operator reconciliation.
## Reproducible verification
No third-party package is needed:
```powershell
python contracts/tests/events-v1/test_contract.py
python contracts/tests/evidence-v1/test_contract.py
```
The tests validate Schema/OpenAPI references, mapper fixtures, RFC 8785-compatible canonical vectors used by v1 examples, duplicate/conflict behavior, unknown versions and sensitive-field rejection.
+77
View File
@@ -0,0 +1,77 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/event.schema.json",
"title": "YoVision anonymous safety event v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version", "producer_id", "source_event_id", "site_ref", "device_ref",
"profile_ref", "event_type", "occurred_at", "severity", "rule", "model",
"observation", "region", "evidence"
],
"properties": {
"schema_version": {"const": "yovision.event/v1"},
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"site_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"device_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"profile_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"event_type": {"enum": ["dangerous_area_entered", "directional_line_crossed"]},
"occurred_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"},
"severity": {"enum": ["low", "medium", "high", "critical"]},
"rule": {
"type": "object", "additionalProperties": false, "required": ["rule_id", "version"],
"properties": {
"rule_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"version": {"type": "string", "minLength": 1, "maxLength": 64}
}
},
"model": {
"type": "object", "additionalProperties": false, "required": ["name", "version"],
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 128},
"version": {"type": "string", "minLength": 1, "maxLength": 64}
}
},
"observation": {
"type": "object", "additionalProperties": false,
"required": ["track_id", "category", "confidence"],
"properties": {
"track_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"category": {"enum": ["person", "vehicle", "other"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"bbox_normalized": {
"type": "array", "minItems": 4, "maxItems": 4,
"items": {"type": "number", "minimum": 0, "maximum": 1}
}
}
},
"region": {
"type": "object", "additionalProperties": false,
"required": ["region_id", "kind"],
"properties": {
"region_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"kind": {"enum": ["area", "line"]},
"crossing_direction": {"enum": ["a_to_b", "b_to_a"]}
},
"allOf": [
{"if": {"properties": {"kind": {"const": "line"}}, "required": ["kind"]}, "then": {"required": ["crossing_direction"]}},
{"if": {"properties": {"kind": {"const": "area"}}, "required": ["kind"]}, "then": {"not": {"required": ["crossing_direction"]}}}
]
},
"evidence": {
"type": "array", "maxItems": 8, "uniqueItems": true,
"items": {"$ref": "../../evidence/v1/evidence-reference.schema.json"}
}
},
"allOf": [
{
"if": {"properties": {"event_type": {"const": "dangerous_area_entered"}}, "required": ["event_type"]},
"then": {"properties": {"region": {"properties": {"kind": {"const": "area"}}}}}
},
{
"if": {"properties": {"event_type": {"const": "directional_line_crossed"}}, "required": ["event_type"]},
"then": {"properties": {"region": {"properties": {"kind": {"const": "line"}}}}}
}
]
}
@@ -0,0 +1,26 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-area-20260831-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "bbox_normalized": [0.12, 0.2, 0.31, 0.74]},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": [
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "pending",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:01.125Z"
}
]
}
@@ -0,0 +1,27 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-line-20260831-0002",
"site_ref": "site-school-a",
"device_ref": "camera-north-corridor",
"profile_ref": "profile-main-stream",
"event_type": "directional_line_crossed",
"occurred_at": "2026-08-31T00:03:10.000Z",
"severity": "medium",
"rule": {"rule_id": "rule-north-one-way", "version": "1"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0088", "category": "person", "confidence": 0.88},
"region": {"region_id": "line-north-one-way", "kind": "line", "crossing_direction": "b_to_a"},
"evidence": [
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0002",
"owner_id": "sense-school-a",
"type": "clip",
"status": "failed",
"captured_at": "2026-08-31T00:03:10.000Z",
"status_updated_at": "2026-08-31T00:03:13.100Z",
"failure": {"code": "processing_failed", "retryable": true}
}
]
}
@@ -0,0 +1,7 @@
{
"event_id": "bell-event-00000042",
"producer_id": "brain-school-a",
"source_event_id": "evt-area-20260831-0001",
"disposition": "duplicate",
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
}
@@ -0,0 +1,5 @@
{
"code": "idempotency_conflict",
"message": "idempotency key already belongs to another canonical payload",
"existing_event_id": "bell-event-00000042"
}
@@ -0,0 +1,5 @@
{
"code": "unsupported_schema_version",
"message": "schema_version yovision.event/v2 is not accepted",
"field": "schema_version"
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/ingest-result.schema.json",
"title": "YoVision Bell event ingest result v1",
"type": "object",
"additionalProperties": false,
"required": ["event_id", "producer_id", "source_event_id", "disposition", "payload_sha256"],
"properties": {
"event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"disposition": {"enum": ["created", "duplicate"]},
"payload_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"openapi": "3.1.0",
"info": {"title": "YoVision standard event ingest API", "version": "1.0.0"},
"paths": {
"/v1/events": {
"post": {
"summary": "Ingest one immutable anonymous safety event",
"parameters": [
{"name": "X-YoVision-Relay-ID", "in": "header", "required": false, "description": "Audited transport hop. A relay must not change producer_id or source_event_id.", "schema": {"type": "string", "maxLength": 128}}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "./event.schema.json"}}}},
"responses": {
"201": {"description": "Created", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
"200": {"description": "Exact duplicate; returns the original Bell Event identity", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
"400": {"description": "Invalid or sensitive payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
"409": {"description": "Same idempotency key with a different canonical payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
"422": {"description": "Unsupported schema major version", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}}
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/problem.schema.json",
"title": "YoVision contract problem v1",
"type": "object",
"additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": {"enum": ["invalid_event", "unsupported_schema_version", "idempotency_conflict", "evidence_not_found", "evidence_expired"]},
"message": {"type": "string", "minLength": 1, "maxLength": 512},
"field": {"type": "string", "pattern": "^[A-Za-z0-9_.\\[\\]-]{1,128}$"},
"existing_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}
}
}
+30
View File
@@ -0,0 +1,30 @@
# Evidence reference contract v1
This contract shares metadata about a logical evidence object. It never grants object access. `owner_id` identifies the service that owns resolution; `evidence_id` is opaque to every consumer. Neither field may be interpreted as a URL or local path.
## State and degradation
- `pending`: capture was accepted but no processing started.
- `processing`: capture or encoding is in progress.
- `success`: capture completed; `content_type` and SHA-256 `integrity` are required. Access authorization is negotiated outside this payload by the machine-identity/connector work.
- `failed`: `failure.code` and `retryable` are required. Bell keeps the immutable Event and renders evidence unavailable; it must not reject or close the Alert because evidence failed.
- HTTP `404` means an unknown logical reference. `410` means expired evidence. Both degrade evidence only, not the Event.
The payload forbids arbitrary properties, so filesystem paths, camera credentials, bearer/user tokens, signed URLs, face templates and notification/Alert state fail schema validation. Do not add access URLs to v1. A short-lived download grant, if later required, needs a separately reviewed endpoint and security contract.
## Ownership
- Brain may request evidence but maps only logical metadata it actually knows.
- Sense is the default evidence owner and advances the status monotonically for a given capture attempt: `pending -> processing -> success|failed`. It must retain the same `evidence_id` while status changes.
- A relay transports the reference unchanged and must not resolve it into a path or URL.
- Bell stores the latest evidence metadata separately from its immutable Event. Evidence failure/expiry never changes Alert ack/close state.
## Compatibility and rollback
v1 consumers ignore no unknown fields because the v1 schema is closed. Additive fields therefore require a new schema revision that producers enable only after consumers accept it. Changed meaning, removed fields, or new required fields require `/v2`. Rollback disables the new producer and continues resolving stored v1 references; it never deletes Event, Receipt, Outbox, or evidence audit facts.
Run the standalone contract check from the repository root:
```powershell
python contracts/tests/evidence-v1/test_contract.py
```
@@ -0,0 +1,60 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/evidence/v1/evidence-reference.schema.json",
"title": "YoVision evidence logical reference v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"evidence_id",
"owner_id",
"type",
"status",
"captured_at",
"status_updated_at"
],
"properties": {
"schema_version": {"const": "yovision.evidence-reference/v1"},
"evidence_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"owner_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"type": {"enum": ["snapshot", "clip"]},
"status": {"enum": ["pending", "processing", "success", "failed"]},
"captured_at": {"type": "string", "format": "date-time"},
"status_updated_at": {"type": "string", "format": "date-time"},
"expires_at": {"type": "string", "format": "date-time"},
"content_type": {"enum": ["image/jpeg", "image/png", "video/mp4"]},
"integrity": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "digest", "size_bytes"],
"properties": {
"algorithm": {"const": "sha256"},
"digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
"size_bytes": {"type": "integer", "minimum": 0}
}
},
"failure": {
"type": "object",
"additionalProperties": false,
"required": ["code", "retryable"],
"properties": {
"code": {"enum": ["capture_failed", "processing_failed", "expired", "unavailable"]},
"retryable": {"type": "boolean"}
}
}
},
"allOf": [
{
"if": {"properties": {"status": {"const": "success"}}, "required": ["status"]},
"then": {"required": ["content_type", "integrity"], "not": {"required": ["failure"]}}
},
{
"if": {"properties": {"status": {"const": "failed"}}, "required": ["status"]},
"then": {"required": ["failure"], "not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}]}}
},
{
"if": {"properties": {"status": {"enum": ["pending", "processing"]}}, "required": ["status"]},
"then": {"not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}, {"required": ["failure"]}]}}
}
]
}
@@ -0,0 +1,10 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0002",
"owner_id": "sense-school-a",
"type": "clip",
"status": "failed",
"captured_at": "2026-08-31T00:03:10.000Z",
"status_updated_at": "2026-08-31T00:03:13.100Z",
"failure": {"code": "processing_failed", "retryable": true}
}
@@ -0,0 +1,9 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "pending",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:01.125Z"
}
@@ -0,0 +1,16 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "success",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:02.450Z",
"expires_at": "2026-09-07T00:00:01.125Z",
"content_type": "image/jpeg",
"integrity": {
"algorithm": "sha256",
"digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75",
"size_bytes": 48215
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"openapi": "3.1.0",
"info": {"title": "YoVision evidence reference API", "version": "1.0.0"},
"paths": {
"/v1/evidence/{evidence_id}": {
"get": {
"summary": "Resolve current metadata for a logical evidence reference",
"parameters": [
{"name": "evidence_id", "in": "path", "required": true, "schema": {"type": "string"}}
],
"responses": {
"200": {"description": "Current metadata, including pending, processing, success or failed states", "content": {"application/json": {"schema": {"$ref": "./evidence-reference.schema.json"}}}},
"404": {"description": "Unknown logical reference", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}},
"410": {"description": "Evidence expired; event remains valid", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}}
}
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
# Machine identity contract v1
`yovision.machine-identity/v1` defines service-to-service identity for YoVision connectors. It is deliberately separate from Sense and Bell users, GoAdmin JWT/Cookie state, database roles and operating-system accounts.
## Authentication mechanism
Every request uses HTTPS and one compact Ed25519 JWS in `Authorization: Bearer <token>`. The protected header is closed and contains `alg=EdDSA`, `typ=YOVISION-MACHINE+JWT`, `kid` and `ver=yovision.machine-identity/v1`. The closed claims object contains:
mTLS is not the primary v1 identity mechanism. A customer PKI may add mTLS later as transport hardening, but it cannot replace or weaken the v1 principal, audience, scope, request binding, replay and revocation checks.
- one instance-specific `iss`/`sub` principal;
- one exact service `aud`;
- the minimum required `scope` values;
- `iat`, `nbf`, `exp` and a single-use random `jti`;
- uppercase HTTP method `htm`, normalized absolute-path reference `htu`, and lowercase SHA-256 `body_sha256`.
Tokens live for at most 300 seconds. Consumers allow at most 30 seconds of clock skew, verify the signature and active key/principal before authorization, then atomically consume `jti` until `exp + skew`. Retrying transport creates a new token and `jti`; business idempotency keys remain unchanged.
Production consumers persist the replay key `(principal, jti)` in their own durable store so a process restart cannot reopen the replay window. The checked-in process-local replay stores are adapter test/default primitives only; connector tasks must inject an atomic durable implementation and test restart behavior without sharing a database across products.
The v1 scopes are:
| Caller | Audience | Scope |
|---|---|---|
| Sense | `yovision-brain` | `source-config:write` |
| Brain | `yovision-sense` | `runtime-status:write` |
| Brain or Sense | `yovision-bell` | `events:ingest` |
| Bell | `yovision-sense` | `evidence:read` |
No wildcard audience or scope exists. A relay authenticates as its own transport principal and never replaces the original event producer identity.
## Key lifecycle
Private Ed25519 keys are generated per product instance and stored outside the repository in an OS-protected file or secret store. Runtime configuration contains only the private-key path. Public registries are local consumer configuration, not a shared database.
Rotation first registers a new `kid`, switches the caller, and removes the old key after an overlap no longer than 24 hours. A disabled principal or revoked `kid` is rejected on every request, including tokens that have not expired. Emergency rollback disables the connector; it never enables a shared password, browser token, query token, plaintext transport or signature bypass.
## Threat boundary
v1 protects against token modification, wrong audience/scope, expired or premature tokens, captured-token replay, key/principal revocation and accidental credential mixing. It does not protect a host after administrator/root compromise, a stolen usable private key before revocation, compromised TLS trust roots, endpoint implementation flaws or denial of service. Rate and body-size limits remain consumer responsibilities.
See `../transport/v1/README.md` for HTTPS and request policy. Stable failures are defined in `errors.md`; callers and logs must expose only the stable code, principal/kid when already authenticated, and correlation ID—never the token, signature, private/public key material or complete Authorization header.
## Compatibility
v1 is closed. New optional claims require all consumers to accept them before producers emit them. Any change to signing input, algorithm, claim meaning, replay semantics, maximum lifetime, audience or scope meaning publishes a new major version. Consumers keep the last accepted major during a controlled migration; rollback disables the new producer version without weakening verification.
## Reproducible verification
From the repository root:
```powershell
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
cd Sense/server
go test -race ./app/sense/integration/machine_identity
cd ../../Bell/server
go test -race ./app/bell/integration/machine_identity
```
The isolated contract test validates both JSON Schemas, the fixed Go/Python Ed25519 vector, request binding, exact audience/scope, expiry, replay, rotation overlap, revocation, bearer-only extraction and verified TLS policy. Product connector tasks remain responsible for injecting a durable replay store and testing restart recovery.
+14
View File
@@ -0,0 +1,14 @@
# Machine identity v1 stable errors
| Code | Meaning | Retry |
|---|---|---|
| `machine_token_missing` | Authorization bearer token is absent or malformed | No, fix request |
| `machine_token_invalid` | Header, claims, signature, request binding or key is invalid | No |
| `machine_token_expired` | Token is outside its accepted time window | Mint a new token |
| `machine_audience_denied` | Exact audience does not match | No |
| `machine_scope_denied` | Required scope is absent or not granted to the key | No |
| `machine_identity_revoked` | Principal or key is disabled/revoked | No; operator action |
| `machine_token_replayed` | The same principal/jti was already accepted | Retry with a new token and the same business idempotency key |
| `machine_transport_required` | HTTPS policy is not satisfied | No; fix deployment |
Responses and audit facts never include the token, signature, key material or Authorization header.
@@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/machine-identity/v1/machine-token.schema.json",
"title": "YoVision machine token claims v1",
"type": "object",
"additionalProperties": false,
"required": ["ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"],
"properties": {
"ver": {"const": "yovision.machine-identity/v1"},
"iss": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
"sub": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
"aud": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
"scope": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"uniqueItems": true,
"items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}
},
"iat": {"type": "integer", "minimum": 0},
"nbf": {"type": "integer", "minimum": 0},
"exp": {"type": "integer", "minimum": 0},
"jti": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22,64}$"},
"htm": {"type": "string", "pattern": "^(GET|POST|PUT|PATCH|DELETE)$"},
"htu": {"type": "string", "pattern": "^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$"},
"body_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
}
}
@@ -0,0 +1,38 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/machine-identity/v1/principal-registry.schema.json",
"title": "YoVision machine principal registry v1",
"type": "object",
"additionalProperties": false,
"required": ["version", "audience", "principals"],
"properties": {
"version": {"const": "yovision.machine-principal-registry/v1"},
"audience": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
"principals": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["principal_id", "enabled", "keys"],
"properties": {
"principal_id": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
"enabled": {"type": "boolean"},
"keys": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["kid", "public_key_base64url", "status", "scopes"],
"properties": {
"kid": {"type": "string", "pattern": "^[A-Za-z0-9._-]{8,64}$"},
"public_key_base64url": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"},
"status": {"enum": ["active", "revoked"]},
"scopes": {"type": "array", "minItems": 1, "maxItems": 4, "uniqueItems": true, "items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}}
}
}
}
}
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
# Sense → Brain 媒体源与区域规则配置契约 v1
`yovision.source-config/v1` 是 Sense 发布、Brain 消费的完整配置快照。它只携带稳定逻辑标识、无凭据媒体引用、Profile 规格、归一化规则及完整性摘要,不暴露 Sense 数据库模型或 Brain 内部配置模型。
本版本选择 JSON Schema,而不是 OpenAPI:快照可经文件、消息或后续 connector 传输,工单 #148 不定义 HTTP 端点。后续 connector 若提供 HTTP API,应引用本 Schema,不复制字段定义。
## 文件
- `source-config.schema.json`:Draft 2020-12 JSON Schema。
- `examples/valid/`:可接受的 active 与待重校准快照。
- `examples/invalid/`:必须安全拒绝的版本、秘密、路径、坐标和绑定错误。
- `compatibility.md`:版本、兼容周期、迁移和回退规则。
- `mapper-fields.md`:Sense 生产者与 Brain 消费者字段映射和测试责任。
## 消费规则
1. 先按 JSON Schema 校验,再执行跨字段语义校验。
2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。
3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。
4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。
5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。
6. `effective_at` 不得早于 `published_at`。
7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。
`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。
## 可复制验证
从仓库根目录运行:
```powershell
& contracts\tests\source-config-v1\run.ps1
```
脚本在系统临时目录创建隔离虚拟环境、安装固定版本的 Schema 校验器并运行测试,不修改产品目录。测试结束后会清理临时环境。
@@ -0,0 +1,29 @@
# v1 兼容、迁移与回退
## 兼容规则
- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。
- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。
- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。
- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。
## revision 与生效
- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。
- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。
- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。
- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。
## 支持周期
- 发布新主版本后,Sense 生产者与 Brain 消费者至少并行支持上一主版本一个正式发布周期,且不少于 90 天;具体停止日期必须在新版本协调工单中冻结。
- 并行期内生产者按目标消费者能力选择版本,不得把两个主版本字段混在同一快照。
## 回退
1. 停止分发有问题的新主版本或新 revision。
2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。
3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。
4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
样例 `examples/valid/recalibration-required.json` 展示 Profile 变化后的安全停用状态。回退不修改已发布 v1 字段语义,也不要求读取 Sense 数据库。
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,8 @@
{
"coordinate-out-of-range.json": "schema",
"credential-field.json": "secret",
"internal-path.json": "internal path",
"profile-binding-mismatch.json": "profile binding",
"query-token.json": "secret",
"unknown-major-version.json": "unknown schema"
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v2",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,62 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {
"id": "site-east"
},
"logical_device": {
"id": "entry-camera-01"
},
"profile": {
"id": "main-stream",
"width": 1920,
"height": 1080,
"encoding": "H264",
"frame_rate": 25
},
"media": {
"ref": "media:site-east/entry-01/main",
"transport": "rtsp"
},
"rule_set": {
"version": "entry-rules-7",
"state": "active",
"profile_binding": {
"profile_id": "main-stream",
"width": 1920,
"height": 1080
},
"areas": [
{
"id": "danger-yard",
"version": 3,
"kind": "danger_area",
"enabled": true,
"points": [
{"x": 0.12, "y": 0.18},
{"x": 0.82, "y": 0.18},
{"x": 0.76, "y": 0.78},
{"x": 0.18, "y": 0.72}
]
}
],
"directional_lines": [
{
"id": "entry-line",
"version": 2,
"kind": "directional_line",
"enabled": true,
"start": {"x": 0.2, "y": 0.5},
"end": {"x": 0.8, "y": 0.5},
"trigger_direction": "left_to_right"
}
]
},
"integrity": {
"algorithm": "sha256",
"value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe"
}
}
@@ -0,0 +1,39 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 8,
"published_at": "2026-08-31T01:00:00Z",
"effective_at": "2026-08-31T01:00:00Z",
"site": {
"id": "site-east"
},
"logical_device": {
"id": "entry-camera-01"
},
"profile": {
"id": "main-stream-v2",
"width": 1280,
"height": 720,
"encoding": "H265",
"frame_rate": 20
},
"media": {
"ref": "media:site-east/entry-01/main-v2",
"transport": "rtsp"
},
"rule_set": {
"version": "entry-rules-8",
"state": "recalibration_required",
"profile_binding": {
"profile_id": "main-stream-v2",
"width": 1280,
"height": 720
},
"areas": [],
"directional_lines": []
},
"integrity": {
"algorithm": "sha256",
"value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba"
}
}
@@ -0,0 +1,35 @@
# 生产者与消费者 mapper 字段表
mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,也不得让 Brain 把共享快照当作 `brain.internal.input/v1`。
| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 |
|---|---|---|
| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 |
| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID |
| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 |
| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 |
| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 |
| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` |
| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` |
| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 |
| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` |
| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 |
| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 |
| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` |
| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 |
| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 |
| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 |
| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 |
| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 |
| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` |
| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 |
| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 |
| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision |
## 测试责任
- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。
- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。
- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。
Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
@@ -0,0 +1,273 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/source-config/v1/source-config.schema.json",
"title": "YoVision Sense to Brain source configuration snapshot v1",
"description": "Credential-free media source, profile binding, and normalized rule configuration published by Sense for Brain.",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"config_id",
"revision",
"published_at",
"effective_at",
"site",
"logical_device",
"profile",
"media",
"rule_set",
"integrity"
],
"properties": {
"schema_version": {
"const": "yovision.source-config/v1"
},
"config_id": {
"$ref": "#/$defs/stable_id"
},
"revision": {
"type": "integer",
"minimum": 1
},
"published_at": {
"type": "string",
"format": "date-time"
},
"effective_at": {
"type": "string",
"format": "date-time"
},
"site": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
}
}
},
"logical_device": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
}
}
},
"profile": {
"$ref": "#/$defs/profile"
},
"media": {
"type": "object",
"additionalProperties": false,
"required": ["ref", "transport"],
"properties": {
"ref": {
"type": "string",
"pattern": "^media:[A-Za-z0-9][A-Za-z0-9._~/-]{0,254}$",
"description": "Opaque credential-free reference resolved by the connector. URI authority, userinfo, query strings, and fragments are forbidden."
},
"transport": {
"enum": ["rtsp"]
}
}
},
"rule_set": {
"type": "object",
"additionalProperties": false,
"required": [
"version",
"state",
"profile_binding",
"areas",
"directional_lines"
],
"properties": {
"version": {
"$ref": "#/$defs/stable_id"
},
"state": {
"enum": ["active", "disabled", "recalibration_required"]
},
"profile_binding": {
"$ref": "#/$defs/profile_binding"
},
"areas": {
"type": "array",
"items": {
"$ref": "#/$defs/area_rule"
},
"maxItems": 1024
},
"directional_lines": {
"type": "array",
"items": {
"$ref": "#/$defs/directional_line_rule"
},
"maxItems": 1024
}
}
},
"integrity": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "value"],
"properties": {
"algorithm": {
"const": "sha256"
},
"value": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
},
"extensions": {
"type": "object",
"description": "Optional namespaced, non-secret extension data. Consumers ignore unknown namespaces.",
"propertyNames": {
"pattern": "^[A-Za-z][A-Za-z0-9.-]{0,127}$"
},
"additionalProperties": {
"type": "object"
}
}
},
"$defs": {
"stable_id": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
},
"positive_integer": {
"type": "integer",
"minimum": 1
},
"positive_number": {
"type": "number",
"exclusiveMinimum": 0
},
"profile": {
"type": "object",
"additionalProperties": false,
"required": ["id", "width", "height", "encoding", "frame_rate"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"width": {
"$ref": "#/$defs/positive_integer"
},
"height": {
"$ref": "#/$defs/positive_integer"
},
"encoding": {
"enum": ["H264", "H265", "MJPEG"]
},
"frame_rate": {
"$ref": "#/$defs/positive_number"
}
}
},
"profile_binding": {
"type": "object",
"additionalProperties": false,
"required": ["profile_id", "width", "height"],
"properties": {
"profile_id": {
"$ref": "#/$defs/stable_id"
},
"width": {
"$ref": "#/$defs/positive_integer"
},
"height": {
"$ref": "#/$defs/positive_integer"
}
}
},
"point": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y"],
"properties": {
"x": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"y": {
"type": "number",
"minimum": 0,
"maximum": 1
}
}
},
"area_rule": {
"type": "object",
"additionalProperties": false,
"required": ["id", "version", "kind", "enabled", "points"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"version": {
"$ref": "#/$defs/positive_integer"
},
"kind": {
"const": "danger_area"
},
"enabled": {
"type": "boolean"
},
"points": {
"type": "array",
"items": {
"$ref": "#/$defs/point"
},
"minItems": 3,
"maxItems": 256
}
}
},
"directional_line_rule": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"kind",
"enabled",
"start",
"end",
"trigger_direction"
],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"version": {
"$ref": "#/$defs/positive_integer"
},
"kind": {
"const": "directional_line"
},
"enabled": {
"type": "boolean"
},
"start": {
"$ref": "#/$defs/point"
},
"end": {
"$ref": "#/$defs/point"
},
"trigger_direction": {
"enum": ["left_to_right", "right_to_left"]
}
}
}
}
}
@@ -0,0 +1,91 @@
"""Small dependency-free validator for the JSON Schema keywords used by v1 contracts."""
from __future__ import annotations
import json
import math
import re
from datetime import datetime
from pathlib import Path
from typing import Any
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def canonical_bytes(value: Any) -> bytes:
"""Canonical bytes for checked-in JCS vectors (all vector numbers are JCS-safe)."""
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
def validate(instance: Any, schema: dict[str, Any], schema_path: Path, location: str = "$") -> list[str]:
if "$ref" in schema:
ref = schema["$ref"]
if ref.startswith("#"):
return [f"{location}: local fragments are not supported by the contract checker"]
target = (schema_path.parent / ref).resolve()
return validate(instance, load_json(target), target, location)
errors: list[str] = []
for subschema in schema.get("allOf", []):
errors.extend(validate(instance, subschema, schema_path, location))
if "anyOf" in schema and not any(not validate(instance, item, schema_path, location) for item in schema["anyOf"]):
errors.append(f"{location}: does not match anyOf")
if "not" in schema and not validate(instance, schema["not"], schema_path, location):
errors.append(f"{location}: matches forbidden schema")
if "if" in schema and not validate(instance, schema["if"], schema_path, location):
errors.extend(validate(instance, schema.get("then", {}), schema_path, location))
expected = schema.get("type")
type_ok = {
"object": lambda x: isinstance(x, dict),
"array": lambda x: isinstance(x, list),
"string": lambda x: isinstance(x, str),
"integer": lambda x: isinstance(x, int) and not isinstance(x, bool),
"number": lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x),
"boolean": lambda x: isinstance(x, bool),
}
if expected and (expected not in type_ok or not type_ok[expected](instance)):
return errors + [f"{location}: expected {expected}"]
if "const" in schema and instance != schema["const"]:
errors.append(f"{location}: expected constant {schema['const']!r}")
if "enum" in schema and instance not in schema["enum"]:
errors.append(f"{location}: value not in enum")
if isinstance(instance, dict):
required = schema.get("required", [])
errors.extend(f"{location}: missing {name}" for name in required if name not in instance)
properties = schema.get("properties", {})
if schema.get("additionalProperties") is False:
errors.extend(f"{location}: unknown property {name}" for name in instance if name not in properties)
for name, value in instance.items():
if name in properties:
errors.extend(validate(value, properties[name], schema_path, f"{location}.{name}"))
elif isinstance(instance, list):
if len(instance) < schema.get("minItems", 0):
errors.append(f"{location}: too few items")
if "maxItems" in schema and len(instance) > schema["maxItems"]:
errors.append(f"{location}: too many items")
if schema.get("uniqueItems") and len({canonical_bytes(item) for item in instance}) != len(instance):
errors.append(f"{location}: duplicate items")
for index, value in enumerate(instance):
errors.extend(validate(value, schema.get("items", {}), schema_path, f"{location}[{index}]"))
elif isinstance(instance, str):
if len(instance) < schema.get("minLength", 0):
errors.append(f"{location}: string too short")
if "maxLength" in schema and len(instance) > schema["maxLength"]:
errors.append(f"{location}: string too long")
if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None:
errors.append(f"{location}: pattern mismatch")
if schema.get("format") == "date-time":
try:
datetime.fromisoformat(instance.replace("Z", "+00:00"))
except ValueError:
errors.append(f"{location}: invalid date-time")
elif isinstance(instance, (int, float)) and not isinstance(instance, bool):
if "minimum" in schema and instance < schema["minimum"]:
errors.append(f"{location}: below minimum")
if "maximum" in schema and instance > schema["maximum"]:
errors.append(f"{location}: above maximum")
return errors
@@ -0,0 +1,17 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-sensitive-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "face_feature": "forbidden"},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": [],
"camera_password": "forbidden"
}
@@ -0,0 +1,16 @@
{
"schema_version": "yovision.event/v2",
"producer_id": "brain-school-a",
"source_event_id": "evt-unknown-version-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": []
}
@@ -0,0 +1,13 @@
{
"algorithm": "RFC8785-JCS+SHA-256",
"vectors": [
{
"name": "dangerous-area-original-and-reordered-duplicate",
"fixture": "../../events/v1/examples/dangerous-area.json",
"idempotency_key": ["brain-school-a", "evt-area-20260831-0001"],
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1",
"conflict_patch": {"severity": "critical"},
"conflict_payload_sha256": "7076771f7827d97ef45831ae221046b8cb347f152dd222c2edd6f14a58e173b2"
}
]
}
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import copy
import hashlib
import sys
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
CONTRACTS = HERE.parents[1]
sys.path.insert(0, str(HERE))
from contract_support import canonical_bytes, load_json, validate # noqa: E402
class EventV1ContractTests(unittest.TestCase):
schema_path = CONTRACTS / "events" / "v1" / "event.schema.json"
schema = load_json(schema_path)
def assert_valid(self, payload: object) -> None:
self.assertEqual([], validate(payload, self.schema, self.schema_path))
def test_anonymous_area_and_line_examples_are_valid(self) -> None:
for name in ("dangerous-area.json", "directional-line-crossed.json"):
with self.subTest(name=name):
self.assert_valid(load_json(CONTRACTS / "events" / "v1" / "examples" / name))
def test_idempotency_vector_duplicate_and_conflict(self) -> None:
vectors = load_json(HERE / "idempotency-vectors.json")["vectors"]
for vector in vectors:
payload = load_json((HERE / vector["fixture"]).resolve())
self.assertEqual(vector["idempotency_key"], [payload["producer_id"], payload["source_event_id"]])
digest = hashlib.sha256(canonical_bytes(payload)).hexdigest()
self.assertEqual(vector["payload_sha256"], digest)
reordered = dict(reversed(list(payload.items())))
self.assertEqual(digest, hashlib.sha256(canonical_bytes(reordered)).hexdigest())
conflict = copy.deepcopy(payload)
conflict.update(vector["conflict_patch"])
conflict_digest = hashlib.sha256(canonical_bytes(conflict)).hexdigest()
self.assertEqual(vector["conflict_payload_sha256"], conflict_digest)
self.assertNotEqual(digest, conflict_digest)
def test_unknown_version_is_rejected(self) -> None:
payload = load_json(HERE / "fixtures" / "unknown-version.json")
self.assertTrue(validate(payload, self.schema, self.schema_path))
def test_brain_producer_sense_relay_and_bell_consumer_fixture(self) -> None:
produced = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
self.assert_valid(produced)
relayed = copy.deepcopy(produced)
self.assertEqual(
(produced["producer_id"], produced["source_event_id"]),
(relayed["producer_id"], relayed["source_event_id"]),
)
self.assertEqual(canonical_bytes(produced), canonical_bytes(relayed))
bell_allowed = set(self.schema["properties"])
self.assertEqual(set(produced), bell_allowed)
self.assertNotIn("alert", produced)
self.assertNotIn("receipt", produced)
def test_sensitive_and_internal_fields_are_rejected(self) -> None:
payload = load_json(HERE / "fixtures" / "sensitive-field.json")
errors = validate(payload, self.schema, self.schema_path)
self.assertTrue(any("camera_password" in error for error in errors))
self.assertTrue(any("face_feature" in error for error in errors))
base = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
for forbidden, value in {
"user_token": "forbidden", "ack_state": "acked", "local_path": "C:/forbidden",
"signed_url": "https://forbidden.invalid/object?signature=forbidden"
}.items():
with self.subTest(forbidden=forbidden):
candidate = copy.deepcopy(base)
candidate[forbidden] = value
self.assertTrue(validate(candidate, self.schema, self.schema_path))
def test_openapi_references_exist_and_responses_are_explicit(self) -> None:
path = CONTRACTS / "events" / "v1" / "openapi.json"
spec = load_json(path)
operation = spec["paths"]["/v1/events"]["post"]
self.assertEqual({"200", "201", "400", "409", "422"}, set(operation["responses"]))
refs: list[str] = []
def collect(value: object) -> None:
if isinstance(value, dict):
refs.extend(item for key, item in value.items() if key == "$ref")
for item in value.values(): collect(item)
elif isinstance(value, list):
for item in value: collect(item)
collect(spec)
self.assertTrue(refs)
for ref in refs:
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
def test_duplicate_conflict_and_unknown_version_response_examples(self) -> None:
directory = CONTRACTS / "events" / "v1"
cases = (
("duplicate-result.json", "ingest-result.schema.json"),
("idempotency-conflict-problem.json", "problem.schema.json"),
("unsupported-version-problem.json", "problem.schema.json"),
)
for fixture_name, schema_name in cases:
with self.subTest(fixture=fixture_name):
schema_path = directory / schema_name
errors = validate(load_json(directory / "examples" / fixture_name), load_json(schema_path), schema_path)
self.assertEqual([], errors)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,12 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-sensitive-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "success",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:02.450Z",
"content_type": "image/jpeg",
"integrity": {"algorithm": "sha256", "digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75", "size_bytes": 48215},
"local_path": "forbidden"
}
@@ -0,0 +1,88 @@
from __future__ import annotations
import copy
import sys
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
CONTRACTS = HERE.parents[1]
EVENT_SUPPORT = HERE.parent / "events-v1"
sys.path.insert(0, str(EVENT_SUPPORT))
from contract_support import load_json, validate # noqa: E402
class EvidenceV1ContractTests(unittest.TestCase):
schema_path = CONTRACTS / "evidence" / "v1" / "evidence-reference.schema.json"
schema = load_json(schema_path)
def errors_for(self, payload: object) -> list[str]:
return validate(payload, self.schema, self.schema_path)
def test_pending_success_and_failed_examples_are_valid(self) -> None:
for name in ("pending.json", "success.json", "failed.json"):
with self.subTest(name=name):
payload = load_json(CONTRACTS / "evidence" / "v1" / "examples" / name)
self.assertEqual([], self.errors_for(payload))
def test_state_specific_metadata_is_enforced(self) -> None:
success = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "success.json")
for required in ("content_type", "integrity"):
with self.subTest(success_requires=required):
candidate = copy.deepcopy(success)
del candidate[required]
self.assertTrue(self.errors_for(candidate))
legacy_available = copy.deepcopy(success)
legacy_available["status"] = "available"
self.assertTrue(self.errors_for(legacy_available))
failed = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "failed.json")
del failed["failure"]
self.assertTrue(self.errors_for(failed))
pending = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
pending["content_type"] = "image/jpeg"
self.assertTrue(self.errors_for(pending))
def test_sensitive_access_material_and_unknown_version_are_rejected(self) -> None:
fixture = load_json(HERE / "fixtures" / "sensitive-field.json")
self.assertTrue(any("local_path" in error for error in self.errors_for(fixture)))
base = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
for forbidden, value in {
"camera_password": "forbidden",
"user_token": "forbidden",
"signed_url": "https://forbidden.invalid/object?signature=forbidden",
"face_feature": "forbidden",
"alert_state": "acked"
}.items():
with self.subTest(forbidden=forbidden):
candidate = copy.deepcopy(base)
candidate[forbidden] = value
self.assertTrue(self.errors_for(candidate))
unknown = copy.deepcopy(base)
unknown["schema_version"] = "yovision.evidence-reference/v2"
self.assertTrue(self.errors_for(unknown))
def test_openapi_refs_and_degradation_responses(self) -> None:
path = CONTRACTS / "evidence" / "v1" / "openapi.json"
spec = load_json(path)
operation = spec["paths"]["/v1/evidence/{evidence_id}"]["get"]
self.assertEqual({"200", "404", "410"}, set(operation["responses"]))
refs: list[str] = []
def collect(value: object) -> None:
if isinstance(value, dict):
refs.extend(item for key, item in value.items() if key == "$ref")
for item in value.values(): collect(item)
elif isinstance(value, list):
for item in value: collect(item)
collect(spec)
for ref in refs:
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,11 @@
{
"version": "yovision.machine-token-vector/v1",
"public_key_base64url": "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ",
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImJyYWluLXZlY3Rvci0wMDAxIiwidHlwIjoiWU9WSVNJT04tTUFDSElORStKV1QiLCJ2ZXIiOiJ5b3Zpc2lvbi5tYWNoaW5lLWlkZW50aXR5L3YxIn0.eyJhdWQiOiJ5b3Zpc2lvbi1zZW5zZSIsImJvZHlfc2hhMjU2IjoiNDA5NDQzYTZlZTVhYTI5NmRjY2Q2YzBkMTkzZTIxNDU2OGRhYTAwNTNiNjYxNTVmYmE4YWRjYTk5NWI3ODIzZCIsImV4cCI6MTgwMDAwMDMwMCwiaHRtIjoiUE9TVCIsImh0dSI6Ii9tYWNoaW5lL3YxL3J1bnRpbWUtc3RhdHVzIiwiaWF0IjoxODAwMDAwMDAwLCJpc3MiOiJ5djpicmFpbjp2ZWN0b3IiLCJqdGkiOiJBUUlEQkFVR0J3Z0pDZ3NNRFE0UEVBIiwibmJmIjoxODAwMDAwMDAwLCJzY29wZSI6WyJydW50aW1lLXN0YXR1czp3cml0ZSJdLCJzdWIiOiJ5djpicmFpbjp2ZWN0b3IiLCJ2ZXIiOiJ5b3Zpc2lvbi5tYWNoaW5lLWlkZW50aXR5L3YxIn0.zrvo_7lRiDX1D6po6OlfQg4hDg6XXlyUEmNMYgpuRl3ArXSjvGuOLivDousIyLtRO4bYJu9xMAWX1cea7MdVBQ",
"now": 1800000000,
"audience": "yovision-sense",
"required_scope": "runtime-status:write",
"method": "POST",
"path": "/machine/v1/runtime-status",
"body_base64": "eyJzdGF0dXMiOiJydW5uaW5nIn0="
}
@@ -0,0 +1,2 @@
cryptography==50.0.1
jsonschema==4.25.1
@@ -0,0 +1,33 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$testDirectory = $PSScriptRoot
$requirements = Join-Path $testDirectory 'requirements.txt'
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$workDirectory = Join-Path $tempRoot ("yovision-machine-identity-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
try {
New-Item -ItemType Directory -Path $workDirectory | Out-Null
$virtualEnvironment = Join-Path $workDirectory '.venv'
python -m venv $virtualEnvironment
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
$env:PYTHONDONTWRITEBYTECODE = '1'
& $python -m pip install --quiet --requirement $requirements
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned machine-identity test dependencies.' }
& $python $testDirectory\test_contract.py
if ($LASTEXITCODE -ne 0) { throw 'Machine-identity v1 contract tests failed.' }
}
finally {
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove a temporary directory outside $tempRoot"
}
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
}
}
@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import base64
import ssl
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "Brain" / "src"))
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from jsonschema import Draft202012Validator
from yovision_brain.integration.machine_identity import (
KeyRecord,
MachineIdentityError,
Registry,
ReplayStore,
Signer,
TransportPolicy,
Verifier,
load_registry,
bearer_token,
)
class ContractFilesTest(unittest.TestCase):
def test_closed_claim_and_registry_schemas(self) -> None:
claims = json.loads((ROOT / "contracts/machine-identity/v1/machine-token.schema.json").read_text(encoding="utf-8"))
registry = json.loads((ROOT / "contracts/machine-identity/v1/principal-registry.schema.json").read_text(encoding="utf-8"))
transport = json.loads((ROOT / "contracts/transport/v1/transport-policy.schema.json").read_text(encoding="utf-8"))
Draft202012Validator.check_schema(claims)
Draft202012Validator.check_schema(registry)
Draft202012Validator.check_schema(transport)
self.assertFalse(claims["additionalProperties"])
self.assertEqual(claims["properties"]["ver"]["const"], "yovision.machine-identity/v1")
self.assertEqual(claims["properties"]["scope"]["items"]["enum"], [
"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"
])
self.assertFalse(registry["additionalProperties"])
self.assertEqual(transport["properties"]["verify_certificate"]["const"], True)
self.assertEqual(transport["properties"]["verify_hostname"]["const"], True)
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
claims_object = json.loads(base64.urlsafe_b64decode(vector["token"].split(".")[1] + "=="))
Draft202012Validator(claims).validate(claims_object)
Draft202012Validator(transport).validate({
"version": "yovision.transport/v1", "tls_min_version": "1.2", "verify_certificate": True,
"verify_hostname": True, "connect_timeout_ms": 1000, "response_header_timeout_ms": 1000,
"request_timeout_ms": 5000, "max_request_bytes": 1048576,
})
def test_brain_dependency_is_frozen(self) -> None:
pyproject = (ROOT / "Brain/pyproject.toml").read_text(encoding="utf-8")
self.assertIn('dependencies = ["cryptography==50.0.1"]', pyproject)
def test_contract_documents_fail_closed(self) -> None:
identity = (ROOT / "contracts/machine-identity/v1/README.md").read_text(encoding="utf-8")
transport = (ROOT / "contracts/transport/v1/README.md").read_text(encoding="utf-8")
for required in ("300 seconds", "30 seconds", "24 hours", "jti", "revoked", "browser token"):
self.assertIn(required, identity)
for required in ("TLS 1.2", "hostname verification", "query-string credentials", "disables the connector"):
self.assertIn(required, transport)
def test_python_verifies_cross_language_vector(self) -> None:
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
raw_key = base64.urlsafe_b64decode(vector["public_key_base64url"] + "=")
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
registry = Registry([KeyRecord("yv:brain:vector", "brain-vector-0001", Ed25519PublicKey.from_public_bytes(raw_key), vector["audience"], frozenset({vector["required_scope"]}))])
verifier = Verifier(registry, ReplayStore(), clock=lambda: vector["now"])
claims = verifier.verify(vector["token"], vector["audience"], vector["required_scope"], vector["method"], vector["path"], base64.b64decode(vector["body_base64"]))
self.assertEqual(claims.iss, "yv:brain:vector")
class BrainAdapterTest(unittest.TestCase):
def setUp(self) -> None:
self.now = 1_800_000_000
self.private = Ed25519PrivateKey.generate()
self.record = KeyRecord(
principal="yv:brain:node-a",
key_id="brain-key-0001",
public_key=self.private.public_key(),
audience="yovision-sense",
scopes=frozenset({"runtime-status:write"}),
)
self.registry = Registry([self.record])
self.signer = Signer("yv:brain:node-a", "brain-key-0001", self.private, clock=lambda: self.now)
def mint(self, body: bytes = b"{}") -> str:
return self.signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", body)
def verify(self, token: str, body: bytes = b"{}", **changes: str):
verifier = Verifier(self.registry, changes.pop("replay", ReplayStore()), clock=lambda: int(changes.pop("now", self.now)))
return verifier.verify(
token,
changes.pop("audience", "yovision-sense"),
changes.pop("scope", "runtime-status:write"),
changes.pop("method", "POST"),
changes.pop("path", "/machine/v1/runtime-status"),
body,
)
def assert_code(self, code: str, callback) -> None:
with self.assertRaises(MachineIdentityError) as caught:
callback()
self.assertEqual(caught.exception.code, code)
self.assertEqual(str(caught.exception), code)
def test_valid_token_and_replay_rejection(self) -> None:
token = self.mint()
replay = ReplayStore()
first = Verifier(self.registry, replay, clock=lambda: self.now)
claims = first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
self.assertEqual(claims.iss, "yv:brain:node-a")
self.assert_code("machine_token_replayed", lambda: first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}"))
def test_bearer_token_has_no_cookie_or_query_fallback(self) -> None:
self.assertEqual(bearer_token("Bearer compact.token.value"), "compact.token.value")
for value in ("", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"):
self.assert_code("machine_token_missing", lambda value=value: bearer_token(value))
def test_wrong_audience_scope_body_and_expiry(self) -> None:
self.assert_code("machine_audience_denied", lambda: self.verify(self.mint(), audience="yovision-bell"))
self.assert_code("machine_scope_denied", lambda: self.verify(self.mint(), scope="events:ingest"))
self.assert_code("machine_token_invalid", lambda: self.verify(self.mint(), body=b"changed"))
self.assert_code("machine_token_expired", lambda: self.verify(self.mint(), now=str(self.now + 361)))
def test_tampering_revocation_and_rotation(self) -> None:
token = self.mint()
parts = token.split(".")
tampered = f"{parts[0]}.{parts[1][:-1]}A.{parts[2]}"
self.assert_code("machine_token_invalid", lambda: self.verify(tampered))
self.assertTrue(self.registry.revoke("brain-key-0001"))
self.assert_code("machine_identity_revoked", lambda: self.verify(self.mint()))
new_private = Ed25519PrivateKey.generate()
overlap = Registry([
self.record,
KeyRecord("yv:brain:node-a", "brain-key-0002", new_private.public_key(), "yovision-sense", frozenset({"runtime-status:write"})),
])
new_signer = Signer("yv:brain:node-a", "brain-key-0002", new_private, clock=lambda: self.now)
verifier = Verifier(overlap, ReplayStore(), clock=lambda: self.now)
verifier.verify(self.mint(), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
verifier.verify(new_signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", b"{}"), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
def test_transport_policy_requires_verified_tls(self) -> None:
policy = TransportPolicy("1.2", True, True, 1000, 1000, 2000, 1024)
context = policy.ssl_context()
self.assertGreaterEqual(context.minimum_version, ssl.TLSVersion.TLSv1_2)
self.assertTrue(context.check_hostname)
self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED)
with self.assertRaises(ValueError):
TransportPolicy("1.1", True, True, 1000, 1000, 2000, 1024).validate()
with self.assertRaises(ValueError):
TransportPolicy("1.2", True, False, 1000, 1000, 2000, 1024).validate()
def test_loads_external_public_registry_and_rejects_wrong_audience(self) -> None:
from cryptography.hazmat.primitives import serialization
raw_public = self.private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
document = {
"version": "yovision.machine-principal-registry/v1",
"audience": "yovision-sense",
"principals": [{
"principal_id": "yv:brain:node-a", "enabled": True,
"keys": [{"kid": "brain-key-0001", "public_key_base64url": base64.urlsafe_b64encode(raw_public).rstrip(b"=").decode(), "status": "active", "scopes": ["runtime-status:write"]}],
}],
}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "principals.json"
path.write_text(json.dumps(document), encoding="utf-8")
registry = load_registry(path, "yovision-sense")
self.assertIsNotNone(registry.lookup("brain-key-0001"))
with self.assertRaises(ValueError):
load_registry(path, "yovision-bell")
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,2 @@
jsonschema==4.23.0
rfc8785==0.1.4
+33
View File
@@ -0,0 +1,33 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$testDirectory = $PSScriptRoot
$requirements = Join-Path $testDirectory 'requirements.txt'
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$workDirectory = Join-Path $tempRoot ("yovision-source-config-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
try {
New-Item -ItemType Directory -Path $workDirectory | Out-Null
$virtualEnvironment = Join-Path $workDirectory '.venv'
python -m venv $virtualEnvironment
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
$env:PYTHONDONTWRITEBYTECODE = '1'
& $python -m pip install --quiet --requirement $requirements
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned contract-test dependencies.' }
& $python -m unittest discover -s $testDirectory -p 'test_*.py' -v
if ($LASTEXITCODE -ne 0) { throw 'Source-config v1 contract tests failed.' }
}
finally {
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove a temporary directory outside $tempRoot"
}
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
}
}
@@ -0,0 +1,261 @@
from __future__ import annotations
import copy
import hashlib
import json
import re
import unittest
from datetime import datetime
from pathlib import Path
from typing import Any
import rfc8785
from jsonschema import Draft202012Validator, FormatChecker
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
CONTRACT_ROOT = REPOSITORY_ROOT / "contracts" / "source-config" / "v1"
SCHEMA_PATH = CONTRACT_ROOT / "source-config.schema.json"
VALID_ROOT = CONTRACT_ROOT / "examples" / "valid"
INVALID_ROOT = CONTRACT_ROOT / "examples" / "invalid"
FORBIDDEN_KEY = re.compile(r"(?:credential|password|secret|token|username|cookie|jwt)", re.IGNORECASE)
FORBIDDEN_MEDIA_CHARACTER = re.compile(r"[?@#\\]")
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise AssertionError(f"{path} must contain a JSON object")
return value
SCHEMA = load_json(SCHEMA_PATH)
VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
def integrity_value(payload: dict[str, Any]) -> str:
content = copy.deepcopy(payload)
content.pop("integrity", None)
return hashlib.sha256(rfc8785.dumps(content)).hexdigest()
def set_integrity(payload: dict[str, Any]) -> None:
payload["integrity"] = {"algorithm": "sha256", "value": integrity_value(payload)}
def reject_secrets(value: Any, path: str = "config") -> None:
if isinstance(value, dict):
for key, child in value.items():
if FORBIDDEN_KEY.search(str(key)):
raise ValueError(f"secret field is forbidden at {path}.{key}")
reject_secrets(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
reject_secrets(child, f"{path}[{index}]")
def polygon_area(points: list[dict[str, float]]) -> float:
return abs(
sum(
point["x"] * points[(index + 1) % len(points)]["y"]
- points[(index + 1) % len(points)]["x"] * point["y"]
for index, point in enumerate(points)
)
/ 2
)
def validate_payload(payload: dict[str, Any]) -> None:
if payload.get("schema_version") != "yovision.source-config/v1":
raise ValueError("unknown schema major version")
reject_secrets(payload)
media_ref = str(payload.get("media", {}).get("ref", ""))
if (
FORBIDDEN_MEDIA_CHARACTER.search(media_ref)
or "://" in media_ref
or re.match(r"^[A-Za-z]:", media_ref)
):
raise ValueError("secret, query, authority, or internal path in media reference")
errors = sorted(VALIDATOR.iter_errors(payload), key=lambda error: list(error.absolute_path))
if errors:
first = errors[0]
location = ".".join(str(part) for part in first.absolute_path) or "config"
raise ValueError(f"schema validation failed at {location}: {first.message}")
profile = payload["profile"]
binding = payload["rule_set"]["profile_binding"]
if (binding["profile_id"], binding["width"], binding["height"]) != (
profile["id"],
profile["width"],
profile["height"],
):
raise ValueError("profile binding does not match the media profile")
published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00"))
effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00"))
if effective_at < published_at:
raise ValueError("effective_at precedes published_at")
rule_set = payload["rule_set"]
rules = [*rule_set["areas"], *rule_set["directional_lines"]]
identifiers = [rule["id"] for rule in rules]
if len(identifiers) != len(set(identifiers)):
raise ValueError("rule ids must be unique across the rule set")
if rule_set["state"] == "recalibration_required" and any(rule["enabled"] for rule in rules):
raise ValueError("recalibration-required rules must not remain enabled")
for area in rule_set["areas"]:
if polygon_area(area["points"]) <= 1e-12:
raise ValueError(f"area {area['id']} is a degenerate polygon")
for line in rule_set["directional_lines"]:
if line["start"] == line["end"]:
raise ValueError(f"directional line {line['id']} has identical endpoints")
if payload["integrity"]["value"] != integrity_value(payload):
raise ValueError("integrity digest mismatch")
def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None:
validate_payload(previous)
validate_payload(current)
if previous["config_id"] != current["config_id"]:
raise ValueError("config_id cannot change within one revision stream")
if current["revision"] <= previous["revision"]:
raise ValueError("revision must increase strictly")
previous_profile = previous["profile"]
current_profile = current["profile"]
profile_changed = any(
previous_profile[field] != current_profile[field]
for field in ("id", "width", "height", "encoding")
)
previous_rule_versions = sorted(
(rule["id"], rule["version"])
for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]]
)
current_rule_versions = sorted(
(rule["id"], rule["version"])
for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]]
)
if (
profile_changed
and previous_rule_versions == current_rule_versions
and current["rule_set"]["state"] != "recalibration_required"
):
raise ValueError("profile changed without rule recalibration state or new rule versions")
class SourceConfigV1ContractTests(unittest.TestCase):
def test_schema_is_valid_draft_2020_12(self) -> None:
Draft202012Validator.check_schema(SCHEMA)
def test_all_valid_examples_pass_schema_semantics_and_integrity(self) -> None:
examples = sorted(VALID_ROOT.glob("*.json"))
self.assertGreaterEqual(len(examples), 2)
for path in examples:
with self.subTest(path=path.name):
validate_payload(load_json(path))
def test_invalid_examples_fail_for_the_declared_reason(self) -> None:
expected = load_json(INVALID_ROOT / "expected-errors.json")
self.assertGreaterEqual(len(expected), 6)
for filename, reason in expected.items():
with self.subTest(path=filename):
with self.assertRaisesRegex(ValueError, str(reason)):
validate_payload(load_json(INVALID_ROOT / filename))
def test_tampering_is_detected_after_other_validation(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["revision"] += 1
with self.assertRaisesRegex(ValueError, "integrity digest mismatch"):
validate_payload(payload)
def test_namespaced_optional_extensions_are_compatible_but_not_secret_bearing(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["extensions"] = {"example.analytics": {"samplingHint": "balanced"}}
set_integrity(payload)
validate_payload(payload)
payload["extensions"] = {"example.analytics": {"accessToken": "forbidden"}}
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "secret field"):
validate_payload(payload)
def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["profile_binding"]["width"] = 1280
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "profile binding"):
validate_payload(payload)
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["state"] = "recalibration_required"
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "must not remain enabled"):
validate_payload(payload)
def test_revision_stream_rejects_stale_and_unrecalibrated_profile_change(self) -> None:
previous = load_json(VALID_ROOT / "active.json")
current = copy.deepcopy(previous)
current["revision"] = previous["revision"]
set_integrity(current)
with self.assertRaisesRegex(ValueError, "revision must increase"):
validate_transition(previous, current)
current["revision"] += 1
current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720})
current["rule_set"]["profile_binding"].update(
{"profile_id": "main-stream-v2", "width": 1280, "height": 720}
)
set_integrity(current)
with self.assertRaisesRegex(ValueError, "without rule recalibration"):
validate_transition(previous, current)
validate_transition(previous, load_json(VALID_ROOT / "recalibration-required.json"))
def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["areas"][0]["points"] = [
{"x": 0, "y": 0},
{"x": 0.5, "y": 0.5},
{"x": 1, "y": 1},
]
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "degenerate polygon"):
validate_payload(payload)
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"]
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "ids must be unique"):
validate_payload(payload)
def test_effective_time_cannot_precede_publication(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["effective_at"] = "2026-08-30T23:59:59Z"
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "precedes"):
validate_payload(payload)
def test_shared_payload_does_not_claim_either_product_internal_model(self) -> None:
for path in sorted(VALID_ROOT.glob("*.json")):
serialized = json.dumps(load_json(path), ensure_ascii=False).lower()
self.assertNotIn("brain.internal.input", serialized)
self.assertNotIn("streamuri", serialized)
self.assertNotIn("profiletoken", serialized)
self.assertNotIn("database", serialized)
self.assertNotRegex(serialized, r"[a-z]:\\")
def test_mapper_documents_both_product_test_responsibilities(self) -> None:
mapper = (CONTRACT_ROOT / "mapper-fields.md").read_text(encoding="utf-8")
self.assertIn("Sense 生产者契约测试", mapper)
self.assertIn("Brain 消费者契约测试", mapper)
self.assertIn("brain.internal.input/v1", mapper)
self.assertIn("admissionProfile.StreamURI", mapper)
if __name__ == "__main__":
unittest.main()
+12
View File
@@ -0,0 +1,12 @@
# Machine transport policy v1
`yovision.transport/v1` applies to every connector protected by machine identity v1.
- HTTPS is mandatory. TLS 1.2 is the minimum and TLS 1.3 is preferred.
- Certificate-chain and hostname verification are mandatory. `InsecureSkipVerify`, plaintext fallback and query-string credentials are forbidden.
- Connection, response-header and total request timeouts are explicit and bounded; consumers enforce a route-specific body limit before decoding.
- `X-Request-ID` is an opaque 16–128 character correlation value. It may be generated by the caller or first trusted hop, is never an authentication factor, and must not contain credentials or personal data.
- Retry only timeout, connection loss, `429` and `5xx` according to the connector policy. Authentication/authorization failures and contract `4xx` responses are terminal until configuration changes.
- Each retry signs a new machine token and `jti`. The business idempotency key and payload remain unchanged.
Transport failures must not start Sense, Brain or Bell with weakened authentication. Rollback disables the connector and preserves local facts, Outbox/Receipt state and last-known-good configuration.
@@ -0,0 +1,18 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/transport/v1/transport-policy.schema.json",
"title": "YoVision machine transport policy v1",
"type": "object",
"additionalProperties": false,
"required": ["version", "tls_min_version", "verify_certificate", "verify_hostname", "connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms", "max_request_bytes"],
"properties": {
"version": {"const": "yovision.transport/v1"},
"tls_min_version": {"enum": ["1.2", "1.3"]},
"verify_certificate": {"const": true},
"verify_hostname": {"const": true},
"connect_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 30000},
"response_header_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 30000},
"request_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 60000},
"max_request_bytes": {"type": "integer", "minimum": 1, "maximum": 10485760}
}
}
+60 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
wiki_revision: 14b961599d6954357142713a5667fb37d38e86b7
synchronized_at: 2026-08-29T12:37:31Z
wiki_revision: 41c2193b2f1edb37abe1e8994d65d02207549039
synchronized_at: 2026-08-31T03:18:12Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -296,3 +296,61 @@ Brain 解码层位于 `Brain/src/yovision_brain/decode/`,只依赖 #11 的内
内部候选包含逻辑输入引用、规则/模型版本、发生时间、匿名框和解释原因,不包含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或证据引用。该格式不是 Brain→Bell 共享契约;Bell API、Outbox、机器身份、证据和跨项目投递必须由协调工单另行实现。
<!-- brain-local-events-v1:end -->
<!-- sense-brain-contracts-v1:start -->
## Sense↔Brain v1 契约边界
- Sense→Brain 配置:`contracts/source-config/v1/source-config.schema.json`;版本 `yovision.source-config/v1`。
- Brain→Sense 状态:`contracts/runtime-status/v1/runtime-status.schema.json`;版本 `yovision.runtime-status/v1`。
- 共同测试:`contracts/tests/source-config-v1/`、`contracts/tests/runtime-status-v1/`。
- 生产者/消费者 mapper 责任分别记录在 `mapper-fields.md` 与 `mapping.md`;产品 adapter 后续由 #152 实现。
数据流固定为:
```text
Sense Device/Profile/Area 内部事实
→ source-config/v1 mapper
→ Brain adapter(后续 #152)
→ Brain 内部配置与运行
→ runtime-status/v1 mapper
→ Sense 只读运维投影(后续 #152)
```
共享契约统一使用 snake_case 与 `schema_version: yovision.<contract>/v1`。源配置使用 `config_id + integer revision`;运行状态以 `configurations[]` 按 `config_id` 回报实际应用 revision。未知主版本、重复配置 ID、倒序状态、摘要失败或敏感字段必须拒绝,且不得覆盖最后已知有效配置/投影。
协议不得包含摄像头凭据、RTSP URL、query token、内部绝对路径、数据库模型、用户/JWT/Cookie 或 Bell Alert 语义。当前只冻结契约,没有新增网络端点、机器身份或跨端 connector。
<!-- sense-brain-contracts-v1:end -->
<!-- standard-event-evidence-v1:start -->
## 标准事件与证据 v1 契约边界
- Event Schema:`contracts/events/v1/event.schema.json`,版本 `yovision.event/v1`。
- Bell 接入描述:`contracts/events/v1/openapi.json`,返回创建、重复、幂等冲突和不支持版本等明确结果。
- Evidence Schema/API:`contracts/evidence/v1/evidence-reference.schema.json`、`openapi.json`,版本 `yovision.evidence-reference/v1`。
- 共同测试:`contracts/tests/events-v1/`、`contracts/tests/evidence-v1/`。
后续 #153 的映射流固定为:
```text
Brain internal candidate / Sense local event
→ yovision.event/v1 producer mapper
→ Sense Outbox relay(默认拓扑,保持原 producer/source ID)
→ Bell v1 ingress
→ Bell private immutable Event + permanent Receipt
→ Bell private Rule / Alert / ack / close
```
规范载荷使用 RFC 8785 JCS 与 SHA-256 形成稳定摘要。同键同摘要返回原 Event;同键不同摘要返回冲突并审计,不覆盖原事实。Evidence 只提供逻辑引用与状态/完整性元数据,不授予访问权限,不包含本机路径、签名 URL 或凭据;取证授权由后续机器身份和 connector 工单实现。
Brain candidate、Sense candidate/Outbox 与 Bell Event/Receipt/Alert 继续是各自内部模型。当前没有新增可运行的跨端 ingress/relay,不能把冻结 Schema 解释为端到端链路已完成。
<!-- standard-event-evidence-v1:end -->
<!-- machine-identity-v1:start -->
## 三项目机器身份与安全传输 v1
工单 #151 已于 2026-08-31 验收并通过 PR #162 合入 `dev`。共享事实源为 `contracts/machine-identity/v1/**` 与 `contracts/transport/v1/**`;Sense、Brain、Bell 分别在自己的 `integration/machine_identity/` 中保留独立适配,不共享用户、JWT、Cookie、Casbin、数据库或业务实现。
v1 使用 HTTPS 上的 Ed25519 短期请求绑定 JWS。每个部署实例拥有独立 principal、`kid` 和仓库外私钥;消费者使用本地外部公钥注册表,按精确 audience 与最小 scope 授权。令牌绑定 HTTP 方法、规范化路径和正文 SHA-256,有效期最多 300 秒、时钟偏差最多 30 秒,并以 `(principal,jti)` 原子防重放。TLS 最低 1.2,证书链与主机名验证不可关闭。
Sense/Bell 使用 Go 标准库 Ed25519,Brain 冻结 `cryptography==50.0.1`。固定跨语言向量证明 Go/Python 可互相验签。#151 只提供身份、注册表、传输策略及可注入 replay 接口;#152/#153 才注册业务 endpoint,并必须使用各产品独立的持久原子 replay store验证重启,不能共享数据库。
<!-- machine-identity-v1:end -->
+43 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
wiki_revision: 27749cbf699093d997284afc277ea53e73a5876f
synchronized_at: 2026-08-29T12:37:41Z
wiki_revision: 7a91edf3ca35ade3e254937c4a68b53d816a3eea
synchronized_at: 2026-08-31T03:18:24Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -233,3 +233,44 @@ synchronized_at: 2026-08-29T12:37:41Z
- 确认和恢复都要求 6–256 字符原因、当前版本和允许的状态;旧版本或错误状态返回冲突。所有动作写入独立流转历史和 GoAdmin 操作审计。
- 运维告警永远设置为 Sense 内部运维记录,不创建本地安全事件或 Bell Alert,不进入跨项目 Outbox,也不实现通知升级。
<!-- sense-ops-alerts:end -->
<!-- sense-brain-contracts-v1:start -->
## Sense↔Brain 配置与状态规则
- **配置流**:由稳定 `config_id` 和严格递增的正整数 `revision` 标识;revision 不得复用或倒退。
- **无凭据媒体引用**:`media.ref` 是由后续 connector 解析的不透明逻辑引用,不是 RTSP URL、本机路径或数据库主键。
- **Profile 绑定**:规则集必须与 Profile ID、宽高一致;Profile 变化必须形成新 revision,并在需要时标记 `recalibration_required`,旧几何不得静默重投影。
- **规则坐标**:区域与方向线使用 0–1 归一化坐标,规则 ID 在同一规则集内唯一;退化多边形和重合线端点无效。
- **完整性**:源配置对移除 `integrity` 后的 JCS 表示计算 SHA-256;校验失败保留上一有效 revision。
- **配置应用状态**:Brain 在 `configurations[]` 中按 `config_id` 报告 `not_configured/applying/applied/rejected` 与实际 `applied_revision`;同一消息重复 ID 整条拒绝。
- **状态时序**:Brain 实例 sequence 单调递增;Sense 拒绝倒序消息。观测时间超过约定 90 秒时由 Sense 标记陈旧,不用未知值覆盖最后已知投影。
- **状态边界**:运行/健康错误只形成 Sense 运维投影,不是业务 Event 或 Bell Alert;不得包含用户会话、凭据、内部路径或客户视频。
- **版本兼容**:v1 只接受已冻结语义;破坏性字段或语义变化发布新主版本。未知主版本停止摄取并保留上一有效事实。
<!-- sense-brain-contracts-v1:end -->
<!-- standard-event-evidence-v1:start -->
## 标准事件、证据和幂等规则
- **标准 Event**:匿名、不可变的跨产品安全事实,不是 Bell Alert,也不携带处置或通知状态。
- **原始生产者**:`producer_id` 始终标识最初产生事件的 Brain 或 Sense 实例;relay 使用独立传输身份,但不得替换业务生产者。
- **永久幂等键**:精确 UTF-8 对 `(producer_id, source_event_id)`。重试沿用同一键,不生成新事件。
- **规范摘要**:完整 Event 使用 RFC 8785 JCS 规范化后计算 SHA-256。同键同摘要为重复成功;同键异摘要为终止性冲突,并追加脱敏审计。
- **时间格式**:Event v1 使用 UTC RFC 3339、三位毫秒和 `Z`;可选字段缺失时省略,不发送 null。
- **证据引用**:`evidence_id` 与 `owner_id` 是不透明逻辑引用,不是 URL、文件路径或访问凭据。
- **证据状态**:`pending → processing → success|failed`。success 要求内容类型和摘要/大小;failed 要求稳定错误码和是否可重试。
- **降级原则**:证据失败、未知或过期不删除 Event,不自动关闭 Alert,也不伪装成完整成功。
- **Bell 所有权**:Bell 独占内部 Event/Receipt、规则、Alert、ack、close、通知与用户审计;上游不得写入这些状态。
- **兼容与回退**:未知主版本终止接收但保留已有事实;破坏性变化发布新主版本。回退停用新生产者版本,不删除 Outbox、Receipt、Event 或审计。
<!-- standard-event-evidence-v1:end -->
<!-- machine-identity-v1:start -->
## 机器身份、权限和重放规则
- 机器 principal 格式为 `yv:<sense|brain|bell>:<instance>`,每个产品实例独立;不得替代、携带或映射 Sense/Bell 用户身份。
- v1 audience 只允许 `yovision-sense`、`yovision-brain`、`yovision-bell`;scope 只允许 `source-config:write`、`runtime-status:write`、`events:ingest`、`evidence:read`,没有通配符。
- 机器令牌只能从 Authorization Bearer 读取,不接受管理员密码、浏览器 JWT/Cookie、query token、共享 secret 或其他产品用户身份。
- 相同 `(principal,jti)` 只能成功一次;传输重试必须签发新令牌和 jti,但业务幂等键与载荷保持不变。
- 私钥只存在于仓库外受操作系统保护的文件或秘密存储;运行配置只引用路径。公钥注册表属于各消费者本地配置,不是共享数据库。
- 轮换先登记新 `kid`,最多并存 24 小时,切换后移除旧 key;禁用 principal 或吊销 `kid` 对每次请求即时生效。
- 回退只能关闭 connector 并恢复三端独立运行,不得降级为明文、共享管理员身份、共享 JWT 或跳过签名/TLS 验证。
<!-- machine-identity-v1:end -->
+84 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
wiki_revision: e6068ff0e42765d32ff4e0ee0e8e51cf7d79b7da
synchronized_at: 2026-08-29T12:37:58Z
wiki_revision: 771fdd92eb03e9dff7d3ae20b7cb89f1ca288959
synchronized_at: 2026-08-31T03:18:34Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -624,3 +624,85 @@ Brain\.venv\Scripts\python.exe -m yovision_brain.app --config Brain\tests\fixtur
CLI 将内部事件 JSON Lines 写入 stdout,并把 completed/cancelled、帧数、检测数和事件数摘要写入 stderr。配置文件必须显式提供,当前使用 JSON;无命中正常返回零事件,读取/配置/模块失败返回非零且不回显机器路径。命令不启动 Sense/Bell、不连接摄像头或网络。
<!-- brain-local-events-v1:end -->
<!-- sense-brain-contracts-v1:start -->
## Sense↔Brain v1 契约验证
源/规则配置契约:
```powershell
pwsh -NoProfile -File contracts/tests/source-config-v1/run.ps1
```
脚本在系统临时目录创建隔离虚拟环境,按固定依赖运行 Schema、跨字段语义、JCS/SHA-256、版本/重校准和秘密拒绝测试,结束后清理所属临时目录。
运行状态契约不需要第三方包:
```powershell
python contracts/tests/runtime-status-v1/test_contract.py
```
测试覆盖六态运行状态、30 秒未来时间偏差、90 秒陈旧边界、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、倒序消息、未知主版本、回退保留和敏感字段拒绝。
仓库级复核:
```powershell
python -m unittest discover -s tests -v
python dev_scripts/harness.py check --strict
git diff --check
```
这些命令只验证冻结契约,不验证 #152 产品 adapter、真实网络传输、机器身份、现场断网恢复或端到端链路。
<!-- sense-brain-contracts-v1:end -->
<!-- standard-event-evidence-v1:start -->
## 标准事件与证据 v1 契约验证
两组测试均只使用 Python 标准库:
```powershell
python contracts/tests/events-v1/test_contract.py
python contracts/tests/evidence-v1/test_contract.py
```
事件测试覆盖匿名危险区域/方向越线样例、Brain producer→Sense relay→Bell consumer mapper fixture、RFC 8785/SHA-256 幂等向量、重复/冲突、未知版本、敏感字段拒绝和 OpenAPI 引用。
证据测试覆盖 `pending/processing/success/failed` 状态约束、success 完整性、失败降级、旧 `available` 状态拒绝、敏感访问材料拒绝和证据 API 响应引用。
仓库级复核:
```powershell
python -m unittest discover -s tests -v
python dev_scripts/harness.py check --strict
git diff --check
```
这些测试只验证冻结契约,不验证 #153 产品 mapper/relay/ingress、#151 机器身份、实际证据存储/授权、网络断线补投或跨项目 E2E。
<!-- standard-event-evidence-v1:end -->
<!-- machine-identity-v1:start -->
## 机器身份与安全传输 v1 验证
从仓库根目录执行隔离契约测试:
```powershell
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
```
脚本在系统临时目录建立虚拟环境,按固定 `cryptography==50.0.1` 和 `jsonschema==4.25.1` 验证封闭 Schema、Go/Python 固定 Ed25519 向量、请求绑定、Bearer-only、错 audience/scope、过期、重放、轮换、吊销和 TLS policy,结束后删除所属临时目录。
三端定向验证:
```powershell
cd Sense/server
go test -race ./app/sense/integration/machine_identity
cd ../../Bell/server
go test -race ./app/bell/integration/machine_identity
cd ../..
Brain\.venv\Scripts\python.exe contracts\tests\machine-identity-v1\test_contract.py
```
这些测试只证明 #151 身份和传输基础。真实客户 PKI/网络、现场时钟漂移、业务 endpoint、断网补投以及持久 replay 重启恢复由 #152/#153/#155 验证;不得用进程内 replay store替代生产结论。
<!-- machine-identity-v1:end -->

Some files were not shown because too many files have changed in this diff Show More