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

@@ -26,7 +26,14 @@ loadEnvFiles([
]);
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
const {
ensureAuthSchema,
signUp: authSignUp,
login: authLogin,
signInWithApple: authSignInWithApple,
issueToken,
verifyJwt,
} = require('./lib/auth');
const {
PlantImportValidationError,
ensurePlantSchema,
@@ -168,13 +175,27 @@ const resolveUserId = (request) => {
return '';
};
const resolveIdempotencyKey = (request) => {
const header = request.header('idempotency-key');
if (typeof header === 'string' && header.trim()) return header.trim();
return '';
};
const toPlantResult = (entry, confidence) => {
const resolveIdempotencyKey = (request) => {
const header = request.header('idempotency-key');
if (typeof header === 'string' && header.trim()) return header.trim();
return '';
};
const createHardPaywallError = (requiredCredits) => {
const error = new Error('Active Pro or trial entitlement required.');
error.code = 'INSUFFICIENT_CREDITS';
error.status = 402;
error.metadata = { required: requiredCredits, available: 0 };
return error;
};
const ensureActiveProEntitlement = (accountSnapshot, requiredCredits) => {
if (!accountSnapshot || accountSnapshot.plan !== 'pro') {
throw createHardPaywallError(requiredCredits);
}
};
const toPlantResult = (entry, confidence) => {
return {
name: entry.name,
botanicalName: entry.botanicalName,
@@ -235,23 +256,37 @@ const toApiErrorPayload = (error) => {
};
}
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
return {
status: 401,
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
};
}
if (isInsufficientCreditsError(error)) {
return {
status: 402,
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
return {
status: 401,
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
};
}
if (isInsufficientCreditsError(error)) {
return {
status: 402,
body: {
code: 'INSUFFICIENT_CREDITS',
message: error.message || 'Insufficient credits.',
details: error.metadata || undefined,
},
};
}
},
};
}
if (
error
&& typeof error === 'object'
&& Number.isInteger(error.status)
&& error.status >= 400
&& error.status < 500
&& typeof error.code === 'string'
) {
return {
status: error.status,
body: { code: error.code, message: error.message || 'Request failed.' },
};
}
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
return {
@@ -487,8 +522,9 @@ app.get('/', (_request, response) => {
'GET /health',
'GET /api/plants',
'POST /api/plants/rebuild',
'POST /auth/signup',
'POST /auth/login',
'POST /auth/signup',
'POST /auth/login',
'POST /auth/apple',
'GET /v1/billing/summary',
'POST /v1/billing/sync-revenuecat',
'POST /v1/scan',
@@ -642,14 +678,17 @@ app.post('/v1/scan', async (request, response) => {
let modelUsed = null;
let modelFallbackCount = 0;
const [creditResult, accountSnapshot, catalogEntries] = await Promise.all([
isGuest(userId)
? Promise.resolve(0)
: consumeCreditsWithIdempotency(db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST),
getAccountSnapshot(db, userId),
getCachedCatalogEntries(db),
]);
creditsCharged += creditResult;
const [accountSnapshot, catalogEntries] = await Promise.all([
getAccountSnapshot(db, userId),
getCachedCatalogEntries(db),
]);
ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);
creditsCharged += await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('scan-primary', userId, idempotencyKey),
SCAN_PRIMARY_COST,
);
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
@@ -785,21 +824,24 @@ app.post('/v1/search/semantic', async (request, response) => {
return;
}
if (!query) {
const payload = {
status: 'no_results',
if (!query) {
const payload = {
status: 'no_results',
results: [],
creditsCharged: 0,
billing: await getBillingSummary(db, userId),
};
await storeEndpointResponse(db, endpointId, payload);
response.status(200).json(payload);
return;
}
const creditsCharged = await consumeCreditsWithIdempotency(
db,
userId,
return;
}
const accountSnapshot = await getAccountSnapshot(db, userId);
ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);
const creditsCharged = await consumeCreditsWithIdempotency(
db,
userId,
chargeKey('semantic-search', userId, idempotencyKey),
SEMANTIC_SEARCH_COST,
);
@@ -831,11 +873,14 @@ app.post('/v1/health-check', async (request, response) => {
const cached = await getEndpointResponse(db, endpointId);
if (cached) {
response.status(200).json(cached);
return;
}
if (!isOpenAiConfigured()) {
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
return;
}
const accountSnapshot = await getAccountSnapshot(db, userId);
ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);
if (!isOpenAiConfigured()) {
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
error.code = 'PROVIDER_ERROR';
throw error;
}
@@ -998,9 +1043,9 @@ app.post('/auth/signup', async (request, response) => {
}
});
app.post('/auth/login', async (request, response) => {
try {
const { email, password } = request.body || {};
app.post('/auth/login', async (request, response) => {
try {
const { email, password } = request.body || {};
if (!email || !password) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' });
}
@@ -1010,8 +1055,23 @@ app.post('/auth/login', async (request, response) => {
} catch (error) {
const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
}
});
}
});
app.post('/auth/apple', async (request, response) => {
try {
const { identityToken, appleUser, email, name } = request.body || {};
if (!identityToken) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' });
}
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
const token = issueToken(user.id, user.email, user.name);
response.status(200).json({ userId: user.id, email: user.email, name: user.name, token });
} catch (error) {
const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
}
});
// ─── Startup ───────────────────────────────────────────────────────────────