Hard paywall

This commit is contained in:
2026-04-28 20:35:53 +02:00
parent 05efbb9910
commit 86631a9bc0
15 changed files with 15251 additions and 14164 deletions

View File

@@ -1,8 +1,18 @@
const crypto = require('crypto');
const { get, run } = require('./postgres');
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
const APPLE_ISSUER = 'https://appleid.apple.com';
const APPLE_AUDIENCE = (
process.env.APPLE_CLIENT_ID
|| process.env.APPLE_BUNDLE_ID
|| process.env.EXPO_PUBLIC_APPLE_CLIENT_ID
|| process.env.IOS_BUNDLE_ID
|| 'com.greenlens.app'
).trim();
let appleJwksCache = { keys: [], expiresAt: 0 };
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
@@ -47,8 +57,100 @@ const issueToken = (userId, email, name) =>
// ─── Password hashing ──────────────────────────────────────────────────────
const hashPassword = (password) =>
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
const hashPassword = (password) =>
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
const parseJwtPart = (value) => JSON.parse(b64urlDecode(value));
const getAppleJwks = async () => {
const now = Date.now();
if (appleJwksCache.keys.length > 0 && appleJwksCache.expiresAt > now) {
return appleJwksCache.keys;
}
const response = await fetch(APPLE_JWKS_URL);
if (!response.ok) {
const error = new Error('Could not load Apple public keys.');
error.code = 'APPLE_AUTH_UNAVAILABLE';
error.status = 503;
throw error;
}
const payload = await response.json();
appleJwksCache = {
keys: Array.isArray(payload.keys) ? payload.keys : [],
expiresAt: now + 6 * 60 * 60 * 1000,
};
return appleJwksCache.keys;
};
const verifyAppleIdentityToken = async (identityToken) => {
if (!identityToken || typeof identityToken !== 'string') {
const error = new Error('Apple identityToken is required.');
error.code = 'BAD_REQUEST';
error.status = 400;
throw error;
}
const parts = identityToken.split('.');
if (parts.length !== 3) {
const error = new Error('Apple identityToken is malformed.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
const [encodedHeader, encodedPayload, encodedSignature] = parts;
let header;
let claims;
try {
header = parseJwtPart(encodedHeader);
claims = parseJwtPart(encodedPayload);
} catch {
const error = new Error('Apple identityToken is malformed.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
if (header.alg !== 'RS256' || !header.kid) {
const error = new Error('Apple identityToken has an unsupported signature.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
const keys = await getAppleJwks();
const jwk = keys.find((key) => key.kid === header.kid);
if (!jwk) {
const error = new Error('Apple public key not found.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(`${encodedHeader}.${encodedPayload}`);
verifier.end();
const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
const validSignature = verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url'));
const nowSeconds = Math.floor(Date.now() / 1000);
const expectedAudiences = new Set([APPLE_AUDIENCE, 'com.greenlens.app'].filter(Boolean));
if (
!validSignature
|| claims.iss !== APPLE_ISSUER
|| !expectedAudiences.has(claims.aud)
|| !claims.sub
|| (claims.exp && nowSeconds > Number(claims.exp))
) {
const error = new Error('Apple identityToken could not be verified.');
error.code = 'APPLE_AUTH_INVALID';
error.status = 401;
throw error;
}
return claims;
};
// ─── Schema ────────────────────────────────────────────────────────────────
@@ -59,10 +161,22 @@ const ensureAuthSchema = async (db) => {
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'email',
apple_subject TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
);
await run(db, "ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'email'");
await run(db, 'ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS apple_subject TEXT');
await run(db, 'ALTER TABLE auth_users ALTER COLUMN password_hash DROP NOT NULL');
await run(
db,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_users_apple_subject
ON auth_users (apple_subject)
WHERE apple_subject IS NOT NULL`,
);
};
// ─── Operations ───────────────────────────────────────────────────────────
@@ -98,14 +212,68 @@ const login = async (db, email, password) => {
err.code = 'USER_NOT_FOUND';
err.status = 401;
throw err;
}
if (user.password_hash !== hashPassword(password)) {
}
if (!user.password_hash) {
const err = new Error('This account uses Apple Sign-In.');
err.code = 'USE_APPLE_LOGIN';
err.status = 401;
throw err;
}
if (user.password_hash !== hashPassword(password)) {
const err = new Error('Wrong password.');
err.code = 'WRONG_PASSWORD';
err.status = 401;
throw err;
}
return { id: user.id, email: user.email, name: user.name };
};
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };
return { id: user.id, email: user.email, name: user.name };
};
const signInWithApple = async (db, identityToken, profile = {}) => {
const claims = await verifyAppleIdentityToken(identityToken);
const appleSubject = String(claims.sub);
const emailFromToken = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : '';
const emailFromProfile = typeof profile.email === 'string' ? profile.email.trim().toLowerCase() : '';
const normalizedEmail = emailFromToken || emailFromProfile;
const profileName = typeof profile.name === 'string' ? profile.name.trim() : '';
const existingByApple = await get(
db,
'SELECT id, email, name FROM auth_users WHERE apple_subject = $1',
[appleSubject],
);
if (existingByApple) return existingByApple;
if (!normalizedEmail) {
const err = new Error('Apple did not return an email for this account.');
err.code = 'APPLE_EMAIL_MISSING';
err.status = 400;
throw err;
}
const existingByEmail = await get(
db,
'SELECT id, email, name FROM auth_users WHERE LOWER(email) = LOWER($1)',
[normalizedEmail],
);
if (existingByEmail) {
const nextName = existingByEmail.name || profileName || normalizedEmail.split('@')[0] || 'GreenLens User';
await run(
db,
'UPDATE auth_users SET apple_subject = $1, auth_provider = $2, name = $3 WHERE id = $4',
[appleSubject, 'apple', nextName, existingByEmail.id],
);
return { ...existingByEmail, name: nextName };
}
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
const name = profileName || normalizedEmail.split('@')[0] || 'GreenLens User';
await run(
db,
`INSERT INTO auth_users (id, email, name, password_hash, auth_provider, apple_subject)
VALUES ($1, $2, $3, NULL, $4, $5)`,
[id, normalizedEmail, name, 'apple', appleSubject],
);
return { id, email: normalizedEmail, name };
};
module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken };

View File

@@ -1,17 +1,18 @@
const { get, run } = require('./postgres');
const FREE_MONTHLY_CREDITS = 15;
const PRO_MONTHLY_CREDITS = 250;
const TOPUP_DEFAULT_CREDITS = 60;
const FREE_MONTHLY_CREDITS = 0;
const TRIAL_MONTHLY_CREDITS = 30;
const PRO_MONTHLY_CREDITS = 100;
const TOPUP_DEFAULT_CREDITS = 100;
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']);
const TOPUP_CREDITS_BY_PRODUCT = {
monthly_pro: 0,
yearly_pro: 0,
topup_small: 25,
topup_medium: 120,
topup_large: 300,
topup_small: 30,
topup_medium: 100,
topup_large: 250,
};
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
@@ -64,6 +65,22 @@ const getMonthlyAllowanceForPlan = (plan) => {
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
};
const getRevenueCatPeriodType = (source) => {
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
};
const isRevenueCatTrial = (source) => {
return getRevenueCatPeriodType(source) === 'trial';
};
const isAllowedMonthlyAllowance = (account) => {
if (account.plan === 'pro') {
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
}
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
};
const createInsufficientCreditsError = (required, available) => {
const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`);
error.code = 'INSUFFICIENT_CREDITS';
@@ -130,7 +147,7 @@ const buildDefaultAccount = (userId, now) => {
const alignAccountToCurrentCycle = (account, now) => {
const next = { ...account };
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan);
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
if (!isAllowedMonthlyAllowance(next)) {
next.monthlyAllowance = expectedMonthlyAllowance;
}
@@ -238,6 +255,7 @@ const getOrCreateAccount = async (db, userId) => {
};
const getAvailableCredits = (account) => {
if (account.plan !== 'pro') return 0;
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
return monthlyRemaining + Math.max(0, account.topupBalance);
};
@@ -326,14 +344,22 @@ const getValidProEntitlement = (customerInfo) => {
const applyRevenueCatEntitlementState = (account, options) => {
const now = new Date();
const previousPlan = account.plan;
const previousMonthlyAllowance = account.monthlyAllowance;
const nextPlan = options.active ? 'pro' : 'free';
const planChanged = account.plan !== nextPlan;
const nextMonthlyAllowance = options.active && options.isTrial
? TRIAL_MONTHLY_CREDITS
: getMonthlyAllowanceForPlan(nextPlan);
const planChanged = previousPlan !== nextPlan;
const trialConvertedToPaid = previousPlan === 'pro'
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
account.plan = nextPlan;
account.provider = 'revenuecat';
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan);
account.monthlyAllowance = nextMonthlyAllowance;
account.renewsAt = options.active ? options.renewsAt || account.renewsAt || addDays(now, 30).toISOString() : null;
if (planChanged) {
if (planChanged || trialConvertedToPaid) {
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString();
@@ -401,6 +427,7 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}
if (source !== 'topup_purchase') {
applyRevenueCatEntitlementState(account, {
active: Boolean(proEntitlement),
isTrial: isRevenueCatTrial(proEntitlement),
renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate),
});
}
@@ -490,6 +517,7 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
if (affectsProEntitlement && shouldGrantRevenueCatSubscription(eventType)) {
applyRevenueCatEntitlementState(account, {
active: true,
isTrial: isRevenueCatTrial(eventPayload),
renewsAt: asIsoDate(eventPayload?.expiration_at_ms || eventPayload?.expiration_at),
});
} else if (affectsProEntitlement && shouldRevokeRevenueCatSubscription(eventType)) {
@@ -519,6 +547,9 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
const consumeCredits = (account, cost) => {
if (cost <= 0) return 0;
if (account.plan !== 'pro') {
throw createInsufficientCreditsError(cost, 0);
}
const available = getAvailableCredits(account);
if (available < cost) {
@@ -567,12 +598,12 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
const getBillingSummary = async (db, userId) => {
if (userId === 'guest') {
return {
entitlement: { plan: 'free', provider: 'mock', status: 'active', renewsAt: null },
entitlement: { plan: 'free', provider: 'mock', status: 'inactive', renewsAt: null },
credits: {
monthlyAllowance: 5,
monthlyAllowance: 0,
usedThisCycle: 0,
topupBalance: 0,
available: 5,
available: 0,
cycleStartedAt: nowIso(),
cycleEndsAt: nowIso(),
},
@@ -595,7 +626,7 @@ const getAccountSnapshot = async (db, userId) => {
provider: 'mock',
cycleStartedAt: nowIso(),
cycleEndsAt: nowIso(),
monthlyAllowance: 5,
monthlyAllowance: 0,
usedThisCycle: 0,
topupBalance: 0,
renewsAt: null,