- commit
- 758f7de874554b1aa08ff5b6ec1537d9d2c179dd
- parent
- d8861e3cd003a6d51cd7a508ad407d692256521d
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 4 | + |
| A | internal/ | 698 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 103 | ++-- |
| M | internal/ | 100 | ++++- |
| M | internal/ | 28 | +- |
| A | internal/ | 95 | ++++ |
| M | internal/ | 2 | +- |
@@ -90,6 +90,7 @@ type Service struct {
9090 store *storage.Store9191 mu sync.RWMutex9292 owner string93+ slug string9394 policy func() policy.Policy9495 }9596 @@ -104,6 +105,9 @@ func New(ctx context.Context, store *storage.Store, owner string) (*Service, err
104105 if _, err := store.DB().ExecContext(ctx, schema+agentSchema); err != nil {105106 return nil, fmt.Errorf("community schema: %w", err)106107 }108+ if _, err := store.DB().ExecContext(ctx, roomSchema); err != nil {109+ return nil, fmt.Errorf("room schema: %w", err)110+ }107111 if err := store.WithTx(ctx, func(tx *sql.Tx) error {108112 _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO community_members(pubkey,role,created_at) VALUES(?,?,?)`, owner, "owner", time.Now().Unix())109113 return err@@ -0,0 +1,698 @@
1+package community2+3+import (4+ "context"5+ "database/sql"6+ "errors"7+ "fmt"8+ "regexp"9+ "strings"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+)13+14+// Rooms are NIP-29 groups that live inside one tenant. The room whose id15+// equals the tenant slug mirrors the tenant roster; every other room keeps16+// its own member list with room-level roles. Membership rows are kept in17+// step with the tenant roster by triggers so every roster path (management18+// calls, invites, NIP-29 and NIP-43 events, owner transfer) is covered.19+const roomSchema = `20+CREATE TABLE IF NOT EXISTS rooms(id TEXT PRIMARY KEY,name TEXT NOT NULL DEFAULT '',about TEXT NOT NULL DEFAULT '',picture TEXT NOT NULL DEFAULT '',access TEXT NOT NULL DEFAULT 'open',created_by TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,event_id TEXT NOT NULL DEFAULT '',deleted_at INTEGER NOT NULL DEFAULT 0);21+CREATE TABLE IF NOT EXISTS room_members(room_id TEXT NOT NULL,pubkey TEXT NOT NULL,role TEXT NOT NULL DEFAULT 'member',added_at INTEGER NOT NULL,PRIMARY KEY(room_id,pubkey));22+CREATE INDEX IF NOT EXISTS room_members_pubkey ON room_members(pubkey);23+CREATE TRIGGER IF NOT EXISTS room_mirror_insert AFTER INSERT ON community_members24+BEGIN25+ INSERT INTO room_members(room_id,pubkey,role,added_at)26+ SELECT value,NEW.pubkey,CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END,NEW.created_at FROM community_meta WHERE key='rooms.slug'27+ ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role;28+END;29+CREATE TRIGGER IF NOT EXISTS room_mirror_update AFTER UPDATE OF role ON community_members30+BEGIN31+ UPDATE room_members SET role=CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END32+ WHERE pubkey=NEW.pubkey AND room_id IN (SELECT value FROM community_meta WHERE key='rooms.slug') AND role<>CASE NEW.role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END;33+END;34+CREATE TRIGGER IF NOT EXISTS room_mirror_delete AFTER DELETE ON community_members35+BEGIN36+ DELETE FROM room_members WHERE pubkey=OLD.pubkey;37+END;38+CREATE TRIGGER IF NOT EXISTS room_member_projection_insert AFTER INSERT ON room_members39+BEGIN40+ INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)41+ VALUES('room-member-'||lower(hex(randomblob(16))),'records-projection','room-member-'||lower(hex(randomblob(16))),'room',json_object('action','room','room',NEW.room_id,'pubkey',NEW.pubkey,'role',NEW.role),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));42+END;43+CREATE TRIGGER IF NOT EXISTS room_member_projection_update AFTER UPDATE OF role ON room_members44+BEGIN45+ INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)46+ VALUES('room-role-'||lower(hex(randomblob(16))),'records-projection','room-role-'||lower(hex(randomblob(16))),'room',json_object('action','room','room',NEW.room_id),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));47+END;48+CREATE TRIGGER IF NOT EXISTS room_member_projection_delete AFTER DELETE ON room_members49+BEGIN50+ INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at)51+ VALUES('room-member-delete-'||lower(hex(randomblob(16))),'records-projection','room-member-delete-'||lower(hex(randomblob(16))),'room',json_object('action','room','room',OLD.room_id,'pubkey',OLD.pubkey,'role',OLD.role,'removed',1),strftime('%s','now'),strftime('%s','now'),strftime('%s','now'));52+END;53+`54+55+const (56+ RoomOpen = "open"57+ RoomMembers = "members"58+59+ RoomRoleOwner = "owner"60+ RoomRoleAdmin = "admin"61+ RoomRoleMember = "member"62+)63+64+// Room is a chat room inside the tenant.65+type Room struct {66+ ID string `json:"id"`67+ Name string `json:"name"`68+ About string `json:"about"`69+ Picture string `json:"picture"`70+ Access string `json:"access"`71+ CreatedBy string `json:"created_by"`72+ CreatedAt int64 `json:"created_at"`73+ EventID string `json:"event_id"`74+ DeletedAt int64 `json:"deleted_at,omitempty"`75+}76+77+// RoomMember is one member row of a room.78+type RoomMember struct {79+ PubKey string `json:"pubkey"`80+ Role string `json:"role"`81+ AddedAt int64 `json:"added_at"`82+}83+84+var roomIDRE = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)85+86+// ValidRoomID reports whether id has the shape of a room id.87+func ValidRoomID(id string) bool { return roomIDRE.MatchString(id) }88+89+// RoomAdminKind reports whether kind is a NIP-29 management event that a90+// room handles itself when it carries the room's h tag.91+func RoomAdminKind(kind int) bool {92+ switch kind {93+ case event.KIND_PUT_USER, event.KIND_REMOVE_USER, event.KIND_EDIT_METADATA, event.KIND_DELETE_EVENT, event.KIND_CREATE_GROUP, event.KIND_DELETE_GROUP, event.KIND_CREATE_INVITE, event.KIND_PINS, event.KIND_JOIN, event.KIND_LEAVE:94+ return true95+ }96+ return false97+}98+99+// RoomNoticeKind reports whether kind is a relay-signed member notice.100+func RoomNoticeKind(kind int) bool { return kind == event.KIND_ROOM_MEMBER_ADDED || kind == event.KIND_ROOM_MEMBER_REMOVED }101+102+// RoomAccessFor maps the tenant read rule onto the slug room's access.103+func RoomAccessFor(reads string) string {104+ if reads == "members" {105+ return RoomMembers106+ }107+ return RoomOpen108+}109+110+// EnsureSlugRoom creates or refreshes the room that stands for the tenant's111+// own group. Its access follows the tenant read rule and its members mirror112+// the tenant roster: the owner is the room owner and moderators are admins.113+// The first mirror is silent so an existing roster does not announce every114+// member as newly added.115+func (s *Service) EnsureSlugRoom(ctx context.Context, slug, access string) error {116+ if !ValidRoomID(slug) {117+ return fmt.Errorf("invalid: room id %q", slug)118+ }119+ if access != RoomMembers {120+ access = RoomOpen121+ }122+ err := s.store.WithTx(ctx, func(tx *sql.Tx) error {123+ if _, err := tx.ExecContext(ctx, `INSERT INTO community_meta(key,value) VALUES('rooms.slug',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, slug); err != nil {124+ return err125+ }126+ var existing string127+ err := tx.QueryRowContext(ctx, `SELECT id FROM rooms WHERE id=?`, slug).Scan(&existing)128+ fresh := errors.Is(err, sql.ErrNoRows)129+ if err != nil && !fresh {130+ return err131+ }132+ if _, err := tx.ExecContext(ctx, `INSERT INTO rooms(id,access,created_by,created_at) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET access=excluded.access,deleted_at=0`, slug, access, s.currentOwner(), now()); err != nil {133+ return err134+ }135+ if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) SELECT ?,pubkey,CASE role WHEN 'owner' THEN 'owner' WHEN 'moderator' THEN 'admin' ELSE 'member' END,created_at FROM community_members WHERE true ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role`, slug); err != nil {136+ return err137+ }138+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey NOT IN (SELECT pubkey FROM community_members)`, slug); err != nil {139+ return err140+ }141+ if fresh {142+ _, err = tx.ExecContext(ctx, `DELETE FROM work_intents WHERE kind='records-projection' AND target='room' AND state='pending' AND json_extract(payload,'$.room')=? AND json_extract(payload,'$.pubkey') IS NOT NULL`, slug)143+ }144+ return err145+ })146+ if err != nil {147+ return err148+ }149+ s.mu.Lock()150+ s.slug = slug151+ s.mu.Unlock()152+ return nil153+}154+155+// SlugRoom returns the id of the room that mirrors the tenant roster.156+func (s *Service) SlugRoom() string {157+ s.mu.RLock()158+ defer s.mu.RUnlock()159+ return s.slug160+}161+162+func (s *Service) currentOwner() string {163+ s.mu.RLock()164+ defer s.mu.RUnlock()165+ return s.owner166+}167+168+// Room returns the room row, including a deleted one, or an empty Room.169+func (s *Service) Room(ctx context.Context, id string) (Room, error) {170+ return scanRoom(s.store.DB().QueryRowContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE id=?`, id))171+}172+173+func roomTx(ctx context.Context, tx *sql.Tx, id string) (Room, error) {174+ return scanRoom(tx.QueryRowContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE id=?`, id))175+}176+177+func scanRoom(row *sql.Row) (Room, error) {178+ var r Room179+ err := row.Scan(&r.ID, &r.Name, &r.About, &r.Picture, &r.Access, &r.CreatedBy, &r.CreatedAt, &r.EventID, &r.DeletedAt)180+ if errors.Is(err, sql.ErrNoRows) {181+ return Room{}, nil182+ }183+ if err != nil {184+ return Room{}, fmt.Errorf("room: %w", err)185+ }186+ return r, nil187+}188+189+// Live reports whether the room exists and has not been deleted.190+func (r Room) Live() bool { return r.ID != "" && r.DeletedAt == 0 }191+192+// Rooms lists the live rooms ordered by id.193+func (s *Service) Rooms(ctx context.Context) ([]Room, error) {194+ rows, err := s.store.DB().QueryContext(ctx, `SELECT id,name,about,picture,access,created_by,created_at,event_id,deleted_at FROM rooms WHERE deleted_at=0 ORDER BY id`)195+ if err != nil {196+ return nil, fmt.Errorf("rooms: %w", err)197+ }198+ defer rows.Close()199+ out := []Room{}200+ for rows.Next() {201+ var r Room202+ if err := rows.Scan(&r.ID, &r.Name, &r.About, &r.Picture, &r.Access, &r.CreatedBy, &r.CreatedAt, &r.EventID, &r.DeletedAt); err != nil {203+ return nil, err204+ }205+ out = append(out, r)206+ }207+ return out, rows.Err()208+}209+210+// RoomMembers lists a room's members, owners and admins first.211+func (s *Service) RoomMembers(ctx context.Context, id string) ([]RoomMember, error) {212+ rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,role,added_at FROM room_members WHERE room_id=? ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,added_at,pubkey`, id)213+ if err != nil {214+ return nil, fmt.Errorf("room members: %w", err)215+ }216+ defer rows.Close()217+ out := []RoomMember{}218+ for rows.Next() {219+ var m RoomMember220+ if err := rows.Scan(&m.PubKey, &m.Role, &m.AddedAt); err != nil {221+ return nil, err222+ }223+ out = append(out, m)224+ }225+ return out, rows.Err()226+}227+228+// RoomMemberCount counts a room's members.229+func (s *Service) RoomMemberCount(ctx context.Context, id string) (int, error) {230+ var n int231+ err := s.store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room_id=?`, id).Scan(&n)232+ return n, err233+}234+235+// RoomRole returns the effective role of pubkey in a room: the stored room236+// role, or owner and admin for the tenant owner and moderators, who may237+// moderate every room. It is empty for people outside the room.238+func (s *Service) RoomRole(ctx context.Context, roomID, pubkey string) (string, error) {239+ if pubkey == "" {240+ return "", nil241+ }242+ var role string243+ err := s.store.DB().QueryRowContext(ctx, `SELECT role FROM room_members WHERE room_id=? AND pubkey=?`, roomID, pubkey).Scan(&role)244+ if err != nil && !errors.Is(err, sql.ErrNoRows) {245+ return "", fmt.Errorf("room role: %w", err)246+ }247+ if role != "" {248+ return role, nil249+ }250+ tenantRole, err := s.Role(ctx, pubkey)251+ if err != nil {252+ return "", err253+ }254+ return impliedRoomRole(tenantRole), nil255+}256+257+func impliedRoomRole(tenantRole string) string {258+ switch tenantRole {259+ case "owner":260+ return RoomRoleOwner261+ case "moderator":262+ return RoomRoleAdmin263+ }264+ return ""265+}266+267+func roomRoleTx(ctx context.Context, tx *sql.Tx, roomID, pubkey string) (string, error) {268+ var role string269+ err := tx.QueryRowContext(ctx, `SELECT role FROM room_members WHERE room_id=? AND pubkey=?`, roomID, pubkey).Scan(&role)270+ if errors.Is(err, sql.ErrNoRows) {271+ return "", nil272+ }273+ return role, err274+}275+276+func (s *Service) effectiveRoomRoleTx(ctx context.Context, tx *sql.Tx, roomID, pubkey string) (string, error) {277+ role, err := roomRoleTx(ctx, tx, roomID, pubkey)278+ if err != nil || role != "" {279+ return role, err280+ }281+ return impliedRoomRole(s.tenantRoleTx(ctx, tx, pubkey)), nil282+}283+284+func (s *Service) tenantRoleTx(ctx context.Context, tx *sql.Tx, pubkey string) string {285+ if pubkey == s.currentOwner() {286+ return "owner"287+ }288+ var role string289+ _ = tx.QueryRowContext(ctx, `SELECT role FROM community_members WHERE pubkey=?`, pubkey).Scan(&role)290+ return role291+}292+293+func roomAdmin(role string) bool { return role == RoomRoleOwner || role == RoomRoleAdmin }294+295+// RoomWriteAllowed decides whether pubkey may post to a room. Members always296+// may; an open room also accepts tenant members, who join on their first297+// message. The second result reports that such a join is pending.298+func (s *Service) RoomWriteAllowed(ctx context.Context, roomID, pubkey string) (bool, bool, error) {299+ room, err := s.Room(ctx, roomID)300+ if err != nil || !room.Live() {301+ return false, false, err302+ }303+ role, err := s.RoomRole(ctx, roomID, pubkey)304+ if err != nil {305+ return false, false, err306+ }307+ if role != "" {308+ return true, false, nil309+ }310+ if room.Access != RoomOpen {311+ return false, false, nil312+ }313+ member, err := s.IsMember(ctx, pubkey)314+ return member, member, err315+}316+317+// RoomVisible decides whether any of the keys may read a room's events.318+// Open rooms follow the tenant read rule; members-only rooms are limited to319+// their members, the tenant owner and moderators.320+func (s *Service) RoomVisible(ctx context.Context, roomID string, keys []string) (bool, error) {321+ room, err := s.Room(ctx, roomID)322+ if err != nil || !room.Live() {323+ return false, err324+ }325+ if room.Access != RoomMembers {326+ return true, nil327+ }328+ for _, key := range keys {329+ role, err := s.RoomRole(ctx, roomID, key)330+ if err != nil {331+ return false, err332+ }333+ if role != "" {334+ return true, nil335+ }336+ }337+ return false, nil338+}339+340+// HandleRoomMessageTx stores a room message and, for an open room, adds a341+// tenant member to the room on their first message in the same transaction.342+func (s *Service) HandleRoomMessageTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) error {343+ if persist == nil {344+ return errors.New("community: nil event persistence callback")345+ }346+ roomID := tagValue(ev, "h")347+ return s.store.WithTx(ctx, func(tx *sql.Tx) error {348+ room, err := roomTx(ctx, tx, roomID)349+ if err != nil {350+ return err351+ }352+ if !room.Live() {353+ return fmt.Errorf("invalid: unknown room %s", roomID)354+ }355+ role, err := s.effectiveRoomRoleTx(ctx, tx, roomID, ev.PubKey)356+ if err != nil {357+ return err358+ }359+ if role == "" {360+ if room.Access != RoomOpen || s.tenantRoleTx(ctx, tx, ev.PubKey) == "" {361+ return fmt.Errorf("restricted: not a member of room %s", roomID)362+ }363+ if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)`, roomID, ev.PubKey, RoomRoleMember, now()); err != nil {364+ return err365+ }366+ }367+ return persist(tx)368+ })369+}370+371+// HandleRoomEventTx applies a NIP-29 management event addressed to a room372+// other than the slug room and stores it in the same transaction.373+func (s *Service) HandleRoomEventTx(ctx context.Context, ev event.Event, persist func(*sql.Tx) error) (MembershipResult, error) {374+ if persist == nil {375+ return MembershipResult{}, errors.New("community: nil event persistence callback")376+ }377+ if !validPubKey(ev.PubKey) {378+ return MembershipResult{}, errors.New("invalid: event pubkey")379+ }380+ roomID := tagValue(ev, "h")381+ if !ValidRoomID(roomID) {382+ return MembershipResult{}, errors.New("invalid: a room id is 1 to 64 characters of a-z, 0-9, - and _")383+ }384+ if roomID == s.SlugRoom() {385+ return MembershipResult{}, errors.New("invalid: the main room is managed by the relay")386+ }387+ if !RoomAdminKind(ev.Kind) {388+ return MembershipResult{}, fmt.Errorf("unsupported: room event kind %d", ev.Kind)389+ }390+ if ev.Kind == event.KIND_CREATE_INVITE || ev.Kind == event.KIND_PINS {391+ return MembershipResult{}, fmt.Errorf("unsupported: kind %d is not available inside rooms", ev.Kind)392+ }393+ tenantRole, err := s.Role(ctx, ev.PubKey)394+ if err != nil {395+ return MembershipResult{}, err396+ }397+ if tenantRole == "" {398+ return MembershipResult{}, errors.New("restricted: rooms are for members of this relay")399+ }400+ out := MembershipResult{OK: true, Stored: true}401+ err = s.store.WithTx(ctx, func(tx *sql.Tx) error {402+ room, err := roomTx(ctx, tx, roomID)403+ if err != nil {404+ return err405+ }406+ if ev.Kind == event.KIND_CREATE_GROUP {407+ return s.createRoomTx(ctx, tx, ev, room, persist)408+ }409+ if !room.Live() {410+ return fmt.Errorf("invalid: unknown room %s", roomID)411+ }412+ role, err := s.effectiveRoomRoleTx(ctx, tx, roomID, ev.PubKey)413+ if err != nil {414+ return err415+ }416+ switch ev.Kind {417+ case event.KIND_EDIT_METADATA:418+ if !roomAdmin(role) {419+ return errors.New("restricted: only room admins edit the room")420+ }421+ meta := roomMetadata(ev, room)422+ if _, err := tx.ExecContext(ctx, `UPDATE rooms SET name=?,about=?,picture=?,access=? WHERE id=?`, meta.Name, meta.About, meta.Picture, meta.Access, roomID); err != nil {423+ return err424+ }425+ if err := enqueueRoomProjectionTx(ctx, tx, ev.ID, roomID, false); err != nil {426+ return err427+ }428+ case event.KIND_PUT_USER:429+ if !roomAdmin(role) {430+ return errors.New("restricted: only room admins add members")431+ }432+ target, wanted := roomTarget(ev)433+ if !validPubKey(target) {434+ return errors.New("invalid: membership event needs a p tag")435+ }436+ if s.tenantRoleTx(ctx, tx, target) == "" {437+ return errors.New("restricted: the person must be a member of this relay first")438+ }439+ if wanted == RoomRoleOwner && role != RoomRoleOwner {440+ return errors.New("restricted: only a room owner appoints owners")441+ }442+ if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?) ON CONFLICT(room_id,pubkey) DO UPDATE SET role=excluded.role WHERE role<>excluded.role`, roomID, target, wanted, now()); err != nil {443+ return err444+ }445+ case event.KIND_REMOVE_USER:446+ if !roomAdmin(role) {447+ return errors.New("restricted: only room admins remove members")448+ }449+ target, _ := roomTarget(ev)450+ if !validPubKey(target) {451+ return errors.New("invalid: membership event needs a p tag")452+ }453+ current, err := roomRoleTx(ctx, tx, roomID, target)454+ if err != nil {455+ return err456+ }457+ if current == "" {458+ out = MembershipResult{OK: true, Message: "duplicate: not a member", Stored: false}459+ return nil460+ }461+ if current == RoomRoleOwner {462+ if role != RoomRoleOwner {463+ return errors.New("restricted: only a room owner removes an owner")464+ }465+ if err := guardLastOwnerTx(ctx, tx, roomID); err != nil {466+ return err467+ }468+ }469+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey=?`, roomID, target); err != nil {470+ return err471+ }472+ case event.KIND_JOIN:473+ current, err := roomRoleTx(ctx, tx, roomID, ev.PubKey)474+ if err != nil {475+ return err476+ }477+ if current != "" {478+ out = MembershipResult{OK: true, Message: "duplicate: already a member", Stored: false}479+ return nil480+ }481+ if room.Access != RoomOpen {482+ return errors.New("restricted: this room is members-only; ask a room admin to add you")483+ }484+ if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)`, roomID, ev.PubKey, RoomRoleMember, now()); err != nil {485+ return err486+ }487+ case event.KIND_LEAVE:488+ current, err := roomRoleTx(ctx, tx, roomID, ev.PubKey)489+ if err != nil {490+ return err491+ }492+ if current == "" {493+ return errors.New("invalid: not a member of this room")494+ }495+ if current == RoomRoleOwner {496+ if err := guardLastOwnerTx(ctx, tx, roomID); err != nil {497+ return err498+ }499+ }500+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=? AND pubkey=?`, roomID, ev.PubKey); err != nil {501+ return err502+ }503+ case event.KIND_DELETE_EVENT:504+ ids := []string{}505+ for _, tag := range ev.Tags {506+ if len(tag) > 1 && tag[0] == "e" && validPubKey(tag[1]) {507+ ids = append(ids, tag[1])508+ }509+ }510+ if len(ids) == 0 {511+ return errors.New("invalid: delete-event needs an e tag")512+ }513+ for _, id := range ids {514+ var author string515+ err := tx.QueryRowContext(ctx, `SELECT pubkey FROM events WHERE id=? AND EXISTS(SELECT 1 FROM tags WHERE event_id=events.id AND name='h' AND value=?)`, id, roomID).Scan(&author)516+ if errors.Is(err, sql.ErrNoRows) {517+ continue518+ }519+ if err != nil {520+ return err521+ }522+ if !roomAdmin(role) && author != ev.PubKey {523+ return errors.New("restricted: only room admins delete other people's messages")524+ }525+ if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id=?`, id); err != nil {526+ return err527+ }528+ }529+ case event.KIND_DELETE_GROUP:530+ if role != RoomRoleOwner {531+ return errors.New("restricted: only a room owner deletes the room")532+ }533+ if _, err := tx.ExecContext(ctx, `UPDATE rooms SET deleted_at=? WHERE id=?`, now(), roomID); err != nil {534+ return err535+ }536+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=?`, roomID); err != nil {537+ return err538+ }539+ if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id IN (SELECT event_id FROM tags WHERE name='h' AND value=?)`, roomID); err != nil {540+ return err541+ }542+ if err := enqueueRoomProjectionTx(ctx, tx, ev.ID, roomID, true); err != nil {543+ return err544+ }545+ }546+ if err := s.recordTx(ctx, tx, ev.PubKey, roomAction(ev.Kind), roomID, strings.TrimSpace(tagValue(ev, "p"))); err != nil {547+ return err548+ }549+ return persist(tx)550+ })551+ return out, err552+}553+554+func (s *Service) createRoomTx(ctx context.Context, tx *sql.Tx, ev event.Event, room Room, persist func(*sql.Tx) error) error {555+ if room.Live() {556+ return errors.New("duplicate: a room with this id exists")557+ }558+ limit := s.currentPolicy().Rooms559+ if limit <= 0 {560+ return errors.New("restricted: room creation is switched off on this relay")561+ }562+ var count int563+ if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM rooms WHERE deleted_at=0 AND id<>?`, s.SlugRoom()).Scan(&count); err != nil {564+ return err565+ }566+ if count >= limit {567+ return fmt.Errorf("restricted: this relay allows %d rooms", limit)568+ }569+ meta := roomMetadata(ev, Room{ID: tagValue(ev, "h"), Access: RoomOpen})570+ if meta.Name == "" {571+ meta.Name = meta.ID572+ }573+ if _, err := tx.ExecContext(ctx, `INSERT INTO rooms(id,name,about,picture,access,created_by,created_at,event_id,deleted_at) VALUES(?,?,?,?,?,?,?,?,0) ON CONFLICT(id) DO UPDATE SET name=excluded.name,about=excluded.about,picture=excluded.picture,access=excluded.access,created_by=excluded.created_by,created_at=excluded.created_at,event_id=excluded.event_id,deleted_at=0`, meta.ID, meta.Name, meta.About, meta.Picture, meta.Access, ev.PubKey, now(), ev.ID); err != nil {574+ return err575+ }576+ if _, err := tx.ExecContext(ctx, `DELETE FROM room_members WHERE room_id=?`, meta.ID); err != nil {577+ return err578+ }579+ if _, err := tx.ExecContext(ctx, `INSERT INTO room_members(room_id,pubkey,role,added_at) VALUES(?,?,?,?)`, meta.ID, ev.PubKey, RoomRoleOwner, now()); err != nil {580+ return err581+ }582+ if err := s.recordTx(ctx, tx, ev.PubKey, "create-room", meta.ID, meta.Access); err != nil {583+ return err584+ }585+ return persist(tx)586+}587+588+func guardLastOwnerTx(ctx context.Context, tx *sql.Tx, roomID string) error {589+ var owners int590+ if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_members WHERE room_id=? AND role='owner'`, roomID).Scan(&owners); err != nil {591+ return err592+ }593+ if owners <= 1 {594+ return errors.New("restricted: a room keeps at least one owner; appoint another owner first")595+ }596+ return nil597+}598+599+func enqueueRoomProjectionTx(ctx context.Context, tx *sql.Tx, eventID, roomID string, deleted bool) error {600+ payload := fmt.Sprintf(`{"action":"room","room":%q}`, roomID)601+ if deleted {602+ payload = fmt.Sprintf(`{"action":"room","room":%q,"deleted":1}`, roomID)603+ }604+ id := "room-" + roomID + "-" + eventID605+ stamp := now()606+ _, 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, "room", payload, stamp, stamp, stamp)607+ return err608+}609+610+// roomTarget returns the p tag target and the requested room role, which611+// defaults to member.612+func roomTarget(ev event.Event) (string, string) {613+ for _, tag := range ev.Tags {614+ if len(tag) > 1 && tag[0] == "p" {615+ role := RoomRoleMember616+ if len(tag) > 2 {617+ switch strings.ToLower(strings.TrimSpace(tag[2])) {618+ case RoomRoleOwner:619+ role = RoomRoleOwner620+ case RoomRoleAdmin, "moderator":621+ role = RoomRoleAdmin622+ }623+ }624+ return tag[1], role625+ }626+ }627+ return "", RoomRoleMember628+}629+630+// roomMetadata applies the metadata tags of a 9007 or 9002 on top of the631+// current room. Buzz's channel_type and NIP-29's visibility markers both632+// select the access rule.633+func roomMetadata(ev event.Event, current Room) Room {634+ next := current635+ for _, tag := range ev.Tags {636+ if len(tag) == 0 {637+ continue638+ }639+ value := ""640+ if len(tag) > 1 {641+ value = strings.TrimSpace(tag[1])642+ }643+ switch tag[0] {644+ case "name":645+ next.Name = value[:min(200, len(value))]646+ case "about":647+ next.About = value[:min(2000, len(value))]648+ case "picture":649+ next.Picture = value[:min(2000, len(value))]650+ case "visibility", "channel_type", "access":651+ if access, ok := roomAccessValue(value); ok {652+ next.Access = access653+ }654+ case "open", "public":655+ if len(tag) == 1 {656+ next.Access = RoomOpen657+ }658+ case "closed", "private":659+ if len(tag) == 1 {660+ next.Access = RoomMembers661+ }662+ }663+ }664+ if next.Access != RoomMembers {665+ next.Access = RoomOpen666+ }667+ return next668+}669+670+func roomAccessValue(value string) (string, bool) {671+ switch strings.ToLower(value) {672+ case "open", "public":673+ return RoomOpen, true674+ case "members", "members-only", "private", "closed", "invite":675+ return RoomMembers, true676+ }677+ return "", false678+}679+680+func roomAction(kind int) string {681+ switch kind {682+ case event.KIND_EDIT_METADATA:683+ return "edit-room"684+ case event.KIND_PUT_USER:685+ return "room-put-user"686+ case event.KIND_REMOVE_USER:687+ return "room-remove-user"688+ case event.KIND_JOIN:689+ return "room-join"690+ case event.KIND_LEAVE:691+ return "room-leave"692+ case event.KIND_DELETE_EVENT:693+ return "room-delete-event"694+ case event.KIND_DELETE_GROUP:695+ return "delete-room"696+ }697+ return "room"698+}@@ -1,51 +1,60 @@
11 package event22 33 const (4- KIND_PROFILE = 05- KIND_DELETION = 56- KIND_VANISH = 627- KIND_AUTH = 222428- KIND_DM = 149- KIND_WRAP = 105910- KIND_REPORT = 198411- KIND_NOSTR_CONNECT = 2413312- KIND_APP_DATA = 3007813- KIND_VIEW = KIND_APP_DATA14- KIND_PRESENCE = 2007815- KIND_MEMBER_ADDED = 800016- KIND_MEMBER_REMOVED = 800117- KIND_ROSTER = 1353418- KIND_ROLE_DEF = 3353419- KIND_NIP43_JOIN = 2893420- KIND_NIP43_INVITE = 2893521- KIND_NIP43_LEAVE = 2893622- KIND_PUT_USER = 900023- KIND_REMOVE_USER = 900124- KIND_EDIT_METADATA = 900225- KIND_DELETE_EVENT = 900526- KIND_CREATE_GROUP = 900727- KIND_DELETE_GROUP = 900828- KIND_CREATE_INVITE = 900929- KIND_PINS = 901030- KIND_JOIN = 902131- KIND_LEAVE = 902232- KIND_GROUP_METADATA = 3900033- KIND_GROUP_ADMINS = 3900134- KIND_GROUP_MEMBERS = 3900235- KIND_GROUP_ROLES = 3900336- KIND_GROUP_PINS = 3900537- KIND_RELAY_DISCOVERY = 3016638- KIND_SITE = 1512839- KIND_NAMED_SITE = 3512840- KIND_SITE_SNAPSHOT = 512841- KIND_MARMOT_GROUP = 44542- KIND_MARMOT_KEY_PACKAGE = 3044343- KIND_REPO = 3061744- KIND_REPO_STATE = 3061845- KIND_GIT_PATCH = 161746- KIND_GIT_PR = 161847- KIND_GIT_PR_UPDATE = 161948- KIND_GIT_ISSUE = 162149- KIND_PUSH_REGISTRATION = 3039050- KIND_AGENT_GRANT = 303924+ KIND_PROFILE = 05+ KIND_DELETION = 56+ KIND_VANISH = 627+ KIND_AUTH = 222428+ KIND_DM = 149+ KIND_WRAP = 105910+ KIND_REPORT = 198411+ KIND_NOSTR_CONNECT = 2413312+ KIND_APP_DATA = 3007813+ KIND_VIEW = KIND_APP_DATA14+ KIND_PRESENCE = 2007815+ KIND_MEMBER_ADDED = 800016+ KIND_MEMBER_REMOVED = 800117+ KIND_ROSTER = 1353418+ KIND_ROLE_DEF = 3353419+ KIND_NIP43_JOIN = 2893420+ KIND_NIP43_INVITE = 2893521+ KIND_NIP43_LEAVE = 2893622+ KIND_PUT_USER = 900023+ KIND_REMOVE_USER = 900124+ KIND_EDIT_METADATA = 900225+ KIND_DELETE_EVENT = 900526+ KIND_CREATE_GROUP = 900727+ KIND_DELETE_GROUP = 900828+ KIND_CREATE_INVITE = 900929+ KIND_PINS = 901030+ KIND_JOIN = 902131+ KIND_LEAVE = 902232+ KIND_GROUP_METADATA = 3900033+ KIND_GROUP_ADMINS = 3900134+ KIND_GROUP_MEMBERS = 3900235+ KIND_GROUP_ROLES = 3900336+ KIND_GROUP_PINS = 3900537+ KIND_RELAY_DISCOVERY = 3016638+ KIND_SITE = 1512839+ KIND_NAMED_SITE = 3512840+ KIND_SITE_SNAPSHOT = 512841+ KIND_MARMOT_GROUP = 44542+ KIND_MARMOT_KEY_PACKAGE = 3044343+ KIND_REPO = 3061744+ KIND_REPO_STATE = 3061845+ KIND_GIT_PATCH = 161746+ KIND_GIT_PR = 161847+ KIND_GIT_PR_UPDATE = 161948+ KIND_GIT_ISSUE = 162149+ KIND_PUSH_REGISTRATION = 3039050+ KIND_CHAT = 951+ KIND_THREAD = 1152+ KIND_THREAD_REPLY = 1253+ KIND_ROOM_PRESENCE = 2000154+ KIND_ROOM_TYPING = 2000255+ KIND_RICH_CONTENT = 4000256+ KIND_CONTENT_EDIT = 4000357+ KIND_ROOM_MEMBER_ADDED = 4410058+ KIND_ROOM_MEMBER_REMOVED = 4410159+ KIND_AGENT_GRANT = 303925160 )@@ -192,8 +192,8 @@ func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now in
192192 }193193 return nil194194 }195- if tag := event.Tag(e, "h"); tag != "" && tag != g.cfg.Slug && e.Kind != event.KIND_MARMOT_GROUP {196- return fmt.Errorf("blocked: this relay hosts one group: %s", g.cfg.Slug)195+ if err := g.roomWrite(ctx, e); err != nil {196+ return err197197 }198198 if p.Inbox.Targeted && !inboxAdmission(p, e) {199199 return errors.New("blocked: inbox event must target the relay owner")@@ -327,12 +327,100 @@ func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {
327327 return err328328 }329329 }330- if tag := event.Tag(e, "h"); tag != "" && tag != g.cfg.Slug && e.Kind != event.KIND_MARMOT_GROUP {330+ if err := g.roomExists(ctx, e); err != nil {331+ return err332+ }333+ return nil334+}335+336+// roomID names the room an event belongs to, or nothing for events outside337+// the room dialect. Marmot group messages reuse the h tag for other ends.338+func (g *Gate) roomID(e event.Event) string {339+ if e.Kind == event.KIND_MARMOT_GROUP {340+ return ""341+ }342+ return event.Tag(e, "h")343+}344+345+// roomExists is the common room check: an h tag must name the slug room or346+// a live room. Room creation is the one event allowed to name a new room.347+func (g *Gate) roomExists(ctx context.Context, e event.Event) error {348+ id := g.roomID(e)349+ if id == "" || id == g.cfg.Slug || e.Kind == event.KIND_CREATE_GROUP {350+ return nil351+ }352+ if g.cfg.Community == nil {331353 return fmt.Errorf("blocked: this relay hosts one group: %s", g.cfg.Slug)332354 }355+ room, err := g.cfg.Community.Room(ctx, id)356+ if err != nil {357+ return fmt.Errorf("check room: %w", err)358+ }359+ if !room.Live() {360+ return fmt.Errorf("invalid: unknown room %s", id)361+ }333362 return nil334363 }335364 365+// roomWrite applies room membership to client writes. The slug room keeps366+// the tenant's own rules. In other rooms, management events are checked by367+// the room handler; every other event needs room membership, which an open368+// room grants to any tenant member.369+func (g *Gate) roomWrite(ctx context.Context, e event.Event) error {370+ if err := g.roomExists(ctx, e); err != nil {371+ return err372+ }373+ id := g.roomID(e)374+ if id != "" && community.RoomNoticeKind(e.Kind) {375+ return errors.New("blocked: member notices are signed by the relay")376+ }377+ if id == "" || id == g.cfg.Slug || e.Kind == event.KIND_CREATE_GROUP || community.RoomAdminKind(e.Kind) {378+ return nil379+ }380+ allowed, _, err := g.cfg.Community.RoomWriteAllowed(ctx, id, e.PubKey)381+ if err != nil {382+ return fmt.Errorf("check room membership: %w", err)383+ }384+ if !allowed {385+ return fmt.Errorf("restricted: not a member of room %s", id)386+ }387+ return nil388+}389+390+// roomVisible applies room access to reads. Members-only rooms, including391+// the relay's own 39000 to 39002 records for them, are served only to room392+// members, the tenant owner and moderators.393+func (g *Gate) roomVisible(ctx context.Context, e event.Event, s relay.Session) bool {394+ id := g.roomID(e)395+ if id == "" && (e.Kind == event.KIND_GROUP_METADATA || e.Kind == event.KIND_GROUP_ADMINS || e.Kind == event.KIND_GROUP_MEMBERS) {396+ id = event.Tag(e, "d")397+ }398+ if id == "" || id == g.cfg.Slug || g.cfg.Community == nil {399+ return true400+ }401+ visible, err := g.cfg.Community.RoomVisible(ctx, id, s.PubKeys)402+ return err == nil && visible403+}404+405+// membersOnlyRoomFilter reports whether a filter asks for a members-only406+// room, so an unauthenticated subscriber is told to AUTH instead of waiting407+// on an empty stream.408+func (g *Gate) membersOnlyRoomFilter(ctx context.Context, f event.Filter) bool {409+ if g.cfg.Community == nil {410+ return false411+ }412+ for _, id := range f.Tags["h"] {413+ if id == g.cfg.Slug {414+ continue415+ }416+ room, err := g.cfg.Community.Room(ctx, id)417+ if err == nil && room.Live() && room.Access == community.RoomMembers {418+ return true419+ }420+ }421+ return false422+}423+336424 func (g *Gate) kindRules(ctx context.Context, p policy.Policy, kind int) (bool, bool, error) {337425 if g.cfg.Community == nil {338426 return len(p.AllowedKinds) == 0 || containsInt(p.AllowedKinds, kind), containsInt(p.BlockedKinds, kind), nil@@ -528,6 +616,9 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session
528616 privateOnly := true529617 authHint := false530618 for _, f := range filters {619+ if len(a.PubKeys) == 0 && g.membersOnlyRoomFilter(ctx, f) {620+ authHint = true621+ }531622 if len(f.Kinds) == 0 {532623 privateOnly = false533624 authHint = true@@ -589,6 +680,9 @@ func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *ev
589680 if !policy.CanRead(p, e, a) {590681 return false591682 }683+ if !g.roomVisible(ctx, e, s) {684+ return false685+ }592686 if event.IsPrivate(e.Kind) {593687 for _, key := range s.PubKeys {594688 if key == e.PubKey {@@ -121,13 +121,31 @@ type Policy struct {
121121 Inbox Inbox `json:"inbox"`122122 Dumps string `json:"dumps"`123123 DumpsKeep int `json:"dumpsKeep"`124- BlockedKinds []int `json:"-"`125- AllowedKinds []int `json:"-"`126- Retention []RetentionRule `json:"-"`124+ // Rooms caps the chat rooms a tenant may create beside its main group.125+ // Zero switches room creation off.126+ Rooms int `json:"rooms"`127+ BlockedKinds []int `json:"-"`128+ AllowedKinds []int `json:"-"`129+ Retention []RetentionRule `json:"-"`130+}131+132+// DefaultRooms is the room allowance applied when a policy does not name one.133+const DefaultRooms = 64134+135+// UnmarshalJSON keeps the room allowance at its default when a stored policy136+// predates the field, so existing tenants do not lose room creation.137+func (p *Policy) UnmarshalJSON(data []byte) error {138+ type plain Policy139+ decoded := plain{Rooms: DefaultRooms}140+ if err := json.Unmarshal(data, &decoded); err != nil {141+ return err142+ }143+ *p = Policy(decoded)144+ return nil127145 }128146 129147 func Defaults(owner string) Policy {130- return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7,148+ return Policy{Owner: owner, CustomHosts: []CustomHost{}, Writes: "open", Reads: "open", DirectoryPublic: true, MaxFuture: 900, Dumps: "off", DumpsKeep: 7, Rooms: DefaultRooms,131149 Features: Features{Search: "prose", Sync: true, Count: true, Discovery: true, Names: true, Files: true, Pages: true, Signer: true, Sites: Sites{Enabled: true, Mirror: true}},132150 Notify: Notify{}, Views: map[string]string{}, Tags: []string{}, LanguageTags: []string{}, RelayCountries: []string{}, OpenKinds: []int{}, BlockedWords: []string{}, PushCallbacks: []string{}, Delivery: Delivery{}}133151 }@@ -189,7 +207,7 @@ func Validate(p Policy) error {
189207 }190208 }191209 }192- if p.ReportThreshold < 0 || p.MinPow < 0 || p.MaxFuture < 0 || p.MemberInvites.Depth < 0 || p.MemberInvites.Quota < 0 {210+ if p.ReportThreshold < 0 || p.MinPow < 0 || p.MaxFuture < 0 || p.MemberInvites.Depth < 0 || p.MemberInvites.Quota < 0 || p.Rooms < 0 {193211 return errors.New("policy: numeric values cannot be negative")194212 }195213 if p.Succession != nil && (p.Succession.Heir == "" || p.Succession.AfterDays <= 0) {@@ -0,0 +1,95 @@
1+package records2+3+import (4+ "context"5+ "database/sql"6+ "errors"7+ "fmt"8+9+ "github.com/FelineStateMachine/tinyrelay/internal/event"10+)11+12+// PublishRoom emits the relay-signed metadata (39000), admins (39001) and13+// members (39002) records for one room. A room that no longer exists has its14+// records retired instead. The slug room is covered by PublishMembership.15+func (s *Service) PublishRoom(ctx context.Context, roomID string, now int64) ([]event.Event, error) {16+ var name, about, picture, access string17+ var deleted int6418+ err := s.store.DB().QueryRowContext(ctx, `SELECT name,about,picture,access,deleted_at FROM rooms WHERE id=?`, roomID).Scan(&name, &about, &picture, &access, &deleted)19+ if errors.Is(err, sql.ErrNoRows) || (err == nil && deleted > 0) {20+ return nil, s.RetireRoom(ctx, roomID)21+ }22+ if err != nil {23+ return nil, fmt.Errorf("room record: %w", err)24+ }25+ rows, err := s.store.DB().QueryContext(ctx, `SELECT pubkey,role FROM room_members WHERE room_id=? ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,pubkey`, roomID)26+ if err != nil {27+ return nil, fmt.Errorf("room members: %w", err)28+ }29+ defer rows.Close()30+ var admins, members [][]string31+ for rows.Next() {32+ var pk, role string33+ if err := rows.Scan(&pk, &role); err != nil {34+ return nil, err35+ }36+ members = append(members, []string{"p", pk})37+ if role == "owner" || role == "admin" {38+ admins = append(admins, []string{"p", pk, role})39+ }40+ }41+ if err := rows.Err(); err != nil {42+ return nil, err43+ }44+ p := s.policy()45+ if access == "open" && !p.DirectoryPublic {46+ members = nil47+ }48+ meta := [][]string{{"-"}, {"d", roomID}, {"name", name}, {"about", about}}49+ if picture != "" {50+ meta = append(meta, []string{"picture", picture})51+ }52+ if access == "members" {53+ meta = append(meta, []string{"closed"})54+ } else {55+ meta = append(meta, []string{"open"})56+ }57+ if p.Reads == "members" {58+ meta = append(meta, []string{"private"})59+ } else {60+ meta = append(meta, []string{"public"})61+ }62+ vals := []struct {63+ kind int64+ tags [][]string65+ }{66+ {event.KIND_GROUP_METADATA, meta},67+ {event.KIND_GROUP_ADMINS, append([][]string{{"-"}, {"d", roomID}}, admins...)},68+ {event.KIND_GROUP_MEMBERS, append([][]string{{"-"}, {"d", roomID}}, members...)},69+ }70+ out := make([]event.Event, 0, len(vals))71+ for _, v := range vals {72+ e, err := s.signed(ctx, v.kind, v.tags, "", now)73+ if err != nil {74+ return nil, err75+ }76+ out = append(out, e)77+ }78+ return out, nil79+}80+81+// RoomNotice emits the relay-signed member notice a Buzz-compatible client82+// shows when someone is added to (44100) or removed from (44101) a room.83+func (s *Service) RoomNotice(ctx context.Context, roomID, pubkey string, added bool, now int64) (event.Event, error) {84+ kind := event.KIND_ROOM_MEMBER_REMOVED85+ if added {86+ kind = event.KIND_ROOM_MEMBER_ADDED87+ }88+ return s.signed(ctx, kind, [][]string{{"h", roomID}, {"p", pubkey}}, "", now)89+}90+91+// RetireRoom removes the relay's own records for a room that was deleted.92+func (s *Service) RetireRoom(ctx context.Context, roomID string) error {93+ _, err := s.store.DB().ExecContext(ctx, `DELETE FROM events WHERE pubkey=? AND kind IN (?,?,?) AND d=?`, s.PublicKey(), event.KIND_GROUP_METADATA, event.KIND_GROUP_ADMINS, event.KIND_GROUP_MEMBERS, roomID)94+ return err95+}@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
236236 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}237237 }238238 239-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}}239+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}}240240 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}241241 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}242242 var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}}