fix(audit): nettoyage accès, garde-fous build, unification quotas
Semaine 1 — fuites et accès : - /archive rendu accessible via sidebar (était invisible) - Suppression pages orphelines : /chat, /graph, /support, test-title-suggestions - Fixes i18n : search-modal (clé non interpolée), sidebar tooltips, export PDF - 15 locales mises à jour Semaine 2 — garde-fous : - 8 erreurs TS fixees → ignoreBuildErrors: false (build type-safe) - reactStrictMode: true (dev-only, zero impact prod) - reserveUsageOrThrow → wrapper deprecie vers reserveAiUsageOrThrow - auth-guard.ts : requireAuthOrResponse() + requireAdminOrResponse() Tests 212/212 | Lint 0 erreur | Build OK | tsc 0 erreur
This commit is contained in:
@@ -251,8 +251,8 @@ Rules:
|
||||
|
||||
const raw = await provider.generateText(prompt)
|
||||
const parsed = this.parseJsonObject(raw)
|
||||
const titles = Array.isArray(parsed?.titles)
|
||||
? parsed.titles.map((t: unknown) => String(t || '').trim()).filter(Boolean).slice(0, count)
|
||||
const titles: string[] = Array.isArray(parsed?.titles)
|
||||
? (parsed.titles as unknown[]).map((t) => String(t || '').trim()).filter(Boolean).slice(0, count)
|
||||
: []
|
||||
while (titles.length < count) {
|
||||
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { auth } from '@/auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type AuthResult =
|
||||
| { ok: true; userId: string }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
/**
|
||||
* Retrieves the authenticated user ID or throws an Unauthorized error.
|
||||
* Use this in Server Actions and API routes to eliminate the repetitive
|
||||
* `const session = await auth(); if (!session?.user?.id) ...` boilerplate.
|
||||
* Use in Server Actions where throw semantics are preferred.
|
||||
*
|
||||
* @throws {Error} 'Unauthorized' if no valid session exists.
|
||||
* @returns The authenticated user's ID string.
|
||||
@@ -15,3 +20,54 @@ export async function requireAuth(): Promise<string> {
|
||||
}
|
||||
return session.user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth guard for API routes — returns a 401 NextResponse instead of throwing.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const auth = await requireAuthOrResponse()
|
||||
* if (!auth.ok) return auth.response
|
||||
* const userId = auth.userId
|
||||
* ```
|
||||
*
|
||||
* @param format 'plain' (default) returns `{ error: 'Unauthorized' }`.
|
||||
* 'success' returns `{ success: false, error: 'Unauthorized' }`.
|
||||
*/
|
||||
export async function requireAuthOrResponse(
|
||||
format: 'plain' | 'success' = 'plain',
|
||||
): Promise<AuthResult> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
const body = format === 'success'
|
||||
? { success: false, error: 'Unauthorized' }
|
||||
: { error: 'Unauthorized' }
|
||||
return { ok: false, response: NextResponse.json(body, { status: 401 }) }
|
||||
}
|
||||
return { ok: true, userId: session.user.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin guard for API routes — checks user role in DB, returns 401/403 on failure.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const auth = await requireAdminOrResponse()
|
||||
* if (!auth.ok) return auth.response
|
||||
* const userId = auth.userId
|
||||
* ```
|
||||
*/
|
||||
export async function requireAdminOrResponse(): Promise<AuthResult> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true },
|
||||
})
|
||||
if (user?.role !== 'ADMIN') {
|
||||
return { ok: false, response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
|
||||
}
|
||||
return { ok: true, userId: session.user.id }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { redis } from '@/lib/redis'
|
||||
import { getCurrentPeriodKey, VALID_FEATURES, type FeatureName } from '@/lib/quota-utils'
|
||||
import type { SubscriptionTier } from '@/lib/plan-entitlements'
|
||||
import { QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
// Imports dynamiques vers entitlements pour éviter les cycles
|
||||
// (entitlements délègue aussi vers credits pour reserveUsageOrThrow).
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
parseRedisInt,
|
||||
isValidFeature,
|
||||
VALID_FEATURES,
|
||||
type FeatureName,
|
||||
} from './quota-utils';
|
||||
import {
|
||||
getLimitAsync,
|
||||
@@ -261,6 +262,11 @@ export async function getEffectiveTier(
|
||||
return 'BASIC';
|
||||
}
|
||||
|
||||
// NOTE: canUseFeature reads the old per-feature Redis counters + UsageLog table.
|
||||
// Since reserveUsageOrThrow now delegates to the unified credits engine (AiCreditAccount),
|
||||
// these counters are no longer written to — this function returns stale/zero usage data.
|
||||
// Only caller: checkEntitlementOrThrow → app/api/ai/tags/route.ts (pre-check that always passes).
|
||||
// Actual enforcement happens via reserveAiUsageOrThrow at the debit site.
|
||||
export async function canUseFeature(
|
||||
userId: string,
|
||||
feature: string,
|
||||
@@ -365,10 +371,10 @@ function featureToAiLane(feature: string): 'chat' | 'tags' | 'embedding' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Réserve des crédits globaux (modèle A).
|
||||
* @deprecated Use `reserveAiUsageOrThrow` from `@/lib/ai-quota` instead.
|
||||
* Thin wrapper kept for backward compat — delegates to the unified credits engine.
|
||||
* `amount` = coût en crédits (ex. slides multi-étapes : 1+N).
|
||||
* Si l'utilisateur a une clé perso (BYOK) pour la voie concernée, aucun débit.
|
||||
* Conserve le nom historique pour ne pas casser tous les appelants.
|
||||
*/
|
||||
export async function reserveUsageOrThrow(
|
||||
userId: string,
|
||||
@@ -378,17 +384,8 @@ export async function reserveUsageOrThrow(
|
||||
if (!isValidFeature(feature)) {
|
||||
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
|
||||
}
|
||||
|
||||
const { getSystemConfig } = await import('@/lib/config');
|
||||
const { willUseByokForLane } = await import('@/lib/ai/provider-for-user');
|
||||
const config = await getSystemConfig();
|
||||
const lane = featureToAiLane(feature);
|
||||
const { usedByok } = await willUseByokForLane(lane, config, userId);
|
||||
if (usedByok) return;
|
||||
|
||||
const { reserveCreditsOrThrow, resolveCreditCost } = await import('@/lib/credits');
|
||||
const cost = resolveCreditCost(feature, { amount });
|
||||
await reserveCreditsOrThrow(userId, cost, { feature });
|
||||
const { reserveAiUsageOrThrow } = await import('@/lib/ai-quota');
|
||||
await reserveAiUsageOrThrow(userId, feature as FeatureName, { amount });
|
||||
}
|
||||
|
||||
/** Units charged for one multi-step slide generation (outline + per-slide expand). */
|
||||
|
||||
Reference in New Issue
Block a user