clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / internal / community / membership.go
membership.go 17.1K
1package community23import (4 "context"5 "crypto/sha256"6 "database/sql"7 "encoding/hex"8 "encoding/json"9 "errors"10 "fmt"11 "strings"12 "time"1314 "github.com/FelineStateMachine/tinyrelay/internal/event"15)1617// EnqueueProjectionTx records a durable records-projection intent in a caller18// owned transaction. Root event ingestion should call this before storing the19// accepted event, so a failed event write rolls back both the projection work20// 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.ID23 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 err30 }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 err34}3536// EnqueueProjection is the non-composable convenience form. Use37// 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}4142// HandleProjectionEventTx is the transaction hook for records-owned group43// metadata and pins (9002 and 9010). The caller applies its records state and44// stores the accepted event from the same transaction; this package adds the45// 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 err55 }56 return s.EnqueueProjectionTx(ctx, tx, ev)57}5859// MembershipResult describes a NIP-29/NIP-43 management event. Membership60// events are projected into community tables and are not themselves stored by61// this package; the relay decides whether to retain or fan them out.62type MembershipResult struct {63 OK bool64 Message string65 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 bool72}7374// HandleMembershipEventTx applies join/leave admission in a transaction owned75// by the service and invokes persist with that transaction. persist owns76// accepted-event storage; returning an error rolls back membership, the77// 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 MembershipResult86 err := s.store.WithTx(ctx, func(tx *sql.Tx) error {87 var exists bool88 if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_members WHERE pubkey=?)`, ev.PubKey).Scan(&exists); err != nil {89 return err90 }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 nil99 }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 err104 }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 nil110 }111 if IsAccessRequest(ev) {112 created, err := s.recordJoinRequestTx(ctx, tx, ev.PubKey, JoinReason(ev.Content))113 if err != nil {114 return err115 }116 out = MembershipResult{OK: true, Message: JoinRequestReceived, AccessRequest: true, NewRequest: created}117 return nil118 }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 err129 }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 err132 }133 } else {134 var creator string135 var exp int64136 var max, uses int137 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 err141 }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 bool149 if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM community_members WHERE pubkey=?)`, creator).Scan(&creatorExists); err != nil {150 return err151 }152 if !creatorExists {153 return errors.New("invite_revoked")154 }155 if err := checkTerms(s.currentPolicy().JoinTerms, tagValue(ev, "terms")); err != nil {156 return err157 }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 err161 }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 err168 }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 err174 }175 if out.Stored {176 return persist(tx)177 }178 return nil179 })180 return out, err181}182183// HandleModerationEventTx applies NIP-29 moderation side effects and lets the184// 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 by186// 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{}, err197 }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 MembershipResult202 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 string213 _ = 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 err220 }221 } else {222 wantsMod := false223 for _, tag := range ev.Tags {224 if len(tag) > 2 && tag[0] == "p" && tag[1] == target && tag[2] == "moderator" {225 wantsMod = true226 }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 err237 }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 err252 }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 error263 code, e = token()264 if e != nil {265 return e266 }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 err272 }273 }274 if err := s.EnqueueProjectionTx(ctx, tx, ev); err != nil {275 return err276 }277 out = MembershipResult{OK: true, Stored: true}278 return persist(tx)279 })280 return out, err281}282283// OnMembershipApplied ignores fn. Projection refresh uses durable284// records-projection work intents.285func (s *Service) OnMembershipApplied(fn func(event.Event)) {286 _ = fn287}288289// HandleMembershipEvent applies the NIP-29 group management subset and the290// 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 MembershipResult297 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{}, err302 } else if role != "" {303 return MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}, nil304 }305 created, err := s.RecordJoinRequest(ctx, ev.PubKey, JoinReason(ev.Content))306 if err != nil {307 return MembershipResult{}, err308 }309 return MembershipResult{OK: true, Message: JoinRequestReceived, AccessRequest: true, NewRequest: created}, nil310 }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{}, err328 }329 result = MembershipResult{OK: true, Stored: ev.Kind == event.KIND_JOIN}330 break331 }332 if _, err := s.ClaimWithTerms(ctx, claim, ev.PubKey, terms); err != nil {333 return MembershipResult{}, err334 }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}, nil343 }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{}, err349 }350 _ = res351 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, nil358}359360func 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}368369func checkTerms(terms, supplied string) error {370 if terms == "" {371 return nil372 }373 sum := sha256.Sum256([]byte(terms))374 if !strings.EqualFold(hex.EncodeToString(sum[:]), supplied) {375 return errors.New("terms_mismatch")376 }377 return nil378}379380func (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{}, err384 }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{}, err403 }404 } else {405 wantsMod := false406 for _, tag := range ev.Tags {407 if len(tag) > 2 && tag[0] == "p" && tag[1] == target && tag[2] == "moderator" {408 wantsMod = true409 }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{}, err420 }421 }422 return MembershipResult{OK: true, Stored: true}, nil423 }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{}, err428 }429 return MembershipResult{OK: true, Stored: true}, nil430 }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 err445 }446 }447 return s.recordTx(ctx, tx, ev.PubKey, "delete-event", strings.Join(ids, ","), "")448 }); err != nil {449 return MembershipResult{}, err450 }451 return MembershipResult{OK: true, Stored: true}, nil452 }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}458459func mustJSON(v any) json.RawMessage {460 b, _ := json.Marshal(v)461 return b462}