mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat: add bulk web-session credential import endpoint (#3403)
Integrated into release/v3.8.16
This commit is contained in:
185
src/app/api/providers/bulk-web-session/route.ts
Normal file
185
src/app/api/providers/bulk-web-session/route.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import {
|
||||
getProviderAuditTarget,
|
||||
summarizeProviderConnectionForAudit,
|
||||
} from "@/lib/compliance/providerAudit";
|
||||
import { createProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { bulkWebSessionImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
requiresWebSessionCredential,
|
||||
getWebSessionCredentialRequirement,
|
||||
hasUsableWebSessionCredential,
|
||||
} from "@/shared/providers/webSessionCredentials";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const auditContext = getAuditRequestContext(request);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(bulkWebSessionImportSchema, body);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const { provider, entries, priority, globalPriority } = validation.data;
|
||||
|
||||
if (!requiresWebSessionCredential(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Provider '${provider}' does not require web-session credentials` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const requirement = getWebSessionCredentialRequirement(provider);
|
||||
if (!requirement || requirement.kind === "none") {
|
||||
return NextResponse.json(
|
||||
{ error: `Provider '${provider}' has no credential requirement` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const errors: Array<{ index: number; name: string; message: string }> = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
try {
|
||||
const providerSpecificData = buildProviderSpecificData(
|
||||
requirement,
|
||||
entry.credential
|
||||
);
|
||||
|
||||
if (!hasUsableWebSessionCredential(provider, providerSpecificData)) {
|
||||
throw new Error(
|
||||
`Credential does not match expected format for ${requirement.credentialName}`
|
||||
);
|
||||
}
|
||||
|
||||
const newConnection = await createProviderConnection({
|
||||
provider,
|
||||
authType: "cookie",
|
||||
name: entry.name,
|
||||
apiKey: null,
|
||||
priority: priority || 1,
|
||||
globalPriority: globalPriority || null,
|
||||
defaultModel: null,
|
||||
providerSpecificData,
|
||||
isActive: true,
|
||||
testStatus: "unknown",
|
||||
});
|
||||
|
||||
const safe: Record<string, unknown> = { ...newConnection };
|
||||
delete safe.apiKey;
|
||||
if (safe.providerSpecificData) {
|
||||
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(
|
||||
safe.providerSpecificData as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
created.push(safe);
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.created",
|
||||
actor: "admin",
|
||||
target: getProviderAuditTarget(newConnection),
|
||||
resourceType: "provider_credentials",
|
||||
status: "success",
|
||||
ipAddress: auditContext.ipAddress || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider,
|
||||
via: "bulk_web_session",
|
||||
connection: summarizeProviderConnectionForAudit(newConnection),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
index: i,
|
||||
name: entry.name,
|
||||
message: sanitizeErrorMessage(err) || "Failed to create connection",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (created.length > 0) {
|
||||
await syncToCloudIfEnabled();
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.bulk_imported",
|
||||
actor: "admin",
|
||||
resourceType: "provider_credentials",
|
||||
status: errors.length === entries.length ? "failure" : "success",
|
||||
ipAddress: auditContext.ipAddress || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider,
|
||||
via: "bulk_web_session",
|
||||
total: entries.length,
|
||||
success: created.length,
|
||||
failed: errors.length,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: created.length,
|
||||
failed: errors.length,
|
||||
total: entries.length,
|
||||
created,
|
||||
errors,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
function buildProviderSpecificData(
|
||||
requirement: ReturnType<typeof getWebSessionCredentialRequirement>,
|
||||
credential: string
|
||||
): Record<string, unknown> {
|
||||
if (!requirement || requirement.kind === "none") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (requirement.kind === "cookie") {
|
||||
data.cookie = credential;
|
||||
for (const key of requirement.storageKeys) {
|
||||
if (key !== "cookie") {
|
||||
data[key] = credential;
|
||||
}
|
||||
}
|
||||
} else if (requirement.kind === "token") {
|
||||
for (const key of requirement.storageKeys) {
|
||||
data[key] = credential;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
// cloud sync is best-effort — ignore errors
|
||||
}
|
||||
}
|
||||
@@ -355,6 +355,23 @@ export const bulkCreateProviderSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
// ──── Bulk Web-Session Import Schema ────
|
||||
|
||||
export const bulkWebSessionImportSchema = z.object({
|
||||
provider: z.string().min(1).max(100),
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
credential: z.string().min(1).max(64 * 1024, "Credential must be under 64 KB"),
|
||||
})
|
||||
)
|
||||
.min(1, "entries must contain at least 1 item")
|
||||
.max(50, "entries must contain at most 50 items"),
|
||||
priority: z.number().int().min(1).max(100).optional(),
|
||||
globalPriority: z.number().int().min(1).max(100).nullable().optional(),
|
||||
});
|
||||
|
||||
// ──── Codex Import Schema ────
|
||||
|
||||
export const importCodexAuthSchema = z.object({
|
||||
|
||||
172
tests/unit/bulk-web-session-import.test.ts
Normal file
172
tests/unit/bulk-web-session-import.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Bulk Web-Session Import — Unit Tests (PR6 of issue #3368)
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/bulk-web-session-import.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { bulkWebSessionImportSchema } from "../../src/shared/validation/schemas.ts";
|
||||
import {
|
||||
requiresWebSessionCredential,
|
||||
getWebSessionCredentialRequirement,
|
||||
hasUsableWebSessionCredential,
|
||||
} from "../../src/shared/providers/webSessionCredentials.ts";
|
||||
|
||||
describe("bulkWebSessionImportSchema", () => {
|
||||
it("accepts valid input with single entry", () => {
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "chatgpt-web",
|
||||
entries: [{ name: "Account 1", credential: "__Secure-next-auth.session-token=abc123" }],
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
it("accepts valid input with multiple entries", () => {
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "grok-web",
|
||||
entries: [
|
||||
{ name: "Account 1", credential: "sso=abc; sso-rw=def" },
|
||||
{ name: "Account 2", credential: "sso=ghi; sso-rw=jkl" },
|
||||
{ name: "Account 3", credential: "sso=mno; sso-rw=pqr" },
|
||||
],
|
||||
priority: 5,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
it("accepts optional globalPriority as null", () => {
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "claude-web",
|
||||
entries: [{ name: "A1", credential: "sessionKey=xyz" }],
|
||||
globalPriority: null,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
it("rejects empty entries array", () => {
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "chatgpt-web",
|
||||
entries: [],
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("rejects more than 50 entries", () => {
|
||||
const entries = Array.from({ length: 51 }, (_, i) => ({
|
||||
name: `Account ${i}`,
|
||||
credential: "cookie=value",
|
||||
}));
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "chatgpt-web",
|
||||
entries,
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("rejects entry with empty credential", () => {
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "chatgpt-web",
|
||||
entries: [{ name: "Account 1", credential: "" }],
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("rejects entry with empty name", () => {
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "chatgpt-web",
|
||||
entries: [{ name: "", credential: "cookie=value" }],
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("rejects missing provider", () => {
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
entries: [{ name: "A1", credential: "cookie=value" }],
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
it("rejects priority out of range", () => {
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "chatgpt-web",
|
||||
entries: [{ name: "A1", credential: "cookie=value" }],
|
||||
priority: 0,
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
|
||||
const result2 = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "chatgpt-web",
|
||||
entries: [{ name: "A1", credential: "cookie=value" }],
|
||||
priority: 101,
|
||||
});
|
||||
assert.equal(result2.success, false);
|
||||
});
|
||||
|
||||
it("accepts exactly 50 entries (boundary)", () => {
|
||||
const entries = Array.from({ length: 50 }, (_, i) => ({
|
||||
name: `Account ${i}`,
|
||||
credential: "cookie=value",
|
||||
}));
|
||||
const result = bulkWebSessionImportSchema.safeParse({
|
||||
provider: "chatgpt-web",
|
||||
entries,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web-session credential helpers", () => {
|
||||
it("requiresWebSessionCredential returns true for web-cookie providers", () => {
|
||||
assert.equal(requiresWebSessionCredential("chatgpt-web"), true);
|
||||
assert.equal(requiresWebSessionCredential("grok-web"), true);
|
||||
assert.equal(requiresWebSessionCredential("claude-web"), true);
|
||||
assert.equal(requiresWebSessionCredential("deepseek-web"), true);
|
||||
});
|
||||
|
||||
it("requiresWebSessionCredential returns false for non-web providers", () => {
|
||||
assert.equal(requiresWebSessionCredential("openai"), false);
|
||||
assert.equal(requiresWebSessionCredential("anthropic"), false);
|
||||
assert.equal(requiresWebSessionCredential("nonexistent"), false);
|
||||
});
|
||||
|
||||
it("getWebSessionCredentialRequirement returns correct kind for cookie providers", () => {
|
||||
const req = getWebSessionCredentialRequirement("chatgpt-web");
|
||||
assert.ok(req);
|
||||
assert.equal(req.kind, "cookie");
|
||||
});
|
||||
|
||||
it("getWebSessionCredentialRequirement returns correct kind for token providers", () => {
|
||||
const req = getWebSessionCredentialRequirement("deepseek-web");
|
||||
assert.ok(req);
|
||||
assert.equal(req.kind, "token");
|
||||
});
|
||||
|
||||
it("hasUsableWebSessionCredential validates cookie data correctly", () => {
|
||||
assert.equal(
|
||||
hasUsableWebSessionCredential("chatgpt-web", {
|
||||
cookie: "__Secure-next-auth.session-token=abc",
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
hasUsableWebSessionCredential("chatgpt-web", { cookie: "" }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
hasUsableWebSessionCredential("chatgpt-web", {}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("hasUsableWebSessionCredential validates token data correctly", () => {
|
||||
assert.equal(
|
||||
hasUsableWebSessionCredential("deepseek-web", { token: "my-token" }),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
hasUsableWebSessionCredential("deepseek-web", { token: " " }),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user