From b20fbe9c8b398e524376d7722c7bfea2404bbd46 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Wed, 2 Sep 2026 19:57:33 -0500 Subject: [PATCH 1/3] fix(translate): move Smartling auth to da-etc, matching Trados - Add smartling/auth.js: gets short-lived tokens from da-etc instead of sending raw userIdentifier/userSecret from the browser to Smartling - smartling/index.js keeps only Smartling API business logic; connect()/ isConnected() delegate to auth.js - Extract shared da-etc login + token-cache helpers into loc/utils/auth.js, used by both trados/auth.js and smartling/auth.js - Scope cached tokens by org/site/env (loc/utils/auth.js tokenKey) so different sites no longer collide on the same cache entry - Update smartling connector tests for the new auth flow Co-Authored-By: Claude --- nx/blocks/loc/connectors/smartling/auth.js | 216 ++++++++++++++++++ nx/blocks/loc/connectors/smartling/index.js | 230 ++------------------ nx/blocks/loc/connectors/trados/auth.js | 36 +-- nx/blocks/loc/utils/auth.js | 68 ++++++ test/loc/connectors/smartling/index.test.js | 53 ++--- 5 files changed, 324 insertions(+), 279 deletions(-) create mode 100644 nx/blocks/loc/connectors/smartling/auth.js create mode 100644 nx/blocks/loc/utils/auth.js diff --git a/nx/blocks/loc/connectors/smartling/auth.js b/nx/blocks/loc/connectors/smartling/auth.js new file mode 100644 index 000000000..fc43b7f68 --- /dev/null +++ b/nx/blocks/loc/connectors/smartling/auth.js @@ -0,0 +1,216 @@ +import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; +import fetchWithRetry from '../../utils/fetchWithRetry.js'; +import { loginViaDaEtc, getCachedToken, setCachedToken } from '../../utils/auth.js'; + +const FALLBACK_EXPIRES_IN_S = 280; // used only if the API response omits expiresIn +const REFRESH_BUFFER_MS = 5000; // refresh this long before the token actually expires +const MIN_REFRESH_DELAY_MS = 2000; // never schedule a refresh sooner than this +const BASE_OPTS = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, +}; + +// translate.da.live's legacy /smartling route is deprecated in favor of +// /translate/smartling// - rewrite configs still pointing at the +// old origin so they keep working without a config migration. +export function resolveOrigin(origin, org, site) { + return origin === `${DA_TRANSLATE}/smartling` + ? `${DA_TRANSLATE}/translate/smartling/${org}/${site}` + : origin; +} + +let token; +let tokenPolling; +// Retained so a failed refresh can fall back to a full re-authentication via +// da-etc: Smartling caps a token pair's session at 12 hours regardless of how +// many times it's refreshed, so refreshes eventually start failing even +// though da-etc's held credentials still work. +let authContext; + +export function getToken() { + return token; +} + +/** + * Exchanges the org/site's Smartling credentials - held server-side by + * da-etc, never sent to the browser - for a fresh access/refresh token pair. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {Promise} The response's `accessToken`, + * `refreshToken`, and `expiresIn`, or null on failure. + */ +async function authenticate(org, site, env) { + const json = await loginViaDaEtc('smartling', org, site, env); + return json?.response?.data || null; +} + +/** + * Persists the current access/refresh token pair plus a computed expiry to + * localStorage. + * @param {string} name - The connector's display name (e.g. 'Smartling'). + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @param {string} accessToken - The current access token. + * @param {string} refreshToken - The current refresh token. + * @param {number} [expiresInSecs] - Seconds until `accessToken` expires; + * falls back to `FALLBACK_EXPIRES_IN_S` if omitted. + * @returns {void} + */ +function setTokenDetails(name, org, site, env, accessToken, refreshToken, expiresInSecs) { + const timestamp = Date.now(); + const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; + const expires = timestamp + expiresInMs; + setCachedToken(name.toLowerCase(), org, site, env, { accessToken, refreshToken, expires }); +} + +function getTokenDetails(name, org, site, env) { + return getCachedToken(name.toLowerCase(), org, site, env); +} + +/** + * Refreshes the current access token, falling back to a full + * re-authentication via da-etc if the refresh token itself has stopped + * working (Smartling caps a token pair's session at 12 hours regardless of + * how many times it's refreshed). Persists the new token, but leaves + * rescheduling the next proactive refresh to the caller - used both by the + * proactive schedule below and reactively via `onUnauthorized` when a + * request 401s before that schedule catches up (e.g. the tab was + * backgrounded and its timers were throttled). + * @returns {Promise<{accessToken: string, expiresIn: number}|null>} The + * new token details, or null if both the refresh and the fallback + * re-authentication failed. + */ +async function refreshOrReauthenticate() { + const { + name, env, endpoint, org, site, + } = authContext; + const { refreshToken: currRefreshToken } = getTokenDetails(name, org, site, env); + + const body = JSON.stringify({ refreshToken: currRefreshToken }); + const opts = { ...BASE_OPTS, body }; + const resp = await fetchWithRetry(`${endpoint}/auth-api/v2/authenticate/refresh`, opts); + let data = resp.ok ? (await resp.json())?.response?.data : null; + + if (!data?.accessToken) data = await authenticate(org, site, env); + if (!data?.accessToken) return null; + + const { accessToken, refreshToken, expiresIn } = data; + token = accessToken; + setTokenDetails(name, org, site, env, accessToken, refreshToken, expiresIn); + return { accessToken, expiresIn }; +} + +/** + * Schedules a token refresh shortly before the current token expires, + * tracking Smartling's actual reported `expiresIn` instead of assuming a + * constant lifetime (that value shrinks as a session nears its 12-hour + * cap). Only stops rescheduling once `refreshOrReauthenticate` fails + * outright, so a translation job that outlives several sessions keeps + * working without user intervention. + * @param {number} [expiresInSecs] - Seconds until the current token + * expires; falls back to `FALLBACK_EXPIRES_IN_S` if omitted. + * @returns {void} + */ +function scheduleRefresh(expiresInSecs) { + const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; + const delay = Math.max(expiresInMs - REFRESH_BUFFER_MS, MIN_REFRESH_DELAY_MS); + + clearTimeout(tokenPolling); + tokenPolling = setTimeout(async () => { + const refreshed = await refreshOrReauthenticate(); + if (!refreshed) { + // Both refresh and re-authentication failed - stop polling rather than + // hammering the API forever with credentials that no longer work. + token = undefined; + tokenPolling = undefined; + return; + } + scheduleRefresh(refreshed.expiresIn); + }, delay); +} + +/** + * Builds a `fetchWithRetry` `onUnauthorized` callback: refreshes (or + * re-authenticates) the token, reschedules the next proactive refresh + * against the new expiry, and rebuilds `opts` with a fresh Authorization + * header - so a 401, e.g. from a token that expired while the tab was + * backgrounded before the proactive refresh above could run, triggers + * exactly one retry with a valid token instead of failing the request + * outright. + * @param {Object} opts - The fetch options to rebuild on success. + * @returns {() => Promise} Callback for `fetchWithRetry`'s + * `onUnauthorized` config. + */ +export function onUnauthorized(opts) { + return async () => { + const refreshed = await refreshOrReauthenticate(); + if (!refreshed) return null; + scheduleRefresh(refreshed.expiresIn); + return { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${refreshed.accessToken}` } }; + }; +} + +/** + * Checks for a still-valid cached token and, if found, resumes background + * refresh scheduling (e.g. after a page reload) instead of requiring the + * user to reconnect. + * @param {Object} config - The service configuration, including + * `origin`/`org`/`site` to resolve the API endpoint. + * @returns {Promise} Whether a still-valid cached token was found. + */ +export async function isConnected(config) { + const { + name, env, origin, org, site, + } = config; + const endpoint = resolveOrigin(origin, org, site); + const { expires, accessToken } = getTokenDetails(name, org, site, env); + const notExpired = expires > Date.now(); + + if (notExpired && !tokenPolling) { + // Cache the token for the ES Module + token = accessToken; + authContext = { + name, env, endpoint, org, site, + }; + + // Kick off the refresh scheduling + scheduleRefresh((expires - Date.now()) / 1000); + return true; + } + + return false; +} + +/** + * Authenticates with Smartling via da-etc and starts background refresh + * scheduling. da-etc holds the org/site's Smartling credentials server-side + * and only ever returns a short-lived access/refresh token pair, so no + * secret reaches the browser. + * @param {Object} service - The service configuration. + * @param {string} service.name - The connector's display name. + * @param {string} service.origin - The configured API origin. + * @param {string} service.env - The environment key (e.g. 'prod'). + * @param {string} service.org - The DA org. + * @param {string} service.site - The DA site. + * @returns {Promise} Whether authentication succeeded. + */ +export async function connect(service) { + const { + name, origin, env, org, site, + } = service; + const endpoint = resolveOrigin(origin, org, site); + + const data = await authenticate(org, site, env); + if (!data?.accessToken) return false; + + authContext = { + name, env, endpoint, org, site, + }; + const { accessToken, refreshToken, expiresIn } = data; + token = accessToken; + setTokenDetails(name, org, site, env, accessToken, refreshToken, expiresIn); + scheduleRefresh(expiresIn); + return true; +} diff --git a/nx/blocks/loc/connectors/smartling/index.js b/nx/blocks/loc/connectors/smartling/index.js index 08be619b9..ac4bb633d 100644 --- a/nx/blocks/loc/connectors/smartling/index.js +++ b/nx/blocks/loc/connectors/smartling/index.js @@ -1,220 +1,26 @@ import { Queue } from '../../../../../nx2/public/utils/tree.js'; import { addDnt, removeDnt } from '../../dnt/dnt.js'; -import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; import fetchWithRetry from '../../utils/fetchWithRetry.js'; +import { + resolveOrigin, getToken, onUnauthorized, + isConnected as checkConnection, + connect as establishConnection, +} from './auth.js'; export const dnt = { addDnt }; -const REFRESH_BUFFER_MS = 5000; // refresh this long before the token actually expires -const FALLBACK_EXPIRES_IN_S = 280; // used only if the API response omits expiresIn -const MIN_REFRESH_DELAY_MS = 2000; // never schedule a refresh sooner than this -const BASE_OPTS = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, -}; - -// translate.da.live's legacy /smartling route is deprecated in favor of -// /translate/smartling// - rewrite configs still pointing at the -// old origin so they keep working without a config migration. -function resolveOrigin(origin, org, site) { - return origin === `${DA_TRANSLATE}/smartling` - ? `${DA_TRANSLATE}/translate/smartling/${org}/${site}` - : origin; -} - -let token; -let tokenPolling; -// Credentials retained so a failed refresh can fall back to a full -// re-authentication: Smartling caps a token pair's session at 12 hours -// regardless of how many times it's refreshed, so refreshes eventually -// start failing even though the original userId/userSecret still work. -let authCredentials; - -/** - * Caches the current access token in the ES module and persists both - * tokens plus a computed expiry to localStorage. - * @param {string} name - The connector's display name (e.g. 'Smartling'). - * @param {string} env - The environment key (e.g. 'prod'). - * @param {string} accessToken - The current access token. - * @param {string} refreshToken - The current refresh token. - * @param {number} [expiresInSecs] - Seconds until `accessToken` expires; - * falls back to `FALLBACK_EXPIRES_IN_S` if omitted. - * @returns {void} - */ -function setTokenDetails(name, env, accessToken, refreshToken, expiresInSecs) { - token = accessToken; - const timestamp = Date.now(); - const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; - localStorage.setItem(`${name.toLowerCase()}.${env}.token`, JSON.stringify({ accessToken, refreshToken, expires: timestamp + expiresInMs })); -} - -function getTokenDetails(name, env) { - const lsTokenDetails = localStorage.getItem(`${name.toLowerCase()}.${env}.token`); - if (lsTokenDetails) { - try { - return JSON.parse(lsTokenDetails); - } catch { - return {}; - } - } - return {}; -} - -/** - * Authenticates with Smartling using a user's identifier/secret. - * @param {string} endpoint - The resolved Smartling API origin. - * @param {string} userIdentifier - The Smartling user identifier. - * @param {string} userSecret - The Smartling user secret. - * @returns {Promise} The response's `accessToken`, - * `refreshToken`, and `expiresIn`, or null on failure. - */ -async function authenticate(endpoint, userIdentifier, userSecret) { - const body = JSON.stringify({ userIdentifier, userSecret }); - const opts = { ...BASE_OPTS, body }; - - const resp = await fetchWithRetry(`${endpoint}/auth-api/v2/authenticate`, opts); - if (!resp.ok) return null; - const json = await resp.json(); - return json?.response?.data || null; -} - -/** - * Refreshes the current access token, falling back to a full - * re-authentication with the original credentials if the refresh token - * itself has stopped working (Smartling caps a token pair's session at - * 12 hours regardless of how many times it's refreshed). Persists the - * new token, but leaves rescheduling the next proactive refresh to the - * caller - used both by the proactive schedule below and reactively via - * `onUnauthorized` when a request 401s before that schedule catches up - * (e.g. the tab was backgrounded and its timers were throttled). - * @returns {Promise<{accessToken: string, expiresIn: number}|null>} The - * new token details, or null if both the refresh and the fallback - * re-authentication failed. - */ -async function refreshOrReauthenticate() { - const { name, env, endpoint, userIdentifier, userSecret } = authCredentials; - const { refreshToken: currRefreshToken } = getTokenDetails(name, env); - - const body = JSON.stringify({ refreshToken: currRefreshToken }); - const opts = { ...BASE_OPTS, body }; - const resp = await fetchWithRetry(`${endpoint}/auth-api/v2/authenticate/refresh`, opts); - let data = resp.ok ? (await resp.json())?.response?.data : null; - - if (!data?.accessToken) data = await authenticate(endpoint, userIdentifier, userSecret); - if (!data?.accessToken) return null; - - const { accessToken, refreshToken, expiresIn } = data; - setTokenDetails(name, env, accessToken, refreshToken, expiresIn); - return { accessToken, expiresIn }; +export function isConnected(config) { + return checkConnection(config); } -/** - * Schedules a token refresh shortly before the current token expires, - * tracking Smartling's actual reported `expiresIn` instead of assuming a - * constant lifetime (that value shrinks as a session nears its 12-hour - * cap). Only stops rescheduling once `refreshOrReauthenticate` fails - * outright, so a translation job that outlives several sessions keeps - * working without user intervention. - * @param {number} [expiresInSecs] - Seconds until the current token - * expires; falls back to `FALLBACK_EXPIRES_IN_S` if omitted. - * @returns {void} - */ -function scheduleRefresh(expiresInSecs) { - const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; - const delay = Math.max(expiresInMs - REFRESH_BUFFER_MS, MIN_REFRESH_DELAY_MS); - - clearTimeout(tokenPolling); - tokenPolling = setTimeout(async () => { - const refreshed = await refreshOrReauthenticate(); - if (!refreshed) { - // Both refresh and re-authentication failed - stop polling rather than - // hammering the API forever with credentials that no longer work. - token = undefined; - tokenPolling = undefined; - return; - } - scheduleRefresh(refreshed.expiresIn); - }, delay); +export function connect(service) { + return establishConnection(service); } -/** - * Builds a `fetchWithRetry` `onUnauthorized` callback: refreshes (or - * re-authenticates) the token, reschedules the next proactive refresh - * against the new expiry, and rebuilds `opts` with a fresh Authorization - * header - so a 401, e.g. from a token that expired while the tab was - * backgrounded before the proactive refresh above could run, triggers - * exactly one retry with a valid token instead of failing the request - * outright. - * @param {Object} opts - The fetch options to rebuild on success. - * @returns {() => Promise} Callback for `fetchWithRetry`'s - * `onUnauthorized` config. - */ -function onUnauthorized(opts) { - return async () => { - const refreshed = await refreshOrReauthenticate(); - if (!refreshed) return null; - scheduleRefresh(refreshed.expiresIn); - return { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${refreshed.accessToken}` } }; - }; -} - -/** - * Checks for a still-valid cached token and, if found, resumes background - * refresh scheduling (e.g. after a page reload) instead of requiring the - * user to reconnect. - * @param {Object} config - The service configuration, including - * `userId`/`userSecret` (retained for a later refresh-failure fallback) - * and `origin`/`org`/`site` to resolve the API endpoint. - * @returns {Promise} Whether a still-valid cached token was found. - */ -export async function isConnected(config) { - const { - name, env, userId, userSecret, origin, org, site, - } = config; - const endpoint = resolveOrigin(origin, org, site); - const { expires, accessToken } = getTokenDetails(name, env); - const notExpired = expires > Date.now(); - - if (notExpired && !tokenPolling) { - // Cache the token for the ES Module - token = accessToken; - authCredentials = { name, env, endpoint, userIdentifier: userId, userSecret }; - - // Kick off the refresh scheduling - scheduleRefresh((expires - Date.now()) / 1000); - return true; - } - - return false; -} - -/** - * Authenticates with Smartling and starts background refresh scheduling. - * @param {Object} service - The service configuration. - * @param {string} service.name - The connector's display name. - * @param {string} service.origin - The configured API origin. - * @param {string} service.env - The environment key (e.g. 'prod'). - * @param {string} service.userId - The Smartling user identifier. - * @param {string} service.userSecret - The Smartling user secret. - * @param {string} service.org - The DA org. - * @param {string} service.site - The DA site. - * @returns {Promise} Whether authentication succeeded. - */ -export async function connect(service) { - const { - name, origin, env, userId, userSecret, org, site, - } = service; - const endpoint = resolveOrigin(origin, org, site); - - const data = await authenticate(endpoint, userId, userSecret); - if (!data?.accessToken) return false; - - authCredentials = { name, env, endpoint, userIdentifier: userId, userSecret }; - const { accessToken, refreshToken, expiresIn } = data; - setTokenDetails(name, env, accessToken, refreshToken, expiresIn); - scheduleRefresh(expiresIn); - return true; -} +const BASE_OPTS = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, +}; /** * Extracts a human-readable message from Smartling's documented error @@ -265,7 +71,7 @@ async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessa body.append('localeIdsToAuthorize[]', lang.code); }); - const opts = { method: 'POST', body, headers: { Authorization: `Bearer ${token}` } }; + const opts = { method: 'POST', body, headers: { Authorization: `Bearer ${getToken()}` } }; const resp = await fetchWithRetry(uploadUrl, opts, { onUnauthorized: onUnauthorized(opts) }); const json = await resp.json(); @@ -296,7 +102,7 @@ async function createJob(endpoint, projectId, title, langs, sendMessage) { const body = JSON.stringify({ jobName, targetLocaleIds }); const opts = { ...BASE_OPTS, body }; - opts.headers.Authorization = `Bearer ${token}`; + opts.headers.Authorization = `Bearer ${getToken()}`; const url = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs`; const resp = await fetchWithRetry(url, opts, { onUnauthorized: onUnauthorized(opts) }); @@ -331,7 +137,7 @@ async function createBatch(endpoint, projectId, jobUid, urls, autoAuthorize, sen }); const opts = { ...BASE_OPTS, body }; - opts.headers.Authorization = `Bearer ${token}`; + opts.headers.Authorization = `Bearer ${getToken()}`; const url = `${endpoint}/job-batches-api/v2/projects/${projectId}/batches`; @@ -372,7 +178,7 @@ export async function saveItems({ const opts = { method: 'GET', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json', }, }; @@ -472,7 +278,7 @@ export async function getStatusAll({ for (const url of urls) { // Built per-url (not hoisted) so a token refresh mid-loop - whether // scheduled or triggered by a 401 below - is picked up by later urls. - const opts = { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } }; + const opts = { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` } }; const progressUrl = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs/${jobUid.value}/file/progress?fileUri=${url.daBasePath}`; const resp = await fetchWithRetry(progressUrl, opts, { onUnauthorized: onUnauthorized(opts) }); const { response } = await resp.json(); diff --git a/nx/blocks/loc/connectors/trados/auth.js b/nx/blocks/loc/connectors/trados/auth.js index 83cddc504..6d0ab5a5b 100644 --- a/nx/blocks/loc/connectors/trados/auth.js +++ b/nx/blocks/loc/connectors/trados/auth.js @@ -1,45 +1,19 @@ -import { daFetch } from '../../../../../nx2/utils/api.js'; +import { loginViaDaEtc, getCachedToken, setCachedToken } from '../../utils/auth.js'; -const LOGIN_ORIGIN = 'https://da-etc.adobeaem.workers.dev'; const TOKEN_BUFFER = 300000; // 5 min buffer before expiry -function tokenKey(org, site, env) { - return `trados.${org}.${site}.${env}.token`; -} - -function getTokenDetails(org, site, env) { - const stored = localStorage.getItem(tokenKey(org, site, env)); - if (!stored) return {}; - try { - return JSON.parse(stored); - } catch { - return {}; - } -} - -function setTokenDetails(org, site, env, accessToken, expires) { - localStorage.setItem( - tokenKey(org, site, env), - JSON.stringify({ accessToken, expires }), - ); -} - export async function getAccessToken(service) { const { org, site, env = 'prod' } = service; - const { accessToken: cached, expires: cachedExpires } = getTokenDetails(org, site, env); + const { accessToken: cached, expires: cachedExpires } = getCachedToken('trados', org, site, env); if (cached && cachedExpires > Date.now()) return cached; - const opts = { method: 'POST' }; - - const resp = await daFetch({ url: `${LOGIN_ORIGIN}/${org}/sites/${site}/integrations/trados/login`, opts }); - if (!resp.ok) return null; - - const { access_token: accessToken, expires_in: expiresIn } = await resp.json(); + const data = await loginViaDaEtc('trados', org, site, env); + const { access_token: accessToken, expires_in: expiresIn } = data || {}; if (!accessToken) return null; const expires = Date.now() + (expiresIn * 1000) - TOKEN_BUFFER; - setTokenDetails(org, site, env, accessToken, expires); + setCachedToken('trados', org, site, env, { accessToken, expires }); return accessToken; } diff --git a/nx/blocks/loc/utils/auth.js b/nx/blocks/loc/utils/auth.js new file mode 100644 index 000000000..b2be467ed --- /dev/null +++ b/nx/blocks/loc/utils/auth.js @@ -0,0 +1,68 @@ +import { daFetch } from '../../../../nx2/utils/api.js'; +import { DA_ETC } from '../../../../nx2/utils/utils.js'; + +/** + * Exchanges a DA org/site's third-party service credentials - held + * server-side by da-etc, never sent to the browser - for a fresh token via + * da-etc's `/integrations//login` endpoint. The caller's own DA/IMS + * session (attached by `daFetch`) is what authorizes the exchange, so no + * secret ever reaches the browser. + * @param {string} service - The da-etc integration name (e.g. 'trados', 'smartling'). + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} [env] - The environment key (e.g. 'prod'); sent as a query param if given. + * @returns {Promise} The parsed response body, or null on failure. + */ +export async function loginViaDaEtc(service, org, site, env) { + const url = new URL(`${DA_ETC}/${org}/sites/${site}/integrations/${service}/login`); + if (env) url.searchParams.set('env', env); + + const resp = await daFetch({ url: url.toString(), opts: { method: 'POST' } }); + if (!resp.ok) return null; + return resp.json(); +} + +/** + * Builds the localStorage key a connector caches its da-etc-issued token + * under, scoped per org/site/env so different sites don't collide. + * @param {string} service - The da-etc integration name (e.g. 'trados', 'smartling'). + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {string} The localStorage key. + */ +function tokenKey(service, org, site, env) { + return `${service}.${org}.${site}.${env}.token`; +} + +/** + * Reads and JSON-parses a connector's cached token entry, tolerating + * missing or corrupt values. + * @param {string} service - The da-etc integration name (e.g. 'trados', 'smartling'). + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {Object} The parsed value, or `{}` if missing/invalid. + */ +export function getCachedToken(service, org, site, env) { + const stored = localStorage.getItem(tokenKey(service, org, site, env)); + if (!stored) return {}; + try { + return JSON.parse(stored); + } catch { + return {}; + } +} + +/** + * JSON-serializes and persists a connector's token details to localStorage. + * @param {string} service - The da-etc integration name (e.g. 'trados', 'smartling'). + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @param {Object} value - The value to persist. + * @returns {void} + */ +export function setCachedToken(service, org, site, env, value) { + localStorage.setItem(tokenKey(service, org, site, env), JSON.stringify(value)); +} diff --git a/test/loc/connectors/smartling/index.test.js b/test/loc/connectors/smartling/index.test.js index fccbaf8a5..be54b76a5 100644 --- a/test/loc/connectors/smartling/index.test.js +++ b/test/loc/connectors/smartling/index.test.js @@ -1,6 +1,6 @@ import { expect } from '@esm-bundle/chai'; import { - connect, isConnected, saveItems, sendAllLanguages, getStatusAll, + isConnected, saveItems, sendAllLanguages, getStatusAll, } from '../../../../nx/blocks/loc/connectors/smartling/index.js'; import { DA_TRANSLATE } from '../../../../nx2/utils/utils.js'; @@ -64,14 +64,14 @@ describe('smartling connector - legacy origin rewriting', () => { // early return depends on tokenPolling being unset, which is otherwise // module-level state left over from every later connect() call in this file. it('resolves the endpoint from origin/org/site in isConnected, not a nonexistent config key', async () => { - localStorage.setItem('smartling.prod.token', JSON.stringify({ + localStorage.setItem(`smartling.${org}.${site}.prod.token`, JSON.stringify({ accessToken: 'cached-token', refreshToken: 'cached-refresh-token', expires: Date.now() + 60000, })); const connected = await isConnected({ - name: 'Smartling', env: 'prod', userId: 'u', userSecret: 's', origin: legacyOrigin, org, site, + name: 'Smartling', env: 'prod', origin: legacyOrigin, org, site, }); expect(connected).to.equal(true); @@ -115,23 +115,6 @@ describe('smartling connector - legacy origin rewriting', () => { expect(langs[0].translation.status).to.equal('created'); }); - it('rewrites the legacy /smartling origin to /translate/smartling// on connect', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - - expect(calls[0].url).to.equal(`${DA_TRANSLATE}/translate/smartling/${org}/${site}/auth-api/v2/authenticate`); - }); - - it('leaves a non-legacy origin untouched on connect', async () => { - const customOrigin = 'https://api.smartling.com'; - await connect({ - name: 'Smartling', origin: customOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - - expect(calls[0].url).to.equal(`${customOrigin}/auth-api/v2/authenticate`); - }); - it('rewrites the origin for sendAllLanguages job/batch/upload calls', async () => { const options = { service: { origin: legacyOrigin, projectId: 'proj-1' } }; const langs = [{ name: 'French', code: 'fr-FR' }]; @@ -163,10 +146,11 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('recovers from a 401 on getStatusAll by refreshing the token and retrying', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - + // Relies on `authContext` already being populated by the isConnected() + // call in the first test above - real auth (connect()) can't be + // exercised here since it now requires a signed-in IMS session. The + // target token is unique so this passes regardless of whatever token an + // earlier test in this file left cached. let progressCalls = 0; let refreshCalls = 0; origFetch = window.fetch; @@ -177,12 +161,12 @@ describe('smartling connector - legacy origin rewriting', () => { if (u.includes('/auth-api/v2/authenticate/refresh')) { refreshCalls += 1; return new Response(JSON.stringify({ - response: { data: { accessToken: 'new-token', refreshToken: 'new-refresh-token', expiresIn: 300 } }, + response: { data: { accessToken: 'getstatusall-token', refreshToken: 'new-refresh-token', expiresIn: 300 } }, }), { status: 200 }); } if (u.includes('/file/progress')) { progressCalls += 1; - if (opts.headers.Authorization !== 'Bearer new-token') return new Response('', { status: 401 }); + if (opts.headers.Authorization !== 'Bearer getstatusall-token') return new Response('', { status: 401 }); return new Response(JSON.stringify({ response: { code: 'SUCCESS', @@ -424,10 +408,11 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('recovers from a 401 by refreshing the token and retrying the request', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - + // Relies on `authContext` already being populated by the isConnected() + // call in the first test above - real auth (connect()) can't be + // exercised here since it now requires a signed-in IMS session. The + // target token is unique so this passes regardless of whatever token an + // earlier test in this file left cached. let jobCalls = 0; let refreshCalls = 0; origFetch = window.fetch; @@ -438,12 +423,12 @@ describe('smartling connector - legacy origin rewriting', () => { if (u.includes('/auth-api/v2/authenticate/refresh')) { refreshCalls += 1; return new Response(JSON.stringify({ - response: { data: { accessToken: 'new-token', refreshToken: 'new-refresh-token', expiresIn: 300 } }, + response: { data: { accessToken: 'sendall-token', refreshToken: 'new-refresh-token', expiresIn: 300 } }, }), { status: 200 }); } if (u.includes('/jobs-api/v3/projects') && opts.method === 'POST') { jobCalls += 1; - if (opts.headers.Authorization !== 'Bearer new-token') return new Response('', { status: 401 }); + if (opts.headers.Authorization !== 'Bearer sendall-token') return new Response('', { status: 401 }); return new Response(JSON.stringify({ response: { data: { translationJobUid: 'job-1' } } }), { status: 200 }); } if (u.includes('/job-batches-api/v2/projects') && u.includes('/batches') && !u.includes('/file')) { @@ -471,10 +456,6 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('gives up without looping when the retried request also 401s', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - let refreshCalls = 0; origFetch = window.fetch; window.fetch = async (url, opts = {}) => { From db342139c0fe84a311f3ac1a043618e9796d2da0 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Thu, 3 Sep 2026 13:25:17 -0500 Subject: [PATCH 2/3] fix(translate): wire Smartling's connector back to its da-etc auth.js - smartling/index.js delegates isConnected/connect and per-request auth to auth.js instead of its own duplicated raw-credential flow (reintroduced by a merge) - auth.js now reads the current token from the shared cache (getToken) instead of a module-level variable - utils/auth.js: rename loginViaDaEtc -> login Co-Authored-By: Claude --- nx/blocks/loc/connectors/smartling/auth.js | 56 ++-- nx/blocks/loc/connectors/smartling/index.js | 296 ++++++-------------- nx/blocks/loc/utils/auth.js | 4 +- test/loc/connectors/smartling/index.test.js | 73 +---- 4 files changed, 123 insertions(+), 306 deletions(-) diff --git a/nx/blocks/loc/connectors/smartling/auth.js b/nx/blocks/loc/connectors/smartling/auth.js index fc43b7f68..f016af6bf 100644 --- a/nx/blocks/loc/connectors/smartling/auth.js +++ b/nx/blocks/loc/connectors/smartling/auth.js @@ -1,7 +1,8 @@ import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; import fetchWithRetry from '../../utils/fetchWithRetry.js'; -import { loginViaDaEtc, getCachedToken, setCachedToken } from '../../utils/auth.js'; +import { login, getCachedToken, setCachedToken } from '../../utils/auth.js'; +const INTEGRATION_NAME = 'smartling'; const FALLBACK_EXPIRES_IN_S = 280; // used only if the API response omits expiresIn const REFRESH_BUFFER_MS = 5000; // refresh this long before the token actually expires const MIN_REFRESH_DELAY_MS = 2000; // never schedule a refresh sooner than this @@ -19,7 +20,6 @@ export function resolveOrigin(origin, org, site) { : origin; } -let token; let tokenPolling; // Retained so a failed refresh can fall back to a full re-authentication via // da-etc: Smartling caps a token pair's session at 12 hours regardless of how @@ -27,8 +27,17 @@ let tokenPolling; // though da-etc's held credentials still work. let authContext; -export function getToken() { - return token; +/** + * Reads the currently cached access token, if any - the single source of + * truth for what's valid right now, kept current by the proactive refresh + * schedule and by `onUnauthorized`'s reactive recovery. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). + * @returns {string|undefined} The cached access token, if any. + */ +export function getToken(org, site, env) { + return getCachedToken(INTEGRATION_NAME, org, site, env).accessToken; } /** @@ -41,14 +50,13 @@ export function getToken() { * `refreshToken`, and `expiresIn`, or null on failure. */ async function authenticate(org, site, env) { - const json = await loginViaDaEtc('smartling', org, site, env); + const json = await login(INTEGRATION_NAME, org, site, env); return json?.response?.data || null; } /** * Persists the current access/refresh token pair plus a computed expiry to * localStorage. - * @param {string} name - The connector's display name (e.g. 'Smartling'). * @param {string} org - The DA org. * @param {string} site - The DA site. * @param {string} env - The environment key (e.g. 'prod'). @@ -58,15 +66,11 @@ async function authenticate(org, site, env) { * falls back to `FALLBACK_EXPIRES_IN_S` if omitted. * @returns {void} */ -function setTokenDetails(name, org, site, env, accessToken, refreshToken, expiresInSecs) { +function setTokenDetails(org, site, env, accessToken, refreshToken, expiresInSecs) { const timestamp = Date.now(); const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; const expires = timestamp + expiresInMs; - setCachedToken(name.toLowerCase(), org, site, env, { accessToken, refreshToken, expires }); -} - -function getTokenDetails(name, org, site, env) { - return getCachedToken(name.toLowerCase(), org, site, env); + setCachedToken(INTEGRATION_NAME, org, site, env, { accessToken, refreshToken, expires }); } /** @@ -83,10 +87,8 @@ function getTokenDetails(name, org, site, env) { * re-authentication failed. */ async function refreshOrReauthenticate() { - const { - name, env, endpoint, org, site, - } = authContext; - const { refreshToken: currRefreshToken } = getTokenDetails(name, org, site, env); + const { endpoint, org, site, env } = authContext; + const { refreshToken: currRefreshToken } = getCachedToken(INTEGRATION_NAME, org, site, env); const body = JSON.stringify({ refreshToken: currRefreshToken }); const opts = { ...BASE_OPTS, body }; @@ -97,8 +99,7 @@ async function refreshOrReauthenticate() { if (!data?.accessToken) return null; const { accessToken, refreshToken, expiresIn } = data; - token = accessToken; - setTokenDetails(name, org, site, env, accessToken, refreshToken, expiresIn); + setTokenDetails(org, site, env, accessToken, refreshToken, expiresIn); return { accessToken, expiresIn }; } @@ -123,7 +124,6 @@ function scheduleRefresh(expiresInSecs) { if (!refreshed) { // Both refresh and re-authentication failed - stop polling rather than // hammering the API forever with credentials that no longer work. - token = undefined; tokenPolling = undefined; return; } @@ -157,22 +157,20 @@ export function onUnauthorized(opts) { * refresh scheduling (e.g. after a page reload) instead of requiring the * user to reconnect. * @param {Object} config - The service configuration, including - * `origin`/`org`/`site` to resolve the API endpoint. + * `origin`/`org`/`site`/`env` to resolve the API endpoint. * @returns {Promise} Whether a still-valid cached token was found. */ export async function isConnected(config) { const { - name, env, origin, org, site, + origin, org, site, env, } = config; const endpoint = resolveOrigin(origin, org, site); - const { expires, accessToken } = getTokenDetails(name, org, site, env); + const { expires } = getCachedToken(INTEGRATION_NAME, org, site, env); const notExpired = expires > Date.now(); if (notExpired && !tokenPolling) { - // Cache the token for the ES Module - token = accessToken; authContext = { - name, env, endpoint, org, site, + endpoint, org, site, env, }; // Kick off the refresh scheduling @@ -189,7 +187,6 @@ export async function isConnected(config) { * and only ever returns a short-lived access/refresh token pair, so no * secret reaches the browser. * @param {Object} service - The service configuration. - * @param {string} service.name - The connector's display name. * @param {string} service.origin - The configured API origin. * @param {string} service.env - The environment key (e.g. 'prod'). * @param {string} service.org - The DA org. @@ -198,7 +195,7 @@ export async function isConnected(config) { */ export async function connect(service) { const { - name, origin, env, org, site, + origin, env, org, site, } = service; const endpoint = resolveOrigin(origin, org, site); @@ -206,11 +203,10 @@ export async function connect(service) { if (!data?.accessToken) return false; authContext = { - name, env, endpoint, org, site, + endpoint, org, site, env, }; const { accessToken, refreshToken, expiresIn } = data; - token = accessToken; - setTokenDetails(name, org, site, env, accessToken, refreshToken, expiresIn); + setTokenDetails(org, site, env, accessToken, refreshToken, expiresIn); scheduleRefresh(expiresIn); return true; } diff --git a/nx/blocks/loc/connectors/smartling/index.js b/nx/blocks/loc/connectors/smartling/index.js index 580a55843..2a8586f53 100644 --- a/nx/blocks/loc/connectors/smartling/index.js +++ b/nx/blocks/loc/connectors/smartling/index.js @@ -1,224 +1,34 @@ import { addDnt, removeDnt } from '../../dnt/dnt.js'; -import { DA_TRANSLATE } from '../../../../../nx2/utils/utils.js'; import downloadQueue from '../../utils/downloadQueue.js'; import fetchWithRetry from '../../utils/fetchWithRetry.js'; +import { + resolveOrigin, getToken, onUnauthorized, + isConnected as checkConnection, connect as establishConnection, +} from './auth.js'; export const dnt = { addDnt }; -const REFRESH_BUFFER_MS = 5000; // refresh this long before the token actually expires -const FALLBACK_EXPIRES_IN_S = 280; // used only if the API response omits expiresIn -const MIN_REFRESH_DELAY_MS = 2000; // never schedule a refresh sooner than this const BASE_OPTS = { method: 'POST', headers: { 'Content-Type': 'application/json' }, }; -// translate.da.live's legacy /smartling route is deprecated in favor of -// /translate/smartling// - rewrite configs still pointing at the -// old origin so they keep working without a config migration. -function resolveOrigin(origin, org, site) { - return origin === `${DA_TRANSLATE}/smartling` - ? `${DA_TRANSLATE}/translate/smartling/${org}/${site}` - : origin; -} - -let token; -let tokenPolling; -// Credentials retained so a failed refresh can fall back to a full -// re-authentication: Smartling caps a token pair's session at 12 hours -// regardless of how many times it's refreshed, so refreshes eventually -// start failing even though the original userId/userSecret still work. -let authCredentials; - -/** - * Caches the current access token in the ES module and persists both - * tokens plus a computed expiry to localStorage. - * @param {string} name - The connector's display name (e.g. 'Smartling'). - * @param {string} env - The environment key (e.g. 'prod'). - * @param {string} accessToken - The current access token. - * @param {string} refreshToken - The current refresh token. - * @param {number} [expiresInSecs] - Seconds until `accessToken` expires; - * falls back to `FALLBACK_EXPIRES_IN_S` if omitted. - * @returns {void} - */ -function setTokenDetails(name, env, accessToken, refreshToken, expiresInSecs) { - token = accessToken; - const timestamp = Date.now(); - const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; - localStorage.setItem(`${name.toLowerCase()}.${env}.token`, JSON.stringify({ accessToken, refreshToken, expires: timestamp + expiresInMs })); -} - -function getTokenDetails(name, env) { - const lsTokenDetails = localStorage.getItem(`${name.toLowerCase()}.${env}.token`); - if (lsTokenDetails) { - try { - return JSON.parse(lsTokenDetails); - } catch { - return {}; - } - } - return {}; -} - -/** - * Authenticates with Smartling using a user's identifier/secret. - * @param {string} endpoint - The resolved Smartling API origin. - * @param {string} userIdentifier - The Smartling user identifier. - * @param {string} userSecret - The Smartling user secret. - * @returns {Promise} The response's `accessToken`, - * `refreshToken`, and `expiresIn`, or null on failure. - */ -async function authenticate(endpoint, userIdentifier, userSecret) { - const body = JSON.stringify({ userIdentifier, userSecret }); - const opts = { ...BASE_OPTS, body }; - - const resp = await fetchWithRetry(`${endpoint}/auth-api/v2/authenticate`, opts); - if (!resp.ok) return null; - const json = await resp.json(); - return json?.response?.data || null; -} - -/** - * Refreshes the current access token, falling back to a full - * re-authentication with the original credentials if the refresh token - * itself has stopped working (Smartling caps a token pair's session at - * 12 hours regardless of how many times it's refreshed). Persists the - * new token, but leaves rescheduling the next proactive refresh to the - * caller - used both by the proactive schedule below and reactively via - * `onUnauthorized` when a request 401s before that schedule catches up - * (e.g. the tab was backgrounded and its timers were throttled). - * @returns {Promise<{accessToken: string, expiresIn: number}|null>} The - * new token details, or null if both the refresh and the fallback - * re-authentication failed. - */ -async function refreshOrReauthenticate() { - const { name, env, endpoint, userIdentifier, userSecret } = authCredentials; - const { refreshToken: currRefreshToken } = getTokenDetails(name, env); - - const body = JSON.stringify({ refreshToken: currRefreshToken }); - const opts = { ...BASE_OPTS, body }; - const resp = await fetchWithRetry(`${endpoint}/auth-api/v2/authenticate/refresh`, opts); - let data = resp.ok ? (await resp.json())?.response?.data : null; - - if (!data?.accessToken) data = await authenticate(endpoint, userIdentifier, userSecret); - if (!data?.accessToken) return null; - - const { accessToken, refreshToken, expiresIn } = data; - setTokenDetails(name, env, accessToken, refreshToken, expiresIn); - return { accessToken, expiresIn }; +export function isConnected(config) { + return checkConnection(config); } /** - * Schedules a token refresh shortly before the current token expires, - * tracking Smartling's actual reported `expiresIn` instead of assuming a - * constant lifetime (that value shrinks as a session nears its 12-hour - * cap). Only stops rescheduling once `refreshOrReauthenticate` fails - * outright, so a translation job that outlives several sessions keeps - * working without user intervention. - * @param {number} [expiresInSecs] - Seconds until the current token - * expires; falls back to `FALLBACK_EXPIRES_IN_S` if omitted. - * @returns {void} - */ -function scheduleRefresh(expiresInSecs) { - const expiresInMs = (expiresInSecs ?? FALLBACK_EXPIRES_IN_S) * 1000; - const delay = Math.max(expiresInMs - REFRESH_BUFFER_MS, MIN_REFRESH_DELAY_MS); - - clearTimeout(tokenPolling); - tokenPolling = setTimeout(async () => { - const refreshed = await refreshOrReauthenticate(); - if (!refreshed) { - // Both refresh and re-authentication failed - stop polling rather than - // hammering the API forever with credentials that no longer work. - token = undefined; - tokenPolling = undefined; - return; - } - scheduleRefresh(refreshed.expiresIn); - }, delay); -} - -/** - * Builds a `fetchWithRetry` `onUnauthorized` callback: refreshes (or - * re-authenticates) the token, reschedules the next proactive refresh - * against the new expiry, and rebuilds `opts` with a fresh Authorization - * header - so a 401, e.g. from a token that expired while the tab was - * backgrounded before the proactive refresh above could run, triggers - * exactly one retry with a valid token instead of failing the request - * outright. - * @param {Object} opts - The fetch options to rebuild on success. - * @returns {() => Promise} Callback for `fetchWithRetry`'s - * `onUnauthorized` config. - */ -function onUnauthorized(opts) { - return async () => { - const refreshed = await refreshOrReauthenticate(); - if (!refreshed) return null; - scheduleRefresh(refreshed.expiresIn); - return { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${refreshed.accessToken}` } }; - }; -} - -/** - * Checks for a still-valid cached token and, if found, resumes background - * refresh scheduling (e.g. after a page reload) instead of requiring the - * user to reconnect. - * @param {Object} config - The service configuration, including - * `userId`/`userSecret` (retained for a later refresh-failure fallback) - * and `origin`/`org`/`site` to resolve the API endpoint. - * @returns {Promise} Whether a still-valid cached token was found. - */ -export async function isConnected(config) { - const { - name, env, userId, userSecret, origin, org, site, - } = config; - const endpoint = resolveOrigin(origin, org, site); - const { expires, accessToken } = getTokenDetails(name, env); - const notExpired = expires > Date.now(); - - if (notExpired && !tokenPolling) { - // Cache the token for the ES Module - token = accessToken; - authCredentials = { name, env, endpoint, userIdentifier: userId, userSecret }; - - // Kick off the refresh scheduling - scheduleRefresh((expires - Date.now()) / 1000); - return true; - } - - return false; -} - -/** - * Authenticates with Smartling and starts background refresh scheduling. + * Authenticates with Smartling via da-etc, surfacing an error message if it + * fails. * @param {Object} service - The service configuration. - * @param {string} service.name - The connector's display name. - * @param {string} service.origin - The configured API origin. - * @param {string} service.env - The environment key (e.g. 'prod'). - * @param {string} service.userId - The Smartling user identifier. - * @param {string} service.userSecret - The Smartling user secret. - * @param {string} service.org - The DA org. - * @param {string} service.site - The DA site. * @param {Function} [sendMessage] - Callback to surface an error message * to the user if authentication fails. * @returns {Promise} Whether authentication succeeded. */ export async function connect(service, sendMessage) { - const { - name, origin, env, userId, userSecret, org, site, - } = service; - const endpoint = resolveOrigin(origin, org, site); - - const data = await authenticate(endpoint, userId, userSecret); - if (!data?.accessToken) { - sendMessage?.({ text: 'Connection to Smartling failed.', type: 'error' }); - return false; - } - - authCredentials = { name, env, endpoint, userIdentifier: userId, userSecret }; - const { accessToken, refreshToken, expiresIn } = data; - setTokenDetails(name, env, accessToken, refreshToken, expiresIn); - scheduleRefresh(expiresIn); - return true; + const connected = await establishConnection(service); + if (!connected) sendMessage?.({ text: 'Connection to Smartling failed.', type: 'error' }); + return connected; } /** @@ -244,6 +54,9 @@ function extractErrorMessage(json) { * Uploads every url to a Smartling batch, reporting an error message per * file that Smartling rejects (e.g. a locale mismatch) instead of only * counting it as not-accepted. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). * @param {string} endpoint - The resolved Smartling API origin. * @param {string} projectId - The Smartling project id. * @param {string} batchUid - The batch to upload files into. @@ -254,7 +67,17 @@ function extractErrorMessage(json) { * @returns {Promise} Each file's Smartling response `code` * (`'ACCEPTED'` on success). */ -async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessage) { +async function uploadFiles( + org, + site, + env, + endpoint, + projectId, + batchUid, + langs, + urls, + sendMessage, +) { const uploadUrl = `${endpoint}/job-batches-api/v2/projects/${projectId}/batches/${batchUid}/file`; const results = []; @@ -270,7 +93,7 @@ async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessa body.append('localeIdsToAuthorize[]', lang.code); }); - const opts = { method: 'POST', body, headers: { Authorization: `Bearer ${token}` } }; + const opts = { method: 'POST', body, headers: { Authorization: `Bearer ${getToken(org, site, env)}` } }; const resp = await fetchWithRetry(uploadUrl, opts, { onUnauthorized: onUnauthorized(opts) }); const json = await resp.json(); @@ -285,6 +108,9 @@ async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessa /** * Creates a Smartling translation job for the given target languages. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). * @param {string} endpoint - The resolved Smartling API origin. * @param {string} projectId - The Smartling project id. * @param {string} title - The project title, used to build the job name. @@ -294,14 +120,14 @@ async function uploadFiles(endpoint, projectId, batchUid, langs, urls, sendMessa * message to the user. * @returns {Promise} The new job's id, or null on failure. */ -async function createJob(endpoint, projectId, title, langs, sendMessage) { +async function createJob(org, site, env, endpoint, projectId, title, langs, sendMessage) { const timestamp = Date.now(); const jobName = `${title}-${timestamp}`; const targetLocaleIds = langs.map((lang) => lang.code); const body = JSON.stringify({ jobName, targetLocaleIds }); const opts = { ...BASE_OPTS, body }; - opts.headers.Authorization = `Bearer ${token}`; + opts.headers.Authorization = `Bearer ${getToken(org, site, env)}`; const url = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs`; const resp = await fetchWithRetry(url, opts, { onUnauthorized: onUnauthorized(opts) }); @@ -317,6 +143,9 @@ async function createJob(endpoint, projectId, title, langs, sendMessage) { /** * Creates a job batch for the uploaded files. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). * @param {string} endpoint - The resolved Smartling API origin. * @param {string} projectId - The Smartling project id. * @param {string} jobUid - The job to attach the batch to. @@ -328,7 +157,17 @@ async function createJob(endpoint, projectId, title, langs, sendMessage) { * message to the user. * @returns {Promise} The new batch's id, or null on failure. */ -async function createBatch(endpoint, projectId, jobUid, urls, autoAuthorize, sendMessage) { +async function createBatch( + org, + site, + env, + endpoint, + projectId, + jobUid, + urls, + autoAuthorize, + sendMessage, +) { const body = JSON.stringify({ authorize: autoAuthorize, translationJobUid: jobUid, @@ -336,7 +175,7 @@ async function createBatch(endpoint, projectId, jobUid, urls, autoAuthorize, sen }); const opts = { ...BASE_OPTS, body }; - opts.headers.Authorization = `Bearer ${token}`; + opts.headers.Authorization = `Bearer ${getToken(org, site, env)}`; const url = `${endpoint}/job-batches-api/v2/projects/${projectId}/batches`; @@ -398,7 +237,7 @@ export async function saveItems({ saveFn, sendMessage, }) { - const { origin, projectId } = service; + const { origin, projectId, env } = service; const endpoint = resolveOrigin(origin, org, site); const downloadCallback = async (url) => { @@ -408,7 +247,7 @@ export async function saveItems({ const opts = { method: 'GET', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${getToken(org, site, env)}`, 'Content-Type': 'application/json', }, }; @@ -454,11 +293,11 @@ export async function sendAllLanguages({ }) { const { sendMessage, saveState } = actions; - const { origin, projectId, autoAuthorize } = options.service; + const { origin, projectId, autoAuthorize, env } = options.service; const endpoint = resolveOrigin(origin, org, site); sendMessage({ text: `Creating job in Smartling for: ${title}.` }); - const jobUid = await createJob(endpoint, projectId, title, langs, sendMessage); + const jobUid = await createJob(org, site, env, endpoint, projectId, title, langs, sendMessage); if (!jobUid) { sendMessage({ text: `Job creation failed for: ${title}.`, type: 'error' }); return; @@ -471,7 +310,17 @@ export async function sendAllLanguages({ // config[`${env}.jobUid`] = jobUid; sendMessage({ text: `Creating a batch in Smartling for: ${title}.` }); - const batchUid = await createBatch(endpoint, projectId, jobUid, urls, autoAuthorize === 'yes', sendMessage); + const batchUid = await createBatch( + org, + site, + env, + endpoint, + projectId, + jobUid, + urls, + autoAuthorize === 'yes', + sendMessage, + ); if (!batchUid) { sendMessage({ text: `Batch creation failed for: ${title}.`, type: 'error' }); return; @@ -485,6 +334,9 @@ export async function sendAllLanguages({ sendMessage({ text: `Uploading ${urls.length} items to Smartling for job: ${title}.` }); const results = await uploadFiles( + org, + site, + env, endpoint, projectId, batchUid, @@ -505,6 +357,9 @@ export async function sendAllLanguages({ /** * Fetches Smartling's per-locale progress for a job. + * @param {string} org - The DA org. + * @param {string} site - The DA site. + * @param {string} env - The environment key (e.g. 'prod'). * @param {string} endpoint - The resolved Smartling API origin. * @param {string} projectId - The Smartling project id. * @param {string} jobUid - The job to check progress for. @@ -515,9 +370,9 @@ export async function sendAllLanguages({ * matching the "No content for translation" status Smartling's dashboard * shows for it. */ -async function fetchJobProgress(endpoint, projectId, jobUid) { +async function fetchJobProgress(org, site, env, endpoint, projectId, jobUid) { const url = `${endpoint}/jobs-api/v3/projects/${projectId}/jobs/${jobUid}/progress`; - const opts = { headers: { Authorization: `Bearer ${token}` } }; + const opts = { headers: { Authorization: `Bearer ${getToken(org, site, env)}` } }; const resp = await fetchWithRetry(url, opts, { onUnauthorized: onUnauthorized(opts) }); if (!resp.ok) return null; @@ -557,7 +412,9 @@ export async function getStatusAll({ org, site, service, langs, urls, actions, }) { const { saveState, sendMessage } = actions; - const { origin, projectId, jobUid } = service; + const { + origin, projectId, jobUid, env, + } = service; const endpoint = resolveOrigin(origin, org, site); if (!jobUid?.value) { @@ -572,7 +429,14 @@ export async function getStatusAll({ const activeLangs = langs.filter((l) => !['complete', 'cancelled'].includes(l.translation.status)); if (!activeLangs.length) return; - const progressByLocale = await fetchJobProgress(endpoint, projectId, jobUid.value); + const progressByLocale = await fetchJobProgress( + org, + site, + env, + endpoint, + projectId, + jobUid.value, + ); if (!progressByLocale) { sendMessage({ text: 'Checking status failed: could not reach Smartling.', type: 'error' }); return; diff --git a/nx/blocks/loc/utils/auth.js b/nx/blocks/loc/utils/auth.js index 580804865..51bacb70c 100644 --- a/nx/blocks/loc/utils/auth.js +++ b/nx/blocks/loc/utils/auth.js @@ -85,7 +85,7 @@ function loginUrl(name, org, site, env) { * @param {string} env - The environment key (e.g. 'prod'). * @returns {Promise} The parsed response body, or null on failure. */ -export async function loginViaDaEtc(name, org, site, env) { +export async function login(name, org, site, env) { const resp = await daFetch({ url: loginUrl(name, org, site, env), opts: { method: 'POST' } }); if (!resp.ok) return null; return resp.json(); @@ -117,7 +117,7 @@ export async function getAccessToken(name, service, { force = false } = {}) { if (cached && cachedExpires > Date.now()) return cached; } - const data = await loginViaDaEtc(name, org, site, env); + const data = await login(name, org, site, env); const { access_token: accessToken, expires_in: expiresIn } = data || {}; if (!accessToken) return null; diff --git a/test/loc/connectors/smartling/index.test.js b/test/loc/connectors/smartling/index.test.js index 14cdbd562..8eadffea9 100644 --- a/test/loc/connectors/smartling/index.test.js +++ b/test/loc/connectors/smartling/index.test.js @@ -1,6 +1,6 @@ import { expect } from '@esm-bundle/chai'; import { - connect, isConnected, saveItems, sendAllLanguages, getStatusAll, + isConnected, saveItems, sendAllLanguages, getStatusAll, } from '../../../../nx/blocks/loc/connectors/smartling/index.js'; import { DA_TRANSLATE } from '../../../../nx2/utils/utils.js'; @@ -68,18 +68,18 @@ describe('smartling connector - legacy origin rewriting', () => { beforeEach(() => installFetch()); afterEach(() => restoreFetch()); - // Must run before any other test calls connect()/scheduleRefresh - isConnected's + // Must run before any other test calls scheduleRefresh - isConnected's // early return depends on tokenPolling being unset, which is otherwise - // module-level state left over from every later connect() call in this file. + // module-level state left over from every later test in this file. it('resolves the endpoint from origin/org/site in isConnected, not a nonexistent config key', async () => { - localStorage.setItem('smartling.prod.token', JSON.stringify({ + localStorage.setItem(`smartling.${org}.${site}.prod.token`, JSON.stringify({ accessToken: 'cached-token', refreshToken: 'cached-refresh-token', expires: Date.now() + 60000, })); const connected = await isConnected({ - name: 'Smartling', env: 'prod', userId: 'u', userSecret: 's', origin: legacyOrigin, org, site, + name: 'Smartling', env: 'prod', origin: legacyOrigin, org, site, }); expect(connected).to.equal(true); @@ -123,47 +123,6 @@ describe('smartling connector - legacy origin rewriting', () => { expect(langs[0].translation.status).to.equal('created'); }); - it('rewrites the legacy /smartling origin to /translate/smartling// on connect', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - - expect(calls[0].url).to.equal(`${DA_TRANSLATE}/translate/smartling/${org}/${site}/auth-api/v2/authenticate`); - }); - - it('leaves a non-legacy origin untouched on connect', async () => { - const customOrigin = 'https://api.smartling.com'; - await connect({ - name: 'Smartling', origin: customOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - - expect(calls[0].url).to.equal(`${customOrigin}/auth-api/v2/authenticate`); - }); - - it('surfaces an error and returns false when connect fails', async () => { - origFetch = window.fetch; - window.fetch = async (url, opts = {}) => { - const u = url.toString(); - calls.push({ url: u, method: opts.method, body: opts.body }); - - if (u.includes('/auth-api/v2/authenticate')) { - return new Response('', { status: 401 }); - } - return new Response('{}', { status: 200 }); - }; - - const messages = []; - const sendMessage = (m) => messages.push(m); - - const result = await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }, sendMessage); - - expect(result).to.equal(false); - const errorMessage = messages.find((m) => m.type === 'error'); - expect(errorMessage.text).to.include('Connection to Smartling failed'); - }); - it('rewrites the origin for sendAllLanguages job/batch/upload calls', async () => { const options = { service: { origin: legacyOrigin, projectId: 'proj-1' } }; const langs = [{ name: 'French', code: 'fr-FR' }]; @@ -489,10 +448,12 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('recovers from a 401 on getStatusAll by refreshing the token and retrying', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - + // Real auth (connect()) now goes through da-etc, which requires a + // signed-in IMS session and isn't mockable at this level. No seeding is + // needed here though - `authContext` is already populated by the + // isConnected() call in the first test above, and the initial request's + // token read is a cache miss regardless (this test's `service` has no + // `env`), so it always 401s and forces the refresh path being tested. let progressCalls = 0; let refreshCalls = 0; origFetch = window.fetch; @@ -807,10 +768,8 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('recovers from a 401 by refreshing the token and retrying the request', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - + // See the getStatusAll 401-recovery test above for why no seeding is + // needed here. let jobCalls = 0; let refreshCalls = 0; origFetch = window.fetch; @@ -854,10 +813,8 @@ describe('smartling connector - legacy origin rewriting', () => { }); it('gives up without looping when the retried request also 401s', async () => { - await connect({ - name: 'Smartling', origin: legacyOrigin, env: 'prod', userId: 'u', userSecret: 's', org, site, - }); - + // See the getStatusAll 401-recovery test above for why no seeding is + // needed here. let refreshCalls = 0; origFetch = window.fetch; window.fetch = async (url, opts = {}) => { From 54afe140b79fefb3c0f9b5336956ff1ff0d07684 Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Thu, 3 Sep 2026 13:44:00 -0500 Subject: [PATCH 3/3] fix(translate): auto-connect Smartling instead of requiring a manual click - isConnected/connect now share one ensureConnected() implementation that authenticates via da-etc on a cache miss, matching Trados/Lionbridge's authReady - no more separate manual "Connect" step to start a project - Fixes a latent bug where a valid cached token was reported as disconnected if the refresh schedule was already armed - Add test coverage for the auto-connect path Co-Authored-By: Claude --- nx/blocks/loc/connectors/smartling/auth.js | 58 +++++++++------------ test/loc/connectors/smartling/index.test.js | 27 ++++++++++ 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/nx/blocks/loc/connectors/smartling/auth.js b/nx/blocks/loc/connectors/smartling/auth.js index f016af6bf..e7e6dcf59 100644 --- a/nx/blocks/loc/connectors/smartling/auth.js +++ b/nx/blocks/loc/connectors/smartling/auth.js @@ -153,14 +153,21 @@ export function onUnauthorized(opts) { } /** - * Checks for a still-valid cached token and, if found, resumes background - * refresh scheduling (e.g. after a page reload) instead of requiring the - * user to reconnect. - * @param {Object} config - The service configuration, including - * `origin`/`org`/`site`/`env` to resolve the API endpoint. - * @returns {Promise} Whether a still-valid cached token was found. + * Ensures a connected session: reuses a still-valid cached token if one + * exists (resuming background refresh scheduling, e.g. after a page + * reload), otherwise authenticates via da-etc - which holds the org/site's + * Smartling credentials server-side and only ever returns a short-lived + * access/refresh token pair, so no secret reaches the browser. Matches + * Trados/Lionbridge's `authReady`: connecting is transparent, with no + * separate manual step required. + * @param {Object} config - The service configuration. + * @param {string} config.origin - The configured API origin. + * @param {string} config.env - The environment key (e.g. 'prod'). + * @param {string} config.org - The DA org. + * @param {string} config.site - The DA site. + * @returns {Promise} Whether a connected session is available. */ -export async function isConnected(config) { +async function ensureConnected(config) { const { origin, org, site, env, } = config; @@ -168,37 +175,16 @@ export async function isConnected(config) { const { expires } = getCachedToken(INTEGRATION_NAME, org, site, env); const notExpired = expires > Date.now(); - if (notExpired && !tokenPolling) { + if (notExpired) { authContext = { endpoint, org, site, env, }; - - // Kick off the refresh scheduling - scheduleRefresh((expires - Date.now()) / 1000); + // Only (re)arm the schedule if it isn't already running, so repeated + // calls against an already-connected session don't stack timers. + if (!tokenPolling) scheduleRefresh((expires - Date.now()) / 1000); return true; } - return false; -} - -/** - * Authenticates with Smartling via da-etc and starts background refresh - * scheduling. da-etc holds the org/site's Smartling credentials server-side - * and only ever returns a short-lived access/refresh token pair, so no - * secret reaches the browser. - * @param {Object} service - The service configuration. - * @param {string} service.origin - The configured API origin. - * @param {string} service.env - The environment key (e.g. 'prod'). - * @param {string} service.org - The DA org. - * @param {string} service.site - The DA site. - * @returns {Promise} Whether authentication succeeded. - */ -export async function connect(service) { - const { - origin, env, org, site, - } = service; - const endpoint = resolveOrigin(origin, org, site); - const data = await authenticate(org, site, env); if (!data?.accessToken) return false; @@ -210,3 +196,11 @@ export async function connect(service) { scheduleRefresh(expiresIn); return true; } + +export function isConnected(config) { + return ensureConnected(config); +} + +export function connect(service) { + return ensureConnected(service); +} diff --git a/test/loc/connectors/smartling/index.test.js b/test/loc/connectors/smartling/index.test.js index 8eadffea9..f181f07bd 100644 --- a/test/loc/connectors/smartling/index.test.js +++ b/test/loc/connectors/smartling/index.test.js @@ -123,6 +123,33 @@ describe('smartling connector - legacy origin rewriting', () => { expect(langs[0].translation.status).to.equal('created'); }); + it('auto-connects via isConnected when there is no cached token, with no separate connect() step', async () => { + // Distinct org/site so this test's cache key can't collide with the + // 'acme'/'site1' state other tests in this file leave behind. + const autoOrg = 'auto-org'; + const autoSite = 'auto-site'; + + origFetch = window.fetch; + window.fetch = async (url, opts = {}) => { + const u = url.toString(); + calls.push({ url: u, method: opts.method, body: opts.body }); + + if (u.includes('/integrations/smartling/login')) { + return new Response(JSON.stringify({ + response: { data: { accessToken: 'auto-token', refreshToken: 'auto-refresh', expiresIn: 300 } }, + }), { status: 200 }); + } + return new Response('{}', { status: 200 }); + }; + + const connected = await isConnected({ + name: 'Smartling', env: 'prod', origin: 'https://api.smartling.com', org: autoOrg, site: autoSite, + }); + + expect(connected).to.equal(true); + expect(calls.some((c) => c.url.includes('/integrations/smartling/login'))).to.equal(true); + }); + it('rewrites the origin for sendAllLanguages job/batch/upload calls', async () => { const options = { service: { origin: legacyOrigin, projectId: 'proj-1' } }; const langs = [{ name: 'French', code: 'fr-FR' }];