Files
mrcharles 05ec1a4142 events: verify, normalize, append-only store with provider-id dedup
Signature verification (gitea hex HMAC-SHA256 over the raw body via
constant-time hmac.Equal; redmine/discourse constant-time shared-secret
headers) with one generic ErrUnverified so rejects give attackers no
oracle. Tolerant normalization of the known Redmine/Discourse/Gitea
payload variants into one Event record (canonical subject ids, actor,
title, repo, sha256 payload digest) plus the DESIGN action mapping
(dispatch_turn / respond_turn / pipeline_step / ignore). Store: JSONL
under state dir, 0600, dedup keyed on provider event id (delivery
header, payload-digest fallback), index rebuilt at startup so replays
across restarts still dedup; torn tail lines skipped, not fatal.
2026-08-28 21:38:16 -05:00

59 lines
2.3 KiB
Go

// Package events is the `harness events` webhook receiver (DESIGN "Events
// are V1 scope"): Redmine, Discourse and Gitea webhooks land here, are
// verified against per-source secrets, normalized into one internal Event
// record, persisted append-only (JSONL, dedup by provider event id), and
// mapped to a conductor action. Secret material is never logged and never
// persisted; only payload digests are.
package events
import "time"
// Provider sources.
const (
SourceRedmine = "redmine"
SourceDiscourse = "discourse"
SourceGitea = "gitea"
)
// Actions an event can map to (DESIGN trigger -> action semantics).
const (
ActionDispatchTurn = "dispatch_turn" // redmine issue update/note -> run a turn for that stack
ActionRespondTurn = "respond_turn" // discourse post reply -> context update + response turn
ActionPipelineStep = "pipeline_step" // gitea PR approved/merged -> next pipeline step
ActionIgnore = "ignore" // recognized but not actionable
)
// Event is the normalized internal record every provider webhook becomes.
// One shape, three sources: source + kind say what happened, actor who did
// it, SubjectID/Subject what it happened to (issue / topic / PR).
type Event struct {
Source string `json:"source"`
Kind string `json:"kind"`
// Action is the mapped conductor action (dispatch_turn, ...).
Action string `json:"action"`
// Actor is the provider-side user who triggered the event.
Actor string `json:"actor"`
// SubjectID canonically identifies the subject: redmine:issue:42,
// discourse:topic:7, gitea:pr:ukrrs/MOPAC#5.
SubjectID string `json:"subject_id"`
// Subject is the human-readable title (best effort).
Subject string `json:"subject,omitempty"`
// Repo is the gitea repository full_name when applicable.
Repo string `json:"repo,omitempty"`
// ProviderID is the provider's own event id (delivery/event header,
// falling back to the payload digest) and the dedup identity.
ProviderID string `json:"provider_id"`
PayloadDigest string `json:"payload_digest"`
ReceivedAt time.Time `json:"received_at"`
}
// DedupKey is the append-store identity: source + provider event id.
func (e Event) DedupKey() string {
return e.Source + ":" + e.ProviderID
}
// Sources lists the receiver's provider sources.
func Sources() []string {
return []string{SourceRedmine, SourceDiscourse, SourceGitea}
}