fix(comms): send messages via a server action, derive identity server-side (SoC)

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) <[email protected]>
This commit is contained in:
Moheeb Zara
2026-08-04 19:10:42 -07:00
co-authored by Claude Opus 4.8
parent 510187a58d
commit 1aa0cd818a
3 changed files with 66 additions and 19 deletions
+46 -2
View File
@@ -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' }