diff --git a/.env.example b/.env.example index 9aa1bc7..c266ea8 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ REVENUECAT_WEBHOOK_SECRET= REVENUECAT_PRO_ENTITLEMENT_ID=pro PLANT_IMPORT_ADMIN_KEY= + +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= +TIKTOK_REDIRECT_URI=https://greenlenspro.com/api/tiktok/callback diff --git a/server/index.js b/server/index.js index 71e6f22..aaab2d6 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const dotenv = require('dotenv'); const express = require('express'); const cors = require('cors'); @@ -25,16 +26,16 @@ loadEnvFiles([ 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 { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); +const { + deleteAccount: authDeleteAccount, + ensureAuthSchema, + signUp: authSignUp, + login: authLogin, + signInWithApple: authSignInWithApple, + issueToken, + verifyJwt, +} = require('./lib/auth'); const { PlantImportValidationError, ensurePlantSchema, @@ -66,15 +67,21 @@ const { } = require('./lib/openai'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); +const { + exchangeCodeForTokens: exchangeTiktokCode, + getTiktokTokens, + refreshTiktokTokens, + saveTiktokTokens, +} = require('./lib/tiktok'); const app = express(); 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 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; let catalogCache = null; @@ -155,6 +162,14 @@ const isAuthorizedImport = (request) => { 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 true; + 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'; }; @@ -176,27 +191,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 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) => { +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, @@ -257,57 +272,57 @@ 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 { - 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.' }, - }; + 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.' }, + }; } return { @@ -523,10 +538,10 @@ app.get('/', (_request, response) => { 'GET /health', 'GET /api/plants', 'POST /api/plants/rebuild', - 'POST /auth/signup', - 'POST /auth/login', - 'POST /auth/apple', - 'DELETE /auth/account', + 'POST /auth/signup', + 'POST /auth/login', + 'POST /auth/apple', + 'DELETE /auth/account', 'GET /v1/billing/summary', 'POST /v1/billing/sync-revenuecat', 'POST /v1/scan', @@ -536,6 +551,10 @@ app.get('/', (_request, response) => { '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', ], }); }); @@ -680,17 +699,17 @@ app.post('/v1/scan', async (request, response) => { let modelUsed = null; let modelFallbackCount = 0; - 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 [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 }); @@ -826,24 +845,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 accountSnapshot = await getAccountSnapshot(db, userId); - ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST); - - 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, ); @@ -875,14 +894,14 @@ app.post('/v1/health-check', async (request, response) => { const cached = await getEndpointResponse(db, endpointId); if (cached) { response.status(200).json(cached); - 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.'); + 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; } @@ -911,12 +930,12 @@ app.post('/v1/health-check', async (request, response) => { : 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: [{ + 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, @@ -947,12 +966,12 @@ app.post('/v1/health-check', async (request, response) => { ); } - const healthCheck = { - generatedAt: nowIso(), - overallHealthScore: analysis.overallHealthScore, - status: analysis.status, - analysisSummary: analysis.analysisSummary, - likelyIssues: analysis.likelyIssues, + const healthCheck = { + generatedAt: nowIso(), + overallHealthScore: analysis.overallHealthScore, + status: analysis.status, + analysisSummary: analysis.analysisSummary, + likelyIssues: analysis.likelyIssues, actionsNow: analysis.actionsNow, plan7Days: analysis.plan7Days, creditsCharged, @@ -1047,9 +1066,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.' }); } @@ -1059,53 +1078,207 @@ 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, - isNewUser: Boolean(user.isNewUser), - }); - } 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, + 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(); + return { clientKey, clientSecret, redirectUri }; +}; + +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'); + 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 }); + 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}`); + } +}); + +// Token handoff for Hermes Agent: always returns a valid access token, +// refreshing it automatically when it expires within the buffer window. +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.' }); + } + + try { + let tokens = await getTiktokTokens(db); + if (!tokens) { + return response.status(404).json({ + code: 'NOT_CONNECTED', + message: 'No TikTok account connected. Visit /api/tiktok/connect first.', + }); + } + + const expiresAt = new Date(tokens.access_token_expires_at).getTime(); + if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) { + const { clientKey, clientSecret } = getTiktokEnv(); + if (!clientKey || !clientSecret) { + return response.status(500).json({ + code: 'SERVER_ERROR', + message: 'TikTok client credentials are not configured.', + }); + } + tokens = await refreshTiktokTokens(db, { clientKey, clientSecret }); + } + + response.json({ + access_token: tokens.access_token, + open_id: tokens.open_id, + scope: tokens.scope, + expires_at: tokens.access_token_expires_at, + }); + } catch (error) { + console.error('TikTok token endpoint error', error); + response.status(error.status || 502).json({ + code: error.code || 'PROVIDER_ERROR', + message: error.message || 'Failed to provide TikTok token.', + }); + } +}); + +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); + if (!tokens) { + return response.json({ connected: false }); + } + response.json({ + connected: true, + openId: tokens.open_id, + scope: tokens.scope, + accessTokenExpiresAt: tokens.access_token_expires_at, + }); +}); // ─── 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 () => { +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); diff --git a/server/lib/tiktok.js b/server/lib/tiktok.js new file mode 100644 index 0000000..17be644 --- /dev/null +++ b/server/lib/tiktok.js @@ -0,0 +1,98 @@ +const { get, run } = require('./postgres'); + +const TIKTOK_ACCOUNT_KEY = 'hermes-agent'; + +const saveTiktokTokens = async (db, tokens) => { + const now = Date.now(); + const accessExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000); + const refreshExpiresAt = tokens.refresh_expires_in + ? new Date(now + Number(tokens.refresh_expires_in) * 1000) + : null; + + await run( + db, + `INSERT INTO tiktok_tokens (account_key, open_id, access_token, refresh_token, scope, access_token_expires_at, refresh_token_expires_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT (account_key) DO UPDATE SET + open_id = EXCLUDED.open_id, + access_token = EXCLUDED.access_token, + refresh_token = EXCLUDED.refresh_token, + scope = EXCLUDED.scope, + access_token_expires_at = EXCLUDED.access_token_expires_at, + refresh_token_expires_at = EXCLUDED.refresh_token_expires_at, + updated_at = CURRENT_TIMESTAMP`, + [ + TIKTOK_ACCOUNT_KEY, + tokens.open_id, + tokens.access_token, + tokens.refresh_token, + tokens.scope || null, + accessExpiresAt, + refreshExpiresAt, + ], + ); +}; + +const getTiktokTokens = async (db) => + get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]); + +const exchangeCodeForTokens = async ({ code, clientKey, clientSecret, redirectUri }) => { + const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_key: clientKey, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }), + }); + + const data = await response.json(); + if (!response.ok || data.error) { + const message = data.error_description || data.error || 'TikTok token exchange failed.'; + const error = new Error(message); + error.status = 502; + throw error; + } + return data; +}; + +const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => { + const existing = await getTiktokTokens(db); + if (!existing) { + const error = new Error('No TikTok account connected.'); + error.status = 404; + throw error; + } + + const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_key: clientKey, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: existing.refresh_token, + }), + }); + + const data = await response.json(); + if (!response.ok || data.error) { + const message = data.error_description || data.error || 'TikTok token refresh failed.'; + const error = new Error(message); + error.status = 502; + throw error; + } + + await saveTiktokTokens(db, data); + return getTiktokTokens(db); +}; + +module.exports = { + exchangeCodeForTokens, + getTiktokTokens, + refreshTiktokTokens, + saveTiktokTokens, +};