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
5 changes: 3 additions & 2 deletions app/api/profile/edit-quota/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ export async function GET(request: NextRequest) {
}

let uid: string;
let authEmail: string | undefined;
try {
const decoded = await getAuth().verifyIdToken(idToken);
uid = decoded.uid;
authEmail = decoded.email;
} catch {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
Expand All @@ -54,8 +56,7 @@ export async function GET(request: NextRequest) {
canGenerate: true,
});
}
const email = (user.email ?? user.Email) as string | undefined;
if (isNoChargeSubscriptionEmail(email)) {
if (isNoChargeSubscriptionEmail(authEmail)) {
return NextResponse.json({
count: 0,
limit: 8,
Expand Down
12 changes: 8 additions & 4 deletions app/api/profile/generate-mystical/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
isNoChargeSubscriptionEmail,
} from '@/lib/subscriptionConfig';
import { consumeBillingAction } from '@/lib/billingCreditsServer';
import { hasUnlimitedBillingAccess } from '@/lib/billingAccess';
import { hasUnlimitedBillingAccess, withTrustedBillingEmail } from '@/lib/billingAccess';
import { logServerError } from '@/lib/serverErrorLogging';
import { rateLimiters } from '@/lib/rateLimit';
import { checkRateLimitWithOptionalFirestore } from '@/lib/rateLimitFirestore';
Expand Down Expand Up @@ -126,6 +126,7 @@ async function writeRegenDecisionTelemetry(

export async function POST(request: NextRequest) {
let uid: string | undefined;
let authEmail: string | undefined;
try {
const baseUrlSource = resolveBaseUrlSource();
if (!ensureAdminAvailable('POST /api/profile/generate-mystical')) {
Expand All @@ -151,6 +152,7 @@ export async function POST(request: NextRequest) {
try {
const decoded = await getAuth().verifyIdToken(idToken);
uid = decoded.uid;
authEmail = decoded.email;
} catch {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
Expand Down Expand Up @@ -192,10 +194,9 @@ export async function POST(request: NextRequest) {
);
}

const email = (userProfile.email ?? userProfile.Email) as string | undefined;
// Onboarding is intentionally low-friction: generation should not be blocked by payment/plan choice.
// Keep this read so existing no-charge account logic remains compatible for downstream analytics/meta.
void isNoChargeSubscriptionEmail(email);
void isNoChargeSubscriptionEmail(authEmail);

// Launch hotfix: do not block mystical profile generation by edit quota.
// We keep counting edits elsewhere so telemetry remains intact.
Expand Down Expand Up @@ -251,7 +252,10 @@ export async function POST(request: NextRequest) {
);
}
if (!isFirstOnboardingGeneration) {
const profileForBilling = profileWithUid as Partial<UserProfile>;
const profileForBilling = withTrustedBillingEmail(
profileWithUid as Partial<UserProfile>,
authEmail,
);
if (!hasUnlimitedBillingAccess(profileForBilling)) {
const billing = await consumeBillingAction(uid, 'profile_regen');
if (!billing.ok) {
Expand Down
5 changes: 3 additions & 2 deletions app/api/profile/record-edit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ export async function POST(request: NextRequest) {
}

let uid: string;
let authEmail: string | undefined;
try {
const decoded = await getAuth().verifyIdToken(idToken);
uid = decoded.uid;
authEmail = decoded.email;
} catch {
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
Expand All @@ -57,8 +59,7 @@ export async function POST(request: NextRequest) {
canGenerate: true,
});
}
const email = (user.email ?? user.Email) as string | undefined;
if (isNoChargeSubscriptionEmail(email)) {
if (isNoChargeSubscriptionEmail(authEmail)) {
return NextResponse.json({
count: 0,
limit: 8,
Expand Down
1 change: 1 addition & 0 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ service cloud.firestore {
'subscriptionId',
'subscriptionStatus',
'noChargeAccount',
'email',
'trialEndDate',
'trialEndTime',
'nextBillingDate',
Expand Down
20 changes: 20 additions & 0 deletions lib/billingAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@ import { hasActiveSubscriptionAccess } from '@/lib/authRouting';
import { isNoChargeSubscriptionEmail } from '@/lib/subscriptionConfig';
import type { BillingUserFields } from '@/lib/billingTypes';

/**
* Email used for no-charge allowlist checks. Only Firebase Auth email is trusted.
* Firestore `users.email` is client-writable and must not grant unlimited billing.
*/
export function trustedBillingEmail(
authEmail: string | null | undefined,
): string | undefined {
if (typeof authEmail !== 'string') return undefined;
const trimmed = authEmail.trim();
return trimmed.length > 0 ? trimmed : undefined;
}

/** Replace any client-supplied email with the Auth token / Admin Auth email. */
export function withTrustedBillingEmail<T extends BillingUserFields>(
profile: T,
authEmail: string | null | undefined,
): T {
return { ...profile, email: trustedBillingEmail(authEmail) };
}

/** Unlimited AI / regen — active membership or comp accounts. */
export function hasUnlimitedBillingAccess(profile: BillingUserFields | null | undefined): boolean {
if (!profile) return false;
Expand Down
19 changes: 15 additions & 4 deletions lib/billingCreditsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import 'server-only';

import { FieldValue } from 'firebase-admin/firestore';
import { devLog } from '@/lib/devLogger';
import { adminDb } from '@/lib/firebase-admin';
import { adminDb, getAuth } from '@/lib/firebase-admin';
import { CREDIT_COSTS, CREDIT_PACK_DEFS } from '@/lib/billingConfig';
import { hasUnlimitedBillingAccess } from '@/lib/billingAccess';
import { hasUnlimitedBillingAccess, withTrustedBillingEmail } from '@/lib/billingAccess';
import {
creditBalanceFromProfile,
isFreeInstanceAvailable,
Expand All @@ -18,6 +18,15 @@ import type {
CreditPackId,
} from '@/lib/billingTypes';

async function authEmailForUser(userId: string): Promise<string | undefined> {
try {
const user = await getAuth().getUser(userId);
return typeof user.email === 'string' ? user.email : undefined;
} catch {
return undefined;
}
}

function userBillingFromData(data: FirebaseFirestore.DocumentData | undefined): BillingUserFields {
if (!data) return {};
return {
Expand Down Expand Up @@ -46,7 +55,8 @@ export async function getBillingSnapshot(userId: string): Promise<{
return { creditBalance: 0, billingMode: 'payg', unlimited: false, freeUseConsumed: {} };
}
const snap = await adminDb.collection('users').doc(userId).get();
const profile = userBillingFromData(snap.data());
const authEmail = await authEmailForUser(userId);
const profile = withTrustedBillingEmail(userBillingFromData(snap.data()), authEmail);
const unlimited = hasUnlimitedBillingAccess(profile);
return {
creditBalance: creditBalanceFromProfile(profile),
Expand Down Expand Up @@ -79,6 +89,7 @@ export async function consumeBillingAction(
const toolSlug = opts?.toolSlug?.trim() || undefined;
const userRef = adminDb.collection('users').doc(userId);
const ledgerRef = userRef.collection('billingLedger').doc();
const authEmail = await authEmailForUser(userId);

try {
return await adminDb.runTransaction(async (tx) => {
Expand All @@ -92,7 +103,7 @@ export async function consumeBillingAction(
};
}

const profile = userBillingFromData(snap.data());
const profile = withTrustedBillingEmail(userBillingFromData(snap.data()), authEmail);
if (hasUnlimitedBillingAccess(profile)) {
return {
ok: true as const,
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/billingCredits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
creditBalanceFromProfile,
} from '@/lib/billingFreeUse';
import { getCreditPackPrice, toolSlugFromSeerRoute } from '@/lib/billingConfig';
import { hasUnlimitedBillingAccess } from '@/lib/billingAccess';
import {
hasUnlimitedBillingAccess,
trustedBillingEmail,
withTrustedBillingEmail,
} from '@/lib/billingAccess';

describe('billingFreeUse', () => {
it('tracks per-tool first free Seer use', () => {
Expand Down Expand Up @@ -46,4 +50,34 @@ describe('billingAccess', () => {
).toBe(true);
expect(creditBalanceFromProfile({ creditBalance: 5 })).toBe(5);
});

it('does not trust a client-written Firestore email for billing identity', () => {
const previous = process.env.NO_CHARGE_SUBSCRIPTION_EMAILS;
process.env.NO_CHARGE_SUBSCRIPTION_EMAILS = 'founder@example.com';
try {
expect(trustedBillingEmail(undefined)).toBeUndefined();
expect(trustedBillingEmail('')).toBeUndefined();
expect(trustedBillingEmail(' ')).toBeUndefined();
expect(trustedBillingEmail('user@example.com')).toBe('user@example.com');

expect(hasUnlimitedBillingAccess({ email: 'founder@example.com' })).toBe(true);

const fromFirestore = withTrustedBillingEmail(
{ email: 'founder@example.com', creditBalance: 3 },
undefined,
);
expect(fromFirestore.email).toBeUndefined();
expect(hasUnlimitedBillingAccess(fromFirestore)).toBe(false);

const fromAuth = withTrustedBillingEmail(
{ email: 'founder@example.com', creditBalance: 3 },
'user@example.com',
);
expect(fromAuth.email).toBe('user@example.com');
expect(hasUnlimitedBillingAccess(fromAuth)).toBe(false);
} finally {
if (previous === undefined) delete process.env.NO_CHARGE_SUBSCRIPTION_EMAILS;
else process.env.NO_CHARGE_SUBSCRIPTION_EMAILS = previous;
}
});
});
Loading