Files
2083970b51 MUL-8374: fix: keep private chat content to the session owner (realtime + REST) (#9677)
* MUL-8374: fix(realtime): deliver private chat content only to the session owner

Chat events and chat-task transcript (task:message) frames were fanned out
to every connection in the workspace. Route every chat:* event, and
task:message for chat tasks, to the chat session creator's user scope
instead. Producers stamp the owner on the bus event (from the same
chat_session row they already read); an event without an owner is dropped
rather than widened. Chat-task lifecycle events keep workspace fanout for
agent presence.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): keep chat-task content private on REST task reads

The transcript endpoint now 404s chat tasks for anyone but the chat
session's creator, and the agent run list and workspace task snapshot keep
foreign chat-task rows but blank their result and error.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(realtime): scope private chat delivery to the owner's workspace connections

User-scope delivery reached the chat creator's connections to every
workspace. Those are authorized against their own workspace only, so a
creator removed from the chat's workspace kept receiving its transcript
and reply through a socket to another workspace. Chat content now goes to
a workspace_member scope each connection joins at connect time; it is
re-authorized against the chat's workspace on every write.

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-29 19:13:45 +08:00

50 lines
1.5 KiB
Go

package handler
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// A chat session is creator-private, and so is everything its tasks produce:
// the transcript, the reply in result, and the failure text in error. Task
// lists stay workspace-wide because presence and workload views count every
// run, so they keep a foreign chat task's row and drop only its content.
// redactForeignChatTaskContent clears the content of every chat task in resp
// that viewerID did not start. tasks[i] must be the row behind resp[i]. A chat
// task whose session can no longer be resolved is treated as foreign.
func (h *Handler) redactForeignChatTaskContent(ctx context.Context, viewerID string, tasks []db.AgentTaskQueue, resp []AgentTaskResponse) error {
seen := map[pgtype.UUID]bool{}
var sessionIDs []pgtype.UUID
for _, t := range tasks {
if t.ChatSessionID.Valid && !seen[t.ChatSessionID] {
seen[t.ChatSessionID] = true
sessionIDs = append(sessionIDs, t.ChatSessionID)
}
}
if len(sessionIDs) == 0 {
return nil
}
rows, err := h.Queries.ListChatSessionCreators(ctx, sessionIDs)
if err != nil {
return err
}
owners := make(map[pgtype.UUID]string, len(rows))
for _, row := range rows {
owners[row.ID] = uuidToString(row.CreatorID)
}
for i, t := range tasks {
if !t.ChatSessionID.Valid {
continue
}
if owner, ok := owners[t.ChatSessionID]; ok && owner != "" && owner == viewerID {
continue
}
resp[i].Result = nil
resp[i].Error = nil
}
return nil
}