TikTok api
This commit is contained in:
523
server/index.js
523
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);
|
||||
|
||||
Reference in New Issue
Block a user