TikTok api

This commit is contained in:
2026-07-02 13:10:19 +02:00
parent e441bcb25f
commit 34fe0b461e
3 changed files with 450 additions and 175 deletions

View File

@@ -21,3 +21,7 @@ REVENUECAT_WEBHOOK_SECRET=
REVENUECAT_PRO_ENTITLEMENT_ID=pro REVENUECAT_PRO_ENTITLEMENT_ID=pro
PLANT_IMPORT_ADMIN_KEY= PLANT_IMPORT_ADMIN_KEY=
TIKTOK_CLIENT_KEY=
TIKTOK_CLIENT_SECRET=
TIKTOK_REDIRECT_URI=https://greenlenspro.com/api/tiktok/callback

View File

@@ -1,5 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
@@ -25,16 +26,16 @@ loadEnvFiles([
path.join(__dirname, '.env.local'), path.join(__dirname, '.env.local'),
]); ]);
const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres'); const { closeDatabase, getDefaultDbPath, openDatabase, get } = require('./lib/postgres');
const { const {
deleteAccount: authDeleteAccount, deleteAccount: authDeleteAccount,
ensureAuthSchema, ensureAuthSchema,
signUp: authSignUp, signUp: authSignUp,
login: authLogin, login: authLogin,
signInWithApple: authSignInWithApple, signInWithApple: authSignInWithApple,
issueToken, issueToken,
verifyJwt, verifyJwt,
} = require('./lib/auth'); } = require('./lib/auth');
const { const {
PlantImportValidationError, PlantImportValidationError,
ensurePlantSchema, ensurePlantSchema,
@@ -66,15 +67,21 @@ const {
} = require('./lib/openai'); } = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const {
exchangeCodeForTokens: exchangeTiktokCode,
getTiktokTokens,
refreshTiktokTokens,
saveTiktokTokens,
} = require('./lib/tiktok');
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const plantsPublicDir = path.join(__dirname, 'public', 'plants'); const plantsPublicDir = path.join(__dirname, 'public', 'plants');
const SCAN_PRIMARY_COST = 1; const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 0; const SCAN_REVIEW_COST = 0;
const SEMANTIC_SEARCH_COST = 2; const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2; const HEALTH_CHECK_COST = 2;
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
let catalogCache = null; let catalogCache = null;
@@ -155,6 +162,14 @@ const isAuthorizedImport = (request) => {
return provided === process.env.PLANT_IMPORT_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 true;
const provided = request.header('x-admin-key') || request.query?.key;
return provided === process.env.PLANT_IMPORT_ADMIN_KEY;
};
const normalizeLanguage = (value) => { const normalizeLanguage = (value) => {
return value === 'de' || value === 'en' || value === 'es' ? value : 'en'; return value === 'de' || value === 'en' || value === 'es' ? value : 'en';
}; };
@@ -176,27 +191,27 @@ const resolveUserId = (request) => {
return ''; return '';
}; };
const resolveIdempotencyKey = (request) => { const resolveIdempotencyKey = (request) => {
const header = request.header('idempotency-key'); const header = request.header('idempotency-key');
if (typeof header === 'string' && header.trim()) return header.trim(); if (typeof header === 'string' && header.trim()) return header.trim();
return ''; return '';
}; };
const createHardPaywallError = (requiredCredits) => { const createHardPaywallError = (requiredCredits) => {
const error = new Error('Active Pro or trial entitlement required.'); const error = new Error('Active Pro or trial entitlement required.');
error.code = 'INSUFFICIENT_CREDITS'; error.code = 'INSUFFICIENT_CREDITS';
error.status = 402; error.status = 402;
error.metadata = { required: requiredCredits, available: 0 }; error.metadata = { required: requiredCredits, available: 0 };
return error; return error;
}; };
const ensureActiveProEntitlement = (accountSnapshot, requiredCredits) => { const ensureActiveProEntitlement = (accountSnapshot, requiredCredits) => {
if (!accountSnapshot || accountSnapshot.plan !== 'pro') { if (!accountSnapshot || accountSnapshot.plan !== 'pro') {
throw createHardPaywallError(requiredCredits); throw createHardPaywallError(requiredCredits);
} }
}; };
const toPlantResult = (entry, confidence) => { const toPlantResult = (entry, confidence) => {
return { return {
name: entry.name, name: entry.name,
botanicalName: entry.botanicalName, botanicalName: entry.botanicalName,
@@ -257,57 +272,57 @@ const toApiErrorPayload = (error) => {
}; };
} }
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') { if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
return { return {
status: 401, status: 401,
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' }, body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
}; };
} }
if (isInsufficientCreditsError(error)) { if (isInsufficientCreditsError(error)) {
return { return {
status: 402, status: 402,
body: { body: {
code: 'INSUFFICIENT_CREDITS', code: 'INSUFFICIENT_CREDITS',
message: error.message || 'Insufficient credits.', message: error.message || 'Insufficient credits.',
details: error.metadata || undefined, 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') { if (
return { error
status: 502, && typeof error === 'object'
body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' }, && Number.isInteger(error.status)
}; && error.status >= 400
} && error.status < 500
&& typeof error.code === 'string'
if (error && typeof error === 'object' && error.code === 'NOT_A_PLANT') { ) {
return { return {
status: 422, status: error.status,
body: { code: 'NOT_A_PLANT', message: error.message || 'Image does not contain a plant.' }, body: { code: error.code, message: error.message || 'Request failed.' },
}; };
} }
if (error && typeof error === 'object' && error.code === 'TIMEOUT') { if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
return { return {
status: 504, status: 502,
body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' }, 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 { return {
@@ -523,10 +538,10 @@ app.get('/', (_request, response) => {
'GET /health', 'GET /health',
'GET /api/plants', 'GET /api/plants',
'POST /api/plants/rebuild', 'POST /api/plants/rebuild',
'POST /auth/signup', 'POST /auth/signup',
'POST /auth/login', 'POST /auth/login',
'POST /auth/apple', 'POST /auth/apple',
'DELETE /auth/account', 'DELETE /auth/account',
'GET /v1/billing/summary', 'GET /v1/billing/summary',
'POST /v1/billing/sync-revenuecat', 'POST /v1/billing/sync-revenuecat',
'POST /v1/scan', 'POST /v1/scan',
@@ -536,6 +551,10 @@ app.get('/', (_request, response) => {
'POST /v1/billing/simulate-webhook', 'POST /v1/billing/simulate-webhook',
'POST /v1/upload/image', 'POST /v1/upload/image',
'POST /api/revenuecat/webhook', '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 modelUsed = null;
let modelFallbackCount = 0; let modelFallbackCount = 0;
const [accountSnapshot, catalogEntries] = await Promise.all([ const [accountSnapshot, catalogEntries] = await Promise.all([
getAccountSnapshot(db, userId), getAccountSnapshot(db, userId),
getCachedCatalogEntries(db), getCachedCatalogEntries(db),
]); ]);
ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST); ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);
creditsCharged += await consumeCreditsWithIdempotency( creditsCharged += await consumeCreditsWithIdempotency(
db, db,
userId, userId,
chargeKey('scan-primary', userId, idempotencyKey), chargeKey('scan-primary', userId, idempotencyKey),
SCAN_PRIMARY_COST, SCAN_PRIMARY_COST,
); );
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true }); let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
@@ -826,24 +845,24 @@ app.post('/v1/search/semantic', async (request, response) => {
return; return;
} }
if (!query) { if (!query) {
const payload = { const payload = {
status: 'no_results', status: 'no_results',
results: [], results: [],
creditsCharged: 0, creditsCharged: 0,
billing: await getBillingSummary(db, userId), billing: await getBillingSummary(db, userId),
}; };
await storeEndpointResponse(db, endpointId, payload); await storeEndpointResponse(db, endpointId, payload);
response.status(200).json(payload); response.status(200).json(payload);
return; return;
} }
const accountSnapshot = await getAccountSnapshot(db, userId); const accountSnapshot = await getAccountSnapshot(db, userId);
ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST); ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);
const creditsCharged = await consumeCreditsWithIdempotency( const creditsCharged = await consumeCreditsWithIdempotency(
db, db,
userId, userId,
chargeKey('semantic-search', userId, idempotencyKey), chargeKey('semantic-search', userId, idempotencyKey),
SEMANTIC_SEARCH_COST, SEMANTIC_SEARCH_COST,
); );
@@ -875,14 +894,14 @@ app.post('/v1/health-check', async (request, response) => {
const cached = await getEndpointResponse(db, endpointId); const cached = await getEndpointResponse(db, endpointId);
if (cached) { if (cached) {
response.status(200).json(cached); response.status(200).json(cached);
return; return;
} }
const accountSnapshot = await getAccountSnapshot(db, userId); const accountSnapshot = await getAccountSnapshot(db, userId);
ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST); ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);
if (!isOpenAiConfigured()) { if (!isOpenAiConfigured()) {
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.'); const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
error.code = 'PROVIDER_ERROR'; error.code = 'PROVIDER_ERROR';
throw error; throw error;
} }
@@ -911,12 +930,12 @@ app.post('/v1/health-check', async (request, response) => {
: language === 'es' : language === 'es'
? 'Volver a escanear cuando la conexión sea estable.' ? 'Volver a escanear cuando la conexión sea estable.'
: 'Try scanning again when your connection is stable.'; : 'Try scanning again when your connection is stable.';
const fallbackHealthCheck = { const fallbackHealthCheck = {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: 50, overallHealthScore: 50,
status: 'watch', status: 'watch',
analysisSummary: unavailableIssue, analysisSummary: unavailableIssue,
likelyIssues: [{ likelyIssues: [{
title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable', title: language === 'de' ? 'Analyse nicht verfügbar' : language === 'es' ? 'Análisis no disponible' : 'Analysis unavailable',
confidence: 0.1, confidence: 0.1,
details: unavailableIssue, details: unavailableIssue,
@@ -947,12 +966,12 @@ app.post('/v1/health-check', async (request, response) => {
); );
} }
const healthCheck = { const healthCheck = {
generatedAt: nowIso(), generatedAt: nowIso(),
overallHealthScore: analysis.overallHealthScore, overallHealthScore: analysis.overallHealthScore,
status: analysis.status, status: analysis.status,
analysisSummary: analysis.analysisSummary, analysisSummary: analysis.analysisSummary,
likelyIssues: analysis.likelyIssues, likelyIssues: analysis.likelyIssues,
actionsNow: analysis.actionsNow, actionsNow: analysis.actionsNow,
plan7Days: analysis.plan7Days, plan7Days: analysis.plan7Days,
creditsCharged, creditsCharged,
@@ -1047,9 +1066,9 @@ app.post('/auth/signup', async (request, response) => {
} }
}); });
app.post('/auth/login', async (request, response) => { app.post('/auth/login', async (request, response) => {
try { try {
const { email, password } = request.body || {}; const { email, password } = request.body || {};
if (!email || !password) { if (!email || !password) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' }); 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) { } catch (error) {
const status = error.status || 500; const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
} }
}); });
app.post('/auth/apple', async (request, response) => { app.post('/auth/apple', async (request, response) => {
try { try {
const { identityToken, appleUser, email, name } = request.body || {}; const { identityToken, appleUser, email, name } = request.body || {};
if (!identityToken) { if (!identityToken) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' }); return response.status(400).json({ code: 'BAD_REQUEST', message: 'identityToken is required.' });
} }
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name }); const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
const token = issueToken(user.id, user.email, user.name); const token = issueToken(user.id, user.email, user.name);
response.status(200).json({ response.status(200).json({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
token, token,
isNewUser: Boolean(user.isNewUser), isNewUser: Boolean(user.isNewUser),
}); });
} catch (error) { } catch (error) {
const status = error.status || 500; const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); 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 ─────────────────────────────────────────────────────────────── // ─── Startup ───────────────────────────────────────────────────────────────
app.delete('/auth/account', async (request, response) => { app.delete('/auth/account', async (request, response) => {
try { try {
const authHeader = request.header('authorization') || request.header('Authorization') || ''; const authHeader = request.header('authorization') || request.header('Authorization') || '';
if (!authHeader.startsWith('Bearer ')) { if (!authHeader.startsWith('Bearer ')) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing bearer token.' }); return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing bearer token.' });
} }
const payload = verifyJwt(authHeader.slice(7)); const payload = verifyJwt(authHeader.slice(7));
if (!payload?.sub) { if (!payload?.sub) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid bearer token.' }); return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid bearer token.' });
} }
await authDeleteAccount(db, String(payload.sub)); await authDeleteAccount(db, String(payload.sub));
response.status(204).send(); response.status(204).send();
} catch (error) { } catch (error) {
const status = error.status || 500; const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
} }
}); });
const start = async () => { const start = async () => {
db = await openDatabase(); db = await openDatabase();
await ensurePlantSchema(db); await ensurePlantSchema(db);
await ensureBillingSchema(db); await ensureBillingSchema(db);

98
server/lib/tiktok.js Normal file
View File

@@ -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,
};