Files
Warp/server/internal/roomserver/visibility.go
T
tajniak81andClaude Opus 5.5 6aaf554336
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 5: federation
Implements PLAN.md Phase 5: the Server-Server API. Warp joins rooms on
other servers and lets their users join its rooms, invites across servers
with the invitee's server countersigning, rejects remote invites (locally
if the inviter is unreachable), and exchanges events, typing, receipts and
to-device messages with other homeservers — all behind the runtime switch,
which now starts and stops real outbound traffic.

The protocol lives in internal/federation with no knowledge of rooms:
X-Matrix signing (fuzzed parser), the spec's discovery algorithm keeping
the dial address and the TLS name apart, a signed client that refuses
every request while the switch is off or the policy denies the
destination, and a key ring that caches other servers' keys for at most a
week, with optional notaries.

The roomserver takes events from elsewhere through the spec's intake:
signatures and hashes (a hash mismatch keeps the redacted form), the rules
against the event's own auth events, against the state before it — with
/get_missing_events for small gaps and /state_ids for wide ones — and
against the current state, where a failure is a soft failure. Events held
without their state are outliers: a column on roomserver_events, because
the current state of a room joined over federation is almost all
outliers, and excluded from every timeline query. Forks are resolved at
the room's head. Membership across servers is make_join/send_join,
make_leave/send_leave and countersigned invites, in both directions;
m.room.server_acl is enforced on everything another server sends.
History before a remote join is backfilled when /messages pages past it,
stored at negative stream positions.

internal/federationapi is the running component: the inbound endpoints,
and an outbound queue that persists what it owes each destination, sends
it in transactions of 50 PDUs and 100 EDUs, backs off from ten seconds to
six hours, survives restarts, and drops events persisted while federation
is off rather than delivering a backlog in one burst. Its Module is what
the switch starts and stops.

