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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
510187a58d
commit
1aa0cd818a
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { Hash, Users2, Send, ChevronLeft, Plus } from 'lucide-react'
|
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 type { Tables } from '@/types/database'
|
||||||
import { PageTitle } from '@/components/ui/page-title'
|
import { PageTitle } from '@/components/ui/page-title'
|
||||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
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() {
|
async function loadMessages() {
|
||||||
if (!selectedChannel) return
|
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 supabase = createClient()
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('comms_messages')
|
.from('comms_messages')
|
||||||
@@ -138,27 +142,19 @@ export default function CommsClient({ member, space, channels }: Props) {
|
|||||||
setMessages(prev => [...prev, optimisticMsg as Message])
|
setMessages(prev => [...prev, optimisticMsg as Message])
|
||||||
setNewMessage('')
|
setNewMessage('')
|
||||||
|
|
||||||
const supabase = createClient()
|
// Sender identity + space_id are derived server-side from the session; the
|
||||||
const { data, error } = await supabase
|
// browser only supplies the channel and text. The realtime subscription
|
||||||
.from('comms_messages')
|
// still delivers the persisted row (temp-id dedup handles it), so latency
|
||||||
.insert({
|
// is unchanged.
|
||||||
channel_id: selectedChannel.id,
|
const result = await sendMessageAction({ channel_id: selectedChannel.id, content })
|
||||||
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()
|
|
||||||
|
|
||||||
if (error) {
|
if ('error' in result && result.error) {
|
||||||
// Roll back optimistic message on failure
|
// Roll back optimistic message on failure
|
||||||
setMessages(prev => prev.filter(m => m.id !== tempId))
|
setMessages(prev => prev.filter(m => m.id !== tempId))
|
||||||
setNewMessage(content)
|
setNewMessage(content)
|
||||||
} else if (data) {
|
} else if ('data' in result && result.data) {
|
||||||
// Replace temp with confirmed message (realtime may also fire — dedup handles it)
|
// 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)
|
setSending(false)
|
||||||
|
|||||||
+46
-2
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
import { requireMember, requireMemberWithRole, logActivity, parseInput } from '@/lib/auth-helpers'
|
||||||
import { createChannelSchema, uuidSchema } from '@/lib/validations'
|
import { createChannelSchema, sendMessageSchema, uuidSchema } from '@/lib/validations'
|
||||||
|
|
||||||
export async function createChannel(input: {
|
export async function createChannel(input: {
|
||||||
name: string
|
name: string
|
||||||
@@ -70,6 +70,50 @@ export async function deleteChannel(channelId: string) {
|
|||||||
return { success: true as const }
|
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) {
|
export async function renameChannel(channelId: string, name: string, description?: string | null) {
|
||||||
const idCheck = parseInput(uuidSchema, channelId)
|
const idCheck = parseInput(uuidSchema, channelId)
|
||||||
if (!idCheck.ok) return { error: 'Invalid channel ID' }
|
if (!idCheck.ok) return { error: 'Invalid channel ID' }
|
||||||
|
|||||||
@@ -92,3 +92,10 @@ export const createChannelSchema = z.object({
|
|||||||
description: z.string().max(500).optional().nullable(),
|
description: z.string().max(500).optional().nullable(),
|
||||||
channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'),
|
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),
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user