tinyrelayfile
tinyrelay/internal/community/membership.go/

internal/community/membership.go raw
1package community
2
3import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "strings"
12 "time"
13
14 "github.com/FelineStateMachine/tinyrelay/internal/event"
15)
16
17// EnqueueProjectionTx records a durable records-projection intent in a caller
18// owned transaction. Root event ingestion should call this before storing the
19// accepted event, so a failed event write rolls back both the projection work
20// and membership side effects. The intent is idempotent by kind/event/target.
21func (s *Service) EnqueueProjectionTx(ctx context.Context, tx *sql.Tx, ev event.Event) error {
22 id := ev.ID
23 if id == "" {
24 sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%s", ev.PubKey, ev.Kind, ev.Content)))
25 id = fmt.Sprintf("community-event-%x", sum[:])
26 }
27 payload, err := json.Marshal(map[string]any{"kind": ev.Kind, "pubkey": ev.PubKey, "id": ev.ID})
28 if err != nil {
29 return err
30 }
31 now := time.Now().Unix()
32 _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, id, "records-projection", id, "membership", string(payload), now, now, now)
33 return err
34}
35
36// EnqueueProjection is the non-composable convenience form. Use
37// EnqueueProjectionTx when event persistence must share the transaction.
38func (s *Service) EnqueueProjection(ctx context.Context, ev event.Event) error {
39 return s.store.WithTx(ctx, func(tx *sql.Tx) error { return s.EnqueueProjectionTx(ctx, tx, ev) })
40}
41
42// HandleProjectionEventTx is the transaction hook for records-owned group
43// metadata and pins (9002 and 9010). The caller applies its records state and
44// stores the accepted event from the same transaction; this package adds the
45// durable projection intent after the side effect succeeds.
46func (s *Service) HandleProjectionEventTx(ctx context.Context, tx *sql.Tx, ev event.Event, sideEffect func(*sql.Tx) error) error {
47 if ev.Kind != event.KIND_EDIT_METADATA && ev.Kind != event.KIND_PINS {
48 return fmt.Errorf("unsupported: projection event kind %d", ev.Kind)
49 }
50 if sideEffect == nil {
51 return errors.New("community: nil projection side effect")
52 }
53 if err := sideEffect(tx); err != nil {
54 return err
55 }
56 return s.EnqueueProjectionTx(ctx, tx, ev)
57}
58
59// MembershipResult describes a NIP-29/NIP-43 management event. Membership
60// events are projected into community tables and are not themselves stored by
61// this package; the relay decides whether to retain or fan them out.
62type MembershipResult struct {
63 OK bool
64 Message string
65 Stored bool
66 // AccessRequest marks a join held for review; Message is its OK reason.
67 AccessRequest bool
68 // NewRequest reports that the request was not pending before, so the
69 // reviewers should be woken. A refresh of a pending request leaves it
70 // false.
71 NewRequest bool
72}
73
74// HandleMembershipEventTx applies join/leave admission in a transaction owned
75// by the service and invokes persist with that transaction. persist owns
76// accepted-event storage; returning an error rolls back membership, the
77// accepted event and queued projection work together.
78func (s *Service) HandleMembershipEventTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) (MembershipResult, error) {
79 if persist == nil {
80 return MembershipResult{}, errors.New("community: nil event persistence callback")
81 }
82 if ev.Kind != event.KIND_JOIN && ev.Kind != event.KIND_NIP43_JOIN && ev.Kind != event.KIND_LEAVE && ev.Kind != event.KIND_NIP43_LEAVE {
83 return MembershipResult{}, fmt.Errorf("unsupported: transactional membership event kind %d", ev.Kind)
84 }
85 var out MembershipResult
86 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
87 var exists bool
88 if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_members WHERE pubkey=?)`, ev.PubKey).Scan(&exists); err != nil {
89 return err
90 }
91 if ev.Kind == event.KIND_LEAVE || ev.Kind == event.KIND_NIP43_LEAVE {
92 if ev.PubKey == s.owner {
93 return errors.New("restricted: the owner cannot leave")
94 }
95 if !exists {
96 if ev.Kind == event.KIND_NIP43_LEAVE {
97 out = MembershipResult{OK: true, Message: "duplicate: not a member", Stored: false}
98 return nil
99 }
100 return errors.New("invalid: not a member")
101 }
102 if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=?`, ev.PubKey); err != nil {
103 return err
104 }
105 out = MembershipResult{OK: true, Message: "info: access revoked", Stored: ev.Kind == event.KIND_LEAVE}
106 } else {
107 if exists {
108 out = MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}
109 return nil
110 }
111 if IsAccessRequest(ev) {
112 created, err := s.recordJoinRequestTx(ctx, tx, ev.PubKey, JoinReason(ev.Content))
113 if err != nil {
114 return err
115 }
116 out = MembershipResult{OK: true, Message: JoinRequestReceived, AccessRequest: true, NewRequest: created}
117 return nil
118 }
119 claim := tagValue(ev, "code")
120 if ev.Kind == event.KIND_NIP43_JOIN {
121 claim = tagValue(ev, "claim")
122 }
123 if claim == "" {
124 if ev.Kind == event.KIND_NIP43_JOIN || s.currentPolicy().Writes != "open" {
125 return errors.New("restricted: a join request needs an invite claim")
126 }
127 if err := checkTerms(s.currentPolicy().JoinTerms, tagValue(ev, "terms")); err != nil {
128 return err
129 }
130 if _, err := tx.ExecContext(ctx, `INSERT INTO community_members(pubkey,role,via,created_at) VALUES(?,?,?,?)`, ev.PubKey, "member", "join", now()); err != nil {
131 return err
132 }
133 } else {
134 var creator string
135 var exp int64
136 var max, uses int
137 if err := tx.QueryRowContext(ctx, `SELECT created_by,expires_at,max_uses,uses FROM community_invites WHERE code=?`, claim).Scan(&creator, &exp, &max, &uses); errors.Is(err, sql.ErrNoRows) {
138 return errors.New("invite_invalid")
139 } else if err != nil {
140 return err
141 }
142 if exp < now() {
143 return errors.New("invite_expired")
144 }
145 if max > 0 && uses >= max {
146 return errors.New("invite_exhausted")
147 }
148 var creatorExists bool
149 if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_members WHERE pubkey=?)`, creator).Scan(&creatorExists); err != nil {
150 return err
151 }
152 if !creatorExists {
153 return errors.New("invite_revoked")
154 }
155 if err := checkTerms(s.currentPolicy().JoinTerms, tagValue(ev, "terms")); err != nil {
156 return err
157 }
158 res, err := tx.ExecContext(ctx, `UPDATE community_invites SET uses=uses+1 WHERE code=? AND (max_uses=0 OR uses<max_uses)`, claim)
159 if err != nil {
160 return err
161 }
162 n, _ := res.RowsAffected()
163 if n != 1 {
164 return errors.New("invite_exhausted")
165 }
166 if _, err := tx.ExecContext(ctx, `INSERT INTO community_members(pubkey,role,invited_by,via,created_at) VALUES(?,?,?,?,?)`, ev.PubKey, "member", creator, "invite "+claim, now()); err != nil {
167 return err
168 }
169 }
170 out = MembershipResult{OK: true, Message: "info: welcome", Stored: ev.Kind == event.KIND_JOIN}
171 }
172 if err := s.EnqueueProjectionTx(ctx, tx, ev); err != nil {
173 return err
174 }
175 if out.Stored {
176 return persist(tx)
177 }
178 return nil
179 })
180 return out, err
181}
182
183// HandleModerationEventTx applies NIP-29 moderation side effects and lets the
184// root store the accepted event in the same transaction. It covers put-user,
185// remove-user, delete-event and create-invite. Metadata and pins are owned by
186// records; roots should call EnqueueProjectionTx from their transaction.
187func (s *Service) HandleModerationEventTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) (MembershipResult, error) {
188 if persist == nil {
189 return MembershipResult{}, errors.New("community: nil event persistence callback")
190 }
191 if ev.Kind != event.KIND_PUT_USER && ev.Kind != event.KIND_REMOVE_USER && ev.Kind != event.KIND_DELETE_EVENT && ev.Kind != event.KIND_CREATE_INVITE {
192 return MembershipResult{}, fmt.Errorf("unsupported: transactional moderation event kind %d", ev.Kind)
193 }
194 role, err := s.Role(ctx, ev.PubKey)
195 if err != nil {
196 return MembershipResult{}, err
197 }
198 if role != "owner" && role != "moderator" && !(ev.Kind == event.KIND_CREATE_INVITE && role == "member") {
199 return MembershipResult{}, errors.New("restricted: not a group admin")
200 }
201 var out MembershipResult
202 err = s.store.WithTx(ctx, func(tx *sql.Tx) error {
203 target := tagValue(ev, "p")
204 switch ev.Kind {
205 case event.KIND_PUT_USER, event.KIND_REMOVE_USER:
206 if !validPubKey(target) {
207 return errors.New("invalid: membership event needs a p tag")
208 }
209 if target == s.owner {
210 return errors.New("invalid: the owner role cannot change")
211 }
212 var old string
213 _ = tx.QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, target).Scan(&old)
214 if old == "moderator" && role != "owner" {
215 return errors.New("restricted: only the owner changes moderators")
216 }
217 if ev.Kind == event.KIND_REMOVE_USER {
218 if _, err := tx.ExecContext(ctx, `DELETE FROM community_members WHERE pubkey=?`, target); err != nil {
219 return err
220 }
221 } else {
222 wantsMod := false
223 for _, tag := range ev.Tags {
224 if len(tag) > 2 && tag[0] == "p" && tag[1] == target && tag[2] == "moderator" {
225 wantsMod = true
226 }
227 }
228 if wantsMod && role != "owner" {
229 return errors.New("restricted: only the owner appoints moderators")
230 }
231 next := "member"
232 if wantsMod {
233 next = "moderator"
234 }
235 if _, err := tx.ExecContext(ctx, `INSERT INTO community_members(pubkey,role,via,created_at) VALUES(?,?,?,?) ON CONFLICT(pubkey) DO UPDATE SET role=excluded.role`, target, next, "put-user", now()); err != nil {
236 return err
237 }
238 }
239 case event.KIND_DELETE_EVENT:
240 ids := []string{}
241 for _, tag := range ev.Tags {
242 if len(tag) > 1 && tag[0] == "e" && validPubKey(tag[1]) {
243 ids = append(ids, tag[1])
244 }
245 }
246 if len(ids) == 0 {
247 return errors.New("invalid: delete-event needs an e tag")
248 }
249 for _, id := range ids {
250 if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id=?`, id); err != nil {
251 return err
252 }
253 }
254 case event.KIND_CREATE_INVITE:
255 if role == "member" {
256 if reason := s.memberInviteGateTx(ctx, tx, ev.PubKey); reason != "" {
257 return errors.New(reason)
258 }
259 }
260 code := tagValue(ev, "code")
261 if code == "" {
262 var e error
263 code, e = token()
264 if e != nil {
265 return e
266 }
267 } else if !codeRE.MatchString(code) {
268 return errors.New("invalid: invite code")
269 }
270 if _, err := tx.ExecContext(ctx, `INSERT INTO community_invites(code,created_by,created_at,expires_at,max_uses,note) VALUES(?,?,?,?,?,?)`, code, ev.PubKey, now(), now()+3*86400, 0, ev.Content[:min(200, len(ev.Content))]); err != nil {
271 return err
272 }
273 }
274 if err := s.EnqueueProjectionTx(ctx, tx, ev); err != nil {
275 return err
276 }
277 out = MembershipResult{OK: true, Stored: true}
278 return persist(tx)
279 })
280 return out, err
281}
282
283// OnMembershipApplied ignores fn. Projection refresh uses durable
284// records-projection work intents.
285func (s *Service) OnMembershipApplied(fn func(event.Event)) {
286 _ = fn
287}
288
289// HandleMembershipEvent applies the NIP-29 group management subset and the
290// NIP-43 join/leave requests. The relay's group id is checked by the caller;
291// this service only owns membership and moderation state.
292func (s *Service) HandleMembershipEvent(ctx context.Context, ev event.Event) (MembershipResult, error) {
293 if ev.PubKey == "" || !validPubKey(ev.PubKey) {
294 return MembershipResult{}, errors.New("invalid: event pubkey")
295 }
296 var result MembershipResult
297 switch ev.Kind {
298 case event.KIND_JOIN, event.KIND_NIP43_JOIN:
299 if IsAccessRequest(ev) {
300 if role, err := s.Role(ctx, ev.PubKey); err != nil {
301 return MembershipResult{}, err
302 } else if role != "" {
303 return MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}, nil
304 }
305 created, err := s.RecordJoinRequest(ctx, ev.PubKey, JoinReason(ev.Content))
306 if err != nil {
307 return MembershipResult{}, err
308 }
309 return MembershipResult{OK: true, Message: JoinRequestReceived, AccessRequest: true, NewRequest: created}, nil
310 }
311 claim := tagValue(ev, "code")
312 if ev.Kind == event.KIND_NIP43_JOIN {
313 claim = tagValue(ev, "claim")
314 }
315 terms := tagValue(ev, "terms")
316 if claim == "" {
317 if ev.Kind == event.KIND_NIP43_JOIN || s.currentPolicy().Writes != "open" {
318 return MembershipResult{}, errors.New("restricted: a join request needs an invite claim")
319 }
320 if s.currentPolicy().JoinTerms != "" {
321 sum := sha256.Sum256([]byte(s.currentPolicy().JoinTerms))
322 if !strings.EqualFold(hex.EncodeToString(sum[:]), terms) {
323 return MembershipResult{}, errors.New("terms_mismatch")
324 }
325 }
326 if _, err := s.setMember(ctx, ev.PubKey, []json.RawMessage{mustJSON(ev.PubKey), mustJSON(map[string]any{"role": "member", "via": "join"})}); err != nil {
327 return MembershipResult{}, err
328 }
329 result = MembershipResult{OK: true, Stored: ev.Kind == event.KIND_JOIN}
330 break
331 }
332 if _, err := s.ClaimWithTerms(ctx, claim, ev.PubKey, terms); err != nil {
333 return MembershipResult{}, err
334 }
335 result = MembershipResult{OK: true, Message: "info: welcome", Stored: ev.Kind == event.KIND_JOIN}
336 case event.KIND_LEAVE, event.KIND_NIP43_LEAVE:
337 if ev.PubKey == s.owner {
338 return MembershipResult{}, errors.New("restricted: the owner cannot leave")
339 }
340 if role, _ := s.Role(ctx, ev.PubKey); role == "" {
341 if ev.Kind == event.KIND_NIP43_LEAVE {
342 return MembershipResult{OK: true, Message: "duplicate: not a member", Stored: false}, nil
343 }
344 return MembershipResult{}, errors.New("invalid: not a member")
345 }
346 res, err := s.removeMember(ctx, ev.PubKey, []json.RawMessage{mustJSON(ev.PubKey)})
347 if err != nil {
348 return MembershipResult{}, err
349 }
350 _ = res
351 result = MembershipResult{OK: true, Message: "info: access revoked", Stored: ev.Kind == event.KIND_LEAVE}
352 case event.KIND_PUT_USER, event.KIND_REMOVE_USER, event.KIND_EDIT_METADATA, event.KIND_DELETE_EVENT, event.KIND_CREATE_INVITE, event.KIND_PINS:
353 result, _ = s.handleModerationEvent(ctx, ev)
354 default:
355 return MembershipResult{}, fmt.Errorf("unsupported: membership event kind %d", ev.Kind)
356 }
357 return result, nil
358}
359
360func tagValue(ev event.Event, name string) string {
361 for _, tag := range ev.Tags {
362 if len(tag) > 1 && tag[0] == name {
363 return tag[1]
364 }
365 }
366 return ""
367}
368
369func checkTerms(terms, supplied string) error {
370 if terms == "" {
371 return nil
372 }
373 sum := sha256.Sum256([]byte(terms))
374 if !strings.EqualFold(hex.EncodeToString(sum[:]), supplied) {
375 return errors.New("terms_mismatch")
376 }
377 return nil
378}
379
380func (s *Service) handleModerationEvent(ctx context.Context, ev event.Event) (MembershipResult, error) {
381 role, err := s.Role(ctx, ev.PubKey)
382 if err != nil {
383 return MembershipResult{}, err
384 }
385 if role != "owner" && role != "moderator" {
386 return MembershipResult{}, errors.New("restricted: not a group admin")
387 }
388 target := tagValue(ev, "p")
389 if ev.Kind == event.KIND_PUT_USER || ev.Kind == event.KIND_REMOVE_USER {
390 if !validPubKey(target) {
391 return MembershipResult{}, errors.New("invalid: membership event needs a p tag")
392 }
393 if target == s.owner {
394 return MembershipResult{}, errors.New("invalid: the owner role cannot change")
395 }
396 old, _ := s.Role(ctx, target)
397 if old == "moderator" && role != "owner" {
398 return MembershipResult{}, errors.New("restricted: only the owner changes moderators")
399 }
400 if ev.Kind == event.KIND_REMOVE_USER {
401 if _, err := s.removeMember(ctx, target, []json.RawMessage{mustJSON(target)}); err != nil {
402 return MembershipResult{}, err
403 }
404 } else {
405 wantsMod := false
406 for _, tag := range ev.Tags {
407 if len(tag) > 2 && tag[0] == "p" && tag[1] == target && tag[2] == "moderator" {
408 wantsMod = true
409 }
410 }
411 if wantsMod && role != "owner" {
412 return MembershipResult{}, errors.New("restricted: only the owner appoints moderators")
413 }
414 memberRole := "member"
415 if wantsMod {
416 memberRole = "moderator"
417 }
418 if _, err := s.setMember(ctx, ev.PubKey, []json.RawMessage{mustJSON(target), mustJSON(map[string]any{"role": memberRole, "via": "put-user"})}); err != nil {
419 return MembershipResult{}, err
420 }
421 }
422 return MembershipResult{OK: true, Stored: true}, nil
423 }
424 if ev.Kind == event.KIND_CREATE_INVITE {
425 _, err := s.createInvite(ctx, ev.PubKey, []json.RawMessage{mustJSON(int64(0)), mustJSON(0), mustJSON(ev.Content)}, false)
426 if err != nil {
427 return MembershipResult{}, err
428 }
429 return MembershipResult{OK: true, Stored: true}, nil
430 }
431 if ev.Kind == event.KIND_DELETE_EVENT {
432 ids := []string{}
433 for _, tag := range ev.Tags {
434 if len(tag) > 1 && tag[0] == "e" && validPubKey(tag[1]) {
435 ids = append(ids, tag[1])
436 }
437 }
438 if len(ids) == 0 {
439 return MembershipResult{}, errors.New("invalid: delete-event needs an e tag")
440 }
441 if err := s.store.WithTx(ctx, func(tx *sql.Tx) error {
442 for _, id := range ids {
443 if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id=?`, id); err != nil {
444 return err
445 }
446 }
447 return s.recordTx(ctx, tx, ev.PubKey, "delete-event", strings.Join(ids, ","), "")
448 }); err != nil {
449 return MembershipResult{}, err
450 }
451 return MembershipResult{OK: true, Stored: true}, nil
452 }
453 if ev.Kind == event.KIND_EDIT_METADATA || ev.Kind == event.KIND_PINS {
454 return MembershipResult{}, errors.New("unsupported: metadata projection is owned by relay root")
455 }
456 return MembershipResult{}, fmt.Errorf("unsupported: membership event kind %d", ev.Kind)
457}
458
459func mustJSON(v any) json.RawMessage {
460 b, _ := json.Marshal(v)
461 return b
462}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs