Add Olist V3 product structure sync

This commit is contained in:
Cauê Faleiros
2026-07-28 14:09:48 -03:00
parent 4547642411
commit b1067325b9
7 changed files with 841 additions and 15 deletions

View File

@@ -0,0 +1,174 @@
import axios from 'axios';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
type OlistV3Tokens = {
access_token: string;
refresh_token?: string;
expires_in?: number;
expires_at?: string;
};
type OlistV3AuthorizationState = {
state: string;
createdAt: string;
};
const OAUTH_BASE_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect';
const TOKEN_URL = `${OAUTH_BASE_URL}/token`;
const AUTHORIZE_URL = `${OAUTH_BASE_URL}/auth`;
const DEFAULT_TOKEN_FILE = path.join(process.cwd(), 'data-runtime', 'olist_v3_tokens.json');
const DEFAULT_STATE_FILE = path.join(process.cwd(), 'data-runtime', 'olist_v3_oauth_state.json');
function requiredEnv(name: string) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing ${name}.`);
return value;
}
function getTokenFile() {
return process.env.OLIST_V3_TOKEN_FILE || DEFAULT_TOKEN_FILE;
}
function getStateFile() {
return process.env.OLIST_V3_OAUTH_STATE_FILE || DEFAULT_STATE_FILE;
}
function ensureParentDirectory(filePath: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function writeJson(filePath: string, value: unknown) {
ensureParentDirectory(filePath);
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), { mode: 0o600 });
}
function readJson<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T;
}
function getClientConfig() {
return {
clientId: requiredEnv('OLIST_V3_CLIENT_ID'),
clientSecret: requiredEnv('OLIST_V3_CLIENT_SECRET'),
redirectUri: requiredEnv('OLIST_V3_REDIRECT_URI')
};
}
function saveTokens(tokens: OlistV3Tokens) {
const expiresIn = Number(tokens.expires_in || 0);
const expiresAt = Number.isFinite(expiresIn) && expiresIn > 0
? new Date(Date.now() + Math.max(0, expiresIn - 60) * 1000).toISOString()
: undefined;
const previous = readJson<OlistV3Tokens>(getTokenFile());
writeJson(getTokenFile(), {
...previous,
...tokens,
refresh_token: tokens.refresh_token || previous?.refresh_token,
expires_at: expiresAt || previous?.expires_at
});
}
function tokenIsUsable(tokens: OlistV3Tokens) {
if (!tokens.access_token) return false;
if (!tokens.expires_at) return true;
return new Date(tokens.expires_at).getTime() > Date.now();
}
async function requestTokens(params: URLSearchParams) {
const response = await axios.post<OlistV3Tokens>(TOKEN_URL, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 30000
});
if (!response.data?.access_token) {
throw new Error('Olist V3 OAuth did not return an access token.');
}
saveTokens(response.data);
return response.data;
}
export function isOlistV3Configured() {
return Boolean(
process.env.OLIST_V3_CLIENT_ID?.trim()
&& process.env.OLIST_V3_CLIENT_SECRET?.trim()
&& process.env.OLIST_V3_REDIRECT_URI?.trim()
);
}
export function getOlistV3AuthorizationUrl() {
const { clientId, redirectUri } = getClientConfig();
const state = crypto.randomBytes(32).toString('hex');
writeJson(getStateFile(), { state, createdAt: new Date().toISOString() } satisfies OlistV3AuthorizationState);
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'openid',
response_type: 'code',
state
});
return `${AUTHORIZE_URL}?${params.toString()}`;
}
export async function exchangeOlistV3AuthorizationCode(code: string, state: string) {
const expected = readJson<OlistV3AuthorizationState>(getStateFile());
const expectedState = Buffer.from(expected?.state || '');
const receivedState = Buffer.from(state || '');
if (!expected?.state || expectedState.length !== receivedState.length || !crypto.timingSafeEqual(expectedState, receivedState)) {
throw new Error('Invalid OAuth state. Start the authorization flow again.');
}
const createdAt = new Date(expected.createdAt).getTime();
if (!Number.isFinite(createdAt) || Date.now() - createdAt > 10 * 60 * 1000) {
throw new Error('OAuth state expired. Start the authorization flow again.');
}
const { clientId, clientSecret, redirectUri } = getClientConfig();
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
code
});
const tokens = await requestTokens(params);
fs.rmSync(getStateFile(), { force: true });
return tokens;
}
export async function getOlistV3AccessToken(forceRefresh = false) {
const current = readJson<OlistV3Tokens>(getTokenFile());
if (current && !forceRefresh && tokenIsUsable(current)) {
return current.access_token;
}
if (!current?.refresh_token) {
throw new Error('Olist V3 is not authorized. Open /api/olist-v3/authorize first.');
}
const { clientId, clientSecret } = getClientConfig();
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: clientId,
client_secret: clientSecret,
refresh_token: current.refresh_token
});
const tokens = await requestTokens(params);
return tokens.access_token;
}
export function getOlistV3AuthStatus() {
const tokens = readJson<OlistV3Tokens>(getTokenFile());
return {
configured: isOlistV3Configured(),
authorized: Boolean(tokens?.access_token || tokens?.refresh_token),
accessTokenUsable: Boolean(tokens && tokenIsUsable(tokens)),
tokenExpiresAt: tokens?.expires_at || null,
tokenFile: getTokenFile()
};
}