feat: add bulk web-session credential import endpoint (#3403)

Integrated into release/v3.8.16
This commit is contained in:
Paijo
2026-06-08 11:24:27 +07:00
committed by GitHub
parent c9663d4f84
commit 71f6e8d312
3 changed files with 374 additions and 0 deletions

View 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
}
}

View File

@@ -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({

View 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
);
});
});