const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const dotenv = require('dotenv'); const express = require('express'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); const loadEnvFiles = (filePaths) => { const mergedFileEnv = {}; for (const filePath of filePaths) { if (!fs.existsSync(filePath)) continue; Object.assign(mergedFileEnv, dotenv.parse(fs.readFileSync(filePath))); } for (const [key, value] of Object.entries(mergedFileEnv)) { if (process.env[key] === undefined) { process.env[key] = value; } } }; loadEnvFiles([ path.join(__dirname, '..', '.env'), path.join(__dirname, '.env'), path.join(__dirname, '..', '.env.local'), path.join(__dirname, '.env.local'), ]); const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); const { deleteAccount: authDeleteAccount, ensureAuthSchema, signUp: authSignUp, login: authLogin, signInWithApple: authSignInWithApple, issueToken, verifyJwt, } = require('./lib/auth'); const { ensureScanEventSchema, recordScanEvent } = require('./lib/scanEvents'); const { PlantImportValidationError, ensurePlantSchema, getPlantDiagnostics, getPlants, rebuildPlantsCatalog, } = require('./lib/plants'); const { chargeKey, consumeCreditsWithIdempotency, refundCreditsWithIdempotency, endpointKey, ensureBillingSchema, getAccountSnapshot, getBillingSummary, getEndpointResponse, ensureSufficientCredits, isInsufficientCreditsError, claimNotificationOnce, simulatePurchase, simulateWebhook, syncRevenueCatCustomerInfo, syncRevenueCatWebhookEvent, storeEndpointResponse, } = require('./lib/billing'); const { analyzePlantHealth, getHealthModel, getScanModel, identifyPlant, isConfigured: isOpenAiConfigured, } = require('./lib/openai'); const { applyCatalogGrounding, enforceEnglishName, normalizeText } = require('./lib/scanGrounding'); const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); const { prepareTiktokPhotoUrls } = require('./lib/tiktok-assets'); const { isPurchaseEventType, notifyPurchase, notifyNewUser, notifyDownload } = require('./lib/discord'); const { exchangeCodeForTokens: exchangeTiktokCode, getTiktokTokens, refreshTiktokTokens, saveTiktokTokens, assertExpectedTiktokAccount, } = require('./lib/tiktok'); const app = express(); // Caddy is the only reverse proxy in front of this service (see // greenlns-landing/Caddyfile), so trust exactly one hop for req.ip / X-Forwarded-For. app.set('trust proxy', 1); const port = Number(process.env.PORT || 3000); const plantsPublicDir = path.join(__dirname, 'public', 'plants'); const SCAN_PRIMARY_COST = 1; const SCAN_REVIEW_COST = 0; const SEMANTIC_SEARCH_COST = 2; const HEALTH_CHECK_COST = 2; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; // Below this the app should treat the identification as uncertain and nudge // the user toward a clearer photo instead of presenting the name as settled. const LOW_CONFIDENCE_RESULT_THRESHOLD = 0.6; let catalogCache = null; const getCachedCatalogEntries = async (db) => { if (catalogCache) return catalogCache; catalogCache = await getPlants(db, { limit: 500 }); return catalogCache; }; const DEFAULT_BOOTSTRAP_PLANTS = [ { id: '1', name: 'Monstera Deliciosa', botanicalName: 'Monstera deliciosa', imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/330px-Monstera_deliciosa2.jpg', description: 'A popular houseplant with large, holey leaves.', categories: ['easy', 'large', 'air_purifier'], confidence: 1, careInfo: { waterIntervalDays: 7, temp: '18-27C', light: 'Indirect bright light', }, }, { id: '2', name: 'Snake Plant', botanicalName: 'Sansevieria trifasciata', imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/330px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg', description: 'A hardy indoor plant known for its upright, sword-like leaves.', categories: ['succulent', 'easy', 'low_light', 'air_purifier'], confidence: 1, careInfo: { waterIntervalDays: 21, temp: '15-30C', light: 'Low to full light', }, }, ]; const FULL_BOOTSTRAP_CATALOG_CANDIDATES = [ path.join(__dirname, 'data', 'plants_dump_utf8.json'), path.join(__dirname, '..', 'plants_dump_utf8.json'), ]; const FULL_BOOTSTRAP_MANIFEST_CANDIDATES = [ path.join(__dirname, 'public', 'plants', 'manifest.json'), ]; let db; const parseBoolean = (value, fallbackValue) => { if (typeof value !== 'string') return fallbackValue; const normalized = value.trim().toLowerCase(); if (normalized === 'true' || normalized === '1') return true; if (normalized === 'false' || normalized === '0') return false; return fallbackValue; }; const hashString = (value) => { let hash = 0; for (let i = 0; i < value.length; i += 1) { hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0; } return Math.abs(hash); }; const clamp = (value, min, max) => { return Math.min(max, Math.max(min, value)); }; const nowIso = () => new Date().toISOString(); const hasImportAdminKey = Boolean(process.env.PLANT_IMPORT_ADMIN_KEY); // Fail closed: if the admin key is not configured, admin routes deny access // instead of becoming publicly reachable. const isAuthorizedImport = (request) => { if (!hasImportAdminKey) return false; const provided = request.header('x-admin-key'); return provided === process.env.PLANT_IMPORT_ADMIN_KEY; }; // Same admin secret, but also accepted as a query param since browser // navigation to /api/tiktok/connect can't set a custom header. const isAuthorizedAdminNavigation = (request) => { if (!hasImportAdminKey) return false; const provided = request.header('x-admin-key') || request.query?.key; return provided === process.env.PLANT_IMPORT_ADMIN_KEY; }; const normalizeLanguage = (value) => { return value === 'de' || value === 'en' || value === 'es' ? value : 'en'; }; const resolveUserId = (request) => { // 1. Bearer JWT (preferred — server-side auth) const authHeader = request.header('authorization'); if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7); if (token === 'guest') return 'guest'; const payload = verifyJwt(token); if (payload && payload.sub) return String(payload.sub); } // 2. Legacy X-User-Id header (kept for backward compat) const headerUserId = request.header('x-user-id'); if (typeof headerUserId === 'string' && headerUserId.trim()) return headerUserId.trim(); const bodyUserId = typeof request.body?.userId === 'string' ? request.body.userId.trim() : ''; if (bodyUserId) return bodyUserId; return ''; }; const resolveIdempotencyKey = (request) => { const header = request.header('idempotency-key'); if (typeof header === 'string' && header.trim()) return header.trim(); return ''; }; const ensureNotGuest = (userId, requiredCredits) => { // Guests may use the limited pre-auth demo identification scan. Other // endpoints must not consume credits from the shared 'guest' account. if (isGuest(userId)) { const error = new Error('Sign in to use scan credits.'); error.code = 'INSUFFICIENT_CREDITS'; error.status = 402; error.metadata = { required: requiredCredits, available: 0 }; throw error; } }; const toPlantResult = (entry, confidence) => { return { name: entry.name, botanicalName: entry.botanicalName, confidence: clamp(confidence, 0.05, 0.99), description: entry.description || `${entry.name} identified from the plant catalog.`, careInfo: { waterIntervalDays: Math.max(1, Number(entry.careInfo?.waterIntervalDays) || 7), light: entry.careInfo?.light || 'Unknown', temp: entry.careInfo?.temp || 'Unknown', }, }; }; const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false, { silent = false } = {}) => { if (!Array.isArray(entries) || entries.length === 0) return null; const baseHash = hashString(`${imageUri || ''}|${entries.length}`); const index = baseHash % entries.length; // Low confidence so the user knows this is a hash-based guess, not a real identification const confidence = preferHighConfidence ? 0.22 + ((baseHash % 3) / 100) : 0.18 + ((baseHash % 7) / 100); if (!silent) { console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', { plant: entries[index]?.name, confidence, imageHint: (imageUri || '').slice(0, 80), }); } return toPlantResult(entries[index], confidence); }; const toImportErrorPayload = (error) => { if (error instanceof PlantImportValidationError) { return { status: 422, body: { code: 'IMPORT_VALIDATION_ERROR', message: error.message, details: error.details || [], }, }; } return { status: 500, body: { code: 'INTERNAL_ERROR', message: error instanceof Error ? error.message : String(error), }, }; }; const toApiErrorPayload = (error) => { if (error && typeof error === 'object' && error.code === 'BAD_REQUEST') { return { status: 400, body: { code: 'BAD_REQUEST', message: error.message || 'Invalid request.' }, }; } 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 { status: 502, body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' }, }; } if (error && typeof error === 'object' && error.code === 'NOT_A_PLANT') { return { status: 422, body: { code: 'NOT_A_PLANT', message: error.message || 'Image does not contain a plant.' }, }; } if (error && typeof error === 'object' && error.code === 'TIMEOUT') { return { status: 504, body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' }, }; } // TikTok (and similar provider) errors carry an HTTP status and sometimes a // structured provider body. Preserve that instead of collapsing everything // into a generic internal error — but never echo secrets or upload URLs. if (error && typeof error === 'object' && Number.isInteger(error.status) && error.status >= 400) { const providerError = error.body?.error; return { status: error.status >= 500 ? 502 : error.status, body: { code: error.status === 416 ? 'TIKTOK_UPLOAD_RANGE_ERROR' : 'PROVIDER_ERROR', message: error.message || 'Provider request failed.', provider_status: error.status, ...(providerError?.code ? { provider_code: providerError.code } : {}), ...(providerError?.log_id ? { provider_log_id: providerError.log_id } : {}), retryable: error.status === 429 || error.status >= 500, }, }; } return { status: 500, body: { code: 'PROVIDER_ERROR', message: error instanceof Error ? error.message : String(error), }, }; }; const ensureRequestAuth = (request) => { const userId = resolveUserId(request); if (!userId) { const error = new Error('Missing X-User-Id header.'); error.code = 'UNAUTHORIZED'; throw error; } return userId; }; const isGuest = (userId) => userId === 'guest'; const ensureNonEmptyString = (value, fieldName) => { if (typeof value === 'string' && value.trim()) return value.trim(); const error = new Error(`${fieldName} is required.`); error.code = 'BAD_REQUEST'; throw error; }; const readJsonFromCandidates = (filePaths) => { for (const filePath of filePaths) { if (!fs.existsSync(filePath)) continue; try { const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); return { parsed: JSON.parse(raw), sourcePath: filePath, }; } catch (error) { console.warn('Failed to parse bootstrap JSON file.', { filePath, error: error instanceof Error ? error.message : String(error), }); } } return null; }; const buildEntriesFromManifest = (manifest) => { const items = Array.isArray(manifest?.items) ? manifest.items : []; return items .filter((item) => item && typeof item.name === 'string' && typeof item.botanicalName === 'string') .map((item) => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `${item.botanicalName}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), name: item.name.trim(), botanicalName: item.botanicalName.trim(), imageUri: typeof item.localImageUri === 'string' && item.localImageUri.trim() ? item.localImageUri.trim() : (typeof item.sourceUri === 'string' ? item.sourceUri.trim() : ''), imageStatus: item.status === 'missing' ? 'missing' : 'ok', description: '', categories: [], confidence: 1, careInfo: { waterIntervalDays: 7, light: 'Unknown', temp: 'Unknown', }, })) .filter((entry) => entry.imageUri); }; const mergeBootstrapEntries = (primaryEntries, secondaryEntries) => { const mergedByBotanical = new Map(); primaryEntries.forEach((entry) => { const botanicalKey = typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : ''; if (!botanicalKey || mergedByBotanical.has(botanicalKey)) return; mergedByBotanical.set(botanicalKey, { ...entry }); }); secondaryEntries.forEach((entry) => { const botanicalKey = typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : ''; if (!botanicalKey) return; const existing = mergedByBotanical.get(botanicalKey); if (!existing) { mergedByBotanical.set(botanicalKey, { ...entry }); return; } const shouldPreferLocalImage = typeof entry.imageUri === 'string' && entry.imageUri.startsWith('/plants/'); mergedByBotanical.set(botanicalKey, { ...existing, imageUri: shouldPreferLocalImage ? entry.imageUri : existing.imageUri, imageStatus: shouldPreferLocalImage ? entry.imageStatus || existing.imageStatus : existing.imageStatus, id: existing.id || entry.id, name: existing.name || entry.name, botanicalName: existing.botanicalName || entry.botanicalName, }); }); return Array.from(mergedByBotanical.values()); }; const loadFullBootstrapCatalog = () => { const catalogDump = readJsonFromCandidates(FULL_BOOTSTRAP_CATALOG_CANDIDATES); const manifestDump = readJsonFromCandidates(FULL_BOOTSTRAP_MANIFEST_CANDIDATES); const catalogEntries = Array.isArray(catalogDump?.parsed) ? catalogDump.parsed : []; const manifestEntries = manifestDump ? buildEntriesFromManifest(manifestDump.parsed) : []; const mergedEntries = mergeBootstrapEntries(catalogEntries, manifestEntries); if (mergedEntries.length === 0) return null; return { entries: mergedEntries, sourcePath: [catalogDump?.sourcePath, manifestDump?.sourcePath].filter(Boolean).join(', '), }; }; const isMinimalBootstrapCatalog = (entries) => { if (!Array.isArray(entries) || entries.length !== DEFAULT_BOOTSTRAP_PLANTS.length) { return false; } const botanicalNames = new Set( entries .map((entry) => (typeof entry?.botanicalName === 'string' ? entry.botanicalName.trim().toLowerCase() : '')) .filter(Boolean), ); return DEFAULT_BOOTSTRAP_PLANTS.every((entry) => botanicalNames.has(entry.botanicalName.trim().toLowerCase())); }; const seedBootstrapCatalogIfNeeded = async () => { const fullCatalog = loadFullBootstrapCatalog(); const diagnostics = await getPlantDiagnostics(db); if (diagnostics.totalCount > 0) { if (fullCatalog && diagnostics.totalCount === DEFAULT_BOOTSTRAP_PLANTS.length) { const existingEntries = await getPlants(db, { limit: DEFAULT_BOOTSTRAP_PLANTS.length + 1 }); if (isMinimalBootstrapCatalog(existingEntries) && fullCatalog.entries.length > existingEntries.length) { await rebuildPlantsCatalog(db, fullCatalog.entries, { source: 'bootstrap_upgrade_from_minimal_catalog', preserveExistingIds: false, enforceUniqueImages: false, }); console.log(`Upgraded minimal bootstrap catalog to full catalog (${fullCatalog.entries.length} entries).`); } } return; } if (fullCatalog) { await rebuildPlantsCatalog(db, fullCatalog.entries, { source: 'bootstrap_full_catalog', preserveExistingIds: false, enforceUniqueImages: false, }); console.log(`Bootstrapped full plant catalog from ${fullCatalog.sourcePath} (${fullCatalog.entries.length} entries).`); return; } await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { source: 'bootstrap_minimal_catalog', preserveExistingIds: false, enforceUniqueImages: false, }); console.warn('Full bootstrap catalog was not found. Seeded minimal fallback catalog with 2 entries.'); }; app.use(cors()); app.use('/plants', express.static(plantsPublicDir)); const revenueCatWebhookSecret = (process.env.REVENUECAT_WEBHOOK_SECRET || '').trim(); const isAuthorizedRevenueCatWebhook = (request) => { if (!revenueCatWebhookSecret) return true; const headerValue = request.header('authorization') || request.header('Authorization') || ''; const normalized = String(headerValue).trim(); return normalized === revenueCatWebhookSecret || normalized === `Bearer ${revenueCatWebhookSecret}`; }; app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (request, response) => { try { if (!isAuthorizedRevenueCatWebhook(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid RevenueCat webhook secret.' }); } const eventPayload = request.body?.event || request.body; const result = await syncRevenueCatWebhookEvent(db, eventPayload); if (isPurchaseEventType(eventPayload?.type)) { // RevenueCat delivers webhooks at-least-once; dedupe notifications by // event id so redeliveries don't ping the sales channel twice. const eventId = String(eventPayload?.id || eventPayload?.transaction_id || '').trim(); const isFirstDelivery = eventId ? await claimNotificationOnce(db, `discord-purchase-event:${eventId}`) : true; if (isFirstDelivery) { // RevenueCat's `price` is always USD; only `price_in_purchased_currency` // matches the `currency` field. const hasLocalPrice = typeof eventPayload?.price_in_purchased_currency === 'number'; notifyPurchase({ productId: eventPayload?.product_id, price: hasLocalPrice ? eventPayload.price_in_purchased_currency : eventPayload?.price, currency: hasLocalPrice ? eventPayload?.currency : 'USD', store: eventPayload?.store, isTrial: String(eventPayload?.period_type || '').toUpperCase() === 'TRIAL', isRenewal: String(eventPayload?.type || '').toUpperCase() === 'RENEWAL', isSandbox: String(eventPayload?.environment || '').toUpperCase() === 'SANDBOX', }); } } response.status(200).json({ received: true, syncedAt: result.syncedAt }); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // TikTok carousels can contain several base64 slides. Authenticate before // accepting the larger body; all other JSON endpoints keep the tighter limit. app.use( '/api/tiktok/upload/photo', (request, response, next) => { if (!isAuthorizedAdminNavigation(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } return next(); }, express.json({ limit: '30mb' }), ); app.use( '/api/tiktok/upload/video', (request, response, next) => { if (!isAuthorizedAdminNavigation(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } return next(); }, // 24 MB decoded video limit; base64 expands the JSON request to roughly 32 MB. express.json({ limit: '32mb' }), ); app.use(express.json({ limit: '10mb' })); app.get('/', (_request, response) => { response.status(200).json({ service: 'greenlns-api', status: 'ok', endpoints: [ 'GET /health', 'GET /api/plants', 'POST /api/plants/rebuild', 'POST /auth/signup', 'POST /auth/login', 'POST /auth/apple', 'DELETE /auth/account', 'GET /v1/billing/summary', 'POST /v1/billing/sync-revenuecat', 'POST /v1/scan', 'POST /v1/search/semantic', 'POST /v1/health-check', 'POST /v1/billing/simulate-purchase', 'POST /v1/billing/simulate-webhook', 'POST /v1/upload/image', 'POST /api/revenuecat/webhook', 'GET /api/tiktok/connect', 'GET /api/tiktok/callback', 'GET /api/tiktok/status', 'GET /api/tiktok/token', 'POST /api/tiktok/upload/video', 'POST /api/tiktok/upload/photo', 'GET /api/tiktok/upload/status', 'GET /api/tiktok/analytics', ], }); }); const getDatabaseHealthTarget = () => { const raw = getDefaultDbPath(); if (!raw) return ''; try { const parsed = new URL(raw); const databaseName = parsed.pathname.replace(/^\//, ''); return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}/${databaseName}`; } catch { return 'configured'; } }; app.get('/health', (_request, response) => { response.status(200).json({ ok: true, uptimeSec: Math.round(process.uptime()), timestamp: new Date().toISOString(), openAiConfigured: isOpenAiConfigured(), dbReady: Boolean(db), dbPath: getDatabaseHealthTarget(), scanModel: getScanModel(), healthModel: getHealthModel(), }); }); app.get('/api/plants', async (request, response) => { try { const query = typeof request.query.q === 'string' ? request.query.q : ''; const category = typeof request.query.category === 'string' ? request.query.category : ''; const limit = request.query.limit; const results = await getPlants(db, { query, category, limit: typeof limit === 'string' ? Number(limit) : undefined, }); response.json(results); } catch (error) { const payload = toImportErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.get('/api/plants/diagnostics', async (_request, response) => { try { const diagnostics = await getPlantDiagnostics(db); response.json(diagnostics); } catch (error) { const payload = toImportErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/api/plants/rebuild', async (request, response) => { if (!isAuthorizedImport(request)) { response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing x-admin-key.', }); return; } const payloadEntries = Array.isArray(request.body) ? request.body : request.body?.entries; const source = typeof request.body?.source === 'string' && request.body.source.trim() ? request.body.source.trim() : 'api_rebuild'; const preserveExistingIds = parseBoolean(request.body?.preserveExistingIds, true); const enforceUniqueImages = parseBoolean(request.body?.enforceUniqueImages, true); try { const summary = await rebuildPlantsCatalog(db, payloadEntries, { source, preserveExistingIds, enforceUniqueImages, }); response.status(200).json(summary); } catch (error) { const payload = toImportErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.get('/v1/billing/summary', async (request, response) => { try { const userId = ensureRequestAuth(request); if (userId !== 'guest') { const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = $1', [userId]); if (!userExists) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' }); } } const summary = await getBillingSummary(db, userId); response.status(200).json(summary); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/billing/sync-revenuecat', async (request, response) => { try { const userId = ensureRequestAuth(request); if (userId === 'guest') { return response.status(400).json({ code: 'BAD_REQUEST', message: 'Guest users cannot sync RevenueCat state.' }); } const customerInfo = request.body?.customerInfo; const source = typeof request.body?.source === 'string' ? request.body.source : undefined; if (!customerInfo || typeof customerInfo !== 'object' || !customerInfo.entitlements) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'customerInfo is required.' }); } const payload = await syncRevenueCatCustomerInfo(db, userId, customerInfo, { source }); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // The 'Bearer guest' token (see resolveUserId above) grants a free, // pre-auth demo scan with no credit charge. Without a limit here, that // path is an unmetered, unauthenticated OpenAI proxy for anyone who // knows the header — cap it per-IP so it stays a teaser, not a leak. const guestScanLimiter = rateLimit({ windowMs: 24 * 60 * 60 * 1000, limit: 3, standardHeaders: true, legacyHeaders: false, skip: (request) => request.header('authorization') !== 'Bearer guest', message: { error: { code: 'GUEST_SCAN_LIMIT', message: 'Daily free scan limit reached. Sign in or download the app for unlimited scans.', }, }, }); app.post('/v1/scan', guestScanLimiter, async (request, response) => { let userId = 'unknown'; let idempotencyKey = null; let creditsCharged = 0; try { userId = ensureRequestAuth(request); idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri'); const language = normalizeLanguage(request.body?.language); const endpointId = endpointKey('scan', userId, idempotencyKey); const cached = await getEndpointResponse(db, endpointId); if (cached) { response.status(200).json(cached); return; } const modelPath = []; let modelUsed = null; let modelFallbackCount = 0; const [accountSnapshot, catalogEntries] = await Promise.all([ getAccountSnapshot(db, userId), getCachedCatalogEntries(db), ]); if (isGuest(userId)) { modelPath.push('guest-demo-no-credit'); } else { creditsCharged += await consumeCreditsWithIdempotency( db, userId, chargeKey('scan-primary', userId, idempotencyKey), SCAN_PRIMARY_COST, ); } // Free tier gets the same model quality; quantity (3 credits/month) is the differentiator. const scanPlan = 'pro'; let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true }); let usedOpenAi = false; let rawPrimaryResult = null; if (isOpenAiConfigured()) { console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel(scanPlan)} (plan: ${scanPlan})`); const openAiPrimary = await identifyPlant({ imageUri, language, mode: 'primary', plan: scanPlan, }); modelFallbackCount = Math.max( modelFallbackCount, Math.max((openAiPrimary?.attemptedModels?.length || 0) - 1, 0), ); if (openAiPrimary?.result) { console.log(`OpenAI primary identification successful for user ${userId}: ${openAiPrimary.result.name} (${openAiPrimary.result.confidence}) using ${openAiPrimary.modelUsed}`); rawPrimaryResult = openAiPrimary.result; const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries, language); result = grounded.result; usedOpenAi = true; modelUsed = openAiPrimary.modelUsed || modelUsed; modelPath.push('openai-primary'); if (grounded.grounded) modelPath.push('catalog-grounded-primary'); } else { if (isGuest(userId)) { const error = new Error('AI demo scan failed. Please try again with a clearer plant photo.'); error.code = 'PROVIDER_ERROR'; error.status = 502; throw error; } console.warn(`OpenAI primary identification returned null for user ${userId} — using catalog fallback.`, { attemptedModels: openAiPrimary?.attemptedModels, plant: result?.name, }); modelPath.push('openai-primary-failed'); modelPath.push('catalog-primary-fallback'); } } else { if (isGuest(userId)) { const error = new Error('AI demo scan is unavailable. Please configure OpenAI before enabling guest demo scans.'); error.code = 'PROVIDER_ERROR'; error.status = 502; throw error; } console.log(`OpenAI not configured, using catalog fallback for user ${userId}`); modelPath.push('openai-not-configured'); modelPath.push('catalog-primary-fallback'); } if (!result) { const error = new Error('Plant catalog is empty. Unable to produce identification fallback.'); error.code = 'PROVIDER_ERROR'; throw error; } const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD; if (shouldReview && accountSnapshot.plan === 'pro') { console.log(`Starting AI review for user ${userId} (confidence ${result.confidence} < ${LOW_CONFIDENCE_REVIEW_THRESHOLD})`); try { if (!isGuest(userId)) { creditsCharged += await consumeCreditsWithIdempotency( db, userId, chargeKey('scan-review', userId, idempotencyKey), SCAN_REVIEW_COST, ); } if (usedOpenAi) { const openAiReview = await identifyPlant({ imageUri, language, mode: 'review', plan: scanPlan, }); modelFallbackCount = Math.max( modelFallbackCount, Math.max((openAiReview?.attemptedModels?.length || 0) - 1, 0), ); if (openAiReview?.result) { console.log(`OpenAI review identification successful for user ${userId}: ${openAiReview.result.name} (${openAiReview.result.confidence}) using ${openAiReview.modelUsed}`); const agrees = reviewAgreesWithPrimary(rawPrimaryResult, openAiReview.result); const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries, language); const decision = decideReviewOutcome({ primaryResult: result, reviewResult: grounded.result, agrees }); if (decision.accept) { // modelUsed and the grounding marker describe the RESULT the user // gets, so they only change when the review actually replaces it. if (decision.replace) { result = grounded.result; modelUsed = openAiReview.modelUsed || modelUsed; if (grounded.grounded) modelPath.push('catalog-grounded-review'); } if (decision.confidence != null) { // Cross-model agreement bonus: both models named the same species. result = { ...result, confidence: decision.confidence }; } modelPath.push('openai-review'); modelPath.push(decision.reason); } else { console.log(`OpenAI review disagreed at lower confidence for user ${userId} (${grounded.result.name} ${grounded.result.confidence} vs ${result.name} ${result.confidence}) — keeping primary result.`); modelPath.push(decision.reason); } } else { console.warn(`OpenAI review identification returned null for user ${userId}.`, { attemptedModels: openAiReview?.attemptedModels, }); modelPath.push('openai-review-failed'); } } else { const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true }); if (reviewFallback) { result = reviewFallback; } modelPath.push('catalog-review-fallback'); } } catch (error) { if (isInsufficientCreditsError(error)) { console.log(`Review skipped for user ${userId} due to insufficient credits`); modelPath.push('review-skipped-insufficient-credits'); } else { throw error; } } } else if (shouldReview) { console.log(`Review skipped for user ${userId} (plan: ${accountSnapshot.plan})`); modelPath.push('review-skipped-free-plan'); } result = enforceEnglishName(result, language); const payload = { result, lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD, creditsCharged, modelPath, modelUsed, modelFallbackCount, billing: await getBillingSummary(db, userId), }; await storeEndpointResponse(db, endpointId, payload); await recordScanEvent(db, { scanType: 'identify', species: result?.botanicalName || result?.name || null, confidence: result?.confidence, locale: language, }); response.status(200).json(payload); } catch (error) { console.error(`Scan error for user ${userId}:`, error); if (error?.code === 'NOT_A_PLANT' && creditsCharged > 0 && idempotencyKey) { try { const refunded = await refundCreditsWithIdempotency( db, userId, chargeKey('scan-refund', userId, idempotencyKey), creditsCharged, ); console.log(`Refunded ${refunded} credit(s) for user ${userId} (NOT_A_PLANT)`); } catch (refundError) { console.error(`Failed to refund credits for user ${userId} (NOT_A_PLANT):`, refundError); } } const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/search/semantic', async (request, response) => { try { const userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const query = typeof request.body?.query === 'string' ? request.body.query.trim() : ''; const endpointId = endpointKey('semantic-search', userId, idempotencyKey); const cached = await getEndpointResponse(db, endpointId); if (cached) { response.status(200).json(cached); return; } 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 accountSnapshot = await getAccountSnapshot(db, userId); ensureNotGuest(userId, SEMANTIC_SEARCH_COST); const creditsCharged = await consumeCreditsWithIdempotency( db, userId, chargeKey('semantic-search', userId, idempotencyKey), SEMANTIC_SEARCH_COST, ); const results = await getPlants(db, { query, limit: 18 }); const payload = { status: results.length > 0 ? 'success' : 'no_results', results, creditsCharged, billing: await getBillingSummary(db, userId), }; await storeEndpointResponse(db, endpointId, payload); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/health-check', async (request, response) => { try { const userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri'); const language = normalizeLanguage(request.body?.language); const endpointId = endpointKey('health-check', userId, idempotencyKey); const cached = await getEndpointResponse(db, endpointId); if (cached) { response.status(200).json(cached); return; } const accountSnapshot = await getAccountSnapshot(db, userId); ensureNotGuest(userId, HEALTH_CHECK_COST); // Balance pre-check: the paid AI analysis below runs BEFORE the charge // (failed analyses are intentionally not charged), so without this check a // user with insufficient credits could trigger unlimited free analyses. ensureSufficientCredits(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; } const analysisResponse = await analyzePlantHealth({ imageUri, language, plantContext: request.body?.plantContext, }); const analysis = analysisResponse?.analysis; if (!analysis) { // All models in the chain failed (timeout, quota, network) — return a graceful // "unavailable" result instead of PROVIDER_ERROR so the user never sees an error alert. // Credits are NOT charged. Response is NOT cached so the user can retry. console.warn('Health check analysis was null — all models returned unusable output.', { attemptedModels: analysisResponse?.attemptedModels, modelUsed: analysisResponse?.modelUsed, }); const unavailableIssue = language === 'de' ? 'Die KI-Analyse ist gerade nicht verfügbar. Bitte versuche es in einem Moment erneut.' : language === 'es' ? 'El análisis de IA no está disponible ahora. Inténtalo de nuevo en un momento.' : 'AI analysis is temporarily unavailable. Please try again in a moment.'; const unavailableAction = language === 'de' ? 'Erneut scannen wenn die Verbindung stabil ist.' : language === 'es' ? 'Volver a escanear cuando la conexión sea estable.' : 'Try scanning again when your connection is stable.'; const fallbackHealthCheck = { generatedAt: nowIso(), overallHealthScore: 50, status: 'watch', analysisSummary: unavailableIssue, likelyIssues: [{ title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable', confidence: 0.1, details: unavailableIssue, }], actionsNow: [unavailableAction], plan7Days: [unavailableAction], creditsCharged: 0, imageUri, }; const fallbackPayload = { healthCheck: fallbackHealthCheck, creditsCharged: 0, modelUsed: null, modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0), billing: await getBillingSummary(db, userId), }; response.status(200).json(fallbackPayload); return; } let creditsCharged = 0; if (!isGuest(userId)) { creditsCharged = await consumeCreditsWithIdempotency( db, userId, chargeKey('health-check', userId, idempotencyKey), HEALTH_CHECK_COST, ); } const healthCheck = { generatedAt: nowIso(), overallHealthScore: analysis.overallHealthScore, status: analysis.status, analysisSummary: analysis.analysisSummary, likelyIssues: analysis.likelyIssues, actionsNow: analysis.actionsNow, plan7Days: analysis.plan7Days, creditsCharged, imageUri, }; const payload = { healthCheck, creditsCharged, modelUsed: analysisResponse?.modelUsed || null, modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0), billing: await getBillingSummary(db, userId), }; await storeEndpointResponse(db, endpointId, payload); await recordScanEvent(db, { scanType: 'health', diagnosis: analysis?.likelyIssues?.[0]?.title || null, confidence: analysis?.likelyIssues?.[0]?.confidence, healthStatus: analysis?.status || null, locale: language, }); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/billing/simulate-purchase', async (request, response) => { try { const userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const productId = ensureNonEmptyString(request.body?.productId, 'productId'); const payload = await simulatePurchase(db, userId, idempotencyKey, productId); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); app.post('/v1/billing/simulate-webhook', async (request, response) => { try { const userId = ensureRequestAuth(request); const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); const event = ensureNonEmptyString(request.body?.event, 'event'); const payload = await simulateWebhook(db, userId, idempotencyKey, event, request.body?.payload || {}); response.status(200).json(payload); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // ─── Image Upload ────────────────────────────────────────────────────────── app.post('/v1/upload/image', async (request, response) => { try { ensureRequestAuth(request); if (!isStorageConfigured()) { return response.status(503).json({ code: 'STORAGE_NOT_CONFIGURED', message: 'Image storage is not configured.', }); } const { imageBase64, contentType = 'image/jpeg' } = request.body || {}; if (!imageBase64 || typeof imageBase64 !== 'string') { return response.status(400).json({ code: 'BAD_REQUEST', message: 'imageBase64 is required.', }); } const { url } = await uploadImage(imageBase64, contentType); response.status(200).json({ url }); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // ─── Install ping ────────────────────────────────────────────────────────── // Fired once by the app on first launch (unauthenticated — there is no account // yet). Deduped per install id so request retries don't ping the downloads // channel twice; a reinstall generates a fresh id and counts as a new download. app.post('/v1/app-install', async (request, response) => { try { const installId = String(request.body?.installId || '').trim(); if (!installId || installId.length > 128) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'installId is required.' }); } const isFirstReport = await claimNotificationOnce(db, `app-install:${installId}`); if (isFirstReport) { notifyDownload({ platform: request.header('x-app-platform'), appVersion: request.header('x-app-version'), }); } response.status(200).json({ ok: true }); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // ─── Auth endpoints ──────────────────────────────────────────────────────── app.post('/auth/signup', async (request, response) => { try { const { email, name, password } = request.body || {}; if (!email || !name || !password) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'email, name and password are required.' }); } const user = await authSignUp(db, email, name, password); const token = issueToken(user.id, user.email, user.name); notifyNewUser({ provider: 'email', platform: request.header('x-app-platform'), appVersion: request.header('x-app-version'), }); response.status(201).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 }); } }); 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.' }); } const user = await authLogin(db, email, password); 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 }); } }); 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); if (user.isNewUser) { notifyNewUser({ provider: 'apple', platform: request.header('x-app-platform'), appVersion: request.header('x-app-version'), }); } response.status(200).json({ userId: user.id, email: user.email, name: user.name, token, isNewUser: Boolean(user.isNewUser), }); } catch (error) { const status = error.status || 500; response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); } }); // ─── TikTok (Hermes Agent posting) ───────────────────────────────────────── const getTiktokEnv = () => { const clientKey = (process.env.TIKTOK_CLIENT_KEY || '').trim(); const clientSecret = (process.env.TIKTOK_CLIENT_SECRET || '').trim(); const redirectUri = (process.env.TIKTOK_REDIRECT_URI || `${process.env.SITE_URL || ''}/api/tiktok/callback`).trim(); const expectedOpenId = (process.env.TIKTOK_EXPECTED_OPEN_ID || '').trim(); return { clientKey, clientSecret, redirectUri, expectedOpenId }; }; const TIKTOK_STATE_COOKIE = 'tiktok_oauth_state'; const readCookie = (request, name) => { const header = request.headers.cookie || ''; for (const part of header.split(';')) { const [key, ...rest] = part.trim().split('='); if (key === name) return decodeURIComponent(rest.join('=')); } return ''; }; // Plain text keeps attacker-controlled query params (error_description) from // being interpreted as HTML by the browser. const sendTiktokText = (response, status, message) => { response.clearCookie(TIKTOK_STATE_COOKIE); response.status(status).type('text/plain').send(message); }; app.get('/api/tiktok/connect', (request, response) => { if (!isAuthorizedAdminNavigation(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } const { clientKey, redirectUri } = getTiktokEnv(); if (!clientKey) { return response.status(500).json({ code: 'SERVER_ERROR', message: 'TIKTOK_CLIENT_KEY is not configured.' }); } const oauthState = crypto.randomUUID(); const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/'); authUrl.searchParams.set('client_key', clientKey); authUrl.searchParams.set('scope', 'user.info.basic,video.publish,video.upload'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('state', oauthState); response.cookie(TIKTOK_STATE_COOKIE, oauthState, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 10 * 60 * 1000, }); response.redirect(authUrl.toString()); }); app.get('/api/tiktok/callback', async (request, response) => { const { code, state, error, error_description: errorDescription } = request.query; const savedState = readCookie(request, TIKTOK_STATE_COOKIE); if (error) { return sendTiktokText(response, 400, `TikTok authorization failed: ${errorDescription || error}`); } if (!code || typeof code !== 'string') { return sendTiktokText(response, 400, 'Missing authorization code.'); } if (!state || typeof state !== 'string' || !savedState || state !== savedState) { return sendTiktokText(response, 403, 'Invalid OAuth state. Start over at /api/tiktok/connect.'); } const { clientKey, clientSecret, redirectUri } = getTiktokEnv(); if (!clientKey || !clientSecret) { return sendTiktokText(response, 500, 'TikTok client credentials are not configured.'); } try { const tokens = await exchangeTiktokCode({ code, clientKey, clientSecret, redirectUri }); assertExpectedTiktokAccount(tokens.open_id, getTiktokEnv().expectedOpenId); await saveTiktokTokens(db, tokens); sendTiktokText(response, 200, 'TikTok account connected. You can close this tab.'); } catch (err) { console.error('TikTok callback error', err); sendTiktokText(response, err.status || 500, `Failed to connect TikTok account: ${err.message}`); } }); // Access tokens stay server-side. Hermes must use the protected upload routes. const TIKTOK_REFRESH_BUFFER_MS = 5 * 60 * 1000; app.get('/api/tiktok/token', async (request, response) => { if (!hasImportAdminKey) { // This endpoint hands out live credentials — never expose it without a key. return response.status(500).json({ code: 'SERVER_ERROR', message: 'PLANT_IMPORT_ADMIN_KEY must be configured to expose TikTok tokens.', }); } if (!isAuthorizedAdminNavigation(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } response.status(410).json({ code: 'TOKEN_HANDOFF_DISABLED', message: 'TikTok tokens are server-managed. Use the GreenLens TikTok upload routes.', }); }); app.get('/api/tiktok/status', async (request, response) => { if (!isAuthorizedAdminNavigation(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } const tokens = await getTiktokTokens(db); const { expectedOpenId } = getTiktokEnv(); if (!tokens) { return response.json({ connected: false, brand: 'greenlens', accountMatch: null, requiresReconnect: true }); } const refreshExpiresAt = tokens.refresh_token_expires_at ? new Date(tokens.refresh_token_expires_at).getTime() : null; const accountMatch = expectedOpenId ? tokens.open_id === expectedOpenId : null; const openIdHash = tokens.open_id ? crypto.createHash('sha256').update(tokens.open_id).digest('hex').slice(0, 12) : null; response.json({ connected: true, brand: 'greenlens', openIdHash, accountMatch, scope: tokens.scope, accessTokenExpiresAt: tokens.access_token_expires_at, refreshTokenExpiresAt: tokens.refresh_token_expires_at, requiresReconnect: refreshExpiresAt !== null && (!Number.isFinite(refreshExpiresAt) || refreshExpiresAt <= Date.now()), }); }); // ─── TikTok Upload Helpers ───────────────────────────────────────────────── const assertConfiguredTiktokAccount = (tokens) => { assertExpectedTiktokAccount(tokens?.open_id, getTiktokEnv().expectedOpenId); return tokens; }; const refreshLiveTiktokTokens = async () => { const { clientKey, clientSecret } = getTiktokEnv(); if (!clientKey || !clientSecret) { const error = new Error('TikTok client credentials are not configured.'); error.status = 500; throw error; } return assertConfiguredTiktokAccount(await refreshTiktokTokens(db, { clientKey, clientSecret })); }; const getLiveTiktokAccessToken = async () => { const tokens = await getTiktokTokens(db); if (!tokens) { const error = new Error('No TikTok account connected.'); error.status = 404; throw error; } const expiresAt = new Date(tokens.access_token_expires_at).getTime(); if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) { return refreshLiveTiktokTokens(); } return assertConfiguredTiktokAccount(tokens); }; const tiktokApi = async (url, options = {}) => { const tokens = await getLiveTiktokAccessToken(); const accessToken = tokens.access_token; const fetchOptions = { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json; charset=UTF-8', }, }; const readTiktokResponse = async (res) => { const text = await res.text(); try { return { res, data: JSON.parse(text) }; } catch { return { res, data: { raw: text } }; } }; let result = await readTiktokResponse(await fetch(url, fetchOptions)); if (result.res.status === 401 && result.data?.error?.code === 'access_token_invalid') { const refreshed = await refreshLiveTiktokTokens(); fetchOptions.headers.Authorization = `Bearer ${refreshed.access_token}`; result = await readTiktokResponse(await fetch(url, fetchOptions)); } const { res, data } = result; if (!res.ok || data?.error?.code !== 'ok') { const message = data?.error?.message || data?.raw || `TikTok API error: ${res.status}`; const error = new Error(message); error.status = res.status; error.body = data; throw error; } return data; }; // TikTok requires chunks between 5 MB and 64 MB; only a file that fits in a // single chunk may be smaller than 5 MB. total_chunk_count is // floor(size / chunk_size) — the remainder merges into the final chunk. const TIKTOK_MAX_CHUNK_BYTES = 64 * 1024 * 1024; const TIKTOK_CHUNK_TIMEOUT_MS = 120_000; const TIKTOK_MAX_CHUNK_ATTEMPTS = 3; const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const tiktokRetryDelayMs = (res, attempt) => { const retryAfter = res?.headers?.get?.('retry-after'); if (retryAfter) { const seconds = Number(retryAfter); if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000); } return Math.min(1000 * 2 ** attempt, 15_000) + Math.floor(Math.random() * 500); }; const planTiktokChunks = (totalBytes) => { if (totalBytes <= TIKTOK_MAX_CHUNK_BYTES) { return { chunkSize: totalBytes, totalChunkCount: 1 }; } const chunkSize = TIKTOK_MAX_CHUNK_BYTES; return { chunkSize, totalChunkCount: Math.floor(totalBytes / chunkSize) }; }; // Validate video type by magic bytes instead of trusting the client MIME string. const sniffVideoMimeType = (buffer) => { if (buffer.length >= 12 && buffer.toString('ascii', 4, 8) === 'ftyp') { const brand = buffer.toString('ascii', 8, 12); return brand.startsWith('qt') ? 'video/quicktime' : 'video/mp4'; } if (buffer.length >= 4 && buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) { return 'video/webm'; } return null; }; const putTiktokChunk = async (uploadUrl, chunk, { start, end, total, mimeType, isLast }) => { let lastError = null; for (let attempt = 0; attempt < TIKTOK_MAX_CHUNK_ATTEMPTS; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIKTOK_CHUNK_TIMEOUT_MS); let res = null; try { res = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': mimeType, 'Content-Length': String(chunk.length), 'Content-Range': `bytes ${start}-${end}/${total}`, }, body: chunk, signal: controller.signal, }); const text = await res.text(); // Complete upload → 201; intermediate chunk → 206. Anything else means // the upload is not actually complete, even if it is 2xx. const expected = isLast ? 201 : 206; if (res.status === expected) { return { status: res.status, body: text }; } lastError = new Error(`TikTok binary upload failed: ${res.status} ${text}`); lastError.status = res.status; const retryable = res.status === 429 || res.status >= 500; if (!retryable) throw lastError; } catch (err) { if (Number.isInteger(err?.status) && !(err.status === 429 || err.status >= 500)) throw err; lastError = Number.isInteger(err?.status) ? err : Object.assign(new Error(`TikTok binary upload network error: ${err?.message || err}`), { status: 502 }); } finally { clearTimeout(timer); } if (attempt < TIKTOK_MAX_CHUNK_ATTEMPTS - 1) await sleepMs(tiktokRetryDelayMs(res, attempt)); } throw lastError || Object.assign(new Error('TikTok binary upload failed.'), { status: 502 }); }; const uploadBinaryToTiktok = async (uploadUrl, buffer, mimeType = 'video/mp4') => { const total = buffer.length; if (!total) { throw Object.assign(new Error('Refusing to upload an empty file to TikTok.'), { status: 400, code: 'BAD_REQUEST' }); } const { chunkSize, totalChunkCount } = planTiktokChunks(total); let result = { status: 0, body: '' }; for (let i = 0; i < totalChunkCount; i++) { const start = i * chunkSize; const isLast = i === totalChunkCount - 1; const end = isLast ? total - 1 : start + chunkSize - 1; result = await putTiktokChunk(uploadUrl, buffer.subarray(start, end + 1), { start, end, total, mimeType, isLast, }); } return result; }; // INITIATED is not delivery. Poll with bounded backoff (TikTok allows max 30 // status calls/min) until the draft reaches the creator's inbox or fails. const TIKTOK_TERMINAL_STATUSES = new Set(['SEND_TO_USER_INBOX', 'PUBLISH_COMPLETE', 'FAILED']); const pollTiktokPublishStatus = async (publishId, { maxWaitMs = 45_000 } = {}) => { const delays = [2_000, 4_000, 8_000, 15_000, 30_000]; const startedAt = Date.now(); let last = { status: 'PROCESSING_UPLOAD', failReason: undefined, raw: null }; for (let i = 0; Date.now() - startedAt < maxWaitMs; i++) { await sleepMs(delays[Math.min(i, delays.length - 1)]); try { const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', { method: 'POST', body: Buffer.from(JSON.stringify({ publish_id: publishId })), }); const data = result?.data || null; last = { status: String(data?.status || 'UNKNOWN'), failReason: typeof data?.fail_reason === 'string' && data.fail_reason ? data.fail_reason : undefined, raw: data, }; if (TIKTOK_TERMINAL_STATUSES.has(last.status)) return last; } catch { // A transient status-fetch failure must not fail an already-uploaded // post; keep the last known state and try again within the window. } } return last; }; // ─── TikTok Video Upload ──────────────────────────────────────────────────── app.post('/api/tiktok/upload/video', async (request, response) => { try { // Uploads are API-only, so the key is accepted via header only (never as // a URL parameter that could land in logs). if (!isAuthorizedImport(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } const { videoBuffer, videoBase64 } = request.body || {}; let uploadBuffer = Buffer.isBuffer(videoBuffer) ? videoBuffer : null; if (!uploadBuffer && typeof videoBase64 === 'string') { const encoded = videoBase64.replace(/^data:video\/[^;]+;base64,/i, '').replace(/\s/g, ''); if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBase64 must be valid base64 video data.' }); } uploadBuffer = Buffer.from(encoded, 'base64'); } if (!uploadBuffer) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBase64 is required.' }); } if (!uploadBuffer.length) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'Video data must not be empty.' }); } if (uploadBuffer.length > 24 * 1024 * 1024) { return response.status(413).json({ code: 'PAYLOAD_TOO_LARGE', message: 'TikTok video must be 24 MB or smaller.' }); } const sniffedMimeType = sniffVideoMimeType(uploadBuffer); if (!sniffedMimeType) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'Video must be MP4, MOV, or WebM (magic-byte check failed).' }); } const videoSize = uploadBuffer.length; const chunkPlan = planTiktokChunks(videoSize); const initBody = { source_info: { source: 'FILE_UPLOAD', video_size: videoSize, chunk_size: chunkPlan.chunkSize, total_chunk_count: chunkPlan.totalChunkCount, }, }; const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', { method: 'POST', body: Buffer.from(JSON.stringify(initBody)), }); const uploadUrl = initResult?.data?.upload_url; const publishId = initResult?.data?.publish_id; if (!uploadUrl || !publishId) { return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing upload_url or publish_id from TikTok.' }); } await uploadBinaryToTiktok(uploadUrl, uploadBuffer, sniffedMimeType); // Note: the secret upload_url is intentionally not returned to clients. const finalStatus = await pollTiktokPublishStatus(publishId); response.status(200).json({ publish_id: publishId, status: finalStatus.status, delivered: finalStatus.status === 'SEND_TO_USER_INBOX', ...(finalStatus.failReason ? { fail_reason: finalStatus.failReason } : {}), }); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // ─── TikTok Photo/Carousel Upload ─────────────────────────────────────────── app.post('/api/tiktok/upload/photo', async (request, response) => { try { if (!isAuthorizedImport(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } const files = Array.isArray(request.body?.files) ? request.body.files : Array.isArray(request.body?.photos) ? request.body.photos : []; if (!files.length) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'files must be a non-empty array.' }); } if (files.length > 35) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'TikTok allows up to 35 photos per carousel post.' }); } // TikTok only accepts PULL_FROM_URL media from a domain verified for this // app. Base64 inputs are normalized to JPEG and hosted under // greenlenspro.com/storage before the TikTok request is created. const photoUrls = await prepareTiktokPhotoUrls(files, { uploadImage, publicBaseUrl: process.env.MINIO_PUBLIC_URL || 'https://greenlenspro.com/storage', }); const title = typeof request.body?.title === 'string' ? request.body.title.trim() : ''; const description = typeof request.body?.description === 'string' ? request.body.description.trim() : ''; if (title.length > 90) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'title must be at most 90 characters for TikTok photo posts.' }); } if (description.length > 4000) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'description must be at most 4000 characters for TikTok photo posts.' }); } const initBody = { media_type: 'PHOTO', // MEDIA_UPLOAD = draft in the creator's TikTok inbox (posting policy is // upload/draft only) and only needs the video.upload scope. post_mode: 'MEDIA_UPLOAD', post_info: { ...(title ? { title } : {}), ...(description ? { description } : {}), }, source_info: { source: 'PULL_FROM_URL', photo_cover_index: 0, photo_images: photoUrls, }, }; const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/content/init/', { method: 'POST', body: Buffer.from(JSON.stringify(initBody)), }); const publishId = initResult?.data?.publish_id; if (!publishId) { return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing publish_id from TikTok.' }); } const finalStatus = await pollTiktokPublishStatus(publishId); response.status(200).json({ publish_id: publishId, status: finalStatus.status, delivered: finalStatus.status === 'SEND_TO_USER_INBOX', ...(finalStatus.failReason ? { fail_reason: finalStatus.failReason } : {}), }); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // ─── TikTok Upload Status ─────────────────────────────────────────────────── app.get('/api/tiktok/upload/status', async (request, response) => { try { if (!isAuthorizedImport(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } const publishId = String(request.query.publish_id || '').trim(); if (!publishId) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'publish_id is required.' }); } const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', { method: 'POST', body: Buffer.from(JSON.stringify({ publish_id: publishId })), }); response.status(200).json(result); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // ─── TikTok Analytics ─────────────────────────────────────────────────────── // user.info only requests fields covered by the approved user.info.basic // scope. video.list needs a scope GreenLens's TikTok app isn't approved for // (would require adding the Display API product and a new app review), so // video stats are omitted until that's added. const TIKTOK_USER_FIELDS = 'display_name,avatar_url'; const TIKTOK_VIDEO_FIELDS = 'id,title,video_description,duration,create_time,share_url,view_count,like_count,comment_count,share_count'; const TIKTOK_VIDEO_PAGE_SIZE = 20; // Display API maximum per page const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const summarizeTiktokVideos = (videos) => { const enriched = videos.map((video) => { const views = Number(video.view_count) || 0; const likes = Number(video.like_count) || 0; const comments = Number(video.comment_count) || 0; const shares = Number(video.share_count) || 0; const engagements = likes + comments + shares; const postedAt = video.create_time ? new Date(Number(video.create_time) * 1000) : null; return { ...video, engagement_rate: views > 0 ? Number((engagements / views).toFixed(4)) : null, posted_at: postedAt ? postedAt.toISOString() : null, posted_weekday_utc: postedAt ? WEEKDAY_NAMES[postedAt.getUTCDay()] : null, posted_hour_utc: postedAt ? postedAt.getUTCHours() : null, }; }); const viewCounts = enriched.map((video) => Number(video.view_count) || 0).sort((a, b) => a - b); const total = (key) => enriched.reduce((sum, video) => sum + (Number(video[key]) || 0), 0); const median = viewCounts.length ? viewCounts.length % 2 ? viewCounts[(viewCounts.length - 1) / 2] : (viewCounts[viewCounts.length / 2 - 1] + viewCounts[viewCounts.length / 2]) / 2 : 0; const summary = { video_count: enriched.length, total_views: total('view_count'), total_likes: total('like_count'), total_comments: total('comment_count'), total_shares: total('share_count'), average_views: enriched.length ? Math.round(total('view_count') / enriched.length) : 0, median_views: median, }; return { videos: enriched, summary }; }; app.get('/api/tiktok/analytics', async (request, response) => { try { if (!isAuthorizedAdminNavigation(request)) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } const maxVideos = Math.min(Math.max(Number(request.query.max_videos) || 50, 1), 200); const userResult = await tiktokApi( `https://open.tiktokapis.com/v2/user/info/?fields=${TIKTOK_USER_FIELDS}`, { method: 'GET' }, ); const videos = []; let videoListUnavailable = false; try { let cursor; let hasMore = true; while (hasMore && videos.length < maxVideos) { const pageBody = { max_count: Math.min(TIKTOK_VIDEO_PAGE_SIZE, maxVideos - videos.length) }; if (cursor) pageBody.cursor = cursor; const page = await tiktokApi( `https://open.tiktokapis.com/v2/video/list/?fields=${TIKTOK_VIDEO_FIELDS}`, { method: 'POST', body: Buffer.from(JSON.stringify(pageBody)) }, ); const pageVideos = Array.isArray(page?.data?.videos) ? page.data.videos : []; videos.push(...pageVideos); cursor = page?.data?.cursor; hasMore = Boolean(page?.data?.has_more) && pageVideos.length > 0; } } catch (error) { // video.list is not part of GreenLens's approved TikTok app scopes yet; // still return the confirmed account identity from user.info. if (error.body?.error?.code === 'scope_not_authorized') { videoListUnavailable = true; } else { throw error; } } const { videos: enrichedVideos, summary } = summarizeTiktokVideos(videos); response.status(200).json({ user: userResult?.data?.user || null, summary, videos: enrichedVideos, videoListUnavailable, }); } catch (error) { const payload = toApiErrorPayload(error); response.status(payload.status).json(payload.body); } }); // ─── Startup ─────────────────────────────────────────────────────────────── app.delete('/auth/account', async (request, response) => { try { const authHeader = request.header('authorization') || request.header('Authorization') || ''; if (!authHeader.startsWith('Bearer ')) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing bearer token.' }); } const payload = verifyJwt(authHeader.slice(7)); if (!payload?.sub) { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid bearer token.' }); } await authDeleteAccount(db, String(payload.sub)); response.status(204).send(); } catch (error) { const status = error.status || 500; response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); } }); const start = async () => { db = await openDatabase(); await ensurePlantSchema(db); await ensureBillingSchema(db); await ensureAuthSchema(db); await ensureScanEventSchema(db); await seedBootstrapCatalogIfNeeded(); if (isStorageConfigured()) { await ensureStorageBucketWithRetry().catch((err) => console.warn('MinIO bucket setup failed:', err.message)); } const server = app.listen(port, () => { console.log(`GreenLens server listening at http://localhost:${port}`); }); const gracefulShutdown = async () => { try { await closeDatabase(db); } catch (error) { console.error('Failed to close database', error); } finally { server.close(() => process.exit(0)); } }; process.on('SIGINT', gracefulShutdown); process.on('SIGTERM', gracefulShutdown); }; start().catch((error) => { console.error('Failed to start GreenLens server', error); process.exit(1); });