- commit
- e69d1926bc0cc2376ed0f9c9455002ceae29f297
- parent
- 3e13e858ec6f4c01975a4ad56ad678a51e73f5fe
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 257 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 3 | + |
| M | internal/ | 8 | + |
| M | internal/ | 49 | +++++- |
| M | internal/ | 1 | + |
| M | internal/ | 17 | ++ |
@@ -0,0 +1,257 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strconv"9+ "strings"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/community"14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/relay"16+)17+18+const (19+ testOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"20+ testAgentSecret = "0000000000000000000000000000000000000000000000000000000000000002"21+ testMemberSecret = "0000000000000000000000000000000000000000000000000000000000000003"22+ testModSecret = "0000000000000000000000000000000000000000000000000000000000000004"23+)24+25+func signedEvent(t *testing.T, secret string, kind int, createdAt int64, tags [][]string, content string) event.Event {26+ t.Helper()27+ if tags == nil {28+ tags = [][]string{}29+ }30+ e := event.Event{Kind: kind, CreatedAt: createdAt, Tags: tags, Content: content}31+ if err := event.Sign(&e, secret); err != nil {32+ t.Fatal(err)33+ }34+ return e35+}36+37+func agentGrantEvent(t *testing.T, agent string, createdAt, expires int64, extra ...[]string) event.Event {38+ t.Helper()39+ tags := [][]string{{"d", agent}, {"p", agent}, {"name", "helper"}, {"expiration", strconv.FormatInt(expires, 10)}}40+ return signedEvent(t, testOwnerSecret, event.KIND_AGENT_GRANT, createdAt, append(tags, extra...), "")41+}42+43+func publishAs(t *testing.T, tenant *Tenant, e event.Event) error {44+ t.Helper()45+ _, err := tenant.Publish(context.Background(), e, relay.Session{PubKeys: []string{e.PubKey}})46+ return err47+}48+49+func TestAgentGrantPublishCreatesRoleRowAndRevokesOnDeletion(t *testing.T) {50+ app, tenant := testTenant(t)51+ ctx := context.Background()52+ agent, _ := event.PublicKey(testAgentSecret)53+ now := time.Now().Unix()54+ grant := agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})55+ if err := publishAs(t, tenant, grant); err != nil {56+ t.Fatalf("grant rejected: %v", err)57+ }58+ if role, _ := tenant.community.Role(ctx, agent); role != "agent" {59+ t.Fatalf("role = %q, want agent", role)60+ }61+ var owner, eventID, name string62+ var expires int6463+ if err := tenant.store.DB().QueryRow(`SELECT owner,event_id,name,expires_at FROM agent_grants WHERE agent=?`, agent).Scan(&owner, &eventID, &name, &expires); err != nil {64+ t.Fatal(err)65+ }66+ if owner != tenant.Policy().Owner || eventID != grant.ID || name != "helper" || expires != now+3600 {67+ t.Fatalf("grant row owner=%s event=%s name=%s expires=%d", owner, eventID, name, expires)68+ }69+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now, nil, "hello")); err != nil {70+ t.Fatalf("agent note rejected: %v", err)71+ }72+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 7, now, nil, "+")); err == nil || !strings.HasPrefix(err.Error(), "restricted: agent grant does not allow kind 7") {73+ t.Fatalf("agent reaction: %v", err)74+ }75+ records, err := tenant.records.PublishMembership(ctx, now)76+ if err != nil {77+ t.Fatal(err)78+ }79+ labelled := map[int]bool{}80+ for _, e := range records {81+ for _, tag := range e.Tags {82+ if len(tag) == 3 && tag[1] == agent && tag[2] == "agent" && (e.Kind == event.KIND_ROSTER || e.Kind == event.KIND_GROUP_MEMBERS) {83+ labelled[e.Kind] = true84+ }85+ if e.Kind == event.KIND_ROLE_DEF && tag[0] == "d" && tag[1] == "agent" {86+ labelled[e.Kind] = true87+ }88+ }89+ }90+ if !labelled[event.KIND_ROSTER] || !labelled[event.KIND_GROUP_MEMBERS] || !labelled[event.KIND_ROLE_DEF] {91+ t.Fatalf("agent role missing from records: %v", labelled)92+ }93+ found := false94+ for _, capability := range tenant.Capabilities(nil) {95+ if capability.ID == "agents" && capability.Status == "enabled" {96+ found = true97+ }98+ }99+ if !found {100+ t.Fatal("agents capability not advertised")101+ }102+ req := httptest.NewRequest(http.MethodGet, "http://relay.test/", nil)103+ req.Header.Set("Accept", "application/nostr+json")104+ res := httptest.NewRecorder()105+ app.ServeHTTP(res, req)106+ var info map[string]json.RawMessage107+ if err := json.Unmarshal(res.Body.Bytes(), &info); err != nil || res.Code != 200 {108+ t.Fatalf("info %d %s %v", res.Code, res.Body.String(), err)109+ }110+ if !strings.Contains(string(info["agents"]), `"id":"agents"`) {111+ t.Fatalf("NIP-11 agents entry = %s", info["agents"])112+ }113+ deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+1, [][]string{{"a", "30392:" + tenant.Policy().Owner + ":" + agent}}, "")114+ if err := publishAs(t, tenant, deletion); err != nil {115+ t.Fatalf("deletion rejected: %v", err)116+ }117+ if role, _ := tenant.community.Role(ctx, agent); role != "" {118+ t.Fatalf("role after deletion = %q", role)119+ }120+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+2, nil, "again")); err == nil || !strings.Contains(err.Error(), "revoked") {121+ t.Fatalf("revoked agent note: %v", err)122+ }123+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+3, now+3600, []string{"k", "1"})); err != nil {124+ t.Fatalf("new grant rejected: %v", err)125+ }126+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+4, nil, "back")); err != nil {127+ t.Fatalf("re-granted agent rejected: %v", err)128+ }129+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+5, now-1, []string{"k", "1"})); err != nil {130+ t.Fatalf("expired grant rejected: %v", err)131+ }132+ if role, _ := tenant.community.Role(ctx, agent); role != "" {133+ t.Fatalf("role after expired grant = %q", role)134+ }135+ var revoked int64136+ if err := tenant.store.DB().QueryRow(`SELECT revoked_at FROM agent_grants WHERE agent=?`, agent).Scan(&revoked); err != nil || revoked == 0 {137+ t.Fatalf("expired grant not revoked: %d %v", revoked, err)138+ }139+ if found := capabilityIDs(tenant)["agents"]; found {140+ t.Fatal("agents capability advertised without an active agent")141+ }142+}143+144+func capabilityIDs(tenant *Tenant) map[string]bool {145+ out := map[string]bool{}146+ for _, capability := range tenant.Capabilities(nil) {147+ out[capability.ID] = true148+ }149+ return out150+}151+152+func TestAgentManagementMethodsAndPermissions(t *testing.T) {153+ _, tenant := testTenant(t)154+ ctx := context.Background()155+ owner := tenant.Policy().Owner156+ agent, _ := event.PublicKey(testAgentSecret)157+ member, _ := event.PublicKey(testMemberSecret)158+ moderator, _ := event.PublicKey(testModSecret)159+ now := time.Now().Unix()160+ for _, target := range []struct {161+ pubkey, role string162+ }{{member, "member"}, {moderator, "moderator"}} {163+ raw, _ := json.Marshal(map[string]string{"role": target.role})164+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(target.pubkey)), raw}); err != nil {165+ t.Fatal(err)166+ }167+ }168+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})); err != nil {169+ t.Fatal(err)170+ }171+ methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)172+ if err != nil {173+ t.Fatal(err)174+ }175+ for _, name := range []string{"listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} {176+ if !containsString(methods.([]string), name) {177+ t.Fatalf("supportedmethods lacks %s", name)178+ }179+ }180+ param := []json.RawMessage{json.RawMessage(strconv.Quote(agent))}181+ for _, actor := range []string{member, agent, strings.Repeat("e", 64)} {182+ for _, method := range []string{"listagents", "pauseagent", "revokeagent", "pauseallagents"} {183+ if _, err := tenant.Execute(ctx, actor, method, param); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {184+ t.Fatalf("%s by non-admin: %v", method, err)185+ }186+ }187+ }188+ listed, err := tenant.Execute(ctx, moderator, "listagents", nil)189+ if err != nil {190+ t.Fatalf("moderator listagents: %v", err)191+ }192+ agents := listed.([]community.AgentSummary)193+ if len(agents) != 1 || agents[0].Agent != agent || agents[0].Name != "helper" || agents[0].Owner != owner || agents[0].Paused || agents[0].RevokedAt != 0 || agents[0].Scope.Kinds[0] != 1 {194+ t.Fatalf("listagents = %+v", agents)195+ }196+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now, nil, "hello")); err != nil {197+ t.Fatal(err)198+ }199+ listed, _ = tenant.Execute(ctx, owner, "listagents", nil)200+ if last := listed.([]community.AgentSummary)[0].LastEvent; last != now {201+ t.Fatalf("lastEvent = %d, want %d", last, now)202+ }203+ if _, err := tenant.Execute(ctx, moderator, "pauseagent", param); err != nil {204+ t.Fatal(err)205+ }206+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+1, nil, "paused")); err == nil || !strings.Contains(err.Error(), "paused") {207+ t.Fatalf("paused agent note: %v", err)208+ }209+ if _, err := tenant.Execute(ctx, owner, "resumeagent", param); err != nil {210+ t.Fatal(err)211+ }212+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+2, nil, "resumed")); err != nil {213+ t.Fatalf("resumed agent note: %v", err)214+ }215+ result, err := tenant.Execute(ctx, owner, "pauseallagents", nil)216+ if err != nil || result.(map[string]any)["changed"] != 1 {217+ t.Fatalf("pauseallagents = %v %v", result, err)218+ }219+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+3, nil, "all paused")); err == nil || !strings.Contains(err.Error(), "paused") {220+ t.Fatalf("note during pause: %v", err)221+ }222+ if result, err := tenant.Execute(ctx, owner, "resumeallagents", nil); err != nil || result.(map[string]any)["changed"] != 1 {223+ t.Fatalf("resumeallagents = %v %v", result, err)224+ }225+ if _, err := tenant.Execute(ctx, owner, "revokeagent", param); err != nil {226+ t.Fatal(err)227+ }228+ if role, _ := tenant.community.Role(ctx, agent); role != "" {229+ t.Fatalf("role after revoke = %q", role)230+ }231+ if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+4, nil, "revoked")); err == nil || !strings.Contains(err.Error(), "revoked") {232+ t.Fatalf("revoked agent note: %v", err)233+ }234+ var stored int235+ if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM events WHERE kind=? AND d=?`, event.KIND_AGENT_GRANT, agent).Scan(&stored); err != nil || stored != 1 {236+ t.Fatalf("grant event kept for audit: %d %v", stored, err)237+ }238+ if _, err := tenant.Execute(ctx, owner, "pauseagent", param); err == nil {239+ t.Fatal("paused a revoked agent")240+ }241+ if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{json.RawMessage(strconv.Quote(strings.Repeat("f", 64)))}); err == nil {242+ t.Fatal("revoked an unknown agent")243+ }244+ audit, err := tenant.Execute(ctx, owner, "listaudit", nil)245+ if err != nil {246+ t.Fatal(err)247+ }248+ seen := map[string]bool{}249+ for _, row := range audit.([]community.AuditRow) {250+ seen[row.Action] = true251+ }252+ for _, action := range []string{"grantagent", "pauseagent", "resumeagent", "pauseallagents", "resumeallagents", "revokeagent"} {253+ if !seen[action] {254+ t.Fatalf("audit lacks %s: %v", action, seen)255+ }256+ }257+}@@ -44,6 +44,9 @@ func (t *Tenant) Capabilities(peers *PeerMonitor) []Capability {
4444 if t.community != nil {4545 setCapability(capabilities, "NIP-43", Capability{ID: "NIP-43", Status: "enabled", Reason: "membership protocol"})4646 setCapability(capabilities, "NIP-56", Capability{ID: "NIP-56", Status: "enabled", Reason: "moderation reports"})47+ if active, err := t.community.ActiveAgentCount(context.Background(), time.Now().Unix()); err == nil && active > 0 {48+ capabilities = append(capabilities, Capability{ID: "agents", Status: "enabled", Reason: "agent identities under owner-signed grants"})49+ }4750 }4851 if t.blobs != nil && p.Features.Files {4952 setCapability(capabilities, "NIP-94", Capability{ID: "NIP-94", Status: "enabled", Reason: "file metadata descriptors"})@@ -322,10 +322,15 @@ func statusFor(err error) int {
322322 func (t *Tenant) information(w http.ResponseWriter, r *http.Request) {323323 p := t.Policy()324324 nips := []int{}325+ var agents *Capability325326 for _, capability := range t.Capabilities(nil) {326327 if capability.Status != "enabled" {327328 continue328329 }330+ if capability.ID == "agents" {331+ entry := capability332+ agents = &entry333+ }329334 if number, ok := capabilityNIPNumber(capability.ID); ok {330335 nips = append(nips, number)331336 }@@ -362,6 +367,9 @@ func (t *Tenant) information(w http.ResponseWriter, r *http.Request) {
362367 if p.Features.Grasp && t.git != nil {363368 info["supported_grasps"] = t.git.SupportedGRASPs()364369 }370+ if agents != nil {371+ info["agents"] = *agents372+ }365373 if p.Features.Sites.Enabled && !privatePolicy(p) {366374 base, _ := url.Parse(t.publicURL)367375 domain := t.siteDomain()@@ -19,6 +19,7 @@ import (
1919 "time"2020 2121 "github.com/FelineStateMachine/tinyrelay/internal/catalog"22+ "github.com/FelineStateMachine/tinyrelay/internal/community"2223 "github.com/FelineStateMachine/tinyrelay/internal/domains"2324 "github.com/FelineStateMachine/tinyrelay/internal/event"2425 "github.com/FelineStateMachine/tinyrelay/internal/policy"@@ -88,6 +89,8 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa
8889 return t.identity(), true, nil8990 case "transferowner":9091 return t.transferOwner(ctx, actor, params)92+ case "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents":93+ return t.agentExecute(ctx, actor, method, params)9194 case "forkrelay":9295 return t.forkRelay(ctx, actor, params)9396 case "deleterelay":@@ -98,7 +101,7 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa
98101 }99102 100103 func managementAdapterMethod(method string) bool {101- for _, name := range []string{"listconnectiontemplates", "listconnections", "setconnections", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listblobs", "deleteblob", "listsites", "deleteevent", "listrecentevents", "searchevents", "storagestats", "publishview", "gitstorage", "resetrules", "getidentity", "transferowner", "forkrelay", "deleterelay"} {104+ for _, name := range []string{"listconnectiontemplates", "listconnections", "setconnections", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listblobs", "deleteblob", "listsites", "deleteevent", "listrecentevents", "searchevents", "storagestats", "publishview", "gitstorage", "resetrules", "getidentity", "transferowner", "forkrelay", "deleterelay", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} {102105 if method == name {103106 return true104107 }@@ -125,7 +128,7 @@ func publicManagementMethod(method string) bool {
125128 // ManagementMethods is the source-compatible NIP-86 registry. The hosted126129 // lease, trial, fuel, and entitlement controls are intentionally absent.127130 func ManagementMethods() []string {128- 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"}131+ 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"}129132 }130133 131134 func managementRoleAllows(method, role string) bool {@@ -135,7 +138,7 @@ func managementRoleAllows(method, role string) bool {
135138 if role != "moderator" {136139 return false137140 }138- for _, name := range []string{"listconnectiontemplates", "listconnections", "listblobs", "listsites", "listrecentevents", "searchevents", "storagestats", "deleteevent"} {141+ for _, name := range []string{"listconnectiontemplates", "listconnections", "listblobs", "listsites", "listrecentevents", "searchevents", "storagestats", "deleteevent", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} {139142 if method == name {140143 return true141144 }@@ -276,6 +279,44 @@ func (t *Tenant) transferOwner(ctx context.Context, actor string, params []json.
276279 return map[string]any{"owner": next, "previousOwner": current}, true, nil277280 }278281 282+// agentExecute serves the agent kill switch and inventory. The community283+// service records each change in the audit log; this adapter only shapes284+// parameters and logs counts.285+func (t *Tenant) agentExecute(ctx context.Context, actor, method string, params []json.RawMessage) (any, bool, error) {286+ now := time.Now().Unix()287+ switch method {288+ case "listagents":289+ agents, err := t.community.Agents(ctx)290+ return agents, true, err291+ case "pauseallagents", "resumeallagents":292+ changed, err := t.community.SetAllAgentsPaused(ctx, actor, method == "pauseallagents")293+ if err != nil {294+ return nil, true, err295+ }296+ t.app.telemetry.Logger().Info("agent grants changed", "tenant", t.meta.Name, "action", method, "changed", changed)297+ return map[string]any{"changed": changed, "paused": method == "pauseallagents"}, true, nil298+ }299+ agent := stringParam(params, 0)300+ if len(agent) != 64 {301+ return nil, true, errors.New("invalid: agent public key required")302+ }303+ if _, err := hex.DecodeString(agent); err != nil {304+ return nil, true, errors.New("invalid: agent public key required")305+ }306+ var grant community.AgentGrant307+ var err error308+ switch method {309+ case "pauseagent", "resumeagent":310+ grant, err = t.community.SetAgentPaused(ctx, actor, agent, method == "pauseagent")311+ case "revokeagent":312+ grant, err = t.community.RevokeAgent(ctx, actor, agent, now)313+ }314+ if err != nil {315+ return nil, true, err316+ }317+ return grant, true, nil318+}319+279320 func (t *Tenant) deleteRelay(ctx context.Context, params []json.RawMessage) (any, bool, error) {280321 name := stringParam(params, 0)281322 if name == "" || !strings.EqualFold(name, t.meta.Name) {@@ -403,7 +444,7 @@ func copyForkConfig(ctx context.Context, source, target *storage.Store) error {
403444 }404445 405446 func copyForkPeople(ctx context.Context, source, target *storage.Store, owner string) error {406- rows, err := source.DB().QueryContext(ctx, `SELECT pubkey,name,note,role,invited_by,via,created_at,keep_days FROM community_members WHERE role<>'owner'`)447+ rows, err := source.DB().QueryContext(ctx, `SELECT pubkey,name,note,role,invited_by,via,created_at,keep_days FROM community_members WHERE role<>'owner' AND role<>'agent'`)407448 if err != nil {408449 return fmt.Errorf("fork members: %w", err)409450 }@@ -652,6 +652,7 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
652652 if e.Kind == 30023 {653653 opts.Intents = append(opts.Intents, storage.Intent{Kind: "view-publish", EventID: e.ID, Target: "articles", Payload: "{}"})654654 }655+ opts.BeforeCommit = t.agentBeforeCommit(e, opts.Now, opts.BeforeCommit)655656 _, err := t.store.Save(ctx, e, opts)656657 if err == nil {657658 t.notifyDevices(ctx, e)@@ -275,6 +275,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
275275 })276276 }277277 }278+ opts.BeforeCommit = t.agentBeforeCommit(e, now, opts.BeforeCommit)278279 opts.Intents = append(opts.Intents, t.replication.Prepare(e, replication.OriginClient)...)279280 if callbackIntents, callbackErr := t.PrepareReplicationCallbacks(ctx, e); callbackErr != nil {280281 return "", callbackErr@@ -392,6 +393,22 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
392393 return "", nil393394 }394395 396+// agentBeforeCommit mirrors agent grants and grant deletions into the agent397+// table and roster in the same transaction that stores the event.398+func (t *Tenant) agentBeforeCommit(e event.Event, now int64, before func(context.Context, *sql.Tx) error) func(context.Context, *sql.Tx) error {399+ if e.Kind != event.KIND_AGENT_GRANT && e.Kind != event.KIND_DELETION {400+ return before401+ }402+ return func(txCtx context.Context, tx *sql.Tx) error {403+ if before != nil {404+ if err := before(txCtx, tx); err != nil {405+ return err406+ }407+ }408+ return t.community.ApplyAgentEventTx(txCtx, tx, e, now)409+ }410+}411+395412 func (t *Tenant) vanish(ctx context.Context, e event.Event) error {396413 for _, tag := range e.Tags {397414 if len(tag) > 1 && tag[0] == "relay" && (tag[1] == "ALL_RELAYS" || strings.TrimSuffix(tag[1], "/") == strings.TrimSuffix(t.RelayURL(), "/")) {