diff --git a/docs/adr/0001-proxy-registry-limit-generalization.md b/docs/adr/0001-proxy-registry-limit-generalization.md new file mode 100644 index 00000000..bb7d766e --- /dev/null +++ b/docs/adr/0001-proxy-registry-limit-generalization.md @@ -0,0 +1,46 @@ +# ADR-0001: Proxy Registry + Usage Control Generalization + +Date: 2026-03-17 +Status: Accepted + +## Context + +OmniRoute sudah punya: + +- Proxy assignment berbasis config-map (`global`, `providers`, `combos`, `keys`). +- Quota-aware selection khusus provider tertentu (notably `codex`). + +Gap utama: + +- Proxy belum menjadi aset reusable yang bisa di-manage sebagai entitas (metadata, where-used, safe delete). +- Usage policy belum konsisten lintas provider. +- Error contract API belum seragam untuk endpoint manajemen. + +## Decision + +1. Tambah **Proxy Registry** sebagai domain baru di DB (`proxy_registry`, `proxy_assignments`). +2. Pertahankan kompatibilitas assignment lama (fallback ke `proxyConfig` lama). +3. Resolver runtime pakai prioritas: + - account -> provider -> global (registry) + - fallback ke legacy resolver jika registry belum ada assignment +4. Wajib redaction kredensial di output list registry default. +5. Standarkan error JSON untuk endpoint manajemen proxy agar konsisten dan punya `requestId`. + +## Consequences + +Positif: + +- Proxy reusable dan bisa dilacak pemakaiannya. +- Safe delete bisa ditegakkan (409 saat masih dipakai). +- Migrasi bertahap tanpa breaking change runtime. + +Negatif: + +- Ada dual-source sementara (registry + legacy config) sampai migrasi selesai. +- Butuh endpoint assignment tambahan dan pemetaan scope yang konsisten. + +## Follow-up + +- Migrasi UI provider/account dari input raw proxy ke selector registry. +- Tambah health telemetry per proxy dan alerting. +- Generalisasi usage control ke provider lain melalui interface policy yang sama. diff --git a/docs/adr/0002-api-error-contract-management-endpoints.md b/docs/adr/0002-api-error-contract-management-endpoints.md new file mode 100644 index 00000000..fced830c --- /dev/null +++ b/docs/adr/0002-api-error-contract-management-endpoints.md @@ -0,0 +1,32 @@ +# ADR-0002: Error Contract for Management Endpoints + +Date: 2026-03-17 +Status: Accepted + +## Decision + +Management endpoints (proxy config, proxy registry, and proxy assignments) return a uniform error body: + +```json +{ + "error": { + "message": "Human-readable summary", + "type": "invalid_request | not_found | conflict | server_error", + "details": {} + }, + "requestId": "uuid" +} +``` + +## Status Mapping + +- 400: invalid request / validation failure +- 404: resource not found +- 409: resource conflict (for example, proxy still assigned) +- 500: unexpected server error + +## Notes + +- `requestId` is mandatory for log correlation. +- `details` is optional and only used for safe validation details. +- Sensitive secrets (proxy credentials, tokens) must never appear in `message` or `details`. diff --git a/docs/adr/0003-security-checklist-proxy-limits.md b/docs/adr/0003-security-checklist-proxy-limits.md new file mode 100644 index 00000000..5ff89da6 --- /dev/null +++ b/docs/adr/0003-security-checklist-proxy-limits.md @@ -0,0 +1,16 @@ +# ADR-0003: Security Checklist for Proxy Registry and Usage Controls + +Date: 2026-03-17 +Status: Accepted + +## Checklist + +- Validate all management payloads with Zod. +- Reject malformed scope assignment updates with status 400. +- Reject deleting an in-use proxy with status 409 unless forced. +- Never expose proxy username/password in list responses by default. +- Never log raw credentials or token values. +- Keep error responses free from internal stack traces. +- Protect management endpoints with existing auth middleware policy. +- Audit mutating operations: create/update/delete/assign/migrate. +- Ensure resolver fallback to legacy config while migration is in transition. diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx new file mode 100644 index 00000000..d2431e83 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -0,0 +1,605 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Card, Modal } from "@/shared/components"; + +type ProxyItem = { + id: string; + name: string; + type: string; + host: string; + port: number; + region?: string | null; + notes?: string | null; + status?: string; +}; + +type UsageInfo = { + count: number; + assignments: Array<{ scope: string; scopeId: string | null }>; +}; + +type HealthInfo = { + proxyId: string; + totalRequests: number; + successRate: number | null; + avgLatencyMs: number | null; + lastSeenAt: string | null; +}; + +const EMPTY_FORM = { + id: "", + name: "", + type: "http", + host: "", + port: "8080", + username: "", + password: "", + region: "", + notes: "", + status: "active", +}; + +export default function ProxyRegistryManager() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [modalOpen, setModalOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + + const [usageById, setUsageById] = useState>({}); + const [healthById, setHealthById] = useState>({}); + const [migrating, setMigrating] = useState(false); + const [bulkOpen, setBulkOpen] = useState(false); + const [bulkSaving, setBulkSaving] = useState(false); + const [bulkScope, setBulkScope] = useState("provider"); + const [bulkScopeIds, setBulkScopeIds] = useState(""); + const [bulkProxyId, setBulkProxyId] = useState(""); + + const editingId = useMemo(() => form.id || "", [form.id]); + + const loadHealth = useCallback(async () => { + try { + const res = await fetch("/api/settings/proxies/health?hours=24"); + const data = await res.json().catch(() => ({})); + if (!res.ok) return; + const entries = Array.isArray(data?.items) ? data.items : []; + const mapped = Object.fromEntries( + entries.map((entry: HealthInfo) => [entry.proxyId, entry]) + ) as Record; + setHealthById(mapped); + } catch { + // ignore health loading errors in UI + } + }, []); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/settings/proxies"); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error?.message || "Failed to load proxy registry"); + setItems([]); + return; + } + setItems(Array.isArray(data?.items) ? data.items : []); + void loadHealth(); + } catch (e: any) { + setError(e?.message || "Failed to load proxy registry"); + setItems([]); + } finally { + setLoading(false); + } + }, [loadHealth]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (items.length > 0 && !bulkProxyId) { + setBulkProxyId(items[0].id); + } + }, [items, bulkProxyId]); + + const openCreate = () => { + setForm(EMPTY_FORM); + setModalOpen(true); + }; + + const openEdit = (item: ProxyItem) => { + setForm({ + id: item.id, + name: item.name || "", + type: item.type || "http", + host: item.host || "", + port: String(item.port || 8080), + username: "", + password: "", + region: item.region || "", + notes: item.notes || "", + status: item.status || "active", + }); + setModalOpen(true); + }; + + const loadUsage = async (proxyId: string) => { + try { + const res = await fetch( + `/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1` + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) return; + setUsageById((prev) => ({ + ...prev, + [proxyId]: { + count: Number(data?.count || 0), + assignments: Array.isArray(data?.assignments) ? data.assignments : [], + }, + })); + } catch { + // ignore usage loading errors in UI + } + }; + + const handleSave = async () => { + if (!form.name.trim() || !form.host.trim()) { + setError("Name and host are required"); + return; + } + + setSaving(true); + setError(null); + + const payload = { + ...(editingId ? { id: editingId } : {}), + name: form.name.trim(), + type: form.type, + host: form.host.trim(), + port: Number(form.port || 8080), + username: form.username.trim(), + password: form.password.trim(), + region: form.region.trim() || null, + notes: form.notes.trim() || null, + status: form.status, + }; + + try { + const res = await fetch("/api/settings/proxies", { + method: editingId ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error?.message || "Failed to save proxy"); + return; + } + + setModalOpen(false); + setForm(EMPTY_FORM); + await load(); + } catch (e: any) { + setError(e?.message || "Failed to save proxy"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + try { + const res = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}`, { + method: "DELETE", + }); + + if (res.ok) { + await load(); + return; + } + + const payload = await res.json().catch(() => ({})); + const inUse = res.status === 409; + if (inUse) { + const ok = window.confirm( + "This proxy is still assigned. Force delete and remove all assignments?" + ); + if (!ok) return; + + const forceRes = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}&force=1`, { + method: "DELETE", + }); + + if (!forceRes.ok) { + const forcePayload = await forceRes.json().catch(() => ({})); + setError(forcePayload?.error?.message || "Failed to force delete proxy"); + return; + } + + await load(); + return; + } + + setError(payload?.error?.message || "Failed to delete proxy"); + } catch (e: any) { + setError(e?.message || "Failed to delete proxy"); + } + }; + + const handleMigrate = async () => { + setMigrating(true); + setError(null); + try { + const res = await fetch("/api/settings/proxies/migrate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ force: false }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data?.error?.message || "Failed to migrate legacy proxy config"); + return; + } + await load(); + } catch (e: any) { + setError(e?.message || "Failed to migrate legacy proxy config"); + } finally { + setMigrating(false); + } + }; + + const handleBulkAssign = async () => { + setBulkSaving(true); + setError(null); + try { + const scopeIds = + bulkScope === "global" + ? [] + : bulkScopeIds + .split(/[\n,]/g) + .map((part) => part.trim()) + .filter(Boolean); + + const res = await fetch("/api/settings/proxies/bulk-assign", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scope: bulkScope, + scopeIds, + proxyId: bulkProxyId || null, + }), + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + setError(payload?.error?.message || "Failed to run bulk assignment"); + return; + } + + setBulkOpen(false); + setBulkScopeIds(""); + await load(); + } catch (e: any) { + setError(e?.message || "Failed to run bulk assignment"); + } finally { + setBulkSaving(false); + } + }; + + return ( + <> + +
+
+

Proxy Registry

+

Store reusable proxies and track assignments.

+
+
+ + + +
+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading proxies...
+ ) : items.length === 0 ? ( +
No saved proxies yet.
+ ) : ( +
+ + + + + + + + + + + + + {items.map((item) => { + const usage = usageById[item.id]; + const health = healthById[item.id]; + return ( + + + + + + + + + ); + })} + +
NameEndpointStatusHealth (24h)UsageActions
+
{item.name}
+ {item.region && ( +
{item.region}
+ )} +
+ {item.type}://{item.host}:{item.port} + + + {item.status || "active"} + + + {health ? ( +
+ {health.successRate ?? 0}% success + {health.avgLatencyMs ?? "-"} ms avg +
+ ) : ( + "-" + )} +
+ {usage ? `${usage.count} assignment(s)` : "-"} + +
+ + + +
+
+
+ )} +
+ + { + if (!saving) setModalOpen(false); + }} + title={editingId ? "Edit Proxy" : "Create Proxy"} + maxWidth="lg" + > +
+
+
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + /> +
+
+ + +
+
+ + setForm((prev) => ({ ...prev, host: e.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, port: e.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, username: e.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, password: e.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, region: e.target.value }))} + /> +
+
+ + +
+
+ +
+ +