tinyrelaycommit

Commit cef34c70332a

browse files at this commit | history

1diff --git a/internal/community/actions.go b/internal/community/actions.go
2index 79f3e0c..f7b7a8a 100644
3--- a/internal/community/actions.go
4+++ b/internal/community/actions.go
5@@ -682,6 +682,12 @@ func (s *Service) audit(ctx context.Context) (any, error) {
6 }
7 return out, rows.Err()
8 }
9+
10+// Record writes one audit row for an action another service carried out.
11+func (s *Service) Record(ctx context.Context, actor, action, target, detail string) error {
12+ return s.store.WithTx(ctx, func(tx *sql.Tx) error { return s.recordTx(ctx, tx, actor, action, target, detail) })
13+}
14+
15 func (s *Service) recordTx(ctx context.Context, tx *sql.Tx, actor, action, target, detail string) error {
16 _, err := tx.ExecContext(ctx, `INSERT INTO community_audit(at,actor,action,target,detail) VALUES(?,?,?,?,?)`, now(), actor, action, target, detail)
17 return err
18diff --git a/internal/daemon/callbacks.go b/internal/daemon/callbacks.go
19new file mode 100644
20index 0000000..e997353
21--- /dev/null
22+++ b/internal/daemon/callbacks.go
23@@ -0,0 +1,587 @@
24+package daemon
25+
26+// Callbacks wake agents that cannot hold a relay connection open. A key
27+// registers an https URL with a filter, and the relay POSTs each matching
28+// event the key may see to that URL as it arrives. Registration is a
29+// management method; delivery is durable work with bounded retries. This
30+// file holds storage, validation, matching and the management methods;
31+// callbacks_delivery.go holds the POST.
32+
33+import (
34+ "context"
35+ "crypto/rand"
36+ "database/sql"
37+ "encoding/hex"
38+ "encoding/json"
39+ "errors"
40+ "fmt"
41+ "net"
42+ "net/url"
43+ "sort"
44+ "strings"
45+ "time"
46+
47+ "github.com/FelineStateMachine/tinyrelay/internal/community"
48+ "github.com/FelineStateMachine/tinyrelay/internal/event"
49+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
50+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
51+)
52+
53+const (
54+ callbackDelivery = "callback-delivery"
55+ callbackTagMax = 8
56+ callbackKindMax = 32
57+ callbackURLMax = 2048
58+ callbackSecretMin = 16
59+ callbackSecretMax = 128
60+ callbackTimeout = 10 * time.Second
61+ callbackMaxAttempts = 3
62+ callbackPauseFailures = 20
63+)
64+
65+// callbackBackoff is the wait before the second and third attempt.
66+var callbackBackoff = []time.Duration{time.Minute, 5 * time.Minute}
67+
68+const callbackSchema = `CREATE TABLE IF NOT EXISTS callbacks(id TEXT PRIMARY KEY, owner TEXT NOT NULL, url TEXT NOT NULL, filter TEXT NOT NULL, secret TEXT NOT NULL, created_at INTEGER NOT NULL, last_delivery_at INTEGER NOT NULL DEFAULT 0, last_status TEXT NOT NULL DEFAULT '', failures INTEGER NOT NULL DEFAULT 0, paused INTEGER NOT NULL DEFAULT 0);
69+CREATE INDEX IF NOT EXISTS callbacks_owner ON callbacks(owner);`
70+
71+// callbackFilter is the NIP-01 subset a callback may use: kinds, authors and
72+// the a, e, p and h tags. since is accepted and ignored.
73+type callbackFilter struct {
74+ Kinds []int `json:"kinds"`
75+ Authors []string `json:"authors,omitempty"`
76+ Tags map[string][]string `json:"-"`
77+}
78+
79+var callbackFilterTags = []string{"a", "e", "p", "h"}
80+
81+// MarshalJSON writes the filter in NIP-01 form, with tag lists as #a, #e,
82+// #p and #h.
83+func (f callbackFilter) MarshalJSON() ([]byte, error) {
84+ out := map[string]any{"kinds": f.Kinds}
85+ if len(f.Authors) > 0 {
86+ out["authors"] = f.Authors
87+ }
88+ for _, name := range callbackFilterTags {
89+ if values := f.Tags[name]; len(values) > 0 {
90+ out["#"+name] = values
91+ }
92+ }
93+ return json.Marshal(out)
94+}
95+
96+// eventFilter is the storage filter that matches the same events.
97+func (f callbackFilter) eventFilter() event.Filter {
98+ tags := map[string][]string{}
99+ for name, values := range f.Tags {
100+ tags[name] = values
101+ }
102+ return event.Filter{Kinds: f.Kinds, Authors: f.Authors, Tags: tags}
103+}
104+
105+// parseCallbackFilter validates the filter subset. Unknown keys, empty
106+// lists, malformed values and lists longer than the cap are refused so a
107+// registration that will never match is caught when it is made.
108+func parseCallbackFilter(raw json.RawMessage) (callbackFilter, error) {
109+ var values map[string]json.RawMessage
110+ if len(raw) == 0 || json.Unmarshal(raw, &values) != nil {
111+ // A filter may arrive as a JSON string from a plain form.
112+ var text string
113+ if json.Unmarshal(raw, &text) != nil || json.Unmarshal([]byte(text), &values) != nil {
114+ return callbackFilter{}, errors.New("invalid: filter must be a JSON object")
115+ }
116+ }
117+ f := callbackFilter{Tags: map[string][]string{}}
118+ for key, value := range values {
119+ switch {
120+ case key == "kinds":
121+ if json.Unmarshal(value, &f.Kinds) != nil || len(f.Kinds) == 0 || len(f.Kinds) > callbackKindMax {
122+ return callbackFilter{}, fmt.Errorf("invalid: kinds must list 1 to %d kinds", callbackKindMax)
123+ }
124+ for _, kind := range f.Kinds {
125+ if kind < 0 || kind > 65535 {
126+ return callbackFilter{}, errors.New("invalid: kinds must be between 0 and 65535")
127+ }
128+ }
129+ case key == "authors":
130+ if json.Unmarshal(value, &f.Authors) != nil || len(f.Authors) == 0 || len(f.Authors) > callbackTagMax {
131+ return callbackFilter{}, fmt.Errorf("invalid: authors must list 1 to %d public keys", callbackTagMax)
132+ }
133+ for _, author := range f.Authors {
134+ if !hex64(author) {
135+ return callbackFilter{}, errors.New("invalid: authors must be 64 lowercase hex characters")
136+ }
137+ }
138+ case key == "since":
139+ // Accepted for compatibility with clients that send whole
140+ // filters; a callback only sees events that arrive after it.
141+ case len(key) == 2 && key[0] == '#' && containsString(callbackFilterTags, key[1:]):
142+ var list []string
143+ if json.Unmarshal(value, &list) != nil || len(list) == 0 || len(list) > callbackTagMax {
144+ return callbackFilter{}, fmt.Errorf("invalid: %s must list 1 to %d values", key, callbackTagMax)
145+ }
146+ for _, item := range list {
147+ if err := checkCallbackTagValue(key[1:], item); err != nil {
148+ return callbackFilter{}, err
149+ }
150+ }
151+ f.Tags[key[1:]] = list
152+ default:
153+ return callbackFilter{}, fmt.Errorf("invalid: filter key %q is not supported; use kinds, authors, #a, #e, #p or #h", key)
154+ }
155+ }
156+ if len(f.Kinds) == 0 {
157+ return callbackFilter{}, errors.New("invalid: filter must name at least one kind")
158+ }
159+ sort.Ints(f.Kinds)
160+ return f, nil
161+}
162+
163+func checkCallbackTagValue(name, value string) error {
164+ switch name {
165+ case "e", "p":
166+ if !hex64(value) {
167+ return fmt.Errorf("invalid: #%s values must be 64 lowercase hex characters", name)
168+ }
169+ case "a":
170+ parts := strings.SplitN(value, ":", 3)
171+ if len(parts) != 3 || !hex64(parts[1]) || strings.Trim(parts[0], "0123456789") != "" || parts[0] == "" {
172+ return errors.New("invalid: #a values must be <kind>:<pubkey>:<identifier>")
173+ }
174+ case "h":
175+ if !community.ValidRoomID(value) {
176+ return errors.New("invalid: #h values must be room ids")
177+ }
178+ }
179+ return nil
180+}
181+
182+// callbackRecord is one stored callback. The secret never leaves the
183+// tenant except once, in the addcallback answer.
184+type callbackRecord struct {
185+ ID string `json:"id"`
186+ Owner string `json:"owner"`
187+ URL string `json:"url"`
188+ Filter callbackFilter `json:"filter"`
189+ CreatedAt int64 `json:"created"`
190+ LastAt int64 `json:"lastDelivery"`
191+ LastStatus string `json:"lastStatus"`
192+ Failures int `json:"failures"`
193+ Paused bool `json:"paused"`
194+ secret string
195+}
196+
197+// summary is the management view: the host stands in for the URL so a
198+// listing shown to moderators does not expose the path.
199+func (c callbackRecord) summary() map[string]any {
200+ return map[string]any{"id": c.ID, "owner": c.Owner, "url": c.URL, "host": callbackHost(c.URL), "filter": c.Filter, "created": c.CreatedAt, "lastDelivery": c.LastAt, "lastStatus": c.LastStatus, "failures": c.Failures, "paused": c.Paused}
201+}
202+
203+func callbackHost(raw string) string {
204+ parsed, err := url.Parse(raw)
205+ if err != nil {
206+ return ""
207+ }
208+ return parsed.Host
209+}
210+
211+func (t *Tenant) initCallbacks(ctx context.Context) error {
212+ _, err := t.store.DB().ExecContext(ctx, callbackSchema)
213+ return err
214+}
215+
216+// checkCallbackURL accepts https URLs to public hosts. When the relay itself
217+// runs on a loopback address, as it does in tests, private targets are
218+// allowed so a local receiver can be exercised.
219+func (t *Tenant) checkCallbackURL(raw string) error {
220+ if len(raw) > callbackURLMax {
221+ return errors.New("invalid: url is too long")
222+ }
223+ parsed, err := url.Parse(raw)
224+ if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" {
225+ return errors.New("invalid: url must be https without credentials or a fragment")
226+ }
227+ if t.loopbackRelay() {
228+ return nil
229+ }
230+ host := strings.ToLower(parsed.Hostname())
231+ if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") || !strings.Contains(host, ".") {
232+ return errors.New("invalid: url must name a public host")
233+ }
234+ if ip := net.ParseIP(host); ip != nil && privateAddress(ip) {
235+ return errors.New("invalid: url must not name a private or loopback address")
236+ }
237+ return nil
238+}
239+
240+func privateAddress(ip net.IP) bool {
241+ if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
242+ return true
243+ }
244+ _, shared, _ := net.ParseCIDR("100.64.0.0/10")
245+ return shared.Contains(ip)
246+}
247+
248+// loopbackRelay reports whether the relay's own public URL is a loopback or
249+// private address, which only happens in tests and local trials.
250+func (t *Tenant) loopbackRelay() bool {
251+ parsed, err := url.Parse(t.publicURL)
252+ if err != nil {
253+ return false
254+ }
255+ host := strings.ToLower(parsed.Hostname())
256+ if host == "localhost" {
257+ return true
258+ }
259+ ip := net.ParseIP(host)
260+ return ip != nil && privateAddress(ip)
261+}
262+
263+func callbackMethod(method string) bool {
264+ return containsString([]string{"listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback"}, method)
265+}
266+
267+// callbackExecute serves the callback management methods. Members and agents
268+// manage callbacks for their own key; the owner and moderators see and
269+// control every callback. Each call runs under the callback operation.
270+func (t *Tenant) callbackExecute(ctx context.Context, actor, method string, params []json.RawMessage) (result any, err error) {
271+ ctx, finish := t.app.telemetry.Start(ctx, "callback")
272+ outcome := "error"
273+ defer func() {
274+ if err != nil {
275+ outcome = callbackOutcome(err)
276+ }
277+ finish(outcome)
278+ }()
279+ role, err := t.community.Role(ctx, actor)
280+ if err != nil {
281+ return nil, err
282+ }
283+ if role == "" {
284+ return nil, fmt.Errorf("restricted: guest may not %s", method)
285+ }
286+ operator := role == "owner" || role == "moderator"
287+ switch method {
288+ case "listcallbacks":
289+ var rows []callbackRecord
290+ if operator {
291+ rows, err = t.callbackRows(ctx, "")
292+ } else {
293+ rows, err = t.callbackRows(ctx, actor)
294+ }
295+ if err != nil {
296+ return nil, err
297+ }
298+ out := make([]map[string]any, 0, len(rows))
299+ for _, row := range rows {
300+ item := row.summary()
301+ if row.Owner != actor {
302+ // Operators see whose callback it is and where it goes,
303+ // not the path, which may carry a token.
304+ delete(item, "url")
305+ }
306+ out = append(out, item)
307+ }
308+ outcome = "ok"
309+ return out, nil
310+ case "addcallback":
311+ result, err = t.addCallback(ctx, actor, role, params)
312+ default:
313+ result, err = t.changeCallback(ctx, actor, operator, method, params)
314+ }
315+ if err == nil {
316+ outcome = "ok"
317+ }
318+ return result, err
319+}
320+
321+func callbackOutcome(err error) string {
322+ switch {
323+ case strings.HasPrefix(err.Error(), "invalid:"), strings.HasPrefix(err.Error(), "not found:"):
324+ return "invalid"
325+ case strings.HasPrefix(err.Error(), "restricted:"):
326+ return "unauthorized"
327+ default:
328+ return "error"
329+ }
330+}
331+
332+func (t *Tenant) addCallback(ctx context.Context, actor, role string, params []json.RawMessage) (any, error) {
333+ if len(params) != 1 {
334+ return nil, errors.New("invalid: addcallback expects one object with url, filter and an optional secret")
335+ }
336+ var options struct {
337+ URL string `json:"url"`
338+ Filter json.RawMessage `json:"filter"`
339+ Secret string `json:"secret"`
340+ }
341+ if err := json.Unmarshal(params[0], &options); err != nil {
342+ return nil, errors.New("invalid: addcallback expects one object with url, filter and an optional secret")
343+ }
344+ options.URL = strings.TrimSpace(options.URL)
345+ if err := t.checkCallbackURL(options.URL); err != nil {
346+ return nil, err
347+ }
348+ filter, err := parseCallbackFilter(options.Filter)
349+ if err != nil {
350+ return nil, err
351+ }
352+ if role == "agent" {
353+ if err := t.checkCallbackScope(ctx, actor, filter); err != nil {
354+ return nil, err
355+ }
356+ }
357+ generated := false
358+ secret := strings.TrimSpace(options.Secret)
359+ if secret == "" {
360+ secret, err = randomHex(32)
361+ if err != nil {
362+ return nil, err
363+ }
364+ generated = true
365+ } else if len(secret) < callbackSecretMin || len(secret) > callbackSecretMax || strings.Trim(secret, "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~") != "" {
366+ return nil, fmt.Errorf("invalid: secret must be %d to %d printable ASCII characters", callbackSecretMin, callbackSecretMax)
367+ }
368+ id, err := randomHex(16)
369+ if err != nil {
370+ return nil, err
371+ }
372+ encoded, err := json.Marshal(filter)
373+ if err != nil {
374+ return nil, err
375+ }
376+ limit := t.Policy().Callbacks
377+ now := time.Now().Unix()
378+ err = t.store.WithTx(ctx, func(tx *sql.Tx) error {
379+ if role != "owner" {
380+ var count int
381+ if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM callbacks WHERE owner=?`, actor).Scan(&count); err != nil {
382+ return err
383+ }
384+ if count >= limit {
385+ return fmt.Errorf("invalid: at most %d callbacks per key", limit)
386+ }
387+ }
388+ _, err := tx.ExecContext(ctx, `INSERT INTO callbacks(id,owner,url,filter,secret,created_at) VALUES(?,?,?,?,?,?)`, id, actor, options.URL, string(encoded), secret, now)
389+ return err
390+ })
391+ if err != nil {
392+ return nil, err
393+ }
394+ t.invalidateCallbacks()
395+ if err := t.community.Record(ctx, actor, "addcallback", id, ""); err != nil {
396+ return nil, err
397+ }
398+ // Counts only: the host, path and secret stay out of the log.
399+ t.app.telemetry.Logger().Info("callback registered", "tenant", t.meta.Name, "kinds", len(filter.Kinds), "generated", generated)
400+ record := callbackRecord{ID: id, Owner: actor, URL: options.URL, Filter: filter, CreatedAt: now}
401+ out := record.summary()
402+ out["secret"] = secret
403+ return out, nil
404+}
405+
406+// checkCallbackScope keeps an agent's callback inside its grant: a room it
407+// names must be one it may post in and a repository it names must be one
408+// the grant covers. Kinds are not restricted, so an agent can listen for
409+// answers it may not publish itself.
410+func (t *Tenant) checkCallbackScope(ctx context.Context, actor string, filter callbackFilter) error {
411+ grant, ok, err := t.community.AgentGrant(ctx, actor)
412+ if err != nil {
413+ return err
414+ }
415+ if !ok || !grant.Active(time.Now().Unix()) {
416+ return errors.New("restricted: agent grant is not active")
417+ }
418+ for _, room := range filter.Tags["h"] {
419+ if !grant.AllowsRoom(room) {
420+ return fmt.Errorf("restricted: agent grant does not cover room %s", room)
421+ }
422+ }
423+ for _, coordinate := range filter.Tags["a"] {
424+ owner, identifier, ok := repoCoordinate(coordinate)
425+ if !ok {
426+ continue
427+ }
428+ if grant.RepoLevel(owner, identifier) == "" {
429+ return fmt.Errorf("restricted: agent grant does not cover repository %s", identifier)
430+ }
431+ }
432+ return nil
433+}
434+
435+func (t *Tenant) changeCallback(ctx context.Context, actor string, operator bool, method string, params []json.RawMessage) (any, error) {
436+ id := callbackIDParam(params)
437+ if id == "" {
438+ return nil, errors.New("invalid: callback id required")
439+ }
440+ record, err := t.callback(ctx, id)
441+ if err != nil {
442+ return nil, err
443+ }
444+ if !operator && record.Owner != actor {
445+ return nil, fmt.Errorf("restricted: only the callback's owner, the relay owner or a moderator may %s", method)
446+ }
447+ switch method {
448+ case "removecallback":
449+ _, err = t.store.DB().ExecContext(ctx, `DELETE FROM callbacks WHERE id=?`, id)
450+ case "pausecallback":
451+ _, err = t.store.DB().ExecContext(ctx, `UPDATE callbacks SET paused=1, last_status='paused' WHERE id=?`, id)
452+ record.Paused, record.LastStatus = true, "paused"
453+ case "resumecallback":
454+ _, err = t.store.DB().ExecContext(ctx, `UPDATE callbacks SET paused=0, failures=0, last_status='' WHERE id=?`, id)
455+ record.Paused, record.Failures, record.LastStatus = false, 0, ""
456+ default:
457+ return nil, fmt.Errorf("unsupported: management method %q", method)
458+ }
459+ if err != nil {
460+ return nil, err
461+ }
462+ t.invalidateCallbacks()
463+ if err := t.community.Record(ctx, actor, method, id, ""); err != nil {
464+ return nil, err
465+ }
466+ if method == "removecallback" {
467+ return map[string]any{"id": id, "removed": true}, nil
468+ }
469+ return record.summary(), nil
470+}
471+
472+// callbackIDParam reads the id from a bare string or from {"id": ...}.
473+func callbackIDParam(params []json.RawMessage) string {
474+ if len(params) == 0 {
475+ return ""
476+ }
477+ if id := stringParam(params, 0); id != "" {
478+ return id
479+ }
480+ var options struct {
481+ ID string `json:"id"`
482+ }
483+ _ = json.Unmarshal(params[0], &options)
484+ return strings.TrimSpace(options.ID)
485+}
486+
487+func (t *Tenant) callback(ctx context.Context, id string) (callbackRecord, error) {
488+ rows, err := t.callbackRows(ctx, "", id)
489+ if err != nil {
490+ return callbackRecord{}, err
491+ }
492+ if len(rows) == 0 {
493+ return callbackRecord{}, errors.New("not found: callback")
494+ }
495+ return rows[0], nil
496+}
497+
498+// callbackRows lists callbacks, narrowed to an owner or to one id.
499+func (t *Tenant) callbackRows(ctx context.Context, owner string, ids ...string) ([]callbackRecord, error) {
500+ query := `SELECT id,owner,url,filter,secret,created_at,last_delivery_at,last_status,failures,paused FROM callbacks`
501+ args := []any{}
502+ switch {
503+ case len(ids) > 0:
504+ query += ` WHERE id=?`
505+ args = append(args, ids[0])
506+ case owner != "":
507+ query += ` WHERE owner=?`
508+ args = append(args, owner)
509+ }
510+ query += ` ORDER BY created_at,id`
511+ rows, err := t.store.DB().QueryContext(ctx, query, args...)
512+ if err != nil {
513+ return nil, fmt.Errorf("list callbacks: %w", err)
514+ }
515+ defer rows.Close()
516+ var out []callbackRecord
517+ for rows.Next() {
518+ var item callbackRecord
519+ var filter string
520+ var paused int
521+ if err := rows.Scan(&item.ID, &item.Owner, &item.URL, &filter, &item.secret, &item.CreatedAt, &item.LastAt, &item.LastStatus, &item.Failures, &paused); err != nil {
522+ return nil, err
523+ }
524+ item.Paused = paused != 0
525+ if item.Filter, err = parseCallbackFilter(json.RawMessage(filter)); err != nil {
526+ return nil, fmt.Errorf("callback filter: %w", err)
527+ }
528+ out = append(out, item)
529+ }
530+ return out, rows.Err()
531+}
532+
533+// callbackRules returns the active callbacks indexed by kind. The index is
534+// built from the table on first use and dropped whenever a callback
535+// changes, so matching an event costs one map lookup.
536+func (t *Tenant) callbackRules(ctx context.Context) map[int][]callbackRecord {
537+ t.callbackMu.Lock()
538+ defer t.callbackMu.Unlock()
539+ if t.callbackIndex != nil {
540+ return t.callbackIndex
541+ }
542+ index := map[int][]callbackRecord{}
543+ rows, err := t.callbackRows(ctx, "")
544+ if err != nil {
545+ t.app.telemetry.Logger().Error("load callbacks", "tenant", t.meta.Name, "error", err)
546+ return index
547+ }
548+ for _, row := range rows {
549+ if row.Paused {
550+ continue
551+ }
552+ for _, kind := range row.Filter.Kinds {
553+ index[kind] = append(index[kind], row)
554+ }
555+ }
556+ t.callbackIndex = index
557+ return index
558+}
559+
560+func (t *Tenant) invalidateCallbacks() {
561+ t.callbackMu.Lock()
562+ t.callbackIndex = nil
563+ t.callbackMu.Unlock()
564+}
565+
566+// notifyCallbacks runs after an event is stored. Each active callback whose
567+// filter matches and whose key may see the event gets one delivery intent.
568+// It never fails the publish; a callback is best effort on top of the
569+// durable event.
570+func (t *Tenant) notifyCallbacks(ctx context.Context, e event.Event) {
571+ candidates := t.callbackRules(ctx)[e.Kind]
572+ if len(candidates) == 0 {
573+ return
574+ }
575+ var intents []storage.Intent
576+ unauthorized := 0
577+ for _, candidate := range candidates {
578+ if !event.Matches(candidate.Filter.eventFilter(), e) {
579+ continue
580+ }
581+ if !t.gate.CanSee(ctx, e, relay.Session{PubKeys: []string{candidate.Owner}, RelayURL: t.RelayURL()}, nil) {
582+ unauthorized++
583+ continue
584+ }
585+ payload, err := json.Marshal(callbackPayload{Event: e, Attempt: 1})
586+ if err != nil {
587+ continue
588+ }
589+ intents = append(intents, storage.Intent{Kind: callbackDelivery, EventID: e.ID, Target: candidate.ID, Payload: string(payload)})
590+ }
591+ if len(intents) == 0 {
592+ return
593+ }
594+ err := t.store.WithTx(ctx, func(tx *sql.Tx) error {
595+ return storage.AddIntents(ctx, tx, intents, time.Now().Unix())
596+ })
597+ if err != nil {
598+ t.app.telemetry.Logger().Error("callback deliveries not queued", "tenant", t.meta.Name, "error", err)
599+ return
600+ }
601+ t.app.telemetry.Logger().Debug("callback deliveries queued", "tenant", t.meta.Name, "queued", len(intents), "unauthorized", unauthorized)
602+}
603+
604+func randomHex(size int) (string, error) {
605+ buffer := make([]byte, size)
606+ if _, err := rand.Read(buffer); err != nil {
607+ return "", fmt.Errorf("callback secret: %w", err)
608+ }
609+ return hex.EncodeToString(buffer), nil
610+}
611diff --git a/internal/daemon/callbacks_delivery.go b/internal/daemon/callbacks_delivery.go
612new file mode 100644
613index 0000000..7cbcfaa
614--- /dev/null
615+++ b/internal/daemon/callbacks_delivery.go
616@@ -0,0 +1,205 @@
617+package daemon
618+
619+// Callback delivery. Each matching event is one POST to the callback URL,
620+// signed with the callback's secret. A failed POST is tried again after one
621+// minute and then after five; after 20 failures in a row the callback is
622+// paused until its owner resumes it.
623+
624+import (
625+ "context"
626+ "crypto/hmac"
627+ "crypto/sha256"
628+ "crypto/tls"
629+ "database/sql"
630+ "encoding/hex"
631+ "encoding/json"
632+ "errors"
633+ "fmt"
634+ "io"
635+ "net/http"
636+ "strings"
637+ "sync"
638+ "time"
639+
640+ "github.com/FelineStateMachine/tinyrelay/internal/event"
641+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
642+ "github.com/FelineStateMachine/tinyrelay/internal/replication"
643+ "github.com/FelineStateMachine/tinyrelay/internal/work"
644+)
645+
646+// callbackPayload is the work intent body: the event itself, so delivery
647+// does not depend on the event still being stored, and the attempt number.
648+type callbackPayload struct {
649+ Event event.Event `json:"event"`
650+ Attempt int `json:"attempt"`
651+}
652+
653+// callbackSignature is the X-Tiny-Signature value for a body: sha256= and
654+// the hex HMAC-SHA256 of the body under the callback secret.
655+func callbackSignature(secret string, body []byte) string {
656+ mac := hmac.New(sha256.New, []byte(secret))
657+ mac.Write(body)
658+ return "sha256=" + hex.EncodeToString(mac.Sum(nil))
659+}
660+
661+// callbackHTTPClient refuses private addresses and redirects unless the
662+// relay itself runs on a loopback address.
663+func (t *Tenant) callbackHTTPClient() *http.Client {
664+ if t.callbackClient != nil {
665+ return t.callbackClient
666+ }
667+ if t.loopbackRelay() {
668+ return &http.Client{Timeout: callbackTimeout, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
669+ }
670+ return replication.NewPinnedClient(callbackTimeout)
671+}
672+
673+// callbackLock bounds delivery to one POST per callback at a time.
674+func (t *Tenant) callbackLock(id string) *sync.Mutex {
675+ t.callbackMu.Lock()
676+ defer t.callbackMu.Unlock()
677+ if t.callbackLocks == nil {
678+ t.callbackLocks = map[string]*sync.Mutex{}
679+ }
680+ lock := t.callbackLocks[id]
681+ if lock == nil {
682+ lock = &sync.Mutex{}
683+ t.callbackLocks[id] = lock
684+ }
685+ return lock
686+}
687+
688+// handleCallbackDelivery serves one callback-delivery intent. It rechecks
689+// the callback and the key's access right before the POST, so a callback
690+// removed or paused after the event arrived delivers nothing.
691+func (t *Tenant) handleCallbackDelivery(ctx context.Context, intent work.Intent) (err error) {
692+ ctx, finish := t.app.telemetry.Start(ctx, "callback")
693+ outcome := "error"
694+ defer func() { finish(outcome) }()
695+ var payload callbackPayload
696+ if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil || payload.Event.ID == "" {
697+ outcome = "invalid"
698+ return errors.New("callback-delivery: invalid payload")
699+ }
700+ if payload.Attempt < 1 {
701+ payload.Attempt = 1
702+ }
703+ lock := t.callbackLock(intent.Target)
704+ lock.Lock()
705+ defer lock.Unlock()
706+ record, err := t.callback(ctx, intent.Target)
707+ if err != nil {
708+ if strings.HasPrefix(err.Error(), "not found:") {
709+ outcome = "paused"
710+ return nil
711+ }
712+ return err
713+ }
714+ if record.Paused {
715+ outcome = "paused"
716+ return nil
717+ }
718+ role, err := t.community.Role(ctx, record.Owner)
719+ if err != nil {
720+ return err
721+ }
722+ if role == "" && record.Owner != t.Policy().Owner {
723+ // The key lost its standing since it registered; stop until a
724+ // person resumes the callback.
725+ outcome = "unauthorized"
726+ return t.pauseCallback(ctx, record.ID, record.Failures, "paused: the key is no longer a member")
727+ }
728+ if !t.gate.CanSee(ctx, payload.Event, relay.Session{PubKeys: []string{record.Owner}, RelayURL: t.RelayURL()}, nil) {
729+ outcome = "unauthorized"
730+ return nil
731+ }
732+ status, postErr := t.postCallback(ctx, record, payload.Event)
733+ now := time.Now().Unix()
734+ if postErr == nil {
735+ outcome = "ok"
736+ _, err = t.store.DB().ExecContext(ctx, `UPDATE callbacks SET last_delivery_at=?, last_status='ok', failures=0 WHERE id=?`, now, record.ID)
737+ t.app.telemetry.Logger().Info("callback delivered", "tenant", t.meta.Name, "attempt", payload.Attempt, "status", status)
738+ return err
739+ }
740+ // Counts only: the reason names the HTTP status or the failure class,
741+ // never the URL.
742+ reason := postErr.Error()
743+ failures := record.Failures + 1
744+ paused := failures >= callbackPauseFailures
745+ if paused {
746+ outcome = "paused"
747+ if err := t.pauseCallback(ctx, record.ID, failures, fmt.Sprintf("paused after %d failures: %s", failures, reason)); err != nil {
748+ return err
749+ }
750+ } else if _, err := t.store.DB().ExecContext(ctx, `UPDATE callbacks SET failures=?, last_status=? WHERE id=?`, failures, reason, record.ID); err != nil {
751+ return err
752+ }
753+ t.app.telemetry.Logger().Info("callback delivery failed", "tenant", t.meta.Name, "attempt", payload.Attempt, "status", status, "failures", failures, "paused", paused)
754+ if paused || payload.Attempt >= callbackMaxAttempts {
755+ return nil
756+ }
757+ return t.retryCallback(ctx, intent, payload)
758+}
759+
760+// pauseCallback stops deliveries and records the count and reason, then
761+// drops the index so no new intents are queued for the callback.
762+func (t *Tenant) pauseCallback(ctx context.Context, id string, failures int, status string) error {
763+ if _, err := t.store.DB().ExecContext(ctx, `UPDATE callbacks SET paused=1, failures=?, last_status=? WHERE id=?`, failures, status, id); err != nil {
764+ return err
765+ }
766+ t.invalidateCallbacks()
767+ return nil
768+}
769+
770+// retryCallback queues the next attempt after its backoff. Each attempt is
771+// its own intent so the durable queue's ordering and fencing still apply.
772+func (t *Tenant) retryCallback(ctx context.Context, intent work.Intent, payload callbackPayload) error {
773+ delay := callbackBackoff[len(callbackBackoff)-1]
774+ if payload.Attempt-1 < len(callbackBackoff) {
775+ delay = callbackBackoff[payload.Attempt-1]
776+ }
777+ payload.Attempt++
778+ encoded, err := json.Marshal(payload)
779+ if err != nil {
780+ return err
781+ }
782+ now := time.Now().Unix()
783+ key := sha256.Sum256([]byte(callbackDelivery + "\x00" + payload.Event.ID + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt)))
784+ return t.store.WithTx(ctx, func(tx *sql.Tx) error {
785+ _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), callbackDelivery, payload.Event.ID+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now)
786+ return err
787+ })
788+}
789+
790+// postCallback sends the event and returns the HTTP status with a bounded
791+// reason on failure.
792+func (t *Tenant) postCallback(ctx context.Context, record callbackRecord, e event.Event) (int, error) {
793+ body, err := json.Marshal(e)
794+ if err != nil {
795+ return 0, errors.New("encode event")
796+ }
797+ ctx, cancel := context.WithTimeout(ctx, callbackTimeout)
798+ defer cancel()
799+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, record.URL, strings.NewReader(string(body)))
800+ if err != nil {
801+ return 0, errors.New("bad url")
802+ }
803+ request.Header.Set("Content-Type", "application/json")
804+ request.Header.Set("X-Tiny-Callback", record.ID)
805+ request.Header.Set("X-Tiny-Signature", callbackSignature(record.secret, body))
806+ request.Header.Set("X-Tiny-Relay", t.publicURL)
807+ request.Header.Set("User-Agent", "tinyrelay")
808+ response, err := t.callbackHTTPClient().Do(request)
809+ if err != nil {
810+ if errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil {
811+ return 0, errors.New("timeout")
812+ }
813+ return 0, errors.New("connection failed")
814+ }
815+ defer response.Body.Close()
816+ _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
817+ if response.StatusCode < 200 || response.StatusCode >= 300 {
818+ return response.StatusCode, fmt.Errorf("HTTP %d", response.StatusCode)
819+ }
820+ return response.StatusCode, nil
821+}
822diff --git a/internal/daemon/callbacks_test.go b/internal/daemon/callbacks_test.go
823new file mode 100644
824index 0000000..c8448b9
825--- /dev/null
826+++ b/internal/daemon/callbacks_test.go
827@@ -0,0 +1,679 @@
828+package daemon
829+
830+import (
831+ "context"
832+ "crypto/hmac"
833+ "crypto/sha256"
834+ "encoding/hex"
835+ "encoding/json"
836+ "io"
837+ "net/http"
838+ "net/http/httptest"
839+ "strconv"
840+ "strings"
841+ "sync"
842+ "testing"
843+ "time"
844+
845+ "github.com/FelineStateMachine/tinyrelay/internal/community"
846+ "github.com/FelineStateMachine/tinyrelay/internal/event"
847+ "github.com/FelineStateMachine/tinyrelay/internal/work"
848+)
849+
850+// loopbackTenant is a tenant whose public URL is a loopback address, which
851+// lets a callback point at a local test server.
852+func loopbackTenant(t *testing.T) (*App, *Tenant) {
853+ t.Helper()
854+ ctx := context.Background()
855+ a, err := New(ctx, Config{DataDir: t.TempDir(), DefaultTenant: "main"})
856+ if err != nil {
857+ t.Fatal(err)
858+ }
859+ t.Cleanup(func() {
860+ if err := a.Close(ctx); err != nil {
861+ t.Error(err)
862+ }
863+ })
864+ owner, _ := event.PublicKey(testOwnerSecret)
865+ meta, err := a.Create(ctx, CreateOptions{Name: "main", Owner: owner, Template: "default"})
866+ if err != nil {
867+ t.Fatal(err)
868+ }
869+ tenant, err := a.tenant(ctx, meta, "http://127.0.0.1:8080")
870+ if err != nil {
871+ t.Fatal(err)
872+ }
873+ // Fence the production worker so the tests drive each delivery intent
874+ // themselves.
875+ tenant.workCancel()
876+ tenant.workWG.Wait()
877+ return a, tenant
878+}
879+
880+func setRole(t *testing.T, tenant *Tenant, pubkey, role string) {
881+ t.Helper()
882+ raw, _ := json.Marshal(map[string]string{"role": role})
883+ if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(pubkey)), raw}); err != nil {
884+ t.Fatal(err)
885+ }
886+}
887+
888+func callbackParams(url string, filter string, extra ...string) []json.RawMessage {
889+ options := map[string]any{"url": url, "filter": json.RawMessage(filter)}
890+ if len(extra) > 0 {
891+ options["secret"] = extra[0]
892+ }
893+ raw, _ := json.Marshal(options)
894+ return []json.RawMessage{raw}
895+}
896+
897+func addCallback(t *testing.T, tenant *Tenant, actor, url, filter string) map[string]any {
898+ t.Helper()
899+ result, err := tenant.Execute(context.Background(), actor, "addcallback", callbackParams(url, filter))
900+ if err != nil {
901+ t.Fatalf("addcallback: %v", err)
902+ }
903+ return result.(map[string]any)
904+}
905+
906+func TestCallbackRegistrationValidatesURLFilterScopeAndCap(t *testing.T) {
907+ _, tenant := testTenant(t)
908+ ctx := context.Background()
909+ owner := tenant.Policy().Owner
910+ member, _ := event.PublicKey(testMemberSecret)
911+ agent, _ := event.PublicKey(testAgentSecret)
912+ setRole(t, tenant, member, "member")
913+ now := time.Now().Unix()
914+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"}, []string{"room", "build"}, []string{"repo", owner + ":tinyrelay:read"})); err != nil {
915+ t.Fatal(err)
916+ }
917+ ids := make([]string, 9)
918+ for i := range ids {
919+ ids[i] = strings.Repeat(strconv.Itoa(i), 64)
920+ }
921+ nineIDs, _ := json.Marshal(ids)
922+ for _, tc := range []struct {
923+ name, actor, url, filter, want string
924+ }{
925+ {"guest", strings.Repeat("e", 64), "https://hooks.example/wake", `{"kinds":[1]}`, "restricted:"},
926+ {"http url", member, "http://hooks.example/wake", `{"kinds":[1]}`, "invalid: url must be https"},
927+ {"credentials", member, "https://user:pw@hooks.example/wake", `{"kinds":[1]}`, "invalid: url must be https"},
928+ {"private address", member, "https://10.0.0.1/wake", `{"kinds":[1]}`, "invalid: url must not name a private"},
929+ {"loopback address", member, "https://127.0.0.1/wake", `{"kinds":[1]}`, "invalid: url must not name a private"},
930+ {"localhost", member, "https://localhost/wake", `{"kinds":[1]}`, "invalid: url must name a public host"},
931+ {"bare host", member, "https://relay/wake", `{"kinds":[1]}`, "invalid: url must name a public host"},
932+ {"no kinds", member, "https://hooks.example/wake", `{"#p":["` + member + `"]}`, "invalid: filter must name at least one kind"},
933+ {"unsupported key", member, "https://hooks.example/wake", `{"kinds":[1],"ids":["` + strings.Repeat("a", 64) + `"]}`, "invalid: filter key \"ids\""},
934+ {"unsupported tag", member, "https://hooks.example/wake", `{"kinds":[1],"#t":["x"]}`, "invalid: filter key \"#t\""},
935+ {"too many values", member, "https://hooks.example/wake", `{"kinds":[1],"#e":` + string(nineIDs) + `}`, "invalid: #e must list 1 to 8 values"},
936+ {"bad pubkey", member, "https://hooks.example/wake", `{"kinds":[1],"#p":["nope"]}`, "invalid: #p values"},
937+ {"bad coordinate", member, "https://hooks.example/wake", `{"kinds":[1621],"#a":["tinyrelay"]}`, "invalid: #a values"},
938+ {"bad room", member, "https://hooks.example/wake", `{"kinds":[9],"#h":["Not A Room"]}`, "invalid: #h values"},
939+ {"not an object", member, "https://hooks.example/wake", `[1]`, "invalid: filter must be a JSON object"},
940+ {"agent room outside grant", agent, "https://hooks.example/wake", `{"kinds":[9],"#h":["general"]}`, "restricted: agent grant does not cover room general"},
941+ {"agent repo outside grant", agent, "https://hooks.example/wake", `{"kinds":[1621],"#a":["30617:` + owner + `:other"]}`, "restricted: agent grant does not cover repository other"},
942+ } {
943+ _, err := tenant.Execute(ctx, tc.actor, "addcallback", callbackParams(tc.url, tc.filter))
944+ if err == nil || !strings.HasPrefix(err.Error(), tc.want) {
945+ t.Errorf("%s: err = %v, want prefix %q", tc.name, err, tc.want)
946+ }
947+ }
948+ if _, err := tenant.Execute(ctx, member, "addcallback", callbackParams("https://hooks.example/wake", `{"kinds":[1]}`, "short")); err == nil || !strings.HasPrefix(err.Error(), "invalid: secret must be") {
949+ t.Fatalf("short secret: %v", err)
950+ }
951+ // An agent may watch the room and repository its grant covers, and any
952+ // kind, including ones it may not publish.
953+ inside := addCallback(t, tenant, agent, "https://hooks.example/wake", `{"kinds":[7,9,1621],"#h":["build"],"#a":["30617:`+owner+`:tinyrelay"],"since":1}`)
954+ if inside["owner"] != agent || inside["host"] != "hooks.example" || inside["paused"] != false {
955+ t.Fatalf("agent callback = %v", inside)
956+ }
957+ secret, _ := inside["secret"].(string)
958+ if len(secret) != 64 {
959+ t.Fatalf("generated secret = %q", secret)
960+ }
961+ filter, _ := json.Marshal(inside["filter"])
962+ if string(filter) != `{"#a":["30617:`+owner+`:tinyrelay"],"#h":["build"],"kinds":[7,9,1621]}` {
963+ t.Fatalf("stored filter = %s", filter)
964+ }
965+ // A member gets four callbacks; the owner has no cap.
966+ for i := 0; i < 4; i++ {
967+ addCallback(t, tenant, member, "https://hooks.example/wake/"+strconv.Itoa(i), `{"kinds":[1]}`)
968+ }
969+ if _, err := tenant.Execute(ctx, member, "addcallback", callbackParams("https://hooks.example/wake/5", `{"kinds":[1]}`)); err == nil || err.Error() != "invalid: at most 4 callbacks per key" {
970+ t.Fatalf("fifth callback: %v", err)
971+ }
972+ for i := 0; i < 6; i++ {
973+ addCallback(t, tenant, owner, "https://hooks.example/owner/"+strconv.Itoa(i), `{"kinds":[1]}`)
974+ }
975+ // The secret shows once: listings and the table row never repeat it.
976+ listed, err := tenant.Execute(ctx, agent, "listcallbacks", nil)
977+ if err != nil {
978+ t.Fatal(err)
979+ }
980+ rows := listed.([]map[string]any)
981+ if len(rows) != 1 || rows[0]["id"] != inside["id"] || rows[0]["url"] != "https://hooks.example/wake" {
982+ t.Fatalf("agent listing = %v", rows)
983+ }
984+ if _, present := rows[0]["secret"]; present {
985+ t.Fatal("listing repeats the secret")
986+ }
987+ encoded, _ := json.Marshal(rows[0])
988+ if strings.Contains(string(encoded), secret) {
989+ t.Fatal("listing carries the secret")
990+ }
991+ // Setting the policy allowance to zero closes registration for
992+ // everyone but the owner.
993+ if _, err := tenant.Execute(ctx, owner, "setpolicy", []json.RawMessage{json.RawMessage(`{"callbacks":0}`)}); err != nil {
994+ t.Fatal(err)
995+ }
996+ if _, err := tenant.Execute(ctx, agent, "addcallback", callbackParams("https://hooks.example/again", `{"kinds":[1]}`)); err == nil || err.Error() != "invalid: at most 0 callbacks per key" {
997+ t.Fatalf("closed registration: %v", err)
998+ }
999+ if got := tenant.Policy().Callbacks; got != 0 {
1000+ t.Fatalf("policy callbacks = %d", got)
1001+ }
1002+}
1003+
1004+func TestCallbackManagementPermissionMatrix(t *testing.T) {
1005+ _, tenant := testTenant(t)
1006+ ctx := context.Background()
1007+ owner := tenant.Policy().Owner
1008+ member, _ := event.PublicKey(testMemberSecret)
1009+ moderator, _ := event.PublicKey(testModSecret)
1010+ agent, _ := event.PublicKey(testAgentSecret)
1011+ guest := strings.Repeat("e", 64)
1012+ setRole(t, tenant, member, "member")
1013+ setRole(t, tenant, moderator, "moderator")
1014+ now := time.Now().Unix()
1015+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})); err != nil {
1016+ t.Fatal(err)
1017+ }
1018+ methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)
1019+ if err != nil {
1020+ t.Fatal(err)
1021+ }
1022+ for _, name := range []string{"listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback"} {
1023+ if !containsString(methods.([]string), name) {
1024+ t.Fatalf("supportedmethods lacks %s", name)
1025+ }
1026+ }
1027+ mine := addCallback(t, tenant, member, "https://hooks.example/member?token=abc", `{"kinds":[1]}`)
1028+ theirs := addCallback(t, tenant, agent, "https://hooks.example/agent", `{"kinds":[1]}`)
1029+ id := func(values map[string]any) json.RawMessage {
1030+ return json.RawMessage(strconv.Quote(values["id"].(string)))
1031+ }
1032+ for _, method := range []string{"listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback"} {
1033+ if _, err := tenant.Execute(ctx, guest, method, []json.RawMessage{id(mine)}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
1034+ t.Fatalf("%s by guest: %v", method, err)
1035+ }
1036+ }
1037+ // A member sees and controls only its own callbacks.
1038+ listed, _ := tenant.Execute(ctx, member, "listcallbacks", nil)
1039+ if rows := listed.([]map[string]any); len(rows) != 1 || rows[0]["id"] != mine["id"] {
1040+ t.Fatalf("member listing = %v", rows)
1041+ }
1042+ for _, method := range []string{"pausecallback", "resumecallback", "removecallback"} {
1043+ if _, err := tenant.Execute(ctx, member, method, []json.RawMessage{id(theirs)}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
1044+ t.Fatalf("%s on another key's callback: %v", method, err)
1045+ }
1046+ }
1047+ paused, err := tenant.Execute(ctx, member, "pausecallback", []json.RawMessage{json.RawMessage(`{"id":"` + mine["id"].(string) + `"}`)})
1048+ if err != nil || paused.(map[string]any)["paused"] != true {
1049+ t.Fatalf("pause own: %v %v", paused, err)
1050+ }
1051+ if resumed, err := tenant.Execute(ctx, member, "resumecallback", []json.RawMessage{id(mine)}); err != nil || resumed.(map[string]any)["paused"] != false {
1052+ t.Fatalf("resume own: %v %v", resumed, err)
1053+ }
1054+ // Moderators and the owner see everything, with the host but not the
1055+ // path of other keys' URLs, and may control any callback.
1056+ for _, actor := range []string{moderator, owner} {
1057+ listed, err := tenant.Execute(ctx, actor, "listcallbacks", nil)
1058+ if err != nil {
1059+ t.Fatalf("listcallbacks by %s: %v", actor, err)
1060+ }
1061+ rows := listed.([]map[string]any)
1062+ byOwner := map[string]map[string]any{}
1063+ for _, row := range rows {
1064+ byOwner[row["owner"].(string)] = row
1065+ }
1066+ if len(rows) != 2 || byOwner[member]["host"] != "hooks.example" || byOwner[agent]["id"] != theirs["id"] {
1067+ t.Fatalf("operator listing = %v", rows)
1068+ }
1069+ if _, present := byOwner[member]["url"]; present {
1070+ t.Fatalf("operator listing carries another key's URL path: %v", byOwner[member])
1071+ }
1072+ if _, err := tenant.Execute(ctx, actor, "pausecallback", []json.RawMessage{id(theirs)}); err != nil {
1073+ t.Fatalf("pause by %s: %v", actor, err)
1074+ }
1075+ if _, err := tenant.Execute(ctx, actor, "resumecallback", []json.RawMessage{id(theirs)}); err != nil {
1076+ t.Fatalf("resume by %s: %v", actor, err)
1077+ }
1078+ }
1079+ if _, err := tenant.Execute(ctx, owner, "pausecallback", []json.RawMessage{json.RawMessage(`"missing"`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
1080+ t.Fatalf("unknown id: %v", err)
1081+ }
1082+ if _, err := tenant.Execute(ctx, owner, "pausecallback", nil); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {
1083+ t.Fatalf("missing id: %v", err)
1084+ }
1085+ removed, err := tenant.Execute(ctx, moderator, "removecallback", []json.RawMessage{id(theirs)})
1086+ if err != nil || removed.(map[string]any)["removed"] != true {
1087+ t.Fatalf("remove by moderator: %v %v", removed, err)
1088+ }
1089+ if _, err := tenant.Execute(ctx, agent, "removecallback", []json.RawMessage{id(mine)}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
1090+ t.Fatalf("agent removing a member's callback: %v", err)
1091+ }
1092+ if _, err := tenant.Execute(ctx, member, "removecallback", []json.RawMessage{id(mine)}); err != nil {
1093+ t.Fatalf("remove own: %v", err)
1094+ }
1095+ audit, err := tenant.Execute(ctx, owner, "listaudit", nil)
1096+ if err != nil {
1097+ t.Fatal(err)
1098+ }
1099+ seen := map[string]bool{}
1100+ for _, row := range audit.([]community.AuditRow) {
1101+ seen[row.Action] = true
1102+ if strings.Contains(row.Detail, "hooks.example") || strings.Contains(row.Target, "hooks.example") {
1103+ t.Fatalf("audit carries a callback URL: %+v", row)
1104+ }
1105+ }
1106+ for _, action := range []string{"addcallback", "pausecallback", "resumecallback", "removecallback"} {
1107+ if !seen[action] {
1108+ t.Fatalf("audit lacks %s: %v", action, seen)
1109+ }
1110+ }
1111+}
1112+
1113+func TestCallbackMatchesFilterAndKeepsPrivateEventsBehindTheGate(t *testing.T) {
1114+ _, tenant := loopbackTenant(t)
1115+ ctx := context.Background()
1116+ member, _ := event.PublicKey(testMemberSecret)
1117+ moderator, _ := event.PublicKey(testModSecret)
1118+ setRole(t, tenant, member, "member")
1119+ setRole(t, tenant, moderator, "moderator")
1120+ now := time.Now().Unix()
1121+ notes := addCallback(t, tenant, member, "https://127.0.0.1:1/notes", `{"kinds":[1],"#p":["`+member+`"]}`)
1122+ messages := addCallback(t, tenant, member, "https://127.0.0.1:1/messages", `{"kinds":[4]}`)
1123+ intents := func() map[string]int {
1124+ rows, err := tenant.store.DB().QueryContext(ctx, "SELECT target,count(*) FROM work_intents WHERE kind=? GROUP BY target", callbackDelivery)
1125+ if err != nil {
1126+ t.Fatal(err)
1127+ }
1128+ defer rows.Close()
1129+ counts := map[string]int{}
1130+ for rows.Next() {
1131+ var target string
1132+ var count int
1133+ if err := rows.Scan(&target, &count); err != nil {
1134+ t.Fatal(err)
1135+ }
1136+ counts[target] = count
1137+ }
1138+ return counts
1139+ }
1140+ // Kind and tag both have to match.
1141+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 1, now, [][]string{{"p", moderator}}, "not for the member")); err != nil {
1142+ t.Fatal(err)
1143+ }
1144+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 7, now, [][]string{{"p", member}}, "+")); err != nil {
1145+ t.Fatal(err)
1146+ }
1147+ if counts := intents(); len(counts) != 0 {
1148+ t.Fatalf("intents after unmatched events = %v", counts)
1149+ }
1150+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 1, now+1, [][]string{{"p", member}}, "for the member")); err != nil {
1151+ t.Fatal(err)
1152+ }
1153+ if counts := intents(); counts[notes["id"].(string)] != 1 {
1154+ t.Fatalf("intents after matched note = %v", counts)
1155+ }
1156+ // A private message the key may not read is matched by the filter but
1157+ // stopped by the gate; one addressed to the key goes through.
1158+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 4, now+2, [][]string{{"p", moderator}}, "secret")); err != nil {
1159+ t.Fatal(err)
1160+ }
1161+ if counts := intents(); counts[messages["id"].(string)] != 0 {
1162+ t.Fatalf("private message leaked to the callback: %v", counts)
1163+ }
1164+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 4, now+3, [][]string{{"p", member}}, "for you")); err != nil {
1165+ t.Fatal(err)
1166+ }
1167+ if counts := intents(); counts[messages["id"].(string)] != 1 {
1168+ t.Fatalf("addressed message not queued: %v", counts)
1169+ }
1170+ // A paused callback leaves the index at once.
1171+ if _, err := tenant.Execute(ctx, member, "pausecallback", []json.RawMessage{json.RawMessage(strconv.Quote(notes["id"].(string)))}); err != nil {
1172+ t.Fatal(err)
1173+ }
1174+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 1, now+4, [][]string{{"p", member}}, "while paused")); err != nil {
1175+ t.Fatal(err)
1176+ }
1177+ if counts := intents(); counts[notes["id"].(string)] != 1 {
1178+ t.Fatalf("paused callback still queued: %v", counts)
1179+ }
1180+ if got := tenant.Policy().Callbacks; got != 4 {
1181+ t.Fatalf("default callbacks allowance = %d", got)
1182+ }
1183+}
1184+
1185+type callbackReceiver struct {
1186+ mu sync.Mutex
1187+ status int
1188+ headers []http.Header
1189+ bodies [][]byte
1190+}
1191+
1192+func (r *callbackReceiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
1193+ body, _ := io.ReadAll(req.Body)
1194+ r.mu.Lock()
1195+ r.headers = append(r.headers, req.Header.Clone())
1196+ r.bodies = append(r.bodies, body)
1197+ status := r.status
1198+ r.mu.Unlock()
1199+ w.WriteHeader(status)
1200+}
1201+
1202+func (r *callbackReceiver) count() int {
1203+ r.mu.Lock()
1204+ defer r.mu.Unlock()
1205+ return len(r.bodies)
1206+}
1207+
1208+// pendingCallbackIntents lists queued deliveries of one event to one
1209+// callback, first attempt first.
1210+func pendingCallbackIntents(t *testing.T, tenant *Tenant, id, eventID string) []work.Intent {
1211+ t.Helper()
1212+ rows, err := tenant.store.DB().QueryContext(context.Background(), "SELECT id,event_id,payload,next_at FROM work_intents WHERE kind=? AND target=? AND state='pending' AND (event_id=? OR event_id LIKE ?) ORDER BY next_at,created_at,id", callbackDelivery, id, eventID, eventID+"#%")
1213+ if err != nil {
1214+ t.Fatal(err)
1215+ }
1216+ defer rows.Close()
1217+ var out []work.Intent
1218+ for rows.Next() {
1219+ var item work.Intent
1220+ var next int64
1221+ if err := rows.Scan(&item.ID, &item.EventID, &item.Payload, &next); err != nil {
1222+ t.Fatal(err)
1223+ }
1224+ item.Kind, item.Target, item.NextAt = callbackDelivery, id, time.Unix(next, 0)
1225+ out = append(out, item)
1226+ }
1227+ return out
1228+}
1229+
1230+func TestCallbackDeliverySignsBodyRetriesAndPauses(t *testing.T) {
1231+ _, tenant := loopbackTenant(t)
1232+ ctx := context.Background()
1233+ member, _ := event.PublicKey(testMemberSecret)
1234+ setRole(t, tenant, member, "member")
1235+ receiver := &callbackReceiver{status: http.StatusAccepted}
1236+ service := httptest.NewTLSServer(receiver)
1237+ defer service.Close()
1238+ tenant.callbackClient = service.Client()
1239+ registered := addCallback(t, tenant, member, service.URL+"/wake?token=t0k3n", `{"kinds":[1621]}`)
1240+ id, secret := registered["id"].(string), registered["secret"].(string)
1241+ now := time.Now().Unix()
1242+ issue := signedEvent(t, testOwnerSecret, 1621, now, [][]string{{"a", "30617:" + tenant.Policy().Owner + ":tinyrelay"}, {"subject", "Broken build"}}, "The build fails.")
1243+ if err := publishAs(t, tenant, issue); err != nil {
1244+ t.Fatal(err)
1245+ }
1246+ queued := pendingCallbackIntents(t, tenant, id, issue.ID)
1247+ if len(queued) != 1 || queued[0].EventID != issue.ID {
1248+ t.Fatalf("queued = %+v", queued)
1249+ }
1250+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {
1251+ t.Fatal(err)
1252+ }
1253+ if receiver.count() != 1 {
1254+ t.Fatalf("deliveries = %d", receiver.count())
1255+ }
1256+ headers, body := receiver.headers[0], receiver.bodies[0]
1257+ mac := hmac.New(sha256.New, []byte(secret))
1258+ mac.Write(body)
1259+ if headers.Get("Content-Type") != "application/json" || headers.Get("X-Tiny-Callback") != id || headers.Get("X-Tiny-Relay") != "http://127.0.0.1:8080" || headers.Get("X-Tiny-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
1260+ t.Fatalf("headers = %v", headers)
1261+ }
1262+ delivered, err := event.Parse(body)
1263+ if err != nil || delivered.ID != issue.ID || delivered.Content != issue.Content {
1264+ t.Fatalf("body = %s (%v)", body, err)
1265+ }
1266+ var lastAt int64
1267+ var lastStatus string
1268+ var failures, paused int
1269+ state := func() {
1270+ t.Helper()
1271+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT last_delivery_at,last_status,failures,paused FROM callbacks WHERE id=?", id).Scan(&lastAt, &lastStatus, &failures, &paused); err != nil {
1272+ t.Fatal(err)
1273+ }
1274+ }
1275+ state()
1276+ if lastAt < now || lastStatus != "ok" || failures != 0 || paused != 0 {
1277+ t.Fatalf("after delivery: at=%d status=%q failures=%d paused=%d", lastAt, lastStatus, failures, paused)
1278+ }
1279+ listed, _ := tenant.Execute(ctx, member, "listcallbacks", nil)
1280+ if rows := listed.([]map[string]any); rows[0]["lastStatus"] != "ok" || rows[0]["lastDelivery"].(int64) < now {
1281+ t.Fatalf("listing after delivery = %v", rows)
1282+ }
1283+
1284+ // A failing receiver: attempt one queues attempt two a minute out,
1285+ // attempt two queues attempt three five minutes out, and attempt three
1286+ // is the last.
1287+ receiver.status = http.StatusInternalServerError
1288+ second := signedEvent(t, testOwnerSecret, 1621, now+1, [][]string{{"a", "30617:" + tenant.Policy().Owner + ":tinyrelay"}, {"subject", "Again"}}, "Again.")
1289+ if err := publishAs(t, tenant, second); err != nil {
1290+ t.Fatal(err)
1291+ }
1292+ queued = pendingCallbackIntents(t, tenant, id, second.ID)
1293+ if len(queued) != 1 {
1294+ t.Fatalf("queued = %+v", queued)
1295+ }
1296+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil {
1297+ t.Fatalf("attempt 1 should not fail the intent: %v", err)
1298+ }
1299+ state()
1300+ if failures != 1 || lastStatus != "HTTP 500" || paused != 0 {
1301+ t.Fatalf("after attempt 1: status=%q failures=%d paused=%d", lastStatus, failures, paused)
1302+ }
1303+ retries := pendingCallbackIntents(t, tenant, id, second.ID)
1304+ if len(retries) != 2 || retries[1].EventID != second.ID+"#2" || retries[1].NextAt.Unix() < now+55 || retries[1].NextAt.Unix() > now+70 {
1305+ t.Fatalf("retry after attempt 1 = %+v", retries)
1306+ }
1307+ var payload callbackPayload
1308+ if err := json.Unmarshal([]byte(retries[1].Payload), &payload); err != nil || payload.Attempt != 2 || payload.Event.ID != second.ID {
1309+ t.Fatalf("retry payload = %s", retries[1].Payload)
1310+ }
1311+ if err := tenant.handleCallbackDelivery(ctx, retries[1]); err != nil {
1312+ t.Fatal(err)
1313+ }
1314+ retries = pendingCallbackIntents(t, tenant, id, second.ID)
1315+ if len(retries) != 3 || retries[2].EventID != second.ID+"#3" || retries[2].NextAt.Unix() < now+295 || retries[2].NextAt.Unix() > now+310 {
1316+ t.Fatalf("retry after attempt 2 = %+v", retries)
1317+ }
1318+ if err := tenant.handleCallbackDelivery(ctx, retries[2]); err != nil {
1319+ t.Fatal(err)
1320+ }
1321+ if again := pendingCallbackIntents(t, tenant, id, second.ID); len(again) != 3 {
1322+ t.Fatalf("attempt 3 queued another attempt: %+v", again)
1323+ }
1324+ state()
1325+ if failures != 3 || receiver.count() != 4 {
1326+ t.Fatalf("after three attempts: failures=%d deliveries=%d", failures, receiver.count())
1327+ }
1328+ // Twenty failures in a row pause the callback with the reason, after
1329+ // which nothing more is queued or sent until it is resumed.
1330+ for failures < callbackPauseFailures {
1331+ if err := tenant.handleCallbackDelivery(ctx, retries[2]); err != nil {
1332+ t.Fatal(err)
1333+ }
1334+ state()
1335+ }
1336+ if paused != 1 || !strings.HasPrefix(lastStatus, "paused after 20 failures: HTTP 500") {
1337+ t.Fatalf("after 20 failures: status=%q paused=%d", lastStatus, paused)
1338+ }
1339+ sent := receiver.count()
1340+ third := signedEvent(t, testOwnerSecret, 1621, now+2, [][]string{{"a", "30617:" + tenant.Policy().Owner + ":tinyrelay"}, {"subject", "Third"}}, "Third.")
1341+ if err := publishAs(t, tenant, third); err != nil {
1342+ t.Fatal(err)
1343+ }
1344+ if queued := pendingCallbackIntents(t, tenant, id, third.ID); len(queued) != 0 {
1345+ t.Fatalf("paused callback queued a delivery: %+v", queued)
1346+ }
1347+ if err := tenant.handleCallbackDelivery(ctx, retries[2]); err != nil || receiver.count() != sent {
1348+ t.Fatalf("paused callback delivered: %v %d", err, receiver.count())
1349+ }
1350+ receiver.status = http.StatusOK
1351+ if _, err := tenant.Execute(ctx, member, "resumecallback", []json.RawMessage{json.RawMessage(strconv.Quote(id))}); err != nil {
1352+ t.Fatal(err)
1353+ }
1354+ state()
1355+ if paused != 0 || failures != 0 {
1356+ t.Fatalf("after resume: failures=%d paused=%d", failures, paused)
1357+ }
1358+ fourth := signedEvent(t, testOwnerSecret, 1621, now+3, [][]string{{"a", "30617:" + tenant.Policy().Owner + ":tinyrelay"}, {"subject", "Fourth"}}, "Fourth.")
1359+ if err := publishAs(t, tenant, fourth); err != nil {
1360+ t.Fatal(err)
1361+ }
1362+ queued = pendingCallbackIntents(t, tenant, id, fourth.ID)
1363+ if len(queued) != 1 {
1364+ t.Fatalf("queued after resume = %+v", queued)
1365+ }
1366+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil || receiver.count() != sent+1 {
1367+ t.Fatalf("delivery after resume: %v %d", err, receiver.count())
1368+ }
1369+ // A removed callback consumes its intents without a POST.
1370+ if _, err := tenant.Execute(ctx, member, "removecallback", []json.RawMessage{json.RawMessage(strconv.Quote(id))}); err != nil {
1371+ t.Fatal(err)
1372+ }
1373+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil || receiver.count() != sent+1 {
1374+ t.Fatalf("removed callback delivered: %v %d", err, receiver.count())
1375+ }
1376+}
1377+
1378+func TestCallbackDeliveryRechecksTheGateAndMembership(t *testing.T) {
1379+ _, tenant := loopbackTenant(t)
1380+ ctx := context.Background()
1381+ owner := tenant.Policy().Owner
1382+ member, _ := event.PublicKey(testMemberSecret)
1383+ setRole(t, tenant, member, "member")
1384+ receiver := &callbackReceiver{status: http.StatusOK}
1385+ service := httptest.NewTLSServer(receiver)
1386+ defer service.Close()
1387+ tenant.callbackClient = service.Client()
1388+ registered := addCallback(t, tenant, member, service.URL+"/wake", `{"kinds":[1]}`)
1389+ id := registered["id"].(string)
1390+ now := time.Now().Unix()
1391+ note := signedEvent(t, testOwnerSecret, 1, now, nil, "hidden later")
1392+ if err := publishAs(t, tenant, note); err != nil {
1393+ t.Fatal(err)
1394+ }
1395+ queued := pendingCallbackIntents(t, tenant, id, note.ID)
1396+ if len(queued) != 1 {
1397+ t.Fatalf("queued = %+v", queued)
1398+ }
1399+ // Hidden between arrival and delivery: nothing is sent.
1400+ if _, err := tenant.Execute(ctx, owner, "banevent", []json.RawMessage{json.RawMessage(strconv.Quote(note.ID))}); err != nil {
1401+ t.Fatal(err)
1402+ }
1403+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil || receiver.count() != 0 {
1404+ t.Fatalf("hidden event delivered: %v %d", err, receiver.count())
1405+ }
1406+ // A key that lost its membership has its callback paused.
1407+ if _, err := tenant.Execute(ctx, owner, "removemember", []json.RawMessage{json.RawMessage(strconv.Quote(member))}); err != nil {
1408+ t.Fatal(err)
1409+ }
1410+ if err := tenant.handleCallbackDelivery(ctx, queued[0]); err != nil || receiver.count() != 0 {
1411+ t.Fatalf("former member delivered: %v %d", err, receiver.count())
1412+ }
1413+ var paused int
1414+ var status string
1415+ if err := tenant.store.DB().QueryRowContext(ctx, "SELECT paused,last_status FROM callbacks WHERE id=?", id).Scan(&paused, &status); err != nil || paused != 1 || status != "paused: the key is no longer a member" {
1416+ t.Fatalf("former member callback: paused=%d status=%q %v", paused, status, err)
1417+ }
1418+}
1419+
1420+func TestMCPCallbackTools(t *testing.T) {
1421+ app, tenant := testTenant(t)
1422+ member, _ := event.PublicKey(testMemberSecret)
1423+ setRole(t, tenant, member, "member")
1424+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {
1425+ t.Helper()
1426+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
1427+ if w.Code != http.StatusOK {
1428+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
1429+ }
1430+ return mcpToolResult(t, response)
1431+ }
1432+ structured := func(result map[string]any) map[string]any {
1433+ value, _ := result["structuredContent"].(map[string]any)
1434+ inner, _ := value["result"].(map[string]any)
1435+ if inner == nil {
1436+ return value
1437+ }
1438+ return inner
1439+ }
1440+ text := func(result map[string]any) string {
1441+ content, _ := result["content"].([]any)
1442+ if len(content) == 0 {
1443+ return ""
1444+ }
1445+ return content[0].(map[string]any)["text"].(string)
1446+ }
1447+ _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")
1448+ names := map[string]bool{}
1449+ for _, tool := range response.Result.(map[string]any)["tools"].([]any) {
1450+ names[tool.(map[string]any)["name"].(string)] = true
1451+ }
1452+ for _, want := range []string{"list_callbacks", "add_callback", "remove_callback", "pause_callback", "resume_callback"} {
1453+ if !names[want] {
1454+ t.Fatalf("missing tool %s", want)
1455+ }
1456+ }
1457+ filter := map[string]any{"kinds": []any{1621.0, 1111.0}, "#p": []any{member}}
1458+ result, isError := call("add_callback", map[string]any{"url": "https://hooks.example/wake", "filter": filter}, strings.Repeat("7", 64))
1459+ if !isError || !strings.Contains(text(result), "restricted") {
1460+ t.Fatalf("add_callback as guest: %v %s", isError, text(result))
1461+ }
1462+ result, isError = call("add_callback", map[string]any{"url": "http://hooks.example/wake", "filter": filter}, testMemberSecret)
1463+ if !isError || !strings.Contains(text(result), "https") {
1464+ t.Fatalf("add_callback with http: %v %s", isError, text(result))
1465+ }
1466+ result, isError = call("add_callback", map[string]any{"url": "https://hooks.example/wake", "filter": filter, "secret": "a-shared-secret-of-my-own"}, testMemberSecret)
1467+ added := structured(result)
1468+ if isError || added["secret"] != "a-shared-secret-of-my-own" || added["host"] != "hooks.example" || added["owner"] != member {
1469+ t.Fatalf("add_callback: %s", text(result))
1470+ }
1471+ id, _ := added["id"].(string)
1472+ result, isError = call("list_callbacks", map[string]any{}, testMemberSecret)
1473+ listed, _ := result["structuredContent"].(map[string]any)["result"].([]any)
1474+ if isError || len(listed) != 1 || listed[0].(map[string]any)["id"] != id || listed[0].(map[string]any)["paused"] != false {
1475+ t.Fatalf("list_callbacks: %s", text(result))
1476+ }
1477+ if strings.Contains(text(result), "a-shared-secret-of-my-own") {
1478+ t.Fatal("list_callbacks repeats the secret")
1479+ }
1480+ result, isError = call("pause_callback", map[string]any{"id": id}, "")
1481+ if isError || structured(result)["paused"] != true {
1482+ t.Fatalf("pause_callback as owner: %s", text(result))
1483+ }
1484+ result, isError = call("resume_callback", map[string]any{"id": id}, testMemberSecret)
1485+ if isError || structured(result)["paused"] != false {
1486+ t.Fatalf("resume_callback: %s", text(result))
1487+ }
1488+ result, isError = call("remove_callback", map[string]any{"id": id}, strings.Repeat("7", 64))
1489+ if !isError {
1490+ t.Fatalf("remove_callback as guest: %s", text(result))
1491+ }
1492+ result, isError = call("remove_callback", map[string]any{"id": id}, testMemberSecret)
1493+ if isError || structured(result)["removed"] != true {
1494+ t.Fatalf("remove_callback: %s", text(result))
1495+ }
1496+ result, _ = call("list_callbacks", map[string]any{}, "")
1497+ if listed, _ := result["structuredContent"].(map[string]any)["result"].([]any); len(listed) != 0 {
1498+ t.Fatalf("list_callbacks after remove: %s", text(result))
1499+ }
1500+ r := httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil)
1501+ w := httptest.NewRecorder()
1502+ app.ServeHTTP(w, r)
1503+ if !strings.Contains(w.Body.String(), "list_callbacks") || !strings.Contains(w.Body.String(), "add_callback") {
1504+ t.Fatalf("llms.txt lacks the callback tools: %s", w.Body.String())
1505+ }
1506+}
1507diff --git a/internal/daemon/management.go b/internal/daemon/management.go
1508index c631370..ff4643c 100644
1509--- a/internal/daemon/management.go
1510+++ b/internal/daemon/management.go
1511@@ -44,6 +44,12 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa
1512 if publicManagementMethod(method) {
1513 return t.executePublicManagement(ctx, actor, method, params)
1514 }
1515+ if callbackMethod(method) {
1516+ // Members and agents manage their own callbacks, so the role rule
1517+ // lives with the methods.
1518+ result, err = t.callbackExecute(ctx, actor, method, params)
1519+ return result, true, err
1520+ }
1521 if !managementAdapterMethod(method) {
1522 return nil, false, nil
1523 }
1524@@ -132,7 +138,7 @@ func publicManagementMethod(method string) bool {
1525 // ManagementMethods is the source-compatible NIP-86 registry. The hosted
1526 // lease, trial, fuel, and entitlement controls are intentionally absent.
1527 func ManagementMethods() []string {
1528- return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"}
1529+ return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback"}
1530 }
1531
1532 func managementRoleAllows(method, role string) bool {
1533diff --git a/internal/daemon/queue_metrics.go b/internal/daemon/queue_metrics.go
1534index 3bf0da1..4b3f983 100644
1535--- a/internal/daemon/queue_metrics.go
1536+++ b/internal/daemon/queue_metrics.go
1537@@ -8,7 +8,7 @@ import (
1538 "github.com/prometheus/client_golang/prometheus"
1539 )
1540
1541-var queueMetricKinds = []string{"delivery", "push", "replication", "webhook", "job", "records-projection", "site-mirror", "git-metadata", "catalog-owner", "callback", "delivery-discovery", "notification-delivery", "notification-broadcast", "notification", "other"}
1542+var queueMetricKinds = []string{"delivery", "push", "replication", "webhook", "job", "records-projection", "site-mirror", "git-metadata", "catalog-owner", "callback", "delivery-discovery", "notification-delivery", "notification-broadcast", "notification", "callback-delivery", "other"}
1543
1544 type queueCollector struct {
1545 app *App
1546diff --git a/internal/daemon/services.go b/internal/daemon/services.go
1547index b3b4520..d175fbc 100644
1548--- a/internal/daemon/services.go
1549+++ b/internal/daemon/services.go
1550@@ -43,6 +43,9 @@ func (t *Tenant) initServices(ctx context.Context) error {
1551 if err := t.initPush(ctx); err != nil {
1552 return err
1553 }
1554+ if err := t.initCallbacks(ctx); err != nil {
1555+ return err
1556+ }
1557 t.blobs, err = blob.New(ctx, blob.Config{
1558 Root: t.meta.Paths.Root, PublicURL: t.publicURL, Store: t.store, Authorize: t.authorizeBlob,
1559 Limits: func() blob.Limits {
1560@@ -506,11 +509,12 @@ func (t *Tenant) workHandlers() map[string]work.Handler {
1561 handlers[kind] = handler
1562 }
1563 handlers[notificationPush] = t.handleNotificationPush
1564+ handlers[callbackDelivery] = t.handleCallbackDelivery
1565 return handlers
1566 }
1567
1568 func (t *Tenant) releaseGit(ctx context.Context, id string) error {
1569- _, err := t.router.CommitGenerated(ctx, func(ctx context.Context) (event.Event, error) {
1570+ released, err := t.router.CommitGenerated(ctx, func(ctx context.Context) (event.Event, error) {
1571 var promoted event.Event
1572 err := t.store.WithTx(ctx, func(tx *sql.Tx) error {
1573 result, err := tx.ExecContext(ctx, "DELETE FROM pending_events WHERE id=?", id)
1574@@ -537,6 +541,11 @@ func (t *Tenant) releaseGit(ctx context.Context, id string) error {
1575 })
1576 return promoted, err
1577 })
1578+ if err == nil && released.ID != "" {
1579+ // A repository state is hidden until its objects arrive, so agents
1580+ // that wait for pushes are woken here rather than at publish time.
1581+ t.notifyCallbacks(ctx, released)
1582+ }
1583 return err
1584 }
1585
1586@@ -674,6 +683,7 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
1587 _, err := t.store.Save(ctx, e, opts)
1588 if err == nil {
1589 t.notifyDevices(ctx, e)
1590+ t.notifyCallbacks(ctx, e)
1591 t.notifyWikiMerge(ctx, e)
1592 }
1593 return err
1594diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
1595index d3fcadc..c8e344b 100644
1596--- a/internal/daemon/tenant.go
1597+++ b/internal/daemon/tenant.go
1598@@ -41,37 +41,41 @@ type tenantConfig struct {
1599 }
1600
1601 type Tenant struct {
1602- app *App
1603- meta catalog.Tenant
1604- store *storage.Store
1605- community *community.Service
1606- gate *gates.Gate
1607- router *relay.Router
1608- auth *auth.Validator
1609- blobs *blob.Service
1610- sites *sites.Service
1611- records *records.Service
1612- config *configport.ConfigStore
1613- replication *replication.Service
1614- git *gitrelay.GitRelay
1615- ui *webui.App
1616- mcp *mcp.Server
1617- schedulerWake chan struct{}
1618- workCtx context.Context
1619- workCancel context.CancelFunc
1620- workWG sync.WaitGroup
1621- workErrMu sync.Mutex
1622- workErr error
1623- publicURL string
1624- mu sync.RWMutex
1625- policyWrite sync.Mutex
1626- policy policy.Policy
1627- maintenance maintenanceGate
1628- gitLegacy *replication.LegacyCache
1629- pushMu sync.Mutex
1630- pushVAPID *webpush.Keys
1631- pushClient *http.Client
1632- pushRecent map[string]time.Time
1633+ app *App
1634+ meta catalog.Tenant
1635+ store *storage.Store
1636+ community *community.Service
1637+ gate *gates.Gate
1638+ router *relay.Router
1639+ auth *auth.Validator
1640+ blobs *blob.Service
1641+ sites *sites.Service
1642+ records *records.Service
1643+ config *configport.ConfigStore
1644+ replication *replication.Service
1645+ git *gitrelay.GitRelay
1646+ ui *webui.App
1647+ mcp *mcp.Server
1648+ schedulerWake chan struct{}
1649+ workCtx context.Context
1650+ workCancel context.CancelFunc
1651+ workWG sync.WaitGroup
1652+ workErrMu sync.Mutex
1653+ workErr error
1654+ publicURL string
1655+ mu sync.RWMutex
1656+ policyWrite sync.Mutex
1657+ policy policy.Policy
1658+ maintenance maintenanceGate
1659+ gitLegacy *replication.LegacyCache
1660+ pushMu sync.Mutex
1661+ pushVAPID *webpush.Keys
1662+ pushClient *http.Client
1663+ pushRecent map[string]time.Time
1664+ callbackMu sync.Mutex
1665+ callbackIndex map[int][]callbackRecord
1666+ callbackLocks map[string]*sync.Mutex
1667+ callbackClient *http.Client
1668 }
1669
1670 func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {
1671@@ -381,6 +385,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
1672 return "", err
1673 }
1674 t.notifyDevices(ctx, e)
1675+ t.notifyCallbacks(ctx, e)
1676 t.notifyWikiMerge(ctx, e)
1677 // Stage Git metadata before acknowledging the event. This closes the
1678 // publish-ACK/receive-pack race: the signed pending refs and hook exist
1679diff --git a/internal/policy/policy.go b/internal/policy/policy.go
1680index 6b54f0c..40ac263 100644
1681--- a/internal/policy/policy.go
1682+++ b/internal/policy/policy.go
1683@@ -126,7 +126,10 @@ type Policy struct {
1684 DumpsKeep int `json:"dumpsKeep"`
1685 // Rooms caps the chat rooms a tenant may create beside its main group.
1686 // Zero switches room creation off.
1687- Rooms int `json:"rooms"`
1688+ Rooms int `json:"rooms"`
1689+ // Callbacks caps the event callbacks one key may register. The owner is
1690+ // not limited. Zero switches registration off for everyone else.
1691+ Callbacks int `json:"callbacks"`
1692 BlockedKinds []int `json:"-"`
1693 AllowedKinds []int `json:"-"`
1694 Retention []RetentionRule `json:"-"`
1695@@ -135,12 +138,16 @@ type Policy struct {
1696 // DefaultRooms is the room allowance applied when a policy does not name one.
1697 const DefaultRooms = 64
1698
1699-// UnmarshalJSON keeps the room allowance and the long task switch at their
1700-// defaults when a stored policy predates the fields, so existing tenants do
1701-// not lose room creation or job traffic.
1702+// DefaultCallbacks is the callback allowance per key when a policy does not
1703+// name one.
1704+const DefaultCallbacks = 4
1705+
1706+// UnmarshalJSON keeps the room and callback allowances and the long task
1707+// switch at their defaults when a stored policy predates the fields, so
1708+// existing tenants do not lose room creation, callbacks or job traffic.
1709 func (p *Policy) UnmarshalJSON(data []byte) error {
1710 type plain Policy
1711- decoded := plain{Rooms: DefaultRooms}
1712+ decoded := plain{Rooms: DefaultRooms, Callbacks: DefaultCallbacks}
1713 decoded.Features.Jobs = true
1714 if err := json.Unmarshal(data, &decoded); err != nil {
1715 return err
1716@@ -150,7 +157,7 @@ func (p *Policy) UnmarshalJSON(data []byte) error {
1717 }
1718
1719 func Defaults(owner string) Policy {
1720- return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7, Rooms: DefaultRooms,
1721+ return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7, Rooms: DefaultRooms, Callbacks: DefaultCallbacks,
1722 Features: Features{Search: "prose", Sync: true, Count: true, Discovery: true, Names: true, Files: true, Pages: true, Signer: true, Sites: Sites{Enabled: true, Mirror: true}, Jobs: true},
1723 Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}}
1724 }
1725@@ -212,7 +219,7 @@ func Validate(p Policy) error {
1726 }
1727 }
1728 }
1729- if p.ReportThreshold < 0 || p.MinPow < 0 || p.MaxFuture < 0 || p.MemberInvites.Depth < 0 || p.MemberInvites.Quota < 0 || p.Rooms < 0 {
1730+ if p.ReportThreshold < 0 || p.MinPow < 0 || p.MaxFuture < 0 || p.MemberInvites.Depth < 0 || p.MemberInvites.Quota < 0 || p.Rooms < 0 || p.Callbacks < 0 {
1731 return errors.New("policy: numeric values cannot be negative")
1732 }
1733 if p.Succession != nil && (p.Succession.Heir == "" || p.Succession.AfterDays <= 0) {
1734diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
1735index f9e6e41..9f31c1b 100644
1736--- a/internal/telemetry/telemetry.go
1737+++ b/internal/telemetry/telemetry.go
1738@@ -236,10 +236,10 @@ func latencyBuckets() []float64 {
1739 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
1740 }
1741
1742-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browsejobs": {}, "browsejob": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}}
1743-var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}
1744+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}}
1745+var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}, "paused": {}}
1746 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}
1747-var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}}
1748+var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}, "callback-delivery": {}}
1749
1750 func bounded(value string, allowed map[string]struct{}) string {
1751 if _, ok := allowed[value]; ok {

About

ownerc01503e8035f
visibilitypublic
headcef34c70332a

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs