Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 50 additions & 54 deletions src/app/api/settings/proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ type UpdateProxyConfigInput = z.infer<typeof updateProxyConfigSchema>;
type ProxyConfigInput = NonNullable<UpdateProxyConfigInput["proxy"]>;
type ProxyMapInput = Record<string, ProxyConfigInput | null>;
type ApiRouteError = Error & { status?: number; type?: string };
const PROXY_LEVEL_TO_REGISTRY_SCOPE = {
global: "global",
provider: "provider",
combo: "combo",
key: "account",
} as const;

function isSocks5Enabled() {
return process.env.ENABLE_SOCKS5_PROXY === "true";
Expand Down Expand Up @@ -53,6 +59,41 @@ function toApiRouteError(error: unknown): ApiRouteError {
return new Error("Unexpected error") as ApiRouteError;
}

function getRegistryScopeForLevel(
level: string
): "global" | "provider" | "combo" | "account" | undefined {
if (!Object.prototype.hasOwnProperty.call(PROXY_LEVEL_TO_REGISTRY_SCOPE, level)) {
return undefined;
}

return PROXY_LEVEL_TO_REGISTRY_SCOPE[
level as keyof typeof PROXY_LEVEL_TO_REGISTRY_SCOPE
];
}

async function getRegistryProxyForLevel(level: string, id: string | null) {
const scope = getRegistryScopeForLevel(level);
if (!scope) return null;
if (scope !== "global" && !id) return null;

const assignments = await getProxyAssignments({ scope });
const assignment =
scope === "global" ? assignments[0] : assignments.find((entry) => entry.scopeId === id);
if (!assignment?.proxyId) return null;

return getProxyById(assignment.proxyId, { includeSecrets: true });
}

function toProxyConfig(proxyData: NonNullable<Awaited<ReturnType<typeof getProxyById>>>) {
return {
type: proxyData.type,
host: proxyData.host,
port: proxyData.port,
username: proxyData.username,
password: proxyData.password,
};
}

function normalizeAndValidateProxy(
proxy: ProxyConfigInput | null | undefined,
pathLabel: string
Expand Down Expand Up @@ -136,59 +177,20 @@ export async function GET(request: Request) {
return Response.json(result);
}

// Get proxy for a specific level - check Proxy Registry first
if (level === "global") {
const assignments = await getProxyAssignments({ scope: "global" });
if (assignments.length > 0 && assignments[0].proxyId) {
const proxyData = await getProxyById(assignments[0].proxyId, { includeSecrets: true });
if (proxyData) {
return Response.json({
level: "global",
id: null,
proxy: {
type: proxyData.type,
host: proxyData.host,
port: proxyData.port,
username: proxyData.username,
password: proxyData.password,
},
});
}
}
// Fallback to old system
const proxy = await getProxyForLevel(level, id);
return Response.json({ level, id, proxy });
}

if (level === "provider" && id) {
const assignments = await getProxyAssignments({ scope: "provider" });
const assignment = assignments.find((entry) => entry.scopeId === id);
if (assignment?.proxyId) {
const proxyData = await getProxyById(assignment.proxyId, { includeSecrets: true });
if (proxyData) {
return Response.json({
level,
id,
proxy: {
type: proxyData.type,
host: proxyData.host,
port: proxyData.port,
username: proxyData.username,
password: proxyData.password,
},
});
}
if (level) {
const proxyData = await getRegistryProxyForLevel(level, id);
if (proxyData) {
return Response.json({
level,
id: level === "global" ? null : id,
proxy: toProxyConfig(proxyData),
});
}

const proxy = await getProxyForLevel(level, id);
return Response.json({ level, id, proxy });
}

if (level) {
const proxy = await getProxyForLevel(level, id);
return Response.json({ level, id, proxy });
}

// Get full config
const config = await getProxyConfig();
const providerAssignments = await getProxyAssignments({ scope: "provider" });
Expand All @@ -207,13 +209,7 @@ export async function GET(request: Request) {

for (const result of providerProxyResults) {
if (!result) continue;
config.providers[result.scopeId] = {
type: result.proxyData.type,
host: result.proxyData.host,
port: result.proxyData.port,
username: result.proxyData.username,
password: result.proxyData.password,
};
config.providers[result.scopeId] = toProxyConfig(result.proxyData);
}
}
return Response.json(config);
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/route-edge-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,81 @@ test("settings proxy route covers full config, resolve, validation, delete and g
assert.equal(missingLevelBody.error.message, "level is required");
});

test("settings proxy route resolves combo and key registry assignments with legacy fallback", async () => {
const legacyPut = await settingsProxyRoute.PUT(
makeRequest("http://localhost/api/settings/proxy", {
method: "PUT",
body: {
combos: {
comboA: { type: "http", host: "legacy-combo.local", port: "9001" },
},
keys: {
accountA: { type: "https", host: "legacy-key.local", port: "9444" },
},
},
})
);

const legacyComboGet = await settingsProxyRoute.GET(
new Request("http://localhost/api/settings/proxy?level=combo&id=comboA")
);
const legacyKeyGet = await settingsProxyRoute.GET(
new Request("http://localhost/api/settings/proxy?level=key&id=accountA")
);

const comboProxy = await localDb.createProxy({
name: "Registry Combo Proxy",
type: "http",
host: "registry-combo.local",
port: 8181,
username: "combo-user",
password: "combo-secret",
});
const accountProxy = await localDb.createProxy({
name: "Registry Account Proxy",
type: "https",
host: "registry-account.local",
port: 9443,
username: "account-user",
password: "account-secret",
});
await localDb.assignProxyToScope("combo", "comboA", comboProxy.id);
await localDb.assignProxyToScope("account", "accountA", accountProxy.id);

const registryComboGet = await settingsProxyRoute.GET(
new Request("http://localhost/api/settings/proxy?level=combo&id=comboA")
);
const registryKeyGet = await settingsProxyRoute.GET(
new Request("http://localhost/api/settings/proxy?level=key&id=accountA")
);

const legacyPutBody = (await legacyPut.json()) as any;
const legacyComboBody = (await legacyComboGet.json()) as any;
const legacyKeyBody = (await legacyKeyGet.json()) as any;
const registryComboBody = (await registryComboGet.json()) as any;
const registryKeyBody = (await registryKeyGet.json()) as any;

assert.equal(legacyPut.status, 200);
assert.equal(legacyPutBody.combos.comboA.host, "legacy-combo.local");
assert.equal(legacyPutBody.keys.accountA.host, "legacy-key.local");
assert.equal(legacyComboGet.status, 200);
assert.equal(legacyComboBody.level, "combo");
assert.equal(legacyComboBody.id, "comboA");
assert.equal(legacyComboBody.proxy.host, "legacy-combo.local");
assert.equal(legacyKeyGet.status, 200);
assert.equal(legacyKeyBody.level, "key");
assert.equal(legacyKeyBody.id, "accountA");
assert.equal(legacyKeyBody.proxy.host, "legacy-key.local");
assert.equal(registryComboGet.status, 200);
assert.equal(registryComboBody.proxy.host, "registry-combo.local");
assert.equal(registryComboBody.proxy.username, "combo-user");
assert.equal(registryComboBody.proxy.password, "combo-secret");
assert.equal(registryKeyGet.status, 200);
assert.equal(registryKeyBody.proxy.host, "registry-account.local");
assert.equal(registryKeyBody.proxy.username, "account-user");
assert.equal(registryKeyBody.proxy.password, "account-secret");
});

test("settings proxy route prefers proxy registry assignments and enforces socks5 feature gating", async () => {
const created = await localDb.createProxy({
name: "Global Proxy",
Expand Down