- commit
- 2151df9512eed751536e29eed3a8c8cfb6ed73ee
- parent
- f9a344a9b6f1e0ef45936782e70050bfb61a1025
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 8 | +- |
| M | internal/ | 24 | ++- |
| M | internal/ | 26 | +++ |
| A | internal/ | 173 | ++++++++++++++++++ |
| A | internal/ | 261 | +++++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
| A | internal/ | 58 | ++++++ |
| A | internal/ | 292 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 9 | + |
| M | internal/ | 14 | +- |
| M | internal/ | 5 | +- |
| M | internal/ | 8 | +- |
| M | internal/ | 4 | + |
@@ -13,6 +13,12 @@ import (
1313 )1414 1515 func (s *Service) setMember(ctx context.Context, actor string, p []json.RawMessage) (any, error) {16+ return s.setMemberVia(ctx, actor, p, "management")17+}18+19+// setMemberVia adds or updates a member and records how the key came in:20+// management for the setmember call, request for an approved access request.21+func (s *Service) setMemberVia(ctx context.Context, actor string, p []json.RawMessage, via string) (any, error) {1622 var pk, name, role string1723 if err := decode(p, 0, &pk); err != nil {1824 return nil, err@@ -65,7 +71,7 @@ func (s *Service) setMember(ctx context.Context, actor string, p []json.RawMessa
6571 if _, err := tx.ExecContext(ctx, `DELETE FROM community_bans WHERE pubkey=?`, pk); err != nil {6672 return err6773 }68- _, 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")74+ _, 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)6975 if err != nil {7076 return fmt.Errorf("set member: %w", err)7177 }@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS community_invites(code TEXT PRIMARY KEY,created_by TE
2929 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));3030 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 '');3131 CREATE TABLE IF NOT EXISTS community_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);32+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);3233 CREATE TRIGGER IF NOT EXISTS community_member_projection_insert AFTER INSERT ON community_members3334 BEGIN3435 INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)@@ -65,6 +66,17 @@ type Invite struct {
6566 Uses int `json:"uses"`6667 Note string `json:"note"`6768 }69+70+// JoinRequest is an access request from a key that asked to join without71+// an invite. It waits for the owner or a moderator to approve or deny it.72+type JoinRequest struct {73+ PubKey string `json:"pubkey"`74+ Reason string `json:"reason"`75+ RequestedAt int64 `json:"requested_at"`76+ Status string `json:"status"`77+ DecidedBy string `json:"decided_by"`78+ DecidedAt int64 `json:"decided_at"`79+}6880 type Report struct {6981 ID int64 `json:"id"`7082 Reporter string `json:"reporter"`@@ -232,6 +244,12 @@ func (s *Service) Execute(ctx context.Context, actor, method string, params []js
232244 return s.removeSubtree(ctx, actor, params)233245 case "listmembers", "listpeople":234246 return s.members(ctx)247+ case "listjoinrequests":248+ return s.listJoinRequests(ctx)249+ case "approvejoin":250+ return s.approveJoin(ctx, actor, params)251+ case "denyjoin":252+ return s.denyJoin(ctx, actor, params)235253 case "listallowedpubkeys":236254 return s.allowedMembers(ctx)237255 case "createinvite":@@ -292,7 +310,7 @@ func (s *Service) Execute(ctx context.Context, actor, method string, params []js
292310 }293311 294312 func allowed(method, role string) bool {295- if method == "listmembers" || method == "listpeople" || method == "listallowedpubkeys" || method == "listbannedpubkeys" || method == "listbannedevents" || method == "listblockedips" || method == "listreports" || method == "listeventsneedingmoderation" || method == "listallowedkinds" || method == "listblockedkinds" || method == "listretention" {313+ 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" {296314 return role == "owner" || role == "moderator"297315 }298316 if role == "owner" {@@ -309,7 +327,7 @@ func allowed(method, role string) bool {
309327 if role != "moderator" {310328 return false311329 }312- 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"} {330+ 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"} {313331 if method == name {314332 return true315333 }@@ -318,7 +336,7 @@ func allowed(method, role string) bool {
318336 }319337 320338 func (s *Service) Methods() []string {321- 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"}339+ 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"}322340 }323341 324342 func roleOrGuest(role string) string {@@ -63,6 +63,12 @@ type MembershipResult struct {
6363 OK bool6464 Message string6565 Stored bool66+ // AccessRequest marks a join held for review; Message is its OK reason.67+ AccessRequest bool68+ // NewRequest reports that the request was not pending before, so the69+ // reviewers should be woken. A refresh of a pending request leaves it70+ // false.71+ NewRequest bool6672 }6773 6874 // HandleMembershipEventTx applies join/leave admission and invokes persist in@@ -103,6 +109,14 @@ func (s *Service) HandleMembershipEventTx(ctx context.Context, ev event.Event, p
103109 out = MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}104110 return nil105111 }112+ if IsAccessRequest(ev) {113+ created, err := s.recordJoinRequestTx(ctx, tx, ev.PubKey, JoinReason(ev.Content))114+ if err != nil {115+ return err116+ }117+ out = MembershipResult{OK: true, Message: JoinRequestReceived, AccessRequest: true, NewRequest: created}118+ return nil119+ }106120 claim := tagValue(ev, "code")107121 if ev.Kind == event.KIND_NIP43_JOIN {108122 claim = tagValue(ev, "claim")@@ -284,6 +298,18 @@ func (s *Service) HandleMembershipEvent(ctx context.Context, ev event.Event) (Me
284298 var result MembershipResult285299 switch ev.Kind {286300 case event.KIND_JOIN, event.KIND_NIP43_JOIN:301+ if IsAccessRequest(ev) {302+ if role, err := s.Role(ctx, ev.PubKey); err != nil {303+ return MembershipResult{}, err304+ } else if role != "" {305+ return MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}, nil306+ }307+ created, err := s.RecordJoinRequest(ctx, ev.PubKey, JoinReason(ev.Content))308+ if err != nil {309+ return MembershipResult{}, err310+ }311+ return MembershipResult{OK: true, Message: JoinRequestReceived, AccessRequest: true, NewRequest: created}, nil312+ }287313 claim := tagValue(ev, "code")288314 if ev.Kind == event.KIND_NIP43_JOIN {289315 claim = tagValue(ev, "claim")@@ -0,0 +1,173 @@
1+package community2+3+import (4+ "context"5+ "database/sql"6+ "encoding/json"7+ "errors"8+ "strings"9+ "unicode/utf8"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+)13+14+// A NIP-43 join (kind 28934) without a claim tag is an access request: the15+// relay holds it for the owner and moderators instead of refusing it. The16+// request is a row, never a stored event, and one key holds one row that a17+// repeat refreshes.18+19+const (20+ // JoinReasonMax bounds the reason kept from the request's content.21+ JoinReasonMax = 50022+ // JoinRequestReceived is the OK reason answered to an access request.23+ JoinRequestReceived = "info: access request received, the relay owner will review it"24+)25+26+// IsAccessRequest reports whether a membership event asks for access rather27+// than claiming an invite.28+func IsAccessRequest(ev event.Event) bool {29+ return ev.Kind == event.KIND_NIP43_JOIN && tagValue(ev, "claim") == ""30+}31+32+// JoinReason is the request content trimmed of surrounding space and cut to33+// JoinReasonMax characters on a character boundary.34+func JoinReason(content string) string {35+ reason := strings.TrimSpace(content)36+ if utf8.RuneCountInString(reason) <= JoinReasonMax {37+ return reason38+ }39+ count := 040+ for i := range reason {41+ if count == JoinReasonMax {42+ return strings.TrimSpace(reason[:i])43+ }44+ count++45+ }46+ return reason47+}48+49+// recordJoinRequestTx stores or refreshes the key's access request and50+// reports whether a new pending request was created. A pending request from51+// the same key takes the new reason and time; a decided one becomes pending52+// again. A banned key is refused.53+func (s *Service) recordJoinRequestTx(ctx context.Context, tx *sql.Tx, pubkey, reason string) (bool, error) {54+ var banned bool55+ if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_bans WHERE pubkey=?)`, pubkey).Scan(&banned); err != nil {56+ return false, err57+ }58+ if banned {59+ return false, errors.New("restricted: this key is banned from the relay")60+ }61+ var status string62+ err := tx.QueryRowContext(ctx, `SELECT status FROM join_requests WHERE pubkey=?`, pubkey).Scan(&status)63+ if err != nil && !errors.Is(err, sql.ErrNoRows) {64+ return false, err65+ }66+ 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 {67+ return false, err68+ }69+ return status != "pending", nil70+}71+72+// RecordJoinRequest is the non-transactional form of recordJoinRequestTx.73+func (s *Service) RecordJoinRequest(ctx context.Context, pubkey, reason string) (bool, error) {74+ created := false75+ err := s.store.WithTx(ctx, func(tx *sql.Tx) error {76+ var err error77+ created, err = s.recordJoinRequestTx(ctx, tx, pubkey, reason)78+ return err79+ })80+ return created, err81+}82+83+// listJoinRequests lists pending requests newest first, then the decided84+// ones newest decision first.85+func (s *Service) listJoinRequests(ctx context.Context) (any, error) {86+ 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`)87+ if err != nil {88+ return nil, err89+ }90+ defer rows.Close()91+ out := []JoinRequest{}92+ for rows.Next() {93+ var r JoinRequest94+ if err := rows.Scan(&r.PubKey, &r.Reason, &r.RequestedAt, &r.Status, &r.DecidedBy, &r.DecidedAt); err != nil {95+ return nil, err96+ }97+ out = append(out, r)98+ }99+ return out, rows.Err()100+}101+102+// PendingJoinRequests counts the requests that wait for a decision.103+func (s *Service) PendingJoinRequests(ctx context.Context) (int, error) {104+ var count int105+ err := s.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM join_requests WHERE status='pending'`).Scan(&count)106+ return count, err107+}108+109+func (s *Service) joinRequestTarget(ctx context.Context, p []json.RawMessage) (string, string, error) {110+ var pk string111+ if err := decode(p, 0, &pk); err != nil {112+ return "", "", err113+ }114+ if !validPubKey(pk) {115+ return "", "", errors.New("invalid: bad pubkey")116+ }117+ var status string118+ err := s.store.DB().QueryRowContext(ctx, `SELECT status FROM join_requests WHERE pubkey=?`, pk).Scan(&status)119+ if errors.Is(err, sql.ErrNoRows) {120+ return "", "", errors.New("not found: no access request from that key")121+ }122+ return pk, status, err123+}124+125+// approveJoin admits the key through the member path setmember uses, so the126+// signed member list and add-user record follow, and marks the request127+// approved. A key that already became a member only has its request marked.128+func (s *Service) approveJoin(ctx context.Context, actor string, p []json.RawMessage) (any, error) {129+ pk, _, err := s.joinRequestTarget(ctx, p)130+ if err != nil {131+ return nil, err132+ }133+ role, err := s.Role(ctx, pk)134+ if err != nil {135+ return nil, err136+ }137+ if role == "" {138+ if _, err := s.setMemberVia(ctx, actor, []json.RawMessage{mustJSON(pk), mustJSON(map[string]any{"role": "member"})}, "request"); err != nil {139+ return nil, err140+ }141+ }142+ return s.decideJoin(ctx, actor, pk, "approved")143+}144+145+// denyJoin marks the request denied. The key stays outside the relay and146+// may ask again.147+func (s *Service) denyJoin(ctx context.Context, actor string, p []json.RawMessage) (any, error) {148+ pk, status, err := s.joinRequestTarget(ctx, p)149+ if err != nil {150+ return nil, err151+ }152+ if status == "approved" {153+ return nil, errors.New("invalid: the request was approved; remove the member instead")154+ }155+ return s.decideJoin(ctx, actor, pk, "denied")156+}157+158+func (s *Service) decideJoin(ctx context.Context, actor, pk, status string) (any, error) {159+ err := s.store.WithTx(ctx, func(tx *sql.Tx) error {160+ if _, err := tx.ExecContext(ctx, `UPDATE join_requests SET status=?,decided_by=?,decided_at=? WHERE pubkey=?`, status, actor, now(), pk); err != nil {161+ return err162+ }163+ action := "denyjoin"164+ if status == "approved" {165+ action = "approvejoin"166+ }167+ return s.recordTx(ctx, tx, actor, action, pk, "")168+ })169+ if err != nil {170+ return nil, err171+ }172+ return map[string]any{"pubkey": pk, "status": status}, nil173+}@@ -0,0 +1,261 @@
1+package community2+3+import (4+ "context"5+ "database/sql"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+ "github.com/FelineStateMachine/tinyrelay/internal/policy"11+ "github.com/FelineStateMachine/tinyrelay/internal/storage"12+)13+14+func requestService(t *testing.T) (*Service, *storage.Store, string) {15+ t.Helper()16+ ctx := context.Background()17+ st, err := storage.Open(ctx, t.TempDir()+"/relay.db")18+ if err != nil {19+ t.Fatal(err)20+ }21+ t.Cleanup(func() { st.Close() })22+ owner := strings.Repeat("a", 64)23+ svc, err := New(ctx, st, owner)24+ if err != nil {25+ t.Fatal(err)26+ }27+ p := policy.Defaults(owner)28+ svc.ConfigurePolicy(func() policy.Policy { return p })29+ return svc, st, owner30+}31+32+func accessRequest(pubkey, content string) event.Event {33+ return event.Event{ID: strings.Repeat("1", 64), PubKey: pubkey, Kind: event.KIND_NIP43_JOIN, Tags: [][]string{{"-"}}, Content: content}34+}35+36+func joinRequestRow(t *testing.T, st *storage.Store, pubkey string) (JoinRequest, int) {37+ t.Helper()38+ var r JoinRequest39+ var count int40+ if err := st.DB().QueryRow(`SELECT count(*) FROM join_requests`).Scan(&count); err != nil {41+ t.Fatal(err)42+ }43+ 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)44+ if err != nil && err != sql.ErrNoRows {45+ t.Fatal(err)46+ }47+ return r, count48+}49+50+func TestAccessRequestIsRecordedRefreshedAndAnswered(t *testing.T) {51+ ctx := context.Background()52+ for _, path := range []string{"transactional", "direct"} {53+ t.Run(path, func(t *testing.T) {54+ svc, st, owner := requestService(t)55+ stranger, member, banned := strings.Repeat("b", 64), strings.Repeat("c", 64), strings.Repeat("d", 64)56+ persisted := false57+ handle := func(ev event.Event) (MembershipResult, error) {58+ if path == "direct" {59+ return svc.HandleMembershipEvent(ctx, ev)60+ }61+ return svc.HandleMembershipEventTx(ctx, ev, func(*sql.Tx) error { persisted = true; return nil })62+ }63+ result, err := handle(accessRequest(stranger, " Hi, I am Bob from the meetup. "))64+ if err != nil {65+ t.Fatal(err)66+ }67+ if !result.OK || result.Stored || !result.AccessRequest || !result.NewRequest || result.Message != JoinRequestReceived {68+ t.Fatalf("first request result %+v", result)69+ }70+ if result.Message != "info: access request received, the relay owner will review it" {71+ t.Fatalf("OK message %q", result.Message)72+ }73+ row, count := joinRequestRow(t, st, stranger)74+ if count != 1 || row.Reason != "Hi, I am Bob from the meetup." || row.Status != "pending" || row.RequestedAt == 0 || row.DecidedBy != "" || row.DecidedAt != 0 {75+ t.Fatalf("row %+v count %d", row, count)76+ }77+ if persisted {78+ t.Fatal("access request was stored as an event")79+ }80+ if role, _ := svc.Role(ctx, stranger); role != "" {81+ t.Fatalf("request made a member: %q", role)82+ }83+ // The owner's own row queued one projection; the request adds none.84+ var intents int85+ if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intents); err != nil || intents != 1 {86+ t.Fatalf("request queued a projection: %d %v", intents-1, err)87+ }88+89+ // A repeat refreshes the reason and time in the same row and is90+ // not a new request.91+ if _, err := st.DB().Exec(`UPDATE join_requests SET requested_at=1 WHERE pubkey=?`, stranger); err != nil {92+ t.Fatal(err)93+ }94+ long := strings.Repeat("x", 600)95+ result, err = handle(accessRequest(stranger, long))96+ if err != nil || !result.OK || result.NewRequest || !result.AccessRequest || result.Message != JoinRequestReceived {97+ t.Fatalf("repeat result %+v %v", result, err)98+ }99+ row, count = joinRequestRow(t, st, stranger)100+ if count != 1 || len(row.Reason) != JoinReasonMax || row.RequestedAt == 1 || row.Status != "pending" {101+ t.Fatalf("refreshed row %+v count %d", row, count)102+ }103+104+ // A member is told so, as before.105+ if _, err := svc.Execute(ctx, owner, "setmember", raws(member, map[string]any{"role": "member"})); err != nil {106+ t.Fatal(err)107+ }108+ result, err = handle(accessRequest(member, "again"))109+ if err != nil || !result.OK || result.Message != "duplicate: already a member" || result.AccessRequest {110+ t.Fatalf("member result %+v %v", result, err)111+ }112+ if _, count := joinRequestRow(t, st, member); count != 1 {113+ t.Fatalf("member request recorded: %d rows", count)114+ }115+116+ // A banned key is refused.117+ if _, err := svc.Execute(ctx, owner, "banpubkey", raws(banned, "spam")); err != nil {118+ t.Fatal(err)119+ }120+ if _, err := handle(accessRequest(banned, "let me in")); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {121+ t.Fatalf("banned request: %v", err)122+ }123+ if _, count := joinRequestRow(t, st, banned); count != 1 {124+ t.Fatalf("banned request recorded: %d rows", count)125+ }126+127+ // A claimed join keeps its invite checks.128+ claimed := accessRequest(strings.Repeat("e", 64), "")129+ claimed.Tags = [][]string{{"-"}, {"claim", "nosuchcode"}}130+ if _, err := handle(claimed); err == nil || err.Error() != "invite_invalid" {131+ t.Fatalf("claimed join: %v", err)132+ }133+ })134+ }135+}136+137+func TestApproveAndDenyAccessRequests(t *testing.T) {138+ ctx := context.Background()139+ svc, st, owner := requestService(t)140+ 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)141+ if _, err := svc.Execute(ctx, owner, "setmember", raws(moderator, map[string]any{"role": "moderator"})); err != nil {142+ t.Fatal(err)143+ }144+ if _, err := svc.Execute(ctx, owner, "setmember", raws(member, map[string]any{"role": "member"})); err != nil {145+ t.Fatal(err)146+ }147+ for _, name := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {148+ if !containsMethod(svc.Methods(), name) {149+ t.Fatalf("methods lack %s", name)150+ }151+ }152+ for _, pk := range []string{alice, bob, carol} {153+ if _, err := svc.HandleMembershipEvent(ctx, accessRequest(pk, "please "+pk[:1])); err != nil {154+ t.Fatal(err)155+ }156+ }157+ // Only the owner and moderators list or decide.158+ for _, actor := range []string{member, alice, strings.Repeat("9", 64)} {159+ for _, method := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {160+ if _, err := svc.Execute(ctx, actor, method, raws(alice)); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {161+ t.Fatalf("%s by %s: %v", method, actor[:1], err)162+ }163+ }164+ }165+ listed, err := svc.Execute(ctx, moderator, "listjoinrequests", nil)166+ if err != nil {167+ t.Fatal(err)168+ }169+ if rows := listed.([]JoinRequest); len(rows) != 3 || rows[0].Status != "pending" || rows[0].Reason != "please "+rows[0].PubKey[:1] {170+ t.Fatalf("listjoinrequests %+v", rows)171+ }172+ var intentsBefore int173+ if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intentsBefore); err != nil {174+ t.Fatal(err)175+ }176+ result, err := svc.Execute(ctx, moderator, "approvejoin", raws(alice))177+ if err != nil {178+ t.Fatal(err)179+ }180+ if got := result.(map[string]any); got["pubkey"] != alice || got["status"] != "approved" {181+ t.Fatalf("approvejoin = %v", got)182+ }183+ var role, via string184+ if err := st.DB().QueryRow(`SELECT role,via FROM community_members WHERE pubkey=?`, alice).Scan(&role, &via); err != nil || role != "member" || via != "request" {185+ t.Fatalf("approved member role=%q via=%q err=%v", role, via, err)186+ }187+ var intentsAfter int188+ if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intentsAfter); err != nil || intentsAfter != intentsBefore+1 {189+ t.Fatalf("approval queued %d projections, want 1", intentsAfter-intentsBefore)190+ }191+ row, _ := joinRequestRow(t, st, alice)192+ if row.Status != "approved" || row.DecidedBy != moderator || row.DecidedAt == 0 {193+ t.Fatalf("approved row %+v", row)194+ }195+ if _, err := svc.Execute(ctx, owner, "denyjoin", raws(alice)); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {196+ t.Fatalf("deny after approve: %v", err)197+ }198+ // A key that already became a member only has its request marked.199+ if _, err := svc.Execute(ctx, owner, "setmember", raws(bob, map[string]any{"name": "bob", "role": "member"})); err != nil {200+ t.Fatal(err)201+ }202+ if _, err := svc.Execute(ctx, owner, "approvejoin", raws(bob)); err != nil {203+ t.Fatal(err)204+ }205+ var name string206+ if err := st.DB().QueryRow(`SELECT name,via FROM community_members WHERE pubkey=?`, bob).Scan(&name, &via); err != nil || name != "bob" || via != "management" {207+ t.Fatalf("existing member changed: name=%q via=%q err=%v", name, via, err)208+ }209+ if row, _ := joinRequestRow(t, st, bob); row.Status != "approved" || row.DecidedBy != owner {210+ t.Fatalf("bob row %+v", row)211+ }212+ if _, err := svc.Execute(ctx, owner, "denyjoin", raws(carol)); err != nil {213+ t.Fatal(err)214+ }215+ if row, _ := joinRequestRow(t, st, carol); row.Status != "denied" || row.DecidedBy != owner || row.DecidedAt == 0 {216+ t.Fatalf("carol row %+v", row)217+ }218+ if role, _ := svc.Role(ctx, carol); role != "" {219+ t.Fatalf("denied key became %q", role)220+ }221+ if _, err := svc.Execute(ctx, owner, "approvejoin", raws(strings.Repeat("8", 64))); err == nil || !strings.HasPrefix(err.Error(), "not found:") {222+ t.Fatalf("approve unknown: %v", err)223+ }224+ // Decided requests follow the pending ones, newest decision first; a225+ // denied key may ask again.226+ if _, err := st.DB().Exec(`UPDATE join_requests SET decided_at=decided_at-60 WHERE pubkey=?`, alice); err != nil {227+ t.Fatal(err)228+ }229+ if _, err := svc.HandleMembershipEvent(ctx, accessRequest(strings.Repeat("7", 64), "new")); err != nil {230+ t.Fatal(err)231+ }232+ again, err := svc.HandleMembershipEvent(ctx, accessRequest(carol, "second try"))233+ if err != nil || !again.NewRequest {234+ t.Fatalf("denied key asking again %+v %v", again, err)235+ }236+ listed, _ = svc.Execute(ctx, owner, "listjoinrequests", nil)237+ rows := listed.([]JoinRequest)238+ if len(rows) != 4 || rows[0].Status != "pending" || rows[1].Status != "pending" || rows[2].Status == "pending" || rows[3].Status == "pending" {239+ t.Fatalf("order %+v", rows)240+ }241+ if rows[2].PubKey != bob || rows[3].PubKey != alice {242+ t.Fatalf("decided order %s %s", rows[2].PubKey[:1], rows[3].PubKey[:1])243+ }244+ audit, _ := svc.Execute(ctx, owner, "listaudit", nil)245+ seen := map[string]bool{}246+ for _, row := range audit.([]AuditRow) {247+ seen[row.Action+":"+row.Target] = true248+ }249+ if !seen["approvejoin:"+alice] || !seen["denyjoin:"+carol] || !seen["setmember:"+alice] {250+ t.Fatalf("audit %v", seen)251+ }252+}253+254+func containsMethod(methods []string, wanted string) bool {255+ for _, method := range methods {256+ if method == wanted {257+ return true258+ }259+ }260+ return false261+}@@ -595,7 +595,7 @@ func (t *Tenant) executeCommunity(ctx context.Context, actor, method string, par
595595 596596 func communityACLMutation(method string) bool {597597 switch method {598- case "setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "banpubkey", "banevent", "allowevent", "blockip", "unblockip", "resolvereport", "allowkind", "disallowkind", "unrulekind", "setblockedwords":598+ case "setmember", "allowpubkey", "removemember", "unrulepubkey", "removesubtree", "approvejoin", "banpubkey", "banevent", "allowevent", "blockip", "unblockip", "resolvereport", "allowkind", "disallowkind", "unrulekind", "setblockedwords":599599 return true600600 default:601601 return false@@ -0,0 +1,58 @@
1+package daemon2+3+import (4+ "context"5+ "strings"6+7+ "github.com/FelineStateMachine/tinyrelay/internal/community"8+ "github.com/FelineStateMachine/tinyrelay/internal/event"9+)10+11+// An access request is a NIP-43 join without a claim. The community service12+// holds it for review; this file wakes the reviewers once per new request.13+14+// joinRequestNotices names the owner and moderators as the people asked,15+// with the asker's short key and reason in the approvals category.16+func (t *Tenant) joinRequestNotices(ctx context.Context, e event.Event) []pushNotice {17+ reason := excerpt(community.JoinReason(e.Content))18+ if reason == "" {19+ reason = "no reason given"20+ }21+ body := shortKey(e.PubKey) + " asks to join: " + reason22+ url := strings.TrimRight(t.publicURL, "/") + "/manage/people"23+ recipients := []string{t.Policy().Owner}24+ if moderators, err := t.community.Moderators(ctx); err == nil {25+ recipients = append(recipients, moderators...)26+ }27+ notices := make([]pushNotice, 0, len(recipients))28+ for _, recipient := range recipients {29+ if recipient == e.PubKey {30+ continue31+ }32+ notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: url})33+ }34+ return notices35+}36+37+// notifyJoinRequest wakes the reviewers' devices for a new pending request.38+// A refreshed request never reaches here, so each request wakes them once.39+// The log carries counts only.40+func (t *Tenant) notifyJoinRequest(ctx context.Context, e event.Event) {41+ notices := t.joinRequestNotices(ctx, e)42+ t.app.telemetry.Logger().Info("access request", "tenant", t.meta.Name, "reviewers", len(notices))43+ if !t.pushDevicesExist(ctx) {44+ return45+ }46+ queued := 047+ for _, notice := range notices {48+ if !t.pushCoalesced(notice.recipient, notice.category) {49+ continue50+ }51+ if err := t.enqueuePushNotice(ctx, notice); err != nil {52+ t.app.telemetry.Logger().Debug("access request notification not queued", "error", err)53+ continue54+ }55+ queued++56+ }57+ t.app.telemetry.Logger().Debug("access request notifications queued", "count", queued)58+}@@ -0,0 +1,292 @@
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+ "github.com/FelineStateMachine/tinyrelay/internal/storage"17+)18+19+const (20+ testAskerSecret = "0000000000000000000000000000000000000000000000000000000000000021"21+ testSecondSecret = "0000000000000000000000000000000000000000000000000000000000000022"22+)23+24+// accessRequestEvent is what Flotilla publishes: a kind 28934 join with no25+// claim tag and the reason in the content.26+func accessRequestEvent(t *testing.T, secret string, createdAt int64, reason string) event.Event {27+ t.Helper()28+ return signedEvent(t, secret, event.KIND_NIP43_JOIN, createdAt, [][]string{{"-"}}, reason)29+}30+31+func joinRequests(t *testing.T, tenant *Tenant, actor string) []community.JoinRequest {32+ t.Helper()33+ listed, err := tenant.Execute(context.Background(), actor, "listjoinrequests", nil)34+ if err != nil {35+ t.Fatal(err)36+ }37+ return listed.([]community.JoinRequest)38+}39+40+// waitProjections lets the tenant worker finish the membership projections41+// queued by an approval.42+func waitProjections(t *testing.T, tenant *Tenant) {43+ t.Helper()44+ deadline := time.Now().Add(15 * time.Second)45+ for {46+ var pending int47+ if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection' AND state IN ('pending','running')`).Scan(&pending); err != nil {48+ t.Fatal(err)49+ }50+ if pending == 0 {51+ return52+ }53+ if time.Now().After(deadline) {54+ t.Fatalf("%d projections still queued", pending)55+ }56+ time.Sleep(50 * time.Millisecond)57+ }58+}59+60+func recordsNaming(t *testing.T, tenant *Tenant, kind int, pubkey string) int {61+ t.Helper()62+ result, err := tenant.store.Query(context.Background(), event.Filter{Kinds: []int{kind}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})63+ if err != nil {64+ t.Fatal(err)65+ }66+ count := 067+ for _, e := range result.Events {68+ if e.PubKey != tenant.records.PublicKey() {69+ continue70+ }71+ for _, tag := range e.Tags {72+ if len(tag) > 1 && tag[1] == pubkey {73+ count++74+ break75+ }76+ }77+ }78+ return count79+}80+81+func TestAccessRequestIsHeldReviewedAndWakesReviewersOnce(t *testing.T) {82+ app, tenant := testTenant(t)83+ ctx := context.Background()84+ owner := tenant.Policy().Owner85+ asker, _ := event.PublicKey(testAskerSecret)86+ second, _ := event.PublicKey(testSecondSecret)87+ member, _ := event.PublicKey(testMemberSecret)88+ moderator, _ := event.PublicKey(testModSecret)89+ now := time.Now().Unix()90+91+ // The owner's device is registered so a request can wake it.92+ service := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }))93+ defer service.Close()94+ tenant.pushClient = service.Client()95+ body, _ := json.Marshal(testSubscription(t, service.URL+"/send/owner-phone"))96+ subscribe := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))97+ signRequest(t, subscribe, string(body))98+ subscribed := httptest.NewRecorder()99+ app.ServeHTTP(subscribed, subscribe)100+ if subscribed.Code != 200 {101+ t.Fatalf("subscribe %d %s", subscribed.Code, subscribed.Body.String())102+ }103+104+ request := accessRequestEvent(t, testAskerSecret, now, " Met you at the meetup, would like to follow the build. ")105+ reason, err := tenant.Publish(ctx, request, relay.Session{PubKeys: []string{asker}})106+ if err != nil {107+ t.Fatalf("access request refused: %v", err)108+ }109+ if reason != "info: access request received, the relay owner will review it" {110+ t.Fatalf("OK reason %q", reason)111+ }112+ rows := joinRequests(t, tenant, owner)113+ 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 {114+ t.Fatalf("listjoinrequests %+v", rows)115+ }116+ var stored int117+ if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM events WHERE kind=?`, event.KIND_NIP43_JOIN).Scan(&stored); err != nil || stored != 0 {118+ t.Fatalf("request stored as an event: %d %v", stored, err)119+ }120+ if role, _ := tenant.community.Role(ctx, asker); role != "" {121+ t.Fatalf("asker became %q", role)122+ }123+ notices := tenant.joinRequestNotices(ctx, request)124+ 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" {125+ t.Fatalf("notices %+v", notices)126+ }127+ if got := countIntents(t, tenant, notificationPush); got != 1 {128+ t.Fatalf("device notifications queued = %d, want 1", got)129+ }130+ // A repeat refreshes the request without waking the owner again.131+ if reason, err := tenant.Publish(ctx, accessRequestEvent(t, testAskerSecret, now+1, "Second reason"), relay.Session{PubKeys: []string{asker}}); err != nil || reason != community.JoinRequestReceived {132+ t.Fatalf("repeat %q %v", reason, err)133+ }134+ if rows := joinRequests(t, tenant, owner); len(rows) != 1 || rows[0].Reason != "Second reason" {135+ t.Fatalf("refreshed %+v", rows)136+ }137+ if got := countIntents(t, tenant, notificationPush); got != 1 {138+ t.Fatalf("device notifications after repeat = %d, want 1", got)139+ }140+ // Without a reason the summary says so; moderators are woken too.141+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(moderator)), json.RawMessage(`{"role":"moderator"}`)}); err != nil {142+ t.Fatal(err)143+ }144+ if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {145+ t.Fatal(err)146+ }147+ quiet := accessRequestEvent(t, testSecondSecret, now, "")148+ notices = tenant.joinRequestNotices(ctx, quiet)149+ if len(notices) != 2 || notices[0].recipient != owner || notices[1].recipient != moderator || notices[1].body != second[:12]+" asks to join: no reason given" {150+ t.Fatalf("moderator notices %+v", notices)151+ }152+ if _, err := tenant.Publish(ctx, quiet, relay.Session{PubKeys: []string{second}}); err != nil {153+ t.Fatal(err)154+ }155+ // A member asking again is told so, and a claimed join still needs a156+ // valid invite.157+ if reason, err := tenant.Publish(ctx, accessRequestEvent(t, testMemberSecret, now, "again"), relay.Session{PubKeys: []string{member}}); err != nil || reason != "duplicate: already a member" {158+ t.Fatalf("member request %q %v", reason, err)159+ }160+ claimed := signedEvent(t, testAskerSecret, event.KIND_NIP43_JOIN, now+2, [][]string{{"-"}, {"claim", "nosuchcode"}}, "")161+ if _, err := tenant.Publish(ctx, claimed, relay.Session{PubKeys: []string{asker}}); err == nil || err.Error() != "invite_invalid" {162+ t.Fatalf("claimed join: %v", err)163+ }164+165+ // Only the owner and moderators review.166+ methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)167+ if err != nil {168+ t.Fatal(err)169+ }170+ for _, name := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {171+ if !containsString(methods.([]string), name) {172+ t.Fatalf("supportedmethods lacks %s", name)173+ }174+ }175+ param := []json.RawMessage{json.RawMessage(strconv.Quote(asker))}176+ for _, actor := range []string{member, asker, strings.Repeat("e", 64)} {177+ for _, method := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {178+ if _, err := tenant.Execute(ctx, actor, method, param); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {179+ t.Fatalf("%s by non-admin: %v", method, err)180+ }181+ }182+ }183+ if rows := joinRequests(t, tenant, moderator); len(rows) != 2 {184+ t.Fatalf("moderator list %+v", rows)185+ }186+187+ // Approval makes a member and the signed records follow.188+ result, err := tenant.Execute(ctx, moderator, "approvejoin", param)189+ if err != nil {190+ t.Fatal(err)191+ }192+ if got := result.(map[string]any); got["status"] != "approved" || got["pubkey"] != asker {193+ t.Fatalf("approvejoin = %v", got)194+ }195+ if role, _ := tenant.community.Role(ctx, asker); role != "member" {196+ t.Fatalf("approved role = %q", role)197+ }198+ waitProjections(t, tenant)199+ if n := recordsNaming(t, tenant, event.KIND_ROSTER, asker); n == 0 {200+ t.Fatal("member list (kind 13534) does not name the approved key")201+ }202+ if n := recordsNaming(t, tenant, event.KIND_MEMBER_ADDED, asker); n == 0 {203+ t.Fatal("no add-user record (kind 8000) for the approved key")204+ }205+ rows = joinRequests(t, tenant, owner)206+ 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 {207+ t.Fatalf("after approval %+v", rows)208+ }209+ if _, err := tenant.Execute(ctx, owner, "denyjoin", []json.RawMessage{json.RawMessage(strconv.Quote(second))}); err != nil {210+ t.Fatal(err)211+ }212+ byKey := map[string]community.JoinRequest{}213+ for _, row := range joinRequests(t, tenant, owner) {214+ byKey[row.PubKey] = row215+ }216+ if len(byKey) != 2 || byKey[second].Status != "denied" || byKey[second].DecidedBy != owner || byKey[asker].Status != "approved" {217+ t.Fatalf("after denial %+v", byKey)218+ }219+ if role, _ := tenant.community.Role(ctx, second); role != "" {220+ t.Fatalf("denied role = %q", role)221+ }222+ audit, _ := tenant.Execute(ctx, owner, "listaudit", nil)223+ seen := map[string]bool{}224+ for _, row := range audit.([]community.AuditRow) {225+ seen[row.Action] = true226+ }227+ if !seen["approvejoin"] || !seen["denyjoin"] {228+ t.Fatalf("audit lacks the decisions: %v", seen)229+ }230+}231+232+func TestMCPJoinRequestTools(t *testing.T) {233+ app, tenant := testTenant(t)234+ ctx := context.Background()235+ asker, _ := event.PublicKey(testAskerSecret)236+ member, _ := event.PublicKey(testMemberSecret)237+ if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {238+ t.Fatal(err)239+ }240+ if _, err := tenant.Publish(ctx, accessRequestEvent(t, testAskerSecret, time.Now().Unix(), "Let me in"), relay.Session{PubKeys: []string{asker}}); err != nil {241+ t.Fatal(err)242+ }243+ _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")244+ names := map[string]bool{}245+ for _, tool := range response.Result.(map[string]any)["tools"].([]any) {246+ names[tool.(map[string]any)["name"].(string)] = true247+ }248+ for _, want := range []string{"list_join_requests", "approve_join", "deny_join"} {249+ if !names[want] {250+ t.Fatalf("missing tool %s", want)251+ }252+ }253+ call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {254+ t.Helper()255+ w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")256+ if w.Code != http.StatusOK {257+ t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())258+ }259+ return mcpToolResult(t, response)260+ }261+ structured := func(result map[string]any) map[string]any {262+ value, _ := result["structuredContent"].(map[string]any)263+ return value264+ }265+ result, isError := call("list_join_requests", map[string]any{}, "")266+ listed, _ := structured(result)["result"].([]any)267+ 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" {268+ t.Fatalf("list_join_requests: %v %v", isError, result)269+ }270+ if _, isError := call("list_join_requests", map[string]any{}, testMemberSecret); !isError {271+ t.Fatal("a member listed access requests")272+ }273+ if _, isError := call("approve_join", map[string]any{"pubkey": asker}, testMemberSecret); !isError {274+ t.Fatal("a member approved an access request")275+ }276+ result, isError = call("deny_join", map[string]any{"pubkey": asker}, "")277+ if isError || structured(result)["result"].(map[string]any)["status"] != "denied" {278+ t.Fatalf("deny_join: %v %v", isError, result)279+ }280+ result, isError = call("approve_join", map[string]any{"pubkey": asker}, "")281+ if isError || structured(result)["result"].(map[string]any)["status"] != "approved" {282+ t.Fatalf("approve_join: %v %v", isError, result)283+ }284+ if role, _ := tenant.community.Role(ctx, asker); role != "member" {285+ t.Fatalf("role after approve_join = %q", role)286+ }287+ llms := httptest.NewRecorder()288+ app.ServeHTTP(llms, httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil))289+ if body := llms.Body.String(); !strings.Contains(body, "list_join_requests") || !strings.Contains(body, "approve_join, deny_join") {290+ t.Fatalf("llms.txt lacks the access request tools: %s", body)291+ }292+}@@ -138,7 +138,7 @@ func publicManagementMethod(method string) bool {
138138 // ManagementMethods is the source-compatible NIP-86 registry. The hosted139139 // lease, trial, fuel, and entitlement controls are intentionally absent.140140 func ManagementMethods() []string {141- 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"}141+ 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"}142142 }143143 144144 func managementRoleAllows(method, role string) bool {@@ -24,7 +24,7 @@ func TestManagementGoldenInventory(t *testing.T) {
2424 params["forkrelay"] = []json.RawMessage{json.RawMessage(`{"name":"fork-golden"}`)}2525 params["transferowner"] = []json.RawMessage{json.RawMessage(`"` + strings.Repeat("1", 64) + `"`)}2626 readMethods := map[string]bool{}27- 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"} {27+ 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"} {2828 readMethods[method] = true2929 }3030 for _, method := range ManagementMethods() {@@ -91,8 +91,8 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
9191 "- Endpoint: "+base+"/mcp accepts POST only. Protocol version "+mcp.Version+", stateless Streamable HTTP, JSON responses.",9292 "- 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+"`.",9393 "- Methods: server/discover, tools/list and tools/call.",94- "- 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.",95- "- 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.",94+ "- 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.",95+ "- 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.",9696 "- 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.",9797 "",9898 "## Files",@@ -133,6 +133,9 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
133133 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) {134134 return t.mcpManage(ctx, call, "listcallbacks")135135 })136+ 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) {137+ return t.mcpManage(ctx, call, "listjoinrequests")138+ })136139 137140 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) {138141 return t.mcpManage(ctx, call, "runjob", call.String("id"))@@ -179,6 +182,12 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
179182 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) {180183 return t.mcpManage(ctx, call, "revokeagent", call.String("agent"))181184 })185+ 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) {186+ return t.mcpManage(ctx, call, "approvejoin", call.String("pubkey"))187+ })188+ 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) {189+ return t.mcpManage(ctx, call, "denyjoin", call.String("pubkey"))190+ })182191 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) {183192 return t.mcpManage(ctx, call, "pauseallagents")184193 })@@ -364,6 +364,9 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
364364 _, saveErr := storage.SaveTx(ctx, tx, e, opts)365365 return saveErr366366 }367+ // reason is the OK message; an access request tells the asker it waits,368+ // and a member asking again is told so.369+ reason := ""367370 room := t.roomScope(e)368371 switch {369372 case room != "" && community.RoomAdminKind(e.Kind):@@ -371,7 +374,14 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
371374 case room != "" && !event.IsEphemeral(e.Kind):372375 err = t.community.HandleRoomMessageTx(ctx, e, persist)373376 case e.Kind == event.KIND_JOIN, e.Kind == event.KIND_LEAVE, e.Kind == event.KIND_NIP43_JOIN, e.Kind == event.KIND_NIP43_LEAVE:374- _, err = t.community.HandleMembershipEventTx(ctx, e, persist)377+ var membership community.MembershipResult378+ membership, err = t.community.HandleMembershipEventTx(ctx, e, persist)379+ if err == nil && (membership.AccessRequest || strings.HasPrefix(membership.Message, "duplicate:")) {380+ reason = membership.Message381+ }382+ if err == nil && membership.NewRequest {383+ t.notifyJoinRequest(ctx, e)384+ }375385 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:376386 _, err = t.community.HandleModerationEventTx(ctx, e, persist)377387 default:@@ -410,7 +420,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
410420 }411421 }412422 outcome = "ok"413- return "", nil423+ return reason, nil414424 }415425 416426 // agentBeforeCommit mirrors agent grants and grant deletions into the agent@@ -527,9 +527,8 @@ func nip43Shape(e event.Event, now int64) error {
527527 return errors.New("invalid: NIP-43 leave needs one protected tag")528528 }529529 }530- if e.Kind == event.KIND_NIP43_JOIN && event.Tag(e, "claim") == "" {531- return errors.New("invalid: NIP-43 join needs a claim tag")532- }530+ // A join without a claim tag is an access request held for the owner's531+ // review, so the claim stays optional.533532 return nil534533 }535534 @@ -36,6 +36,7 @@ func TestNIP43RequestsRequireProtectedShape(t *testing.T) {
3636 tags [][]string3737 }{3838 {name: "join claim", kind: event.KIND_NIP43_JOIN, tags: [][]string{{"-"}, {"claim", "code"}}},39+ {name: "join without claim is an access request", kind: event.KIND_NIP43_JOIN, tags: [][]string{{"-"}}},3940 {name: "leave", kind: event.KIND_NIP43_LEAVE, tags: [][]string{{"-"}}},4041 } {4142 t.Run(tc.name, func(t *testing.T) {@@ -52,12 +53,13 @@ func TestNIP43RequestsRequireProtectedShape(t *testing.T) {
5253 name string5354 kind int5455 tags [][]string56+ at int645557 }{56- {name: "join without claim", kind: event.KIND_NIP43_JOIN, tags: [][]string{{"-"}}},57- {name: "leave without protection", kind: event.KIND_NIP43_LEAVE, tags: [][]string{}},58+ {name: "join outside the time window", kind: event.KIND_NIP43_JOIN, tags: [][]string{{"-"}}, at: 1000},59+ {name: "leave without protection", kind: event.KIND_NIP43_LEAVE, tags: [][]string{}, at: 10},5860 } {5961 t.Run(tc.name, func(t *testing.T) {60- e := event.Event{CreatedAt: 10, Kind: tc.kind, Tags: tc.tags}62+ e := event.Event{CreatedAt: tc.at, Kind: tc.kind, Tags: tc.tags}6163 if err := event.Sign(&e, strings.Repeat("1", 64)); err != nil {6264 t.Fatal(err)6365 }@@ -911,6 +911,10 @@ func knownProtocolReason(reason string) bool {
911911 }912912 913913 func accessEvent(e event.Event) bool {914+ if e.Kind == event.KIND_NIP43_JOIN && event.Tag(e, "claim") == "" {915+ // An access request waits for review and changes nobody's access.916+ return false917+ }914918 switch e.Kind {915919 case event.KIND_REPORT, event.KIND_VANISH, event.KIND_JOIN, event.KIND_LEAVE,916920 event.KIND_NIP43_JOIN, event.KIND_NIP43_LEAVE, event.KIND_PUT_USER,