TikTok V5
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
const { get, run } = require('./postgres');
|
||||
|
||||
const TIKTOK_ACCOUNT_KEY = 'hermes-agent';
|
||||
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
|
||||
const refreshExpiresAt = tokens.refresh_expires_in !== undefined && tokens.refresh_expires_in !== null
|
||||
? new Date(now + Number(tokens.refresh_expires_in) * 1000)
|
||||
: null;
|
||||
|
||||
@@ -19,7 +21,7 @@ const saveTiktokTokens = async (db, tokens) => {
|
||||
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,
|
||||
refresh_token_expires_at = COALESCE(EXCLUDED.refresh_token_expires_at, tiktok_tokens.refresh_token_expires_at),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
TIKTOK_ACCOUNT_KEY,
|
||||
@@ -33,8 +35,23 @@ const saveTiktokTokens = async (db, tokens) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getTiktokTokens = async (db) =>
|
||||
get(db, `SELECT * FROM tiktok_tokens WHERE account_key = ?`, [TIKTOK_ACCOUNT_KEY]);
|
||||
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/', {
|
||||
@@ -59,7 +76,7 @@ const exchangeCodeForTokens = async ({ code, clientKey, clientSecret, redirectUr
|
||||
return data;
|
||||
};
|
||||
|
||||
const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
|
||||
const performTiktokRefresh = async (db, { clientKey, clientSecret }) => {
|
||||
const existing = await getTiktokTokens(db);
|
||||
if (!existing) {
|
||||
const error = new Error('No TikTok account connected.');
|
||||
@@ -67,6 +84,16 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
|
||||
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' },
|
||||
@@ -80,9 +107,11 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.error) {
|
||||
const message = data.error_description || data.error || 'TikTok token refresh failed.';
|
||||
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 = 502;
|
||||
error.status = providerCode === 'invalid_grant' ? 401 : 502;
|
||||
error.code = providerCode === 'invalid_grant' ? 'TIKTOK_RECONNECT_REQUIRED' : 'TIKTOK_REFRESH_FAILED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -90,8 +119,34 @@ const refreshTiktokTokens = async (db, { clientKey, clientSecret }) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user