mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-27 17:23:52 +00:00
chore(security): hardening pass + Trae IDE provider
Bundle of small targeted improvements that landed in parallel with the PR #2678 review pass. Security hardening: - vercel-deploy edge function: inline SSRF guard blocks RFC1918 / loopback / link-local / IPv6 ULA / embedded-credential x-relay-target values. Cannot import Node-side helpers from the Edge runtime so the check is duplicated inline at the entry point. - webhooks/[id] GET: mask webhook.secret to first-10-chars + "..." so the detail endpoint no longer hands out the full signing secret. - db/proxies redactProxySecrets: also redact relayAuth inside the notes blob for type=vercel proxies (previously only username/password masked). - freeProxyProviders {iplocate, oneproxy, proxifly}: drop private/loopback hosts via isPrivateHost() before persisting — prevents an upstream feed from injecting LAN-pointing proxy entries. 9router supervisor: - _lib.ts: add module-level in-flight guard so two concurrent getOrInitSupervisor calls don't both construct supervisors and race the registration (the loser orphans its child process). - rotate-key: unregisterSupervisor before rebuilding so the stale spawnArgs closure (which captured the OLD apiKey at construction time) is discarded; the fresh supervisor reads the new key. Trae IDE OAuth provider (import_token): - src/lib/oauth/{constants/oauth,providers/index,providers/trae}: register ByteDance Trae IDE as an import_token provider. ByteDance has not published a public OAuth client_id/secret nor a device-code flow, so manual paste of the user's API token is the only safe entry path today. TODO comments mark the upgrade path if a public CLI ships. - tests/unit/{oauth-providers-config,oauth-trae}: cover the registration + import_token mapping shape. Tooling: - scripts/check/check-openapi-security-tiers: strip line comments before parsing routeGuard.ts array entries — inline // T-XX: annotations were polluting parsed tokens and producing false-positive mismatches. - package.json: add @types/bun devDep, mark workspace private.
This commit is contained in:
parent
0c94c397db
commit
0e56c5f54a
15 changed files with 296 additions and 41 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ServiceSupervisor> | null = null;
|
||||
|
||||
export async function getOrInitSupervisor(): Promise<ServiceSupervisor> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<unknown> }).getProxy({
|
||||
protocol: "http",
|
||||
anonymity: anonymity as "elite" | "anonymous" | "transparent",
|
||||
speed: "fast",
|
||||
quantity,
|
||||
});
|
||||
const result = await (proxifly as { getProxy: (opts: unknown) => Promise<unknown> }).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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
32
src/lib/oauth/providers/trae.ts
Normal file
32
src/lib/oauth/providers/trae.ts
Normal file
|
|
@ -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",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
@ -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"));
|
||||
});
|
||||
|
|
|
|||
98
tests/unit/oauth-trae.test.ts
Normal file
98
tests/unit/oauth-trae.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue