Files
multica/server/internal/handler/workspace.go
Bohan Jiang 607209de7c fix(avatar): serve avatars through a signed endpoint on private buckets (MUL-5393) (#6088)
* fix(avatar): serve avatars through a signed endpoint on private buckets (MUL-5393)

Avatar uploads persisted the raw storage object URL into `avatar_url`. On a
deployment whose bucket is private and has no public CDN domain (S3 with Block
Public Access, R2, MinIO) that URL is a guaranteed 403 in the browser:
ATTACHMENT_DOWNLOAD_MODE only ever applied to the attachment download
endpoint, so every user / agent / squad / workspace avatar rendered broken
even though the upload itself succeeded.

Resolve at read time instead of at upload time. What is persisted stays the
durable object reference, so nothing with a TTL is ever written to the
database and avatars saved by an older build are fixed without a backfill.
What is served is `/api/avatars/<sig>/<key>`, a stable URL the server resolves
per request through the deployment's existing storage download policy
(presigned redirect, CloudFront-signed redirect, or proxied body).

The endpoint is unauthenticated and the HMAC signature is the credential: the
session cookie is SameSite=Strict, so an auth-gated URL cannot be a native
<img src> from Desktop, a mobile webview, or a split-origin self-hosted web
app. The signature covers the storage key and only image extensions resolve,
so an avatar_url pointed at a private document cannot launder it into a
publicly fetchable URL.

Deployments that already work are untouched: a public CDN domain without
per-request signing, and the local-disk backend whose /uploads/* route is
public, both keep returning the raw URL.

Fixes #6024

Co-authored-by: multica-agent <github@multica.ai>

* fix(avatar): only publish avatar-class objects through the signed endpoint (MUL-5393)

Review found that being able to name a storage object was treated as
permission to publish it. `ownedStorageKey` proved only that a URL came from
this deployment's storage, and every image-shaped key was then signed — while
the avatar update endpoints accepted any raw storage URL. A caller who had
seen a private image attachment's URL could submit it as their own avatar, and
the unauthenticated endpoint would keep re-signing it indefinitely. A user
avatar propagates to every workspace that user belongs to, so the leak crossed
workspace boundaries.

Add the missing authorization rule: an object is serveable as an avatar only
when it is avatar-class — a standalone image upload not attached to an issue,
comment, chat session, chat message, or task. The check resolves the backing
attachment row from the id UploadFile embeds in the object filename, so it
needs no lookup by URL and no new index.

It is enforced on both sides. The write side rejects such a value with 403
before anything is stored; the read side re-checks per request, which is what
makes the guarantee hold for rows written before this existed and revokes the
URL if an object is later bound to a comment or chat.

Scope is the `workspaces/` namespace — the only place that can hold content
belonging to someone other than whoever is setting the avatar, covering both
uploads and channel media ingest. Keys elsewhere (the per-user standalone
namespace, or objects an operator placed in the bucket) stay usable, which
keeps the documented "an explicit avatar_url is preserved" contract intact.

Uploader identity is deliberately not part of the rule: duplicating an agent
legitimately reuses the source agent's avatar object, which a different admin
may have uploaded. Publishing someone else's unbound image would require
knowing its URL, and unbound rows appear in no listing endpoint.

Also clamp the 302's cache lifetime to half the signed URL's own TTL (0 ->
no-store). ATTACHMENT_DOWNLOAD_URL_TTL takes any positive duration, so the
fixed 60s could outlive the target it pointed at on a short-TTL deployment.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:39:18 +08:00

825 lines
26 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/logger"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
var nonAlpha = regexp.MustCompile(`[^a-zA-Z]`)
var workspaceSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// generateIssuePrefix produces a 2-5 char uppercase prefix from a workspace name.
// Examples: "Jiayuan's Workspace" → "JIA", "My Team" → "MYT", "AB" → "AB".
func generateIssuePrefix(name string) string {
letters := nonAlpha.ReplaceAllString(name, "")
if len(letters) == 0 {
return "WS"
}
letters = strings.ToUpper(letters)
if len(letters) > 3 {
letters = letters[:3]
}
return letters
}
type WorkspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
Context *string `json:"context"`
Settings any `json:"settings"`
Repos any `json:"repos"`
IssuePrefix string `json:"issue_prefix"`
AvatarURL *string `json:"avatar_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (h *Handler) workspaceToResponse(w db.Workspace) WorkspaceResponse {
var settings any
if w.Settings != nil {
json.Unmarshal(w.Settings, &settings)
}
if settings == nil {
settings = map[string]any{}
}
var repos any
if w.Repos != nil {
json.Unmarshal(w.Repos, &repos)
}
if repos == nil {
repos = []any{}
}
return WorkspaceResponse{
ID: uuidToString(w.ID),
Name: w.Name,
Slug: w.Slug,
Description: textToPtr(w.Description),
Context: textToPtr(w.Context),
Settings: settings,
Repos: repos,
IssuePrefix: w.IssuePrefix,
AvatarURL: h.resolveAvatarURLPtr(textToPtr(w.AvatarUrl)),
CreatedAt: timestampToString(w.CreatedAt),
UpdatedAt: timestampToString(w.UpdatedAt),
}
}
type MemberResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
}
func memberToResponse(m db.Member) MemberResponse {
return MemberResponse{
ID: uuidToString(m.ID),
WorkspaceID: uuidToString(m.WorkspaceID),
UserID: uuidToString(m.UserID),
Role: m.Role,
CreatedAt: timestampToString(m.CreatedAt),
}
}
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaces, err := h.Queries.ListWorkspaces(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list workspaces")
return
}
resp := make([]WorkspaceResponse, len(workspaces))
for i, ws := range workspaces {
resp[i] = h.workspaceToResponse(ws)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
id := workspaceIDFromURL(r, "id")
idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
if !ok {
return
}
ws, err := h.Queries.GetWorkspace(r.Context(), idUUID)
if err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
return
}
writeJSON(w, http.StatusOK, h.workspaceToResponse(ws))
}
type CreateWorkspaceRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
Context *string `json:"context"`
IssuePrefix *string `json:"issue_prefix"`
}
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Self-host gate (#3433): when the operator has set
// DISABLE_WORKSPACE_CREATION=true, no caller — including existing
// workspace owners — may create additional workspaces. The frontend
// hides every "Create workspace" affordance via /api/config, but the
// 403 here is the only authoritative check.
if h.cfg.DisableWorkspaceCreation {
writeError(w, http.StatusForbidden, "workspace creation is disabled for this instance")
return
}
var req CreateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.Name = strings.TrimSpace(req.Name)
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
if req.Name == "" || req.Slug == "" {
writeError(w, http.StatusBadRequest, "name and slug are required")
return
}
if !workspaceSlugPattern.MatchString(req.Slug) {
writeError(w, http.StatusBadRequest, "slug must contain only lowercase letters, numbers, and hyphens")
return
}
if isReservedSlug(req.Slug) {
writeError(w, http.StatusBadRequest, "slug is reserved")
return
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create workspace")
return
}
defer tx.Rollback(r.Context())
issuePrefix := generateIssuePrefix(req.Name)
if req.IssuePrefix != nil && strings.TrimSpace(*req.IssuePrefix) != "" {
issuePrefix = strings.ToUpper(strings.TrimSpace(*req.IssuePrefix))
}
qtx := h.Queries.WithTx(tx)
ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
Name: req.Name,
Slug: req.Slug,
Description: ptrToText(req.Description),
Context: ptrToText(req.Context),
IssuePrefix: issuePrefix,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "workspace slug already exists")
return
}
writeError(w, http.StatusInternalServerError, "failed to create workspace: "+err.Error())
return
}
_, err = qtx.CreateMember(r.Context(), db.CreateMemberParams{
WorkspaceID: ws.ID,
UserID: parseUUID(userID),
Role: "owner",
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to add owner: "+err.Error())
return
}
// NOTE: CreateWorkspace deliberately does NOT mark the user as
// onboarded. The `onboarded_at` flag is owned by CompleteOnboarding
// (Step 3 of the flow) and by AcceptInvitation (invitee joining an
// existing workspace). This decouples "the user has a workspace"
// from "the user has finished setup"; the workspace-layer route
// gate (web layout / desktop App.tsx overlay) redirects un-onboarded
// users back to /onboarding instead.
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create workspace")
return
}
wsID := uuidToString(ws.ID)
// "Is this the user's first workspace?" is derived in PostHog by looking
// at whether they have a prior workspace_created event, not stamped at
// emit time. Stamping here would race under concurrent creates without
// a schema change, and the event stream answers the question exactly.
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.WorkspaceCreated(userID, wsID))
h.notifyDaemonWorkspacesChanged(userID)
slog.Info("workspace created", append(logger.RequestAttrs(r), "workspace_id", wsID, "name", ws.Name, "slug", ws.Slug)...)
writeJSON(w, http.StatusCreated, h.workspaceToResponse(ws))
}
type UpdateWorkspaceRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Context *string `json:"context"`
Settings any `json:"settings"`
Repos any `json:"repos"`
IssuePrefix *string `json:"issue_prefix"`
AvatarURL *string `json:"avatar_url"`
}
type workspaceRepoRef struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
}
func validateAndNormalizeWorkspaceRepos(value any) ([]byte, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var repos []workspaceRepoRef
if err := json.Unmarshal(raw, &repos); err != nil {
return nil, fmt.Errorf("repos must be an array of repository objects: %w", err)
}
normalized := make([]workspaceRepoRef, 0, len(repos))
seen := make(map[string]struct{}, len(repos))
for i, repo := range repos {
repo.URL = strings.TrimSpace(repo.URL)
repo.Description = strings.TrimSpace(repo.Description)
if repo.URL == "" {
return nil, fmt.Errorf("repos[%d]: url is required", i)
}
if !isValidGitRepoURL(repo.URL) {
return nil, fmt.Errorf("repos[%d]: url must be a valid http(s) or ssh git URL", i)
}
if _, ok := seen[repo.URL]; ok {
continue
}
seen[repo.URL] = struct{}{}
normalized = append(normalized, repo)
}
out, err := json.Marshal(normalized)
if err != nil {
return nil, err
}
return out, nil
}
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
id := workspaceIDFromURL(r, "id")
idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
if !ok {
return
}
var req UpdateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateWorkspaceParams{
ID: idUUID,
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
params.Name = pgtype.Text{String: name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Context != nil {
params.Context = pgtype.Text{String: *req.Context, Valid: true}
}
if req.Settings != nil {
s, _ := json.Marshal(req.Settings)
params.Settings = s
}
if req.Repos != nil {
reposJSON, err := validateAndNormalizeWorkspaceRepos(req.Repos)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
params.Repos = reposJSON
}
if req.IssuePrefix != nil {
prefix := strings.ToUpper(strings.TrimSpace(*req.IssuePrefix))
if prefix != "" {
params.IssuePrefix = pgtype.Text{String: prefix, Valid: true}
}
}
if req.AvatarURL != nil {
// Read the stored value so an unchanged re-send skips revalidation —
// this handler is the one avatar writer that doesn't already have the
// row in hand.
var current string
if existing, err := h.Queries.GetWorkspace(r.Context(), idUUID); err == nil {
current = existing.AvatarUrl.String
}
accepted, ok := h.acceptAvatarURL(w, r, *req.AvatarURL, current)
if !ok {
return
}
params.AvatarUrl = pgtype.Text{String: accepted, Valid: true}
}
ws, err := h.Queries.UpdateWorkspace(r.Context(), params)
if err != nil {
slog.Warn("update workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", id)...)
writeError(w, http.StatusInternalServerError, "failed to update workspace: "+err.Error())
return
}
slog.Info("workspace updated", append(logger.RequestAttrs(r), "workspace_id", id)...)
userID := requestUserID(r)
h.publish(protocol.EventWorkspaceUpdated, uuidToString(ws.ID), "member", userID, map[string]any{"workspace": h.workspaceToResponse(ws)})
if req.Name != nil {
if members, err := h.Queries.ListMembers(r.Context(), ws.ID); err == nil {
userIDs := make([]string, 0, len(members))
for _, member := range members {
userIDs = append(userIDs, uuidToString(member.UserID))
}
h.notifyDaemonWorkspacesChanged(userIDs...)
}
}
writeJSON(w, http.StatusOK, h.workspaceToResponse(ws))
}
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
return
}
resp := make([]MemberResponse, len(members))
for i, m := range members {
resp[i] = memberToResponse(m)
}
writeJSON(w, http.StatusOK, resp)
}
type MemberWithUserResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url"`
}
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
members, err := h.Queries.ListMembersWithUser(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
return
}
resp := make([]MemberWithUserResponse, len(members))
for i, m := range members {
resp[i] = MemberWithUserResponse{
ID: uuidToString(m.ID),
WorkspaceID: uuidToString(m.WorkspaceID),
UserID: uuidToString(m.UserID),
Role: m.Role,
CreatedAt: timestampToString(m.CreatedAt),
Name: m.UserName,
Email: m.UserEmail,
AvatarURL: h.resolveAvatarURLPtr(textToPtr(m.UserAvatarUrl)),
}
}
writeJSON(w, http.StatusOK, resp)
}
type CreateMemberRequest struct {
Email string `json:"email"`
Role string `json:"role"`
}
func (h *Handler) memberWithUserResponse(member db.Member, user db.User) MemberWithUserResponse {
return MemberWithUserResponse{
ID: uuidToString(member.ID),
WorkspaceID: uuidToString(member.WorkspaceID),
UserID: uuidToString(member.UserID),
Role: member.Role,
CreatedAt: timestampToString(member.CreatedAt),
Name: user.Name,
Email: user.Email,
AvatarURL: h.resolveAvatarURLPtr(textToPtr(user.AvatarUrl)),
}
}
func normalizeMemberRole(role string) (string, bool) {
if role == "" {
return "member", true
}
role = strings.TrimSpace(role)
switch role {
case "owner", "admin", "member":
return role, true
default:
return "", false
}
}
func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
var req CreateMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
role, valid := normalizeMemberRole(req.Role)
if !valid {
writeError(w, http.StatusBadRequest, "invalid member role")
return
}
if role == "owner" && requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
user, err := h.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if isNotFound(err) {
// Auto-create user with email so they can be invited before signing up
user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{
Name: email,
Email: email,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
} else {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
}
member, err := h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
WorkspaceID: requester.WorkspaceID,
UserID: user.ID,
Role: role,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "user is already a member")
return
}
slog.Warn("create member failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID, "email", email)...)
writeError(w, http.StatusInternalServerError, "failed to create member")
return
}
slog.Info("member added", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "email", email, "role", role)...)
userID := requestUserID(r)
eventPayload := map[string]any{"member": h.memberWithUserResponse(member, user)}
if ws, err := h.Queries.GetWorkspace(r.Context(), requester.WorkspaceID); err == nil {
eventPayload["workspace_name"] = ws.Name
}
h.publish(protocol.EventMemberAdded, uuidToString(requester.WorkspaceID), "member", userID, eventPayload)
h.notifyDaemonWorkspacesChanged(uuidToString(user.ID))
writeJSON(w, http.StatusCreated, h.memberWithUserResponse(member, user))
}
type UpdateMemberRequest struct {
Role string `json:"role"`
}
func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
memberID := chi.URLParam(r, "memberId")
memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id")
if !ok {
return
}
target, err := h.Queries.GetMember(r.Context(), memberUUID)
if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) {
writeError(w, http.StatusNotFound, "member not found")
return
}
var req UpdateMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.Role) == "" {
writeError(w, http.StatusBadRequest, "role is required")
return
}
role, valid := normalizeMemberRole(req.Role)
if !valid {
writeError(w, http.StatusBadRequest, "invalid member role")
return
}
if (target.Role == "owner" || role == "owner") && requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
if target.Role == "owner" && role != "owner" {
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update member")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
updatedMember, err := h.Queries.UpdateMemberRole(r.Context(), db.UpdateMemberRoleParams{
ID: target.ID,
Role: role,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update member")
return
}
h.MembershipCache.Invalidate(r.Context(), uuidToString(target.UserID), workspaceID)
user, err := h.Queries.GetUser(r.Context(), updatedMember.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load member")
return
}
userID := requestUserID(r)
h.publish(protocol.EventMemberUpdated, uuidToString(requester.WorkspaceID), "member", userID, map[string]any{
"member": h.memberWithUserResponse(updatedMember, user),
})
writeJSON(w, http.StatusOK, h.memberWithUserResponse(updatedMember, user))
}
func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
memberID := chi.URLParam(r, "memberId")
memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id")
if !ok {
return
}
target, err := h.Queries.GetMember(r.Context(), memberUUID)
if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) {
writeError(w, http.StatusNotFound, "member not found")
return
}
if target.Role == "owner" && requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
if target.Role == "owner" {
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete member")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
requesterUserID := requestUserID(r)
result, err := h.revokeAndRemoveMember(r.Context(), target.WorkspaceID, target.UserID, target.ID, parseUUID(requesterUserID))
if err != nil {
slog.Warn("delete member failed", append(logger.RequestAttrs(r), "error", err, "member_id", memberID, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete member")
return
}
h.MembershipCache.Invalidate(r.Context(), uuidToString(target.UserID), workspaceID)
wsIDStr := uuidToString(requester.WorkspaceID)
logRevocation(result, wsIDStr, uuidToString(target.UserID))
h.publishRevocation(r.Context(), result, wsIDStr, "member", requesterUserID)
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(target.ID), "workspace_id", workspaceID, "user_id", uuidToString(target.UserID))...)
h.publish(protocol.EventMemberRemoved, wsIDStr, "member", requesterUserID, map[string]any{
"member_id": uuidToString(target.ID),
"workspace_id": wsIDStr,
"user_id": uuidToString(target.UserID),
})
h.notifyDaemonWorkspacesChanged(uuidToString(target.UserID))
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) LeaveWorkspace(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
if member.Role == "owner" {
members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
result, err := h.revokeAndRemoveMember(r.Context(), member.WorkspaceID, member.UserID, member.ID, member.UserID)
if err != nil {
slog.Warn("leave workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
return
}
h.MembershipCache.Invalidate(r.Context(), uuidToString(member.UserID), workspaceID)
userID := requestUserID(r)
logRevocation(result, workspaceID, uuidToString(member.UserID))
h.publishRevocation(r.Context(), result, workspaceID, "member", userID)
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "user_id", uuidToString(member.UserID))...)
h.publish(protocol.EventMemberRemoved, workspaceID, "member", userID, map[string]any{
"member_id": uuidToString(member.ID),
"workspace_id": workspaceID,
"user_id": uuidToString(member.UserID),
})
h.notifyDaemonWorkspacesChanged(uuidToString(member.UserID))
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
// Defense in depth: the route is already gated by the
// RequireWorkspaceRoleFromURL("owner") middleware, but we re-check here
// so that the handler is safe regardless of how it gets wired up
// (direct calls in tests, future router refactors, etc.).
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
if requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
// Invalidate membership cache for all workspace members before deletion.
// After CASCADE deletes the member rows, cache entries become harmless
// orphans (downstream lookups for the deleted workspace will fail), but
// proactive invalidation prevents any stale-access window up to TTL.
var affectedUserIDs []string
if members, err := h.Queries.ListMembers(r.Context(), requester.WorkspaceID); err == nil {
affectedUserIDs = make([]string, 0, len(members))
for _, m := range members {
userID := uuidToString(m.UserID)
h.MembershipCache.Invalidate(r.Context(), userID, workspaceID)
affectedUserIDs = append(affectedUserIDs, userID)
}
}
// The teardown runs in one transaction so the chat_session row locks below
// are still held when DeleteWorkspace sweeps chat_draft_restore. Without
// them, FinalizeDeferredCancelledChat could commit a restore for one of
// these sessions after the sweep's snapshot was taken: the session cascades
// away, the restore has no FK to follow it (MUL-3515) and no reaper, and the
// user's prompt is stranded forever (#5219). The finalizer takes the same
// lock before inserting, so it either blocks until the session is gone and
// skips the insert, or commits first and the sweep sees its row.
//
// The workspace row is locked first, because the session locks only cover
// sessions that already exist: a CreateChatSession committing inside the
// delete window would otherwise slip in a session nobody locked, and its
// restore would outlive the cascade the same way. Holding the workspace row
// FOR UPDATE blocks that insert on its workspace FK (FOR KEY SHARE).
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
slog.Warn("begin workspace delete tx failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
if _, err := qtx.LockWorkspaceForDelete(r.Context(), requester.WorkspaceID); err != nil {
slog.Warn("lock workspace for delete failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
if _, err := qtx.LockChatSessionsByWorkspace(r.Context(), requester.WorkspaceID); err != nil {
slog.Warn("lock workspace chat sessions failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
if err := qtx.DeleteChatPinnedAgentsByWorkspace(r.Context(), requester.WorkspaceID); err != nil {
slog.Warn("delete workspace chat pins failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
// At this point workspaceMember has resolved → workspaceID is a valid UUID
// (the lookup would have errored otherwise), so reuse the resolved value.
if err := qtx.DeleteWorkspace(r.Context(), requester.WorkspaceID); err != nil {
slog.Warn("delete workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
if err := tx.Commit(r.Context()); err != nil {
slog.Warn("commit workspace delete failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
slog.Info("workspace deleted", append(logger.RequestAttrs(r), "workspace_id", workspaceID)...)
h.publish(protocol.EventWorkspaceDeleted, workspaceID, "member", requestUserID(r), map[string]any{
"workspace_id": workspaceID,
})
h.notifyDaemonWorkspacesChanged(affectedUserIDs...)
w.WriteHeader(http.StatusNoContent)
}