diff --git a/app/api/vedic-interpretations/dasha/route.ts b/app/api/vedic-interpretations/dasha/route.ts index ec30f819..4f21b2bf 100644 --- a/app/api/vedic-interpretations/dasha/route.ts +++ b/app/api/vedic-interpretations/dasha/route.ts @@ -1,25 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { VedicInterpretationEnhancer } from '@/lib/vedicInterpretationEnhancer'; +import { authorizeVedicInterpretationRequest } from '@/lib/vedicInterpretationsRouteGuard'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; -export async function POST(request: NextRequest) { +async function handleDasha(request: NextRequest) { try { - const { dashaData, chartData, userId } = await request.json(); - - if (!userId || !chartData || !dashaData) { + const gate = await authorizeVedicInterpretationRequest(request, 'vedic-interpretations-dasha'); + if (!gate.ok) return gate.response; + + const { dashaData, chartData } = gate.body; + const userId = gate.userId; + + if (!chartData || !dashaData) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - + const enhancer = new VedicInterpretationEnhancer(); const interpretation = await enhancer.generateDashaInterpretation( dashaData, chartData, userId ); - + return NextResponse.json({ interpretation }); } catch (error) { devLog.error('Dasha interpretation error:', error, 'route'); @@ -29,3 +35,5 @@ export async function POST(request: NextRequest) { ); } } + +export const POST = withRateLimit(handleDasha, rateLimiters.ai, 'vedic_interpretations_dasha_post'); diff --git a/app/api/vedic-interpretations/divisional/route.ts b/app/api/vedic-interpretations/divisional/route.ts index 62edecc7..285eff80 100644 --- a/app/api/vedic-interpretations/divisional/route.ts +++ b/app/api/vedic-interpretations/divisional/route.ts @@ -2,19 +2,25 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { VedicInterpretationEnhancer } from '@/lib/vedicInterpretationEnhancer'; import { getUserProfile } from '@/lib/firebase'; +import { authorizeVedicInterpretationRequest } from '@/lib/vedicInterpretationsRouteGuard'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; -export async function POST(request: NextRequest) { +async function handleDivisional(request: NextRequest) { try { - const { chartType, chartData, userId } = await request.json(); - - if (!chartType || !chartData || !userId) { + const gate = await authorizeVedicInterpretationRequest(request, 'vedic-interpretations-divisional'); + if (!gate.ok) return gate.response; + + const { chartType, chartData } = gate.body; + const userId = gate.userId; + + if (!chartType || !chartData) { return NextResponse.json( { error: 'Missing required parameters' }, { status: 400 } ); } - - // Fetch user profile to get displayName/firstName + + // Owned userId only — never load another user's profile for personalization. let userName: string | undefined; try { const userProfile = await getUserProfile(userId); @@ -23,12 +29,11 @@ export async function POST(request: NextRequest) { devLog.warn('Could not fetch user profile for personalization:', error, 'route'); // Continue without userName - will use "you" instead } - + const enhancer = new VedicInterpretationEnhancer(); - - // Generate interpretations based on chart type - let interpretations: any = {}; - + + let interpretations: Record = {}; + if (chartType === 'D9') { interpretations = { marriageIndicators: await enhancer.generateDivisionalInsight( @@ -54,7 +59,7 @@ export async function POST(request: NextRequest) { ) }; } - + return NextResponse.json({ interpretations }); } catch (error) { devLog.error('Error generating divisional interpretations:', error, 'route'); @@ -64,3 +69,5 @@ export async function POST(request: NextRequest) { ); } } + +export const POST = withRateLimit(handleDivisional, rateLimiters.ai, 'vedic_interpretations_divisional_post'); diff --git a/app/api/vedic-interpretations/houses/route.ts b/app/api/vedic-interpretations/houses/route.ts index 2ae6500b..873362b1 100644 --- a/app/api/vedic-interpretations/houses/route.ts +++ b/app/api/vedic-interpretations/houses/route.ts @@ -1,25 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { VedicInterpretationEnhancer } from '@/lib/vedicInterpretationEnhancer'; +import { authorizeVedicInterpretationRequest } from '@/lib/vedicInterpretationsRouteGuard'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; -export async function POST(request: NextRequest) { +async function handleHouses(request: NextRequest) { try { - const { houseNumber, chartData, userId } = await request.json(); - - if (!userId || !chartData || houseNumber === undefined) { + const gate = await authorizeVedicInterpretationRequest(request, 'vedic-interpretations-houses'); + if (!gate.ok) return gate.response; + + const { houseNumber, chartData } = gate.body; + const userId = gate.userId; + + if (!chartData || houseNumber === undefined) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - + const enhancer = new VedicInterpretationEnhancer(); const interpretation = await enhancer.generateHouseInterpretation( - houseNumber, + Number(houseNumber), chartData, userId ); - + return NextResponse.json({ interpretation }); } catch (error) { devLog.error('House interpretation error:', error, 'route'); @@ -29,3 +35,5 @@ export async function POST(request: NextRequest) { ); } } + +export const POST = withRateLimit(handleHouses, rateLimiters.ai, 'vedic_interpretations_houses_post'); diff --git a/app/api/vedic-interpretations/overview/route.ts b/app/api/vedic-interpretations/overview/route.ts index f0ccacf2..c5ca8205 100644 --- a/app/api/vedic-interpretations/overview/route.ts +++ b/app/api/vedic-interpretations/overview/route.ts @@ -1,21 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { VedicInterpretationEnhancer } from '@/lib/vedicInterpretationEnhancer'; +import { authorizeVedicInterpretationRequest } from '@/lib/vedicInterpretationsRouteGuard'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; -export async function POST(request: NextRequest) { +/** + * Vedic overview interpretations: must not be an unauthenticated paid proxy + * or allow Admin cache IDOR via body userId. + */ +async function handleOverview(request: NextRequest) { try { - const { chartData, userId } = await request.json(); - - if (!userId || !chartData) { + const gate = await authorizeVedicInterpretationRequest(request, 'vedic-interpretations-overview'); + if (!gate.ok) return gate.response; + + const { chartData } = gate.body; + const userId = gate.userId; + + if (!chartData) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - + const enhancer = new VedicInterpretationEnhancer(); const interpretation = await enhancer.generateEnhancedOverview(chartData, userId); - + return NextResponse.json({ interpretation }); } catch (error) { devLog.error('Overview interpretation error:', error, 'route'); @@ -25,3 +35,5 @@ export async function POST(request: NextRequest) { ); } } + +export const POST = withRateLimit(handleOverview, rateLimiters.ai, 'vedic_interpretations_overview_post'); diff --git a/app/api/vedic-interpretations/panchanga/route.ts b/app/api/vedic-interpretations/panchanga/route.ts index 23f7f7fb..101ddc6a 100644 --- a/app/api/vedic-interpretations/panchanga/route.ts +++ b/app/api/vedic-interpretations/panchanga/route.ts @@ -1,25 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { VedicInterpretationEnhancer } from '@/lib/vedicInterpretationEnhancer'; +import { authorizeVedicInterpretationRequest } from '@/lib/vedicInterpretationsRouteGuard'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; -export async function POST(request: NextRequest) { +async function handlePanchanga(request: NextRequest) { try { - const { panchangaData, chartData, userId } = await request.json(); - - if (!userId || !chartData || !panchangaData) { + const gate = await authorizeVedicInterpretationRequest(request, 'vedic-interpretations-panchanga'); + if (!gate.ok) return gate.response; + + const { panchangaData, chartData } = gate.body; + const userId = gate.userId; + + if (!chartData || !panchangaData) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - + const enhancer = new VedicInterpretationEnhancer(); const interpretation = await enhancer.generatePanchangaInsight( panchangaData, chartData, userId ); - + return NextResponse.json({ interpretation }); } catch (error) { devLog.error('Panchanga interpretation error:', error, 'route'); @@ -29,3 +35,5 @@ export async function POST(request: NextRequest) { ); } } + +export const POST = withRateLimit(handlePanchanga, rateLimiters.ai, 'vedic_interpretations_panchanga_post'); diff --git a/app/api/vedic-interpretations/planets/route.ts b/app/api/vedic-interpretations/planets/route.ts index 02414e39..bcd922ad 100644 --- a/app/api/vedic-interpretations/planets/route.ts +++ b/app/api/vedic-interpretations/planets/route.ts @@ -1,25 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { VedicInterpretationEnhancer } from '@/lib/vedicInterpretationEnhancer'; +import { authorizeVedicInterpretationRequest } from '@/lib/vedicInterpretationsRouteGuard'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; -export async function POST(request: NextRequest) { +async function handlePlanets(request: NextRequest) { try { - const { planet, chartData, userId } = await request.json(); - - if (!userId || !chartData || !planet) { + const gate = await authorizeVedicInterpretationRequest(request, 'vedic-interpretations-planets'); + if (!gate.ok) return gate.response; + + const { planet, chartData } = gate.body; + const userId = gate.userId; + + if (!chartData || !planet) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - + const enhancer = new VedicInterpretationEnhancer(); const interpretation = await enhancer.generatePlanetaryInterpretation( - planet, + String(planet), chartData, userId ); - + return NextResponse.json({ interpretation }); } catch (error) { devLog.error('Planetary interpretation error:', error, 'route'); @@ -29,3 +35,5 @@ export async function POST(request: NextRequest) { ); } } + +export const POST = withRateLimit(handlePlanets, rateLimiters.ai, 'vedic_interpretations_planets_post'); diff --git a/app/api/vedic-interpretations/remedies/route.ts b/app/api/vedic-interpretations/remedies/route.ts index dacc5e70..8ca7970b 100644 --- a/app/api/vedic-interpretations/remedies/route.ts +++ b/app/api/vedic-interpretations/remedies/route.ts @@ -1,26 +1,32 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { VedicInterpretationEnhancer } from '@/lib/vedicInterpretationEnhancer'; +import { authorizeVedicInterpretationRequest } from '@/lib/vedicInterpretationsRouteGuard'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; -export async function POST(request: NextRequest) { +async function handleRemedies(request: NextRequest) { try { - const { planet, remedy, chartData, userId } = await request.json(); - - if (!userId || !chartData || !planet || !remedy) { + const gate = await authorizeVedicInterpretationRequest(request, 'vedic-interpretations-remedies'); + if (!gate.ok) return gate.response; + + const { planet, remedy, chartData } = gate.body; + const userId = gate.userId; + + if (!chartData || !planet || !remedy) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - + const enhancer = new VedicInterpretationEnhancer(); const interpretation = await enhancer.generateRemedyInterpretation( - planet, + String(planet), remedy, chartData, userId ); - + return NextResponse.json({ interpretation }); } catch (error) { devLog.error('Remedy interpretation error:', error, 'route'); @@ -30,3 +36,5 @@ export async function POST(request: NextRequest) { ); } } + +export const POST = withRateLimit(handleRemedies, rateLimiters.ai, 'vedic_interpretations_remedies_post'); diff --git a/app/api/vedic-interpretations/transits/route.ts b/app/api/vedic-interpretations/transits/route.ts index 4344dc50..0669640f 100644 --- a/app/api/vedic-interpretations/transits/route.ts +++ b/app/api/vedic-interpretations/transits/route.ts @@ -1,25 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { devLog } from '@/lib/devLogger'; import { VedicInterpretationEnhancer } from '@/lib/vedicInterpretationEnhancer'; +import { authorizeVedicInterpretationRequest } from '@/lib/vedicInterpretationsRouteGuard'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; -export async function POST(request: NextRequest) { +async function handleTransits(request: NextRequest) { try { - const { transitData, chartData, userId } = await request.json(); - - if (!userId || !chartData || !transitData) { + const gate = await authorizeVedicInterpretationRequest(request, 'vedic-interpretations-transits'); + if (!gate.ok) return gate.response; + + const { transitData, chartData } = gate.body; + const userId = gate.userId; + + if (!chartData || !transitData) { return NextResponse.json( { error: 'Missing required fields' }, { status: 400 } ); } - + const enhancer = new VedicInterpretationEnhancer(); const interpretation = await enhancer.generateTransitInterpretation( transitData, chartData, userId ); - + return NextResponse.json({ interpretation }); } catch (error) { devLog.error('Transit interpretation error:', error, 'route'); @@ -29,3 +35,5 @@ export async function POST(request: NextRequest) { ); } } + +export const POST = withRateLimit(handleTransits, rateLimiters.ai, 'vedic_interpretations_transits_post'); diff --git a/lib/vedicInterpretationsRouteGuard.ts b/lib/vedicInterpretationsRouteGuard.ts new file mode 100644 index 00000000..b7006a7a --- /dev/null +++ b/lib/vedicInterpretationsRouteGuard.ts @@ -0,0 +1,51 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth'; + +export type VedicInterpretationAuthOk = { + ok: true; + userId: string; + body: Record; +}; + +export type VedicInterpretationAuthFail = { + ok: false; + response: NextResponse; +}; + +/** + * Require Firebase Bearer auth and body.userId ownership before Groq + * generation or Admin cache R/W under users/{userId}/vedicInterpretations. + */ +export async function authorizeVedicInterpretationRequest( + request: NextRequest, + logTag: string, +): Promise { + const auth = await verifyUserRequest(request, logTag); + if (!auth.ok) { + return { + ok: false, + response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }; + } + + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return { + ok: false, + response: NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }), + }; + } + + const ownedUserId = resolveOwnedUserId(body.userId, auth.uid); + if (!ownedUserId) { + return { + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }; + } + + return { ok: true, userId: ownedUserId, body }; +} diff --git a/tests/integration/vedic-interpretations-auth.test.ts b/tests/integration/vedic-interpretations-auth.test.ts new file mode 100644 index 00000000..9e102753 --- /dev/null +++ b/tests/integration/vedic-interpretations-auth.test.ts @@ -0,0 +1,191 @@ +/** + * Vedic interpretation routes must not be unauthenticated paid proxies + * (Groq via VedicInterpretationEnhancer) or allow Admin cache / profile + * IDOR via body userId. + * @jest-environment node + */ + +import { NextRequest } from 'next/server'; + +const mockVerifyIdToken = jest.fn(); +const mockGenerateEnhancedOverview = jest.fn(); +const mockGenerateDivisionalInsight = jest.fn(); +const mockGetUserProfile = jest.fn(); + +jest.mock('@/lib/firebase-admin', () => ({ + getAuth: () => ({ verifyIdToken: mockVerifyIdToken }), +})); + +jest.mock('@/lib/vedicInterpretationEnhancer', () => ({ + VedicInterpretationEnhancer: jest.fn().mockImplementation(() => ({ + generateEnhancedOverview: (...args: unknown[]) => mockGenerateEnhancedOverview(...args), + generateDivisionalInsight: (...args: unknown[]) => mockGenerateDivisionalInsight(...args), + })), +})); + +jest.mock('@/lib/firebase', () => ({ + getUserProfile: (...args: unknown[]) => mockGetUserProfile(...args), +})); + +jest.mock('@/lib/rateLimitFirestore', () => ({ + checkRateLimitWithOptionalFirestore: async ( + limiter: { check: (identifier: string) => { allowed: boolean; remaining: number; resetTime: number } }, + _logicalKey: string, + identifier: string, + ) => limiter.check(identifier), +})); + +import { POST as postOverview } from '@/app/api/vedic-interpretations/overview/route'; +import { POST as postDivisional } from '@/app/api/vedic-interpretations/divisional/route'; +import { POST as postPlanets } from '@/app/api/vedic-interpretations/planets/route'; +import { POST as postHouses } from '@/app/api/vedic-interpretations/houses/route'; +import { POST as postDasha } from '@/app/api/vedic-interpretations/dasha/route'; +import { POST as postTransits } from '@/app/api/vedic-interpretations/transits/route'; +import { POST as postRemedies } from '@/app/api/vedic-interpretations/remedies/route'; +import { POST as postPanchanga } from '@/app/api/vedic-interpretations/panchanga/route'; + +const sampleChart = { + ascendant: { degree: 15.2, sign: 'Aries' }, + planets: { Moon: { sign: 'Taurus', house: 2 } }, +}; + +function makeOverviewBody(overrides: Record = {}) { + return { + userId: 'user-1', + chartData: sampleChart, + ...overrides, + }; +} + +describe('POST /api/vedic-interpretations/* auth', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGenerateEnhancedOverview.mockResolvedValue('Overview insight.'); + mockGenerateDivisionalInsight.mockResolvedValue('Divisional insight.'); + mockGetUserProfile.mockResolvedValue({ displayName: 'Seeker' }); + }); + + it('rejects missing Authorization on overview without calling Groq enhancer', async () => { + const req = new NextRequest('http://localhost/api/vedic-interpretations/overview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(makeOverviewBody()), + }); + + const res = await postOverview(req); + expect(res.status).toBe(401); + expect(mockGenerateEnhancedOverview).not.toHaveBeenCalled(); + expect(mockVerifyIdToken).not.toHaveBeenCalled(); + }); + + it('rejects invalid token on overview without calling enhancer', async () => { + mockVerifyIdToken.mockRejectedValueOnce(new Error('bad token')); + const req = new NextRequest('http://localhost/api/vedic-interpretations/overview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer bad', + }, + body: JSON.stringify(makeOverviewBody()), + }); + + const res = await postOverview(req); + expect(res.status).toBe(401); + expect(mockGenerateEnhancedOverview).not.toHaveBeenCalled(); + }); + + it('rejects userId mismatch (cache IDOR) without calling enhancer', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ uid: 'attacker', email: 'a@b.c' }); + const req = new NextRequest('http://localhost/api/vedic-interpretations/overview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer good', + }, + body: JSON.stringify(makeOverviewBody({ userId: 'victim' })), + }); + + const res = await postOverview(req); + expect(res.status).toBe(403); + expect(mockGenerateEnhancedOverview).not.toHaveBeenCalled(); + }); + + it('allows owned userId and generates overview', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' }); + const req = new NextRequest('http://localhost/api/vedic-interpretations/overview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer good', + }, + body: JSON.stringify(makeOverviewBody()), + }); + + const res = await postOverview(req); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.interpretation).toBe('Overview insight.'); + expect(mockGenerateEnhancedOverview).toHaveBeenCalledWith(sampleChart, 'user-1'); + }); + + it('rejects divisional userId mismatch without profile or Groq', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ uid: 'attacker', email: 'a@b.c' }); + const req = new NextRequest('http://localhost/api/vedic-interpretations/divisional', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer good', + }, + body: JSON.stringify({ + userId: 'victim', + chartType: 'D9', + chartData: sampleChart, + }), + }); + + const res = await postDivisional(req); + expect(res.status).toBe(403); + expect(mockGetUserProfile).not.toHaveBeenCalled(); + expect(mockGenerateDivisionalInsight).not.toHaveBeenCalled(); + }); + + it('allows owned divisional request and loads only owned profile', async () => { + mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' }); + const req = new NextRequest('http://localhost/api/vedic-interpretations/divisional', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer good', + }, + body: JSON.stringify({ + userId: 'user-1', + chartType: 'D9', + chartData: sampleChart, + }), + }); + + const res = await postDivisional(req); + expect(res.status).toBe(200); + expect(mockGetUserProfile).toHaveBeenCalledWith('user-1'); + expect(mockGenerateDivisionalInsight).toHaveBeenCalled(); + }); + + it.each([ + ['planets', postPlanets, { userId: 'user-1', planet: 'Moon', chartData: sampleChart }], + ['houses', postHouses, { userId: 'user-1', houseNumber: 1, chartData: sampleChart }], + ['dasha', postDasha, { userId: 'user-1', dashaData: { maha: 'Venus' }, chartData: sampleChart }], + ['transits', postTransits, { userId: 'user-1', transitData: { planet: 'Jupiter' }, chartData: sampleChart }], + ['remedies', postRemedies, { userId: 'user-1', planet: 'Saturn', remedy: 'mantra', chartData: sampleChart }], + ['panchanga', postPanchanga, { userId: 'user-1', panchangaData: { tithi: 'Shukla' }, chartData: sampleChart }], + ])('rejects missing Authorization on %s without enhancer work', async (_name, post, body) => { + const req = new NextRequest('http://localhost/api/vedic-interpretations/x', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const res = await post(req); + expect(res.status).toBe(401); + expect(mockVerifyIdToken).not.toHaveBeenCalled(); + }); +});