feat: 实现可靠事件与证据 connector (#153)

This commit is contained in:
QiuSW
2026-08-31 14:57:48 +08:00
parent cf00d73436
commit 04c5deecfb
36 changed files with 4102 additions and 14 deletions
@@ -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") }
+4 -4
View File
@@ -8,12 +8,12 @@ import (
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
)
// CreateWithOutbox commits the local candidate and its internal delivery
// record atomically. The payload remains Sense-internal and is not a Bell or
// Brain contract.
// record atomically. The payload is the frozen anonymous event contract; it
// never carries the candidate's internal evidence path or delivery attempts.
func CreateWithOutbox(ctx context.Context, db *gorm.DB, candidate EventCandidate, payload map[string]interface{}, now time.Time) error {
encoded, err := json.Marshal(payload)
if err != nil {
@@ -23,7 +23,7 @@ func CreateWithOutbox(ctx context.Context, db *gorm.DB, candidate EventCandidate
if err := tx.Create(&candidate).Error; err != nil {
return fmt.Errorf("create local event candidate: %w", err)
}
_, err = outbox.Enqueue(tx, outbox.EnqueueInput{InternalType: "local_event_candidate", BusinessRef: candidate.ID, IdempotencyKey: "local-event:" + candidate.ID + ":v1", PayloadJSON: encoded}, now)
_, err = bell_connector.EnqueueEvent(tx, encoded, now)
return err
})
}
@@ -2,6 +2,9 @@ package local_event
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
@@ -9,6 +12,7 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
)
@@ -24,7 +28,18 @@ func TestCreateWithOutboxCommitsAndRollsBackAtomically(t *testing.T) {
}
now := time.Date(2026, 8, 28, 10, 0, 0, 0, time.UTC)
candidate := EventCandidate{ID: uuid.NewString(), OccurredAt: now, SourceRef: "SEN-CAM-01", RuleRef: "rule-1", RuleName: "区域闯入", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStatePending, RetainUntil: now.Add(24 * time.Hour)}
if err = CreateWithOutbox(context.Background(), db, candidate, map[string]interface{}{"eventId": candidate.ID}, now); err != nil {
fixturePath := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", "dangerous-area.json")
fixture, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatal(err)
}
var payload map[string]interface{}
if err = json.Unmarshal(fixture, &payload); err != nil {
t.Fatal(err)
}
payload["producer_id"] = "sense-local"
payload["source_event_id"] = candidate.ID
if err = CreateWithOutbox(context.Background(), db, candidate, payload, now); err != nil {
t.Fatal(err)
}
var candidates, messages int64
@@ -33,9 +48,13 @@ func TestCreateWithOutboxCommitsAndRollsBackAtomically(t *testing.T) {
if candidates != 1 || messages != 1 {
t.Fatalf("candidates=%d messages=%d", candidates, messages)
}
var message outbox.Message
if err = db.First(&message).Error; err != nil || message.InternalType != bell_connector.OutboxType {
t.Fatalf("local event did not enqueue Bell contract delivery: type=%s err=%v", message.InternalType, err)
}
duplicate := candidate
duplicate.ID = candidate.ID
if err = CreateWithOutbox(context.Background(), db, duplicate, map[string]interface{}{"eventId": duplicate.ID}, now); err == nil {
if err = CreateWithOutbox(context.Background(), db, duplicate, payload, now); err == nil {
t.Fatal("expected duplicate transaction failure")
}
db.Model(&EventCandidate{}).Count(&candidates)
+119
View File
@@ -0,0 +1,119 @@
package api
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
)
func TestBellConnectorRuntimeWiringIsOptionalAndMigrationGated(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file:sense-api-bell-connector?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
engine := gin.New()
disabled := func(string) string { return "" }
if err = startBellConnectorRuntime(context.Background(), engine, db, disabled); err != nil {
t.Fatalf("disabled connector prevented Sense startup: %v", err)
}
if len(engine.Routes()) != 0 {
t.Fatalf("disabled connector registered routes: %#v", engine.Routes())
}
enabled := func(key string) string {
if key == "SENSE_EVENT_INGRESS_ENABLED" {
return "true"
}
return ""
}
if err = startBellConnectorRuntime(context.Background(), gin.New(), db, enabled); err == nil {
t.Fatal("enabled connector started without its formal migration")
}
if err = db.AutoMigrate(
&bell_connector.InboundEvent{}, &bell_connector.EvidenceRecord{}, &bell_connector.ReplayToken{},
&outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{},
); err != nil {
t.Fatal(err)
}
registryPath := writeSenseRegistry(t)
runtimeEnvironment := func(key string) string {
switch key {
case "SENSE_EVENT_INGRESS_ENABLED":
return "true"
case "SENSE_MACHINE_PRINCIPAL_REGISTRY":
return registryPath
default:
return ""
}
}
registered := gin.New()
if err = startBellConnectorRuntime(context.Background(), registered, db, runtimeEnvironment); err != nil {
t.Fatalf("enabled connector did not register after migration: %v", err)
}
routes := registered.Routes()
if len(routes) != 2 || routes[0].Path != "/v1/events" || routes[1].Path != "/v1/evidence/:evidence_id" {
t.Fatalf("unexpected connector routes: %#v", routes)
}
}
func TestConnectorRuntimeUsesOnlyTheExplicitDefaultDatabase(t *testing.T) {
defaultDB, err := gorm.Open(sqlite.Open("file:sense-default-runtime?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
secondaryDB, err := gorm.Open(sqlite.Open("file:sense-secondary-runtime?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sdk.Runtime.SetDb("", defaultDB)
sdk.Runtime.SetDb("analytics", secondaryDB)
t.Cleanup(func() {
sdk.Runtime.SetDb("", nil)
sdk.Runtime.SetDb("analytics", nil)
})
if selected := defaultRuntimeDatabase(); selected != defaultDB {
t.Fatalf("runtime selected a non-default database: %p", selected)
}
}
func writeSenseRegistry(t *testing.T) string {
t.Helper()
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
document := map[string]any{
"version": "yovision.machine-principal-registry/v1",
"audience": "yovision-sense",
"principals": []any{map[string]any{
"principal_id": "yv:brain:school-a", "enabled": true,
"keys": []any{map[string]any{
"kid": "brain-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
"status": "active", "scopes": []string{"events:ingest"},
}},
}},
}
encoded, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "sense-registry.json")
if err = os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
return path
}
+20 -6
View File
@@ -17,9 +17,11 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/router"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
@@ -88,15 +90,19 @@ func run() error {
}
runtimeCtx, runtimeCancel := context.WithCancel(context.Background())
defer runtimeCancel()
var runtimeDBFound bool
for _, db := range sdk.Runtime.GetDb() {
runtimeDBFound = true
db := defaultRuntimeDatabase()
if db != nil {
engine, engineOK := sdk.Runtime.GetEngine().(*gin.Engine)
if !engineOK || engine == nil {
return errors.New("Sense connector runtime requires Gin engine")
}
if err := startBellConnectorRuntime(runtimeCtx, engine, db, os.Getenv); err != nil {
return fmt.Errorf("Bell connector runtime unavailable: %w", err)
}
if err := media.StartRuntime(runtimeCtx, db); err != nil {
return fmt.Errorf("MediaMTX runtime unavailable: %w", err)
}
break
}
if !runtimeDBFound {
} else {
log.Error("MediaMTX runtime unavailable: Sense database is not initialized")
}
@@ -166,6 +172,14 @@ func run() error {
return nil
}
func startBellConnectorRuntime(ctx context.Context, engine *gin.Engine, db *gorm.DB, getenv bell_connector.Environment) error {
return bell_connector.StartRuntime(ctx, engine, db, getenv)
}
func defaultRuntimeDatabase() *gorm.DB {
return sdk.Runtime.GetDbByKey("")
}
//var Router runtime.Router
func tip() {
@@ -0,0 +1,30 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseBellConnector)
}
func migrateSenseBellConnector(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(
&bell_connector.InboundEvent{},
&bell_connector.EvidenceRecord{},
&bell_connector.ReplayToken{},
); err != nil {
return err
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,66 @@
package version
import (
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestSenseBellConnectorMigrationIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
const version = "2026083112000"
for attempt := 0; attempt < 2; attempt++ {
if err = migrateSenseBellConnector(db, version); err != nil {
t.Fatalf("migration attempt %d: %v", attempt+1, err)
}
}
for name, model := range map[string]any{
"inbound events": &bell_connector.InboundEvent{},
"evidence records": &bell_connector.EvidenceRecord{},
"replay tokens": &bell_connector.ReplayToken{},
} {
if !db.Migrator().HasTable(model) {
t.Fatalf("%s table missing", name)
}
var count int64
if err = db.Model(model).Count(&count).Error; err != nil {
t.Fatalf("count %s: %v", name, err)
}
if count != 0 {
t.Fatalf("migration inserted %d %s fixtures", count, name)
}
}
if !db.Migrator().HasIndex(&bell_connector.ReplayToken{}, "ExpiresAt") {
t.Fatal("replay expiry index missing")
}
now := time.Now().UTC()
token := bell_connector.ReplayToken{Principal: "sense", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
if err = db.Create(&token).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&token).Error; err == nil {
t.Fatal("duplicate replay token accepted")
}
var applied int64
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if applied != 1 {
t.Fatalf("migration records=%d, want 1", applied)
}
}
@@ -0,0 +1,235 @@
package bell_connector_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/bell_connector"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
)
func TestPersistentOutboxRetriesWithoutChangingOriginalIdentity(t *testing.T) {
db, databasePath := openDatabase(t)
body := fixture(t)
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC) }}
var attempts atomic.Int32
var received [][]byte
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
payload := make([]byte, request.ContentLength)
_, _ = request.Body.Read(payload)
received = append(received, payload)
requestID := request.Header.Get("X-Request-ID")
writer.Header().Set("X-Request-ID", requestID)
if request.Header.Get("X-YoVision-Relay-ID") != "sense-school-a" || !strings.HasPrefix(request.Header.Get("Authorization"), "Bearer ") || len(requestID) < 16 || len(requestID) > 128 {
http.Error(writer, "missing relay identity", http.StatusUnauthorized)
return
}
if attempts.Add(1) == 1 {
writer.WriteHeader(http.StatusServiceUnavailable)
_, _ = writer.Write([]byte(`{"code":"ingest_unavailable","message":"temporarily unavailable"}`))
return
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"event_id":"bell-event-1","producer_id":"brain-school-a","source_event_id":"evt-area-20260831-0001","disposition":"created","payload_sha256":"4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"}`))
}))
defer server.Close()
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
if _, err := bell_connector.EnqueueEvent(db, body, now); err != nil {
t.Fatal(err)
}
client := &bell_connector.Client{Endpoint: server.URL, RelayID: "sense-school-a", Signer: signer, HTTP: server.Client(), Enabled: true}
relay := bell_connector.Relay{DB: db, Client: client, Now: func() time.Time { return now }, Backoff: func(int) time.Duration { return 0 }}
if delivered, err := relay.DeliverBatch(context.Background(), "worker-1", 10); err != nil || delivered != 0 {
t.Fatalf("unavailable delivery=%d err=%v", delivered, err)
}
var queued outbox.Message
if err := db.First(&queued).Error; err != nil || queued.State != outbox.StateRetry {
t.Fatalf("outbox was not retained for retry: state=%s err=%v", queued.State, err)
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
if err = sqlDatabase.Close(); err != nil {
t.Fatal(err)
}
db = openDatabasePath(t, databasePath)
if err = db.First(&queued).Error; err != nil {
t.Fatal(err)
}
// Constructing a new relay simulates restart; the durable row is claimed
// and delivered with its original bytes and business identity.
restarted := bell_connector.Relay{DB: db, Client: client, Now: func() time.Time { return now }, Backoff: func(int) time.Duration { return 0 }}
if delivered, err := restarted.DeliverBatch(context.Background(), "worker-2", 10); err != nil || delivered != 1 {
t.Fatalf("recovery delivery=%d err=%v", delivered, err)
}
if len(received) != 2 || string(received[0]) != string(body) || string(received[1]) != string(body) || bell_connector.PreserveIdentity(received[0], received[1]) != nil {
t.Fatal("relay changed the frozen payload or original identity")
}
if err := db.First(&queued).Error; err != nil || queued.State != outbox.StateDelivered {
t.Fatalf("outbox not delivered: state=%s err=%v", queued.State, err)
}
}
func TestTerminalConflictDisabledTimeoutAndReplayRestart(t *testing.T) {
db, databasePath := openDatabase(t)
body := fixture(t)
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey}
policy := machine_identity.TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true, ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: time.Second, MaxRequestBytes: 64 * 1024}
if _, err := bell_connector.NewClient("https://user:pass@bell.example", "sense-school-a", signer, policy); err == nil || !strings.Contains(err.Error(), "HTTPS origin") {
t.Fatalf("endpoint userinfo was not rejected: %v", err)
}
conflictServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusConflict)
_, _ = writer.Write([]byte(`{"code":"idempotency_conflict","message":"conflict","existing_event_id":"bell-1"}`))
}))
defer conflictServer.Close()
if _, err := bell_connector.EnqueueEvent(db, body, time.Now()); err != nil {
t.Fatal(err)
}
client := &bell_connector.Client{Endpoint: conflictServer.URL, Signer: signer, HTTP: conflictServer.Client(), Enabled: true}
if _, err := (bell_connector.Relay{DB: db, Client: client}).DeliverBatch(context.Background(), "worker", 1); err != nil {
t.Fatal(err)
}
var message outbox.Message
if err := db.First(&message).Error; err != nil || message.State != outbox.StateDead {
t.Fatalf("terminal conflict was retried: state=%s err=%v", message.State, err)
}
if _, err := (bell_connector.Client{Enabled: false}).Send(context.Background(), body); err == nil || !strings.Contains(err.Error(), "connector_disabled") {
t.Fatalf("disabled connector error=%v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := client.Send(cancelled, body); err == nil {
t.Fatal("cancelled/timeout request unexpectedly succeeded")
}
now := time.Now().UTC()
firstStore := bell_connector.PersistentReplayStore{DB: db}
if !firstStore.Consume("yv:bell:school-a", "abcdefghijklmnopqrstuv", now.Add(time.Minute), now) {
t.Fatal("first replay consume failed")
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
if err = sqlDatabase.Close(); err != nil {
t.Fatal(err)
}
db = openDatabasePath(t, databasePath)
restartedStore := bell_connector.PersistentReplayStore{DB: db}
if restartedStore.Consume("yv:bell:school-a", "abcdefghijklmnopqrstuv", now.Add(time.Minute), now) {
t.Fatal("replay was accepted after store restart")
}
}
func TestBrainIngressAndBellEvidenceEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
db, _ := openDatabase(t)
brainPublic, brainPrivate, _ := ed25519.GenerateKey(rand.Reader)
bellPublic, bellPrivate, _ := ed25519.GenerateKey(rand.Reader)
registry, err := machine_identity.NewRegistry(
machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: brainPublic, Audience: "yovision-sense", Scopes: []string{"events:ingest"}, Enabled: true},
machine_identity.KeyRecord{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PublicKey: bellPublic, Audience: "yovision-sense", Scopes: []string{"evidence:read"}, Enabled: true},
)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
verifier := machine_identity.Verifier{Registry: registry, Replay: bell_connector.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}
body := fixture(t)
brainSigner := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: brainPrivate, Now: func() time.Time { return now }}
token, err := brainSigner.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/v1/events", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("X-Request-ID", "request-id-0000001")
response := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(response)
ginContext.Request = request
(bell_connector.IngressHandler{DB: db, Verifier: verifier, EvidenceOwnerID: "sense-school-a", Now: func() time.Time { return now }}).Post(ginContext)
if response.Code != http.StatusAccepted {
t.Fatalf("Brain ingress status=%d body=%s", response.Code, response.Body.String())
}
var evidence bell_connector.EvidenceRecord
if err = db.First(&evidence, "evidence_id = ?", "ev-school-east-0001").Error; err != nil {
t.Fatal(err)
}
bellSigner := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: bellPrivate, Now: func() time.Time { return now }}
path := "/v1/evidence/ev-school-east-0001"
token, err = bellSigner.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
if err != nil {
t.Fatal(err)
}
getRequest := httptest.NewRequest(http.MethodGet, path, nil)
getRequest.Header.Set("Authorization", "Bearer "+token)
getRequest.Header.Set("X-Request-ID", "request-id-0000002")
getResponse := httptest.NewRecorder()
getContext, _ := gin.CreateTestContext(getResponse)
getContext.Request = getRequest
getContext.Params = gin.Params{{Key: "evidence_id", Value: "ev-school-east-0001"}}
(bell_connector.EvidenceHandler{DB: db, Verifier: verifier, Now: func() time.Time { return now }}).Get(getContext)
if getResponse.Code != http.StatusOK || !bytes.Equal(getResponse.Body.Bytes(), evidence.Payload) {
t.Fatalf("evidence lookup status=%d body=%s", getResponse.Code, getResponse.Body.String())
}
}
func openDatabase(t *testing.T) (*gorm.DB, string) {
databasePath := filepath.Join(t.TempDir(), "sense-bell-connector.sqlite")
return openDatabasePath(t, databasePath), databasePath
}
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{}, &bell_connector.ReplayToken{}, &bell_connector.InboundEvent{}, &bell_connector.EvidenceRecord{}); err != nil {
t.Fatal(err)
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDatabase.Close() })
return db
}
func fixture(t *testing.T) []byte {
t.Helper()
path := filepath.Join("..", "..", "..", "..", "contracts", "events", "v1", "examples", "dangerous-area.json")
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var event map[string]any
if json.Unmarshal(body, &event) != nil {
t.Fatal("invalid fixture")
}
return body
}
@@ -0,0 +1,72 @@
module git.ilapage.cn/ila/yovision/Sense/tests/integration/bell_connector
go 1.26.5
require (
git.ilapage.cn/ila/yovision/Sense/server v0.0.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
)
require (
dario.cat/mergo v1.0.1 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/andeya/ameda v1.5.3 // indirect
github.com/andeya/goutil v1.1.2 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bytedance/go-tagexpr/v2 v2.9.11 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 // indirect
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2 // indirect
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-sqlite3 v1.14.49 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nyaruka/phonenumbers v1.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.30.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
replace git.ilapage.cn/ila/yovision/Sense/server => ../../../server
@@ -0,0 +1,204 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andeya/ameda v1.5.3 h1:SvqnhQPZwwabS8HQTRGfJwWPl2w9ZIPInHAw9aE1Wlk=
github.com/andeya/ameda v1.5.3/go.mod h1:FQDHRe1I995v6GG+8aJ7UIUToEmbdTJn/U26NCPIgXQ=
github.com/andeya/goutil v1.0.1/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
github.com/andeya/goutil v1.1.2 h1:RiFWFkL/9yXh2SjQkNWOHqErU1x+RauHmeR23eNUzSg=
github.com/andeya/goutil v1.1.2/go.mod h1:jEG5/QnnhG7yGxwFUX6Q+JGMif7sjdHmmNVjn7nhJDo=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/go-tagexpr/v2 v2.9.11 h1:jJgmoDKPKacGl0llPYbYL/+/2N+Ng0vV0ipbnVssXHY=
github.com/bytedance/go-tagexpr/v2 v2.9.11/go.mod h1:UAyKh4ZRLBPGsyTRFZoPqTni1TlojMdOJXQnEIPCX84=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 h1:K62Lb6bsgLOB++z/VAvRvtiEBdNCuMfmQGTGGWMdPpM=
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99/go.mod h1:9+sJ9zvvkXC5sPjPEZM3Jpb9n2Q2VtcrGZly0UHYF5I=
github.com/chanxuehong/util v0.0.0-20200304121633-ca8141845b13/go.mod h1:XEYt99iTxMqkv+gW85JX/DdUINHUe43Sbe5AtqSaDAQ=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd h1:v3JNsFZmplLO/Cmiyr/rGvR7lW1ld9lB+d5h4yR0MTI=
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd/go.mod h1:mysjrtCs9MmN8hqDf4/mc4eQ26Rt9s1p5oO+fhJlLB4=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 h1:UI9ppj+iWl/0Y6kp9w06dwGUdLsdxWRC3p0J0gLHApI=
github.com/go-admin-team/go-admin-core v1.5.3-rc.3.0.20250408121721-2763de5dcdf4/go.mod h1:uWX7fPisJ6DluUP9vR3m3818RkDpZb/4dnwbZdmZN6Q=
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2 h1:cPTLzpvvyh8kyB24jblB+2W0QZBugP+8VYN3R34Pb4s=
github.com/go-admin-team/go-admin-core/plugins/logger/zap v1.5.2/go.mod h1:ejtJ3aohd6EznZ9Q+KZVA3NwPU/2qIm0gayIGM3tIXw=
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 h1:gU2OBSCsfSrqWId2monoEQhPKXZqhZ8W9ol9f7A4DAE=
github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4/go.mod h1:va1lNEXHGnV161Avr0lzi5gnT8OazJ/wmN9xnsY9N/s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nyaruka/phonenumbers v1.0.55/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U=
github.com/nyaruka/phonenumbers v1.2.2 h1:OwVjf7Y4uHoK9VJUrA8ebR0ha2yc6sEYbfrwkq0asCY=
github.com/nyaruka/phonenumbers v1.2.2/go.mod h1:wzk2qq7qwsaBKrfbkWKdgHYOOH+QFTesSpIq53ELw8M=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=