const { get, run } = require('./postgres'); const TIKTOK_ACCOUNT_KEY = 'greenlens'; const LEGACY_TIKTOK_ACCOUNT_KEY = 'hermes-agent'; let refreshInFlight = null; 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 !== undefined && tokens.refresh_expires_in !== null ? 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 = COALESCE(EXCLUDED.refresh_token_expires_at, tiktok_tokens.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) => { const current = await get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]); if (current) return current; // Preserve an existing production connection when upgrading from the old, // generic key. Future writes use the brand-specific key. const legacy = await get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [LEGACY_TIKTOK_ACCOUNT_KEY]); if (!legacy) return null; await saveTiktokTokens(db, { ...legacy, expires_in: Math.max(0, Math.floor((new Date(legacy.access_token_expires_at).getTime() - Date.now()) / 1000)), refresh_expires_in: legacy.refresh_token_expires_at ? Math.max(0, Math.floor((new Date(legacy.refresh_token_expires_at).getTime() - Date.now()) / 1000)) : undefined, }); return 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 performTiktokRefresh = 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 refreshExpiresAt = existing.refresh_token_expires_at ? new Date(existing.refresh_token_expires_at).getTime() : null; if (refreshExpiresAt !== null && (!Number.isFinite(refreshExpiresAt) || refreshExpiresAt <= Date.now())) { const error = new Error('TikTok authorization has expired. Reconnect the GreenLens TikTok account.'); error.status = 401; error.code = 'TIKTOK_RECONNECT_REQUIRED'; 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 providerCode = typeof data.error === 'string' ? data.error : data.error?.code; const message = data.error_description || data.error?.message || providerCode || 'TikTok token refresh failed.'; const error = new Error(message); error.status = providerCode === 'invalid_grant' ? 401 : 502; error.code = providerCode === 'invalid_grant' ? 'TIKTOK_RECONNECT_REQUIRED' : 'TIKTOK_REFRESH_FAILED'; throw error; } await saveTiktokTokens(db, data); return getTiktokTokens(db); }; const refreshTiktokTokens = async (db, credentials) => { if (!refreshInFlight) { refreshInFlight = performTiktokRefresh(db, credentials).finally(() => { refreshInFlight = null; }); } return refreshInFlight; }; const assertExpectedTiktokAccount = (openId, expectedOpenId) => { const expected = String(expectedOpenId || '').trim(); if (!expected) { const error = new Error('TIKTOK_EXPECTED_OPEN_ID is not configured for GreenLens.'); error.status = 500; error.code = 'TIKTOK_ACCOUNT_NOT_CONFIGURED'; throw error; } if (String(openId || '').trim() !== expected) { const error = new Error('Connected TikTok account does not match the configured GreenLens account.'); error.status = 409; error.code = 'TIKTOK_ACCOUNT_MISMATCH'; throw error; } }; module.exports = { exchangeCodeForTokens, assertExpectedTiktokAccount, getTiktokTokens, refreshTiktokTokens, saveTiktokTokens, };