diff --git a/README.md b/README.md index 94fb231..e03420c 100644 --- a/README.md +++ b/README.md @@ -262,13 +262,14 @@ Current behavior: - Forwarded calls are recorded via TwiML `` - The app stores recording metadata on `Call` (`recordingSid`, `recordingUrl`, `recordingStatus`, `recordingDurationSeconds`) when Twilio posts recording callbacks to `/api/twilio/status` -- The app does **not** proxy/download recording audio files; recordings remain hosted in Twilio unless you add a separate ingestion/storage pipeline +- Recording audio remains hosted in Twilio; CallbackCloser exposes a server-mediated redirect route for authenticated in-app access Where to access recordings: +- Lead detail page (`/app/leads/[leadId]`) shows recording status/duration and an authenticated `Open recording` action +- Recording links are mediated through `/api/leads/[leadId]/recording`, which checks the signed-in owner and lead business before redirecting - Twilio Console -> Monitor -> Calls (or Call Logs / Recordings, depending on account UI) - Database (`Call.recording*` fields) for metadata lookup / correlation -- The app does not currently surface recordings in the dashboard UI ## Billing Gating Behavior @@ -318,6 +319,7 @@ Prisma models included: - `/api/twilio/voice` - Twilio voice webhook - `/api/twilio/status` - Twilio dial action callback - `/api/twilio/sms` - Twilio SMS webhook +- `/api/leads/[leadId]/recording` - authenticated recording redirect for lead owners - `/api/stripe/webhook` - Stripe webhook ## Notes / MVP Constraints diff --git a/app/api/leads/[leadId]/recording/route.ts b/app/api/leads/[leadId]/recording/route.ts new file mode 100644 index 0000000..f70fc15 --- /dev/null +++ b/app/api/leads/[leadId]/recording/route.ts @@ -0,0 +1,57 @@ +import { auth } from '@clerk/nextjs/server'; +import { NextResponse } from 'next/server'; + +import { db } from '@/lib/db'; +import { resolveRecordingAccessReason } from '@/lib/recording-access'; +import { absoluteUrl } from '@/lib/url'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(_request: Request, { params }: { params: { leadId: string } }) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 }); + } + + const lead = await db.lead.findUnique({ + where: { id: params.leadId }, + select: { + id: true, + business: { + select: { + ownerClerkId: true, + }, + }, + call: { + select: { + recordingUrl: true, + }, + }, + }, + }); + + if (!lead) { + return NextResponse.json({ error: 'Lead not found' }, { status: 404 }); + } + + const accessReason = resolveRecordingAccessReason({ + requestUserId: userId, + businessOwnerClerkId: lead.business.ownerClerkId, + recordingUrl: lead.call?.recordingUrl ?? null, + }); + + if (accessReason === 'wrong_business') { + return NextResponse.json({ error: 'Lead not found' }, { status: 404 }); + } + + if (accessReason === 'recording_unavailable') { + return NextResponse.json({ error: 'Recording not available for this lead' }, { status: 404 }); + } + + if (accessReason !== 'ok') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return NextResponse.redirect(lead.call!.recordingUrl!, { status: 303 }); +} diff --git a/app/app/leads/[leadId]/page.tsx b/app/app/leads/[leadId]/page.tsx index bdf24be..99052dc 100644 --- a/app/app/leads/[leadId]/page.tsx +++ b/app/app/leads/[leadId]/page.tsx @@ -92,7 +92,7 @@ export default async function LeadDetailPage({ params, searchParams }: { params: Call Record - Twilio voice callback data for the originating call. + Twilio voice callback data and recording metadata for the originating call. {lead.call ? ( @@ -102,6 +102,17 @@ export default async function LeadDetailPage({ params, searchParams }: { params:
Answered{lead.call.answered ? 'Yes' : 'No'}
Missed{lead.call.missed ? 'Yes' : 'No'}
Duration{lead.call.callDurationSeconds ?? 0}s
+
Recording status{lead.call.recordingStatus || 'not_available'}
+
Recording duration{lead.call.recordingDurationSeconds ?? 0}s
+
+ {lead.call.recordingUrl ? ( +
+ +
+ ) : ( +

Recording link unavailable until Twilio recording metadata is received.

+ )} +
) : (

No call record linked.

diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 9989ecd..bf55331 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -483,3 +483,35 @@ Dependencies: G4 (recommended) - `npm run env:check` -> PASS - Notes: - No functional regressions observed in local validation gates. + +- 2026-03-02 - G14 (DONE) + - Branch: `hardening/g14-recordings-ux` + - What changed: + - Added access-control helper for recording links in `lib/recording-access.ts`: + - only authenticated owner of the lead's business can open recordings + - blocks wrong-business and missing-recording cases + - Added server-mediated recording access route: + - `app/api/leads/[leadId]/recording/route.ts` + - validates auth + business ownership before redirecting to Twilio recording URL + - hides cross-business lead access behind `404` + - Updated lead detail UI (`app/app/leads/[leadId]/page.tsx`) to show: + - recording status + - recording duration + - gated `Open recording` action (uses server route, not raw URL) + - Added focused access-control tests in `tests/recording-access.test.ts`. + - Updated recording docs in `README.md` to reflect the in-app gated flow. + - Commands run + results: + - `npm test` -> PASS (38/38) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - Files touched: + - `lib/recording-access.ts` + - `app/api/leads/[leadId]/recording/route.ts` + - `app/app/leads/[leadId]/page.tsx` + - `tests/recording-access.test.ts` + - `README.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `4e8f4d8` diff --git a/lib/recording-access.ts b/lib/recording-access.ts new file mode 100644 index 0000000..f96aa3f --- /dev/null +++ b/lib/recording-access.ts @@ -0,0 +1,18 @@ +export type RecordingAccessReason = + | 'ok' + | 'unauthenticated' + | 'wrong_business' + | 'recording_unavailable'; + +export function resolveRecordingAccessReason(input: { + requestUserId: string | null | undefined; + businessOwnerClerkId: string | null | undefined; + recordingUrl: string | null | undefined; +}): RecordingAccessReason { + if (!input.requestUserId) return 'unauthenticated'; + if (!input.businessOwnerClerkId || input.businessOwnerClerkId !== input.requestUserId) { + return 'wrong_business'; + } + if (!input.recordingUrl) return 'recording_unavailable'; + return 'ok'; +} diff --git a/tests/recording-access.test.ts b/tests/recording-access.test.ts new file mode 100644 index 0000000..3fd8364 --- /dev/null +++ b/tests/recording-access.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveRecordingAccessReason } from '../lib/recording-access.ts'; + +test('recording access denies unauthenticated requests', () => { + const reason = resolveRecordingAccessReason({ + requestUserId: null, + businessOwnerClerkId: 'user_123', + recordingUrl: 'https://api.twilio.com/recordings/abc', + }); + + assert.equal(reason, 'unauthenticated'); +}); + +test('recording access denies users outside the lead business', () => { + const reason = resolveRecordingAccessReason({ + requestUserId: 'user_123', + businessOwnerClerkId: 'user_456', + recordingUrl: 'https://api.twilio.com/recordings/abc', + }); + + assert.equal(reason, 'wrong_business'); +}); + +test('recording access denies when no recording URL is present', () => { + const reason = resolveRecordingAccessReason({ + requestUserId: 'user_123', + businessOwnerClerkId: 'user_123', + recordingUrl: null, + }); + + assert.equal(reason, 'recording_unavailable'); +}); + +test('recording access allows authenticated owner with recording URL', () => { + const reason = resolveRecordingAccessReason({ + requestUserId: 'user_123', + businessOwnerClerkId: 'user_123', + recordingUrl: 'https://api.twilio.com/recordings/abc', + }); + + assert.equal(reason, 'ok'); +});