Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 31 additions & 12 deletions lib/supabase/persistence.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
'use server'

import { getSupabaseServerClient } from '@/lib/supabase/client'
import { getSupabaseServiceRoleClient } from './service-role'
import { type Chat, type AIMessage } from '@/lib/types'
import { PostgrestError } from '@supabase/supabase-js'

export async function saveChat(chat: Chat, userId: string): Promise<{ data: string | null; error: PostgrestError | null }> {
const supabase = getSupabaseServerClient()
const supabase = getSupabaseServiceRoleClient()

// Insert into chats table
const { data: chatData, error: chatError } = await supabase
.from('chats')
.insert({
id: chat.id,
user_id: userId,
title: chat.title,
})
Comment on lines 8 to +18

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the service role client inside a general persistence function is a high-risk elevation of privilege. As written, saveChat(chat, userId) trusts the caller-provided userId and then performs privileged inserts, which can allow cross-user writes if this server action is ever reachable by an untrusted caller or if a bug passes the wrong userId.

Given the PR description (“bypass a missing INSERT policy on the chats table”), this should be treated as a temporary workaround and tightly scoped. At minimum, enforce that userId comes from the authenticated session on the server (not a caller argument), or keep the service-role client usage isolated to the minimal operation and add explicit checks/guardrails.

Suggestion

Refactor saveChat so userId is derived from the current session (server-side) and cannot be overridden by the caller, and/or keep the service role client limited to only the chats insert while using the regular server client for user-scoped operations.

Example direction:

  • Fetch session user id from getSupabaseServerClient().auth.getUser() (or your existing auth util) and ignore the passed userId.
  • Alternatively, change the function signature to omit userId entirely.
  • Consider adding an allowlist check: if (userId !== session.user.id) throw.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

.select('id')
.single()

if (chatError) {
console.error('Error saving chat:', chatError)
return { data: null, error: chatError }
}

const messagesToInsert = chat.messages.map(message => ({
id: message.id,
chat_id: chat.id,
user_id: userId,
role: message.role,
content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
createdAt: message.createdAt ? new Date(message.createdAt).toISOString() : new Date().toISOString(),
created_at: message.createdAt ? new Date(message.createdAt).toISOString() : new Date().toISOString(),
}))

const { data, error } = await supabase.rpc('save_chat_with_messages', {
chat_id: chat.id,
user_id: userId,
title: chat.title,
messages: messagesToInsert,
})
const { error: messagesError } = await supabase
.from('messages')
.insert(messagesToInsert)

if (error) {
console.error('Error saving chat with messages:', error)
return { data: null, error }
if (messagesError) {
console.error('Error saving messages:', messagesError)
// Attempt to delete the chat if messages fail to save
await supabase.from('chats').delete().eq('id', chat.id)
return { data: null, error: messagesError }
Comment on lines +40 to +44

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rollback logic is not safe as-is:

  • If inserting messages fails and the subsequent delete fails, you silently ignore the deletion error, potentially leaving an orphaned chat row.
  • The delete is executed with the service-role client, so if chat.id collides or is reused unexpectedly, you could delete a chat you didn't just insert.

Also, the sequence is not atomic; partial writes can occur. Prefer a transactional approach (RPC or SQL function) when you need to guarantee all-or-nothing behavior.

Suggestion

Handle rollback failures explicitly and make the rollback safer by deleting only if the chat insert in this request succeeded and by checking for a known marker (e.g., inserted row matches user_id, title, timestamps) before deletion.

If you can, move this to a single database transaction:

  • Re-introduce an RPC/SQL function (that actually exists) that inserts chat + messages in a transaction.
  • Or use a BEGIN … COMMIT transaction via a Postgres function called from Supabase.

At minimum:

  • Capture and log the delete error.
  • Consider returning a combined error that indicates partial failure.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

}

return { data: data as string, error: null }
return { data: chatData.id, error: null }
}

export async function getMessagesByChatId(chatId: string): Promise<{ data: any[] | null; error: PostgrestError | null }> {
Expand Down
27 changes: 27 additions & 0 deletions lib/supabase/service-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'

export function getSupabaseServiceRoleClient() {
const cookieStore = cookies()

return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {
async get(name: string) {
const store = await cookieStore
return store.get(name)?.value
},
async set(name: string, value: string, options: CookieOptions) {
const store = await cookieStore
store.set({ name, value, ...options })
},
async remove(name: string, options: CookieOptions) {
const store = await cookieStore
store.set({ name, value: '', ...options })
},
},
}
)
Comment on lines +1 to +26

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSupabaseServiceRoleClient() uses createServerClient with cookie read/write hooks. Service role operations generally should not depend on user cookies/sessions, and persisting auth cookies while using the service role key increases the chance of confusing or unsafe behavior (mixing privileged and user contexts).

A safer pattern is to use the plain Supabase JS client (or a server client configured without cookie persistence) for service role usage, and keep it completely separate from request/session cookie handling.

Suggestion

Create a dedicated service-role client that does not read/write Next.js cookies.

For example, use @supabase/supabase-js createClient(url, serviceKey, { auth: { persistSession: false } }) (or equivalent in your stack) so privileged calls are not coupled to user session cookies.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

}