From 1aa0cd818af8ac80416467d9b4402c6c838dac86 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 4 Aug 2026 19:10:42 -0700 Subject: [PATCH] fix(comms): send messages via a server action, derive identity server-side (SoC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat composer inserted comms_messages straight from the browser with CLIENT-SUPPLIED user_id/display_name/handle/space_id — a member could forge another person's name/handle. Add a sendMessage server action that derives identity from the session, verifies the channel belongs to the caller's space, validates content, and logs the send. The optimistic insert + realtime subscription are preserved, so latency is unchanged. loadMessages stays on the browser client (the realtime subscription needs it) with a comment noting it. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(app)/comms/comms-client.tsx | 30 +++++++++----------- lib/actions/comms.ts | 48 ++++++++++++++++++++++++++++++-- lib/validations/forum.ts | 7 +++++ 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/app/(app)/comms/comms-client.tsx b/app/(app)/comms/comms-client.tsx index bb6ecf2..b264763 100644 --- a/app/(app)/comms/comms-client.tsx +++ b/app/(app)/comms/comms-client.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation' import { toast } from 'sonner' import { createClient } from '@/lib/supabase/client' import { Hash, Users2, Send, ChevronLeft, Plus } from 'lucide-react' -import { createChannel } from '@/lib/actions' +import { createChannel, sendMessage as sendMessageAction } from '@/lib/actions' import type { Tables } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' @@ -106,6 +106,10 @@ export default function CommsClient({ member, space, channels }: Props) { async function loadMessages() { if (!selectedChannel) return + // Intentional read-only exception: reads stay on the browser client so the + // postgres_changes realtime subscription above shares the same session. + // Writes go through the sendMessage server action (identity is derived + // server-side there). const supabase = createClient() const { data } = await supabase .from('comms_messages') @@ -138,27 +142,19 @@ export default function CommsClient({ member, space, channels }: Props) { setMessages(prev => [...prev, optimisticMsg as Message]) setNewMessage('') - const supabase = createClient() - const { data, error } = await supabase - .from('comms_messages') - .insert({ - channel_id: selectedChannel.id, - space_id: space?.id ?? member.space_id, - user_id: member.user_id as string, - display_name: member.display_name, - handle: member.handle, - content, - }) - .select() - .single() + // Sender identity + space_id are derived server-side from the session; the + // browser only supplies the channel and text. The realtime subscription + // still delivers the persisted row (temp-id dedup handles it), so latency + // is unchanged. + const result = await sendMessageAction({ channel_id: selectedChannel.id, content }) - if (error) { + if ('error' in result && result.error) { // Roll back optimistic message on failure setMessages(prev => prev.filter(m => m.id !== tempId)) setNewMessage(content) - } else if (data) { + } else if ('data' in result && result.data) { // Replace temp with confirmed message (realtime may also fire — dedup handles it) - setMessages(prev => prev.map(m => (m.id === tempId ? data : m))) + setMessages(prev => prev.map(m => (m.id === tempId ? result.data : m))) } setSending(false) diff --git a/lib/actions/comms.ts b/lib/actions/comms.ts index 2bc5925..745d2f3 100644 --- a/lib/actions/comms.ts +++ b/lib/actions/comms.ts @@ -2,8 +2,8 @@ import { revalidatePath } from 'next/cache' import { createClient } from '@/lib/supabase/server' -import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers' -import { createChannelSchema, uuidSchema } from '@/lib/validations' +import { requireMember, requireMemberWithRole, logActivity, parseInput } from '@/lib/auth-helpers' +import { createChannelSchema, sendMessageSchema, uuidSchema } from '@/lib/validations' export async function createChannel(input: { name: string @@ -70,6 +70,50 @@ export async function deleteChannel(channelId: string) { return { success: true as const } } +export async function sendMessage(input: { channel_id: string; content: string }) { + const v = parseInput(sendMessageSchema, input) + if (!v.ok) return { error: v.error } + + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data: { user } } = await supabase.auth.getUser() + if (!user) return { error: 'Not authenticated' } + + // The channel must belong to the caller's space, or a forged channel_id + // could post into another tenant's channel. + const { data: channel } = await supabase + .from('comms_channels') + .select('id') + .eq('id', v.data.channel_id) + .eq('space_id', member.space_id) + .maybeSingle() + if (!channel) return { error: 'Channel not found' } + + // Identity is derived from the session — never from client input. + const { data, error } = await supabase + .from('comms_messages') + .insert({ + channel_id: v.data.channel_id, + space_id: member.space_id, + user_id: user.id, + display_name: member.display_name, + handle: member.handle, + content: v.data.content, + }) + .select() + .single() + + if (error) return { error: error.message } + + await logActivity(supabase, member, 'message.send', 'comms_message', data.id) + + revalidatePath('/comms') + return { data } +} + export async function renameChannel(channelId: string, name: string, description?: string | null) { const idCheck = parseInput(uuidSchema, channelId) if (!idCheck.ok) return { error: 'Invalid channel ID' } diff --git a/lib/validations/forum.ts b/lib/validations/forum.ts index 88ec10d..53c0c4c 100644 --- a/lib/validations/forum.ts +++ b/lib/validations/forum.ts @@ -92,3 +92,10 @@ export const createChannelSchema = z.object({ description: z.string().max(500).optional().nullable(), channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'), }) + +// Only the channel and the text are client-supplied; sender identity and +// space_id are derived server-side from the session (never trusted here). +export const sendMessageSchema = z.object({ + channel_id: z.string().uuid('Invalid channel ID'), + content: z.string().min(1, 'Message is required').max(4000), +})