Files
Momento/memento-note/lib/entitlements.ts
Antigravity 88a7d2ad0a
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m59s
CI / Deploy production (on server) (push) Successful in 24s
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
2026-07-17 18:47:44 +00:00

541 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { redis } from './redis';
import { prisma } from './prisma';
import { hasAnyActiveByok } from './byok';
import {
getCurrentPeriodKey,
getRedisKey,
parseRedisInt,
isValidFeature,
VALID_FEATURES,
type FeatureName,
} from './quota-utils';
import {
getLimitAsync,
getTierFeaturesAsync,
invalidateEntitlementCache,
TIER_LIMITS,
type SubscriptionTier,
} from './plan-entitlements';
export interface EntitlementResult {
allowed: boolean;
remaining: number;
limit: number;
tier: SubscriptionTier;
reason?: 'QUOTA_EXCEEDED' | 'TIER_LIMITED' | 'FEATURE_NOT_AVAILABLE' | 'SERVICE_UNAVAILABLE';
message?: string;
upgradeTier?: 'PRO' | 'BUSINESS';
byokConfigured?: boolean;
}
export class QuotaServiceUnavailableError extends Error {
code = 'QUOTA_SERVICE_UNAVAILABLE';
constructor(message = 'Quota service temporarily unavailable') {
super(message);
}
}
export class QuotaExceededError extends Error {
code = 'QUOTA_EXCEEDED';
upgradeTier: 'PRO' | 'BUSINESS';
feature: string;
currentQuota: number;
usedQuota: number;
byokConfigured: boolean;
billingOwnerId?: string;
triggeredByUserId?: string;
isGuestActor?: boolean;
currentTier?: string;
constructor(
upgradeTier: 'PRO' | 'BUSINESS',
feature: string,
currentQuota: number,
usedQuota: number,
byokConfigured: boolean = false,
sessionMeta?: {
billingOwnerId?: string;
triggeredByUserId?: string;
isGuestActor?: boolean;
currentTier?: string;
},
) {
super(`Quota exceeded for ${feature}`);
this.upgradeTier = upgradeTier;
this.feature = feature;
this.currentQuota = currentQuota;
this.usedQuota = usedQuota;
this.byokConfigured = byokConfigured;
this.billingOwnerId = sessionMeta?.billingOwnerId;
this.triggeredByUserId = sessionMeta?.triggeredByUserId;
this.isGuestActor = sessionMeta?.isGuestActor;
this.currentTier = sessionMeta?.currentTier;
}
toJSON() {
return {
error: this.code,
feature: this.feature,
upgradeTier: this.upgradeTier,
byokConfigured: this.byokConfigured,
isGuestActor: this.isGuestActor ?? false,
currentTier: this.currentTier,
};
}
}
const TTL_SECONDS = 90 * 24 * 60 * 60;
function getPeriodDates(): { period: string; periodStart: Date; periodEnd: Date } {
const period = getCurrentPeriodKey();
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
const periodEnd = new Date(Date.UTC(periodStart.getUTCFullYear(), periodStart.getUTCMonth() + 1, 1));
return { period, periodStart, periodEnd };
}
async function getDatabaseUsageCounts(
userId: string,
features: string[],
): Promise<Record<string, number>> {
if (features.length === 0) return {};
const { periodStart } = getPeriodDates();
const rows = await prisma.usageLog.findMany({
where: { userId, periodStart, feature: { in: features } },
select: { feature: true, requestsCount: true },
});
return Object.fromEntries(rows.map((r) => [r.feature, r.requestsCount]));
}
async function mirrorUsageCountToDatabase(
userId: string,
feature: string,
requestsCount: number,
): Promise<void> {
const { periodStart, periodEnd } = getPeriodDates();
await prisma.usageLog.upsert({
where: {
userId_feature_periodStart: { userId, feature, periodStart },
},
create: {
userId,
feature,
periodStart,
periodEnd,
requestsCount,
tokensUsed: 0,
syncedAt: new Date(),
},
update: {
requestsCount,
syncedAt: new Date(),
},
}).catch((err) => {
console.error('[entitlements] Failed to mirror usage to DB:', err);
});
}
/** Fallback when Redis is unavailable — atomic increment in PostgreSQL. */
async function reserveUsageInDatabase(
userId: string,
feature: string,
limit: number,
amount: number = 1,
): Promise<number> {
const { periodStart, periodEnd } = getPeriodDates();
const units = Math.max(1, Math.floor(amount));
return prisma.$transaction(async (tx) => {
const existing = await tx.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current + units > limit) return -1;
const updated = await tx.usageLog.upsert({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
create: {
userId,
feature,
periodStart,
periodEnd,
requestsCount: units,
tokensUsed: 0,
syncedAt: new Date(),
},
update: {
requestsCount: { increment: units },
syncedAt: new Date(),
},
});
return updated.requestsCount;
});
}
function parseReserveResult(raw: unknown): number {
const n = typeof raw === 'number' ? raw : Number(raw);
return Number.isFinite(n) ? n : NaN;
}
function shouldFailClosedOnRedisError(): boolean {
return process.env.NODE_ENV === 'production';
}
const INCREMENT_BY_LUA = `
local count = tonumber(ARGV[1]) or 1
local ttl = tonumber(ARGV[2])
redis.call('INCRBY', KEYS[1], count)
local ttlResult = redis.call('TTL', KEYS[1])
if ttlResult == -1 then
redis.call('EXPIRE', KEYS[1], ttl)
end
local newCount = tonumber(redis.call('GET', KEYS[1]))
return newCount
`;
const RELEASE_LUA = `
local amount = tonumber(ARGV[1]) or 1
if amount < 1 then amount = 1 end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current <= 0 then return 0 end
local dec = math.min(amount, current)
redis.call('DECRBY', KEYS[1], dec)
return tonumber(redis.call('GET', KEYS[1]) or '0')
`;
/** Libère des crédits précédemment réservés (modèle A). */
export async function releaseUsage(
userId: string,
feature: string,
amount: number = 1,
): Promise<void> {
if (!isValidFeature(feature)) return;
const units = Math.max(1, Math.floor(Number(amount) || 1));
const { releaseCredits } = await import('@/lib/credits');
await releaseCredits(userId, units, { feature });
}
const RESERVE_LUA = `
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local amount = tonumber(ARGV[3]) or 1
if amount < 1 then amount = 1 end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current + amount > limit then
return -1
end
redis.call('INCRBY', KEYS[1], amount)
local ttlResult = redis.call('TTL', KEYS[1])
if ttlResult == -1 then
redis.call('EXPIRE', KEYS[1], ttl)
end
local newCount = tonumber(redis.call('GET', KEYS[1]))
return newCount
`;
export async function getUserInfo(
userId: string,
): Promise<{ tier: SubscriptionTier; status: string; currentPeriodEnd?: Date }> {
const subscription = await prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) return { tier: 'BASIC', status: 'INACTIVE' };
return {
tier: subscription.tier as SubscriptionTier,
status: subscription.status,
currentPeriodEnd: subscription.currentPeriodEnd,
};
}
export async function getEffectiveTier(
userId: string,
): Promise<SubscriptionTier> {
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
if (status === 'ACTIVE' || status === 'TRIALING') return tier;
if ((status === 'PAST_DUE' || status === 'CANCELED') && currentPeriodEnd) {
if (new Date() < new Date(currentPeriodEnd)) return tier;
}
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,
): Promise<EntitlementResult> {
if (!isValidFeature(feature)) {
return {
allowed: false,
remaining: 0,
limit: 0,
tier: 'BASIC',
reason: 'TIER_LIMITED',
message: `Unknown feature: ${feature}`,
};
}
const tier = await getEffectiveTier(userId);
const limit = await getLimitAsync(tier, feature);
if (limit === undefined) {
return {
allowed: false,
remaining: 0,
limit: 0,
tier,
reason: 'FEATURE_NOT_AVAILABLE',
message: `Feature "${feature}" is not available on the ${tier} plan. Upgrade to unlock it.`,
upgradeTier: tier === 'BASIC' ? 'PRO' : 'BUSINESS',
};
}
if (limit === Infinity) {
return { allowed: true, remaining: Infinity, limit: Infinity, tier };
}
try {
const key = getRedisKey(userId, feature);
const currentStr = await redis.get(key);
const redisCurrent = parseRedisInt(currentStr);
const dbCounts = await getDatabaseUsageCounts(userId, [feature]);
const current = Math.max(redisCurrent, dbCounts[feature] ?? 0);
const allowed = current < limit;
if (!allowed) {
const byokConfigured = await hasAnyActiveByok(userId);
return {
allowed: false,
remaining: 0,
limit,
tier,
reason: 'QUOTA_EXCEEDED',
upgradeTier: tier === 'BASIC' ? 'PRO' : 'BUSINESS',
byokConfigured,
};
}
return {
allowed: true,
remaining: Math.max(0, limit - current),
limit,
tier,
byokConfigured: await hasAnyActiveByok(userId),
};
} catch (err) {
console.error('[entitlements] Redis unavailable:', err);
if (shouldFailClosedOnRedisError()) {
return {
allowed: false,
remaining: 0,
limit,
tier,
reason: 'SERVICE_UNAVAILABLE',
message: 'Quota service temporarily unavailable. Please try again later.',
};
}
return { allowed: true, remaining: limit, limit, tier };
}
}
export async function checkEntitlementOrThrow(
userId: string,
feature: string,
): Promise<void> {
const result = await canUseFeature(userId, feature);
if (!result.allowed) {
throw new QuotaExceededError(
result.upgradeTier ?? (result.tier === 'BASIC' ? 'PRO' : 'BUSINESS'),
feature,
result.limit,
result.limit > 0 ? result.limit - result.remaining : 0,
result.byokConfigured ?? false,
{ currentTier: result.tier },
);
}
}
/** Mappe une feature de quota vers la voie IA (pour le BYOK). */
function featureToAiLane(feature: string): 'chat' | 'tags' | 'embedding' {
if (feature === 'auto_tag' || feature === 'auto_title') return 'tags';
if (feature === 'semantic_search') return 'embedding';
return 'chat';
}
/**
* @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.
*/
export async function reserveUsageOrThrow(
userId: string,
feature: string,
amount: number = 1,
): Promise<void> {
if (!isValidFeature(feature)) {
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
}
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). */
export function slideGenerateQuotaUnits(slideCount?: number | null): number {
// 1 outline + N expand calls (clamped). Reflects real multi-call cost.
const n = typeof slideCount === 'number' && Number.isFinite(slideCount)
? Math.min(8, Math.max(3, Math.round(slideCount)))
: 6;
return 1 + n;
}
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
export async function checkSessionEntitlementOrThrow(
billingOwnerId: string,
triggeredByUserId: string,
isGuestActor: boolean,
feature: string,
): Promise<void> {
try {
await reserveUsageOrThrow(billingOwnerId, feature);
} catch (err) {
if (err instanceof QuotaExceededError) {
err.billingOwnerId = billingOwnerId;
err.triggeredByUserId = triggeredByUserId;
err.isGuestActor = isGuestActor;
}
throw err;
}
}
export function incrementUsageAsync(userId: string, feature: string, count: number = 1): Promise<void> {
if (!isValidFeature(feature)) return Promise.resolve();
const key = getRedisKey(userId, feature);
return redis.eval(INCREMENT_BY_LUA, 1, key, String(count), String(TTL_SECONDS)).then(() => {}).catch((err) => {
console.error('[entitlements] Async increment failed:', err);
});
}
export type ResetUserUsageOptions = {
/** Si fourni, ne remet à zéro que ces fonctionnalités. Sinon : toutes. */
features?: string[];
};
/**
* Remet à zéro la consommation IA dun utilisateur pour la période mensuelle en cours
* (compteurs Redis + lignes UsageLog en base).
* Réservé aux actions admin.
*/
export async function resetUserUsageForCurrentPeriod(
userId: string,
options?: ResetUserUsageOptions,
): Promise<{ period: string; featuresReset: string[]; redisDeleted: number; dbUpdated: number }> {
if (!userId?.trim()) {
throw new Error('userId required');
}
const period = getCurrentPeriodKey();
const { periodStart } = getPeriodDates();
const features = (options?.features?.length
? options.features.filter((f) => isValidFeature(f))
: [...VALID_FEATURES]) as string[];
if (features.length === 0) {
return { period, featuresReset: [], redisDeleted: 0, dbUpdated: 0 };
}
const keys = features.map((f) => getRedisKey(userId, f));
let redisDeleted = 0;
try {
if (keys.length === 1) {
redisDeleted = await redis.del(keys[0]);
} else {
redisDeleted = await redis.del(...keys);
}
} catch (err) {
console.error('[entitlements] resetUserUsage Redis del failed:', err);
}
const dbResult = await prisma.usageLog.updateMany({
where: {
userId,
periodStart,
feature: { in: features },
},
data: {
requestsCount: 0,
tokensUsed: 0,
syncedAt: new Date(),
},
});
return {
period,
featuresReset: features,
redisDeleted,
dbUpdated: dbResult.count,
};
}
export async function getUserQuotas(
userId: string,
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
const tier = await getEffectiveTier(userId);
const features = await getTierFeaturesAsync(tier);
const period = getCurrentPeriodKey();
if (features.length === 0) return {};
const keys = features.map((f) => `usage:${userId}:${f}:${period}`);
try {
const values = await redis.mget(...keys);
const dbCounts = await getDatabaseUsageCounts(userId, features);
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
for (let i = 0; i < features.length; i++) {
const feature = features[i];
const limit = (await getLimitAsync(tier, feature)) ?? 0;
const redisCurrent = parseRedisInt(values[i]);
const dbCurrent = dbCounts[feature] ?? 0;
const current = Math.max(redisCurrent, dbCurrent);
result[feature] = {
remaining: limit === Infinity ? Infinity : Math.max(0, limit - current),
limit,
used: current,
};
}
return result;
} catch (err) {
console.error('[entitlements] getUserQuotas Redis error:', err);
if (shouldFailClosedOnRedisError()) {
throw new QuotaServiceUnavailableError();
}
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
for (const feature of features) {
const limit = (await getLimitAsync(tier, feature)) ?? 0;
result[feature] = { remaining: limit, limit, used: 0 };
}
return result;
}
}
export { TIER_LIMITS, invalidateEntitlementCache };
export type { SubscriptionTier };
/** @deprecated Use getLimitAsync — sync helper for tests only */
export async function getLimit(tier: SubscriptionTier, feature: string): Promise<number | undefined> {
return getLimitAsync(tier, feature);
}