feat(codex): bulk import Codex auth.json — multi-file, paste, ZIP

Adds three input modes for importing multiple Codex accounts at once,
all feeding a single partial-failure backend endpoint.

- `codexAuthZipExtract.ts`: safe ZIP extraction via fflate — rejects
  path traversal (../ and absolute paths), per-file 256 KB cap, 10 MB
  total cap, max 50 .json entries
- `POST /api/providers/codex-auth/zip-extract`: server-side ZIP
  extraction returning [{name, json, parseError}] to the client
- `POST /api/providers/codex-auth/import-bulk`: iterates entries,
  partial-failure semantics (always 200), per-entry audit log
  `provider.credentials.imported` + summary `bulk_imported`
- `importCodexAuthBulkSchema`: max 50 entries, email validation
- `<ImportCodexAuthModal>`: Single/Bulk top tabs; Bulk has Upload
  files, Paste list (JSON array or --- separator), ZIP sub-modes;
  live entry preview list; overwrite checkbox; result panel
- Install `fflate@0.8.3` (pure TypeScript, zero native deps)
- 29 unit tests: 12 ZIP safety cases + 17 schema/parser/shape cases
This commit is contained in:
diegosouzapw
2026-05-17 16:50:32 -03:00
parent 02564d5a5b
commit 25f3fe1ac5
10 changed files with 1291 additions and 0 deletions

7
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"csv-stringify": "^6.7.0",
"express": "^5.2.1",
"fetch-socks": "^1.3.3",
"fflate": "^0.8.3",
"fuse.js": "^7.3.0",
"gray-matter": "^4.0.3",
"http-proxy-middleware": "^4.0.0",
@@ -9177,6 +9178,12 @@
"undici": ">=7"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",

View File

@@ -145,6 +145,7 @@
"csv-stringify": "^6.7.0",
"express": "^5.2.1",
"fetch-socks": "^1.3.3",
"fflate": "^0.8.3",
"fuse.js": "^7.3.0",
"gray-matter": "^4.0.3",
"http-proxy-middleware": "^4.0.0",

View File

