Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ce224ee
docs: add spec for large-room post restriction in message-gatekeeper
claude May 7, 2026
71879eb
docs: add implementation plan for large-room post restriction
claude May 7, 2026
6461648
docs(spec+plan): expand bypass to admins and bots, add RoleAdmin cons…
claude May 7, 2026
2c0e81d
feat(model): add backward-compatible Code field to ErrorResponse
claude May 7, 2026
aa70711
feat(natsutil): add MarshalErrorWithCode helper for coded error replies
claude May 7, 2026
b89faf3
feat(message-gatekeeper): add GetRoom to Store interface and MongoStore
claude May 7, 2026
6b9a21e
feat(message-gatekeeper): add LargeRoomThreshold config (no behavior …
claude May 7, 2026
52465f0
feat(model): add RoleAdmin constant (assignment wiring tracked separa…
claude May 7, 2026
4b51f38
feat(message-gatekeeper): add inline isBot helper (duplicates room-se…
claude May 7, 2026
389c284
feat(message-gatekeeper): add canBypassLargeRoomCap predicate
claude May 7, 2026
eb02792
feat(message-gatekeeper): add codedError sentinel and reply dispatch …
claude May 7, 2026
8ac1742
feat(message-gatekeeper): reject non-owner sends in rooms over threshold
claude May 7, 2026
692a0d7
style(message-gatekeeper): re-align Config struct tags after LargeRoo…
claude May 7, 2026
ee6490e
style(message-gatekeeper): goimports fix on handler_test.go
claude May 7, 2026
0e1d004
test(message-gatekeeper): post-review test improvements
claude May 7, 2026
2f40110
refactor(message-gatekeeper): extract codeLargeRoomPostRestricted con…
claude May 7, 2026
f9bb8ac
docs: clarify RoleAdmin dormancy and errLargeRoomPostRestricted scope
claude May 7, 2026
ca558e6
review: address CodeRabbit findings
claude May 8, 2026
7f75f6a
refactor(message-gatekeeper): GetRoom → GetRoomUserCount with projection
claude May 8, 2026
e34b362
fix(history-service): missing imports in mongorepo.RoomRepo wiring
claude May 8, 2026
651fd2e
docs(spec): bring large-room-post-restriction spec in line with what …
claude May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,407 changes: 1,407 additions & 0 deletions docs/superpowers/plans/2026-05-07-large-room-post-restriction.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions history-service/internal/mongorepo/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo"

"github.com/hmchangw/chat/pkg/model"
"github.com/hmchangw/chat/pkg/mongoutil"
)

const roomsCollection = "rooms"

type RoomRepo struct {
rooms *Collection[model.Room]
rooms *mongoutil.Collection[model.Room]
}

func NewRoomRepo(db *mongo.Database) *RoomRepo {
return &RoomRepo{
rooms: NewCollection[model.Room](db.Collection(roomsCollection)),
rooms: mongoutil.NewCollection[model.Room](db.Collection(roomsCollection)),
}
}

Expand All @@ -28,7 +29,7 @@ func NewRoomRepo(db *mongo.Database) *RoomRepo {
func (r *RoomRepo) GetMinUserLastSeenAt(ctx context.Context, roomID string) (*time.Time, error) {
room, err := r.rooms.FindOne(ctx,
bson.M{"_id": roomID},
WithProjection(bson.M{"minUserLastSeenAt": 1, "_id": 0}),
mongoutil.WithProjection(bson.M{"minUserLastSeenAt": 1, "_id": 0}),
)
if err != nil {
return nil, fmt.Errorf("get room %s minUserLastSeenAt: %w", roomID, err)
Expand Down
1 change: 1 addition & 0 deletions history-service/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/hmchangw/chat/history-service/internal/cassrepo"
"github.com/hmchangw/chat/history-service/internal/models"
"github.com/hmchangw/chat/history-service/internal/mongorepo"
pkgmodel "github.com/hmchangw/chat/pkg/model"
"github.com/hmchangw/chat/pkg/mongoutil"
"github.com/hmchangw/chat/pkg/natsrouter"
Expand Down
74 changes: 66 additions & 8 deletions message-gatekeeper/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,24 @@ type publishFunc func(ctx context.Context, msg *nats.Msg, opts ...jetstream.Publ
// Handler processes messages from the MESSAGES stream and validates them
// before publishing to MESSAGES_CANONICAL.
type Handler struct {
store Store
publish publishFunc
reply replyFunc
siteID string
parentFetcher ParentMessageFetcher
store Store
publish publishFunc
reply replyFunc
siteID string
parentFetcher ParentMessageFetcher
largeRoomThreshold int
}

// NewHandler constructs a new Handler with the given dependencies.
func NewHandler(store Store, publish publishFunc, reply replyFunc, siteID string, parentFetcher ParentMessageFetcher) *Handler {
return &Handler{store: store, publish: publish, reply: reply, siteID: siteID, parentFetcher: parentFetcher}
func NewHandler(store Store, publish publishFunc, reply replyFunc, siteID string, parentFetcher ParentMessageFetcher, largeRoomThreshold int) *Handler {
return &Handler{
store: store,
publish: publish,
reply: reply,
siteID: siteID,
parentFetcher: parentFetcher,
largeRoomThreshold: largeRoomThreshold,
}
}

// HandleJetStreamMsg processes a JetStream message from the MESSAGES stream.
Expand All @@ -75,7 +83,7 @@ func (h *Handler) HandleJetStreamMsg(ctx context.Context, msg jetstream.Msg) {
}
} else {
// Validation error: reply with error and ack.
h.sendReply(ctx, account, msg.Data(), natsutil.MarshalError(err.Error()))
h.sendReply(ctx, account, msg.Data(), h.marshalErrorReply(err))
if err := msg.Ack(); err != nil {
slog.Error("failed to ack message", "error", err)
}
Expand Down Expand Up @@ -156,6 +164,30 @@ func (h *Handler) processMessage(ctx context.Context, account, roomID, siteID st
return nil, &infraError{cause: fmt.Errorf("get subscription for user %s in room %s: %w", account, roomID, err)}
}

// Large-room post restriction: in rooms with more than the configured
// threshold of members, only owners, admins, and bots may send top-level
// messages. Thread replies are exempt regardless of room size; bypass-eligible
// senders (owner/admin role, or bot account name) are exempt regardless of
// room size. Both bypasses skip the Room fetch entirely (approach B —
// owner fast-path generalized).
isThreadReply := req.ThreadParentMessageID != ""
if !isThreadReply && !canBypassLargeRoomCap(sub) {
userCount, err := h.store.GetRoomUserCount(ctx, roomID)
if err != nil {
return nil, &infraError{cause: fmt.Errorf("get user count for room %s: %w", roomID, err)}
}
if userCount > h.largeRoomThreshold {
slog.Info("send blocked",
"reason", codeLargeRoomPostRestricted,
"account", account,
"roomID", roomID,
"userCount", userCount,
"threshold", h.largeRoomThreshold,
)
return nil, errLargeRoomPostRestricted
}
}

// Build Message
now := time.Now().UTC()

Expand Down Expand Up @@ -221,3 +253,29 @@ func (h *Handler) resolveQuoteSnapshot(ctx context.Context, account, roomID, sit
return snap, nil
}
}

// canBypassLargeRoomCap reports whether the subscriber is exempt from the
// large-room post restriction. Owners, admins, and bots bypass.
//
// "Bot" is detected by account-name pattern (\.bot$|^p_) — see helper.go.
// This single function is the edit point if/when the bypass policy changes
// (e.g. promoting isBot to a shared package, adding new roles, etc.).
func canBypassLargeRoomCap(sub *model.Subscription) bool {
for _, r := range sub.Roles {
if r == model.RoleOwner || r == model.RoleAdmin {
return true
}
}
return isBot(sub.User.Account)
}

// marshalErrorReply produces the JSON reply payload for a validation error.
// If the error is (or wraps) a *codedError, the reply carries the code;
// otherwise the reply is the legacy uncoded shape.
func (h *Handler) marshalErrorReply(err error) []byte {
var ce *codedError
if errors.As(err, &ce) {
return natsutil.MarshalErrorWithCode(ce.Message, ce.Code)
}
return natsutil.MarshalError(err.Error())
}
Loading
Loading