Compare commits

...
Author SHA1 Message Date
ila 5128f080b4 Merge Bell 联系人与值班排班 (#183)
用户已于 2026-09-01 明确验收通过 #183。
2026-09-01 14:14:35 +08:00
QiuSW e81f00e9aa feat: 实现 Bell 联系人与值班排班 (#183) 2026-09-01 12:16:52 +08:00
ila 5adee5c3b4 merge: 同步三项目协调 E2E 文档 (#171)
用户于 2026-08-31 明确验收通过 #171。
2026-08-31 21:42:47 +08:00
QiuSW a69ef627c7 docs: 修正协调 E2E 清理边界措辞 (#171) 2026-08-31 21:12:33 +08:00
QiuSW d4de462d44 docs: 同步协调 E2E 运行与排障说明 (#171) 2026-08-31 21:08:32 +08:00
ila 0276bceab5 merge: 完成三项目协调隔离 E2E (#155)
用户已于 2026-08-31 明确验收通过 #155。
2026-08-31 20:49:20 +08:00
QiuSW c2b2943a3a test: 增加三项目协调隔离 E2E (#155) 2026-08-31 17:34:37 +08:00
ila 55b12df373 docs: 同步根级部署编排说明 (#168)
用户于 2026-08-31 明确验收通过 #168。
2026-08-31 16:57:34 +08:00
QiuSW 27d465c250 docs: 同步根级部署编排说明 (#168) 2026-08-31 16:32:48 +08:00
ila 4a2c4aa638 feat: 建立三项目可选部署编排 (#154)
用户于 2026-08-31 明确验收通过 #154。
2026-08-31 16:16:49 +08:00
QiuSW 504dd1a2e9 test: 补充三端独立编排验证 (#154) 2026-08-31 16:12:14 +08:00
QiuSW e2f7183ecf feat: 建立三项目可选部署编排 (#154) 2026-08-31 16:08:05 +08:00
ila eb4e1a9ea1 merge: 同步 connector 验收文档 (#152 #153)
同步已验收 #152/#153 的 Wiki 核心镜像。
2026-08-31 15:40:54 +08:00
QiuSW 9055f2522c docs: 记录 connector 验收 (#152 #153) 2026-08-31 15:36:19 +08:00
ila 130087a1ba merge: 完成可靠事件与证据 connector (#153)
用户已于 2026-08-31 验收通过 #153。
2026-08-31 15:16:18 +08:00
ila 0e53e04e95 merge: 完成 Sense 与 Brain connector (#152)
用户已于 2026-08-31 验收通过 #152。
2026-08-31 15:15:48 +08:00
QiuSW 04c5deecfb feat: 实现可靠事件与证据 connector (#153) 2026-08-31 14:57:48 +08:00
QiuSW 82afce1f81 feat: 实现 Sense Brain 控制连接器 (#152) 2026-08-31 11:51:58 +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 a22d3ce0f1 fix: 对齐多配置运行状态语义 (#149) 2026-08-31 08:59:48 +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 54c58551ae fix: 统一运行状态契约版本标识 (#149) 2026-08-31 08:49:49 +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
QiuSW e4fed702c4 feat: 冻结 Brain 运行状态契约 v1 (#149) 2026-08-31 08:44:36 +08:00
ila 49aa79f3b9 Merge pull request '#147' from docs/8-mvp-acceptance into dev
同步 MVP #8 验收后的核心 Wiki 镜像。
2026-08-29 20:49:44 +08:00
175 changed files with 13816 additions and 28 deletions
+59
View File
@@ -0,0 +1,59 @@
package contact
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"strings"
)
var ErrChannelKeyUnavailable = errors.New("联系人通道加密密钥未配置或格式错误")
func ParseChannelKey(value string) ([]byte, error) {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value))
if err != nil || len(key) != 32 {
return nil, ErrChannelKeyUnavailable
}
return key, nil
}
func encryptAddress(key []byte, value string) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, ErrChannelKeyUnavailable
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, []byte(value), nil), nil
}
func decryptAddress(key, encoded []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", ErrChannelKeyUnavailable
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(encoded) < gcm.NonceSize() {
return "", errors.New("通道密文已损坏")
}
plain, err := gcm.Open(nil, encoded[:gcm.NonceSize()], encoded[gcm.NonceSize():], nil)
return string(plain), err
}
func fingerprint(value string) string {
sum := sha256.Sum256([]byte(value))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
+146
View File
@@ -0,0 +1,146 @@
package contact
import (
"errors"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
)
type Handler struct{ api.Api }
type enabledInput struct {
Enabled *bool `json:"enabled" binding:"required"`
ExpectedVersion int `json:"expectedVersion" binding:"required"`
}
type validationInput struct {
Status string `json:"status"`
Detail string `json:"detail"`
}
func (h Handler) service() (Service, error) {
key, err := ParseChannelKey(os.Getenv("BELL_CONTACT_CHANNEL_KEY"))
return NewService(h.Orm, key), err
}
func (h Handler) List(c *gin.Context) {
var q PageQuery
h.MakeContext(c).MakeOrm().Bind(&q, binding.Form)
if h.Errors != nil {
h.Error(400, ErrInvalid, ErrInvalid.Error())
return
}
items, count, err := NewService(h.Orm, nil).List(c.Request.Context(), q)
if err != nil {
h.Error(500, errors.New("读取联系人失败"), "读取联系人失败")
return
}
p, s := pageValues(q.PageIndex, q.PageSize)
h.PageOK(items, int(count), p, s, "查询成功")
}
func (h Handler) Create(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
return
}
var input WriteInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(400, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm, nil).Create(c.Request.Context(), input, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) Update(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
return
}
var input WriteInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(400, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm, nil).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) SetEnabled(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
return
}
var input enabledInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil || input.Enabled == nil {
h.Error(400, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm, nil).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, input.ExpectedVersion, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) AddChannel(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护通道"), "仅管理员可维护通道")
return
}
if err := restoreChannelBody(c); err != nil {
h.MakeContext(c).Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
return
}
var input ChannelInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(400, ErrInvalid, ErrInvalid.Error())
return
}
service, keyErr := h.service()
if keyErr != nil {
h.Error(503, keyErr, keyErr.Error())
return
}
item, err := service.AddChannel(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) ValidateChannel(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可验证通道"), "仅管理员可验证通道")
return
}
var input validationInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(400, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm, nil).RecordValidation(c.Request.Context(), c.Param("id"), input.Status, input.Detail, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) result(item any, err error) {
switch {
case err == nil:
h.OK(item, "保存成功")
case errors.Is(err, ErrInvalid):
h.Error(400, err, err.Error())
case errors.Is(err, ErrNotFound):
h.Error(404, err, err.Error())
case errors.Is(err, ErrConflict):
h.Error(409, err, err.Error())
case errors.Is(err, ErrChannelKeyUnavailable):
h.Error(503, err, err.Error())
default:
h.Logger.Errorf("write Bell contact failed: %v", err)
h.Error(409, errors.New("联系人保存失败"), "联系人保存失败")
}
}
func admin(c *gin.Context) bool {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return role == "admin"
}
var _ = http.StatusOK
+68
View File
@@ -0,0 +1,68 @@
package contact
import (
"encoding/json"
"time"
)
type Contact struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
Name string `json:"name" gorm:"size:128;not null"`
Role string `json:"role" gorm:"size:128;not null"`
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
Version int `json:"version" gorm:"not null;default:1"`
CreatedBy int `json:"createdBy" gorm:"not null"`
UpdatedBy int `json:"updatedBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
}
func (Contact) TableName() string { return "bell_contacts" }
type Channel struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
ContactID string `json:"contactId" gorm:"type:uuid;not null;index"`
Kind string `json:"kind" gorm:"size:16;not null"`
AddressCiphertext []byte `json:"-" gorm:"type:bytea;not null"`
AddressFingerprint string `json:"-" gorm:"size:64;not null;index"`
AddressMasked string `json:"addressMasked" gorm:"size:64;not null"`
CreatedBy int `json:"createdBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
}
func (Channel) TableName() string { return "bell_contact_channels" }
type ChannelValidation struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
ChannelID string `json:"channelId" gorm:"type:uuid;not null;index"`
Status string `json:"status" gorm:"size:16;not null"`
Detail string `json:"detail" gorm:"size:256;not null"`
ActorID int `json:"actorId" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
}
func (ChannelValidation) TableName() string { return "bell_contact_channel_validations" }
type AuditFact struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
ContactID string `json:"contactId" gorm:"type:uuid;not null;index"`
Action string `json:"action" gorm:"size:32;not null"`
Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"`
ActorID int `json:"actorId" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
}
func (AuditFact) TableName() string { return "bell_contact_audit_facts" }
type ChannelView struct {
ID string `json:"id"`
Kind string `json:"kind"`
AddressMasked string `json:"addressMasked"`
Status string `json:"status"`
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
}
type ContactView struct {
Contact
Channels []ChannelView `json:"channels"`
}
+62
View File
@@ -0,0 +1,62 @@
package contact
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const maxChannelRequestBytes = 8 * 1024
const channelBodyKey = "bell.contact.channel-body"
const channelBodyErrorKey = "bell.contact.channel-body-error"
var redactedChannelBody = []byte(`{"redacted":true}`)
// RedactRequestBody must run before GoAdmin's LoggerToFile middleware. The
// handler restores the original body from Gin context, while sys_opera_log
// only sees a fixed marker and never the contact address.
func RedactRequestBody() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost || !isChannelCreatePath(c.Request.URL.Path) {
c.Next()
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxChannelRequestBytes+1))
if err != nil {
c.Set(channelBodyErrorKey, err)
} else if len(body) > maxChannelRequestBytes {
c.Set(channelBodyErrorKey, errors.New("request body too large"))
} else {
c.Set(channelBodyKey, body)
}
_ = c.Request.Body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader(redactedChannelBody))
c.Next()
}
}
func restoreChannelBody(c *gin.Context) error {
if value, ok := c.Get(channelBodyErrorKey); ok {
return value.(error)
}
value, ok := c.Get(channelBodyKey)
if !ok {
return errors.New("channel request body was not captured")
}
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
return nil
}
func isChannelCreatePath(path string) bool {
const prefix = "/api/v1/bell/contacts/"
const suffix = "/channels"
if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) {
return false
}
id := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix)
return id != "" && !strings.Contains(id, "/")
}
+251
View File
@@ -0,0 +1,251 @@
package contact
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PageQuery struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
Name string `form:"name"`
Enabled *bool `form:"enabled"`
}
type Service struct {
DB *gorm.DB
Key []byte
}
func NewService(db *gorm.DB, key []byte) Service { return Service{DB: db, Key: key} }
func (s Service) List(ctx context.Context, query PageQuery) ([]ContactView, int64, error) {
page, size := pageValues(query.PageIndex, query.PageSize)
db := s.DB.WithContext(ctx).Model(&Contact{})
if name := strings.TrimSpace(query.Name); name != "" {
db = db.Where("name ILIKE ? OR role ILIKE ?", "%"+name+"%", "%"+name+"%")
}
if query.Enabled != nil {
db = db.Where("enabled = ?", *query.Enabled)
}
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, 0, err
}
var contacts []Contact
if err := db.Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(&contacts).Error; err != nil {
return nil, 0, err
}
views := make([]ContactView, 0, len(contacts))
for _, item := range contacts {
view, err := s.view(ctx, item)
if err != nil {
return nil, 0, err
}
views = append(views, view)
}
return views, count, nil
}
func (s Service) Create(ctx context.Context, input WriteInput, actor int) (ContactView, error) {
input, err := normalizeContact(input)
if err != nil {
return ContactView{}, err
}
now := time.Now().UTC()
item := Contact{ID: uuid.NewString(), Name: input.Name, Role: input.Role, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now}
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&item).Error; err != nil {
return err
}
return appendAudit(tx, item, "created", actor)
})
if err != nil {
return ContactView{}, err
}
return ContactView{Contact: item, Channels: []ChannelView{}}, nil
}
func (s Service) Update(ctx context.Context, id string, input WriteInput, actor int) (ContactView, error) {
if _, err := uuid.Parse(id); err != nil {
return ContactView{}, ErrNotFound
}
input, err := normalizeContact(input)
if err != nil {
return ContactView{}, err
}
if input.ExpectedVersion < 1 {
return ContactView{}, ErrInvalid
}
var item Contact
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "role": input.Role, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
var count int64
_ = tx.Model(&Contact{}).Where("id = ?", id).Count(&count).Error
if count == 0 {
return ErrNotFound
}
return ErrConflict
}
if err := tx.First(&item, "id = ?", id).Error; err != nil {
return err
}
return appendAudit(tx, item, "updated", actor)
})
if err != nil {
return ContactView{}, err
}
return s.view(ctx, item)
}
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, expectedVersion, actor int) (ContactView, error) {
if _, err := uuid.Parse(id); err != nil {
return ContactView{}, ErrNotFound
}
if expectedVersion < 1 {
return ContactView{}, ErrInvalid
}
var item Contact
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, expectedVersion).Updates(map[string]any{"enabled": enabled, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrConflict
}
if err := tx.First(&item, "id = ?", id).Error; err != nil {
return err
}
return appendAudit(tx, item, map[bool]string{true: "enabled", false: "disabled"}[enabled], actor)
})
if err != nil {
return ContactView{}, err
}
return s.view(ctx, item)
}
func (s Service) AddChannel(ctx context.Context, contactID string, input ChannelInput, actor int) (ChannelView, error) {
if len(s.Key) != 32 {
return ChannelView{}, ErrChannelKeyUnavailable
}
if _, err := uuid.Parse(contactID); err != nil {
return ChannelView{}, ErrNotFound
}
input, err := normalizeChannel(input)
if err != nil {
return ChannelView{}, err
}
ciphertext, err := encryptAddress(s.Key, input.Address)
if err != nil {
return ChannelView{}, err
}
now := time.Now().UTC()
item := Channel{ID: uuid.NewString(), ContactID: contactID, Kind: input.Kind, AddressCiphertext: ciphertext, AddressFingerprint: fingerprint(input.Address), AddressMasked: maskAddress(input.Address), CreatedBy: actor, CreatedAt: now}
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var contact Contact
if err := tx.First(&contact, "id = ?", contactID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if err := tx.Create(&item).Error; err != nil {
return err
}
return appendAudit(tx, contact, "channel_added", actor)
})
if err != nil {
return ChannelView{}, err
}
return ChannelView{ID: item.ID, Kind: item.Kind, AddressMasked: item.AddressMasked, Status: "pending"}, nil
}
func (s Service) RecordValidation(ctx context.Context, channelID, status, detail string, actor int) (ChannelView, error) {
status = strings.ToLower(strings.TrimSpace(status))
detail = strings.TrimSpace(detail)
if status != "verified" && status != "failed" {
return ChannelView{}, ErrInvalid
}
if len([]rune(detail)) > 256 || hasControl(detail) {
return ChannelView{}, ErrInvalid
}
var channel Channel
fact := ChannelValidation{ID: uuid.NewString(), ChannelID: channelID, Status: status, Detail: detail, ActorID: actor, CreatedAt: time.Now().UTC()}
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.First(&channel, "id = ?", channelID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if err := tx.Create(&fact).Error; err != nil {
return err
}
var c Contact
if err := tx.First(&c, "id = ?", channel.ContactID).Error; err != nil {
return err
}
return appendAudit(tx, c, "channel_validation_"+status, actor)
})
if err != nil {
return ChannelView{}, err
}
return ChannelView{ID: channel.ID, Kind: channel.Kind, AddressMasked: channel.AddressMasked, Status: status, ValidatedAt: &fact.CreatedAt}, nil
}
// DecryptChannelAddress is intentionally server-only. API responses never expose this value.
func (s Service) DecryptChannelAddress(ctx context.Context, channelID string) (string, error) {
var item Channel
if err := s.DB.WithContext(ctx).First(&item, "id = ?", channelID).Error; err != nil {
return "", err
}
return decryptAddress(s.Key, item.AddressCiphertext)
}
func (s Service) view(ctx context.Context, item Contact) (ContactView, error) {
var channels []Channel
if err := s.DB.WithContext(ctx).Where("contact_id = ?", item.ID).Order("created_at,id").Find(&channels).Error; err != nil {
return ContactView{}, err
}
views := make([]ChannelView, 0, len(channels))
for _, ch := range channels {
view := ChannelView{ID: ch.ID, Kind: ch.Kind, AddressMasked: ch.AddressMasked, Status: "pending"}
var fact ChannelValidation
err := s.DB.WithContext(ctx).Where("channel_id = ?", ch.ID).Order("created_at DESC,id DESC").Take(&fact).Error
if err == nil {
view.Status = fact.Status
view.ValidatedAt = &fact.CreatedAt
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return ContactView{}, err
}
views = append(views, view)
}
return ContactView{Contact: item, Channels: views}, nil
}
func appendAudit(tx *gorm.DB, item Contact, action string, actor int) error {
snapshot, err := json.Marshal(map[string]any{"id": item.ID, "name": item.Name, "role": item.Role, "enabled": item.Enabled, "version": item.Version})
if err != nil {
return err
}
return tx.Create(&AuditFact{ID: uuid.NewString(), ContactID: item.ID, Action: action, Snapshot: snapshot, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
}
func pageValues(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return page, size
}
@@ -0,0 +1,60 @@
package contact
import (
"errors"
"regexp"
"strings"
)
var (
ErrInvalid = errors.New("联系人或通道信息不符合要求")
ErrNotFound = errors.New("联系人或通道不存在")
ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试")
phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
)
type WriteInput struct {
Name string `json:"name"`
Role string `json:"role"`
ExpectedVersion int `json:"expectedVersion"`
}
type ChannelInput struct {
Kind string `json:"kind"`
Address string `json:"address"`
}
func normalizeContact(input WriteInput) (WriteInput, error) {
input.Name = strings.TrimSpace(input.Name)
input.Role = strings.TrimSpace(input.Role)
if input.Name == "" || len([]rune(input.Name)) > 128 || input.Role == "" || len([]rune(input.Role)) > 128 || hasControl(input.Name+input.Role) {
return WriteInput{}, ErrInvalid
}
return input, nil
}
func normalizeChannel(input ChannelInput) (ChannelInput, error) {
input.Kind = strings.ToLower(strings.TrimSpace(input.Kind))
input.Address = strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(input.Address), " ", ""), "-", "")
if (input.Kind != "sms" && input.Kind != "voice") || !phonePattern.MatchString(input.Address) {
return ChannelInput{}, ErrInvalid
}
return input, nil
}
func maskAddress(value string) string {
runes := []rune(value)
if len(runes) <= 4 {
return "****"
}
return strings.Repeat("*", min(8, len(runes)-4)) + string(runes[len(runes)-4:])
}
func hasControl(value string) bool {
for _, r := range value {
if r < 32 || r == 127 {
return true
}
}
return false
}
@@ -0,0 +1,127 @@
package duty_schedule
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
)
type Handler struct{ api.Api }
func (h Handler) List(c *gin.Context) {
var q PageQuery
h.MakeContext(c).MakeOrm().Bind(&q, binding.Form)
if h.Errors != nil {
h.Error(400, ErrInvalid, ErrInvalid.Error())
return
}
items, count, err := NewService(h.Orm).List(c.Request.Context(), q)
if err != nil {
h.Error(500, errors.New("读取排班失败"), "读取排班失败")
return
}
p, s := pageValues(q.PageIndex, q.PageSize)
h.PageOK(items, int(count), p, s, "查询成功")
}
func (h Handler) CreateGroup(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
return
}
var input GroupInput
h.bind(c, &input)
if h.Errors != nil {
return
}
item, err := NewService(h.Orm).CreateGroup(c.Request.Context(), input, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) UpdateGroup(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
return
}
var input GroupInput
h.bind(c, &input)
if h.Errors != nil {
return
}
item, err := NewService(h.Orm).UpdateGroup(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) AddMember(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
return
}
var input MemberInput
h.bind(c, &input)
if h.Errors != nil {
return
}
item, err := NewService(h.Orm).AddMember(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) CreateSchedule(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
return
}
var input ScheduleInput
h.bind(c, &input)
if h.Errors != nil {
return
}
item, err := NewService(h.Orm).CreateSchedule(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
h.result(item, err)
}
func (h Handler) Publish(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可发布排班"), "仅管理员可发布排班")
return
}
item, err := NewService(h.Orm).Publish(c.Request.Context(), c.Param("id"), user.GetUserId(c))
h.result(item, err)
}
func (h Handler) CreateOverride(c *gin.Context) {
if !admin(c) {
h.MakeContext(c).Error(403, errors.New("仅管理员可维护替班"), "仅管理员可维护替班")
return
}
var input OverrideInput
h.bind(c, &input)
if h.Errors != nil {
return
}
item, err := NewService(h.Orm).CreateOverride(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
h.result(item, err)
}
func (h *Handler) bind(c *gin.Context, value any) {
h.MakeContext(c).MakeOrm().Bind(value, binding.JSON)
if h.Errors != nil {
h.Error(400, ErrInvalid, ErrInvalid.Error())
}
}
func (h Handler) result(item any, err error) {
switch {
case err == nil:
h.OK(item, "保存成功")
case errors.Is(err, ErrInvalid) || errors.Is(err, ErrCoverage):
h.Error(400, err, err.Error())
case errors.Is(err, ErrNotFound):
h.Error(404, err, err.Error())
case errors.Is(err, ErrConflict):
h.Error(409, err, err.Error())
default:
h.Logger.Errorf("write Bell duty schedule failed: %v", err)
h.Error(409, errors.New("排班保存失败"), "排班保存失败")
}
}
func admin(c *gin.Context) bool {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return role == "admin"
}
@@ -0,0 +1,94 @@
package duty_schedule
import (
"encoding/json"
"time"
)
type Group struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
Name string `json:"name" gorm:"size:128;not null;uniqueIndex"`
Timezone string `json:"timezone" gorm:"size:64;not null"`
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
Version int `json:"version" gorm:"not null;default:1"`
CreatedBy int `json:"createdBy" gorm:"not null"`
UpdatedBy int `json:"updatedBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
}
func (Group) TableName() string { return "bell_duty_groups" }
type Member struct {
GroupID string `json:"groupId" gorm:"type:uuid;primaryKey"`
ContactID string `json:"contactId" gorm:"type:uuid;primaryKey"`
Role string `json:"role" gorm:"size:16;not null"`
CreatedBy int `json:"createdBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
}
func (Member) TableName() string { return "bell_duty_members" }
type ScheduleVersion struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
Version int `json:"version" gorm:"not null"`
Timezone string `json:"timezone" gorm:"size:64;not null"`
EffectiveFrom time.Time `json:"effectiveFrom" gorm:"type:timestamptz;not null"`
EffectiveTo *time.Time `json:"effectiveTo,omitempty" gorm:"type:timestamptz"`
Status string `json:"status" gorm:"size:16;not null"`
CreatedBy int `json:"createdBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
PublishedBy *int `json:"publishedBy,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty" gorm:"type:timestamptz"`
}
func (ScheduleVersion) TableName() string { return "bell_duty_schedule_versions" }
type RotationSlot struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
ScheduleVersionID string `json:"scheduleVersionId" gorm:"type:uuid;not null;index"`
Weekday int `json:"weekday" gorm:"not null"`
StartMinute int `json:"startMinute" gorm:"not null"`
EndMinute int `json:"endMinute" gorm:"not null"`
PrimaryContactID string `json:"primaryContactId" gorm:"type:uuid;not null"`
BackupContactID string `json:"backupContactId" gorm:"type:uuid;not null"`
}
func (RotationSlot) TableName() string { return "bell_duty_rotation_slots" }
type Override struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
OriginalContactID string `json:"originalContactId" gorm:"type:uuid;not null"`
ReplacementContactID string `json:"replacementContactId" gorm:"type:uuid;not null"`
StartsAt time.Time `json:"startsAt" gorm:"type:timestamptz;not null;index"`
EndsAt time.Time `json:"endsAt" gorm:"type:timestamptz;not null"`
Reason string `json:"reason" gorm:"size:256;not null"`
CreatedBy int `json:"createdBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
}
func (Override) TableName() string { return "bell_duty_overrides" }
type AuditFact struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
Action string `json:"action" gorm:"size:32;not null"`
Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"`
ActorID int `json:"actorId" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
}
func (AuditFact) TableName() string { return "bell_duty_audit_facts" }
type GroupView struct {
Group
Members []Member `json:"members"`
Schedules []ScheduleView `json:"schedules"`
Overrides []Override `json:"overrides"`
}
type ScheduleView struct {
ScheduleVersion
Slots []RotationSlot `json:"slots"`
}
@@ -0,0 +1,299 @@
package duty_schedule
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"go-admin/app/bell/contact"
)
type PageQuery struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
Name string `form:"name"`
Enabled *bool `form:"enabled"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) List(ctx context.Context, q PageQuery) ([]GroupView, int64, error) {
p, z := pageValues(q.PageIndex, q.PageSize)
db := s.DB.WithContext(ctx).Model(&Group{})
if name := strings.TrimSpace(q.Name); name != "" {
db = db.Where("name ILIKE ?", "%"+name+"%")
}
if q.Enabled != nil {
db = db.Where("enabled = ?", *q.Enabled)
}
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, 0, err
}
var groups []Group
if err := db.Order("created_at DESC,id DESC").Offset((p - 1) * z).Limit(z).Find(&groups).Error; err != nil {
return nil, 0, err
}
views := make([]GroupView, 0, len(groups))
for _, g := range groups {
v, err := s.view(ctx, g)
if err != nil {
return nil, 0, err
}
views = append(views, v)
}
return views, count, nil
}
func (s Service) CreateGroup(ctx context.Context, input GroupInput, actor int) (GroupView, error) {
input, err := normalizeGroup(input)
if err != nil {
return GroupView{}, err
}
now := time.Now().UTC()
item := Group{ID: uuid.NewString(), Name: input.Name, Timezone: input.Timezone, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now}
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&item).Error; err != nil {
return err
}
return audit(tx, item.ID, "group_created", item, actor)
})
return GroupView{Group: item, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}, err
}
func (s Service) UpdateGroup(ctx context.Context, id string, input GroupInput, actor int) (GroupView, error) {
input, err := normalizeGroup(input)
if err != nil {
return GroupView{}, err
}
if input.ExpectedVersion < 1 {
return GroupView{}, ErrInvalid
}
var item Group
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
r := tx.Model(&Group{}).Where("id=? AND version=?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "timezone": input.Timezone, "version": gorm.Expr("version+1"), "updated_by": actor, "updated_at": time.Now().UTC()})
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
return ErrConflict
}
if err := tx.First(&item, "id=?", id).Error; err != nil {
return err
}
return audit(tx, id, "group_updated", item, actor)
})
if err != nil {
return GroupView{}, err
}
return s.view(ctx, item)
}
func (s Service) AddMember(ctx context.Context, groupID string, input MemberInput, actor int) (Member, error) {
input.Role = strings.ToLower(strings.TrimSpace(input.Role))
if input.Role != "primary" && input.Role != "backup" {
return Member{}, ErrInvalid
}
now := time.Now().UTC()
item := Member{GroupID: groupID, ContactID: input.ContactID, Role: input.Role, CreatedBy: actor, CreatedAt: now}
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := assertEnabledContact(tx, input.ContactID); err != nil {
return err
}
if err := tx.First(&Group{}, "id=?", groupID).Error; err != nil {
return ErrNotFound
}
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "group_id"}, {Name: "contact_id"}}, DoUpdates: clause.AssignmentColumns([]string{"role", "created_by", "created_at"})}).Create(&item).Error; err != nil {
return err
}
return audit(tx, groupID, "member_saved", item, actor)
})
return item, err
}
func (s Service) CreateSchedule(ctx context.Context, groupID string, input ScheduleInput, actor int) (ScheduleView, error) {
if input.EffectiveFrom.IsZero() || (input.EffectiveTo != nil && !input.EffectiveTo.After(input.EffectiveFrom)) {
return ScheduleView{}, ErrInvalid
}
if err := validateSlots(input.Slots); err != nil {
return ScheduleView{}, err
}
var result ScheduleView
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var group Group
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&group, "id=?", groupID).Error; err != nil {
return ErrNotFound
}
for _, slot := range input.Slots {
if err := assertGroupMember(tx, groupID, slot.PrimaryContactID); err != nil {
return err
}
if err := assertGroupMember(tx, groupID, slot.BackupContactID); err != nil {
return err
}
}
var latest int
tx.Model(&ScheduleVersion{}).Where("group_id=?", groupID).Select("coalesce(max(version),0)").Scan(&latest)
now := time.Now().UTC()
version := ScheduleVersion{ID: uuid.NewString(), GroupID: groupID, Version: latest + 1, Timezone: group.Timezone, EffectiveFrom: input.EffectiveFrom.UTC(), EffectiveTo: input.EffectiveTo, Status: "draft", CreatedBy: actor, CreatedAt: now}
if err := tx.Create(&version).Error; err != nil {
return err
}
slots := make([]RotationSlot, 0, len(input.Slots))
for _, in := range input.Slots {
slots = append(slots, RotationSlot{ID: uuid.NewString(), ScheduleVersionID: version.ID, Weekday: in.Weekday, StartMinute: in.StartMinute, EndMinute: in.EndMinute, PrimaryContactID: in.PrimaryContactID, BackupContactID: in.BackupContactID})
}
if err := tx.Create(&slots).Error; err != nil {
return err
}
if err := audit(tx, groupID, "schedule_created", version, actor); err != nil {
return err
}
result = ScheduleView{ScheduleVersion: version, Slots: slots}
return nil
})
return result, err
}
func (s Service) Publish(ctx context.Context, id string, actor int) (ScheduleView, error) {
var result ScheduleView
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item ScheduleVersion
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id=?", id).Error; err != nil {
return ErrNotFound
}
if item.Status != "draft" {
return ErrConflict
}
var slots []RotationSlot
if err := tx.Where("schedule_version_id=?", id).Find(&slots).Error; err != nil {
return err
}
inputs := make([]SlotInput, 0, len(slots))
for _, v := range slots {
inputs = append(inputs, SlotInput{Weekday: v.Weekday, StartMinute: v.StartMinute, EndMinute: v.EndMinute, PrimaryContactID: v.PrimaryContactID, BackupContactID: v.BackupContactID})
}
if err := validateSlots(inputs); err != nil {
return err
}
for _, v := range slots {
if err := assertVerifiedContact(tx, v.PrimaryContactID); err != nil {
return err
}
if err := assertVerifiedContact(tx, v.BackupContactID); err != nil {
return err
}
}
now := time.Now().UTC()
if err := tx.Model(&item).Updates(map[string]any{"status": "published", "published_by": actor, "published_at": now}).Error; err != nil {
return err
}
item.Status = "published"
item.PublishedBy = &actor
item.PublishedAt = &now
if err := audit(tx, item.GroupID, "schedule_published", item, actor); err != nil {
return err
}
result = ScheduleView{ScheduleVersion: item, Slots: slots}
return nil
})
return result, err
}
func (s Service) CreateOverride(ctx context.Context, groupID string, input OverrideInput, actor int) (Override, error) {
input, err := normalizeOverride(input)
if err != nil {
return Override{}, err
}
item := Override{ID: uuid.NewString(), GroupID: groupID, OriginalContactID: input.OriginalContactID, ReplacementContactID: input.ReplacementContactID, StartsAt: input.StartsAt.UTC(), EndsAt: input.EndsAt.UTC(), Reason: input.Reason, CreatedBy: actor, CreatedAt: time.Now().UTC()}
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := assertGroupMember(tx, groupID, input.OriginalContactID); err != nil {
return err
}
if err := assertGroupMember(tx, groupID, input.ReplacementContactID); err != nil {
return err
}
var overlaps int64
if err := tx.Model(&Override{}).Where("group_id=? AND original_contact_id=? AND starts_at < ? AND ends_at > ?", groupID, input.OriginalContactID, item.EndsAt, item.StartsAt).Count(&overlaps).Error; err != nil {
return err
}
if overlaps > 0 {
return ErrConflict
}
if err := tx.Create(&item).Error; err != nil {
return err
}
return audit(tx, groupID, "override_created", item, actor)
})
return item, err
}
func (s Service) view(ctx context.Context, g Group) (GroupView, error) {
v := GroupView{Group: g, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("role,contact_id").Find(&v.Members).Error; err != nil {
return v, err
}
var versions []ScheduleVersion
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("version DESC").Find(&versions).Error; err != nil {
return v, err
}
for _, sv := range versions {
var slots []RotationSlot
if err := s.DB.WithContext(ctx).Where("schedule_version_id=?", sv.ID).Order("weekday,start_minute").Find(&slots).Error; err != nil {
return v, err
}
v.Schedules = append(v.Schedules, ScheduleView{ScheduleVersion: sv, Slots: slots})
}
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("starts_at DESC").Limit(50).Find(&v.Overrides).Error; err != nil {
return v, err
}
return v, nil
}
func assertEnabledContact(tx *gorm.DB, id string) error {
var c contact.Contact
if err := tx.Where("id=? AND enabled=true", id).First(&c).Error; err != nil {
return ErrInvalid
}
return nil
}
func assertGroupMember(tx *gorm.DB, groupID, contactID string) error {
if err := assertEnabledContact(tx, contactID); err != nil {
return err
}
var count int64
if err := tx.Model(&Member{}).Where("group_id=? AND contact_id=?", groupID, contactID).Count(&count).Error; err != nil {
return err
}
if count != 1 {
return ErrInvalid
}
return nil
}
func assertVerifiedContact(tx *gorm.DB, contactID string) error {
if err := assertEnabledContact(tx, contactID); err != nil {
return err
}
var count int64
err := tx.Raw(`SELECT count(*) FROM bell_contact_channels c WHERE c.contact_id=? AND (SELECT v.status FROM bell_contact_channel_validations v WHERE v.channel_id=c.id ORDER BY v.created_at DESC,v.id DESC LIMIT 1)='verified'`, contactID).Scan(&count).Error
if err != nil {
return err
}
if count == 0 {
return ErrInvalid
}
return nil
}
func audit(tx *gorm.DB, groupID, action string, value any, actor int) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return tx.Create(&AuditFact{ID: uuid.NewString(), GroupID: groupID, Action: action, Snapshot: data, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
}
func pageValues(p, s int) (int, int) {
if p < 1 {
p = 1
}
if s < 1 || s > 100 {
s = 20
}
return p, s
}
@@ -0,0 +1,90 @@
package duty_schedule
import (
"errors"
"sort"
"strings"
"time"
)
var (
ErrInvalid = errors.New("值班排班信息不符合要求")
ErrNotFound = errors.New("值班组或排班不存在")
ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试")
ErrCoverage = errors.New("周排班存在空档或重叠")
)
type GroupInput struct {
Name string `json:"name"`
Timezone string `json:"timezone"`
ExpectedVersion int `json:"expectedVersion"`
}
type MemberInput struct {
ContactID string `json:"contactId"`
Role string `json:"role"`
}
type SlotInput struct {
Weekday int `json:"weekday"`
StartMinute int `json:"startMinute"`
EndMinute int `json:"endMinute"`
PrimaryContactID string `json:"primaryContactId"`
BackupContactID string `json:"backupContactId"`
}
type ScheduleInput struct {
EffectiveFrom time.Time `json:"effectiveFrom"`
EffectiveTo *time.Time `json:"effectiveTo"`
Slots []SlotInput `json:"slots"`
}
type OverrideInput struct {
OriginalContactID string `json:"originalContactId"`
ReplacementContactID string `json:"replacementContactId"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
Reason string `json:"reason"`
}
func normalizeGroup(input GroupInput) (GroupInput, error) {
input.Name = strings.TrimSpace(input.Name)
input.Timezone = strings.TrimSpace(input.Timezone)
if input.Name == "" || len([]rune(input.Name)) > 128 {
return GroupInput{}, ErrInvalid
}
if _, err := time.LoadLocation(input.Timezone); err != nil {
return GroupInput{}, ErrInvalid
}
return input, nil
}
func validateSlots(slots []SlotInput) error {
if len(slots) == 0 {
return ErrCoverage
}
byDay := map[int][]SlotInput{}
for _, slot := range slots {
if slot.Weekday < 0 || slot.Weekday > 6 || slot.StartMinute < 0 || slot.EndMinute > 1440 || slot.StartMinute >= slot.EndMinute || slot.PrimaryContactID == "" || slot.BackupContactID == "" || slot.PrimaryContactID == slot.BackupContactID {
return ErrInvalid
}
byDay[slot.Weekday] = append(byDay[slot.Weekday], slot)
}
for day := 0; day < 7; day++ {
daySlots := byDay[day]
sort.Slice(daySlots, func(i, j int) bool { return daySlots[i].StartMinute < daySlots[j].StartMinute })
cursor := 0
for _, slot := range daySlots {
if slot.StartMinute != cursor {
return ErrCoverage
}
cursor = slot.EndMinute
}
if cursor != 1440 {
return ErrCoverage
}
}
return nil
}
func normalizeOverride(input OverrideInput) (OverrideInput, error) {
input.Reason = strings.TrimSpace(input.Reason)
if input.OriginalContactID == "" || input.ReplacementContactID == "" || input.OriginalContactID == input.ReplacementContactID || input.StartsAt.IsZero() || !input.EndsAt.After(input.StartsAt) || input.Reason == "" || len([]rune(input.Reason)) > 256 {
return OverrideInput{}, ErrInvalid
}
return input, nil
}
@@ -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
}
+21
View File
@@ -0,0 +1,21 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/contact"
"go-admin/common/middleware"
)
func init() { registrars = append(registrars, registerContactRouter) }
func registerContactRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
h := contact.Handler{}
secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
secured.GET("/contacts", h.List)
secured.POST("/contacts", h.Create)
secured.PUT("/contacts/:id", h.Update)
secured.PUT("/contacts/:id/enabled", h.SetEnabled)
secured.POST("/contacts/:id/channels", h.AddChannel)
secured.POST("/contact-channels/:id/validations", h.ValidateChannel)
}
@@ -0,0 +1,22 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/duty_schedule"
"go-admin/common/middleware"
)
func init() { registrars = append(registrars, registerDutyScheduleRouter) }
func registerDutyScheduleRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
h := duty_schedule.Handler{}
secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
secured.GET("/duty-groups", h.List)
secured.POST("/duty-groups", h.CreateGroup)
secured.PUT("/duty-groups/:id", h.UpdateGroup)
secured.POST("/duty-groups/:id/members", h.AddMember)
secured.POST("/duty-groups/:id/schedules", h.CreateSchedule)
secured.POST("/duty-schedules/:id/publish", h.Publish)
secured.POST("/duty-groups/:id/overrides", h.CreateOverride)
}
+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)
}
+3 -1
View File
@@ -21,6 +21,7 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/router"
"go-admin/app/bell/alert_lifecycle"
"go-admin/app/bell/contact"
bellrouter "go-admin/app/bell/router"
"go-admin/app/bell/synthetic"
"go-admin/common/bellconfig"
@@ -184,7 +185,8 @@ func initRouter() {
Use(common.RequestId(pkg.TrafficKey)).
Use(api.SetRequestLogger).
Use(synthetic.RedactRequestBody()).
Use(alert_lifecycle.RedactRequestBody())
Use(alert_lifecycle.RedactRequestBody()).
Use(contact.RedactRequestBody())
common.InitMiddleware(r)
@@ -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,160 @@
package version
import (
"fmt"
"runtime"
"go-admin/app/bell/contact"
duty "go-admin/app/bell/duty_schedule"
"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), migrateBellContactSchedule)
}
func migrateBellContactSchedule(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&contact.Contact{}, &contact.Channel{}, &contact.ChannelValidation{}, &contact.AuditFact{}, &duty.Group{}, &duty.Member{}, &duty.ScheduleVersion{}, &duty.RotationSlot{}, &duty.Override{}, &duty.AuditFact{}); err != nil {
return err
}
if tx.Dialector.Name() == "postgres" {
for _, sql := range contactScheduleSQL {
if err := tx.Exec(sql).Error; err != nil {
return err
}
}
if err := seedContactScheduleAccess(tx); err != nil {
return err
}
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
})
}
var contactScheduleSQL = []string{
`ALTER TABLE bell_contacts ADD CONSTRAINT bell_contacts_version_check CHECK (version > 0)`,
`ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_kind_check CHECK (kind IN ('sms','voice'))`,
`ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`CREATE UNIQUE INDEX bell_contact_channel_identity_idx ON bell_contact_channels(contact_id,kind,address_fingerprint)`,
`CREATE TRIGGER bell_contact_channels_immutable BEFORE UPDATE OR DELETE ON bell_contact_channels FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_status_check CHECK (status IN ('verified','failed'))`,
`ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_channel_fk FOREIGN KEY (channel_id) REFERENCES bell_contact_channels(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_contact_audit_facts ADD CONSTRAINT bell_contact_audit_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_duty_groups ADD CONSTRAINT bell_duty_groups_version_check CHECK (version > 0)`,
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_role_check CHECK (role IN ('primary','backup'))`,
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`CREATE UNIQUE INDEX bell_duty_schedule_group_version_idx ON bell_duty_schedule_versions(group_id,version)`,
`ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_status_check CHECK (status IN ('draft','published'))`,
`ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`CREATE OR REPLACE FUNCTION bell_guard_schedule_version() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' OR OLD.status = 'published' THEN RAISE EXCEPTION 'Bell published schedule cannot be changed' USING ERRCODE = '55000'; END IF; IF NEW.status <> 'published' OR OLD.status <> 'draft' THEN RAISE EXCEPTION 'Bell schedule transition is invalid' USING ERRCODE = '55000'; END IF; RETURN NEW; END $$`,
`CREATE TRIGGER bell_duty_schedule_version_guard BEFORE UPDATE OR DELETE ON bell_duty_schedule_versions FOR EACH ROW EXECUTE FUNCTION bell_guard_schedule_version()`,
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_range_check CHECK (weekday BETWEEN 0 AND 6 AND start_minute >= 0 AND end_minute <= 1440 AND start_minute < end_minute AND primary_contact_id <> backup_contact_id)`,
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_version_fk FOREIGN KEY (schedule_version_id) REFERENCES bell_duty_schedule_versions(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_primary_fk FOREIGN KEY (primary_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_backup_fk FOREIGN KEY (backup_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_range_check CHECK (starts_at < ends_at AND original_contact_id <> replacement_contact_id)`,
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_original_fk FOREIGN KEY (original_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_replacement_fk FOREIGN KEY (replacement_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_duty_audit_facts ADD CONSTRAINT bell_duty_audit_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`CREATE TRIGGER bell_contact_validations_immutable BEFORE UPDATE OR DELETE ON bell_contact_channel_validations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_contact_audit_immutable BEFORE UPDATE OR DELETE ON bell_contact_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_duty_slots_immutable BEFORE UPDATE OR DELETE ON bell_duty_rotation_slots FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_duty_overrides_immutable BEFORE UPDATE OR DELETE ON bell_duty_overrides FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_duty_audit_immutable BEFORE UPDATE OR DELETE ON bell_duty_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
}
type contactScheduleSeed struct {
ID int
Path string
Action string
}
func seedContactScheduleAccess(tx *gorm.DB) error {
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'),GREATEST((SELECT max(menu_id) FROM sys_menu),1));SELECT setval(pg_get_serial_sequence('sys_api','id'),GREATEST((SELECT max(id) FROM sys_api),1))`).Error; err != nil {
return err
}
var rootID int
if err := tx.Raw("SELECT menu_id FROM sys_menu WHERE path='/bell' AND parent_id=0 ORDER BY menu_id LIMIT 1").Scan(&rootID).Error; err != nil || rootID == 0 {
return fmt.Errorf("Bell menu root missing")
}
contacts, err := insertContactScheduleMenu(tx, rootID, "BellContacts", "联系人与通道", "user", "contacts", "C", "bell:contact:list", "", "/bell/contacts/index", 4)
if err != nil {
return err
}
dutyMenu, err := insertContactScheduleMenu(tx, rootID, "BellDutySchedules", "值班排班", "time", "duty-schedules", "C", "bell:duty:list", "", "/bell/duty-schedules/index", 5)
if err != nil {
return err
}
contactWrite, err := insertContactScheduleMenu(tx, contacts.ID, "", "维护联系人", "", "", "F", "bell:contact:write", "POST", "", 1)
if err != nil {
return err
}
dutyWrite, err := insertContactScheduleMenu(tx, dutyMenu.ID, "", "维护排班", "", "", "F", "bell:duty:write", "POST", "", 1)
if err != nil {
return err
}
specs := []struct{ title, path, action string }{
{"联系人列表", "/api/v1/bell/contacts", "GET"}, {"新增联系人", "/api/v1/bell/contacts", "POST"}, {"修改联系人", "/api/v1/bell/contacts/:id", "PUT"}, {"启停联系人", "/api/v1/bell/contacts/:id/enabled", "PUT"}, {"新增联系通道", "/api/v1/bell/contacts/:id/channels", "POST"}, {"记录通道验证", "/api/v1/bell/contact-channels/:id/validations", "POST"},
{"值班组列表", "/api/v1/bell/duty-groups", "GET"}, {"新增值班组", "/api/v1/bell/duty-groups", "POST"}, {"修改值班组", "/api/v1/bell/duty-groups/:id", "PUT"}, {"保存值班成员", "/api/v1/bell/duty-groups/:id/members", "POST"}, {"新增排班版本", "/api/v1/bell/duty-groups/:id/schedules", "POST"}, {"发布排班版本", "/api/v1/bell/duty-schedules/:id/publish", "POST"}, {"新增临时替班", "/api/v1/bell/duty-groups/:id/overrides", "POST"},
}
apis := make([]contactScheduleSeed, 0, len(specs))
for _, s := range specs {
v, e := insertContactScheduleAPI(tx, s.title, s.path, s.action)
if e != nil {
return e
}
apis = append(apis, v)
}
links := map[int][]contactScheduleSeed{contacts.ID: {apis[0]}, contactWrite.ID: apis[1:6], dutyMenu.ID: {apis[6]}, dutyWrite.ID: apis[7:]}
for menu, items := range links {
for _, item := range items {
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", menu, item.ID).Error; err != nil {
return err
}
}
}
var operator int
if err := tx.Raw("SELECT role_id FROM sys_role WHERE role_key='operator' AND deleted_at IS NULL ORDER BY role_id LIMIT 1").Scan(&operator).Error; err != nil {
return err
}
if operator != 0 {
for _, menu := range []contactScheduleSeed{contacts, dutyMenu} {
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operator, menu.ID).Error; err != nil {
return err
}
}
for _, item := range []contactScheduleSeed{apis[0], apis[6]} {
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
return err
}
}
}
return nil
}
func insertContactScheduleMenu(tx *gorm.DB, parent int, name, title, icon, path, menuType, permission, action, component string, sort int) (contactScheduleSeed, error) {
var id int
err := tx.Raw(`INSERT INTO sys_menu(menu_name,title,icon,path,paths,menu_type,action,permission,parent_id,no_cache,breadcrumb,component,sort,visible,is_frame,create_by,update_by,created_at,updated_at) VALUES(?,?,?,?, '',?,?,?,?,false,'',?,?, '0','1',1,1,now(),now()) RETURNING menu_id`, name, title, icon, path, menuType, action, permission, parent, component, sort).Scan(&id).Error
if err != nil {
return contactScheduleSeed{}, err
}
var parentPaths string
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id=?", parent).Scan(&parentPaths).Error; err != nil {
return contactScheduleSeed{}, err
}
if err = tx.Exec("UPDATE sys_menu SET paths=? WHERE menu_id=?", fmt.Sprintf("%s/%d", parentPaths, id), id).Error; err != nil {
return contactScheduleSeed{}, err
}
return contactScheduleSeed{ID: id}, nil
}
func insertContactScheduleAPI(tx *gorm.DB, title, path, action string) (contactScheduleSeed, error) {
var id int
err := tx.Raw(`INSERT INTO sys_api(handle,title,path,type,action,created_at,updated_at,create_by,update_by) VALUES('',?,?, 'BUS',?,now(),now(),1,1) RETURNING id`, title, path, action).Scan(&id).Error
return contactScheduleSeed{ID: id, Path: path, Action: action}, err
}
@@ -0,0 +1,36 @@
package version
import (
"testing"
"go-admin/app/bell/contact"
duty "go-admin/app/bell/duty_schedule"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestBellContactScheduleMigrationIsIdempotent(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)
}
for i := 0; i < 2; i++ {
if err = migrateBellContactSchedule(db, "2026090110000"); err != nil {
t.Fatalf("attempt %d: %v", i+1, err)
}
}
for name, model := range map[string]any{"contacts": &contact.Contact{}, "channels": &contact.Channel{}, "validations": &contact.ChannelValidation{}, "groups": &duty.Group{}, "members": &duty.Member{}, "versions": &duty.ScheduleVersion{}, "slots": &duty.RotationSlot{}, "overrides": &duty.Override{}} {
if !db.Migrator().HasTable(model) {
t.Fatalf("%s table missing", name)
}
}
var count int64
if err = db.Model(&common.Migration{}).Where("version=?", "2026090110000").Count(&count).Error; err != nil || count != 1 {
t.Fatalf("migration records=%d err=%v", count, err)
}
}
@@ -0,0 +1,169 @@
package bell_contact_schedule_test
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
adminmodels "go-admin/app/admin/models"
"go-admin/app/bell/contact"
duty "go-admin/app/bell/duty_schedule"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestContactSchedulePostgres(t *testing.T) {
dsn := os.Getenv("BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL to run PostgreSQL verification")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
key := []byte("0123456789abcdef0123456789abcdef")
contacts := contact.NewService(db, key)
primary, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人甲", Role: "主值班"}, 1)
if err != nil {
t.Fatal(err)
}
backup, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人乙", Role: "备值班"}, 1)
if err != nil {
t.Fatal(err)
}
primaryChannel, err := contacts.AddChannel(ctx, primary.ID, contact.ChannelInput{Kind: "sms", Address: "+8613800000001"}, 1)
if err != nil {
t.Fatal(err)
}
backupChannel, err := contacts.AddChannel(ctx, backup.ID, contact.ChannelInput{Kind: "voice", Address: "+8613800000002"}, 1)
if err != nil {
t.Fatal(err)
}
if strings.Contains(primaryChannel.AddressMasked, "13800000001") {
t.Fatal("channel response leaked address")
}
plain, err := contacts.DecryptChannelAddress(ctx, primaryChannel.ID)
if err != nil || plain != "+8613800000001" {
t.Fatalf("server-only decrypt failed: %q %v", plain, err)
}
encoded, _ := json.Marshal(primaryChannel)
if strings.Contains(string(encoded), plain) {
t.Fatal("serialized channel leaked plaintext")
}
if _, err = contacts.RecordValidation(ctx, primaryChannel.ID, "verified", "合成验证", 1); err != nil {
t.Fatal(err)
}
if _, err = contacts.RecordValidation(ctx, backupChannel.ID, "verified", "合成验证", 1); err != nil {
t.Fatal(err)
}
if _, err = contacts.Update(ctx, primary.ID, contact.WriteInput{Name: "联系人甲", Role: "主值班", ExpectedVersion: 99}, 1); !errors.Is(err, contact.ErrConflict) {
t.Fatalf("stale contact update err=%v", err)
}
if _, err = contacts.SetEnabled(ctx, primary.ID, false, primary.Version, 1); err != nil {
t.Fatal(err)
}
listed, _, err := contacts.List(ctx, contact.PageQuery{PageIndex: 1, PageSize: 20})
if err != nil || len(listed) != 2 {
t.Fatalf("contact list len=%d err=%v", len(listed), err)
}
var primaryView *contact.ContactView
for index := range listed {
if listed[index].ID == primary.ID {
primaryView = &listed[index]
}
}
if primaryView == nil || primaryView.Enabled || len(primaryView.Channels) != 1 || primaryView.Channels[0].Status != "verified" {
t.Fatalf("contact enabled state was coupled to validation: %#v", primaryView)
}
// Re-enable with the new version before assigning duty.
var disabled contact.Contact
if err = db.First(&disabled, "id=?", primary.ID).Error; err != nil {
t.Fatal(err)
}
if _, err = contacts.SetEnabled(ctx, primary.ID, true, disabled.Version, 1); err != nil {
t.Fatal(err)
}
duties := duty.NewService(db)
group, err := duties.CreateGroup(ctx, duty.GroupInput{Name: "夜间值班组", Timezone: "Asia/Shanghai"}, 1)
if err != nil {
t.Fatal(err)
}
if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: primary.ID, Role: "primary"}, 1); err != nil {
t.Fatal(err)
}
if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: backup.ID, Role: "backup"}, 1); err != nil {
t.Fatal(err)
}
if _, err = duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: []duty.SlotInput{{Weekday: 0, StartMinute: 0, EndMinute: 720, PrimaryContactID: primary.ID, BackupContactID: backup.ID}}}, 1); !errors.Is(err, duty.ErrCoverage) {
t.Fatalf("schedule gap was accepted: %v", err)
}
slots := make([]duty.SlotInput, 0, 7)
for day := 0; day < 7; day++ {
slots = append(slots, duty.SlotInput{Weekday: day, StartMinute: 0, EndMinute: 1440, PrimaryContactID: primary.ID, BackupContactID: backup.ID})
}
v1, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: slots}, 1)
if err != nil {
t.Fatal(err)
}
published, err := duties.Publish(ctx, v1.ID, 1)
if err != nil || published.Status != "published" {
t.Fatalf("publish status=%s err=%v", published.Status, err)
}
v2, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(24 * time.Hour), Slots: slots}, 1)
if err != nil || v2.Version != 2 {
t.Fatalf("second version=%d err=%v", v2.Version, err)
}
var persisted duty.ScheduleVersion
if err = db.First(&persisted, "id=?", v1.ID).Error; err != nil || persisted.Version != 1 || persisted.Status != "published" {
t.Fatalf("historical version changed: %#v err=%v", persisted, err)
}
now := time.Now().UTC().Add(2 * time.Hour)
if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now, EndsAt: now.Add(time.Hour), Reason: "合成替班"}, 1); err != nil {
t.Fatal(err)
}
if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now.Add(30 * time.Minute), EndsAt: now.Add(90 * time.Minute), Reason: "重叠替班"}, 1); !errors.Is(err, duty.ErrConflict) {
t.Fatalf("overlap err=%v", err)
}
if err = db.Model(&contact.ChannelValidation{}).Where("channel_id=?", primaryChannel.ID).Update("detail", "tampered").Error; err == nil {
t.Fatal("validation fact was mutable")
}
if err = db.Model(&duty.RotationSlot{}).Where("schedule_version_id=?", v1.ID).Update("start_minute", 1).Error; err == nil {
t.Fatal("published rotation slot was mutable")
}
if err = db.Model(&duty.ScheduleVersion{}).Where("id=?", v1.ID).Update("effective_from", time.Now().UTC()).Error; err == nil {
t.Fatal("published schedule version was mutable")
}
var menus, reads, writes int64
if err = db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id=rm.role_id").Joins("JOIN sys_menu m ON m.menu_id=rm.menu_id").Where("r.role_key=? AND m.path IN ?", "operator", []string{"contacts", "duty-schedules"}).Count(&menus).Error; err != nil {
t.Fatal(err)
}
if err = db.Table("casbin_rule").Where("v0=? AND v2=? AND v1 IN ?", "operator", "GET", []string{"/api/v1/bell/contacts", "/api/v1/bell/duty-groups"}).Count(&reads).Error; err != nil {
t.Fatal(err)
}
if err = db.Table("casbin_rule").Where("v0=? AND v2<>? AND (v1 LIKE ? OR v1 LIKE ?)", "operator", "GET", "/api/v1/bell/contacts%", "/api/v1/bell/duty-%").Count(&writes).Error; err != nil {
t.Fatal(err)
}
if menus != 2 || reads != 2 || writes != 0 {
t.Fatalf("operator access escaped scope: menus=%d reads=%d writes=%d", menus, reads, writes)
}
password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD")
if password != "" {
var roleID int
if err = db.Table("sys_role").Select("role_id").Where("role_key=?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("operator role id=%d err=%v", roleID, err)
}
user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
if err = db.Create(&user).Error; err != nil {
t.Fatal(err)
}
}
}
@@ -0,0 +1,124 @@
[CmdletBinding()]
param([string]$PostgresBin = 'D:\pgsql17\bin')
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$pgStarted = $false
$server = $null
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-183-' + [guid]::NewGuid().ToString('N'))
$data = Join-Path $testRoot 'postgres'
$log = Join-Path $testRoot 'postgres.log'
$pgOut = Join-Path $testRoot 'pg.out'
$pgErr = Join-Path $testRoot 'pg.err'
$serverOut = Join-Path $testRoot 'bell.out.log'
$serverErr = Join-Path $testRoot 'bell.err.log'
$serverExe = Join-Path $testRoot 'bell-server.exe'
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Get-FreePort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
}
function Wait-Port([int]$Port) {
for ($attempt = 0; $attempt -lt 120; $attempt++) {
try {
$client = [Net.Sockets.TcpClient]::new()
$open = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
$client.Dispose()
if ($open) { return }
} catch {}
Start-Sleep -Milliseconds 250
}
throw "PostgreSQL port $Port did not open"
}
function Wait-Health([string]$BaseUrl) {
for ($attempt = 0; $attempt -lt 100; $attempt++) {
try {
$health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy
if ($health.status -eq 'ok') { return }
} catch {}
Start-Sleep -Milliseconds 300
}
throw 'Bell health endpoint did not become ready'
}
New-Item -ItemType Directory -Path $testRoot | Out-Null
$pgPort = Get-FreePort
$bellPort = Get-FreePort
$baseUrl = "http://127.0.0.1:$bellPort"
try {
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name) -PathType Leaf)) { throw "Missing PostgreSQL tool: $name" }
}
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $pgPort -h 127.0.0.1`" start"
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -RedirectStandardOutput $pgOut -RedirectStandardError $pgErr -WindowStyle Hidden | Out-Null
Wait-Port $pgPort
$pgStarted = $true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres bell_183
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=bell_183 sslmode=disable"
$env:BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_183_admin'
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_CONTACT_CHANNEL_KEY = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('0123456789abcdef0123456789abcdef'))
$env:BELL_HOST = '127.0.0.1'
$env:BELL_PORT = $bellPort.ToString()
Push-Location $serverRoot
try {
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
if ($LASTEXITCODE -ne 0) { throw "migration failed: $(Join-Path $testRoot 'migrate.log')" }
go test ./tests/bell_contact_schedule -count=1 -v
if ($LASTEXITCODE -ne 0) { throw 'contact schedule tests failed' }
go build -o $serverExe .
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
} finally { Pop-Location }
$server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', 'config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
Wait-Health $baseUrl
$adminBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
$admin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminBody -NoProxy
$adminHeaders = @{ Authorization = "Bearer $($admin.token)" }
$contactBody = @{ name = 'HTTP联系人'; role = '测试值班' } | ConvertTo-Json -Compress
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $contactBody -NoProxy
if ([int]$created.code -ne 200) { throw 'administrator contact create failed' }
$address = '+8613900000003'
$channelBody = @{ kind = 'sms'; address = $address } | ConvertTo-Json -Compress
$channel = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts/$($created.data.id)/channels" -Headers $adminHeaders -ContentType 'application/json' -Body $channelBody -NoProxy
if ([int]$channel.code -ne 200 -or ($channel | ConvertTo-Json -Depth 10 -Compress).Contains($address)) { throw 'write-only channel HTTP boundary failed' }
$logRow = ''
for ($attempt = 0; $attempt -lt 40; $attempt++) {
$logRow = & (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $pgPort -U postgres -d bell_183 -Atc "SELECT id::text || '|' || coalesce(oper_param,'') FROM sys_opera_log WHERE oper_url LIKE '/api/v1/bell/contacts/%/channels' ORDER BY id DESC LIMIT 1"
if ($LASTEXITCODE -ne 0) { throw 'operation log query failed' }
if ($logRow) { break }
Start-Sleep -Milliseconds 100
}
if (-not $logRow -or $logRow.Contains($address)) { throw "operation log redaction failed: $logRow" }
$loggedBody = ($logRow -split '\|', 2)[1]
if ($loggedBody -and -not $loggedBody.Contains('"redacted":true')) { throw "unexpected operation log marker: $loggedBody" }
$operatorBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
$operator = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorBody -NoProxy
$operatorHeaders = @{ Authorization = "Bearer $($operator.token)" }
foreach ($path in @('/api/v1/bell/contacts', '/api/v1/bell/duty-groups')) {
$read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -NoProxy
if ([int]$read.code -ne 200) { throw "operator read failed: $path" }
}
$denied = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $operatorHeaders -ContentType 'application/json' -Body $contactBody -NoProxy
if ([int]$denied.code -ne 403) { throw "operator write returned $($denied.code)" }
$menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -NoProxy
$menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress
foreach ($title in @('联系人与通道', '值班排班')) { if (-not $menuJson.Contains($title)) { throw "operator menu missing $title" } }
Write-Output 'BELL_183_HTTP admin_contact=200 channel_write_only=true operator_reads=200 operator_write=403 menus=true'
} finally {
if ($null -ne $server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000) | Out-Null }
if ($pgStarted) { & (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $testRoot 'stop.log') }
foreach ($name in @('BELL_DATABASE_URL', 'BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_RULE_ALERT_OPERATOR_PASSWORD', 'BELL_CONTACT_CHANNEL_KEY', 'BELL_HOST', 'BELL_PORT')) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue }
Write-Verbose "Bell #183 artifacts: $testRoot"
}
@@ -0,0 +1,22 @@
package bell_contact_schedule_test
import (
"encoding/base64"
"errors"
"testing"
"go-admin/app/bell/contact"
)
func TestChannelKeyAndWriteOnlyRoundTrip(t *testing.T) {
encoded := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
key, err := contact.ParseChannelKey(encoded)
if err != nil || len(key) != 32 {
t.Fatalf("key parse failed: len=%d err=%v", len(key), err)
}
for _, value := range []string{"", "short", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef"))} {
if _, err = contact.ParseChannelKey(value); !errors.Is(err, contact.ErrChannelKeyUnavailable) {
t.Fatalf("invalid key accepted: %q err=%v", value, err)
}
}
}
@@ -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) }
+7
View File
@@ -0,0 +1,7 @@
import request from '@/utils/request'
export function listContacts(query) { return request({ url: '/api/v1/bell/contacts', method: 'get', params: query }) }
export function createContact(data) { return request({ url: '/api/v1/bell/contacts', method: 'post', data }) }
export function updateContact(id, data) { return request({ url: `/api/v1/bell/contacts/${id}`, method: 'put', data }) }
export function setContactEnabled(id, enabled, expectedVersion) { return request({ url: `/api/v1/bell/contacts/${id}/enabled`, method: 'put', data: { enabled, expectedVersion }}) }
export function addContactChannel(id, data) { return request({ url: `/api/v1/bell/contacts/${id}/channels`, method: 'post', data }) }
export function validateContactChannel(id, data) { return request({ url: `/api/v1/bell/contact-channels/${id}/validations`, method: 'post', data }) }
+8
View File
@@ -0,0 +1,8 @@
import request from '@/utils/request'
export function listDutyGroups(query) { return request({ url: '/api/v1/bell/duty-groups', method: 'get', params: query }) }
export function createDutyGroup(data) { return request({ url: '/api/v1/bell/duty-groups', method: 'post', data }) }
export function updateDutyGroup(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}`, method: 'put', data }) }
export function saveDutyMember(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/members`, method: 'post', data }) }
export function createDutySchedule(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/schedules`, method: 'post', data }) }
export function publishDutySchedule(id) { return request({ url: `/api/v1/bell/duty-schedules/${id}/publish`, method: 'post' }) }
export function createDutyOverride(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/overrides`, method: 'post', data }) }
+21
View File
@@ -0,0 +1,21 @@
<template>
<BasicLayout><template #wrapper><el-card>
<template #header><div class="heading"><div><h2>联系人与通道</h2><p>通道地址保存后只显示脱敏值,验证状态与联系人启用状态相互独立。</p></div><el-button v-permisaction="['bell:contact:write']" type="primary" @click="openCreate">新增联系人</el-button></div></template>
<el-form ref="queryForm" :model="query" :inline="true"><el-form-item label="联系人" prop="name"><el-input v-model="query.name" clearable placeholder="姓名或岗位" @keyup.enter="search" /></el-form-item><el-form-item label="状态" prop="enabled"><el-select v-model="query.enabled" clearable placeholder="全部" style="width:120px"><el-option label="启用" :value="true" /><el-option label="停用" :value="false" /></el-select></el-form-item><el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state" />
<el-table v-loading="loading" :data="items" border row-key="id">
<el-table-column prop="name" label="联系人" min-width="130" /><el-table-column prop="role" label="岗位" min-width="130" />
<el-table-column label="通道" min-width="260"><template #default="scope"><div v-if="scope.row.channels.length"><el-tag v-for="ch in scope.row.channels" :key="ch.id" :type="statusType(ch.status)" class="channel">{{ kindName(ch.kind) }} {{ ch.addressMasked }} · {{ statusName(ch.status) }}</el-tag></div><span v-else class="muted">未配置</span></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" :disabled="!canWrite" inline-prompt active-text="启" inactive-text="停" @change="toggle(scope.row)" /></template></el-table-column><el-table-column prop="version" label="版本" width="70" />
<el-table-column label="操作" width="210" fixed="right"><template #default="scope"><el-button v-permisaction="['bell:contact:write']" link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button v-permisaction="['bell:contact:write']" link type="primary" @click="openChannel(scope.row)">新增通道</el-button><el-dropdown v-if="scope.row.channels.length && canWrite" @command="command => validate(scope.row, command)"><el-button link type="primary">验证通道</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-for="ch in scope.row.channels" :key="ch.id" :command="ch">{{ kindName(ch.kind) }} {{ ch.addressMasked }}</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
<template #empty><el-empty description="暂无联系人" /></template>
</el-table><pagination v-show="total>0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<el-dialog v-model="contactDialog" :title="editing?'编辑联系人':'新增联系人'" width="min(520px, calc(100vw - 32px))" :close-on-click-modal="false"><el-form ref="contactForm" :model="form" :rules="rules" label-position="top"><el-form-item label="称呼" prop="name"><el-input v-model.trim="form.name" maxlength="128" /></el-form-item><el-form-item label="岗位" prop="role"><el-input v-model.trim="form.role" maxlength="128" /></el-form-item></el-form><template #footer><el-button @click="contactDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveContact">保存</el-button></template></el-dialog>
<el-dialog v-model="channelDialog" title="新增联系通道" width="min(520px, calc(100vw - 32px))" :close-on-click-modal="false"><el-alert title="号码仅在本次填写时可见,保存后只返回脱敏值。" type="info" :closable="false" class="state" /><el-form ref="channelForm" :model="channel" :rules="channelRules" label-position="top"><el-form-item label="通道" prop="kind"><el-select v-model="channel.kind" style="width:100%"><el-option label="短信" value="sms" /><el-option label="语音" value="voice" /></el-select></el-form-item><el-form-item label="号码" prop="address"><el-input v-model.trim="channel.address" autocomplete="off" placeholder="请输入合法测试号码" /></el-form-item></el-form><template #footer><el-button @click="channelDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveChannel">保存</el-button></template></el-dialog>
</el-card></template></BasicLayout>
</template>
<script>
import { addContactChannel, createContact, listContacts, setContactEnabled, updateContact, validateContactChannel } from '@/api/bell/contact'
export default { name: 'BellContacts', data() { return { loading: false, saving: false, error: '', items: [], total: 0, contactDialog: false, channelDialog: false, editing: false, editingId: '', channelContactId: '', query: { pageIndex: 1, pageSize: 10, name: '', enabled: null }, form: { name: '', role: '', expectedVersion: 0 }, channel: { kind: 'sms', address: '' }, rules: { name: [{ required: true, message: '请输入称呼', trigger: 'blur' }], role: [{ required: true, message: '请输入岗位', trigger: 'blur' }] }, channelRules: { kind: [{ required: true, message: '请选择通道', trigger: 'change' }], address: [{ required: true, pattern: /^\+?[0-9 -]{6,24}$/, message: '请输入有效号码', trigger: 'blur' }] }} }, computed: { canWrite() { const p = this.$store.getters.permisaction || []; return p.includes('*:*:*') || p.includes('bell:contact:write') } }, created() { this.load() }, methods: { async load() { this.loading = true; this.error = ''; try { const r = await listContacts(this.query); this.items = r.data.list || []; this.total = r.data.count || 0 } catch (e) { this.error = e.message || '联系人加载失败' } finally { this.loading = false } }, search() { this.query.pageIndex = 1; this.load() }, reset() { this.$refs.queryForm.resetFields(); this.query.enabled = null; this.search() }, openCreate() { this.editing = false; this.editingId = ''; this.form = { name: '', role: '', expectedVersion: 0 }; this.contactDialog = true }, openEdit(row) { this.editing = true; this.editingId = row.id; this.form = { name: row.name, role: row.role, expectedVersion: row.version }; this.contactDialog = true }, openChannel(row) { this.channelContactId = row.id; this.channel = { kind: 'sms', address: '' }; this.channelDialog = true }, async saveContact() { try { await this.$refs.contactForm.validate(); this.saving = true; if (this.editing) await updateContact(this.editingId, this.form); else await createContact(this.form); this.msgSuccess('联系人已保存'); this.contactDialog = false; await this.load() } catch (e) { if (e && e.message) this.error = e.message } finally { this.saving = false } }, async saveChannel() { try { await this.$refs.channelForm.validate(); this.saving = true; await addContactChannel(this.channelContactId, this.channel); this.msgSuccess('通道已加密保存,等待验证'); this.channelDialog = false; await this.load() } catch (e) { if (e && e.message) this.error = e.message } finally { this.saving = false } }, async toggle(row) { try { await setContactEnabled(row.id, row.enabled, row.version); this.msgSuccess(row.enabled ? '联系人已启用' : '联系人已停用'); await this.load() } catch (e) { row.enabled = !row.enabled; this.error = e.message || '状态更新失败' } }, async validate(row, ch) { try { await this.$confirm(`确认合成验证 ${ch.addressMasked} 成功?本操作不会发送外部消息。`, '记录验证结果', { type: 'warning' }); await validateContactChannel(ch.id, { status: 'verified', detail: '人工合成验证' }); this.msgSuccess('验证事实已记录'); await this.load() } catch (e) { if (e !== 'cancel' && e !== 'close' && e && e.message) this.error = e.message } }, kindName(v) { return { sms: '短信', voice: '语音' }[v] || v }, statusName(v) { return { pending: '待验证', verified: '已验证', failed: '验证失败' }[v] || v }, statusType(v) { return { pending: 'warning', verified: 'success', failed: 'danger' }[v] || 'info' } }}
</script>
<style scoped>.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.heading h2{margin:0}.heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state{margin-bottom:16px}.channel{margin:2px 6px 2px 0}.muted{color:var(--el-text-color-secondary)}</style>
File diff suppressed because one or more lines are too long
+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,7 @@
"""Credential-free Sense control-plane connector for Brain."""
from .consumer import ApplyResult, SourceConfigConsumer, SourceConfigError
from .replay import SQLiteReplayStore
from .status import RuntimeStatusPublisher
__all__ = ["ApplyResult", "SourceConfigConsumer", "SourceConfigError", "SQLiteReplayStore", "RuntimeStatusPublisher"]
@@ -0,0 +1,57 @@
"""Machine-authenticated adapters with bounded timeout/backoff and a kill switch."""
from __future__ import annotations
import time
import re
from dataclasses import dataclass
from typing import Callable
from yovision_brain.integration.machine_identity.token import Signer, Verifier, bearer_token
from .consumer import ApplyResult, SourceConfigConsumer
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
@dataclass(frozen=True, slots=True)
class ConnectorResponse:
status: int
body: bytes
correlation_id: str
class SourceConfigEndpoint:
def __init__(self, consumer: SourceConfigConsumer, verifier: Verifier, *, enabled: bool = True, max_body_bytes: int = 10 * 1024 * 1024) -> None:
self._consumer, self._verifier, self._enabled, self._max = consumer, verifier, enabled, max_body_bytes
def receive(self, authorization: str, body: bytes, correlation_id: str) -> ApplyResult:
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
if len(body) > self._max: raise ValueError("REQUEST_TOO_LARGE")
token=bearer_token(authorization)
self._verifier.verify(token,"yovision-brain","source-config:write","POST","/machine/v1/source-config",body)
return self._consumer.apply(body)
class StatusSender:
def __init__(self, signer: Signer, send: Callable[[str, bytes, str, float], ConnectorResponse], *, enabled: bool = True, timeout_seconds: float = 5.0, max_attempts: int = 4, sleeper: Callable[[float], None] = time.sleep) -> None:
if timeout_seconds <= 0 or max_attempts < 1: raise ValueError("invalid connector retry policy")
self._signer,self._send,self._enabled,self._timeout,self._attempts,self._sleep=signer,send,enabled,timeout_seconds,max_attempts,sleeper
def publish(self, body: bytes, correlation_id: str) -> ConnectorResponse:
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
last: Exception|None=None
for attempt in range(self._attempts):
try:
# A retry gets a fresh jti: the previous request may have been
# accepted even when its response was lost.
token=self._signer.mint("yovision-sense",("runtime-status:write",),"POST","/machine/v1/runtime-status",body)
response=self._send("Bearer "+token,body,correlation_id,self._timeout)
if 200<=response.status<300:return response
if response.status<500:raise RuntimeError(f"STATUS_REJECTED_{response.status}")
last=RuntimeError(f"STATUS_REMOTE_{response.status}")
except (TimeoutError,ConnectionError) as exc:last=exc
if attempt+1<self._attempts:self._sleep(min(2**attempt,30))
raise RuntimeError("STATUS_DELIVERY_EXHAUSTED") from last
@@ -0,0 +1,250 @@
"""Strict source-config/v1 validation and atomic last-known-good application."""
from __future__ import annotations
import hashlib
import hmac
import json
import re
import sqlite3
import threading
import time
from contextlib import closing
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Mapping
from urllib.parse import urlsplit
from yovision_brain.rules.models import AreaDefinition, DirectionalLineDefinition, NormalizedPoint, RuleSet
VERSION = "yovision.source-config/v1"
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$")
_EXTENSION_NAMESPACE = re.compile(r"^[A-Za-z][A-Za-z0-9.-]{0,127}$")
_SECRET = re.compile(r"password|secret|credential|cookie|jwt|username|stream_uri", re.I)
class SourceConfigError(ValueError):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
@dataclass(frozen=True, slots=True)
class AppliedConfig:
config_id: str
revision: int
logical_device_id: str
media_ref: str
profile_encoding: str
frame_rate: float
rule_state: str
rules: RuleSet | None
@dataclass(frozen=True, slots=True)
class ApplyResult:
config_id: str
revision: int
state: str
config: AppliedConfig | None
class SourceConfigConsumer:
"""Persists validated snapshots before atomically changing the active pointer."""
def __init__(self, state_path: str | Path, *, clock: Callable[[], float] | None = None) -> None:
self._path = str(state_path)
self._clock = clock or time.time
self._lock = threading.RLock()
with closing(self._connect()) as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS source_snapshots (
config_id TEXT NOT NULL, revision INTEGER NOT NULL, effective_at INTEGER NOT NULL,
state TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(config_id, revision));
CREATE TABLE IF NOT EXISTS source_active (
config_id TEXT PRIMARY KEY, revision INTEGER NOT NULL,
FOREIGN KEY(config_id, revision) REFERENCES source_snapshots(config_id, revision));
"""
)
connection.commit()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self._path, timeout=5)
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA journal_mode=WAL")
return connection
def apply(self, body: bytes) -> ApplyResult:
document = _parse_and_validate(body)
config_id, revision = document["config_id"], document["revision"]
mapped = _map(document)
effective_at = int(_timestamp(document["effective_at"]))
with self._lock, closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
latest = connection.execute(
"SELECT revision, payload, state, effective_at FROM source_snapshots WHERE config_id=? ORDER BY revision DESC LIMIT 1",
(config_id,),
).fetchone()
if latest and revision < latest[0]:
connection.rollback()
raise SourceConfigError("STALE_REVISION")
canonical = body.decode("utf-8")
if latest and revision == latest[0]:
if json.loads(latest[1]) != document:
connection.rollback()
raise SourceConfigError("REVISION_CONFLICT")
connection.rollback()
return ApplyResult(config_id, revision, "idempotent", self.get_active(config_id))
connection.execute(
"INSERT INTO source_snapshots(config_id, revision, effective_at, state, payload) VALUES (?, ?, ?, ?, ?)",
(config_id, revision, effective_at, document["rule_set"]["state"], canonical),
)
if effective_at <= int(self._clock()):
connection.execute(
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
(config_id, revision),
)
state = "applied"
else:
state = "scheduled"
connection.commit()
return ApplyResult(config_id, revision, state, mapped if state == "applied" else self.get_active(config_id))
def activate_due(self) -> tuple[AppliedConfig, ...]:
now = int(self._clock())
activated: list[AppliedConfig] = []
with self._lock, closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
rows = connection.execute(
"SELECT s.payload FROM source_snapshots s JOIN (SELECT config_id, MAX(revision) revision FROM source_snapshots WHERE effective_at<=? GROUP BY config_id) d ON d.config_id=s.config_id AND d.revision=s.revision",
(now,),
).fetchall()
for (payload,) in rows:
document = json.loads(payload)
connection.execute(
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
(document["config_id"], document["revision"]),
)
activated.append(_map(document))
connection.commit()
return tuple(activated)
def get_active(self, config_id: str) -> AppliedConfig | None:
with closing(self._connect()) as connection:
row = connection.execute(
"SELECT s.payload FROM source_active a JOIN source_snapshots s ON s.config_id=a.config_id AND s.revision=a.revision WHERE a.config_id=?",
(config_id,),
).fetchone()
return _map(json.loads(row[0])) if row else None
def _parse_and_validate(body: bytes) -> dict[str, object]:
try:
document = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise SourceConfigError("CONFIG_INVALID") from None
if not isinstance(document, dict):
raise SourceConfigError("CONFIG_INVALID")
if document.get("schema_version") != VERSION:
raise SourceConfigError("UNSUPPORTED_SCHEMA_VERSION")
if _contains_secret(document):
raise SourceConfigError("CONFIG_INVALID")
required = {"schema_version", "config_id", "revision", "published_at", "effective_at", "site", "logical_device", "profile", "media", "rule_set", "integrity"}
if set(document) - (required | {"extensions"}) or not required <= set(document):
raise SourceConfigError("CONFIG_INVALID")
extensions = document.get("extensions", {})
if not isinstance(extensions, dict) or any(
not isinstance(namespace, str)
or not _EXTENSION_NAMESPACE.fullmatch(namespace)
or not isinstance(value, dict)
for namespace, value in extensions.items()
):
raise SourceConfigError("CONFIG_INVALID")
integrity = document.get("integrity")
if not isinstance(integrity, dict) or set(integrity) != {"algorithm", "value"} or integrity.get("algorithm") != "sha256":
raise SourceConfigError("CONFIG_INVALID")
unsigned = dict(document); unsigned.pop("integrity")
digest = hashlib.sha256(json.dumps(unsigned, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()).hexdigest()
if not hmac.compare_digest(digest, str(integrity.get("value", ""))):
raise SourceConfigError("CONFIG_INVALID")
try:
if not _ID.fullmatch(document["config_id"]) or isinstance(document["revision"], bool) or document["revision"] < 1:
raise ValueError
published, effective = _timestamp(document["published_at"]), _timestamp(document["effective_at"])
if effective < published:
raise ValueError
for field in ("site", "logical_device"):
if not isinstance(document[field], dict) or set(document[field]) != {"id"} or not _ID.fullmatch(document[field]["id"]): raise ValueError
_validate_profile(document["profile"])
media = document["media"]
if not isinstance(media, dict) or set(media) != {"ref", "transport"} or media["transport"] != "rtsp" or not isinstance(media["ref"], str) or not media["ref"].startswith("media:") or any(marker in media["ref"] for marker in ("?", "#", "@", "\\", "://")): raise ValueError
_validate_rules(document["rule_set"], document["profile"])
except (KeyError, TypeError, ValueError, AttributeError):
raise SourceConfigError("CONFIG_INVALID") from None
return document
def _validate_profile(profile: object) -> None:
if not isinstance(profile, dict) or set(profile) != {"id", "width", "height", "encoding", "frame_rate"}: raise ValueError
if not _ID.fullmatch(profile["id"]) or profile["encoding"] not in {"H264", "H265", "MJPEG"}: raise ValueError
for field in ("width", "height"):
if isinstance(profile[field], bool) or not isinstance(profile[field], int) or profile[field] < 1: raise ValueError
if isinstance(profile["frame_rate"], bool) or not isinstance(profile["frame_rate"], (int, float)) or profile["frame_rate"] <= 0: raise ValueError
def _validate_rules(rules: object, profile: Mapping[str, object]) -> None:
if not isinstance(rules, dict) or set(rules) != {"version", "state", "profile_binding", "areas", "directional_lines"}: raise ValueError
if not _ID.fullmatch(rules["version"]) or rules["state"] not in {"active", "disabled", "recalibration_required"}: raise ValueError
binding = rules["profile_binding"]
if binding != {"profile_id": profile["id"], "width": profile["width"], "height": profile["height"]}: raise ValueError
if not isinstance(rules["areas"], list) or not isinstance(rules["directional_lines"], list) or len(rules["areas"]) > 1024 or len(rules["directional_lines"]) > 1024: raise ValueError
identifiers: set[str] = set()
for area in rules["areas"]:
if not isinstance(area, dict) or set(area) != {"id", "version", "kind", "enabled", "points"} or area["kind"] != "danger_area" or not isinstance(area["enabled"], bool) or not 3 <= len(area["points"]) <= 256: raise ValueError
_rule_identity(area, identifiers); points = tuple(_point(value) for value in area["points"])
polygon = sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(points, points[1:]+points[:1])) / 2
if abs(polygon) < 1e-9: raise ValueError
for line in rules["directional_lines"]:
if not isinstance(line, dict) or set(line) != {"id", "version", "kind", "enabled", "start", "end", "trigger_direction"} or line["kind"] != "directional_line" or not isinstance(line["enabled"], bool) or line["trigger_direction"] not in {"left_to_right", "right_to_left"}: raise ValueError
_rule_identity(line, identifiers)
if _point(line["start"]) == _point(line["end"]): raise ValueError
def _rule_identity(rule: Mapping[str, object], identifiers: set[str]) -> None:
if not isinstance(rule["id"], str) or not _ID.fullmatch(rule["id"]) or rule["id"] in identifiers or isinstance(rule["version"], bool) or not isinstance(rule["version"], int) or rule["version"] < 1: raise ValueError
identifiers.add(rule["id"])
def _point(value: object) -> tuple[float, float]:
if not isinstance(value, dict) or set(value) != {"x", "y"}: raise ValueError
x, y = value["x"], value["y"]
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int,float)) or not isinstance(y,(int,float)) or not 0 <= x <= 1 or not 0 <= y <= 1: raise ValueError
return float(x), float(y)
def _map(document: Mapping[str, object]) -> AppliedConfig:
profile, rules = document["profile"], document["rule_set"]
rule_set = None
if rules["state"] == "active":
rule_set = RuleSet(
version=rules["version"], profile_id=profile["id"], width=profile["width"], height=profile["height"],
areas=tuple(AreaDefinition(a["id"], tuple(NormalizedPoint(**p) for p in a["points"])) for a in rules["areas"] if a["enabled"]),
directional_lines=tuple(DirectionalLineDefinition(l["id"], NormalizedPoint(**l["start"]), NormalizedPoint(**l["end"]), l["trigger_direction"]) for l in rules["directional_lines"] if l["enabled"]),
)
return AppliedConfig(document["config_id"], document["revision"], document["logical_device"]["id"], document["media"]["ref"], profile["encoding"], float(profile["frame_rate"]), rules["state"], rule_set)
def _timestamp(value: object) -> float:
if not isinstance(value, str) or not value.endswith("Z"): raise ValueError
return datetime.fromisoformat(value[:-1] + "+00:00").astimezone(timezone.utc).timestamp()
def _contains_secret(value: object) -> bool:
if isinstance(value, dict): return any(_SECRET.search(str(k)) or _contains_secret(v) for k,v in value.items())
if isinstance(value, list): return any(_contains_secret(item) for item in value)
if isinstance(value, str):
split=urlsplit(value)
return bool(split.username or split.password or value.startswith("file:") or re.match(r"^[A-Za-z]:[\\/]", value))
return False
@@ -0,0 +1,44 @@
"""Brain-owned durable replay storage; never shared with Sense or Bell."""
from __future__ import annotations
import sqlite3
import threading
from contextlib import closing
from pathlib import Path
class SQLiteReplayStore:
def __init__(self, path: str | Path) -> None:
self._path = str(path)
self._lock = threading.Lock()
with closing(self._connect()) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"CREATE TABLE IF NOT EXISTS machine_replay (principal TEXT NOT NULL, token_id TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(principal, token_id))"
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self._path, timeout=5, isolation_level=None)
connection.execute("PRAGMA busy_timeout=5000")
return connection
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
if not principal or not token_id or expires_at <= now:
return False
with self._lock, closing(self._connect()) as connection:
try:
connection.execute("BEGIN IMMEDIATE")
connection.execute("DELETE FROM machine_replay WHERE expires_at <= ?", (now,))
connection.execute(
"INSERT INTO machine_replay(principal, token_id, expires_at) VALUES (?, ?, ?)",
(principal, token_id, expires_at),
)
connection.execute("COMMIT")
return True
except sqlite3.IntegrityError:
connection.execute("ROLLBACK")
return False
except Exception:
connection.execute("ROLLBACK")
return False
@@ -0,0 +1,77 @@
"""Persistent sequence allocation and safe runtime-status/v1 production."""
from __future__ import annotations
import json
import re
import sqlite3
import threading
import uuid
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
from typing import Mapping, Sequence
_LOGICAL_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$")
_ERROR_CODE = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$")
_RUNTIME_STATES = {"unconfigured", "starting", "running", "degraded", "failed", "stopped"}
class RuntimeStatusPublisher:
def __init__(self, state_path: str | Path, brain_instance_ref: str) -> None:
self._path, self._instance, self._lock = str(state_path), brain_instance_ref, threading.Lock()
with closing(self._connect()) as connection:
connection.execute("CREATE TABLE IF NOT EXISTS runtime_sequence(instance_ref TEXT PRIMARY KEY, sequence INTEGER NOT NULL)")
connection.commit()
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self._path, timeout=5)
def build(self, *, runtime_state: str, runtime_version: str, started_at: datetime | None, model_ref: str, model_version: str, configurations: Sequence[Mapping[str, object]], health: Mapping[str, object], inputs: Sequence[Mapping[str, object]], observed_at: datetime | None = None) -> bytes:
_validate(runtime_state, runtime_version, self._instance, model_ref, model_version, configurations, health, inputs)
with self._lock, closing(self._connect()) as connection:
connection.execute("BEGIN IMMEDIATE")
row=connection.execute("SELECT sequence FROM runtime_sequence WHERE instance_ref=?",(self._instance,)).fetchone();sequence=(row[0]+1) if row else 0
connection.execute("INSERT INTO runtime_sequence(instance_ref,sequence) VALUES(?,?) ON CONFLICT(instance_ref) DO UPDATE SET sequence=excluded.sequence",(self._instance,sequence));connection.commit()
observed=(observed_at or datetime.now(timezone.utc)).astimezone(timezone.utc)
document={"schema_version":"yovision.runtime-status/v1","status_id":str(uuid.uuid4()),"brain_instance_ref":self._instance,"sequence":sequence,"observed_at":_utc(observed),"runtime":{"state":runtime_state,"version":runtime_version,"started_at":_utc(started_at) if started_at else None},"model":{"model_ref":model_ref,"version":model_version},"configurations":list(configurations),"health":dict(health),"inputs":list(inputs)}
raw=json.dumps(document,separators=(",",":"),sort_keys=True).encode()
lowered=raw.lower();
for marker in (b"password",b"credential",b"stream_uri",b"cookie",b"jwt",b"file://"):
if marker in lowered: raise ValueError("runtime status contains sensitive field")
return raw
def _utc(value: datetime) -> str:
if value.tzinfo is None: raise ValueError("runtime timestamp must be timezone-aware")
return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00","Z")
def _validate(runtime_state: str, runtime_version: str, instance: str, model_ref: str, model_version: str, configurations: Sequence[Mapping[str, object]], health: Mapping[str, object], inputs: Sequence[Mapping[str, object]]) -> None:
if runtime_state not in _RUNTIME_STATES or not _VERSION.fullmatch(runtime_version) or not _LOGICAL_REF.fullmatch(instance) or not _LOGICAL_REF.fullmatch(model_ref) or not _VERSION.fullmatch(model_version):
raise ValueError("invalid runtime identity or version")
if len(configurations) > 4096 or len(inputs) > 4096:
raise ValueError("runtime status collection too large")
seen: set[str] = set()
for item in configurations:
if set(item) != {"config_id", "apply_state", "applied_revision", "error_code"} or not isinstance(item["config_id"], str) or not _LOGICAL_REF.fullmatch(item["config_id"]) or item["config_id"] in seen:
raise ValueError("invalid configuration status")
seen.add(item["config_id"]); state=item["apply_state"]; revision=item["applied_revision"]; error=item["error_code"]
if state not in {"not_configured","applying","applied","rejected"} or (state=="not_configured" and revision is not None) or (state=="applied" and (isinstance(revision,bool) or not isinstance(revision,int) or revision<1)) or (state=="rejected" and (not isinstance(error,str) or not _ERROR_CODE.fullmatch(error))):
raise ValueError("invalid configuration status")
if set(health) != {"overall","error_codes","metrics"} or health["overall"] not in {"healthy","degraded","unhealthy"} or not _codes(health["error_codes"],32) or not _metrics(health["metrics"]):
raise ValueError("invalid health status")
for item in inputs:
if set(item) != {"input_ref","state","error_codes","metrics"} or not isinstance(item["input_ref"],str) or not _LOGICAL_REF.fullmatch(item["input_ref"]) or item["state"] not in _RUNTIME_STATES or not _codes(item["error_codes"],16) or not _metrics(item["metrics"]):
raise ValueError("invalid input status")
def _codes(value: object, limit: int) -> bool:
return isinstance(value,list) and len(value)<=limit and len(set(value))==len(value) and all(isinstance(code,str) and _ERROR_CODE.fullmatch(code) for code in value)
def _metrics(value: object) -> bool:
if not isinstance(value,Mapping) or set(value)!={"load_percent","queue_depth","latency_ms"}: return False
load,queue,latency=value["load_percent"],value["queue_depth"],value["latency_ms"]
return not isinstance(load,bool) and isinstance(load,(int,float)) and 0<=load<=100 and not isinstance(queue,bool) and isinstance(queue,int) and queue>=0 and not isinstance(latency,bool) and isinstance(latency,(int,float)) and latency>=0
@@ -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,101 @@
from __future__ import annotations
import hashlib
import json
import threading
from datetime import datetime, timedelta, timezone
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from yovision_brain.integration.machine_identity.token import KeyRecord, Registry, Signer, Verifier
from yovision_brain.integration.sense_control.connector import ConnectorResponse, SourceConfigEndpoint, StatusSender
from yovision_brain.integration.sense_control.consumer import SourceConfigConsumer, SourceConfigError
from yovision_brain.integration.sense_control.replay import SQLiteReplayStore
from yovision_brain.integration.sense_control.status import RuntimeStatusPublisher
def source_document(revision: int = 1, *, state: str = "active", effective: int = 0) -> bytes:
now=datetime(2026,8,31,tzinfo=timezone.utc)
document={"schema_version":"yovision.source-config/v1","config_id":"gate-primary","revision":revision,"published_at":now.isoformat().replace("+00:00","Z"),"effective_at":(now+timedelta(seconds=effective)).isoformat().replace("+00:00","Z"),"site":{"id":"site-east"},"logical_device":{"id":"camera-1"},"profile":{"id":"main","width":1920,"height":1080,"encoding":"H264","frame_rate":25},"media":{"ref":"media:site-east/camera-1/main","transport":"rtsp"},"rule_set":{"version":f"rules-{revision}","state":state,"profile_binding":{"profile_id":"main","width":1920,"height":1080},"areas":[{"id":"danger","version":1,"kind":"danger_area","enabled":True,"points":[{"x":.1,"y":.1},{"x":.8,"y":.1},{"x":.5,"y":.8}]}],"directional_lines":[]}}
digest=hashlib.sha256(json.dumps(document,separators=(",",":"),sort_keys=True).encode()).hexdigest();document["integrity"]={"algorithm":"sha256","value":digest}
return json.dumps(document,separators=(",",":"),sort_keys=True).encode()
def with_extension(body: bytes, extensions: object) -> bytes:
document=json.loads(body);document["extensions"]=extensions;unsigned=dict(document);unsigned.pop("integrity");document["integrity"]["value"]=hashlib.sha256(json.dumps(unsigned,separators=(",",":"),sort_keys=True).encode()).hexdigest();return json.dumps(document,separators=(",",":"),sort_keys=True).encode()
def identity(tmp_path, now: int):
private=Ed25519PrivateKey.generate();signer=Signer("yv:sense:east","sense-key-01",private,clock=lambda:now)
registry=Registry([KeyRecord("yv:sense:east","sense-key-01",private.public_key(),"yovision-brain",frozenset({"source-config:write"}))])
replay=SQLiteReplayStore(tmp_path/"replay.sqlite")
return signer,Verifier(registry,replay,clock=lambda:now)
def test_authenticated_apply_is_idempotent_and_replay_survives_restart(tmp_path):
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());signer,verifier=identity(tmp_path,now);body=source_document();consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now);endpoint=SourceConfigEndpoint(consumer,verifier)
token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body)
result=endpoint.receive("Bearer "+token,body,"corr-request-0001");assert result.state=="applied" and result.config.rules is not None
with pytest.raises(ValueError,match="machine_token_replayed"):endpoint.receive("Bearer "+token,body,"corr-request-0001")
restarted=SourceConfigEndpoint(SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now),Verifier(verifier._registry,SQLiteReplayStore(tmp_path/"replay.sqlite"),clock=lambda:now))
with pytest.raises(ValueError,match="machine_token_replayed"):restarted.receive("Bearer "+token,body,"corr-request-0002")
new_token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body);assert restarted.receive("Bearer "+new_token,body,"corr-request-0003").state=="idempotent"
def test_atomic_replay_accepts_once_under_concurrency(tmp_path):
store=SQLiteReplayStore(tmp_path/"atomic.sqlite");results=[]
threads=[threading.Thread(target=lambda:results.append(store.consume("yv:sense:east","token-id",200,100))) for _ in range(12)]
for thread in threads:thread.start()
for thread in threads:thread.join()
assert results.count(True)==1
def test_stale_unknown_profile_and_recalibration_are_safe(tmp_path):
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
assert consumer.apply(source_document(2)).config.rules is not None
with pytest.raises(SourceConfigError,match="STALE_REVISION"):consumer.apply(source_document(1))
invalid=json.loads(source_document(3));invalid["profile"]["width"]=1280;invalid["integrity"]["value"]="0"*64
with pytest.raises(SourceConfigError,match="CONFIG_INVALID"):consumer.apply(json.dumps(invalid).encode())
unknown=json.loads(source_document(3));unknown["schema_version"]="yovision.source-config/v2"
with pytest.raises(SourceConfigError,match="UNSUPPORTED_SCHEMA_VERSION"):consumer.apply(json.dumps(unknown).encode())
safe=consumer.apply(source_document(3,state="recalibration_required"));assert safe.config.rule_state=="recalibration_required" and safe.config.rules is None
def test_future_effective_snapshot_activates_atomically_after_restart(tmp_path):
base=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());current=[base];path=tmp_path/"state.sqlite";consumer=SourceConfigConsumer(path,clock=lambda:current[0])
assert consumer.apply(source_document(1)).state=="applied";scheduled=consumer.apply(source_document(2,effective=60));assert scheduled.state=="scheduled" and scheduled.config.revision==1
current[0]+=61;restarted=SourceConfigConsumer(path,clock=lambda:current[0]);activated=restarted.activate_due();assert activated[0].revision==2 and restarted.get_active("gate-primary").revision==2
def test_unknown_valid_extension_namespace_is_ignored(tmp_path):
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
result=consumer.apply(with_extension(source_document(),{"vendor.example":{"feature":"safe"}}));assert result.state=="applied" and result.config.revision==1
@pytest.mark.parametrize("extensions", [[], {"1invalid":{}}, {"vendor_ok":{}}, {"vendor.example":"not-an-object"}])
def test_invalid_extensions_are_rejected(tmp_path, extensions):
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
with pytest.raises(SourceConfigError,match="CONFIG_INVALID"):consumer.apply(with_extension(source_document(),extensions))
def test_status_sequence_restart_retry_timeout_and_disable(tmp_path):
path=tmp_path/"status.sqlite";publisher=RuntimeStatusPublisher(path,"brain-east-01");health={"overall":"healthy","error_codes":[],"metrics":{"load_percent":1.0,"queue_depth":0,"latency_ms":2.0}}
one=json.loads(publisher.build(runtime_state="running",runtime_version="1.0.0",started_at=datetime.now(timezone.utc),model_ref="people-detection",model_version="1",configurations=[],health=health,inputs=[]));two=json.loads(RuntimeStatusPublisher(path,"brain-east-01").build(runtime_state="running",runtime_version="1.0.0",started_at=None,model_ref="people-detection",model_version="1",configurations=[],health=health,inputs=[]));assert (one["sequence"],two["sequence"])==(0,1)
private=Ed25519PrivateKey.generate();signer=Signer("yv:brain:east","brain-key-01",private,clock=lambda:1_787_000_000);attempts=[]
def send(_auth,_body,_corr,timeout):attempts.append(timeout);raise TimeoutError
sender=StatusSender(signer,send,max_attempts=3,sleeper=lambda _:None)
with pytest.raises(RuntimeError,match="STATUS_DELIVERY_EXHAUSTED"):sender.publish(b"{}","corr-request-0001")
assert attempts==[5.0,5.0,5.0]
disabled=StatusSender(signer,lambda *_:ConnectorResponse(204,b"","corr-request-0001"),enabled=False)
with pytest.raises(RuntimeError,match="CONNECTOR_DISABLED"):disabled.publish(b"{}","corr-request-0001")
@pytest.mark.parametrize("request_id", ["short", "0123456789abcde\n", "0123456789abcde!", "a"*129])
def test_connector_rejects_unsafe_request_ids(tmp_path, request_id):
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());signer,verifier=identity(tmp_path,now);body=source_document();endpoint=SourceConfigEndpoint(SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now),verifier)
token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body)
with pytest.raises(ValueError,match="INVALID_CORRELATION_ID"):endpoint.receive("Bearer "+token,body,request_id)
status_signer=Signer("yv:brain:east","brain-key-01",Ed25519PrivateKey.generate(),clock=lambda:now)
sender=StatusSender(status_signer,lambda *_:ConnectorResponse(204,b"",request_id))
with pytest.raises(ValueError,match="INVALID_CORRELATION_ID"):sender.publish(b"{}",request_id)
@@ -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,118 @@
package brain_control
import (
"errors"
"fmt"
"regexp"
"time"
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
)
const (
sourceConfigPath = "/machine/v1/source-config"
runtimeStatusPath = "/machine/v1/runtime-status"
)
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
type SendResponse struct {
StatusCode int
Body []byte
CorrelationID string
}
type SendFunc func(authorization string, body []byte, correlationID string, timeout time.Duration) (SendResponse, error)
type ConfigSender struct {
Signer mi.Signer
Send SendFunc
Enabled bool
Timeout time.Duration
MaxAttempts int
Sleep func(time.Duration)
}
func (s ConfigSender) Publish(config SourceConfig, correlationID string) (SendResponse, error) {
if !s.Enabled {
return SendResponse{}, errors.New("CONNECTOR_DISABLED")
}
if s.Send == nil || !requestIDPattern.MatchString(correlationID) {
return SendResponse{}, errors.New("invalid connector configuration")
}
if s.Timeout <= 0 {
s.Timeout = 5 * time.Second
}
if s.MaxAttempts == 0 {
s.MaxAttempts = 4
}
if s.MaxAttempts < 1 {
return SendResponse{}, errors.New("invalid connector retry policy")
}
if s.Sleep == nil {
s.Sleep = time.Sleep
}
if err := ValidateSourceConfig(config); err != nil {
return SendResponse{}, err
}
body, err := MarshalSourceConfig(config)
if err != nil {
return SendResponse{}, err
}
var last error
for attempt := 0; attempt < s.MaxAttempts; attempt++ {
token, mintErr := s.Signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", sourceConfigPath, body)
if mintErr != nil {
return SendResponse{}, mintErr
}
response, sendErr := s.Send("Bearer "+token, body, correlationID, s.Timeout)
if sendErr == nil && response.StatusCode >= 200 && response.StatusCode < 300 {
return response, nil
}
if sendErr == nil && response.StatusCode < 500 {
return SendResponse{}, fmt.Errorf("source config rejected: %d", response.StatusCode)
}
if sendErr != nil {
last = sendErr
} else {
last = fmt.Errorf("source config remote status: %d", response.StatusCode)
}
if attempt+1 < s.MaxAttempts {
delay := time.Second << attempt
if delay > 30*time.Second {
delay = 30 * time.Second
}
s.Sleep(delay)
}
}
return SendResponse{}, fmt.Errorf("source config delivery exhausted: %w", last)
}
type RuntimeStatusEndpoint struct {
Verifier mi.Verifier
Store ProjectionStore
Enabled bool
MaxBodyBytes int
}
func (e RuntimeStatusEndpoint) Receive(authorization string, body []byte, correlationID string, expected map[string]int64) (ProjectionView, error) {
if !e.Enabled {
return ProjectionView{}, errors.New("CONNECTOR_DISABLED")
}
if !requestIDPattern.MatchString(correlationID) {
return ProjectionView{}, errors.New("INVALID_CORRELATION_ID")
}
if e.MaxBodyBytes == 0 {
e.MaxBodyBytes = 10 * 1024 * 1024
}
if len(body) > e.MaxBodyBytes {
return ProjectionView{}, errors.New("REQUEST_TOO_LARGE")
}
token, err := mi.BearerToken(authorization)
if err != nil {
return ProjectionView{}, err
}
if _, err = e.Verifier.Verify(token, "yovision-sense", "runtime-status:write", "POST", runtimeStatusPath, body); err != nil {
return ProjectionView{}, err
}
return e.Store.Ingest(body, expected)
}
@@ -0,0 +1,124 @@
package brain_control
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"strings"
"sync"
"testing"
"time"
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func memoryDB(t *testing.T, name string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&ReplayToken{}, &RuntimeProjection{}, &SourceRevision{}); err != nil {
t.Fatal(err)
}
return db
}
func TestSourceMapperAndRecalibration(t *testing.T) {
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
facts := SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site/camera/main", Revision: 1, PublishedAt: now, EffectiveAt: now, Profile: Profile{ID: "main", Width: 1920, Height: 1080, Encoding: "h264", FrameRate: 25}, RuleSetVersion: "rules-1", Areas: []AreaRule{{ID: "danger", Version: 1, Kind: "danger_area", Enabled: true, Points: []Point{{.1, .1}, {.8, .1}, {.5, .8}}}}}
config, err := MapSourceConfig(facts)
if err != nil {
t.Fatal(err)
}
if err = ValidateSourceConfig(config); err != nil {
t.Fatal(err)
}
facts.Revision = 2
facts.NeedsRecalibration = true
config, err = MapSourceConfig(facts)
if err != nil {
t.Fatal(err)
}
if config.RuleSet.State != "recalibration_required" || config.RuleSet.Areas[0].Enabled {
t.Fatal("recalibration did not disable rules")
}
}
func TestReplayAtomicAndRestart(t *testing.T) {
db := memoryDB(t, "replay-package")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
registry, _ := mi.NewRegistry(mi.KeyRecord{Principal: "yv:brain:east", KeyID: "brain-key-01", PublicKey: pub, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true})
signer := mi.Signer{Principal: "yv:brain:east", KeyID: "brain-key-01", PrivateKey: priv, Now: func() time.Time { return now }}
body := []byte("{}")
token, _ := signer.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
accepted := 0
var mu sync.Mutex
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
v := mi.Verifier{Registry: registry, Replay: GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
if _, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
mu.Lock()
accepted++
mu.Unlock()
}
}()
}
wg.Wait()
if accepted != 1 {
t.Fatalf("accepted %d", accepted)
}
v := mi.Verifier{Registry: registry, Replay: GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
if _, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
t.Fatal("restart replay accepted")
}
}
func TestProjectionStaleRecoveryAndMismatch(t *testing.T) {
db := memoryDB(t, "projection-package")
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
store := ProjectionStore{DB: db, Clock: func() time.Time { return now }, StaleAfter: 90 * time.Second}
raw := runtimeFixture("018f4d6a-8d1b-4a25-8b37-9085f9c0d101", 1, now, 2)
view, err := store.Ingest(raw, map[string]int64{"gate": 3})
if err != nil || !view.RevisionMismatch {
t.Fatalf("view %+v err %v", view, err)
}
now = now.Add(91 * time.Second)
view, _ = store.View("brain-east-01")
if !view.Offline {
t.Fatal("not offline")
}
raw = runtimeFixture("018f4d6a-8d1b-4a25-8b37-9085f9c0d102", 2, now, 3)
view, err = store.Ingest(raw, map[string]int64{"gate": 3})
if err != nil || !view.Recovered {
t.Fatalf("recovery %+v err %v", view, err)
}
}
func TestConnectorRejectsUnsafeRequestIDs(t *testing.T) {
for _, value := range []string{"short", "0123456789abcde\n", "0123456789abcde!", strings.Repeat("a", 129)} {
sender := ConfigSender{Enabled: true, Send: func(string, []byte, string, time.Duration) (SendResponse, error) {
t.Fatal("unsafe request id reached transport")
return SendResponse{}, nil
}}
if _, err := sender.Publish(SourceConfig{}, value); err == nil {
t.Fatalf("sender accepted request id %q", value)
}
endpoint := RuntimeStatusEndpoint{Enabled: true}
if _, err := endpoint.Receive("Bearer ignored", nil, value, nil); err == nil || err.Error() != "INVALID_CORRELATION_ID" {
t.Fatalf("endpoint accepted request id %q: %v", value, err)
}
}
}
func runtimeFixture(id string, seq int64, observed time.Time, revision int64) []byte {
value := map[string]any{"schema_version": RuntimeStatusVersion, "status_id": id, "brain_instance_ref": "brain-east-01", "sequence": seq, "observed_at": observed.Format(time.RFC3339), "runtime": map[string]any{"state": "running", "version": "1.0.0", "started_at": observed.Format(time.RFC3339)}, "model": map[string]any{"model_ref": "people", "version": "1"}, "configurations": []any{map[string]any{"config_id": "gate", "apply_state": "applied", "applied_revision": revision, "error_code": nil}}, "health": map[string]any{"overall": "healthy", "error_codes": []any{}, "metrics": map[string]any{"load_percent": 1.0, "queue_depth": 0, "latency_ms": 1.0}}, "inputs": []any{}}
raw, _ := json.Marshal(value)
return raw
}
@@ -0,0 +1,135 @@
package brain_control
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
)
var stableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
func MapSourceConfig(f SourceFacts) (SourceConfig, error) {
if !stableID.MatchString(f.ConfigID) || !stableID.MatchString(f.SiteID) || !stableID.MatchString(f.LogicalDeviceID) || !stableID.MatchString(f.Profile.ID) || f.Revision < 1 || f.Profile.Width < 1 || f.Profile.Height < 1 || f.Profile.FrameRate <= 0 {
return SourceConfig{}, errors.New("invalid source configuration facts")
}
encoding := strings.ToUpper(f.Profile.Encoding)
if encoding != "H264" && encoding != "H265" && encoding != "MJPEG" {
return SourceConfig{}, errors.New("unsupported profile encoding")
}
if f.PublishedAt.IsZero() || f.EffectiveAt.Before(f.PublishedAt) {
return SourceConfig{}, errors.New("invalid source configuration time")
}
if !stableID.MatchString(f.RuleSetVersion) {
return SourceConfig{}, errors.New("invalid rule set version")
}
if strings.ContainsAny(f.MediaPath, "?#@\\") || strings.Contains(f.MediaPath, "://") || f.MediaPath == "" {
return SourceConfig{}, errors.New("media path must be opaque and credential-free")
}
if len(f.Areas) > 1024 || len(f.DirectionalLines) > 1024 {
return SourceConfig{}, errors.New("too many rules")
}
seen := map[string]bool{}
for _, a := range f.Areas {
if !stableID.MatchString(a.ID) || seen[a.ID] || a.Version < 1 || a.Kind != "danger_area" || len(a.Points) < 3 || len(a.Points) > 256 || !validPoints(a.Points) || polygonArea(a.Points) == 0 {
return SourceConfig{}, errors.New("invalid area rule")
}
seen[a.ID] = true
}
for _, l := range f.DirectionalLines {
if !stableID.MatchString(l.ID) || seen[l.ID] || l.Version < 1 || l.Kind != "directional_line" || (l.TriggerDirection != "left_to_right" && l.TriggerDirection != "right_to_left") || !validPoints([]Point{l.Start, l.End}) || l.Start == l.End {
return SourceConfig{}, errors.New("invalid directional line rule")
}
seen[l.ID] = true
}
var out SourceConfig
out.SchemaVersion, out.ConfigID, out.Revision = SourceConfigVersion, f.ConfigID, f.Revision
out.PublishedAt, out.EffectiveAt = f.PublishedAt.UTC(), f.EffectiveAt.UTC()
out.Site.ID, out.LogicalDevice.ID = f.SiteID, f.LogicalDeviceID
out.Profile = f.Profile
out.Profile.Encoding = encoding
out.Media.Ref, out.Media.Transport = "media:"+strings.TrimPrefix(f.MediaPath, "/"), "rtsp"
out.RuleSet.Version = f.RuleSetVersion
out.RuleSet.State = "active"
if f.Disabled {
out.RuleSet.State = "disabled"
}
if f.NeedsRecalibration {
out.RuleSet.State = "recalibration_required"
}
out.RuleSet.ProfileBinding.ProfileID, out.RuleSet.ProfileBinding.Width, out.RuleSet.ProfileBinding.Height = f.Profile.ID, f.Profile.Width, f.Profile.Height
out.RuleSet.Areas = append([]AreaRule(nil), f.Areas...)
out.RuleSet.DirectionalLines = append([]DirectionalLineRule(nil), f.DirectionalLines...)
if out.RuleSet.State != "active" {
for i := range out.RuleSet.Areas {
out.RuleSet.Areas[i].Enabled = false
}
for i := range out.RuleSet.DirectionalLines {
out.RuleSet.DirectionalLines[i].Enabled = false
}
}
digest, err := sourceDigest(out)
if err != nil {
return SourceConfig{}, err
}
out.Integrity.Algorithm, out.Integrity.Value = "sha256", digest
return out, nil
}
func MarshalSourceConfig(config SourceConfig) ([]byte, error) { return json.Marshal(config) }
func sourceDigest(config SourceConfig) (string, error) {
raw, err := json.Marshal(config)
if err != nil {
return "", err
}
var value map[string]any
if err = json.Unmarshal(raw, &value); err != nil {
return "", err
}
delete(value, "integrity")
canonical, err := json.Marshal(value)
if err != nil {
return "", err
}
sum := sha256.Sum256(canonical)
return hex.EncodeToString(sum[:]), nil
}
func validPoints(points []Point) bool {
for _, p := range points {
if p.X < 0 || p.X > 1 || p.Y < 0 || p.Y > 1 {
return false
}
}
return true
}
func polygonArea(p []Point) float64 {
var a float64
for i := range p {
n := p[(i+1)%len(p)]
a += p[i].X*n.Y - n.X*p[i].Y
}
if a < 0 {
a = -a
}
return a / 2
}
func ValidateSourceConfig(config SourceConfig) error {
if config.SchemaVersion != SourceConfigVersion {
return fmt.Errorf("unsupported source config version")
}
digest, err := sourceDigest(config)
if err != nil || config.Integrity.Algorithm != "sha256" || digest != config.Integrity.Value {
return errors.New("source config integrity mismatch")
}
_, err = MapSourceConfig(SourceFacts{ConfigID: config.ConfigID, SiteID: config.Site.ID, LogicalDeviceID: config.LogicalDevice.ID, MediaPath: strings.TrimPrefix(config.Media.Ref, "media:"), Revision: config.Revision, PublishedAt: config.PublishedAt, EffectiveAt: config.EffectiveAt, Profile: config.Profile, RuleSetVersion: config.RuleSet.Version, Disabled: config.RuleSet.State == "disabled", NeedsRecalibration: config.RuleSet.State == "recalibration_required", Areas: config.RuleSet.Areas, DirectionalLines: config.RuleSet.DirectionalLines})
return err
}
func UTCNow() time.Time { return time.Now().UTC() }
@@ -0,0 +1,122 @@
package brain_control
import "time"
const (
SourceConfigVersion = "yovision.source-config/v1"
RuntimeStatusVersion = "yovision.runtime-status/v1"
)
type Point struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type Profile struct {
ID string `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
Encoding string `json:"encoding"`
FrameRate float64 `json:"frame_rate"`
}
type AreaRule struct {
ID string `json:"id"`
Version int64 `json:"version"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Points []Point `json:"points"`
}
type DirectionalLineRule struct {
ID string `json:"id"`
Version int64 `json:"version"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Start Point `json:"start"`
End Point `json:"end"`
TriggerDirection string `json:"trigger_direction"`
}
type SourceConfig struct {
SchemaVersion string `json:"schema_version"`
ConfigID string `json:"config_id"`
Revision int64 `json:"revision"`
PublishedAt time.Time `json:"published_at"`
EffectiveAt time.Time `json:"effective_at"`
Site struct {
ID string `json:"id"`
} `json:"site"`
LogicalDevice struct {
ID string `json:"id"`
} `json:"logical_device"`
Profile Profile `json:"profile"`
Media struct {
Ref string `json:"ref"`
Transport string `json:"transport"`
} `json:"media"`
RuleSet struct {
Version string `json:"version"`
State string `json:"state"`
ProfileBinding struct {
ProfileID string `json:"profile_id"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"profile_binding"`
Areas []AreaRule `json:"areas"`
DirectionalLines []DirectionalLineRule `json:"directional_lines"`
} `json:"rule_set"`
Integrity struct {
Algorithm string `json:"algorithm"`
Value string `json:"value"`
} `json:"integrity"`
}
// SourceFacts is an explicit, credential-free boundary DTO. Callers map their
// GORM entities into it; database models are never serialized as a contract.
type SourceFacts struct {
ConfigID, SiteID, LogicalDeviceID, MediaRouteID, MediaPath string
Revision int64
PublishedAt, EffectiveAt time.Time
Profile Profile
RuleSetVersion string
Disabled, NeedsRecalibration bool
Areas []AreaRule
DirectionalLines []DirectionalLineRule
}
type ReplayToken struct {
Principal string `gorm:"size:128;primaryKey"`
TokenID string `gorm:"size:96;primaryKey"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time `gorm:"not null"`
}
func (ReplayToken) TableName() string { return "sense_brain_runtime_replay_tokens" }
type RuntimeProjection struct {
BrainInstanceRef string `gorm:"size:128;primaryKey"`
StatusID string `gorm:"size:36;not null;uniqueIndex"`
Sequence int64 `gorm:"not null"`
ObservedAt time.Time `gorm:"not null;index"`
ReceivedAt time.Time `gorm:"not null"`
RuntimeState string `gorm:"size:32;not null"`
RuntimeVersion string `gorm:"size:64;not null"`
ModelRef string `gorm:"size:128;not null"`
ModelVersion string `gorm:"size:64;not null"`
HealthOverall string `gorm:"size:32;not null"`
ExpectedRevisionsJSON string `gorm:"type:jsonb;not null"`
ConfigurationsJSON string `gorm:"type:jsonb;not null"`
HealthJSON string `gorm:"type:jsonb;not null"`
InputsJSON string `gorm:"type:jsonb;not null"`
WasOffline bool `gorm:"not null;default:false"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (RuntimeProjection) TableName() string { return "sense_brain_runtime_projections" }
type SourceRevision struct {
ConfigID string `gorm:"size:128;primaryKey"`
Revision int64 `gorm:"not null"`
UpdatedAt time.Time
}
func (SourceRevision) TableName() string { return "sense_brain_source_revisions" }
@@ -0,0 +1,145 @@
package brain_control
import (
"bytes"
"encoding/json"
"errors"
"io"
"regexp"
"time"
)
type runtimeStatus struct {
SchemaVersion string `json:"schema_version"`
StatusID string `json:"status_id"`
BrainInstanceRef string `json:"brain_instance_ref"`
Sequence int64 `json:"sequence"`
ObservedAt time.Time `json:"observed_at"`
Runtime struct {
State string `json:"state"`
Version string `json:"version"`
StartedAt *time.Time `json:"started_at"`
} `json:"runtime"`
Model struct {
ModelRef string `json:"model_ref"`
Version string `json:"version"`
} `json:"model"`
Configurations []configurationStatus `json:"configurations"`
Health healthStatus `json:"health"`
Inputs []inputStatus `json:"inputs"`
}
type configurationStatus struct {
ConfigID string `json:"config_id"`
ApplyState string `json:"apply_state"`
AppliedRevision *int64 `json:"applied_revision"`
ErrorCode *string `json:"error_code"`
}
type metrics struct {
LoadPercent float64 `json:"load_percent"`
QueueDepth int64 `json:"queue_depth"`
LatencyMS float64 `json:"latency_ms"`
}
type healthStatus struct {
Overall string `json:"overall"`
ErrorCodes []string `json:"error_codes"`
Metrics metrics `json:"metrics"`
}
type inputStatus struct {
InputRef string `json:"input_ref"`
State string `json:"state"`
ErrorCodes []string `json:"error_codes"`
Metrics metrics `json:"metrics"`
}
var uuid4 = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
var errCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]{2,63}$`)
func parseRuntimeStatus(raw []byte) (runtimeStatus, error) {
var s runtimeStatus
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if err := d.Decode(&s); err != nil {
return s, errors.New("CONFIG_INVALID")
}
if err := d.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return s, errors.New("CONFIG_INVALID")
}
if s.SchemaVersion != RuntimeStatusVersion {
return s, errors.New("UNSUPPORTED_SCHEMA_VERSION")
}
if !uuid4.MatchString(s.StatusID) || !stableID.MatchString(s.BrainInstanceRef) || s.Sequence < 0 || s.ObservedAt.IsZero() || !validState(s.Runtime.State) || s.Runtime.Version == "" || !stableID.MatchString(s.Model.ModelRef) || s.Model.Version == "" || len(s.Configurations) > 4096 || len(s.Inputs) > 4096 || !validHealth(s.Health) {
return s, errors.New("CONFIG_INVALID")
}
seen := map[string]bool{}
for _, c := range s.Configurations {
if !stableID.MatchString(c.ConfigID) || seen[c.ConfigID] || !validApply(c) {
return s, errors.New("CONFIG_INVALID")
}
seen[c.ConfigID] = true
}
for _, i := range s.Inputs {
if !stableID.MatchString(i.InputRef) || !validState(i.State) || !validMetrics(i.Metrics) || !validCodes(i.ErrorCodes, 16) {
return s, errors.New("CONFIG_INVALID")
}
}
return s, nil
}
func validState(v string) bool {
switch v {
case "unconfigured", "starting", "running", "degraded", "failed", "stopped":
return true
}
return false
}
func validApply(c configurationStatus) bool {
switch c.ApplyState {
case "not_configured":
return c.AppliedRevision == nil
case "applying":
return true
case "applied":
return c.AppliedRevision != nil && *c.AppliedRevision >= 1
case "rejected":
return c.ErrorCode != nil && errCode.MatchString(*c.ErrorCode)
}
return false
}
func validMetrics(m metrics) bool {
return m.LoadPercent >= 0 && m.LoadPercent <= 100 && m.QueueDepth >= 0 && m.LatencyMS >= 0
}
func validCodes(v []string, n int) bool {
if len(v) > n {
return false
}
seen := map[string]bool{}
for _, x := range v {
if seen[x] || !errCode.MatchString(x) {
return false
}
seen[x] = true
}
return true
}
func validHealth(h healthStatus) bool {
return (h.Overall == "healthy" || h.Overall == "degraded" || h.Overall == "unhealthy") && validCodes(h.ErrorCodes, 32) && validMetrics(h.Metrics)
}
func validRuntimeTransition(from, to string) bool {
if from == to {
return true
}
switch from {
case "unconfigured":
return to == "starting" || to == "stopped"
case "starting":
return to == "running" || to == "degraded" || to == "failed" || to == "stopped"
case "running":
return to == "degraded" || to == "failed" || to == "stopped"
case "degraded":
return to == "running" || to == "failed" || to == "stopped"
case "failed":
return to == "starting" || to == "stopped"
case "stopped":
return to == "starting"
}
return false
}
@@ -0,0 +1,159 @@
package brain_control
import (
"encoding/json"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type GORMReplayStore struct{ DB *gorm.DB }
func (s GORMReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
if s.DB == nil || principal == "" || tokenID == "" || !expiresAt.After(now) {
return false
}
return s.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("expires_at <= ?", now).Delete(&ReplayToken{}).Error; err != nil {
return err
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt, CreatedAt: now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New("replayed")
}
return nil
}) == nil
}
type RevisionStore struct{ DB *gorm.DB }
func (s RevisionStore) Next(configID string) (int64, error) {
if s.DB == nil || !stableID.MatchString(configID) {
return 0, errors.New("invalid revision store")
}
var next int64
err := s.DB.Transaction(func(tx *gorm.DB) error {
var row SourceRevision
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("config_id = ?", configID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
row = SourceRevision{ConfigID: configID, Revision: 1}
if err = tx.Create(&row).Error; err != nil {
return err
}
next = 1
return nil
}
if err != nil {
return err
}
row.Revision++
next = row.Revision
return tx.Save(&row).Error
})
return next, err
}
type ProjectionStore struct {
DB *gorm.DB
Clock func() time.Time
StaleAfter time.Duration
FutureSkew time.Duration
}
type ProjectionView struct {
Projection RuntimeProjection
Offline, Stale, Recovered, RevisionMismatch bool
}
func (s ProjectionStore) Ingest(raw []byte, expected map[string]int64) (ProjectionView, error) {
if s.DB == nil {
return ProjectionView{}, errors.New("projection database required")
}
now := time.Now().UTC()
if s.Clock != nil {
now = s.Clock().UTC()
}
if s.StaleAfter == 0 {
s.StaleAfter = 90 * time.Second
}
if s.FutureSkew == 0 {
s.FutureSkew = 30 * time.Second
}
status, err := parseRuntimeStatus(raw)
if err != nil {
return ProjectionView{}, err
}
if status.ObservedAt.After(now.Add(s.FutureSkew)) {
return ProjectionView{}, errors.New("FUTURE_OBSERVATION")
}
var view ProjectionView
err = s.DB.Transaction(func(tx *gorm.DB) error {
var old RuntimeProjection
find := tx.Where("brain_instance_ref = ?", status.BrainInstanceRef).First(&old).Error
if find == nil {
if old.StatusID == status.StatusID {
view.Projection = old
return nil
}
if status.Sequence <= old.Sequence {
return errors.New("OUT_OF_ORDER_STATUS")
}
if !validRuntimeTransition(old.RuntimeState, status.Runtime.State) {
return errors.New("INVALID_STATUS_TRANSITION")
}
}
if find != nil && !errors.Is(find, gorm.ErrRecordNotFound) {
return find
}
expectedJSON, _ := json.Marshal(expected)
configs, _ := json.Marshal(status.Configurations)
health, _ := json.Marshal(status.Health)
inputs, _ := json.Marshal(status.Inputs)
p := RuntimeProjection{BrainInstanceRef: status.BrainInstanceRef, StatusID: status.StatusID, Sequence: status.Sequence, ObservedAt: status.ObservedAt, ReceivedAt: now, RuntimeState: status.Runtime.State, RuntimeVersion: status.Runtime.Version, ModelRef: status.Model.ModelRef, ModelVersion: status.Model.Version, HealthOverall: status.Health.Overall, ExpectedRevisionsJSON: string(expectedJSON), ConfigurationsJSON: string(configs), HealthJSON: string(health), InputsJSON: string(inputs), WasOffline: find == nil && now.Sub(old.ObservedAt) > s.StaleAfter}
if find == nil {
p.CreatedAt = old.CreatedAt
view.Recovered = p.WasOffline
}
if err := tx.Save(&p).Error; err != nil {
return err
}
view.Projection = p
return nil
})
if err != nil {
return ProjectionView{}, err
}
view.Stale = now.Sub(view.Projection.ObservedAt) > s.StaleAfter
view.Offline = view.Stale
applied := make(map[string]*int64, len(status.Configurations))
for _, c := range status.Configurations {
applied[c.ConfigID] = c.AppliedRevision
}
for configID, want := range expected {
got, ok := applied[configID]
if !ok || got == nil || *got != want {
view.RevisionMismatch = true
}
}
return view, nil
}
func (s ProjectionStore) View(instance string) (ProjectionView, error) {
var p RuntimeProjection
if err := s.DB.First(&p, "brain_instance_ref = ?", instance).Error; err != nil {
return ProjectionView{}, err
}
now := time.Now().UTC()
if s.Clock != nil {
now = s.Clock().UTC()
}
stale := s.StaleAfter
if stale == 0 {
stale = 90 * time.Second
}
return ProjectionView{Projection: p, Offline: now.Sub(p.ObservedAt) > stale, Stale: now.Sub(p.ObservedAt) > stale}, nil
}
@@ -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,25 @@
package version
import (
"runtime"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
"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), migrateBrainRuntime)
}
func migrateBrainRuntime(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&brain_control.ReplayToken{}, &brain_control.RuntimeProjection{}, &brain_control.SourceRevision{}); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,28 @@
package version
import (
"testing"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestMigrateBrainRuntime(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:brain-runtime-migration?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err = migrateBrainRuntime(db, "2026083112000"); err != nil {
t.Fatal(err)
}
for _, model := range []any{&brain_control.ReplayToken{}, &brain_control.RuntimeProjection{}, &brain_control.SourceRevision{}} {
if !db.Migrator().HasTable(model) {
t.Fatalf("missing table for %T", model)
}
}
}
@@ -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=
@@ -0,0 +1,138 @@
package brain_control_test
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"strings"
"sync"
"testing"
"time"
bc "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func testDB(t *testing.T, name string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&bc.ReplayToken{}, &bc.RuntimeProjection{}, &bc.SourceRevision{}); err != nil {
t.Fatal(err)
}
return db
}
func TestMapperProducesCredentialFreeFrozenContract(t *testing.T) {
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
c, err := bc.MapSourceConfig(bc.SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site-east/camera-1/main", Revision: 1, PublishedAt: now, EffectiveAt: now, Profile: bc.Profile{ID: "main", Width: 1920, Height: 1080, Encoding: "h264", FrameRate: 25}, RuleSetVersion: "rules-1", Areas: []bc.AreaRule{{ID: "danger", Version: 1, Kind: "danger_area", Enabled: true, Points: []bc.Point{{X: .1, Y: .1}, {X: .8, Y: .1}, {X: .5, Y: .8}}}}})
if err != nil {
t.Fatal(err)
}
if err = bc.ValidateSourceConfig(c); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(c)
text := strings.ToLower(string(raw))
for _, secret := range []string{"password", "username", "rtsp://", "stream_uri", "credential"} {
if strings.Contains(text, secret) {
t.Fatalf("leaked %q", secret)
}
}
c2, err := bc.MapSourceConfig(bc.SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site-east/camera-1/main", Revision: 2, PublishedAt: now, EffectiveAt: now, Profile: bc.Profile{ID: "main-v2", Width: 1280, Height: 720, Encoding: "H265", FrameRate: 20}, RuleSetVersion: "rules-2", NeedsRecalibration: true, Areas: []bc.AreaRule{{ID: "danger", Version: 2, Kind: "danger_area", Enabled: true, Points: []bc.Point{{X: .1, Y: .1}, {X: .8, Y: .1}, {X: .5, Y: .8}}}}})
if err != nil {
t.Fatal(err)
}
if c2.RuleSet.State != "recalibration_required" || c2.RuleSet.Areas[0].Enabled {
t.Fatal("recalibration must disable rules")
}
}
func TestDurableReplayIsAtomicAndSurvivesVerifierRestart(t *testing.T) {
db := testDB(t, "sense-replay")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
registry, _ := mi.NewRegistry(mi.KeyRecord{Principal: "yv:brain:east", KeyID: "brain-key-01", PublicKey: pub, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true})
signer := mi.Signer{Principal: "yv:brain:east", KeyID: "brain-key-01", PrivateKey: priv, Now: func() time.Time { return now }}
body := []byte(`{"ok":true}`)
token, _ := signer.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
results := make(chan bool, 8)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
v := mi.Verifier{Registry: registry, Replay: bc.GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
_, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body)
results <- err == nil
}()
}
wg.Wait()
close(results)
accepted := 0
for ok := range results {
if ok {
accepted++
}
}
if accepted != 1 {
t.Fatalf("accepted=%d", accepted)
}
v2 := mi.Verifier{Registry: registry, Replay: bc.GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
if _, err := v2.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
t.Fatal("replay accepted after verifier restart")
}
}
func TestRevisionStoreConcurrentAndRestart(t *testing.T) {
db := testDB(t, "sense-revisions")
store := bc.RevisionStore{DB: db}
for want := int64(1); want <= 3; want++ {
got, err := store.Next("gate-primary")
if err != nil || got != want {
t.Fatalf("got %d err %v", got, err)
}
}
restarted := bc.RevisionStore{DB: db}
got, err := restarted.Next("gate-primary")
if err != nil || got != 4 {
t.Fatalf("restart got %d err %v", got, err)
}
}
func TestRuntimeProjectionStaleRecoveryMismatchAndOrdering(t *testing.T) {
db := testDB(t, "sense-projection")
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
store := bc.ProjectionStore{DB: db, Clock: func() time.Time { return now }, StaleAfter: 90 * time.Second}
raw := statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d101", 41, now, "running", 20)
view, err := store.Ingest(raw, map[string]int64{"gate-primary": 21})
if err != nil {
t.Fatal(err)
}
if !view.RevisionMismatch || view.Stale {
t.Fatalf("bad initial view %+v", view)
}
now = now.Add(91 * time.Second)
view, err = store.View("brain-east-01")
if err != nil || !view.Offline || !view.Stale {
t.Fatalf("offline %+v %v", view, err)
}
raw = statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d102", 42, now, "running", 21)
view, err = store.Ingest(raw, map[string]int64{"gate-primary": 21})
if err != nil || !view.Recovered || view.RevisionMismatch {
t.Fatalf("recovery %+v %v", view, err)
}
if _, err = store.Ingest(statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d103", 41, now, "running", 21), nil); err == nil || err.Error() != "OUT_OF_ORDER_STATUS" {
t.Fatalf("expected ordering rejection: %v", err)
}
}
func statusJSON(id string, seq int64, observed time.Time, state string, revision int64) []byte {
v := map[string]any{"schema_version": bc.RuntimeStatusVersion, "status_id": id, "brain_instance_ref": "brain-east-01", "sequence": seq, "observed_at": observed.Format(time.RFC3339), "runtime": map[string]any{"state": state, "version": "1.0.0", "started_at": observed.Add(-time.Minute).Format(time.RFC3339)}, "model": map[string]any{"model_ref": "people-detection", "version": "2026.08.1"}, "configurations": []any{map[string]any{"config_id": "gate-primary", "apply_state": "applied", "applied_revision": revision, "error_code": nil}}, "health": map[string]any{"overall": "healthy", "error_codes": []any{}, "metrics": map[string]any{"load_percent": 1.0, "queue_depth": 0, "latency_ms": 2.0}}, "inputs": []any{}}
raw, _ := json.Marshal(v)
return raw
}
@@ -0,0 +1,18 @@
module git.ilapage.cn/ila/yovision/Sense/tests/integration/brain_control
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 (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.49 // indirect
golang.org/x/text v0.40.0 // indirect
)
replace git.ilapage.cn/ila/yovision/Sense/server => ../../../server
@@ -0,0 +1,12 @@
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/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
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=
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}
}

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