Also: a TLS listener for port 8448, trusted key servers, key validity and
an outbound CA bundle in the config; per-destination health and backoff
reset in the admin API; remote profiles, aliases and directories in the
Client-Server API; a Complement federation profile. See
docs/federation.md, including its known gaps (no partial-state joins, no
key rotation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:14:20 +02:00

242 lines
8.9 KiB
Go

package roomserver
import (
"context"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// VisibleRange is the span of a room's event stream a user may read.
//
// It is a range rather than a yes or no because membership is not a yes or
// no either. A user who has left a room keeps what they saw while they were
// in it and sees nothing after their leave; a user who joined a room whose
// history visibility is `joined` sees nothing from before their join. Both
// are the same question — where does this user's window start and end — and
// answering it once per room is what lets a timeline read be a single
// bounded query rather than a filter applied to every event in it.
type VisibleRange struct {
// Allowed is false when the user may see nothing of the room at all.
Allowed bool
// From is the first position the user may see, exclusive. Zero means
// the start of the room, backfilled history included: positions can be
// negative, so zero is the one value that cannot be a real floor.
From int64
// To is the last position the user may see, inclusive. Zero means the
// top of the stream, and goes on meaning that as the stream grows.
To int64
// Membership is the user's current membership, empty for a user who was
// never in the room.
Membership string
// HistoryVisibility is the room's current history visibility, which the
// bounds above were derived from.
HistoryVisibility string
}
// Contains reports whether a position is inside the range.
func (v VisibleRange) Contains(pos int64) bool {
switch {
case !v.Allowed:
return false
case v.From != 0 && pos <= v.From:
return false
case v.To != 0 && pos > v.To:
return false
}
return true
}
// Clamp narrows a caller's window to what the range allows, reporting
// whether anything is left of it.
//
// The bounds are exclusive at the bottom and inclusive at the top, the same
// as a storage.TimelineRequest, so the result can be handed straight to
// one.
func (v VisibleRange) Clamp(from, to int64) (int64, int64, bool) {
if !v.Allowed {
return 0, 0, false
}
// Zero is unbounded at either end, and every other value — negative
// ones inside backfilled history among them — is a real bound, so the
// narrower of two bounds is not simply the larger or smaller number.
if v.From != 0 && (from == 0 || from < v.From) {
from = v.From
}
if v.To != 0 && (to == 0 || to > v.To) {
to = v.To
}
if from != 0 && to != 0 && from >= to {
return 0, 0, false
}
return from, to, true
}
// VisibleRange works out what a user may read of a room.
//
// The rules are the spec's history visibility:
//
// - world_readable: everything, to anybody, member or not.
// - shared: everything, to anybody who is or has been a member.
// - invited: from the user's invite onwards.
// - joined: from the user's join onwards.
//
// A user who has left or was banned is bounded at their own membership
// event in every case, which is what stops a kicked user's client from
// going on streaming the room. An invitee is told enough to decide — the
// stripped state that travels with the invite — and no timeline.
//
// The approximation here is the room's *current* history visibility. The
// spec judges each event by the visibility in force when it was sent, so a
// room that was once world_readable and is now `joined` hides history here
// that the spec would show, and the reverse shows history it would hide.
// Doing it exactly means resolving the history visibility at every event in
// the window — a state read per event — and belongs with the state storage
// work of PLAN.md open question 1. Until then this follows the setting the
// room's members last agreed on, which is the one they would expect to
// apply.
func (r *API) VisibleRange(ctx context.Context, roomID, userID string) (VisibleRange, error) {
room, rules, err := r.roomAndRules(ctx, roomID)
if err != nil {
return VisibleRange{}, err
}
summary, err := r.store.RoomSummary(ctx, room.ID)
if err != nil {
return VisibleRange{}, err
}
return r.visibleRange(ctx, roomID, rules, userID, summary.HistoryVisibility)
}
// visibleRange is VisibleRange with the room's history visibility already
// in hand, for the callers — a sync over many rooms — that have it.
func (r *API) visibleRange(ctx context.Context, roomID string, rules roomversion.Rules, userID, historyVisibility string) (VisibleRange, error) {
out := VisibleRange{HistoryVisibility: historyVisibility}
changes, err := r.membershipHistory(ctx, roomID, rules, userID)
if err != nil {
return VisibleRange{}, err
}
if len(changes) > 0 {
out.Membership = changes[len(changes)-1].membership
}
worldReadable := historyVisibility == HistoryVisibilityWorldReadable
switch out.Membership {
case event.MembershipJoin:
out.Allowed = true
case event.MembershipLeave, event.MembershipBan:
out.Allowed = true
// Bounded at their own membership event: what they saw stays
// theirs, what happened afterwards does not.
out.To = changes[len(changes)-1].position
default:
// Invited, knocking, or never a member. Only a world-readable room
// has anything to show them.
out.Allowed = worldReadable
}
// World-readable removes the floor — they could read the room as a
// stranger, so there is nothing to gain by hiding it from them as a
// member — but not the ceiling: a leaver's *membership* view still ends
// where they left, and the stranger's view is what /messages on a
// world-readable room gives them anyway.
if !worldReadable && out.Allowed {
switch historyVisibility {
case HistoryVisibilityInvited, HistoryVisibilityJoined:
out.From = spanStart(changes, historyVisibility)
}
}
return out, nil
}
// membershipChange is one of a user's membership events in a room.
type membershipChange struct {
membership string
position int64
}
// membershipHistory reads a user's membership events in a room, oldest
// first.
func (r *API) membershipHistory(ctx context.Context, roomID string, rules roomversion.Rules, userID string) ([]membershipChange, error) {
stored, err := r.store.UserMembershipEvents(ctx, roomID, userID)
if err != nil {
return nil, err
}
out := make([]membershipChange, 0, len(stored))
for _, s := range stored {
e, err := parseEvent(s, rules)
if err != nil {
return nil, err
}
membership, err := e.Membership()
if err != nil {
// A member event with no membership could not have been
// accepted into the room, so this is a corrupted row rather
// than something a client sent. Skipping it would widen the
// window silently.
return nil, fmt.Errorf("member event %s of %s has no membership: %w", s.EventID, roomID, err)
}
out = append(out, membershipChange{membership: membership, position: s.StreamOrdering})
}
return out, nil
}
// spanStart is the floor of the user's current membership span, exclusive.
//
// For `joined` visibility that is the join that began the span they are in
// — or, for a user who has left, the one that began the span they left
// from. For `invited` it is the invite that preceded that join, because an
// invitee is entitled to the room from the moment they were asked.
//
// A user who joined, left and rejoined therefore sees their latest span and
// not the earlier ones, which is what the spec's per-event rule works out
// to for everything after their last join.
func spanStart(changes []membershipChange, historyVisibility string) int64 {
// Walk back to the membership event that opened the current span.
start := len(changes) - 1
for start > 0 && changes[start].membership != event.MembershipJoin {
start--
}
for start > 0 && changes[start-1].membership == event.MembershipJoin {
// Consecutive joins are profile changes, not rejoins; the span
// began at the first of them.
start--
}
if historyVisibility == HistoryVisibilityInvited &&
start > 0 && changes[start-1].membership == event.MembershipInvite {
start--
}
// Exclusive, so one below the event itself: the membership event is the
// first thing the user sees, which is how a client renders "you joined".
return storage.PositionBefore(changes[start].position)
}
// requireReadable refuses a caller who may see nothing of a room, and
// returns the range for one who may.
func (r *API) requireReadable(ctx context.Context, roomID, userID string) (VisibleRange, error) {
visible, err := r.VisibleRange(ctx, roomID, userID)
if err != nil {
return VisibleRange{}, err
}
return visible, visible.refusal(roomID, userID)
}
// refusal turns a range that allows nothing into the error a client is
// given for it.
func (v VisibleRange) refusal(roomID, userID string) error {
if v.Allowed {
return nil
}
if v.Membership == event.MembershipInvite {
return fmt.Errorf("%w: %s has been invited to %s but has not joined it",
ErrForbidden, userID, roomID)
}
return fmt.Errorf("%w: %s is not in %s", ErrForbidden, userID, roomID)
}