diff --git a/src/app/api/providers/bulk-web-session/route.ts b/src/app/api/providers/bulk-web-session/route.ts new file mode 100644 index 0000000000..48196ab1a5 --- /dev/null +++ b/src/app/api/providers/bulk-web-session/route.ts @@ -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> = []; + 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 = { ...newConnection }; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse( + safe.providerSpecificData as Record + ); + } + 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, + credential: string +): Record { + if (!requirement || requirement.kind === "none") { + return {}; + } + + const data: Record = {}; + + 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 + } +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index df33ca90c2..07020923dc 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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({ diff --git a/tests/unit/bulk-web-session-import.test.ts b/tests/unit/bulk-web-session-import.test.ts new file mode 100644 index 0000000000..881828331d --- /dev/null +++ b/tests/unit/bulk-web-session-import.test.ts @@ -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 + ); + }); +});