Files
pad/internal/server/handlers_collab.go
T
xarmian 51cd6e84e4 fix(collab): op-id durable fence for restore-rollback vs applier-ack race (BUG-2276 residual 2)
Closes the restore-rollback vs applier-ack clobber race with a durable operation-id correlation instead of a timing heuristic. The client brackets its setContent with an applier_apply_start{request_id} control frame; the server decides whether the external write persisted by reading the per-conn op-log high-water UNDER the same appendMu that sets the restore freeze (finalize-at-freeze — no drain, so a blocked write can't stall the restore; no timing window). Edges handled: unanchored conns are never elected; gate admission spans registration; legacy (pre-bracket) clients negotiate capability and an unconfirmable legacy round-trip returns a retryable 409 applier_ambiguous (fail-safe, never a clobber); the applier callback is synchronous-by-type so nothing can split the bracket. Normal acks stay on a lock-free, latency-identical fast path.

Confirming Codex (high effort): redesigned from a timing grace after review; 3 rounds on the op-id design (2 P1 -> 3 P1+P2 -> CLEAN/converging). E2E + Go(PostgreSQL) green; go test -race clean 8x. Go/Web CI red only on the pre-existing dependency advisories (BUG-2278).

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 20:51:19 -04:00

854 lines
34 KiB
Go

package server
import (
"errors"
"log/slog"
"math/rand"
"net/http"
"strconv"
"time"
"github.com/PerpetualSoftware/pad/internal/collab"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
// distantFuture is used as a "prune everything" cutoff for the
// item_yjs_updates table. PruneYjsUpdatesBefore takes a strict-less-
// than cutoff, so passing a far-future time sweeps the whole row set.
var distantFuture = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
// collabMembershipRevalInterval is how often an active collab WS
// re-runs authorizeCollabAccess to catch a mid-stream revocation
// (member removed, role demoted, item-grant revoked, etc.). 60s
// matches the SSE membership-revalidation cadence and trades
// "promptness of revocation visibility" against "per-conn DB
// load". Exposed as a package var so tests can shrink it without
// waiting a real minute.
var collabMembershipRevalInterval = 60 * time.Second
// collabUpgrader is the gorilla/websocket Upgrader used by handleCollab.
//
// CheckOrigin defaults: gorilla returns true when the Origin header is
// absent OR when Origin's host equals Request.Host. The pad web UI is
// served by this Go binary, so production traffic is always
// same-origin and the default policy is exactly what we want — no
// extra CORS-style allow-list to keep in sync with the SSE handler.
//
// Buffer sizes left at 4 KiB (gorilla's default) — Yjs binary updates
// produced by typical keystroke-rate edits fit comfortably; large
// initial sync messages (full-document state) get fragmented across
// reads automatically.
var collabUpgrader = websocket.Upgrader{}
// collabMaxMessageBytes caps the size of a single WebSocket message the
// server will accept on a collab connection. Without a cap, an
// authenticated client could send an arbitrarily large frame and force
// the server to buffer it before ReadMessage returns — the auth
// middleware's HTTP body limit no longer applies once the connection
// is upgraded.
//
// 1 MiB is generous for everyday Yjs ops (keystroke-rate updates are
// in the tens-of-bytes range) and still big enough to absorb a full
// initial-sync state for a typical document. If a future workload
// needs more headroom (e.g. very large Y.Doc snapshots), bump this
// alongside any matching CLAUDE.md note.
const collabMaxMessageBytes = 1 << 20 // 1 MiB
// handleCollab is the WebSocket entry point for real-time collab on a
// single item.
//
// GET /api/v1/collab/{itemID}
//
// Auth + access checks run BEFORE the protocol upgrade (they need to
// be able to write a JSON error response). Once upgraded, this
// handler is intentionally bare — the room manager (TASK-1255) is the
// piece that wires reads + the OpBus together. For now the handler
// just spins up the connection, logs it, drains incoming frames, and
// closes cleanly when the client disconnects. That's enough surface
// area to validate the auth path end-to-end without coupling to
// in-flight room-manager work.
//
// Authorisation re-creates the workspace-access logic from
// RequireWorkspaceAccess but keyed on the item's workspace ID rather
// than a {slug} path param — the WebSocket URL takes only itemID. We
// also re-check freshness of the user (via store.GetUser) so a
// mid-session admin demotion or member removal closes the upgrade
// path immediately, mirroring sseSubscriberStillHasAccess. The
// periodic per-connection revalidation lives in TASK-1256.
func (s *Server) handleCollab(w http.ResponseWriter, r *http.Request) {
itemID := chi.URLParam(r, "itemID")
if itemID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "itemID is required")
return
}
item, err := s.store.GetItem(itemID)
if err != nil {
writeInternalError(w, err)
return
}
if item == nil {
// 404 — same surface as any other item-not-found path.
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
access, err := s.authorizeCollabAccess(r, item)
if err != nil {
var sErr *statusError
if errors.As(err, &sErr) {
writeError(w, sErr.code, sErr.kind, sErr.message)
return
}
writeInternalError(w, err)
return
}
// Upgrade. After this returns successfully w/r are hijacked — we
// MUST NOT touch them; only conn.WriteMessage / conn.Close.
if s.collab == nil {
// RoomManager wiring is optional — a self-host build that
// doesn't enable collab still exposes the route but should
// fail loud rather than silently accepting the upgrade and
// dropping every byte. 503 mirrors the SSE handler's
// "events bus not configured" path.
writeError(w, http.StatusServiceUnavailable, "unavailable",
"Collaboration is not available on this server")
return
}
// Schema-version handshake (TASK-1268, PLAN-1248). The client
// announces its SCHEMA_VERSION via `?schema_version=...`; if it
// doesn't match the server's current value we reject the upgrade
// outright. Admitting a mismatched client and silently letting it
// stamp old (or future) ops onto the op-log would corrupt the
// rebuild flow's ability to detect mismatches — the server's
// stamp is supposed to mark the era of every persisted row.
//
// The empty-query path is treated as legacy-compatible "version
// 1" rather than a hard error so older bundles served from a
// browser cache during a deploy don't fail with cryptic 400s
// before the user has a chance to refresh. The compatibility
// shim only covers v1 — once we ever bump past it, missing
// schema_version is rejected.
clientSchemaVersion := r.URL.Query().Get("schema_version")
if clientSchemaVersion == "" {
clientSchemaVersion = "1"
}
if clientSchemaVersion != s.collab.SchemaVersion() {
writeError(w, http.StatusBadRequest, "schema_mismatch",
"This editor is incompatible with the server. Please refresh the page.")
return
}
// `?since=<id>` is the resume-cursor announce (TASK-1319). The
// client tells us the highest item_yjs_updates.id its local
// Y.Doc has applied; if that id is below the current MIN, the
// expected suffix has been pruned and the room manager sends a
// `force_refresh` JSON control frame after the upgrade (we need
// the conn to write the JSON, hence post-upgrade). Empty / blank
// / unparseable values are tolerated as 0 (treat as fresh
// client) so older bundles served from a browser cache during a
// deploy still get a working session.
var sinceID int64
if raw := r.URL.Query().Get("since"); raw != "" {
if v, perr := strconv.ParseInt(raw, 10, 64); perr == nil && v > 0 {
sinceID = v
}
}
// `?content_seq=<seq>` is the items.content generation the client's Y.Doc
// was SEEDED from (BUG-2264). If it predates the item's most recent restore,
// Join force_refreshes the client before its on-open Y.encodeStateAsUpdate
// can re-push the stale pre-restore document. Empty / blank / unparseable
// values are tolerated as 0 (older bundles that don't announce it fall
// through to the legacy resume-cursor behaviour with no regression).
var contentSeq int64
if raw := r.URL.Query().Get("content_seq"); raw != "" {
if v, perr := strconv.ParseInt(raw, 10, 64); perr == nil && v > 0 {
contentSeq = v
}
}
// `?applier_bracket=1` announces that this client sends the applier_apply_start
// bracket (BUG-2276 residual 2). Only bracket-capable conns can have their applier
// outcome durably confirmed against a concurrent version restore; the manager
// prefers them when electing and fails a legacy round-trip SAFE (retryable) rather
// than risk a clobber. Absent/other value = legacy (false).
bracketCapable := r.URL.Query().Get("applier_bracket") == "1"
conn, err := collabUpgrader.Upgrade(w, r, nil)
if err != nil {
// Upgrade itself emits the right HTTP status (e.g. 400 on
// missing Sec-WebSocket-Key). Just log and bail.
slog.Warn("collab: websocket upgrade failed",
"item_id", itemID,
"error", err,
)
return
}
defer conn.Close()
// Cap incoming message size before any read to bound server-side
// memory pressure from a misbehaving / malicious peer. ReadMessage
// returns an error when this is exceeded, which our loop handles
// like any other read error (close the connection cleanly).
conn.SetReadLimit(collabMaxMessageBytes)
// Identify the connecting principal in logs. currentUser is nil
// for legacy workspace-scoped API tokens, fresh-install setups,
// and similar non-user callers — leave the field empty in that
// case so log readers can tell the connection came in via a
// non-user path.
var userID string
if u := currentUser(r); u != nil {
userID = u.ID
}
slog.Info("collab: websocket connected",
"item_id", itemID,
"workspace_id", item.WorkspaceID,
"user_id", userID,
"remote_addr", r.RemoteAddr,
)
defer slog.Info("collab: websocket disconnected",
"item_id", itemID,
"user_id", userID,
)
// Periodic auth revalidation: catch member-removed /
// role-demoted / grant-revoked mid-stream and force-close the
// WS. Mirrors handlers_events.go's sseSubscriberStillHasAccess
// pattern but routed through the room manager so the close
// frame goes out under writeMu (no concurrent-write panics
// against the room's writeLoop / replay path).
//
// The loop is gated on `registered` — closed by Join once the conn
// is in the room's conn map — so the first tick's SetConnWritable
// can't race Join's setup and no-op against an unregistered conn,
// which would strand a startup-window demotion (a viewer able to
// write) until a later tick. If Join bails before registering
// (schema/force-refresh/closed), revalDone unblocks the wait so the
// goroutine exits without leaking. Per TASK-265.
revalDone := make(chan struct{})
defer close(revalDone)
registered := make(chan struct{})
go func() {
select {
case <-registered:
s.collabRevalidationLoop(r, item, conn, itemID, userID, revalDone)
case <-revalDone:
}
}()
// Hand the connection to the RoomManager. It owns the
// op-log replay, fan-out, and lifecycle bookkeeping (lazy create
// + grace-TTL reclaim). Returns when the WS closes for any reason.
// access.canWrite gates whether the room persists+rebroadcasts
// this peer's inbound sync frames — a read-only participant
// (viewer / view-only guest) still receives broadcasts but its
// own frames are dropped (TASK-265). The onRegistered callback
// (closes `registered`) fires once the conn is in the room, so
// revalidation only starts after SetConnWritable can find it.
onRegistered := func() { close(registered) }
if err := s.collab.Join(itemID, conn, sinceID, contentSeq, access.canWrite, bracketCapable, onRegistered); err != nil {
// ErrForceRefreshSent is the protocol's normal close-after-
// notify path — the JSON frame is already on the wire and
// the client knows what to do. Don't warn.
if errors.Is(err, collab.ErrForceRefreshSent) {
return
}
// ErrStaleSeedFenceUnavailable is an expected, retryable close (the
// durable stale-seed boundary read failed, so we fail closed without
// admitting the peer; Join already logged it at warn). The deferred
// conn.Close closes the WS so the client reconnects with backoff,
// Y.Doc intact. Don't double-warn. Per BUG-2264 (Codex xhigh).
if errors.Is(err, collab.ErrStaleSeedFenceUnavailable) {
return
}
// Normal closure paths surface here as websocket.CloseError
// values that aren't worth logging. Anything unexpected
// (transport failure, room manager hard error) gets a warn.
if websocket.IsUnexpectedCloseError(err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived,
) {
slog.Warn("collab: websocket session ended unexpectedly",
"item_id", itemID,
"user_id", userID,
"error", err,
)
}
}
}
// collabRevalidationLoop ticks every collabMembershipRevalInterval
// while the WebSocket is open and re-runs authorizeCollabAccess. On
// access loss it sends a close frame with ClosePolicyViolation +
// "Your access to this item was revoked." and closes the conn,
// which propagates through the room manager's read loop and tears
// the session down cleanly.
//
// First fire is jittered across [0, interval) so a fleet of clients
// that all reconnected after a deploy don't synchronise their reval
// ticks and storm the auth path together.
//
// Stops when stop is closed (handler returning), so a finished
// session doesn't leak the goroutine + a long-lived ticker.
func (s *Server) collabRevalidationLoop(
r *http.Request,
item *models.Item,
conn *websocket.Conn,
itemID string,
userID string,
stop <-chan struct{},
) {
interval := collabMembershipRevalInterval
// First-fire jitter: rand.Int63n is fine for spread purposes —
// the security argument doesn't depend on unpredictability.
first := time.Duration(rand.Int63n(int64(interval)))
timer := time.NewTimer(first)
defer timer.Stop()
for {
select {
case <-stop:
return
case <-timer.C:
// Re-fetch the item every tick so a mid-session move
// (item collection changed to one the user can't see)
// or hard-delete is honoured as an access change. The
// snapshot captured at upgrade time isn't enough.
fresh, ferr := s.store.GetItem(itemID)
if ferr != nil {
slog.Warn("collab: revalidation GetItem failed; keeping connection open",
"item_id", itemID,
"user_id", userID,
"error", ferr,
)
timer.Reset(interval)
continue
}
if fresh == nil {
slog.Info("collab: item disappeared mid-stream, closing connection",
"item_id", itemID,
"user_id", userID,
)
s.collab.CloseConn(
itemID, conn,
websocket.ClosePolicyViolation,
"This item is no longer available.",
)
return
}
access, err := s.authorizeCollabAccess(r, fresh)
switch {
case err == nil:
// Still authorised. Push any write-permission change
// to the live connection so the room's per-frame gate
// reflects the current role without a reconnect — e.g.
// an editor demoted to viewer becomes read-only
// (TASK-265), or a viewer promoted to editor gains
// write. This complements the CloseConn path below,
// which only fires when access is lost entirely.
s.collab.SetConnWritable(itemID, conn, access.canWrite)
// Re-arm at the regular cadence; the connect-time
// jitter has already spread the fleet so subsequent
// fires can be evenly spaced.
timer.Reset(interval)
case isAccessDenial(err):
// Real revocation — close the conn.
slog.Info("collab: access revoked mid-stream, closing connection",
"item_id", itemID,
"user_id", userID,
)
s.collab.CloseConn(
itemID, conn,
websocket.ClosePolicyViolation,
"Your access to this item was revoked.",
)
return
default:
// Transient internal error (DB blip on GetUser /
// grant lookup, etc.). Logging at warn so an
// operator notices a sustained pattern, but we
// MUST NOT close the conn — a single failed
// query shouldn't punt every active editor.
// Re-arm and try again on the next tick.
slog.Warn("collab: revalidation error; keeping connection open",
"item_id", itemID,
"user_id", userID,
"error", err,
)
timer.Reset(interval)
}
}
}
}
// isAccessDenial reports whether the given error from
// authorizeCollabAccess represents a real authorization decision
// (member removed, role demoted, item-grant revoked) versus an
// internal / transient error (DB blip on a lookup). Only access
// denials should close the live WebSocket; transient errors must
// fall through so a single failed query doesn't punt every active
// editor in the workspace.
//
// authorizeCollabAccess returns *statusError for every "we
// know they don't have access" branch, and a plain error (without
// the statusError wrap) for store / internal errors. errors.As is
// the canonical way to discriminate.
func isAccessDenial(err error) bool {
var sErr *statusError
return errors.As(err, &sErr)
}
// applyContentViaCollab routes an external content update through a
// connected browser tab via the collab room manager's
// designated-applier protocol. Returns nil when an applier acked
// successfully (caller should suppress the direct items.content
// write); any error means "fall back to direct write".
//
// Errors are categorised + logged at the right level so operators
// can see when degraded paths fire. Returning the error rather than
// silently swallowing lets callers add their own telemetry / metrics
// later without re-deriving the categorisation.
//
// Retries internally when the no-room classification races a fresh
// Join (PruneAndApply returns ErrRoomActiveDuringPrune): the room
// is now active, so we re-call ApplyExternalContent against the
// live peer. Capped at applyContentMaxRetries to prevent runaway
// loops if joins keep landing during the prune attempts.
const applyContentMaxRetries = 3
// directWriteFn is the caller's items.content writer. Invoked only
// on the no-room/no-applier paths, INSIDE the per-item setup lock
// (so a fresh Join cannot replay stale op-log between prune and
// content write).
type directWriteFn func() error
func (s *Server) applyContentViaCollab(r *http.Request, itemID, markdown string, directWrite directWriteFn) error {
if s.collab == nil {
return errors.New("collab not configured")
}
for attempt := 0; attempt < applyContentMaxRetries; attempt++ {
err := s.applyContentViaCollabOnce(r, itemID, markdown, directWrite)
if !errors.Is(err, collab.ErrRoomActiveDuringPrune) {
return err
}
// A fresh Join slipped in during PruneAndApply's check.
// Loop and re-try ApplyExternalContent against the now-
// active room rather than direct-writing past the live
// peer (whose Y.Doc would otherwise outvote the direct
// write on next flush). Per Codex review round 6.
}
slog.Warn("collab: exhausted prune retries; falling back to direct write",
"item_id", itemID,
)
return collab.ErrRoomActiveDuringPrune
}
func (s *Server) applyContentViaCollabOnce(r *http.Request, itemID, markdown string, directWrite directWriteFn) error {
err := s.collab.ApplyExternalContent(itemID, markdown)
switch {
case err == nil:
// Caller suppresses the direct write.
return nil
case errors.Is(err, collab.ErrNoActiveRoom),
errors.Is(err, collab.ErrNoApplierAvailable):
// No live editors — direct write is the right thing. We
// also prune the op-log here: any prior collab state is
// strictly older than the items.content the caller is
// about to write, and replaying it on the next collab
// session would resurrect stale content and silently
// overwrite this update on the next 5s flush. Common
// triggers for this path are (a) CLI/MCP/API updates
// outside any co-edit session, (b) raw-mode toggles that
// destroy the in-tab provider before saving, and (c) raw
// saves that hit the server while the room is still in
// its 60s grace TTL with zero conns (returns
// ErrNoApplierAvailable).
//
// PruneAndApply runs the prune under the per-item setup
// lock so a fresh Join racing in this exact window can't
// load the soon-to-be-pruned op-log under our feet.
// Pruning is safe in both no-conn cases — there are no
// peers in memory whose Y.Doc would diverge.
// ErrAllAppliersTimedOut is intentionally NOT pruned
// because peers may still be alive there.
paErr := s.collab.PruneAndApply(itemID, func() error {
// Prune the (now-stale) op-log. Failure here is logged
// but does NOT block the content write — the prune
// matters for FUTURE collab sessions, while the
// content write is the user's actual intent.
if _, perr := s.store.PruneYjsUpdatesBefore(itemID, distantFuture); perr != nil {
slog.Warn("collab: failed to prune op-log on direct-write fallback",
"item_id", itemID,
"error", perr,
)
}
// Write items.content under the same per-item lock so
// a concurrent Join can't slip in between prune and
// write, replay an empty op-log, then later overwrite
// our fresh write from its stale Y.Doc state. Per
// Codex review round 8.
return directWrite()
})
switch {
case paErr == nil:
// Prune + direct write completed atomically.
// Caller suppresses any subsequent items.content write.
return nil
case errors.Is(paErr, collab.ErrRoomActiveDuringPrune):
// A peer joined between ApplyExternalContent's check
// and PruneAndApply's re-check. Surface the error so
// the outer retry loop in applyContentViaCollab
// re-routes through the now-active applier — direct-
// writing past a live peer would let its Y.Doc
// outvote our update on the next flush. Per Codex
// review round 6.
return paErr
default:
slog.Warn("collab: failed to prune op-log on direct-write fallback",
"item_id", itemID,
"error", paErr,
)
return err
}
case errors.Is(err, collab.ErrAllAppliersTimedOut):
slog.Warn("collab: all designated appliers timed out; falling back to direct items.content write",
"item_id", itemID,
"actor", actorIDFromRequest(r),
)
return err
default:
slog.Warn("collab: applier path failed; falling back to direct items.content write",
"item_id", itemID,
"error", err,
)
return err
}
}
// actorIDFromRequest returns a non-empty identity string for the
// caller when one is available — user id, token id, or empty. Used
// in slog calls where we want SOMETHING actor-shaped without
// caring about the precise auth path.
func actorIDFromRequest(r *http.Request) string {
if u := currentUser(r); u != nil {
return u.ID
}
if tw := tokenWorkspaceID(r); tw != "" {
return "token-ws:" + tw
}
return ""
}
// statusError lets authorizeCollabAccess return a typed error that
// carries the HTTP status + payload pieces handleCollab should write.
// Keeping it private to this file — a separate utility might emerge
// once another WS handler needs the same shape.
type statusError struct {
code int
kind string
message string
}
func (e *statusError) Error() string { return e.message }
func newStatusError(code int, kind, message string) *statusError {
return &statusError{code: code, kind: kind, message: message}
}
// collabAccess is the positive outcome of authorizeCollabAccess: the
// caller is admitted to the collab room (read / live-view). canWrite
// reports whether the caller may additionally PERSIST inbound Yjs
// frames — false for a workspace viewer or view-only guest, who is
// admitted as a read-only participant (keeps presence + live view,
// but the room drops its inbound sync frames). Threaded into
// RoomManager.Join and refreshed by the periodic revalidation. Per
// TASK-265.
type collabAccess struct {
canWrite bool
}
// collabTokenWriteScopeAllowed reports whether the request's auth
// principal is permitted to WRITE, mirroring REST's per-method token
// scope gate (TokenAuth → tokenScopeAllows). The collab upgrade is a
// GET, so a read-scoped bearer token passes that method check; this
// re-applies the write-capability half so such a token can't persist
// Yjs mutations over the socket. Cookie / CLI-session and fresh-install
// principals carry no token scopes; tokenScopeAllows treats an empty
// scope string as unrestricted, so they are always allowed. Uses
// http.MethodPost as the representative mutating verb (tokenScopeAllows
// only distinguishes read verbs from write verbs). Per TASK-265.
func (s *Server) collabTokenWriteScopeAllowed(r *http.Request) bool {
return tokenScopeAllows(TokenScopesFromContext(r.Context()), http.MethodPost, r.URL.Path)
}
// authorizeCollabAccess mirrors RequireWorkspaceAccess but keyed on
// the item's workspace ID (the WS URL path doesn't carry a workspace
// slug). It checks:
//
// - Fresh install (no users) → grant.
// - Legacy workspace-scoped API token → grant if the token's
// workspace matches the item's workspace.
// - OAuth token allow-list (TASK-953) → reject when the workspace
// isn't on the consented list, even for valid members.
// - Authenticated user → admin OR member OR has guest grants.
// - Anything else → 403 / 401 as appropriate.
//
// On success it returns a collabAccess describing the admission —
// including canWrite, which reports whether the caller may PERSIST
// inbound Yjs frames (mirrors the REST requireEditPermission
// predicate). A non-editor (workspace viewer / view-only guest) is
// admitted read-only (canWrite=false): it keeps live view + presence
// but its sync frames are dropped by the room. On a known denial it
// returns a zero collabAccess + *statusError; store errors surface as
// a non-statusError. TASK-265.
func (s *Server) authorizeCollabAccess(r *http.Request, item *models.Item) (collabAccess, error) {
wsID := item.WorkspaceID
// Workspace lookup is needed for the OAuth-allow-list slug compare
// AND so a "vanished workspace" condition surfaces as 404 rather
// than a confusing 403.
ws, err := s.store.GetWorkspaceByID(wsID)
if err != nil {
return collabAccess{}, err
}
if ws == nil {
return collabAccess{}, newStatusError(http.StatusNotFound, "not_found", "Workspace not found")
}
// OAuth token allow-list gate.
if !tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) {
s.recordMCPAuthzDenial(r, "workspace_not_in_allowlist")
return collabAccess{}, newStatusError(http.StatusForbidden, "permission_denied",
"Token is not authorized for this workspace")
}
// Fresh-install escape hatch. No users yet → no auth at all, so
// the REST surface treats the caller as owner; grant write too —
// EXCEPT a legacy workspace token still carries a scope even on a
// zero-user instance, so a read-scoped token stays read-only here
// too (collabTokenWriteScopeAllowed returns true for the no-token
// anonymous setup caller, whose scopes are empty = unrestricted).
if count, _ := s.store.UserCount(); count == 0 {
return collabAccess{canWrite: s.collabTokenWriteScopeAllowed(r)}, nil
}
// Legacy API token (workspace-scoped, no user context). The REST
// middleware maps a matching workspace-scoped token to the editor
// role, so it may write — but only if the token's SCOPE permits
// writes (a read-scoped token is admitted read-only, mirroring REST).
if tokenWsID := tokenWorkspaceID(r); tokenWsID != "" && currentUser(r) == nil {
if tokenWsID == wsID {
return collabAccess{canWrite: s.collabTokenWriteScopeAllowed(r)}, nil
}
return collabAccess{}, newStatusError(http.StatusForbidden, "forbidden",
"Token not authorized for this workspace")
}
user := currentUser(r)
if user == nil {
return collabAccess{}, newStatusError(http.StatusUnauthorized, "unauthorized",
"Authentication required")
}
// Re-fetch the user fresh so a mid-session role demotion is
// reflected immediately. Mirrors sseSubscriberStillHasAccess.
fresh, err := s.store.GetUser(user.ID)
if err != nil {
return collabAccess{}, err
}
if fresh == nil {
return collabAccess{}, newStatusError(http.StatusForbidden, "forbidden", "User not found")
}
if fresh.IsDisabled() {
return collabAccess{}, newStatusError(http.StatusForbidden, "forbidden", "User is disabled")
}
// PLAN-1933 DR-4: the collab upgrade authorizes then persists
// incoming Yjs frames to item_yjs_updates (room.go) — a content
// mutation reached over a GET, so the /api/v1 method gate can't
// catch it. Reject the upgrade for an unverified cloud user before
// any admin/membership bypass below (the write-lock applies to
// everyone, including an unverified admin). No-op on self-host and
// for verified users via emailUnverifiedBlocked. Uses the freshly
// re-fetched user so an admin force-verify mid-session is honoured.
if s.emailUnverifiedBlocked(fresh) {
return collabAccess{}, newStatusError(http.StatusForbidden, "email_not_verified",
"Verify your email address to edit content.")
}
// Admin platform-role bypass — cookie session auth only (BUG-1616).
// Bearer-borne admin (CLI / PAT / MCP) falls through to the
// membership-only check below. Mirrors RequireWorkspaceAccess,
// which maps a cookie-session admin to the owner role → write.
isBearer := isBearerAuth(r)
if fresh.Role == "admin" && !isBearer {
return collabAccess{canWrite: true}, nil
}
// Workspace-level gate: any access at all? Membership OR guest grants.
// Without this, a logged-in user with no relationship to this
// workspace would silently fall into the item-visibility check below
// and 404, which would leak whether the item exists. Reject with
// 403 first so non-members see the same shape they always have.
member, err := s.store.GetWorkspaceMember(wsID, fresh.ID)
if err != nil {
return collabAccess{}, err
}
hasWorkspaceLevelAccess := member != nil
if !hasWorkspaceLevelAccess {
// Bearer-admin (BUG-1616): membership-only stance. Skip the
// guest-grants fallback exactly like RequireWorkspaceAccess.
if fresh.Role == "admin" && isBearer {
s.recordMCPAuthzDenial(r, "not_a_member")
return collabAccess{}, newStatusError(http.StatusForbidden, "forbidden",
"You are not a member of this workspace")
}
hasGrants, err := s.store.UserHasGrantsInWorkspace(wsID, fresh.ID)
if err != nil {
return collabAccess{}, err
}
hasWorkspaceLevelAccess = hasGrants
}
if !hasWorkspaceLevelAccess {
s.recordMCPAuthzDenial(r, "not_a_member")
return collabAccess{}, newStatusError(http.StatusForbidden, "forbidden",
"You are not a member of this workspace")
}
// Compute the write decision ONCE, up front, mirroring the REST
// edit path (requireEditPermission) EXACTLY. requireEditPermission
// grants an editor/owner MEMBER by role FIRST — short-circuiting
// before any grant lookup — and only falls back to
// ResolveUserPermission for viewers/guests, so that grants can
// OVERRIDE an insufficient base role. We must preserve that order:
// ResolveUserPermission resolves item/collection grants BEFORE
// membership role, so computing canWrite purely from it would let
// an incidental `view` grant on this item/collection wrongly demote
// a legitimate editor/owner to read-only. Members are never role
// "guest" (guests are non-members with grants), so the role check
// here is the analogue of requireEditPermission's
// `role != "guest" && requireRole(r, "editor")`. The visibility
// checks below decide READ admission to the room; canWrite decides
// whether the admitted conn may persist inbound frames. Per TASK-265.
var canWrite bool
if member != nil && roleLevel(member.Role) >= roleLevel("editor") {
canWrite = true
} else {
perm, err := s.store.ResolveUserPermission(wsID, fresh.ID, item.ID, item.CollectionID)
if err != nil {
return collabAccess{}, err
}
canWrite = permissionLevel(perm) >= permissionLevel("edit")
}
// Token write-scope gate (mirror REST): the collab upgrade is a GET,
// so a read-scoped bearer token (PAT / OAuth) sails past the
// method-keyed tokenScopeAllows check in TokenAuth — but it must not
// be able to PERSIST Yjs mutations over the socket. Downgrade to
// read-only when the caller's token scope doesn't permit writes.
// Non-token principals (cookie / CLI session) have empty scopes,
// which map to "unrestricted" → no downgrade. Per TASK-265.
if canWrite {
canWrite = s.collabTokenWriteScopeAllowed(r)
}
// Item-level visibility check. Mirrors requireItemVisible +
// guestResourceFilter without depending on middleware-set request
// context — the WS path doesn't go through RequireWorkspaceAccess.
//
// Two-stage check:
// 1. Coarse: the item's collection must be in the user's
// visible set. VisibleCollectionIDs returns nil for "all"
// access; that's the easy grant. A non-nil slice may include
// collections "anchored" by an item-level grant (so the
// collection appears in the nav even though the user only
// has access to a single item in it) — that's NOT enough to
// grant collab access to other items in the same collection.
// 2. Strict (only when the user has item-level grants): require
// one of (a) full collection grant, (b) member's "specific"
// access list including this collection, (c) item grant on
// THIS exact item. Otherwise the visible-IDs hit was
// anchored by a sibling's grant and we must 404.
//
// Without the strict stage, a guest with `item:A` grant could
// upgrade /api/v1/collab/{B} for a sibling B in the same
// collection — the bug Codex found in round 3.
visibleIDs, err := s.store.VisibleCollectionIDs(wsID, fresh.ID)
if err != nil {
return collabAccess{}, err
}
if visibleIDs == nil {
return collabAccess{canWrite: canWrite}, nil // "all" access
}
collectionIsVisible := false
for _, id := range visibleIDs {
if id == item.CollectionID {
collectionIsVisible = true
break
}
}
if !collectionIsVisible {
return collabAccess{}, newStatusError(http.StatusNotFound, "not_found", "Item not found")
}
// Visible-set hit. If the user has NO item-level grants, the
// visibility came from full collection access (member's "specific"
// access list, or a full collection grant) — grant access to any
// item in the collection.
collGrants, itemGrants, err := s.store.ListUserGrants(wsID, fresh.ID)
if err != nil {
return collabAccess{}, err
}
if len(itemGrants) == 0 {
return collabAccess{canWrite: canWrite}, nil
}
// User has item grants. The visible-set hit may have been anchored
// by a sibling item's grant, so we need a strict check.
// (a) Full collection grant on this collection.
for _, g := range collGrants {
if g.CollectionID == item.CollectionID {
return collabAccess{canWrite: canWrite}, nil
}
}
// (b) Member's "specific" access list including this collection.
// guestResourceFilter only consults this branch for non-guests.
if member != nil {
memberColls, err := s.store.GetMemberCollectionAccess(wsID, fresh.ID)
if err != nil {
return collabAccess{}, err
}
for _, id := range memberColls {
if id == item.CollectionID {
return collabAccess{canWrite: canWrite}, nil
}
}
}
// (c) Item-level grant on this exact item.
for _, g := range itemGrants {
if g.ItemID == item.ID {
return collabAccess{canWrite: canWrite}, nil
}
}
// Visibility was anchored by a sibling grant — 404, mirroring
// requireItemVisible's "don't leak existence" pattern.
return collabAccess{}, newStatusError(http.StatusNotFound, "not_found", "Item not found")
}