Skip to content
Merged
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
51 changes: 18 additions & 33 deletions src/app/api/whatsapp/broadcast/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
Expand Down Expand Up @@ -60,42 +60,28 @@ interface NewRecipient {

export async function POST(request: Request) {
try {
const supabase = await createClient()

const {
data: { user },
error: authError,
} = await supabase.auth.getUser()

if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Requires the 'agent' role — `canSendMessages` in lib/auth/roles is
// explicit that running broadcasts is a write operation and that
// viewers are read-only.
//
// This endpoint writes NOTHING to the database: it reads the config
// and template, then calls Meta directly. So unlike the rest of the
// app there was no RLS policy backstopping a missing role check —
// resolving `account_id` straight off the profile (which only needs
// 'viewer') was the ONLY gate, and it let a viewer blast a template
// to arbitrary phone numbers from the account's WhatsApp number.
// Nothing about that is recoverable after the fact, so the check has
// to happen here.
const { supabase, accountId, userId } = await requireRole('agent')

// Per-user broadcast budget. Note: this limits how often a user
// can *start* a campaign, not how many messages go out inside
// one — the fan-out loop below runs without additional gating.
const limit = checkRateLimit(`broadcast:${user.id}`, RATE_LIMITS.broadcast)
const limit = checkRateLimit(`broadcast:${userId}`, RATE_LIMITS.broadcast)
if (!limit.success) {
return rateLimitResponse(limit)
}

// Resolve the caller's account_id. whatsapp_config + templates
// + broadcasts are all account-scoped post-multi-user, so the
// old `.eq('user_id', user.id)` filters miss every row created
// by a teammate.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}

const body = await request.json()
const {
recipients: newRecipients,
Expand Down Expand Up @@ -254,10 +240,9 @@ export async function POST(request: Request) {
results,
})
} catch (error) {
// requireRole throws Unauthorized/Forbidden; toErrorResponse maps
// those to 401/403 and collapses anything else to a generic 500.
console.error('Error in WhatsApp broadcast POST:', error)
return NextResponse.json(
{ error: 'Failed to process broadcast' },
{ status: 500 }
)
return toErrorResponse(error)
}
}
45 changes: 12 additions & 33 deletions src/app/api/whatsapp/react/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { requireRole, toErrorResponse } from '@/lib/auth/account';
import { sendReactionMessage } from '@/lib/whatsapp/meta-api';
import { decrypt } from '@/lib/whatsapp/encryption';
import { sanitizePhoneForMeta } from '@/lib/whatsapp/phone-utils';
Expand All @@ -20,37 +20,17 @@ import {
*/
export async function POST(request: Request) {
try {
const supabase = await createClient();
// Reacting is a write operation (`canSendMessages`), and it pushes the
// reaction to Meta before mirroring it locally — so, as on /send, a
// missing role check let a read-only viewer put a visible reaction on
// the customer's message even though RLS blocked the local mirror.
const { supabase, accountId, userId } = await requireRole('agent');

const {
data: { user },
error: authError,
} = await supabase.auth.getUser();

if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const limit = checkRateLimit(`react:${user.id}`, RATE_LIMITS.react);
const limit = checkRateLimit(`react:${userId}`, RATE_LIMITS.react);
if (!limit.success) {
return rateLimitResponse(limit);
}

// Resolve the caller's account_id so conversation + whatsapp_config
// lookups work for teammates who didn't author the rows directly.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle();
const accountId = profile?.account_id as string | undefined;
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
);
}

const body = await request.json();
const { message_id, emoji } = body as {
message_id?: string;
Expand Down Expand Up @@ -150,7 +130,7 @@ export async function POST(request: Request) {
.delete()
.eq('message_id', targetMessage.id)
.eq('actor_type', 'agent')
.eq('actor_id', user.id);
.eq('actor_id', userId);

if (delError) {
console.error('[whatsapp/react] DB delete failed:', delError.message);
Expand All @@ -167,7 +147,7 @@ export async function POST(request: Request) {
message_id: targetMessage.id,
conversation_id: targetMessage.conversation_id,
actor_type: 'agent',
actor_id: user.id,
actor_id: userId,
emoji,
},
{ onConflict: 'message_id,actor_type,actor_id' },
Expand All @@ -184,10 +164,9 @@ export async function POST(request: Request) {

return NextResponse.json({ success: true });
} catch (error) {
// requireRole throws Unauthorized/Forbidden; toErrorResponse maps
// those to 401/403 and collapses anything else to a generic 500.
console.error('Error in WhatsApp react POST:', error);
return NextResponse.json(
{ error: 'Failed to react to message' },
{ status: 500 },
);
return toErrorResponse(error);
}
}
57 changes: 56 additions & 1 deletion src/app/api/whatsapp/send/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const messageInserts: Array<Record<string, unknown>> = []
// Toggles for the per-test scenario.
let existingConversation: Record<string, unknown> | null = null
let contactRow: Record<string, unknown> | null = null
// The caller's role, as `requireRole` reads it off the profile. Sending
// requires 'agent'; 'viewer' must be refused before anything reaches Meta.
let callerRole: string = 'admin'
// A conversation created during the request becomes retrievable by id —
// the shared send core re-loads the conversation (with its contact) from
// just the id, so the mock must model insert-then-select-by-id.
Expand All @@ -35,7 +38,12 @@ function makeSupabaseMock() {
const selectResult = () => {
switch (table) {
case 'profiles':
return { data: { account_id: 'acct-1' }, error: null }
return {
data: { account_id: 'acct-1', account_role: callerRole },
error: null,
}
case 'accounts':
return { data: { id: 'acct-1', name: 'Acme' }, error: null }
case 'contacts':
return { data: contactRow, error: null }
case 'conversations':
Expand Down Expand Up @@ -179,6 +187,7 @@ describe('POST /api/whatsapp/send — contact_id template path', () => {
existingConversation = null
createdConversation = null
contactRow = CONTACT
callerRole = 'admin'
supabaseMock = makeSupabaseMock()
sendTemplateMessage.mockClear()
})
Expand Down Expand Up @@ -259,3 +268,49 @@ describe('POST /api/whatsapp/send — contact_id template path', () => {
expect(res.status).toBe(400)
})
})

describe('POST /api/whatsapp/send — role enforcement', () => {
beforeEach(() => {
conversationInserts.length = 0
messageInserts.length = 0
existingConversation = {
id: 'conv-existing',
account_id: 'acct-1',
contact_id: 'contact-1',
contact: CONTACT,
}
createdConversation = null
contactRow = CONTACT
callerRole = 'admin'
supabaseMock = makeSupabaseMock()
sendTemplateMessage.mockClear()
})

afterEach(() => {
vi.clearAllMocks()
})

it('refuses a viewer with 403 and never reaches Meta', async () => {
// A viewer is read-only (`canSendMessages`). The route used to resolve
// account_id straight off the profile with no role check: RLS blocked
// the message INSERT, but the send core calls Meta first, so the
// customer still received a real WhatsApp message that RLS could not
// un-send. The gate has to come before any outbound call.
callerRole = 'viewer'

const res = await postContactTemplate()

expect(res.status).toBe(403)
expect(sendTemplateMessage).not.toHaveBeenCalled()
expect(messageInserts).toHaveLength(0)
})

it('allows an agent through', async () => {
callerRole = 'agent'

const res = await postContactTemplate()

expect(res.status).toBe(200)
expect(sendTemplateMessage).toHaveBeenCalledTimes(1)
})
})
52 changes: 16 additions & 36 deletions src/app/api/whatsapp/send/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { requireRole, toErrorResponse } from '@/lib/auth/account'
import {
checkRateLimit,
rateLimitResponse,
Expand All @@ -22,44 +23,24 @@ import {
// dashboard's internal `{ error }` shape.
export async function POST(request: Request) {
try {
const supabase = await createClient()

const {
data: { user },
error: authError,
} = await supabase.auth.getUser()

if (authError || !user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Requires the 'agent' role, matching both `canSendMessages` and the
// `messages_modify` RLS policy (migration 017).
//
// Resolving `account_id` off the profile — which any 'viewer' has —
// was previously the only gate. RLS did block the message INSERT, but
// the send core calls Meta BEFORE it persists, so a viewer's request
// still delivered a real WhatsApp message to the customer and merely
// failed to record it (surfacing as "sent to Meta but failed to save
// to DB"). RLS can't un-send that, so the role check belongs here.
const { supabase, accountId, userId } = await requireRole('agent')

// Per-user rate limit. Bucket key is scoped to this route so
// `/broadcast` has an independent budget.
const limit = checkRateLimit(`send:${user.id}`, RATE_LIMITS.send)
const limit = checkRateLimit(`send:${userId}`, RATE_LIMITS.send)
if (!limit.success) {
return rateLimitResponse(limit)
}

// Resolve the caller's account_id. Every downstream lookup
// (conversation, whatsapp_config, message_templates) is account-
// scoped post-multi-user, so the previous `user_id` filters
// returned nothing for teammates who didn't author the row.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}

const body = await request.json()
const {
// `conversation_id` targets an existing thread (inbox). `contact_id`
Expand Down Expand Up @@ -148,7 +129,7 @@ export async function POST(request: Request) {
const resolved = await findOrCreateConversation(
supabase,
accountId,
user.id,
userId,
contact_id
)
if (!resolved) {
Expand Down Expand Up @@ -201,11 +182,10 @@ export async function POST(request: Request) {
throw err
}
} catch (error) {
// requireRole throws Unauthorized/Forbidden; toErrorResponse maps
// those to 401/403 and collapses anything else to a generic 500.
console.error('Error in WhatsApp send POST:', error)
return NextResponse.json(
{ error: 'Failed to send message' },
{ status: 500 }
)
return toErrorResponse(error)
}
}

Expand Down
Loading
Loading