diff --git a/package.json b/package.json index 1522de51d9..b20a742a8e 100644 --- a/package.json +++ b/package.json @@ -232,7 +232,8 @@ "typescript-eslint": "^8.59.4", "vitest": "^4.1.7", "wait-on": "^9.0.10", - "wtfnode": "^0.10.1" + "wtfnode": "^0.10.1", + "@types/bun": "latest" }, "lint-staged": { "*.{js,jsx,ts,tsx,mjs}": [ @@ -259,5 +260,6 @@ "ip-address": "10.2.0", "qs": "^6.15.2", "uuid": "^14.0.0" - } + }, + "private": true } diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs index 1fcb0a6699..fad7fb5adb 100644 --- a/scripts/check/check-openapi-security-tiers.mjs +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -16,7 +16,10 @@ const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.t function parseStringArray(match) { if (!match) return []; + // Strip line comments before splitting — array entries in routeGuard.ts often + // carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens. return match[1] + .replace(/\/\/[^\n]*/g, "") .split(",") .map((s) => s.trim().replace(/^["']|["']$/g, "")) .filter(Boolean); diff --git a/src/app/api/services/9router/_lib.ts b/src/app/api/services/9router/_lib.ts index 111f8f2dd6..b34d5f5615 100644 --- a/src/app/api/services/9router/_lib.ts +++ b/src/app/api/services/9router/_lib.ts @@ -11,22 +11,41 @@ import { getOrCreateApiKey } from "@/lib/services/apiKey"; const TOOL = "9router"; const PORT = 20130; +// Module-level in-flight guard. Without this, two concurrent requests that +// both observe `getSupervisor() === null` and then await getOrCreateApiKey +// would each construct a supervisor and the second register overwrites the +// first — orphaning the first child process beyond lifecycle control. +let initInFlight: Promise | null = null; + export async function getOrInitSupervisor(): Promise { const existing = getSupervisor(TOOL); if (existing) return existing; - const apiKey = await getOrCreateApiKey(TOOL); + if (initInFlight) return initInFlight; - const sup = new ServiceSupervisor({ - tool: TOOL, - port: PORT, - spawnArgs: () => resolveSpawnArgs(apiKey, PORT), - healthUrl: () => `http://127.0.0.1:${PORT}/api/health`, - healthIntervalMs: 2_000, - stopTimeoutMs: 15_000, - logsBufferBytes: 5_242_880, + initInFlight = (async () => { + // Double-check after entering the in-flight branch — a competing caller + // may have completed initialization while we awaited the lock check. + const racy = getSupervisor(TOOL); + if (racy) return racy; + + const apiKey = await getOrCreateApiKey(TOOL); + + const sup = new ServiceSupervisor({ + tool: TOOL, + port: PORT, + spawnArgs: () => resolveSpawnArgs(apiKey, PORT), + healthUrl: () => `http://127.0.0.1:${PORT}/api/health`, + healthIntervalMs: 2_000, + stopTimeoutMs: 15_000, + logsBufferBytes: 5_242_880, + }); + + registerSupervisor(sup); + return sup; + })().finally(() => { + initInFlight = null; }); - registerSupervisor(sup); - return sup; + return initInFlight; } diff --git a/src/app/api/services/9router/rotate-key/route.ts b/src/app/api/services/9router/rotate-key/route.ts index 013798fdf2..ebf2bc0634 100644 --- a/src/app/api/services/9router/rotate-key/route.ts +++ b/src/app/api/services/9router/rotate-key/route.ts @@ -1,4 +1,4 @@ -import { getSupervisor } from "@/lib/services/registry"; +import { getSupervisor, unregisterSupervisor } from "@/lib/services/registry"; import { getOrInitSupervisor } from "../_lib"; import { generateServiceApiKey } from "@/lib/services/apiKey"; import { updateServiceField } from "@/lib/db/versionManager"; @@ -17,7 +17,10 @@ export async function POST(): Promise { let restarted = false; if (wasRunning && sup) { await sup.stop(); - // Re-initialize supervisor so it picks up the new key from getOrCreateApiKey + // Unregister the existing supervisor so its stale spawnArgs closure (which + // captured the OLD apiKey at construction time) is discarded. getOrInitSupervisor + // will then build a fresh supervisor whose closure reads the new key. + unregisterSupervisor("9router"); const freshSup = await getOrInitSupervisor(); await freshSup.start(); restarted = true; diff --git a/src/app/api/settings/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts index 9c2bbd17d7..f24839e6d8 100644 --- a/src/app/api/settings/proxy/vercel-deploy/route.ts +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -10,13 +10,56 @@ const POLL_INTERVAL_MS = 3000; const POLL_MAX_ATTEMPTS = 40; // ~2 min function buildRelayFunction(relayAuth: string): string { - // relayAuth is a random hex string generated server-side — no user input + // relayAuth is a random hex string generated server-side — no user input. + // The runtime SSRF guard is inlined into the edge function (cannot import + // Node-side helpers from the Edge runtime); it blocks RFC1918, loopback, + // link-local, IPv6 ULA, and embedded credentials on the x-relay-target host. return `export const config = { runtime: "edge" }; + +function isPrivateHostname(h) { + if (!h) return true; + const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); + if ( + host === "localhost" || + host === "0.0.0.0" || + host === "127.0.0.1" || + host === "::1" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.startsWith("::ffff:") + ) return true; + const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/); + if (v4) { + const a = +v4[1], b = +v4[2]; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + return false; + } + if (host.includes(":")) { + return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); + } + return false; +} + export default async function handler(req) { const auth = req.headers.get("x-relay-auth"); if (auth !== "${relayAuth}") return new Response("Unauthorized", { status: 401 }); const target = req.headers.get("x-relay-target"); if (!target) return new Response("missing x-relay-target", { status: 400 }); + let targetUrl; + try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); } + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + return new Response("forbidden x-relay-target protocol", { status: 403 }); + } + if (targetUrl.username || targetUrl.password) { + return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 }); + } + if (isPrivateHostname(targetUrl.hostname)) { + return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); + } const relayPath = req.headers.get("x-relay-path") || "/"; const headers = new Headers(req.headers); ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h)); @@ -30,10 +73,7 @@ export default async function handler(req) { }`; } -async function pollDeployment( - deploymentApiUrl: string, - token: string -): Promise<"READY" | "ERROR"> { +async function pollDeployment(deploymentApiUrl: string, token: string): Promise<"READY" | "ERROR"> { for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); try { diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index ae51219a12..db30c54120 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -39,7 +39,11 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string if (!webhook) { return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); } - return NextResponse.json({ webhook }); + const masked = { + ...webhook, + secret: webhook.secret ? `${webhook.secret.slice(0, 10)}...` : null, + }; + return NextResponse.json({ webhook: masked }); } catch (error: any) { return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 }); } diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 527f4c80b4..23c6fd7cde 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -127,8 +127,7 @@ function extractRelayAuth(notes: unknown): string | undefined { function toRegistryProxyResolution(row: unknown, level: ProxyScope, levelId: string | null) { const record = toRecord(row); - const relayAuth = - record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; + const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -366,10 +365,22 @@ function coerceProxyPayload(value: unknown, fallbackName: string): ProxyPayload } export function redactProxySecrets(proxy: ProxyRegistryRecord): ProxyRegistryRecord { + let redactedNotes = proxy.notes; + if (proxy.type === "vercel" && proxy.notes) { + try { + const parsed = JSON.parse(proxy.notes); + if (parsed && typeof parsed === "object" && "relayAuth" in parsed) { + redactedNotes = JSON.stringify({ ...parsed, relayAuth: "***" }); + } + } catch { + // Non-JSON notes pass through unchanged + } + } return { ...proxy, username: proxy.username ? "***" : "", password: proxy.password ? "***" : "", + notes: redactedNotes, }; } @@ -671,8 +682,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string .get(connectionId); if (accountAssignment) { const record = toRecord(accountAssignment); - const relayAuth = - record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; + const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -700,8 +710,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string .get(connection.provider); if (providerAssignment) { const record = toRecord(providerAssignment); - const relayAuth = - record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; + const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -725,8 +734,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string .get(); if (globalAssignment) { const record = toRecord(globalAssignment); - const relayAuth = - record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; + const relayAuth = record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, diff --git a/src/lib/freeProxyProviders/iplocate.ts b/src/lib/freeProxyProviders/iplocate.ts index df65bb24d9..9395e8f2d2 100644 --- a/src/lib/freeProxyProviders/iplocate.ts +++ b/src/lib/freeProxyProviders/iplocate.ts @@ -1,7 +1,7 @@ import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types"; +import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; -const BASE_URL = - "https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols"; +const BASE_URL = "https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols"; const PROTOCOLS = ["http", "https", "socks4", "socks5"] as const; // In-module cache to respect GitHub raw rate limits @@ -50,9 +50,7 @@ export class IplocateProvider implements FreeProxyProvider { const res = await fetch(url, { signal: AbortSignal.timeout(15000), headers: - lastFetchAt > 0 - ? { "If-Modified-Since": new Date(lastFetchAt).toUTCString() } - : {}, + lastFetchAt > 0 ? { "If-Modified-Since": new Date(lastFetchAt).toUTCString() } : {}, }); if (res.status === 304) continue; @@ -66,6 +64,10 @@ export class IplocateProvider implements FreeProxyProvider { for (const p of data) { if (!p.ip || !p.port) continue; + if (isPrivateHost(p.ip)) { + errors.push(`${proto}: skipped private/loopback host ${p.ip}`); + continue; + } const item: FreeProxyItem = { source: "iplocate", host: p.ip, diff --git a/src/lib/freeProxyProviders/oneproxy.ts b/src/lib/freeProxyProviders/oneproxy.ts index b870a569cf..1984334c9a 100644 --- a/src/lib/freeProxyProviders/oneproxy.ts +++ b/src/lib/freeProxyProviders/oneproxy.ts @@ -1,4 +1,5 @@ import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types"; +import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; const DEFAULT_API_URL = "https://1proxy-api.aitradepulse.com/api/v1/proxies/advanced"; const DEFAULT_MAX = 500; @@ -81,6 +82,10 @@ export class OneproxyProvider implements FreeProxyProvider { if (!Array.isArray(json.proxies) || json.proxies.length === 0) break; for (const p of json.proxies) { + if (!p.ip || isPrivateHost(p.ip)) { + errors.push(`1proxy: skipped private/loopback host ${p.ip}`); + continue; + } const item: FreeProxyItem = { source: "1proxy", host: p.ip, diff --git a/src/lib/freeProxyProviders/proxifly.ts b/src/lib/freeProxyProviders/proxifly.ts index df0f27e514..6895952118 100644 --- a/src/lib/freeProxyProviders/proxifly.ts +++ b/src/lib/freeProxyProviders/proxifly.ts @@ -1,4 +1,5 @@ import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types"; +import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; const DEFAULT_QUANTITY = 100; const DEFAULT_ANONYMITY = "elite"; @@ -38,22 +39,28 @@ export class ProxiflyProvider implements FreeProxyProvider { try { const proxiflyModule = await import("proxifly"); const proxifly = proxiflyModule.default ?? proxiflyModule; - const result = await (proxifly as { getProxy: (opts: unknown) => Promise }).getProxy({ - protocol: "http", - anonymity: anonymity as "elite" | "anonymous" | "transparent", - speed: "fast", - quantity, - }); + const result = await (proxifly as { getProxy: (opts: unknown) => Promise }).getProxy( + { + protocol: "http", + anonymity: anonymity as "elite" | "anonymous" | "transparent", + speed: "fast", + quantity, + } + ); const proxies: ProxiflyProxy[] = Array.isArray(result) ? result : [result as ProxiflyProxy]; for (const p of proxies) { if (!p.ip || !p.port) continue; + if (isPrivateHost(p.ip)) { + errors.push(`Proxifly: skipped private/loopback host ${p.ip}`); + continue; + } const item: FreeProxyItem = { source: "proxifly", host: p.ip, port: Number(p.port), - type: ((p.protocol || "http").toLowerCase() as FreeProxyItem["type"]), + type: (p.protocol || "http").toLowerCase() as FreeProxyItem["type"], countryCode: p.country?.slice(0, 2).toUpperCase() || null, qualityScore: p.speed != null ? Math.min(100, Math.max(0, Math.round(p.speed))) : null, latencyMs: null, diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 5164ac75a6..c21fabac5d 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -329,6 +329,29 @@ export const WINDSURF_CONFIG = { extensionVersion: "3.14.0", }; +// Trae IDE OAuth Configuration (Import Token) +// +// Trae is an AI-native IDE by ByteDance. Authentication uses a personal API +// token that users can find inside the Trae IDE under: +// Settings → Account → API Token (or similar path) +// +// TODO(trae-auth): ByteDance has not published a public OAuth client_id/secret +// for the Trae IDE. Once a public CLI or device-code flow is documented at +// https://docs.trae.ai, update flowType to "device_code" and add the +// appropriate endpoints. Until then, import_token (manual paste) is the only +// confirmed safe default. +export const TRAE_CONFIG = { + // Inference API endpoint (used by the IDE extension) + apiEndpoint: "https://api.trae.ai", + // Chat completions path (mirrored from OpenAI-compatible providers) + chatEndpoint: "/v1/chat/completions", + // Trae website — users retrieve their token here after signing in + webUrl: "https://trae.ai", + // Token storage note for users — no automated extraction path is available + // because Trae does not expose a public SQLite / keychain location yet. + tokenNote: "Sign in to Trae IDE, then copy your API token from the account settings.", +}; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -351,4 +374,5 @@ export const PROVIDERS = { CLINE: "cline", WINDSURF: "windsurf", DEVIN_CLI: "devin-cli", + TRAE: "trae", }; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index b747eff146..31651dac5b 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -24,6 +24,7 @@ import { cursor } from "./cursor"; import { kilocode } from "./kilocode"; import { cline } from "./cline"; import { windsurf } from "./windsurf"; +import { trae } from "./trae"; export const PROVIDERS = { claude, @@ -43,6 +44,7 @@ export const PROVIDERS = { windsurf, // devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login) "devin-cli": windsurf, + trae, }; export default PROVIDERS; diff --git a/src/lib/oauth/providers/trae.ts b/src/lib/oauth/providers/trae.ts new file mode 100644 index 0000000000..f11b4edeaa --- /dev/null +++ b/src/lib/oauth/providers/trae.ts @@ -0,0 +1,32 @@ +import { TRAE_CONFIG } from "../constants/oauth"; + +/** + * Trae IDE OAuth Provider (Import Token) + * + * Trae is an AI-native IDE by ByteDance. Authentication relies on a personal + * API token that the user copies from the Trae account settings page and pastes + * into the OmniRoute connection form. + * + * Why import_token and not device_code / authorization_code: + * ByteDance has not published a public OAuth client_id/secret for the Trae + * IDE, nor documented a device-code or browser-redirect flow for third-party + * integrations. The authHint in providers.ts (see IDE_PROVIDER_IDS) confirms + * that "paste your API token" is the supported onboarding path. + * + * TODO(trae-auth): if ByteDance publishes a public OAuth application for Trae, + * upgrade flowType to "device_code" or "authorization_code_pkce" and embed + * the client credentials via resolvePublicCred() (Hard Rule #11). + * Reference: https://docs.trae.ai (check for OAuth / CLI integration docs) + */ +export const trae = { + config: TRAE_CONFIG, + flowType: "import_token", + mapTokens: (tokens) => ({ + accessToken: tokens.accessToken, + refreshToken: null, + expiresIn: tokens.expiresIn || 86400, + providerSpecificData: { + authMethod: "imported", + }, + }), +}; diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index d3584fe13b..3af87200cd 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -36,6 +36,7 @@ const { PROVIDERS: OAUTH_PROVIDER_IDS, QODER_CONFIG, QWEN_CONFIG, + TRAE_CONFIG, WINDSURF_CONFIG, } = oauthModule; const { REGISTRY } = registryModule; @@ -60,6 +61,7 @@ const EXPECTED_PROVIDER_KEYS = [ "cline", "windsurf", "devin-cli", + "trae", ]; const EXPECTED_CONFIG_BY_PROVIDER = { @@ -79,6 +81,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { cline: CLINE_CONFIG, windsurf: WINDSURF_CONFIG, "devin-cli": WINDSURF_CONFIG, + trae: TRAE_CONFIG, }; const REQUIRED_FIELDS_BY_PROVIDER = { @@ -125,6 +128,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = { cline: ["appBaseUrl", "apiBaseUrl", "authorizeUrl", "tokenExchangeUrl", "refreshUrl"], windsurf: ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], "devin-cli": ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], + trae: ["apiEndpoint", "chatEndpoint", "webUrl"], }; function getByPath(object, path) { @@ -325,6 +329,8 @@ test("device and import-token providers expose the flow-specific fields expected assert.equal(PROVIDERS.cursor.flowType, "import_token"); assert.equal(CURSOR_CONFIG.dbKeys.accessToken, "cursorAuth/accessToken"); assert.equal(CURSOR_CONFIG.dbKeys.machineId, "storage.serviceMachineId"); + assert.equal(PROVIDERS.trae.flowType, "import_token"); + assert.equal(typeof TRAE_CONFIG.apiEndpoint, "string"); assert.ok(Array.isArray(KIRO_CONFIG.authMethods)); assert.ok(KIRO_CONFIG.authMethods.includes("builder-id")); }); diff --git a/tests/unit/oauth-trae.test.ts b/tests/unit/oauth-trae.test.ts new file mode 100644 index 0000000000..9704efabf0 --- /dev/null +++ b/tests/unit/oauth-trae.test.ts @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const providersModule = await import("../../src/lib/oauth/providers/index.ts"); +const oauthModule = await import("../../src/lib/oauth/constants/oauth.ts"); +const oauthHandlers = await import("../../src/lib/oauth/providers.ts"); + +const PROVIDERS = providersModule.default; +const { TRAE_CONFIG, PROVIDERS: OAUTH_PROVIDER_IDS } = oauthModule; +const { getProvider } = oauthHandlers; + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +test("PROVIDERS map includes a 'trae' entry", () => { + assert.ok("trae" in PROVIDERS, "PROVIDERS must contain trae"); +}); + +test("getProvider('trae') does not throw", () => { + assert.doesNotThrow(() => getProvider("trae")); +}); + +test("trae provider has the correct id via getProvider", () => { + // trae provider has no .config.id — confirm via OAUTH_PROVIDER_IDS constant + const traeKey = OAUTH_PROVIDER_IDS.TRAE; + assert.equal(traeKey, "trae"); +}); + +// --------------------------------------------------------------------------- +// Shape +// --------------------------------------------------------------------------- + +test("trae provider exposes the expected shape", () => { + const provider = PROVIDERS.trae; + + assert.ok(provider, "trae provider must exist"); + assert.equal( + provider.flowType, + "import_token", + "Trae uses import_token until ByteDance publishes a public OAuth client" + ); + assert.ok(provider.config, "trae provider must have a config object"); + assert.equal(typeof provider.mapTokens, "function", "trae provider must expose mapTokens"); +}); + +test("TRAE_CONFIG has required API endpoint fields", () => { + assert.ok(typeof TRAE_CONFIG.apiEndpoint === "string" && TRAE_CONFIG.apiEndpoint.length > 0); + assert.ok(typeof TRAE_CONFIG.chatEndpoint === "string" && TRAE_CONFIG.chatEndpoint.length > 0); + assert.ok(typeof TRAE_CONFIG.webUrl === "string" && TRAE_CONFIG.webUrl.length > 0); + assert.ok(TRAE_CONFIG.apiEndpoint.startsWith("https://"), "apiEndpoint must use HTTPS"); + assert.ok(TRAE_CONFIG.webUrl.startsWith("https://"), "webUrl must use HTTPS"); +}); + +// --------------------------------------------------------------------------- +// mapTokens +// --------------------------------------------------------------------------- + +test("trae mapTokens returns expected structure with valid input", () => { + const provider = PROVIDERS.trae; + + const mapped = provider.mapTokens({ + accessToken: "trae-test-token", + expiresIn: 7200, + }); + + assert.equal(mapped.accessToken, "trae-test-token"); + assert.equal(mapped.refreshToken, null, "Trae import_token has no refresh token"); + assert.equal(mapped.expiresIn, 7200); + assert.equal(mapped.providerSpecificData.authMethod, "imported"); +}); + +test("trae mapTokens defaults expiresIn to 86400 when not provided", () => { + const provider = PROVIDERS.trae; + + const mapped = provider.mapTokens({ accessToken: "trae-token-2" }); + + assert.equal(mapped.expiresIn, 86400); +}); + +test("trae mapTokens returns an object even when called with empty tokens", () => { + const provider = PROVIDERS.trae; + const mapped = provider.mapTokens({}); + + assert.ok(mapped && typeof mapped === "object"); + assert.equal(mapped.refreshToken, null); + assert.equal(mapped.expiresIn, 86400); +}); + +// --------------------------------------------------------------------------- +// OAuth provider ID constant alignment +// --------------------------------------------------------------------------- + +test("OAUTH_PROVIDER_IDS.TRAE matches the PROVIDERS key", () => { + const traeId = OAUTH_PROVIDER_IDS.TRAE; + assert.equal(traeId, "trae"); + assert.ok(traeId in PROVIDERS, "PROVIDERS must include the key from OAUTH_PROVIDER_IDS.TRAE"); +});