@@ -1059,6 +1059,7 @@ export default function ProviderDetailPage() {
);
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
const [importCodexModalOpen, setImportCodexModalOpen] = useState(false);
const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false);
const [savingCodexGlobalFastServiceTier, setSavingCodexGlobalFastServiceTier] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -3339,6 +3340,17 @@ export default function ProviderDetailPage() {
Experimental OAuth
</Button>
)}
{providerId === "codex" && (
<Button
variant="secondary"
icon="upload_file"
onClick={() => setImportCodexModalOpen(true)}
>
{typeof t.has === "function" && t.has("importCodexAuth")
? t("importCodexAuth")
: "Import auth"}
</Button>
)}
</>
)}
</div>
@@ -3787,6 +3799,17 @@ export default function ProviderDetailPage() {
isCcCompatible={isCcCompatible}
/>
)}
{/* Codex Import Auth Modal */}
{providerId === "codex" && importCodexModalOpen && (
<ImportCodexAuthModal
key="import-codex-modal"
onClose={() => setImportCodexModalOpen(false)}
onSuccess={() => {
setImportCodexModalOpen(false);
fetchData();
}}
/>
)}
{/* Batch Test Results Modal */}
{batchTestResults && (
<div
@@ -6972,6 +6995,643 @@ function AddApiKeyModal({
);
}
// ──── ImportCodexAuthModal ────────────────────────────────────────────────────
interface ImportCodexAuthModalProps {
onClose: () => void;
onSuccess: () => void;
}
type ImportTopTab = "single" | "bulk";
type BulkSubMode = "upload" | "paste" | "zip";
interface BulkEntry {
name: string;
json: unknown;
parseError: string | null;
email: string | null;
}
function extractEmailFromJwtLocal(idToken: string): string | null {
try {
const parts = idToken.split(".");
if (parts.length !== 3) return null;
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
return typeof payload.email === "string" ? payload.email : null;
} catch {
return null;
}
}
function previewCodexJson(json: unknown): { valid: boolean; email: string | null } {
try {
const doc = json && typeof json === "object" ? (json as Record<string, unknown>) : null;
if (!doc || doc.auth_mode !== "chatgpt") return { valid: false, email: null };
const tokens =
doc.tokens && typeof doc.tokens === "object" ? (doc.tokens as Record<string, unknown>) : null;
if (!tokens?.id_token || typeof tokens.id_token !== "string")
return { valid: false, email: null };
return { valid: true, email: extractEmailFromJwtLocal(tokens.id_token as string) };
} catch {
return { valid: false, email: null };
}
}
function parseBulkPasteText(text: string): BulkEntry[] {
const trimmed = text.trim();
if (!trimmed) return [];
const tryParse = (s: string): BulkEntry => {
try {
const json = JSON.parse(s);
const { email } = previewCodexJson(json);
return { name: email || "unknown", json, parseError: null, email };
} catch {
return { name: "parse error", json: null, parseError: "Invalid JSON", email: null };
}
};
try {
const arr = JSON.parse(trimmed);
if (Array.isArray(arr))
return arr.map((item) => {
const { email } = previewCodexJson(item);
return { name: email || "unknown", json: item, parseError: null, email };
});
const { email } = previewCodexJson(arr);
return [{ name: email || "unknown", json: arr, parseError: null, email }];
} catch {
return trimmed
.split(/^---$/m)
.map((s) => tryParse(s.trim()))
.filter((e) => e.json !== null || e.parseError !== null);
}
}
function ImportCodexAuthModal({ onClose, onSuccess }: ImportCodexAuthModalProps) {
const t = useTranslations("providers");
const notify = useNotificationStore();
// Top-level tab: Single / Bulk
const [topTab, setTopTab] = useState<ImportTopTab>("single");
// ── Single mode state ──
const [singleTab, setSingleTab] = useState<"upload" | "paste">("upload");
const [singleParsedJson, setSingleParsedJson] = useState<unknown>(null);
const [singleParseError, setSingleParseError] = useState<string | null>(null);
const [singleDetectedEmail, setSingleDetectedEmail] = useState<string | null>(null);
const [singlePasteText, setSinglePasteText] = useState("");
const [singleName, setSingleName] = useState("");
const [singleEmail, setSingleEmail] = useState("");
const [singleOverwrite, setSingleOverwrite] = useState(false);
const [singleLoading, setSingleLoading] = useState(false);
const [singleError, setSingleError] = useState<string | null>(null);
// ── Bulk mode state ──
const [bulkMode, setBulkMode] = useState<BulkSubMode>("upload");
const [bulkEntries, setBulkEntries] = useState<BulkEntry[]>([]);
const [bulkPasteText, setBulkPasteText] = useState("");
const [bulkZipExtracting, setBulkZipExtracting] = useState(false);
const [bulkZipError, setBulkZipError] = useState<string | null>(null);
const [bulkOverwrite, setBulkOverwrite] = useState(false);
const [bulkLoading, setBulkLoading] = useState(false);
const [bulkResult, setBulkResult] = useState<{
success: number;
failed: number;
errors: { index: number; name: string; message: string }[];
} | null>(null);
// ── Single helpers ──
function handleSinglePreview(json: unknown) {
setSingleParseError(null);
setSingleDetectedEmail(null);
setSingleParsedJson(null);
const { valid, email } = previewCodexJson(json);
if (!valid) {
setSingleParseError(t("codexImportInvalidShape") || "Not a valid Codex auth.json");
return;
}
setSingleDetectedEmail(email);
if (email && !singleEmail) setSingleEmail(email);
setSingleParsedJson(json);
}
function handleSingleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
handleSinglePreview(JSON.parse(ev.target?.result as string));
} catch {
setSingleParseError(t("codexImportInvalidJson") || "Could not parse JSON");
}
};
reader.readAsText(file);
}
function handleSinglePasteChange(text: string) {
setSinglePasteText(text);
if (!text.trim()) {
setSingleParsedJson(null);
setSingleParseError(null);
setSingleDetectedEmail(null);
return;
}
try {
handleSinglePreview(JSON.parse(text));
} catch {
setSingleParseError(t("codexImportInvalidJson") || "Could not parse JSON");
setSingleParsedJson(null);
}
}
async function handleSingleSubmit() {
if (!singleParsedJson) return;
setSingleLoading(true);
setSingleError(null);
try {
const res = await fetch("/api/providers/codex-auth/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: { kind: "json", json: singleParsedJson },
name: singleName.trim() || undefined,
email: singleEmail.trim() || undefined,
overwriteExisting: singleOverwrite,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setSingleError(
data.code === "duplicate_account"
? t("codexImportDuplicate") ||
"Account already exists — enable Replace existing to overwrite"
: data.error || t("codexImportFailed") || "Failed to import"
);
return;
}
notify.success(t("codexImportSuccess") || "Codex connection imported successfully");
onSuccess();
} catch {
setSingleError(t("codexImportFailed") || "Failed to import Codex auth");
} finally {
setSingleLoading(false);
}
}
// ── Bulk helpers ──
function handleBulkFilesChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
const entries: BulkEntry[] = [];
let pending = files.length;
if (pending === 0) return;
files.forEach((file) => {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const json = JSON.parse(ev.target?.result as string);
const { email } = previewCodexJson(json);
entries.push({
name: email || file.name.replace(".json", ""),
json,
parseError: null,
email,
});
} catch {
entries.push({ name: file.name, json: null, parseError: "Invalid JSON", email: null });
}
if (--pending === 0) setBulkEntries([...entries]);
};
reader.readAsText(file);
});
}
function handleBulkPasteChange(text: string) {
setBulkPasteText(text);
if (!text.trim()) {
setBulkEntries([]);
return;
}
setBulkEntries(parseBulkPasteText(text));
}
async function handleZipUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setBulkZipExtracting(true);
setBulkZipError(null);
setBulkEntries([]);
try {
const res = await fetch("/api/providers/codex-auth/zip-extract", {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: file,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setBulkZipError(data.error || t("codexImportBulkZipError") || "Failed to extract ZIP");
return;
}
const extracted: BulkEntry[] = (data.entries || []).map(
(entry: { name: string; json: unknown; parseError: string | null }) => {
if (entry.parseError)
return { name: entry.name, json: null, parseError: entry.parseError, email: null };
const { email } = previewCodexJson(entry.json);
return {
name: email || entry.name.replace(".json", ""),
json: entry.json,
parseError: null,
email,
};
}
);
setBulkEntries(extracted);
} catch {
setBulkZipError(t("codexImportBulkZipError") || "Failed to extract ZIP");
} finally {
setBulkZipExtracting(false);
}
}
async function handleBulkSubmit() {
const validEntries = bulkEntries.filter((e) => !e.parseError && e.json !== null);
if (validEntries.length === 0) return;
setBulkLoading(true);
setBulkResult(null);
try {
const res = await fetch("/api/providers/codex-auth/import-bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entries: validEntries.map((e) => ({
json: e.json,
name: e.name || undefined,
email: e.email || undefined,
})),
overwriteExisting: bulkOverwrite,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify.error(data.error || t("codexImportFailed") || "Failed to import");
return;
}
setBulkResult({ success: data.success, failed: data.failed, errors: data.errors || [] });
if (data.success > 0) onSuccess();
} catch {
notify.error(t("codexImportFailed") || "Failed to import Codex auth");
} finally {
setBulkLoading(false);
}
}
const singleCanSubmit = !!singleParsedJson && !singleParseError && !singleLoading;
const validBulkCount = bulkEntries.filter((e) => !e.parseError && e.json !== null).length;
const bulkCanSubmit = validBulkCount > 0 && !bulkLoading && !bulkZipExtracting;
const TOP_TABS: { id: ImportTopTab; label: string }[] = [
{ id: "single", label: t("codexImportTabSingle") || "Single" },
{ id: "bulk", label: t("codexImportTabBulk") || "Bulk" },
];
const BULK_MODES: { id: BulkSubMode; label: string }[] = [
{ id: "upload", label: t("codexImportBulkModeUpload") || "Upload files" },
{ id: "paste", label: t("codexImportBulkModePaste") || "Paste list" },
{ id: "zip", label: t("codexImportBulkModeZip") || "ZIP archive" },
];
return (
<Modal
isOpen
onClose={onClose}
title={t("codexImportModalTitle") || "Import Codex Auth"}
maxWidth="max-w-lg"
>
<div className="flex flex-col gap-4">
{/* Top-level Single / Bulk tabs */}
<div className="flex border-b border-border">
{TOP_TABS.map(({ id, label }) => (
<button
key={id}
onClick={() => {
setTopTab(id);
setBulkResult(null);
setSingleError(null);
}}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
topTab === id
? "border-primary text-primary"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{label}
</button>
))}
</div>
{/* ── Single tab ── */}
{topTab === "single" && (
<>
{/* Source sub-tabs */}
<div className="flex border-b border-border">
{(["upload", "paste"] as const).map((id) => (
<button
key={id}
onClick={() => {
setSingleTab(id);
setSingleParsedJson(null);
setSingleParseError(null);
setSingleDetectedEmail(null);
}}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
singleTab === id
? "border-primary text-primary"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
{id === "upload"
? t("codexImportTabUpload") || "Upload file"
: t("codexImportTabPaste") || "Paste JSON"}
</button>
))}
</div>
{singleTab === "upload" && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-main">
{t("codexImportFileLabel") || "Choose auth.json"}
</label>
<input
type="file"
accept=".json"
onChange={handleSingleFileChange}
className="text-sm text-text-muted file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-border file:text-xs file:bg-bg-subtle file:text-text-main hover:file:bg-bg-hover cursor-pointer"
/>
<p className="text-xs text-text-muted">
{t("codexImportFileHint") ||
"Select the auth.json file exported from Codex or OmniRoute."}
</p>
</div>
)}
{singleTab === "paste" && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-main">
{t("codexImportPasteLabel") || "Paste the JSON content"}
</label>
<textarea
value={singlePasteText}
onChange={(e) => handleSinglePasteChange(e.target.value)}
rows={7}
placeholder='{ "auth_mode": "chatgpt", ... }'
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-xs font-mono text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 resize-none"
/>
</div>
)}
{singleParseError && <p className="text-sm text-red-500">{singleParseError}</p>}
{singleDetectedEmail && !singleParseError && (
<p className="text-xs text-text-muted">
{t("codexImportDetectedEmail", { email: singleDetectedEmail }) ||
`Detected: ${singleDetectedEmail}`}
</p>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-text-main">
{t("codexImportEmailLabel") || "Account email"}
</label>
<input
type="email"
value={singleEmail}
onChange={(e) => setSingleEmail(e.target.value)}
placeholder="user@example.com"
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<p className="text-xs text-text-muted">
{t("codexImportEmailHint") || "Auto-detected from the file; edit if needed."}
</p>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-text-main">
{t("codexImportNameLabel") || "Connection name (optional)"}
</label>
<input
type="text"
value={singleName}
onChange={(e) => setSingleName(e.target.value)}
placeholder={singleEmail || "Codex (imported)"}
className="rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={singleOverwrite}
onChange={(e) => setSingleOverwrite(e.target.checked)}
className="rounded border-border"
/>
<span className="text-sm text-text-main">
{t("codexImportOverwriteLabel") ||
"Replace existing connection if account already exists"}
</span>
</label>
</div>
{singleError && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
{singleError}
</div>
)}
<div className="flex gap-2 pt-1">
<Button
onClick={handleSingleSubmit}
disabled={!singleCanSubmit}
loading={singleLoading}
fullWidth
>
{t("codexImportSubmit") || "Import"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</>
)}
{/* ── Bulk tab ── */}
{topTab === "bulk" && (
<>
{/* Sub-mode selector */}
<div className="flex gap-1 p-1 bg-bg-subtle rounded-lg">
{BULK_MODES.map(({ id, label }) => (
<button
key={id}
onClick={() => {
setBulkMode(id);
setBulkEntries([]);
setBulkZipError(null);
setBulkPasteText("");
setBulkResult(null);
}}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
bulkMode === id
? "bg-bg-primary text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
}`}
>
{label}
</button>
))}
</div>
{/* Upload mode */}
{bulkMode === "upload" && (
<div className="flex flex-col gap-2">
<input
type="file"
accept=".json"
multiple
onChange={handleBulkFilesChange}
className="text-sm text-text-muted file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-border file:text-xs file:bg-bg-subtle file:text-text-main hover:file:bg-bg-hover cursor-pointer"
/>
<p className="text-xs text-text-muted">
{t("codexImportBulkUploadHint") || "Select multiple .json files"}
</p>
</div>
)}
{/* Paste mode */}
{bulkMode === "paste" && (
<div className="flex flex-col gap-2">
<textarea
value={bulkPasteText}
onChange={(e) => handleBulkPasteChange(e.target.value)}
rows={7}
placeholder={'[{ "auth_mode": "chatgpt", ... }, ...]'}
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-xs font-mono text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 resize-none"
/>
<p className="text-xs text-text-muted">
{t("codexImportBulkPasteHint") || "JSON array or multiple JSONs separated by ---"}
</p>
</div>
)}
{/* ZIP mode */}
{bulkMode === "zip" && (
<div className="flex flex-col gap-2">
<input
type="file"
accept=".zip"
onChange={handleZipUpload}
disabled={bulkZipExtracting}
className="text-sm text-text-muted file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-border file:text-xs file:bg-bg-subtle file:text-text-main hover:file:bg-bg-hover cursor-pointer disabled:opacity-50"
/>
{bulkZipExtracting && (
<p className="text-xs text-text-muted animate-pulse">
{t("codexImportBulkZipExtracting") || "Extracting ZIP…"}
</p>
)}
{bulkZipError && <p className="text-sm text-red-500">{bulkZipError}</p>}
<p className="text-xs text-text-muted">
{t("codexImportBulkZipHint") ||
"Upload a .zip containing auth.json files (max 50 files, 10 MB)"}
</p>
</div>
)}
{/* Entry preview list */}
{bulkEntries.length > 0 && !bulkResult && (
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto rounded-lg border border-border bg-bg-subtle p-2">
<p className="text-xs font-medium text-text-muted px-1">
{validBulkCount} / {bulkEntries.length} valid
</p>
{bulkEntries.map((entry, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1 rounded">
<span
className={`material-symbols-outlined text-[14px] ${entry.parseError ? "text-red-500" : "text-emerald-500"}`}
>
{entry.parseError ? "error" : "check_circle"}
</span>
<span className="text-xs text-text-main flex-1 truncate">{entry.name}</span>
{entry.parseError && (
<span className="text-xs text-red-400">{entry.parseError}</span>
)}
</div>
))}
</div>
)}
{/* Overwrite checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={bulkOverwrite}
onChange={(e) => setBulkOverwrite(e.target.checked)}
className="rounded border-border"
/>
<span className="text-sm text-text-main">
{t("codexImportOverwriteLabel") ||
"Replace existing connections if accounts already exist"}
</span>
</label>
{/* Result panel */}
{bulkResult && (
<div
className={`rounded-lg border px-4 py-3 text-sm ${
bulkResult.failed === 0
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
: "bg-amber-500/10 border-amber-500/20 text-amber-400"
}`}
>
<p className="font-medium">
{bulkResult.success}{" "}
{t("codexImportBulkSuccess", { count: bulkResult.success }) || "imported"} ·{" "}
{bulkResult.failed}{" "}
{t("codexImportBulkFailed", { count: bulkResult.failed }) || "failed"}
</p>
{bulkResult.errors.length > 0 && (
<ul className="mt-2 space-y-0.5 text-xs">
{bulkResult.errors.map((e, i) => (
<li key={i}>
<span className="font-medium">{e.name}:</span> {e.message}
</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-2 pt-1">
<Button
onClick={handleBulkSubmit}
disabled={!bulkCanSubmit}
loading={bulkLoading}
fullWidth
>
{bulkLoading
? t("saving") || "Importing…"
: typeof t.has === "function" && t.has("codexImportBulkSubmit")
? t("codexImportBulkSubmit", { count: validBulkCount })
: `Import ${validBulkCount} accounts`}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</>
)}
</div>
</Modal>
);
}
function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl;
try {

View File

@@ -0,0 +1,112 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
import {
parseAndValidateCodexAuth,
createConnectionFromAuthFile,
} from "@/lib/oauth/utils/codexAuthImport";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { getProviderAuditTarget } from "@/lib/compliance/providerAudit";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { importCodexAuthBulkSchema } from "@/shared/validation/schemas";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
function sanitizeConnectionForResponse(connection: Record<string, unknown>) {
const safe = { ...connection };
delete safe.accessToken;
delete safe.refreshToken;
delete safe.idToken;
delete safe.apiKey;
if (safe.providerSpecificData) {
safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData);
}
return safe;
}
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 parsedBody = validateBody(importCodexAuthBulkSchema, body);
if (isValidationFailure(parsedBody)) {
return NextResponse.json({ error: parsedBody.error }, { status: 400 });
}
const { entries, overwriteExisting } = parsedBody.data;
const created: Record<string, unknown>[] = [];
const errors: { index: number; name: string; message: string }[] = [];
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
const label = e.name || `entry ${i + 1}`;
try {
const parsedAuth = parseAndValidateCodexAuth(e.json);
const { connection } = await createConnectionFromAuthFile(parsedAuth, {
name: e.name,
email: e.email,
overwriteExisting,
});
const safe = sanitizeConnectionForResponse(connection as Record<string, unknown>);
created.push(safe);
logAuditEvent({
action: "provider.credentials.imported",
actor: "admin",
target: getProviderAuditTarget(connection),
resourceType: "provider_credentials",
status: "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider: "codex",
email: parsedAuth.email || e.email,
bulkIndex: i,
},
});
} catch (err) {
let message: string;
if (err instanceof CodexAuthFileError) {
message = err.message;
} else {
message = sanitizeErrorMessage(err) || "Failed to import";
}
errors.push({ index: i, name: label, message });
}
}
logAuditEvent({
action: "provider.credentials.bulk_imported",
actor: "admin",
target: "codex",
resourceType: "provider_credentials",
status: errors.length === entries.length ? "failure" : "success",
ipAddress: auditContext.ipAddress || undefined,
requestId: auditContext.requestId,
metadata: {
provider: "codex",
total: entries.length,
success: created.length,
failed: errors.length,
},
});
return NextResponse.json({
success: created.length,
failed: errors.length,
total: entries.length,
created,
errors,
});
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { extractCodexAuthZip } from "@/lib/oauth/utils/codexAuthZipExtract";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const ZIP_BODY_LIMIT = 11 * 1024 * 1024; // 11 MB — slightly above the 10 MB extracted limit
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const contentLength = Number(request.headers.get("content-length") || "0");
if (contentLength > ZIP_BODY_LIMIT) {
return NextResponse.json(
{ error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" },
{ status: 413 }
);
}
let buffer: Buffer;
try {
const arrayBuffer = await request.arrayBuffer();
if (arrayBuffer.byteLength > ZIP_BODY_LIMIT) {
return NextResponse.json(
{ error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" },
{ status: 413 }
);
}
buffer = Buffer.from(arrayBuffer);
} catch {
return NextResponse.json({ error: "Failed to read request body" }, { status: 400 });
}
try {
const files = extractCodexAuthZip(buffer);
const entries = files.map((f) => {
try {
return { name: f.name, json: JSON.parse(f.content), parseError: null };
} catch {
return { name: f.name, json: null, parseError: "Not valid JSON" };
}
});
return NextResponse.json({ entries });
} catch (error) {
return NextResponse.json(
{ error: sanitizeErrorMessage(error) || "Failed to extract ZIP", code: "extract_failed" },
{ status: 400 }
);
}
}

View File

@@ -3106,6 +3106,39 @@
"codexAuthApplyFailed": "Failed to apply Codex auth.json locally",
"codexAuthExported": "Codex auth.json exported",
"codexAuthExportFailed": "Failed to export Codex auth.json",
"importCodexAuth": "Import auth",
"codexImportModalTitle": "Import Codex Auth",
"codexImportTabSingle": "Single",
"codexImportTabBulk": "Bulk",
"codexImportTabUpload": "Upload file",
"codexImportTabPaste": "Paste JSON",
"codexImportFileLabel": "Choose auth.json",
"codexImportFileHint": "Select the auth.json file exported from Codex or from OmniRoute.",
"codexImportPasteLabel": "Paste the JSON content",
"codexImportEmailLabel": "Account email",
"codexImportEmailHint": "Auto-detected from the file; edit if needed.",
"codexImportNameLabel": "Connection name (optional)",
"codexImportOverwriteLabel": "Replace existing connection if account already exists",
"codexImportSubmit": "Import",
"codexImportSuccess": "Codex connection imported successfully",
"codexImportInvalidJson": "Could not parse the file as JSON",
"codexImportInvalidShape": "The file is not a valid Codex auth.json",
"codexImportDuplicate": "Account already exists — enable \"Replace existing\" to overwrite",
"codexImportFailed": "Failed to import Codex auth",
"codexImportDetectedEmail": "Detected: {email}",
"codexImportNoEmailDetected": "No email detected in file",
"codexImportBulkModeUpload": "Upload files",
"codexImportBulkModePaste": "Paste list",
"codexImportBulkModeZip": "ZIP archive",
"codexImportBulkUploadHint": "Select multiple .json files or drag and drop",
"codexImportBulkPasteHint": "JSON array [ {...}, {...} ] or multiple JSONs separated by --- on its own line",
"codexImportBulkZipHint": "Upload a .zip containing auth.json files (max 50 files, 10 MB)",
"codexImportBulkSubmit": "Import {count} accounts",
"codexImportBulkLimit": "Max 50 files per import",
"codexImportBulkSuccess": "{count} imported",
"codexImportBulkFailed": "{count} failed",
"codexImportBulkZipExtracting": "Extracting ZIP…",
"codexImportBulkZipError": "Failed to extract ZIP",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",

View File

@@ -0,0 +1,89 @@
import path from "path";
import { unzipSync, type Unzipped } from "fflate";
export interface ExtractedZipFile {
name: string;
content: string;
}
export interface ExtractZipOptions {
maxFiles?: number;
maxFileSizeBytes?: number;
maxTotalSizeBytes?: number;
}
const DEFAULT_MAX_FILES = 50;
const DEFAULT_MAX_FILE_SIZE = 256 * 1024;
const DEFAULT_MAX_TOTAL = 10 * 1024 * 1024;
function isSafeEntryName(name: string): boolean {
if (!name.toLowerCase().endsWith(".json")) return false;
if (name.includes("..")) return false;
if (path.isAbsolute(name)) return false;
if (/[\r\n\0]/.test(name)) return false;
return true;
}
export function extractCodexAuthZip(
zipBuffer: Buffer,
options: ExtractZipOptions = {}
): ExtractedZipFile[] {
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
const maxFileSize = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
const maxTotal = options.maxTotalSizeBytes ?? DEFAULT_MAX_TOTAL;
let unzipped: Unzipped;
try {
unzipped = unzipSync(new Uint8Array(zipBuffer));
} catch {
throw new Error("Could not parse ZIP archive — file may be corrupt or not a valid ZIP");
}
const entries = Object.entries(unzipped).filter(([, data]) => data !== undefined);
const jsonEntries = entries.filter(([name]) => name.toLowerCase().endsWith(".json"));
if (jsonEntries.length === 0) {
throw new Error("ZIP archive contains no .json files");
}
if (jsonEntries.length > maxFiles) {
throw new Error(
`ZIP archive contains ${jsonEntries.length} .json files — max allowed is ${maxFiles}`
);
}
let totalBytes = 0;
const result: ExtractedZipFile[] = [];
for (const [entryName, data] of jsonEntries) {
const baseName = path.basename(entryName);
if (!isSafeEntryName(baseName)) {
throw new Error(
`ZIP entry "${baseName}" has an unsafe filename (must be a .json file without path traversal)`
);
}
if (!isSafeEntryName(entryName)) {
throw new Error(
`ZIP entry path "${entryName}" is unsafe (no "..", absolute paths, or control characters allowed)`
);
}
if (data.byteLength > maxFileSize) {
throw new Error(
`ZIP entry "${baseName}" is ${data.byteLength} bytes — exceeds ${maxFileSize} byte limit per file`
);
}
totalBytes += data.byteLength;
if (totalBytes > maxTotal) {
throw new Error(`ZIP archive total uncompressed size exceeds ${maxTotal} byte limit`);
}
const content = new TextDecoder("utf-8").decode(data);
result.push({ name: baseName, content });
}
return result;
}

View File

@@ -322,6 +322,22 @@ export const bulkCreateProviderSchema = z
}
});
// ──── Codex Import Bulk Schema ────
export const importCodexAuthBulkSchema = z.object({
entries: z
.array(
z.object({
json: z.unknown(),
name: z.string().min(1).max(200).optional(),
email: z.string().email("Must be a valid email").optional(),
})
)
.min(1, "At least one entry is required")
.max(50, "At most 50 entries per bulk import"),
overwriteExisting: z.boolean().optional(),
});
// ──── API Key Schemas ────
export const createKeySchema = z.object({

View File

@@ -0,0 +1,187 @@
import test from "node:test";
import assert from "node:assert/strict";
import { z } from "zod";
// Local copy of importCodexAuthBulkSchema — avoids importing Next.js deps.
const importCodexAuthBulkSchema = z.object({
entries: z
.array(
z.object({
json: z.unknown(),
name: z.string().min(1).max(200).optional(),
email: z.string().email().optional(),
})
)
.min(1)
.max(50),
overwriteExisting: z.boolean().optional(),
});
function parse(body: unknown) {
return importCodexAuthBulkSchema.safeParse(body);
}
// ──── Schema tests ────────────────────────────────────────────────────────────
test("bulk schema: valid single entry passes", () => {
const result = parse({ entries: [{ json: { auth_mode: "chatgpt" } }] });
assert.ok(result.success);
});
test("bulk schema: valid multiple entries pass", () => {
const result = parse({
entries: [
{ json: {}, name: "Account A", email: "a@example.com" },
{ json: {}, name: "Account B" },
],
overwriteExisting: true,
});
assert.ok(result.success);
assert.equal(result.data.entries.length, 2);
assert.equal(result.data.overwriteExisting, true);
});
test("bulk schema: empty entries array fails", () => {
const result = parse({ entries: [] });
assert.ok(!result.success);
});
test("bulk schema: missing entries fails", () => {
const result = parse({});
assert.ok(!result.success);
});
test("bulk schema: 50 entries passes", () => {
const entries = Array.from({ length: 50 }, (_, i) => ({ json: { index: i } }));
const result = parse({ entries });
assert.ok(result.success);
});
test("bulk schema: 51 entries fails", () => {
const entries = Array.from({ length: 51 }, (_, i) => ({ json: { index: i } }));
const result = parse({ entries });
assert.ok(!result.success);
});
test("bulk schema: invalid email in entry fails", () => {
const result = parse({
entries: [{ json: {}, email: "not-an-email" }],
});
assert.ok(!result.success);
const emailIssue = result.error.issues.find((i) => i.path.some((p) => p === "email"));
assert.ok(emailIssue);
});
test("bulk schema: empty name in entry fails", () => {
const result = parse({ entries: [{ json: {}, name: "" }] });
assert.ok(!result.success);
});
test("bulk schema: overwriteExisting must be boolean", () => {
const result = parse({
entries: [{ json: {} }],
overwriteExisting: "yes",
});
assert.ok(!result.success);
});
// ──── Paste-list parser tests ─────────────────────────────────────────────────
// Client-side paste parser logic (mirrors what the UI does before submitting)
function parsePasteList(text: string): Array<{ json: unknown; parseError: string | null }> {
const trimmed = text.trim();
if (!trimmed) return [];
// Try as a JSON array first
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((item) => ({ json: item, parseError: null }));
}
// Single object
return [{ json: parsed, parseError: null }];
} catch {
// Fall back to --- separator
const parts = trimmed
.split(/^---$/m)
.map((s) => s.trim())
.filter(Boolean);
return parts.map((part) => {
try {
return { json: JSON.parse(part), parseError: null };
} catch {
return { json: null, parseError: "Invalid JSON" };
}
});
}
}
test("paste parser: JSON array returns all items", () => {
const text = JSON.stringify([{ auth_mode: "chatgpt" }, { auth_mode: "chatgpt" }]);
const result = parsePasteList(text);
assert.equal(result.length, 2);
assert.ok(result.every((r) => r.parseError === null));
});
test("paste parser: single JSON object returns one item", () => {
const text = JSON.stringify({ auth_mode: "chatgpt" });
const result = parsePasteList(text);
assert.equal(result.length, 1);
assert.equal(result[0].parseError, null);
});
test("paste parser: --- separator splits multiple JSONs", () => {
const a = JSON.stringify({ auth_mode: "chatgpt", index: 1 });
const b = JSON.stringify({ auth_mode: "chatgpt", index: 2 });
const text = `${a}\n---\n${b}`;
const result = parsePasteList(text);
assert.equal(result.length, 2);
assert.ok(result.every((r) => r.parseError === null));
});
test("paste parser: invalid JSON in --- section marked as error", () => {
const good = JSON.stringify({ auth_mode: "chatgpt" });
const text = `${good}\n---\nnot valid json`;
const result = parsePasteList(text);
assert.equal(result.length, 2);
assert.equal(result[0].parseError, null);
assert.ok(result[1].parseError !== null);
});
test("paste parser: empty string returns empty array", () => {
assert.equal(parsePasteList("").length, 0);
assert.equal(parsePasteList(" ").length, 0);
});
// ──── Partial-failure response shape ──────────────────────────────────────────
test("bulk response shape: partial success has correct structure", () => {
const response = {
success: 2,
failed: 1,
total: 3,
created: [{ id: "a" }, { id: "b" }],
errors: [{ index: 2, name: "entry 3", message: "duplicate_account" }],
};
assert.equal(response.success + response.failed, response.total);
assert.equal(response.created.length, response.success);
assert.equal(response.errors.length, response.failed);
assert.ok("index" in response.errors[0]);
assert.ok("name" in response.errors[0]);
assert.ok("message" in response.errors[0]);
});
test("bulk response shape: all-failure has status failed", () => {
const entries = 3;
const failed = 3;
const status = failed === entries ? "failure" : "success";
assert.equal(status, "failure");
});
test("bulk response shape: partial failure has status success", () => {
const entries = 3;
const failed = 1;
const status = failed === entries ? "failure" : "success";
assert.equal(status, "success");
});

View File

@@ -0,0 +1,134 @@
import test from "node:test";
import assert from "node:assert/strict";
import { zipSync, strToU8 } from "fflate";
// Mirror the safety logic from codexAuthZipExtract.ts so we can test without
// importing the module (which is fine since it has no external DB deps, but
// we test the pure logic to keep the test surface clear).
interface ExtractedZipFile {
name: string;
content: string;
}
interface ExtractZipOptions {
maxFiles?: number;
maxFileSizeBytes?: number;
maxTotalSizeBytes?: number;
}
// Local re-implementation of the exported function to exercise it without
// importing Node-only code in the test runner.
import { extractCodexAuthZip } from "../../src/lib/oauth/utils/codexAuthZipExtract.ts";
// ──── Helpers ─────────────────────────────────────────────────────────────────
function makeZip(files: Record<string, string>): Buffer {
const entries: Record<string, Uint8Array> = {};
for (const [name, content] of Object.entries(files)) {
entries[name] = strToU8(content);
}
return Buffer.from(zipSync(entries));
}
const VALID_AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: {}, OPENAI_API_KEY: null });
// ──── Tests ───────────────────────────────────────────────────────────────────
test("extractCodexAuthZip: happy path — returns all .json entries", () => {
const zip = makeZip({
"auth-a.json": VALID_AUTH,
"auth-b.json": VALID_AUTH,
"auth-c.json": VALID_AUTH,
});
const files = extractCodexAuthZip(zip);
assert.equal(files.length, 3);
const names = files.map((f) => f.name).sort();
assert.deepEqual(names, ["auth-a.json", "auth-b.json", "auth-c.json"]);
});
test("extractCodexAuthZip: ignores non-.json entries", () => {
const zip = makeZip({
"auth-a.json": VALID_AUTH,
"README.md": "# Readme",
"config.yaml": "key: value",
});
const files = extractCodexAuthZip(zip);
assert.equal(files.length, 1);
assert.equal(files[0].name, "auth-a.json");
});
test("extractCodexAuthZip: rejects archive with no .json files", () => {
const zip = makeZip({ "README.md": "# Readme" });
assert.throws(() => extractCodexAuthZip(zip), /no \.json files/i);
});
test("extractCodexAuthZip: rejects entry with .. in path", () => {
const zip = makeZip({ "../../../etc/passwd.json": '{"evil":true}' });
assert.throws(() => extractCodexAuthZip(zip), /unsafe/i);
});
test("extractCodexAuthZip: rejects absolute path entries", () => {
const zip = makeZip({ "/etc/passwd.json": '{"evil":true}' });
assert.throws(() => extractCodexAuthZip(zip), /unsafe/i);
});
test("extractCodexAuthZip: rejects archive exceeding max file count", () => {
const files: Record<string, string> = {};
for (let i = 0; i <= 50; i++) {
files[`auth-${i}.json`] = VALID_AUTH;
}
const zip = makeZip(files);
assert.throws(() => extractCodexAuthZip(zip), /max allowed is 50/i);
});
test("extractCodexAuthZip: respects custom maxFiles option", () => {
const zip = makeZip({
"auth-a.json": VALID_AUTH,
"auth-b.json": VALID_AUTH,
"auth-c.json": VALID_AUTH,
});
assert.throws(() => extractCodexAuthZip(zip, { maxFiles: 2 }), /max allowed is 2/i);
});
test("extractCodexAuthZip: rejects individual file exceeding per-file cap", () => {
const bigContent = "x".repeat(257 * 1024);
const zip = makeZip({ "big.json": bigContent });
assert.throws(() => extractCodexAuthZip(zip, { maxFileSizeBytes: 256 * 1024 }), /exceeds/i);
});
test("extractCodexAuthZip: rejects total size exceeding cap", () => {
// Each chunk is 4 MB — under the per-file override (5 MB) but 3 × 4 MB = 12 MB > 10 MB total
const chunk = "x".repeat(4 * 1024 * 1024);
const zip = makeZip({
"a.json": chunk,
"b.json": chunk,
"c.json": chunk,
});
assert.throws(
() =>
extractCodexAuthZip(zip, {
maxFileSizeBytes: 5 * 1024 * 1024,
maxTotalSizeBytes: 10 * 1024 * 1024,
}),
/total/i
);
});
test("extractCodexAuthZip: content is returned as UTF-8 string", () => {
const content = JSON.stringify({ hello: "wörld 🌍" });
const zip = makeZip({ "auth.json": content });
const files = extractCodexAuthZip(zip);
assert.equal(files[0].content, content);
});
test("extractCodexAuthZip: uses basename from nested path entries", () => {
const zip = makeZip({ "subdir/auth-nested.json": VALID_AUTH });
const files = extractCodexAuthZip(zip);
assert.equal(files[0].name, "auth-nested.json");
});
test("extractCodexAuthZip: rejects corrupt / non-ZIP buffer", () => {
const notAZip = Buffer.from("this is not a zip file");
assert.throws(() => extractCodexAuthZip(notAZip), /could not parse zip/i);
});