Skip to content
Draft
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
57 changes: 37 additions & 20 deletions app/api/tools/ogham/generate-report/route.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,66 @@
/**
* Ogham Report Generation API
* Generates comprehensive personalized Ogham reports
*
* Requires a signed-in Firebase user — must not be an unauthenticated Groq
* proxy or Admin Firestore IDOR via body userId (oghamReadings R/W).
*
* Trusted server callers (Stage B) should import `oghamIntelligence.generateReading`
* directly instead of HTTP-looping through this route.
*/

import { NextRequest, NextResponse } from 'next/server'
import { oghamIntelligence } from '@/lib/oghamIntelligence'
import { isProfileComplete, UserProfile } from '@/lib/firebase'
import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth'
import { withRateLimit, rateLimiters } from '@/lib/rateLimit'
import { devLog } from '@/lib/devLogger'

export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, userProfile: providedProfile } = body
async function handlePost(request: NextRequest) {
const auth = await verifyUserRequest(request, 'ogham-generate-report')
if (!auth.ok) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}

if (!userId) {
try {
let body: Record<string, unknown>
try {
body = (await request.json()) as Record<string, unknown>
} catch {
return NextResponse.json(
{ success: false, error: 'User ID is required' },
{ status: 400 }
{ success: false, error: 'Invalid JSON body' },
{ status: 400 },
)
}

// Use provided profile or require it
const userProfile = providedProfile as UserProfile | null
const ownedUserId = resolveOwnedUserId(body.userId, auth.uid)
if (!ownedUserId) {
return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 })
}

const userProfile = body.userProfile as UserProfile | null

if (!userProfile) {
return NextResponse.json(
{ success: false, error: 'User profile is required' },
{ status: 400 }
{ status: 400 },
)
}

// Check profile completion
if (!isProfileComplete(userProfile)) {
return NextResponse.json(
{
success: false,
{
success: false,
error: 'Complete birth profile required. Please complete your profile first.',
missingFields: ['birthDate', 'birthTime', 'birthPlace']
missingFields: ['birthDate', 'birthTime', 'birthPlace'],
},
{ status: 400 }
{ status: 400 },
)
}

devLog.info('🔮 Generating Ogham report for user:', userId, 'ogham')
devLog.info('🔮 Generating Ogham report for user:', ownedUserId, 'ogham')

// Generate comprehensive Ogham reading
const report = await oghamIntelligence.generateReading(userId, userProfile)
const report = await oghamIntelligence.generateReading(ownedUserId, userProfile)

devLog.info('✅ Ogham report generated successfully', undefined, 'ogham')

Expand All @@ -58,15 +73,17 @@ export async function POST(request: NextRequest) {
})
} catch (error) {
devLog.error('❌ Error generating Ogham report:', error, 'route')

return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to generate report',
details: process.env.NODE_ENV === 'development' ? String(error) : undefined,
},
{ status: 500 }
{ status: 500 },
)
}
}

export const POST = withRateLimit(handlePost, rateLimiters.ai, 'ogham_generate_report_post')

19 changes: 7 additions & 12 deletions lib/profileGenerationOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1220,18 +1220,13 @@ async function runTool(

case 'ogham': {
try {
const res = await fetch(`${baseUrl}/api/tools/ogham/generate-report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, userProfile: profile }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string })?.error ?? `Ogham API: ${res.status}`);
}
const json = await res.json();
const data = json.data ?? json;
return { status: 'success', data: (data as Record<string, unknown>) ?? {}, generatedAt, _usage: json._usage ?? json.usage };
const { oghamIntelligence } = await import('@/lib/oghamIntelligence');
const report = await oghamIntelligence.generateReading(userId, profile);
return {
status: 'success',
data: { report, generatedAt } as unknown as Record<string, unknown>,
generatedAt,
};
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
devLog.warn('[ProfileOrchestrator] Ogham failed:', msg, 'profileGenerationOrchestrator');
Expand Down
135 changes: 135 additions & 0 deletions tests/integration/ogham-generate-report-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Ogham generate-report must not be an unauthenticated Groq proxy
* or allow Admin oghamReadings IDOR via body userId.
* @jest-environment node
*/

import { NextRequest } from 'next/server';

const mockVerifyIdToken = jest.fn();
const mockGenerateReading = jest.fn();

jest.mock('@/lib/firebase-admin', () => ({
getAuth: () => ({ verifyIdToken: mockVerifyIdToken }),
}));

jest.mock('@/lib/oghamIntelligence', () => ({
oghamIntelligence: {
generateReading: (...args: unknown[]) => mockGenerateReading(...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 } from '@/app/api/tools/ogham/generate-report/route';

const completeProfile = {
birthDate: '1990-01-15',
birthTime: '14:30:00',
birthPlace: 'Dublin',
};

function makeBody(overrides: Record<string, unknown> = {}) {
return {
userId: 'user-1',
userProfile: completeProfile,
...overrides,
};
}

describe('POST /api/tools/ogham/generate-report auth', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGenerateReading.mockResolvedValue({
id: 'ogham_owned',
overview: 'owned ogham report',
});
});

it('rejects missing Authorization without Groq or Firestore', async () => {
const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(makeBody({ userId: 'victim' })),
});

const res = await POST(req);
expect(res.status).toBe(401);
expect(mockVerifyIdToken).not.toHaveBeenCalled();
expect(mockGenerateReading).not.toHaveBeenCalled();
});

it('rejects invalid token without Groq or Firestore', async () => {
mockVerifyIdToken.mockRejectedValueOnce(new Error('bad token'));
const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer bad',
},
body: JSON.stringify(makeBody({ userId: 'victim' })),
});

const res = await POST(req);
expect(res.status).toBe(401);
expect(mockGenerateReading).not.toHaveBeenCalled();
});

it('rejects userId mismatch (oghamReadings IDOR) without Groq', async () => {
mockVerifyIdToken.mockResolvedValueOnce({ uid: 'attacker', email: 'a@b.c' });
const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer good',
},
body: JSON.stringify(makeBody({ userId: 'victim' })),
});

const res = await POST(req);
expect(res.status).toBe(403);
expect(mockGenerateReading).not.toHaveBeenCalled();
});

it('rejects missing userId without Groq', async () => {
mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' });
const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer good',
},
body: JSON.stringify({ userProfile: completeProfile }),
});

const res = await POST(req);
expect(res.status).toBe(403);
expect(mockGenerateReading).not.toHaveBeenCalled();
});

it('allows owned userId and generates without treating body userId as victim', async () => {
mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' });
const req = new NextRequest('http://localhost/api/tools/ogham/generate-report', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer good',
},
body: JSON.stringify(makeBody()),
});

const res = await POST(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.success).toBe(true);
expect(json.data.report.id).toBe('ogham_owned');
expect(mockGenerateReading).toHaveBeenCalledTimes(1);
expect(mockGenerateReading).toHaveBeenCalledWith('user-1', completeProfile);
});
});
Loading