| 1 | diff --git a/internal/community/actions.go b/internal/community/actions.go |
| 2 | index 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 | } |
| 27 | diff --git a/internal/community/community.go b/internal/community/community.go |
| 28 | index 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 { |
| 97 | diff --git a/internal/community/membership.go b/internal/community/membership.go |
| 98 | index 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") |
| 148 | diff --git a/internal/community/requests.go b/internal/community/requests.go |
| 149 | new file mode 100644 |
| 150 | index 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 | +} |
| 327 | diff --git a/internal/community/requests_test.go b/internal/community/requests_test.go |
| 328 | new file mode 100644 |
| 329 | index 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 | +} |
| 594 | diff --git a/internal/daemon/http.go b/internal/daemon/http.go |
| 595 | index 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 |
| 607 | diff --git a/internal/daemon/join_requests.go b/internal/daemon/join_requests.go |
| 608 | new file mode 100644 |
| 609 | index 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 | +} |
| 671 | diff --git a/internal/daemon/join_requests_test.go b/internal/daemon/join_requests_test.go |
| 672 | new file mode 100644 |
| 673 | index 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 | +} |
| 969 | diff --git a/internal/daemon/management.go b/internal/daemon/management.go |
| 970 | index 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 { |
| 982 | diff --git a/internal/daemon/management_golden_test.go b/internal/daemon/management_golden_test.go |
| 983 | index 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() { |
| 995 | diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go |
| 996 | index 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", |
| 1010 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 1011 | index 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 | }) |
| 1037 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 1038 | index 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 |
| 1076 | diff --git a/internal/gates/gates.go b/internal/gates/gates.go |
| 1077 | index 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 | |
| 1092 | diff --git a/internal/gates/import_test.go b/internal/gates/import_test.go |
| 1093 | index 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 | } |
| 1121 | diff --git a/internal/relay/relay.go b/internal/relay/relay.go |
| 1122 | index 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, |