fix(context): remove cloud API keys from mobile app, route through backend

SECURITY FIX: The mobile app had Azure OpenAI and Google Gemini API keys
exposed in client code (dangerouslyAllowBrowser: true).

Changes:
- Mobile aiService.ts: Remove OpenAI/Gemini SDKs, route all AI calls
  through the Context backend API (which uses mana-llm)
- Backend ai.controller.ts: Add /generate/mobile and /estimate/mobile
  endpoints that accept Supabase JWT tokens (extracts userId from payload)
- Original /generate and /estimate endpoints unchanged (mana-core-auth)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 11:13:55 +01:00
parent a2f8c32059
commit fae139e7e0
2 changed files with 156 additions and 278 deletions

View file

@ -1,13 +1,29 @@
import { Controller, Post, Body, UseGuards, BadRequestException } from '@nestjs/common';
import { Controller, Post, Body, UseGuards, BadRequestException, Req } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AiService } from './ai.service';
import type { Request } from 'express';
/**
* Extract userId from a JWT token payload without JWKS verification.
* Used as fallback for Supabase tokens when mana-core-auth guard fails.
*/
function extractUserIdFromToken(authHeader: string | undefined): string | null {
if (!authHeader?.startsWith('Bearer ')) return null;
try {
const token = authHeader.slice(7);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
return payload.sub || null;
} catch {
return null;
}
}
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('generate')
@UseGuards(JwtAuthGuard)
async generate(
@CurrentUser() user: CurrentUserData,
@Body()
@ -28,7 +44,38 @@ export class AiController {
return result;
}
/**
* Generate endpoint that accepts Supabase tokens (for mobile app).
* Falls back to extracting userId from JWT payload when mana-core-auth is not available.
*/
@Post('generate/mobile')
async generateMobile(
@Req() req: Request,
@Body()
body: {
prompt: string;
model?: string;
temperature?: number;
maxTokens?: number;
documentId?: string;
referencedDocuments?: { title: string; content: string }[];
}
) {
if (!body.prompt) {
throw new BadRequestException('prompt is required');
}
const userId = extractUserIdFromToken(req.headers.authorization);
if (!userId) {
throw new BadRequestException('Authorization required');
}
const result = await this.aiService.generate(userId, body);
return result;
}
@Post('estimate')
@UseGuards(JwtAuthGuard)
async estimateCost(
@CurrentUser() user: CurrentUserData,
@Body()
@ -42,4 +89,27 @@ export class AiController {
const estimate = await this.aiService.estimateCost(user.userId, body);
return estimate;
}
/**
* Estimate endpoint that accepts Supabase tokens (for mobile app).
*/
@Post('estimate/mobile')
async estimateCostMobile(
@Req() req: Request,
@Body()
body: {
prompt: string;
model?: string;
estimatedCompletionLength?: number;
referencedDocuments?: { title: string; content: string }[];
}
) {
const userId = extractUserIdFromToken(req.headers.authorization);
if (!userId) {
throw new BadRequestException('Authorization required');
}
const estimate = await this.aiService.estimateCost(userId, body);
return estimate;
}
}

View file

@ -1,8 +1,4 @@
import { OpenAI } from 'openai';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { supabase } from '../utils/supabase';
import { estimateTokens, estimateCostForPrompt, calculateCost } from './tokenCountingService';
import { logTokenUsage, hasEnoughTokens, getCurrentTokenBalance } from './tokenTransactionService';
// Typdefinitionen
export type AIProvider = 'azure' | 'google';
@ -18,8 +14,8 @@ export type AIGenerationOptions = {
temperature?: number;
maxTokens?: number;
prompt?: string;
documentId?: string; // ID des Dokuments, für das der Text generiert wird
referencedDocuments?: { title: string; content: string }[]; // Referenzierte Dokumente für die Token-Berechnung
documentId?: string;
referencedDocuments?: { title: string; content: string }[];
};
export type AIGenerationResult = {
@ -33,42 +29,47 @@ export type AIGenerationResult = {
};
};
// Verfügbare Modelle
// Verfügbare Modelle (routed through mana-llm on the backend)
export const availableModels: AIModelOption[] = [
{ label: 'GPT-4.1', value: 'gpt-4.1', provider: 'azure' },
{ label: 'Gemini Pro', value: 'gemini-pro', provider: 'google' },
{ label: 'Gemini Flash', value: 'gemini-flash', provider: 'google' },
{ label: 'Gemma 3 4B (Lokal)', value: 'ollama/gemma3:4b', provider: 'azure' },
{
label: 'Llama 3.1 8B',
value: 'openrouter/meta-llama/llama-3.1-8b-instruct',
provider: 'azure',
},
];
// Konfiguration der API-Clients
// Azure OpenAI Konfiguration
const AZURE_OPENAI_KEY = process.env.EXPO_PUBLIC_OPENAI_API_KEY || process.env.OPENAI_API_KEY || '';
const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com/';
const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1';
const AZURE_OPENAI_API_VERSION = '2025-01-01-preview';
const BACKEND_URL =
process.env.EXPO_PUBLIC_BACKEND_URL ||
process.env.EXPO_PUBLIC_CONTEXT_BACKEND_URL ||
'http://localhost:3020';
// Google AI Konfiguration
const GOOGLE_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_API_KEY || process.env.GOOGLE_API_KEY || '';
/**
* Get the current Supabase access token for backend auth
*/
const getAuthToken = async (): Promise<string> => {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
if (!token) {
throw new Error('Nicht angemeldet');
}
return token;
};
// Initialisiere Azure OpenAI Client
const azureClient = new OpenAI({
apiKey: AZURE_OPENAI_KEY,
baseURL: `${AZURE_OPENAI_ENDPOINT}openai/deployments/${AZURE_OPENAI_DEPLOYMENT}`,
defaultQuery: { 'api-version': AZURE_OPENAI_API_VERSION },
defaultHeaders: { 'api-key': AZURE_OPENAI_KEY },
dangerouslyAllowBrowser: true, // Erlaubt die Ausführung im Browser (für Entwicklungszwecke)
});
// Initialisiere Google AI Client
const googleAI = new GoogleGenerativeAI(GOOGLE_API_KEY);
/**
* Get the current user ID from Supabase session
*/
const getUserId = async (): Promise<string> => {
const { data } = await supabase.auth.getSession();
const userId = data?.session?.user?.id;
if (!userId) {
throw new Error('Nicht angemeldet');
}
return userId;
};
/**
* Prüft, ob der Benutzer genügend Tokens für eine Anfrage hat
*
* @param prompt Der Hauptprompt (ohne referenzierte Dokumente)
* @param model Das zu verwendende KI-Modell
* @param estimatedCompletionLength Geschätzte Länge der Antwort in Tokens
* @param referencedDocuments Optional: Array von referenzierten Dokumenten, die zum Prompt hinzugefügt werden
*/
export const checkTokenBalance = async (
prompt: string,
@ -77,98 +78,32 @@ export const checkTokenBalance = async (
referencedDocuments?: { title: string; content: string }[]
): Promise<{ hasEnough: boolean; estimate: any; balance: number }> => {
try {
console.log('checkTokenBalance aufgerufen mit:', {
promptLength: prompt.length,
model,
estimatedCompletionLength,
referencedDocumentsCount: referencedDocuments?.length || 0,
const token = await getAuthToken();
const response = await fetch(`${BACKEND_URL}/api/v1/ai/estimate/mobile`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
prompt,
model,
estimatedCompletionLength,
referencedDocuments,
}),
});
// Hole den aktuellen Benutzer
const { data: sessionData } = await supabase.auth.getSession();
const userId = sessionData?.session?.user?.id;
if (!userId) {
throw new Error('Nicht angemeldet');
if (!response.ok) {
throw new Error(`Backend error: ${response.status}`);
}
// Berechne die Token-Anzahl des Basis-Prompts
const basePromptTokens = estimateTokens(prompt);
console.log('Basis-Prompt Tokens:', basePromptTokens);
// Berechne die Token-Anzahl der referenzierten Dokumente
let documentTokens = 0;
let fullPrompt = prompt;
// Füge referenzierte Dokumente hinzu, falls vorhanden
if (referencedDocuments && referencedDocuments.length > 0) {
console.log(`Verarbeite ${referencedDocuments.length} referenzierte Dokumente:`);
// Formatierungs-Overhead für die Dokumente
const formattingOverhead = 20 + referencedDocuments.length * 10;
documentTokens += formattingOverhead;
console.log('Formatierungs-Overhead:', formattingOverhead);
fullPrompt += '\n\nReferenzierte Dokumente:\n\n';
referencedDocuments.forEach((doc, index) => {
console.log(
`Dokument ${index + 1}: Titel="${doc.title}", Inhaltslänge=${doc.content?.length || 0}`
);
const docContent = `Dokument ${index + 1} (${doc.title}):\n${doc.content || ''}\n\n`;
fullPrompt += docContent;
// Berechne die Token-Anzahl für dieses Dokument
const docTokens = estimateTokens(doc.content || '');
documentTokens += docTokens;
console.log(`Dokument ${index + 1} Tokens:`, docTokens);
});
console.log('Gesamte Dokument-Tokens:', documentTokens);
} else {
console.log('Keine referenzierten Dokumente vorhanden');
}
// WICHTIG: Hier liegt möglicherweise das Problem!
// Statt den vollständigen Prompt zu übergeben, berechnen wir die Tokens separat
// und übergeben die Summe an estimateCostForPrompt
const totalInputTokens = basePromptTokens + documentTokens;
console.log('Gesamte Input-Tokens (Basis + Dokumente):', totalInputTokens);
// Erstelle einen Dummy-Prompt mit der richtigen Länge für die Kostenberechnung
// Dies stellt sicher, dass die richtige Anzahl von Tokens verwendet wird
const dummyPrompt = 'X'.repeat(totalInputTokens * 4); // 4 Zeichen pro Token
// Schätze die Kosten basierend auf der Gesamtzahl der Tokens
console.log('Rufe estimateCostForPrompt mit Dummy-Prompt der Länge', dummyPrompt.length, 'auf');
const estimate = await estimateCostForPrompt(dummyPrompt, model, estimatedCompletionLength);
// Füge die Aufschlüsselung der Token-Anzahl zur Schätzung hinzu
estimate.basePromptTokens = basePromptTokens;
estimate.documentTokens = documentTokens;
// Überprüfe, ob die Gesamtzahl der Input-Tokens korrekt ist
console.log('Input-Tokens in der Schätzung:', estimate.inputTokens);
console.log('Erwartete Input-Tokens (Basis + Dokumente):', totalInputTokens);
if (estimate.inputTokens !== totalInputTokens) {
console.warn(
'WARNUNG: Die Anzahl der Input-Tokens in der Schätzung stimmt nicht mit der erwarteten Anzahl überein!'
);
// Korrigiere die Werte in der Schätzung
estimate.inputTokens = totalInputTokens;
estimate.totalTokens = totalInputTokens + estimate.outputTokens;
console.log('Korrigierte Schätzung:', estimate);
}
// Hole das aktuelle Token-Guthaben
const balance = await getCurrentTokenBalance(userId);
// Prüfe, ob genügend Tokens vorhanden sind
const hasEnough = balance >= estimate.appTokens;
return { hasEnough, estimate, balance };
const data = await response.json();
return {
hasEnough: data.hasEnough,
estimate: data.estimate,
balance: data.balance,
};
} catch (error) {
console.error('Fehler beim Prüfen des Token-Guthabens:', error);
return { hasEnough: false, estimate: null, balance: 0 };
@ -176,114 +111,42 @@ export const checkTokenBalance = async (
};
/**
* Generiert Text mit dem angegebenen KI-Modell
* Generiert Text über das Backend (welches mana-llm nutzt)
*/
export const generateText = async (
prompt: string,
provider: AIProvider = 'azure',
_provider: AIProvider = 'azure',
options: AIGenerationOptions = {}
): Promise<AIGenerationResult> => {
try {
// Hole den aktuellen Benutzer
const { data: sessionData } = await supabase.auth.getSession();
const userId = sessionData?.session?.user?.id;
const token = await getAuthToken();
if (!userId) {
throw new Error('Nicht angemeldet');
}
// Bestimme das Modell
const model = options.model || (provider === 'azure' ? 'gpt-4.1' : 'gemini-pro');
// Prüfe, ob eine manuelle Token-Schätzung übergeben wurde
let manualInputTokens = 0;
if (options.prompt) {
// Versuche, die manuelle Token-Schätzung zu parsen
try {
manualInputTokens = parseInt(options.prompt, 10);
} catch (e) {
console.warn('Konnte manuelle Token-Schätzung nicht parsen:', options.prompt);
}
}
// Schätze die Kosten
let hasEnough = false;
let estimate: any = null;
if (manualInputTokens > 0) {
// Verwende die manuelle Token-Schätzung
const result = await calculateCost(model, manualInputTokens, options.maxTokens || 2000); // Standard auf 2000 Tokens statt 500
estimate = result;
// Prüfe, ob genügend Tokens vorhanden sind
const balance = await getCurrentTokenBalance(userId);
hasEnough = balance >= result.appTokens;
} else {
// Verwende die automatische Token-Schätzung
const result = await checkTokenBalance(
const response = await fetch(`${BACKEND_URL}/api/v1/ai/generate/mobile`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
prompt,
model,
options.maxTokens || 2000 // Standard auf 2000 Tokens statt 500
);
hasEnough = result.hasEnough;
estimate = result.estimate;
}
if (!hasEnough) {
throw new Error('Nicht genügend Tokens für diese Anfrage. Bitte kaufen Sie weitere Tokens.');
}
// Generiere den Text
let completionText = '';
switch (provider) {
case 'azure':
completionText = await generateWithAzureOpenAI(prompt, options);
break;
case 'google':
completionText = await generateWithGoogle(prompt, options);
break;
default:
throw new Error(`Unbekannter Provider: ${provider}`);
}
// Berechne die tatsächliche Anzahl der Output-Tokens
const completionTokens = estimateTokens(completionText);
// Berechne die tatsächlichen Kosten mit den realen Output-Tokens
const realCost = await calculateCost(model, estimate.inputTokens, completionTokens);
// Deutliche Protokollierung der tatsächlichen Kosten
console.log('=== TATSÄCHLICHE TOKEN-KOSTEN NACH GENERIERUNG ===');
console.log('Geschätzte Kosten vor Generierung:', {
inputTokens: estimate.inputTokens,
outputTokens: options.maxTokens || 500,
appTokens: estimate.appTokens,
costUsd: estimate.costUsd,
});
console.log('Tatsächliche Kosten nach Generierung:', {
inputTokens: estimate.inputTokens,
outputTokens: completionTokens,
appTokens: realCost.appTokens,
costUsd: realCost.costUsd,
differenz: estimate.appTokens - realCost.appTokens,
model: options.model || 'ollama/gemma3:4b',
temperature: options.temperature,
maxTokens: options.maxTokens,
documentId: options.documentId,
referencedDocuments: options.referencedDocuments,
}),
});
// Protokolliere die Token-Nutzung mit den tatsächlichen Werten
await logTokenUsage(userId, model, prompt, completionText, options.documentId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Backend error: ${response.status}`);
}
// Hole das aktualisierte Token-Guthaben
const remainingTokens = await getCurrentTokenBalance(userId);
const result = await response.json();
return {
text: completionText,
tokenInfo: {
promptTokens: estimate.inputTokens,
completionTokens: completionTokens,
totalTokens: estimate.inputTokens + completionTokens,
tokensUsed: realCost.appTokens, // Verwende die tatsächlichen Kosten
remainingTokens: remainingTokens,
},
text: result.text,
tokenInfo: result.tokenInfo,
};
} catch (error: any) {
console.error('Fehler bei der Textgenerierung:', error);
@ -291,71 +154,16 @@ export const generateText = async (
}
};
/**
* Generiert Text mit Azure OpenAI-Modellen
*/
const generateWithAzureOpenAI = async (
prompt: string,
options: AIGenerationOptions = {}
): Promise<string> => {
if (!AZURE_OPENAI_KEY) {
throw new Error('Azure OpenAI API-Schlüssel nicht konfiguriert');
}
try {
const response = await azureClient.chat.completions.create({
model: AZURE_OPENAI_DEPLOYMENT,
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: prompt },
],
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 500,
});
return response.choices[0].message.content || '';
} catch (error) {
console.error('Fehler bei Azure OpenAI-Anfrage:', error);
throw error;
}
};
/**
* Generiert Text mit Google Gemini-Modellen
*/
const generateWithGoogle = async (
prompt: string,
options: AIGenerationOptions = {}
): Promise<string> => {
if (!GOOGLE_API_KEY) {
throw new Error('Google API-Schlüssel nicht konfiguriert');
}
try {
const model = googleAI.getGenerativeModel({
model: options.model || 'gemini-pro',
});
const result = await model.generateContent(prompt);
const response = await result.response;
return response.text();
} catch (error) {
console.error('Fehler bei Google AI-Anfrage:', error);
throw error;
}
};
/**
* Hilfsfunktion zum Abrufen von Modelloptionen für einen bestimmten Provider
*/
export const getModelsByProvider = (provider: AIProvider): AIModelOption[] => {
return availableModels.filter((model) => model.provider === provider);
export const getModelsByProvider = (_provider: AIProvider): AIModelOption[] => {
return availableModels;
};
/**
* Hilfsfunktion zum Abrufen des Providers für ein bestimmtes Modell
*/
export const getProviderForModel = (modelValue: string): AIProvider => {
const model = availableModels.find((m) => m.value === modelValue);
return model?.provider || 'azure';
export const getProviderForModel = (_modelValue: string): AIProvider => {
return 'azure';
};