tinyrelaycommit

Commit 0dbee9115994

browse files at this commit | history

1diff --git a/internal/community/community.go b/internal/community/community.go
2index cc8b46f..12b9771 100644
3--- a/internal/community/community.go
4+++ b/internal/community/community.go
5@@ -194,6 +194,24 @@ func (s *Service) Role(ctx context.Context, pubkey string) (string, error) {
6 return role, nil
7 }
8
9+// Moderators lists the keys that hold the moderator role.
10+func (s *Service) Moderators(ctx context.Context) ([]string, error) {
11+ rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey FROM community_members WHERE role='moderator' ORDER BY pubkey`)
12+ if err != nil {
13+ return nil, fmt.Errorf("moderators: %w", err)
14+ }
15+ defer rows.Close()
16+ var moderators []string
17+ for rows.Next() {
18+ var pubkey string
19+ if err := rows.Scan(&pubkey); err != nil {
20+ return nil, err
21+ }
22+ moderators = append(moderators, pubkey)
23+ }
24+ return moderators, rows.Err()
25+}
26+
27 func (s *Service) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
28 if method == "supportedmethods" {
29 return s.Methods(), nil
30diff --git a/internal/daemon/services.go b/internal/daemon/services.go
31index d175fbc..c4f6240 100644
32--- a/internal/daemon/services.go
33+++ b/internal/daemon/services.go
34@@ -685,6 +685,7 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
35 t.notifyDevices(ctx, e)
36 t.notifyCallbacks(ctx, e)
37 t.notifyWikiMerge(ctx, e)
38+ t.notifyWikiProposal(ctx, e)
39 }
40 return err
41 }
42diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
43index c8e344b..46615e9 100644
44--- a/internal/daemon/tenant.go
45+++ b/internal/daemon/tenant.go
46@@ -387,6 +387,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
47 t.notifyDevices(ctx, e)
48 t.notifyCallbacks(ctx, e)
49 t.notifyWikiMerge(ctx, e)
50+ t.notifyWikiProposal(ctx, e)
51 // Stage Git metadata before acknowledging the event. This closes the
52 // publish-ACK/receive-pack race: the signed pending refs and hook exist
53 // before a client can push objects for the state.
54diff --git a/internal/daemon/wiki.go b/internal/daemon/wiki.go
55index 8f8af8e..6e8a44b 100644
56--- a/internal/daemon/wiki.go
57+++ b/internal/daemon/wiki.go
58@@ -51,6 +51,14 @@ type wikiVersion struct {
59 Defer *wikiRef `json:"defer,omitempty"`
60 Content string `json:"content,omitempty"`
61 Links []string `json:"links,omitempty"`
62+ // Proposal marks a version from an agent whose grant says wiki:
63+ // propose. Approval is pending, approved or rejected; the other fields
64+ // name the deciding reaction, its time and its author once one exists.
65+ Proposal bool `json:"proposal,omitempty"`
66+ Approval string `json:"approval,omitempty"`
67+ ApprovalEvent string `json:"approval_event,omitempty"`
68+ ApprovalAt int64 `json:"approval_at,omitempty"`
69+ ApprovalBy string `json:"approval_by,omitempty"`
70 }
71
72 type wikiAnswer struct {
73diff --git a/internal/daemon/wiki_approvals.go b/internal/daemon/wiki_approvals.go
74new file mode 100644
75index 0000000..1aa3065
76--- /dev/null
77+++ b/internal/daemon/wiki_approvals.go
78@@ -0,0 +1,207 @@
79+package daemon
80+
81+// Wiki proposals. A version (kind 30818) whose author holds an agent grant
82+// with wiki set to propose is a proposal: it stays pending until the relay
83+// owner or a moderator reacts to the version's event id with "+", which
84+// approves it, or "-", which rejects it. A newer version from the agent is
85+// a new event id and starts pending again, so every edit is decided on its
86+// own. A pending or rejected proposal is visible only to the owner,
87+// moderators and its author; for everyone else it does not exist. The
88+// state is computed here and carried on every browse result that lists
89+// versions.
90+
91+import (
92+ "context"
93+ "net/url"
94+ "strings"
95+ "time"
96+
97+ "github.com/FelineStateMachine/tinyrelay/internal/event"
98+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
99+)
100+
101+const (
102+ wikiProposalPending = "pending"
103+ wikiProposalApproved = "approved"
104+ wikiProposalRejected = "rejected"
105+ wikiGrantPropose = "propose"
106+)
107+
108+// wikiRoles caches the role and grant lookups one browse call makes, since
109+// a page's versions and their reactions repeat the same few keys.
110+type wikiRoles struct {
111+ t *Tenant
112+ roles map[string]string
113+ proposers map[string]bool
114+}
115+
116+func (t *Tenant) wikiRoles() *wikiRoles {
117+ return &wikiRoles{t: t, roles: map[string]string{}, proposers: map[string]bool{}}
118+}
119+
120+func (r *wikiRoles) role(ctx context.Context, pubkey string) (string, error) {
121+ if role, ok := r.roles[pubkey]; ok {
122+ return role, nil
123+ }
124+ role, err := r.t.community.Role(ctx, pubkey)
125+ if err != nil {
126+ return "", err
127+ }
128+ r.roles[pubkey] = role
129+ return role, nil
130+}
131+
132+// decides reports whether a key may approve or reject proposals: the relay
133+// owner and moderators.
134+func (r *wikiRoles) decides(ctx context.Context, pubkey string) (bool, error) {
135+ if pubkey == "" {
136+ return false, nil
137+ }
138+ role, err := r.role(ctx, pubkey)
139+ return wikiDecidingRole(role), err
140+}
141+
142+func wikiDecidingRole(role string) bool {
143+ return role == "owner" || role == "moderator"
144+}
145+
146+// proposer reports whether versions from a key are proposals: the key holds
147+// an agent grant with wiki set to propose and no human standing on the
148+// relay. A paused, revoked or expired grant keeps counting, so an agent's
149+// pending versions do not surface when its grant ends.
150+func (r *wikiRoles) proposer(ctx context.Context, pubkey string) (bool, error) {
151+ if known, ok := r.proposers[pubkey]; ok {
152+ return known, nil
153+ }
154+ grant, ok, err := r.t.community.AgentGrant(ctx, pubkey)
155+ if err != nil {
156+ return false, err
157+ }
158+ proposer := ok && grant.Scope.Wiki == wikiGrantPropose
159+ if proposer {
160+ role, err := r.role(ctx, pubkey)
161+ if err != nil {
162+ return false, err
163+ }
164+ proposer = role == "" || role == "agent"
165+ }
166+ r.proposers[pubkey] = proposer
167+ return proposer, nil
168+}
169+
170+// wikiProposals marks the proposals among versions and resolves each one's
171+// state from the owner's and moderators' reactions to its event id. The
172+// newest deciding reaction counts.
173+func (t *Tenant) wikiProposals(ctx context.Context, roles *wikiRoles, versions []wikiVersion) error {
174+ var ids []string
175+ for i := range versions {
176+ proposer, err := roles.proposer(ctx, versions[i].Author)
177+ if err != nil {
178+ return err
179+ }
180+ if !proposer {
181+ continue
182+ }
183+ versions[i].Proposal = true
184+ versions[i].Approval = wikiProposalPending
185+ ids = append(ids, versions[i].ID)
186+ }
187+ if len(ids) == 0 {
188+ return nil
189+ }
190+ rows, err := t.store.Query(ctx, event.Filter{Kinds: []int{kindReaction}, Tags: map[string][]string{"e": ids}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: wikiQueryLimit})
191+ if err != nil {
192+ return err
193+ }
194+ reactions := rows.Events
195+ sortEventsNewestFirst(reactions)
196+ for _, reaction := range reactions {
197+ state := wikiProposalState(reaction.Content)
198+ if state == "" {
199+ continue
200+ }
201+ decides, err := roles.decides(ctx, reaction.PubKey)
202+ if err != nil {
203+ return err
204+ }
205+ if !decides {
206+ continue
207+ }
208+ for _, id := range event.TagValues(reaction, "e") {
209+ for i := range versions {
210+ v := &versions[i]
211+ if v.ID != id || !v.Proposal || v.ApprovalEvent != "" {
212+ continue
213+ }
214+ v.Approval, v.ApprovalEvent, v.ApprovalAt, v.ApprovalBy = state, reaction.ID, reaction.CreatedAt, reaction.PubKey
215+ }
216+ }
217+ }
218+ return nil
219+}
220+
221+func wikiProposalState(content string) string {
222+ switch wikiReactionStatus(content) {
223+ case wikiMergeAccepted:
224+ return wikiProposalApproved
225+ case wikiMergeRejected:
226+ return wikiProposalRejected
227+ }
228+ return ""
229+}
230+
231+// wikiVisible keeps the versions the actor may see: every version that is
232+// not a proposal or is approved, plus pending and rejected proposals for
233+// the owner, moderators and the proposal's author.
234+func (t *Tenant) wikiVisible(ctx context.Context, roles *wikiRoles, actor string, versions []wikiVersion) ([]wikiVersion, error) {
235+ decides, err := roles.decides(ctx, actor)
236+ if err != nil {
237+ return nil, err
238+ }
239+ visible := make([]wikiVersion, 0, len(versions))
240+ for _, v := range versions {
241+ if !v.Proposal || v.Approval == wikiProposalApproved || decides || (actor != "" && v.Author == actor) {
242+ visible = append(visible, v)
243+ }
244+ }
245+ return visible, nil
246+}
247+
248+// notifyWikiProposal wakes the owner's and moderators' devices in the
249+// approvals category when a proposal arrives. A decision wakes nobody.
250+func (t *Tenant) notifyWikiProposal(ctx context.Context, e event.Event) {
251+ if e.Kind != kindWikiArticle || !t.pushDevicesExist(ctx) {
252+ return
253+ }
254+ roles := t.wikiRoles()
255+ proposer, err := roles.proposer(ctx, e.PubKey)
256+ if err != nil || !proposer {
257+ return
258+ }
259+ v := wikiVersionFrom(e, false)
260+ if v.D == "" {
261+ return
262+ }
263+ name := shortKey(e.PubKey)
264+ if grant, ok, err := t.community.AgentGrant(ctx, e.PubKey); err == nil && ok && strings.TrimSpace(grant.Name) != "" {
265+ name = strings.TrimSpace(grant.Name)
266+ }
267+ moderators, err := t.community.Moderators(ctx)
268+ if err != nil {
269+ return
270+ }
271+ notice := pushNotice{category: pushApprovals, body: name + " proposes wiki: " + excerpt(v.Title), url: strings.TrimRight(t.publicURL, "/") + "/wiki/" + url.PathEscape(v.D)}
272+ queued := 0
273+ for _, recipient := range uniqueStrings(append([]string{t.Policy().Owner}, moderators...)) {
274+ if recipient == e.PubKey || !t.pushCoalesced(recipient, pushApprovals) {
275+ continue
276+ }
277+ notice.recipient = recipient
278+ if err := t.enqueuePushNotice(ctx, notice); err != nil {
279+ t.app.telemetry.Logger().Debug("wiki proposal notification not queued", "error", err)
280+ continue
281+ }
282+ queued++
283+ }
284+ t.app.telemetry.Logger().Debug("wiki proposal notifications queued", "count", queued)
285+}
286diff --git a/internal/daemon/wiki_browse.go b/internal/daemon/wiki_browse.go
287index b85519d..b3b2ed1 100644
288--- a/internal/daemon/wiki_browse.go
289+++ b/internal/daemon/wiki_browse.go
290@@ -49,6 +49,8 @@ type wikiPage struct {
291 Merges []wikiMerge `json:"merges"`
292 RedirectsTo []wikiRedirect `json:"redirects_to"`
293 RedirectsFrom []wikiRedirect `json:"redirects_from"`
294+ // CanApprove reports whether the caller may accept or reject proposals.
295+ CanApprove bool `json:"can_approve"`
296 }
297
298 type wikiMergeDetail struct {
299@@ -110,7 +112,10 @@ func (t *Tenant) wikiQuery(ctx context.Context, session relay.Session, filters .
300 return rows, nil
301 }
302
303-func (t *Tenant) wikiVersions(ctx context.Context, session relay.Session, filter event.Filter) ([]wikiVersion, error) {
304+// wikiVersions lists the versions the actor may see, with each proposal's
305+// state filled in; pending and rejected proposals stay in the list only
306+// for the owner, moderators and their author.
307+func (t *Tenant) wikiVersions(ctx context.Context, roles *wikiRoles, actor string, session relay.Session, filter event.Filter) ([]wikiVersion, error) {
308 filter.Kinds = []int{kindWikiArticle}
309 rows, err := t.wikiQuery(ctx, session, filter)
310 if err != nil {
311@@ -122,7 +127,10 @@ func (t *Tenant) wikiVersions(ctx context.Context, session relay.Session, filter
312 versions = append(versions, wikiVersionFrom(row, false))
313 }
314 }
315- return versions, nil
316+ if err := t.wikiProposals(ctx, roles, versions); err != nil {
317+ return nil, err
318+ }
319+ return t.wikiVisible(ctx, roles, actor, versions)
320 }
321
322 // wikiMerges lists the merge requests aimed at any of the coordinates with
323@@ -162,7 +170,12 @@ func wikiMatches(v wikiVersion, query string) bool {
324
325 func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseRequest) (any, error) {
326 session := browseSession(t, actor)
327- versions, err := t.wikiVersions(ctx, session, event.Filter{})
328+ roles := t.wikiRoles()
329+ versions, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{})
330+ if err != nil {
331+ return nil, err
332+ }
333+ decides, err := roles.decides(ctx, actor)
334 if err != nil {
335 return nil, err
336 }
337@@ -203,7 +216,7 @@ func (t *Tenant) browseWiki(ctx context.Context, actor string, q wikiBrowseReque
338 }
339 items = append(items, wikiPageItem{D: d, Title: preferred.Title, Summary: preferred.Summary, Version: preferred, Versions: len(page), OpenMerges: open})
340 }
341- return map[string]any{"items": items, "next_cursor": next}, nil
342+ return map[string]any{"items": items, "next_cursor": next, "can_approve": decides}, nil
343 }
344
345 // wikiRankVersions fills in member likes when they decide the preferred
346@@ -239,7 +252,12 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
347 return nil, errors.New("invalid: wiki page name")
348 }
349 session := browseSession(t, actor)
350- versions, err := t.wikiVersions(ctx, session, event.Filter{Tags: map[string][]string{"d": {d}}})
351+ roles := t.wikiRoles()
352+ versions, err := t.wikiVersions(ctx, roles, actor, session, event.Filter{Tags: map[string][]string{"d": {d}}})
353+ if err != nil {
354+ return nil, err
355+ }
356+ decides, err := roles.decides(ctx, actor)
357 if err != nil {
358 return nil, err
359 }
360@@ -247,7 +265,7 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
361 if err != nil {
362 return nil, err
363 }
364- page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}}
365+ page := wikiPage{D: d, Title: d, Versions: versions, Merges: []wikiMerge{}, RedirectsTo: []wikiRedirect{}, RedirectsFrom: []wikiRedirect{}, CanApprove: decides}
366 for _, row := range redirects {
367 r := wikiRedirectFrom(row)
368 if r.D == d && r.TargetD != "" && r.TargetD != d {
369@@ -290,6 +308,7 @@ func (t *Tenant) browseWikiPage(ctx context.Context, actor string, q wikiBrowseR
370 if len(rows) == 1 {
371 full := wikiVersionFrom(rows[0], true)
372 full.Likes = preferred.Likes
373+ full.Proposal, full.Approval, full.ApprovalEvent, full.ApprovalAt, full.ApprovalBy = preferred.Proposal, preferred.Approval, preferred.ApprovalEvent, preferred.ApprovalAt, preferred.ApprovalBy
374 preferred = full
375 }
376 page.Title = preferred.Title
377@@ -342,5 +361,18 @@ func (t *Tenant) browseWikiMerge(ctx context.Context, actor string, q wikiBrowse
378 detail.Target = &v
379 }
380 }
381+ // The versions keep their proposal state; a merge request's reader
382+ // must see the proposed content to answer it, so nothing is hidden.
383+ roles := t.wikiRoles()
384+ for _, v := range []*wikiVersion{detail.Proposed, detail.Target} {
385+ if v == nil {
386+ continue
387+ }
388+ marked := []wikiVersion{*v}
389+ if err := t.wikiProposals(ctx, roles, marked); err != nil {
390+ return nil, err
391+ }
392+ *v = marked[0]
393+ }
394 return detail, nil
395 }

About

ownerc01503e8035f
visibilitypublic
head0dbee9115994

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs