tinyrelaycommit

Commit 2151df9512ee

browse files at this commit | history

1diff --git a/internal/community/actions.go b/internal/community/actions.go
2index f7b7a8a..bfac2c2 100644
3--- a/internal/community/actions.go
4+++ b/internal/community/actions.go
5@@ -13,6 +13,12 @@ import (
6 )
7
8 func (s *Service) setMember(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
9+ return s.setMemberVia(ctx, actor, p, "management")
10+}
11+
12+// setMemberVia adds or updates a member and records how the key came in:
13+// management for the setmember call, request for an approved access request.
14+func (s *Service) setMemberVia(ctx context.Context, actor string, p []json.RawMessage, via string) (any, error) {
15 var pk, name, role string
16 if err := decode(p, 0, &pk); err != nil {
17 return nil, err
18@@ -65,7 +71,7 @@ func (s *Service) setMember(ctx context.Context, actor string, p []json.RawMessa
19 if _, err := tx.ExecContext(ctx, `DELETE FROM community_bans WHERE pubkey=?`, pk); err != nil {
20 return err
21 }
22- _, err := tx.ExecContext(ctx, `INSERT INTO community_members(pubkey,name,role,created_at,via) VALUES(?,?,?,?,?) ON CONFLICT(pubkey) DO UPDATE SET name=excluded.name,role=excluded.role`, pk, name, role, now(), "management")
23+ _, err := tx.ExecContext(ctx, `INSERT INTO community_members(pubkey,name,role,created_at,via) VALUES(?,?,?,?,?) ON CONFLICT(pubkey) DO UPDATE SET name=excluded.name,role=excluded.role`, pk, name, role, now(), via)
24 if err != nil {
25 return fmt.Errorf("set member: %w", err)
26 }
27diff --git a/internal/community/community.go b/internal/community/community.go
28index 12b9771..01cfa75 100644
29--- a/internal/community/community.go
30+++ b/internal/community/community.go
31@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS community_invites(code TEXT PRIMARY KEY,created_by TE
32 CREATE TABLE IF NOT EXISTS community_reports(id INTEGER PRIMARY KEY AUTOINCREMENT,reporter TEXT NOT NULL,target_event TEXT NOT NULL,type TEXT NOT NULL,content TEXT NOT NULL,at INTEGER NOT NULL,status TEXT NOT NULL DEFAULT 'open',resolved_by TEXT NOT NULL DEFAULT '',resolved_at INTEGER NOT NULL DEFAULT 0,action TEXT NOT NULL DEFAULT '',UNIQUE(reporter,target_event));
33 CREATE TABLE IF NOT EXISTS community_audit(id INTEGER PRIMARY KEY AUTOINCREMENT,at INTEGER NOT NULL,actor TEXT NOT NULL,action TEXT NOT NULL,target TEXT NOT NULL DEFAULT '',detail TEXT NOT NULL DEFAULT '');
34 CREATE TABLE IF NOT EXISTS community_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
35+CREATE TABLE IF NOT EXISTS join_requests(pubkey TEXT PRIMARY KEY,reason TEXT NOT NULL DEFAULT '',requested_at INTEGER NOT NULL,status TEXT NOT NULL DEFAULT 'pending',decided_by TEXT NOT NULL DEFAULT '',decided_at INTEGER NOT NULL DEFAULT 0);
36 CREATE TRIGGER IF NOT EXISTS community_member_projection_insert AFTER INSERT ON community_members
37 BEGIN
38 INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)
39@@ -65,6 +66,17 @@ type Invite struct {
40 Uses int `json:"uses"`
41 Note string `json:"note"`
42 }
43+
44+// JoinRequest is an access request from a key that asked to join without
45+// an invite. It waits for the owner or a moderator to approve or deny it.
46+type JoinRequest struct {
47+ PubKey string `json:"pubkey"`
48+ Reason string `json:"reason"`
49+ RequestedAt int64 `json:"requested_at"`
50+ Status string `json:"status"`
51+ DecidedBy string `json:"decided_by"`
52+ DecidedAt int64 `json:"decided_at"`
53+}
54 type Report struct {
55 ID int64 `json:"id"`
56 Reporter string `json:"reporter"`
57@@ -232,6 +244,12 @@ func (s *Service) Execute(ctx context.Context, actor, method string, params []js
58 return s.removeSubtree(ctx, actor, params)
59 case "listmembers", "listpeople":
60 return s.members(ctx)
61+ case "listjoinrequests":
62+ return s.listJoinRequests(ctx)
63+ case "approvejoin":
64+ return s.approveJoin(ctx, actor, params)
65+ case "denyjoin":
66+ return s.denyJoin(ctx, actor, params)
67 case "listallowedpubkeys":
68 return s.allowedMembers(ctx)
69 case "createinvite":
70@@ -292,7 +310,7 @@ func (s *Service) Execute(ctx context.Context, actor, method string, params []js
71 }
72
73 func allowed(method, role string) bool {
74- if method == "listmembers" || method == "listpeople" || method == "listallowedpubkeys" || method == "listbannedpubkeys" || method == "listbannedevents" || method == "listblockedips" || method == "listreports" || method == "listeventsneedingmoderation" || method == "listallowedkinds" || method == "listblockedkinds" || method == "listretention" {
75+ if method == "listmembers" || method == "listpeople" || method == "listjoinrequests" || method == "listallowedpubkeys" || method == "listbannedpubkeys" || method == "listbannedevents" || method == "listblockedips" || method == "listreports" || method == "listeventsneedingmoderation" || method == "listallowedkinds" || method == "listblockedkinds" || method == "listretention" {
76 return role == "owner" || role == "moderator"
77 }
78 if role == "owner" {
79@@ -309,7 +327,7 @@ func allowed(method, role string) bool {
80 if role != "moderator" {
81 return false
82 }
83- for _, name := range []string{"setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "createinvite", "createclaim", "listinvites", "listclaims", "revokeinvite", "deleteclaim", "banpubkey", "banevent", "allowevent", "blockip", "unblockip", "listaudit", "resolvereport", "allowkind", "disallowkind", "unrulekind", "setblockedwords"} {
84+ for _, name := range []string{"setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "approvejoin", "denyjoin", "createinvite", "createclaim", "listinvites", "listclaims", "revokeinvite", "deleteclaim", "banpubkey", "banevent", "allowevent", "blockip", "unblockip", "listaudit", "resolvereport", "allowkind", "disallowkind", "unrulekind", "setblockedwords"} {
85 if method == name {
86 return true
87 }
88@@ -318,7 +336,7 @@ func allowed(method, role string) bool {
89 }
90
91 func (s *Service) Methods() []string {
92- return []string{"supportedmethods", "listaudit", "listmembers", "listpeople", "listallowedpubkeys", "listbannedpubkeys", "setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "banpubkey", "banevent", "allowevent", "listbannedevents", "blockip", "unblockip", "listblockedips", "listreports", "listeventsneedingmoderation", "resolvereport", "allowkind", "disallowkind", "unrulekind", "listallowedkinds", "listblockedkinds", "setretention", "listretention", "purgekind", "setblockedwords"}
93+ return []string{"supportedmethods", "listaudit", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "listallowedpubkeys", "listbannedpubkeys", "setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "banpubkey", "banevent", "allowevent", "listbannedevents", "blockip", "unblockip", "listblockedips", "listreports", "listeventsneedingmoderation", "resolvereport", "allowkind", "disallowkind", "unrulekind", "listallowedkinds", "listblockedkinds", "setretention", "listretention", "purgekind", "setblockedwords"}
94 }
95
96 func roleOrGuest(role string) string {
97diff --git a/internal/community/membership.go b/internal/community/membership.go
98index 38dfc98..8e0f675 100644
99--- a/internal/community/membership.go
100+++ b/internal/community/membership.go
101@@ -63,6 +63,12 @@ type MembershipResult struct {
102 OK bool
103 Message string
104 Stored bool
105+ // AccessRequest marks a join held for review; Message is its OK reason.
106+ AccessRequest bool
107+ // NewRequest reports that the request was not pending before, so the
108+ // reviewers should be woken. A refresh of a pending request leaves it
109+ // false.
110+ NewRequest bool
111 }
112
113 // HandleMembershipEventTx applies join/leave admission and invokes persist in
114@@ -103,6 +109,14 @@ func (s *Service) HandleMembershipEventTx(ctx context.Context, ev event.Event, p
115 out = MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}
116 return nil
117 }
118+ if IsAccessRequest(ev) {
119+ created, err := s.recordJoinRequestTx(ctx, tx, ev.PubKey, JoinReason(ev.Content))
120+ if err != nil {
121+ return err
122+ }
123+ out = MembershipResult{OK: true, Message: JoinRequestReceived, AccessRequest: true, NewRequest: created}
124+ return nil
125+ }
126 claim := tagValue(ev, "code")
127 if ev.Kind == event.KIND_NIP43_JOIN {
128 claim = tagValue(ev, "claim")
129@@ -284,6 +298,18 @@ func (s *Service) HandleMembershipEvent(ctx context.Context, ev event.Event) (Me
130 var result MembershipResult
131 switch ev.Kind {
132 case event.KIND_JOIN, event.KIND_NIP43_JOIN:
133+ if IsAccessRequest(ev) {
134+ if role, err := s.Role(ctx, ev.PubKey); err != nil {
135+ return MembershipResult{}, err
136+ } else if role != "" {
137+ return MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}, nil
138+ }
139+ created, err := s.RecordJoinRequest(ctx, ev.PubKey, JoinReason(ev.Content))
140+ if err != nil {
141+ return MembershipResult{}, err
142+ }
143+ return MembershipResult{OK: true, Message: JoinRequestReceived, AccessRequest: true, NewRequest: created}, nil
144+ }
145 claim := tagValue(ev, "code")
146 if ev.Kind == event.KIND_NIP43_JOIN {
147 claim = tagValue(ev, "claim")
148diff --git a/internal/community/requests.go b/internal/community/requests.go
149new file mode 100644
150index 0000000..fd39f56
151--- /dev/null
152+++ b/internal/community/requests.go
153@@ -0,0 +1,173 @@
154+package community
155+
156+import (
157+ "context"
158+ "database/sql"
159+ "encoding/json"
160+ "errors"
161+ "strings"
162+ "unicode/utf8"
163+
164+ "github.com/FelineStateMachine/tinyrelay/internal/event"
165+)
166+
167+// A NIP-43 join (kind 28934) without a claim tag is an access request: the
168+// relay holds it for the owner and moderators instead of refusing it. The
169+// request is a row, never a stored event, and one key holds one row that a
170+// repeat refreshes.
171+
172+const (
173+ // JoinReasonMax bounds the reason kept from the request's content.
174+ JoinReasonMax = 500
175+ // JoinRequestReceived is the OK reason answered to an access request.
176+ JoinRequestReceived = "info: access request received, the relay owner will review it"
177+)
178+
179+// IsAccessRequest reports whether a membership event asks for access rather
180+// than claiming an invite.
181+func IsAccessRequest(ev event.Event) bool {
182+ return ev.Kind == event.KIND_NIP43_JOIN && tagValue(ev, "claim") == ""
183+}
184+
185+// JoinReason is the request content trimmed of surrounding space and cut to
186+// JoinReasonMax characters on a character boundary.
187+func JoinReason(content string) string {
188+ reason := strings.TrimSpace(content)
189+ if utf8.RuneCountInString(reason) <= JoinReasonMax {
190+ return reason
191+ }
192+ count := 0
193+ for i := range reason {
194+ if count == JoinReasonMax {
195+ return strings.TrimSpace(reason[:i])
196+ }
197+ count++
198+ }
199+ return reason
200+}
201+
202+// recordJoinRequestTx stores or refreshes the key's access request and
203+// reports whether a new pending request was created. A pending request from
204+// the same key takes the new reason and time; a decided one becomes pending
205+// again. A banned key is refused.
206+func (s *Service) recordJoinRequestTx(ctx context.Context, tx *sql.Tx, pubkey, reason string) (bool, error) {
207+ var banned bool
208+ if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_bans WHERE pubkey=?)`, pubkey).Scan(&banned); err != nil {
209+ return false, err
210+ }
211+ if banned {
212+ return false, errors.New("restricted: this key is banned from the relay")
213+ }
214+ var status string
215+ err := tx.QueryRowContext(ctx, `SELECT status FROM join_requests WHERE pubkey=?`, pubkey).Scan(&status)
216+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
217+ return false, err
218+ }
219+ if _, err := tx.ExecContext(ctx, `INSERT INTO join_requests(pubkey,reason,requested_at,status,decided_by,decided_at) VALUES(?,?,?,'pending','',0) ON CONFLICT(pubkey) DO UPDATE SET reason=excluded.reason,requested_at=excluded.requested_at,status='pending',decided_by='',decided_at=0`, pubkey, reason, now()); err != nil {
220+ return false, err
221+ }
222+ return status != "pending", nil
223+}
224+
225+// RecordJoinRequest is the non-transactional form of recordJoinRequestTx.
226+func (s *Service) RecordJoinRequest(ctx context.Context, pubkey, reason string) (bool, error) {
227+ created := false
228+ err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
229+ var err error
230+ created, err = s.recordJoinRequestTx(ctx, tx, pubkey, reason)
231+ return err
232+ })
233+ return created, err
234+}
235+
236+// listJoinRequests lists pending requests newest first, then the decided
237+// ones newest decision first.
238+func (s *Service) listJoinRequests(ctx context.Context) (any, error) {
239+ rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,reason,requested_at,status,decided_by,decided_at FROM join_requests ORDER BY status<>'pending', CASE WHEN status='pending' THEN requested_at ELSE decided_at END DESC, pubkey LIMIT 200`)
240+ if err != nil {
241+ return nil, err
242+ }
243+ defer rows.Close()
244+ out := []JoinRequest{}
245+ for rows.Next() {
246+ var r JoinRequest
247+ if err := rows.Scan(&r.PubKey, &r.Reason, &r.RequestedAt, &r.Status, &r.DecidedBy, &r.DecidedAt); err != nil {
248+ return nil, err
249+ }
250+ out = append(out, r)
251+ }
252+ return out, rows.Err()
253+}
254+
255+// PendingJoinRequests counts the requests that wait for a decision.
256+func (s *Service) PendingJoinRequests(ctx context.Context) (int, error) {
257+ var count int
258+ err := s.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM join_requests WHERE status='pending'`).Scan(&count)
259+ return count, err
260+}
261+
262+func (s *Service) joinRequestTarget(ctx context.Context, p []json.RawMessage) (string, string, error) {
263+ var pk string
264+ if err := decode(p, 0, &pk); err != nil {
265+ return "", "", err
266+ }
267+ if !validPubKey(pk) {
268+ return "", "", errors.New("invalid: bad pubkey")
269+ }
270+ var status string
271+ err := s.store.DB().QueryRowContext(ctx, `SELECT status FROM join_requests WHERE pubkey=?`, pk).Scan(&status)
272+ if errors.Is(err, sql.ErrNoRows) {
273+ return "", "", errors.New("not found: no access request from that key")
274+ }
275+ return pk, status, err
276+}
277+
278+// approveJoin admits the key through the member path setmember uses, so the
279+// signed member list and add-user record follow, and marks the request
280+// approved. A key that already became a member only has its request marked.
281+func (s *Service) approveJoin(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
282+ pk, _, err := s.joinRequestTarget(ctx, p)
283+ if err != nil {
284+ return nil, err
285+ }
286+ role, err := s.Role(ctx, pk)
287+ if err != nil {
288+ return nil, err
289+ }
290+ if role == "" {
291+ if _, err := s.setMemberVia(ctx, actor, []json.RawMessage{mustJSON(pk), mustJSON(map[string]any{"role": "member"})}, "request"); err != nil {
292+ return nil, err
293+ }
294+ }
295+ return s.decideJoin(ctx, actor, pk, "approved")
296+}
297+
298+// denyJoin marks the request denied. The key stays outside the relay and
299+// may ask again.
300+func (s *Service) denyJoin(ctx context.Context, actor string, p []json.RawMessage) (any, error) {
301+ pk, status, err := s.joinRequestTarget(ctx, p)
302+ if err != nil {
303+ return nil, err
304+ }
305+ if status == "approved" {
306+ return nil, errors.New("invalid: the request was approved; remove the member instead")
307+ }
308+ return s.decideJoin(ctx, actor, pk, "denied")
309+}
310+
311+func (s *Service) decideJoin(ctx context.Context, actor, pk, status string) (any, error) {
312+ err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
313+ if _, err := tx.ExecContext(ctx, `UPDATE join_requests SET status=?,decided_by=?,decided_at=? WHERE pubkey=?`, status, actor, now(), pk); err != nil {
314+ return err
315+ }
316+ action := "denyjoin"
317+ if status == "approved" {
318+ action = "approvejoin"
319+ }
320+ return s.recordTx(ctx, tx, actor, action, pk, "")
321+ })
322+ if err != nil {
323+ return nil, err
324+ }
325+ return map[string]any{"pubkey": pk, "status": status}, nil
326+}
327diff --git a/internal/community/requests_test.go b/internal/community/requests_test.go
328new file mode 100644
329index 0000000..45ecefc
330--- /dev/null
331+++ b/internal/community/requests_test.go
332@@ -0,0 +1,261 @@
333+package community
334+
335+import (
336+ "context"
337+ "database/sql"
338+ "strings"
339+ "testing"
340+
341+ "github.com/FelineStateMachine/tinyrelay/internal/event"
342+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
343+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
344+)
345+
346+func requestService(t *testing.T) (*Service, *storage.Store, string) {
347+ t.Helper()
348+ ctx := context.Background()
349+ st, err := storage.Open(ctx, t.TempDir()+"/relay.db")
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ t.Cleanup(func() { st.Close() })
354+ owner := strings.Repeat("a", 64)
355+ svc, err := New(ctx, st, owner)
356+ if err != nil {
357+ t.Fatal(err)
358+ }
359+ p := policy.Defaults(owner)
360+ svc.ConfigurePolicy(func() policy.Policy { return p })
361+ return svc, st, owner
362+}
363+
364+func accessRequest(pubkey, content string) event.Event {
365+ return event.Event{ID: strings.Repeat("1", 64), PubKey: pubkey, Kind: event.KIND_NIP43_JOIN, Tags: [][]string{{"-"}}, Content: content}
366+}
367+
368+func joinRequestRow(t *testing.T, st *storage.Store, pubkey string) (JoinRequest, int) {
369+ t.Helper()
370+ var r JoinRequest
371+ var count int
372+ if err := st.DB().QueryRow(`SELECT count(*) FROM join_requests`).Scan(&count); err != nil {
373+ t.Fatal(err)
374+ }
375+ err := st.DB().QueryRow(`SELECT pubkey,reason,requested_at,status,decided_by,decided_at FROM join_requests WHERE pubkey=?`, pubkey).Scan(&r.PubKey, &r.Reason, &r.RequestedAt, &r.Status, &r.DecidedBy, &r.DecidedAt)
376+ if err != nil && err != sql.ErrNoRows {
377+ t.Fatal(err)
378+ }
379+ return r, count
380+}
381+
382+func TestAccessRequestIsRecordedRefreshedAndAnswered(t *testing.T) {
383+ ctx := context.Background()
384+ for _, path := range []string{"transactional", "direct"} {
385+ t.Run(path, func(t *testing.T) {
386+ svc, st, owner := requestService(t)
387+ stranger, member, banned := strings.Repeat("b", 64), strings.Repeat("c", 64), strings.Repeat("d", 64)
388+ persisted := false
389+ handle := func(ev event.Event) (MembershipResult, error) {
390+ if path == "direct" {
391+ return svc.HandleMembershipEvent(ctx, ev)
392+ }
393+ return svc.HandleMembershipEventTx(ctx, ev, func(*sql.Tx) error { persisted = true; return nil })
394+ }
395+ result, err := handle(accessRequest(stranger, " Hi, I am Bob from the meetup. "))
396+ if err != nil {
397+ t.Fatal(err)
398+ }
399+ if !result.OK || result.Stored || !result.AccessRequest || !result.NewRequest || result.Message != JoinRequestReceived {
400+ t.Fatalf("first request result %+v", result)
401+ }
402+ if result.Message != "info: access request received, the relay owner will review it" {
403+ t.Fatalf("OK message %q", result.Message)
404+ }
405+ row, count := joinRequestRow(t, st, stranger)
406+ if count != 1 || row.Reason != "Hi, I am Bob from the meetup." || row.Status != "pending" || row.RequestedAt == 0 || row.DecidedBy != "" || row.DecidedAt != 0 {
407+ t.Fatalf("row %+v count %d", row, count)
408+ }
409+ if persisted {
410+ t.Fatal("access request was stored as an event")
411+ }
412+ if role, _ := svc.Role(ctx, stranger); role != "" {
413+ t.Fatalf("request made a member: %q", role)
414+ }
415+ // The owner's own row queued one projection; the request adds none.
416+ var intents int
417+ if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intents); err != nil || intents != 1 {
418+ t.Fatalf("request queued a projection: %d %v", intents-1, err)
419+ }
420+
421+ // A repeat refreshes the reason and time in the same row and is
422+ // not a new request.
423+ if _, err := st.DB().Exec(`UPDATE join_requests SET requested_at=1 WHERE pubkey=?`, stranger); err != nil {
424+ t.Fatal(err)
425+ }
426+ long := strings.Repeat("x", 600)
427+ result, err = handle(accessRequest(stranger, long))
428+ if err != nil || !result.OK || result.NewRequest || !result.AccessRequest || result.Message != JoinRequestReceived {
429+ t.Fatalf("repeat result %+v %v", result, err)
430+ }
431+ row, count = joinRequestRow(t, st, stranger)
432+ if count != 1 || len(row.Reason) != JoinReasonMax || row.RequestedAt == 1 || row.Status != "pending" {
433+ t.Fatalf("refreshed row %+v count %d", row, count)
434+ }
435+
436+ // A member is told so, as before.
437+ if _, err := svc.Execute(ctx, owner, "setmember", raws(member, map[string]any{"role": "member"})); err != nil {
438+ t.Fatal(err)
439+ }
440+ result, err = handle(accessRequest(member, "again"))
441+ if err != nil || !result.OK || result.Message != "duplicate: already a member" || result.AccessRequest {
442+ t.Fatalf("member result %+v %v", result, err)
443+ }
444+ if _, count := joinRequestRow(t, st, member); count != 1 {
445+ t.Fatalf("member request recorded: %d rows", count)
446+ }
447+
448+ // A banned key is refused.
449+ if _, err := svc.Execute(ctx, owner, "banpubkey", raws(banned, "spam")); err != nil {
450+ t.Fatal(err)
451+ }
452+ if _, err := handle(accessRequest(banned, "let me in")); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
453+ t.Fatalf("banned request: %v", err)
454+ }
455+ if _, count := joinRequestRow(t, st, banned); count != 1 {
456+ t.Fatalf("banned request recorded: %d rows", count)
457+ }
458+
459+ // A claimed join keeps its invite checks.
460+ claimed := accessRequest(strings.Repeat("e", 64), "")
461+ claimed.Tags = [][]string{{"-"}, {"claim", "nosuchcode"}}
462+ if _, err := handle(claimed); err == nil || err.Error() != "invite_invalid" {
463+ t.Fatalf("claimed join: %v", err)
464+ }
465+ })
466+ }
467+}
468+
469+func TestApproveAndDenyAccessRequests(t *testing.T) {
470+ ctx := context.Background()
471+ svc, st, owner := requestService(t)
472+ moderator, member, alice, bob, carol := strings.Repeat("b", 64), strings.Repeat("c", 64), strings.Repeat("d", 64), strings.Repeat("e", 64), strings.Repeat("f", 64)
473+ if _, err := svc.Execute(ctx, owner, "setmember", raws(moderator, map[string]any{"role": "moderator"})); err != nil {
474+ t.Fatal(err)
475+ }
476+ if _, err := svc.Execute(ctx, owner, "setmember", raws(member, map[string]any{"role": "member"})); err != nil {
477+ t.Fatal(err)
478+ }
479+ for _, name := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {
480+ if !containsMethod(svc.Methods(), name) {
481+ t.Fatalf("methods lack %s", name)
482+ }
483+ }
484+ for _, pk := range []string{alice, bob, carol} {
485+ if _, err := svc.HandleMembershipEvent(ctx, accessRequest(pk, "please "+pk[:1])); err != nil {
486+ t.Fatal(err)
487+ }
488+ }
489+ // Only the owner and moderators list or decide.
490+ for _, actor := range []string{member, alice, strings.Repeat("9", 64)} {
491+ for _, method := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {
492+ if _, err := svc.Execute(ctx, actor, method, raws(alice)); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
493+ t.Fatalf("%s by %s: %v", method, actor[:1], err)
494+ }
495+ }
496+ }
497+ listed, err := svc.Execute(ctx, moderator, "listjoinrequests", nil)
498+ if err != nil {
499+ t.Fatal(err)
500+ }
501+ if rows := listed.([]JoinRequest); len(rows) != 3 || rows[0].Status != "pending" || rows[0].Reason != "please "+rows[0].PubKey[:1] {
502+ t.Fatalf("listjoinrequests %+v", rows)
503+ }
504+ var intentsBefore int
505+ if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intentsBefore); err != nil {
506+ t.Fatal(err)
507+ }
508+ result, err := svc.Execute(ctx, moderator, "approvejoin", raws(alice))
509+ if err != nil {
510+ t.Fatal(err)
511+ }
512+ if got := result.(map[string]any); got["pubkey"] != alice || got["status"] != "approved" {
513+ t.Fatalf("approvejoin = %v", got)
514+ }
515+ var role, via string
516+ if err := st.DB().QueryRow(`SELECT role,via FROM community_members WHERE pubkey=?`, alice).Scan(&role, &via); err != nil || role != "member" || via != "request" {
517+ t.Fatalf("approved member role=%q via=%q err=%v", role, via, err)
518+ }
519+ var intentsAfter int
520+ if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intentsAfter); err != nil || intentsAfter != intentsBefore+1 {
521+ t.Fatalf("approval queued %d projections, want 1", intentsAfter-intentsBefore)
522+ }
523+ row, _ := joinRequestRow(t, st, alice)
524+ if row.Status != "approved" || row.DecidedBy != moderator || row.DecidedAt == 0 {
525+ t.Fatalf("approved row %+v", row)
526+ }
527+ if _, err := svc.Execute(ctx, owner, "denyjoin", raws(alice)); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {
528+ t.Fatalf("deny after approve: %v", err)
529+ }
530+ // A key that already became a member only has its request marked.
531+ if _, err := svc.Execute(ctx, owner, "setmember", raws(bob, map[string]any{"name": "bob", "role": "member"})); err != nil {
532+ t.Fatal(err)
533+ }
534+ if _, err := svc.Execute(ctx, owner, "approvejoin", raws(bob)); err != nil {
535+ t.Fatal(err)
536+ }
537+ var name string
538+ if err := st.DB().QueryRow(`SELECT name,via FROM community_members WHERE pubkey=?`, bob).Scan(&name, &via); err != nil || name != "bob" || via != "management" {
539+ t.Fatalf("existing member changed: name=%q via=%q err=%v", name, via, err)
540+ }
541+ if row, _ := joinRequestRow(t, st, bob); row.Status != "approved" || row.DecidedBy != owner {
542+ t.Fatalf("bob row %+v", row)
543+ }
544+ if _, err := svc.Execute(ctx, owner, "denyjoin", raws(carol)); err != nil {
545+ t.Fatal(err)
546+ }
547+ if row, _ := joinRequestRow(t, st, carol); row.Status != "denied" || row.DecidedBy != owner || row.DecidedAt == 0 {
548+ t.Fatalf("carol row %+v", row)
549+ }
550+ if role, _ := svc.Role(ctx, carol); role != "" {
551+ t.Fatalf("denied key became %q", role)
552+ }
553+ if _, err := svc.Execute(ctx, owner, "approvejoin", raws(strings.Repeat("8", 64))); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
554+ t.Fatalf("approve unknown: %v", err)
555+ }
556+ // Decided requests follow the pending ones, newest decision first; a
557+ // denied key may ask again.
558+ if _, err := st.DB().Exec(`UPDATE join_requests SET decided_at=decided_at-60 WHERE pubkey=?`, alice); err != nil {
559+ t.Fatal(err)
560+ }
561+ if _, err := svc.HandleMembershipEvent(ctx, accessRequest(strings.Repeat("7", 64), "new")); err != nil {
562+ t.Fatal(err)
563+ }
564+ again, err := svc.HandleMembershipEvent(ctx, accessRequest(carol, "second try"))
565+ if err != nil || !again.NewRequest {
566+ t.Fatalf("denied key asking again %+v %v", again, err)
567+ }
568+ listed, _ = svc.Execute(ctx, owner, "listjoinrequests", nil)
569+ rows := listed.([]JoinRequest)
570+ if len(rows) != 4 || rows[0].Status != "pending" || rows[1].Status != "pending" || rows[2].Status == "pending" || rows[3].Status == "pending" {
571+ t.Fatalf("order %+v", rows)
572+ }
573+ if rows[2].PubKey != bob || rows[3].PubKey != alice {
574+ t.Fatalf("decided order %s %s", rows[2].PubKey[:1], rows[3].PubKey[:1])
575+ }
576+ audit, _ := svc.Execute(ctx, owner, "listaudit", nil)
577+ seen := map[string]bool{}
578+ for _, row := range audit.([]AuditRow) {
579+ seen[row.Action+":"+row.Target] = true
580+ }
581+ if !seen["approvejoin:"+alice] || !seen["denyjoin:"+carol] || !seen["setmember:"+alice] {
582+ t.Fatalf("audit %v", seen)
583+ }
584+}
585+
586+func containsMethod(methods []string, wanted string) bool {
587+ for _, method := range methods {
588+ if method == wanted {
589+ return true
590+ }
591+ }
592+ return false
593+}
594diff --git a/internal/daemon/http.go b/internal/daemon/http.go
595index 11e90df..f5ab888 100644
596--- a/internal/daemon/http.go
597+++ b/internal/daemon/http.go
598@@ -595,7 +595,7 @@ func (t *Tenant) executeCommunity(ctx context.Context, actor, method string, par
599
600 func communityACLMutation(method string) bool {
601 switch method {
602- case "setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "banpubkey", "banevent", "allowevent", "blockip", "unblockip", "resolvereport", "allowkind", "disallowkind", "unrulekind", "setblockedwords":
603+ case "setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "approvejoin", "banpubkey", "banevent", "allowevent", "blockip", "unblockip", "resolvereport", "allowkind", "disallowkind", "unrulekind", "setblockedwords":
604 return true
605 default:
606 return false
607diff --git a/internal/daemon/join_requests.go b/internal/daemon/join_requests.go
608new file mode 100644
609index 0000000..f525533
610--- /dev/null
611+++ b/internal/daemon/join_requests.go
612@@ -0,0 +1,58 @@
613+package daemon
614+
615+import (
616+ "context"
617+ "strings"
618+
619+ "github.com/FelineStateMachine/tinyrelay/internal/community"
620+ "github.com/FelineStateMachine/tinyrelay/internal/event"
621+)
622+
623+// An access request is a NIP-43 join without a claim. The community service
624+// holds it for review; this file wakes the reviewers once per new request.
625+
626+// joinRequestNotices names the owner and moderators as the people asked,
627+// with the asker's short key and reason in the approvals category.
628+func (t *Tenant) joinRequestNotices(ctx context.Context, e event.Event) []pushNotice {
629+ reason := excerpt(community.JoinReason(e.Content))
630+ if reason == "" {
631+ reason = "no reason given"
632+ }
633+ body := shortKey(e.PubKey) + " asks to join: " + reason
634+ url := strings.TrimRight(t.publicURL, "/") + "/manage/people"
635+ recipients := []string{t.Policy().Owner}
636+ if moderators, err := t.community.Moderators(ctx); err == nil {
637+ recipients = append(recipients, moderators...)
638+ }
639+ notices := make([]pushNotice, 0, len(recipients))
640+ for _, recipient := range recipients {
641+ if recipient == e.PubKey {
642+ continue
643+ }
644+ notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: url})
645+ }
646+ return notices
647+}
648+
649+// notifyJoinRequest wakes the reviewers' devices for a new pending request.
650+// A refreshed request never reaches here, so each request wakes them once.
651+// The log carries counts only.
652+func (t *Tenant) notifyJoinRequest(ctx context.Context, e event.Event) {
653+ notices := t.joinRequestNotices(ctx, e)
654+ t.app.telemetry.Logger().Info("access request", "tenant", t.meta.Name, "reviewers", len(notices))
655+ if !t.pushDevicesExist(ctx) {
656+ return
657+ }
658+ queued := 0
659+ for _, notice := range notices {
660+ if !t.pushCoalesced(notice.recipient, notice.category) {
661+ continue
662+ }
663+ if err := t.enqueuePushNotice(ctx, notice); err != nil {
664+ t.app.telemetry.Logger().Debug("access request notification not queued", "error", err)
665+ continue
666+ }
667+ queued++
668+ }
669+ t.app.telemetry.Logger().Debug("access request notifications queued", "count", queued)
670+}
671diff --git a/internal/daemon/join_requests_test.go b/internal/daemon/join_requests_test.go
672new file mode 100644
673index 0000000..b815395
674--- /dev/null
675+++ b/internal/daemon/join_requests_test.go
676@@ -0,0 +1,292 @@
677+package daemon
678+
679+import (
680+ "context"
681+ "encoding/json"
682+ "net/http"
683+ "net/http/httptest"
684+ "strconv"
685+ "strings"
686+ "testing"
687+ "time"
688+
689+ "github.com/FelineStateMachine/tinyrelay/internal/community"
690+ "github.com/FelineStateMachine/tinyrelay/internal/event"
691+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
692+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
693+)
694+
695+const (
696+ testAskerSecret = "0000000000000000000000000000000000000000000000000000000000000021"
697+ testSecondSecret = "0000000000000000000000000000000000000000000000000000000000000022"
698+)
699+
700+// accessRequestEvent is what Flotilla publishes: a kind 28934 join with no
701+// claim tag and the reason in the content.
702+func accessRequestEvent(t *testing.T, secret string, createdAt int64, reason string) event.Event {
703+ t.Helper()
704+ return signedEvent(t, secret, event.KIND_NIP43_JOIN, createdAt, [][]string{{"-"}}, reason)
705+}
706+
707+func joinRequests(t *testing.T, tenant *Tenant, actor string) []community.JoinRequest {
708+ t.Helper()
709+ listed, err := tenant.Execute(context.Background(), actor, "listjoinrequests", nil)
710+ if err != nil {
711+ t.Fatal(err)
712+ }
713+ return listed.([]community.JoinRequest)
714+}
715+
716+// waitProjections lets the tenant worker finish the membership projections
717+// queued by an approval.
718+func waitProjections(t *testing.T, tenant *Tenant) {
719+ t.Helper()
720+ deadline := time.Now().Add(15 * time.Second)
721+ for {
722+ var pending int
723+ if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection' AND state IN ('pending','running')`).Scan(&pending); err != nil {
724+ t.Fatal(err)
725+ }
726+ if pending == 0 {
727+ return
728+ }
729+ if time.Now().After(deadline) {
730+ t.Fatalf("%d projections still queued", pending)
731+ }
732+ time.Sleep(50 * time.Millisecond)
733+ }
734+}
735+
736+func recordsNaming(t *testing.T, tenant *Tenant, kind int, pubkey string) int {
737+ t.Helper()
738+ result, err := tenant.store.Query(context.Background(), event.Filter{Kinds: []int{kind}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})
739+ if err != nil {
740+ t.Fatal(err)
741+ }
742+ count := 0
743+ for _, e := range result.Events {
744+ if e.PubKey != tenant.records.PublicKey() {
745+ continue
746+ }
747+ for _, tag := range e.Tags {
748+ if len(tag) > 1 && tag[1] == pubkey {
749+ count++
750+ break
751+ }
752+ }
753+ }
754+ return count
755+}
756+
757+func TestAccessRequestIsHeldReviewedAndWakesReviewersOnce(t *testing.T) {
758+ app, tenant := testTenant(t)
759+ ctx := context.Background()
760+ owner := tenant.Policy().Owner
761+ asker, _ := event.PublicKey(testAskerSecret)
762+ second, _ := event.PublicKey(testSecondSecret)
763+ member, _ := event.PublicKey(testMemberSecret)
764+ moderator, _ := event.PublicKey(testModSecret)
765+ now := time.Now().Unix()
766+
767+ // The owner's device is registered so a request can wake it.
768+ service := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }))
769+ defer service.Close()
770+ tenant.pushClient = service.Client()
771+ body, _ := json.Marshal(testSubscription(t, service.URL+"/send/owner-phone"))
772+ subscribe := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))
773+ signRequest(t, subscribe, string(body))
774+ subscribed := httptest.NewRecorder()
775+ app.ServeHTTP(subscribed, subscribe)
776+ if subscribed.Code != 200 {
777+ t.Fatalf("subscribe %d %s", subscribed.Code, subscribed.Body.String())
778+ }
779+
780+ request := accessRequestEvent(t, testAskerSecret, now, " Met you at the meetup, would like to follow the build. ")
781+ reason, err := tenant.Publish(ctx, request, relay.Session{PubKeys: []string{asker}})
782+ if err != nil {
783+ t.Fatalf("access request refused: %v", err)
784+ }
785+ if reason != "info: access request received, the relay owner will review it" {
786+ t.Fatalf("OK reason %q", reason)
787+ }
788+ rows := joinRequests(t, tenant, owner)
789+ if len(rows) != 1 || rows[0].PubKey != asker || rows[0].Reason != "Met you at the meetup, would like to follow the build." || rows[0].Status != "pending" || rows[0].RequestedAt == 0 {
790+ t.Fatalf("listjoinrequests %+v", rows)
791+ }
792+ var stored int
793+ if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM events WHERE kind=?`, event.KIND_NIP43_JOIN).Scan(&stored); err != nil || stored != 0 {
794+ t.Fatalf("request stored as an event: %d %v", stored, err)
795+ }
796+ if role, _ := tenant.community.Role(ctx, asker); role != "" {
797+ t.Fatalf("asker became %q", role)
798+ }
799+ notices := tenant.joinRequestNotices(ctx, request)
800+ if len(notices) != 1 || notices[0].recipient != owner || notices[0].category != pushApprovals || notices[0].body != asker[:12]+" asks to join: Met you at the meetup, would like to follow the build." || notices[0].url != "http://relay.test/manage/people" {
801+ t.Fatalf("notices %+v", notices)
802+ }
803+ if got := countIntents(t, tenant, notificationPush); got != 1 {
804+ t.Fatalf("device notifications queued = %d, want 1", got)
805+ }
806+ // A repeat refreshes the request without waking the owner again.
807+ if reason, err := tenant.Publish(ctx, accessRequestEvent(t, testAskerSecret, now+1, "Second reason"), relay.Session{PubKeys: []string{asker}}); err != nil || reason != community.JoinRequestReceived {
808+ t.Fatalf("repeat %q %v", reason, err)
809+ }
810+ if rows := joinRequests(t, tenant, owner); len(rows) != 1 || rows[0].Reason != "Second reason" {
811+ t.Fatalf("refreshed %+v", rows)
812+ }
813+ if got := countIntents(t, tenant, notificationPush); got != 1 {
814+ t.Fatalf("device notifications after repeat = %d, want 1", got)
815+ }
816+ // Without a reason the summary says so; moderators are woken too.
817+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(moderator)), json.RawMessage(`{"role":"moderator"}`)}); err != nil {
818+ t.Fatal(err)
819+ }
820+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {
821+ t.Fatal(err)
822+ }
823+ quiet := accessRequestEvent(t, testSecondSecret, now, "")
824+ notices = tenant.joinRequestNotices(ctx, quiet)
825+ if len(notices) != 2 || notices[0].recipient != owner || notices[1].recipient != moderator || notices[1].body != second[:12]+" asks to join: no reason given" {
826+ t.Fatalf("moderator notices %+v", notices)
827+ }
828+ if _, err := tenant.Publish(ctx, quiet, relay.Session{PubKeys: []string{second}}); err != nil {
829+ t.Fatal(err)
830+ }
831+ // A member asking again is told so, and a claimed join still needs a
832+ // valid invite.
833+ if reason, err := tenant.Publish(ctx, accessRequestEvent(t, testMemberSecret, now, "again"), relay.Session{PubKeys: []string{member}}); err != nil || reason != "duplicate: already a member" {
834+ t.Fatalf("member request %q %v", reason, err)
835+ }
836+ claimed := signedEvent(t, testAskerSecret, event.KIND_NIP43_JOIN, now+2, [][]string{{"-"}, {"claim", "nosuchcode"}}, "")
837+ if _, err := tenant.Publish(ctx, claimed, relay.Session{PubKeys: []string{asker}}); err == nil || err.Error() != "invite_invalid" {
838+ t.Fatalf("claimed join: %v", err)
839+ }
840+
841+ // Only the owner and moderators review.
842+ methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)
843+ if err != nil {
844+ t.Fatal(err)
845+ }
846+ for _, name := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {
847+ if !containsString(methods.([]string), name) {
848+ t.Fatalf("supportedmethods lacks %s", name)
849+ }
850+ }
851+ param := []json.RawMessage{json.RawMessage(strconv.Quote(asker))}
852+ for _, actor := range []string{member, asker, strings.Repeat("e", 64)} {
853+ for _, method := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {
854+ if _, err := tenant.Execute(ctx, actor, method, param); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
855+ t.Fatalf("%s by non-admin: %v", method, err)
856+ }
857+ }
858+ }
859+ if rows := joinRequests(t, tenant, moderator); len(rows) != 2 {
860+ t.Fatalf("moderator list %+v", rows)
861+ }
862+
863+ // Approval makes a member and the signed records follow.
864+ result, err := tenant.Execute(ctx, moderator, "approvejoin", param)
865+ if err != nil {
866+ t.Fatal(err)
867+ }
868+ if got := result.(map[string]any); got["status"] != "approved" || got["pubkey"] != asker {
869+ t.Fatalf("approvejoin = %v", got)
870+ }
871+ if role, _ := tenant.community.Role(ctx, asker); role != "member" {
872+ t.Fatalf("approved role = %q", role)
873+ }
874+ waitProjections(t, tenant)
875+ if n := recordsNaming(t, tenant, event.KIND_ROSTER, asker); n == 0 {
876+ t.Fatal("member list (kind 13534) does not name the approved key")
877+ }
878+ if n := recordsNaming(t, tenant, event.KIND_MEMBER_ADDED, asker); n == 0 {
879+ t.Fatal("no add-user record (kind 8000) for the approved key")
880+ }
881+ rows = joinRequests(t, tenant, owner)
882+ if len(rows) != 2 || rows[0].PubKey != second || rows[0].Status != "pending" || rows[1].PubKey != asker || rows[1].Status != "approved" || rows[1].DecidedBy != moderator || rows[1].DecidedAt == 0 {
883+ t.Fatalf("after approval %+v", rows)
884+ }
885+ if _, err := tenant.Execute(ctx, owner, "denyjoin", []json.RawMessage{json.RawMessage(strconv.Quote(second))}); err != nil {
886+ t.Fatal(err)
887+ }
888+ byKey := map[string]community.JoinRequest{}
889+ for _, row := range joinRequests(t, tenant, owner) {
890+ byKey[row.PubKey] = row
891+ }
892+ if len(byKey) != 2 || byKey[second].Status != "denied" || byKey[second].DecidedBy != owner || byKey[asker].Status != "approved" {
893+ t.Fatalf("after denial %+v", byKey)
894+ }
895+ if role, _ := tenant.community.Role(ctx, second); role != "" {
896+ t.Fatalf("denied role = %q", role)
897+ }
898+ audit, _ := tenant.Execute(ctx, owner, "listaudit", nil)
899+ seen := map[string]bool{}
900+ for _, row := range audit.([]community.AuditRow) {
901+ seen[row.Action] = true
902+ }
903+ if !seen["approvejoin"] || !seen["denyjoin"] {
904+ t.Fatalf("audit lacks the decisions: %v", seen)
905+ }
906+}
907+
908+func TestMCPJoinRequestTools(t *testing.T) {
909+ app, tenant := testTenant(t)
910+ ctx := context.Background()
911+ asker, _ := event.PublicKey(testAskerSecret)
912+ member, _ := event.PublicKey(testMemberSecret)
913+ if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {
914+ t.Fatal(err)
915+ }
916+ if _, err := tenant.Publish(ctx, accessRequestEvent(t, testAskerSecret, time.Now().Unix(), "Let me in"), relay.Session{PubKeys: []string{asker}}); err != nil {
917+ t.Fatal(err)
918+ }
919+ _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")
920+ names := map[string]bool{}
921+ for _, tool := range response.Result.(map[string]any)["tools"].([]any) {
922+ names[tool.(map[string]any)["name"].(string)] = true
923+ }
924+ for _, want := range []string{"list_join_requests", "approve_join", "deny_join"} {
925+ if !names[want] {
926+ t.Fatalf("missing tool %s", want)
927+ }
928+ }
929+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {
930+ t.Helper()
931+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
932+ if w.Code != http.StatusOK {
933+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
934+ }
935+ return mcpToolResult(t, response)
936+ }
937+ structured := func(result map[string]any) map[string]any {
938+ value, _ := result["structuredContent"].(map[string]any)
939+ return value
940+ }
941+ result, isError := call("list_join_requests", map[string]any{}, "")
942+ listed, _ := structured(result)["result"].([]any)
943+ if isError || len(listed) != 1 || listed[0].(map[string]any)["pubkey"] != asker || listed[0].(map[string]any)["reason"] != "Let me in" || listed[0].(map[string]any)["status"] != "pending" {
944+ t.Fatalf("list_join_requests: %v %v", isError, result)
945+ }
946+ if _, isError := call("list_join_requests", map[string]any{}, testMemberSecret); !isError {
947+ t.Fatal("a member listed access requests")
948+ }
949+ if _, isError := call("approve_join", map[string]any{"pubkey": asker}, testMemberSecret); !isError {
950+ t.Fatal("a member approved an access request")
951+ }
952+ result, isError = call("deny_join", map[string]any{"pubkey": asker}, "")
953+ if isError || structured(result)["result"].(map[string]any)["status"] != "denied" {
954+ t.Fatalf("deny_join: %v %v", isError, result)
955+ }
956+ result, isError = call("approve_join", map[string]any{"pubkey": asker}, "")
957+ if isError || structured(result)["result"].(map[string]any)["status"] != "approved" {
958+ t.Fatalf("approve_join: %v %v", isError, result)
959+ }
960+ if role, _ := tenant.community.Role(ctx, asker); role != "member" {
961+ t.Fatalf("role after approve_join = %q", role)
962+ }
963+ llms := httptest.NewRecorder()
964+ app.ServeHTTP(llms, httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil))
965+ if body := llms.Body.String(); !strings.Contains(body, "list_join_requests") || !strings.Contains(body, "approve_join, deny_join") {
966+ t.Fatalf("llms.txt lacks the access request tools: %s", body)
967+ }
968+}
969diff --git a/internal/daemon/management.go b/internal/daemon/management.go
970index ff4643c..4262486 100644
971--- a/internal/daemon/management.go
972+++ b/internal/daemon/management.go
973@@ -138,7 +138,7 @@ func publicManagementMethod(method string) bool {
974 // ManagementMethods is the source-compatible NIP-86 registry. The hosted
975 // lease, trial, fuel, and entitlement controls are intentionally absent.
976 func ManagementMethods() []string {
977- 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"}
978+ return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "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"}
979 }
980
981 func managementRoleAllows(method, role string) bool {
982diff --git a/internal/daemon/management_golden_test.go b/internal/daemon/management_golden_test.go
983index 596bb69..8171d5a 100644
984--- a/internal/daemon/management_golden_test.go
985+++ b/internal/daemon/management_golden_test.go
986@@ -24,7 +24,7 @@ func TestManagementGoldenInventory(t *testing.T) {
987 params["forkrelay"] = []json.RawMessage{json.RawMessage(`{"name":"fork-golden"}`)}
988 params["transferowner"] = []json.RawMessage{json.RawMessage(`"` + strings.Repeat("1", 64) + `"`)}
989 readMethods := map[string]bool{}
990- for _, method := range []string{"supportedmethods", "listaudit", "stats", "getpolicy", "listviews", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listinvites", "listclaims", "listlisthistory", "listeventsneedingmoderation", "listblockedips", "listreports", "exportconfig", "listblobs", "listsites", "listrecentevents", "searchevents", "listpins", "storagestats", "listretention", "listallowedkinds", "listblockedkinds", "listpresets", "listconnectiontemplates", "listconnections", "pullstatus", "listjobs", "deliverystatus", "listdumps", "listbackups", "clearsuccession", "successionstatus", "listdomains"} {
991+ for _, method := range []string{"supportedmethods", "listaudit", "stats", "getpolicy", "listviews", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listjoinrequests", "listinvites", "listclaims", "listlisthistory", "listeventsneedingmoderation", "listblockedips", "listreports", "exportconfig", "listblobs", "listsites", "listrecentevents", "searchevents", "listpins", "storagestats", "listretention", "listallowedkinds", "listblockedkinds", "listpresets", "listconnectiontemplates", "listconnections", "pullstatus", "listjobs", "deliverystatus", "listdumps", "listbackups", "clearsuccession", "successionstatus", "listdomains"} {
992 readMethods[method] = true
993 }
994 for _, method := range ManagementMethods() {
995diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go
996index 294b327..c19d55e 100644
997--- a/internal/daemon/mcp_http.go
998+++ b/internal/daemon/mcp_http.go
999@@ -91,8 +91,8 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
1000 "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",
1001 "- Headers: `MCP-Protocol-Version: "+mcp.Version+"`, `Mcp-Method` equal to the JSON-RPC method and, for tools/call, `Mcp-Name` equal to params.name. The body's params._meta carries `"+mcp.MetaProtocolVersion+"` and `"+mcp.MetaClientCapabilities+"`.",
1002 "- Methods: server/discover, tools/list and tools/call.",
1003- "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_jobs and read_job.",
1004- "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, add_callback, remove_callback, pause_callback and resume_callback. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature.",
1005+ "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_join_requests, list_jobs and read_job.",
1006+ "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback and resume_callback. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature.",
1007 "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",
1008 "",
1009 "## Files",
1010diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
1011index 203a7c6..cddea04 100644
1012--- a/internal/daemon/mcp_tools.go
1013+++ b/internal/daemon/mcp_tools.go
1014@@ -133,6 +133,9 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
1015 add("list_callbacks", "List event callbacks: id, owner, host, filter, paused state, failures and last delivery. Members and agents see their own; the owner and moderators see every callback. The secret is never listed.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1016 return t.mcpManage(ctx, call, "listcallbacks")
1017 })
1018+ add("list_join_requests", "List access requests from people who asked to join without an invite: pubkey, reason, requested_at, status (pending, approved or denied), decided_by and decided_at. Pending requests come first. Requires an owner or moderator key.", mcp.Object(nil), mcpReads, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1019+ return t.mcpManage(ctx, call, "listjoinrequests")
1020+ })
1021
1022 add("run_job", "Queue an existing job to run now. Read jobs through read_management to check its completion.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1023 return t.mcpManage(ctx, call, "runjob", call.String("id"))
1024@@ -179,6 +182,12 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
1025 add("revoke_agent", "End an agent's grant and remove its agent role. The grant event stays stored for audit; a fresh grant restores access. Requires an owner or moderator key.", mcp.Object(map[string]any{"agent": mcpPubKey}, "agent"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1026 return t.mcpManage(ctx, call, "revokeagent", call.String("agent"))
1027 })
1028+ add("approve_join", "Approve an access request: the key becomes a member, the relay's member list and add-user record follow, and the request is marked approved. Requires an owner or moderator key.", mcp.Object(map[string]any{"pubkey": mcpPubKey}, "pubkey"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1029+ return t.mcpManage(ctx, call, "approvejoin", call.String("pubkey"))
1030+ })
1031+ add("deny_join", "Deny an access request. The key stays outside the relay and may ask again. Requires an owner or moderator key.", mcp.Object(map[string]any{"pubkey": mcpPubKey}, "pubkey"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1032+ return t.mcpManage(ctx, call, "denyjoin", call.String("pubkey"))
1033+ })
1034 add("pause_all_agents", "Pause every active agent at once. Requires an owner or moderator key.", mcp.Object(nil), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {
1035 return t.mcpManage(ctx, call, "pauseallagents")
1036 })
1037diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
1038index 46615e9..52203c2 100644
1039--- a/internal/daemon/tenant.go
1040+++ b/internal/daemon/tenant.go
1041@@ -364,6 +364,9 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
1042 _, saveErr := storage.SaveTx(ctx, tx, e, opts)
1043 return saveErr
1044 }
1045+ // reason is the OK message; an access request tells the asker it waits,
1046+ // and a member asking again is told so.
1047+ reason := ""
1048 room := t.roomScope(e)
1049 switch {
1050 case room != "" && community.RoomAdminKind(e.Kind):
1051@@ -371,7 +374,14 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
1052 case room != "" && !event.IsEphemeral(e.Kind):
1053 err = t.community.HandleRoomMessageTx(ctx, e, persist)
1054 case e.Kind == event.KIND_JOIN, e.Kind == event.KIND_LEAVE, e.Kind == event.KIND_NIP43_JOIN, e.Kind == event.KIND_NIP43_LEAVE:
1055- _, err = t.community.HandleMembershipEventTx(ctx, e, persist)
1056+ var membership community.MembershipResult
1057+ membership, err = t.community.HandleMembershipEventTx(ctx, e, persist)
1058+ if err == nil && (membership.AccessRequest || strings.HasPrefix(membership.Message, "duplicate:")) {
1059+ reason = membership.Message
1060+ }
1061+ if err == nil && membership.NewRequest {
1062+ t.notifyJoinRequest(ctx, e)
1063+ }
1064 case e.Kind == event.KIND_PUT_USER, e.Kind == event.KIND_REMOVE_USER, e.Kind == event.KIND_DELETE_EVENT, e.Kind == event.KIND_CREATE_INVITE:
1065 _, err = t.community.HandleModerationEventTx(ctx, e, persist)
1066 default:
1067@@ -410,7 +420,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
1068 }
1069 }
1070 outcome = "ok"
1071- return "", nil
1072+ return reason, nil
1073 }
1074
1075 // agentBeforeCommit mirrors agent grants and grant deletions into the agent
1076diff --git a/internal/gates/gates.go b/internal/gates/gates.go
1077index 82e41a8..6a27aaf 100644
1078--- a/internal/gates/gates.go
1079+++ b/internal/gates/gates.go
1080@@ -527,9 +527,8 @@ func nip43Shape(e event.Event, now int64) error {
1081 return errors.New("invalid: NIP-43 leave needs one protected tag")
1082 }
1083 }
1084- if e.Kind == event.KIND_NIP43_JOIN && event.Tag(e, "claim") == "" {
1085- return errors.New("invalid: NIP-43 join needs a claim tag")
1086- }
1087+ // A join without a claim tag is an access request held for the owner's
1088+ // review, so the claim stays optional.
1089 return nil
1090 }
1091
1092diff --git a/internal/gates/import_test.go b/internal/gates/import_test.go
1093index 15c4b19..d895dc4 100644
1094--- a/internal/gates/import_test.go
1095+++ b/internal/gates/import_test.go
1096@@ -36,6 +36,7 @@ func TestNIP43RequestsRequireProtectedShape(t *testing.T) {
1097 tags [][]string
1098 }{
1099 {name: "join claim", kind: event.KIND_NIP43_JOIN, tags: [][]string{{"-"}, {"claim", "code"}}},
1100+ {name: "join without claim is an access request", kind: event.KIND_NIP43_JOIN, tags: [][]string{{"-"}}},
1101 {name: "leave", kind: event.KIND_NIP43_LEAVE, tags: [][]string{{"-"}}},
1102 } {
1103 t.Run(tc.name, func(t *testing.T) {
1104@@ -52,12 +53,13 @@ func TestNIP43RequestsRequireProtectedShape(t *testing.T) {
1105 name string
1106 kind int
1107 tags [][]string
1108+ at int64
1109 }{
1110- {name: "join without claim", kind: event.KIND_NIP43_JOIN, tags: [][]string{{"-"}}},
1111- {name: "leave without protection", kind: event.KIND_NIP43_LEAVE, tags: [][]string{}},
1112+ {name: "join outside the time window", kind: event.KIND_NIP43_JOIN, tags: [][]string{{"-"}}, at: 1000},
1113+ {name: "leave without protection", kind: event.KIND_NIP43_LEAVE, tags: [][]string{}, at: 10},
1114 } {
1115 t.Run(tc.name, func(t *testing.T) {
1116- e := event.Event{CreatedAt: 10, Kind: tc.kind, Tags: tc.tags}
1117+ e := event.Event{CreatedAt: tc.at, Kind: tc.kind, Tags: tc.tags}
1118 if err := event.Sign(&e, strings.Repeat("1", 64)); err != nil {
1119 t.Fatal(err)
1120 }
1121diff --git a/internal/relay/relay.go b/internal/relay/relay.go
1122index 95e5a50..4716205 100644
1123--- a/internal/relay/relay.go
1124+++ b/internal/relay/relay.go
1125@@ -911,6 +911,10 @@ func knownProtocolReason(reason string) bool {
1126 }
1127
1128 func accessEvent(e event.Event) bool {
1129+ if e.Kind == event.KIND_NIP43_JOIN && event.Tag(e, "claim") == "" {
1130+ // An access request waits for review and changes nobody's access.
1131+ return false
1132+ }
1133 switch e.Kind {
1134 case event.KIND_REPORT, event.KIND_VANISH, event.KIND_JOIN, event.KIND_LEAVE,
1135 event.KIND_NIP43_JOIN, event.KIND_NIP43_LEAVE, event.KIND_PUT_USER,

About

ownerc01503e8035f
visibilitypublic
head2151df9512ee

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs