Hard paywall
This commit is contained in:
160
server/index.js
160
server/index.js
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user