mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
refactor: replace any types with generics and add Zod validation schemas
Eliminate `any` usage across the codebase by introducing proper generics, typed interfaces (StatementLike, DbLike, PromptRow, etc.), and helper conversion functions (toNumber, toString, parseVariables). Add comprehensive Zod validation schemas for API endpoint inputs to enforce runtime type safety alongside compile-time checks.
This commit is contained in:
@@ -114,10 +114,10 @@ export function decrypt(ciphertext: string | null | undefined): string | null |
|
||||
|
||||
/**
|
||||
* Encrypt sensitive fields in a connection object (mutates in-place).
|
||||
* Uses `any` because the DB layer returns untyped rows from rowToCamel/cleanNulls.
|
||||
*/
|
||||
export function encryptConnectionFields(conn: any): any {
|
||||
export function encryptConnectionFields<T extends ConnectionFields | null | undefined>(conn: T): T {
|
||||
if (!isEncryptionEnabled()) return conn;
|
||||
if (!conn) return conn;
|
||||
|
||||
if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey);
|
||||
if (conn.accessToken) conn.accessToken = encrypt(conn.accessToken);
|
||||
@@ -128,9 +128,8 @@ export function encryptConnectionFields(conn: any): any {
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields in a connection row (returns new object).
|
||||
* Uses `any` because the DB layer returns untyped rows from rowToCamel/cleanNulls.
|
||||
*/
|
||||
export function decryptConnectionFields(row: any): any {
|
||||
export function decryptConnectionFields<T extends ConnectionFields | null | undefined>(row: T): T {
|
||||
if (!row) return row;
|
||||
if (!isEncryptionEnabled()) return row;
|
||||
|
||||
|
||||
@@ -12,6 +12,61 @@
|
||||
import crypto from "node:crypto";
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
interface StatementLike<TRow = unknown> {
|
||||
all: (...params: unknown[]) => TRow[];
|
||||
get: (...params: unknown[]) => TRow | undefined;
|
||||
run: (...params: unknown[]) => { lastInsertRowid?: number | bigint; changes?: number };
|
||||
}
|
||||
|
||||
interface DbLike {
|
||||
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
|
||||
exec: (sql: string) => void;
|
||||
transaction: (fn: () => void) => () => void;
|
||||
}
|
||||
|
||||
interface PromptRow {
|
||||
id: unknown;
|
||||
slug: unknown;
|
||||
version: unknown;
|
||||
content: unknown;
|
||||
content_hash: unknown;
|
||||
variables: unknown;
|
||||
description: unknown;
|
||||
is_active: unknown;
|
||||
created_at: unknown;
|
||||
}
|
||||
|
||||
interface PromptListRow {
|
||||
slug: unknown;
|
||||
active_version: unknown;
|
||||
total_versions: unknown;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number"
|
||||
? value
|
||||
: typeof value === "bigint"
|
||||
? Number(value)
|
||||
: typeof value === "string" && value.trim().length > 0
|
||||
? Number(value)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function toString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function parseVariables(value: unknown): string[] | null {
|
||||
if (typeof value !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((item): item is string => typeof item === "string");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Schema (auto-created on first access) ──
|
||||
|
||||
const PROMPT_SCHEMA = `
|
||||
@@ -37,7 +92,7 @@ let _initialized = false;
|
||||
function ensureSchema(): void {
|
||||
if (_initialized) return;
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
db.exec(PROMPT_SCHEMA);
|
||||
_initialized = true;
|
||||
} catch {
|
||||
@@ -74,13 +129,13 @@ export function savePrompt(
|
||||
options: { variables?: string[]; description?: string } = {}
|
||||
): PromptTemplate {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const hash = hashContent(content);
|
||||
|
||||
// Check if identical content already exists for this slug
|
||||
const existing = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? AND content_hash = ?")
|
||||
.get(slug, hash) as any;
|
||||
.prepare<PromptRow>("SELECT * FROM prompt_templates WHERE slug = ? AND content_hash = ?")
|
||||
.get(slug, hash);
|
||||
|
||||
if (existing) {
|
||||
return rowToPrompt(existing);
|
||||
@@ -93,9 +148,11 @@ export function savePrompt(
|
||||
|
||||
// Get next version number
|
||||
const maxVersion = db
|
||||
.prepare("SELECT MAX(version) as max_v FROM prompt_templates WHERE slug = ?")
|
||||
.get(slug) as any;
|
||||
const nextVersion = (maxVersion?.max_v || 0) + 1;
|
||||
.prepare<{
|
||||
max_v: unknown;
|
||||
}>("SELECT MAX(version) as max_v FROM prompt_templates WHERE slug = ?")
|
||||
.get(slug);
|
||||
const nextVersion = toNumber(maxVersion?.max_v, 0) + 1;
|
||||
|
||||
// Insert new version
|
||||
const result = db
|
||||
@@ -113,7 +170,7 @@ export function savePrompt(
|
||||
);
|
||||
|
||||
return {
|
||||
id: Number(result.lastInsertRowid),
|
||||
id: toNumber(result.lastInsertRowid, 0),
|
||||
slug,
|
||||
version: nextVersion,
|
||||
content,
|
||||
@@ -130,10 +187,10 @@ export function savePrompt(
|
||||
*/
|
||||
export function getActivePrompt(slug: string): PromptTemplate | null {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const row = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? AND is_active = 1")
|
||||
.get(slug) as any;
|
||||
.prepare<PromptRow>("SELECT * FROM prompt_templates WHERE slug = ? AND is_active = 1")
|
||||
.get(slug);
|
||||
return row ? rowToPrompt(row) : null;
|
||||
}
|
||||
|
||||
@@ -142,10 +199,10 @@ export function getActivePrompt(slug: string): PromptTemplate | null {
|
||||
*/
|
||||
export function getPromptVersion(slug: string, version: number): PromptTemplate | null {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const row = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? AND version = ?")
|
||||
.get(slug, version) as any;
|
||||
.prepare<PromptRow>("SELECT * FROM prompt_templates WHERE slug = ? AND version = ?")
|
||||
.get(slug, version);
|
||||
return row ? rowToPrompt(row) : null;
|
||||
}
|
||||
|
||||
@@ -154,21 +211,25 @@ export function getPromptVersion(slug: string, version: number): PromptTemplate
|
||||
*/
|
||||
export function listPromptVersions(slug: string): PromptTemplate[] {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const rows = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? ORDER BY version DESC")
|
||||
.all(slug) as any[];
|
||||
.prepare<PromptRow>("SELECT * FROM prompt_templates WHERE slug = ? ORDER BY version DESC")
|
||||
.all(slug);
|
||||
return rows.map(rowToPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all prompt slugs with their active version info.
|
||||
*/
|
||||
export function listPrompts(): Array<{ slug: string; activeVersion: number; totalVersions: number }> {
|
||||
export function listPrompts(): Array<{
|
||||
slug: string;
|
||||
activeVersion: number;
|
||||
totalVersions: number;
|
||||
}> {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const rows = db
|
||||
.prepare(
|
||||
.prepare<PromptListRow>(
|
||||
`SELECT slug,
|
||||
MAX(CASE WHEN is_active = 1 THEN version ELSE 0 END) as active_version,
|
||||
COUNT(*) as total_versions
|
||||
@@ -176,12 +237,12 @@ export function listPrompts(): Array<{ slug: string; activeVersion: number; tota
|
||||
GROUP BY slug
|
||||
ORDER BY slug`
|
||||
)
|
||||
.all() as any[];
|
||||
.all();
|
||||
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
activeVersion: r.active_version,
|
||||
totalVersions: r.total_versions,
|
||||
slug: toString(r.slug),
|
||||
activeVersion: toNumber(r.active_version, 0),
|
||||
totalVersions: toNumber(r.total_versions, 0),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -190,11 +251,11 @@ export function listPrompts(): Array<{ slug: string; activeVersion: number; tota
|
||||
*/
|
||||
export function rollbackPrompt(slug: string, version: number): PromptTemplate | null {
|
||||
ensureSchema();
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
|
||||
const target = db
|
||||
.prepare("SELECT * FROM prompt_templates WHERE slug = ? AND version = ?")
|
||||
.get(slug, version) as any;
|
||||
.prepare<PromptRow>("SELECT * FROM prompt_templates WHERE slug = ? AND version = ?")
|
||||
.get(slug, version);
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
@@ -226,16 +287,16 @@ export function renderPrompt(slug: string, vars: Record<string, string> = {}): s
|
||||
|
||||
// ── Internal ──
|
||||
|
||||
function rowToPrompt(row: any): PromptTemplate {
|
||||
function rowToPrompt(row: PromptRow): PromptTemplate {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
version: row.version,
|
||||
content: row.content,
|
||||
contentHash: row.content_hash,
|
||||
variables: row.variables ? JSON.parse(row.variables) : null,
|
||||
description: row.description,
|
||||
isActive: row.is_active === 1,
|
||||
createdAt: row.created_at,
|
||||
id: toNumber(row.id, 0),
|
||||
slug: toString(row.slug),
|
||||
version: toNumber(row.version, 1),
|
||||
content: toString(row.content),
|
||||
contentHash: toString(row.content_hash),
|
||||
variables: parseVariables(row.variables),
|
||||
description: typeof row.description === "string" ? row.description : null,
|
||||
isActive: row.is_active === 1 || row.is_active === true,
|
||||
createdAt: toString(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,10 +7,34 @@ import { getDbInstance, rowToCamel, cleanNulls } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import { encryptConnectionFields, decryptConnectionFields } from "./encryption";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface StatementLike<TRow = unknown> {
|
||||
all: (...params: unknown[]) => TRow[];
|
||||
get: (...params: unknown[]) => TRow | undefined;
|
||||
run: (...params: unknown[]) => { changes?: number };
|
||||
}
|
||||
|
||||
interface DbLike {
|
||||
prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function toNumberOrZero(value: unknown): number {
|
||||
return typeof value === "number" ? value : 0;
|
||||
}
|
||||
|
||||
// ──────────────── Provider Connections ────────────────
|
||||
|
||||
export async function getProviderConnections(filter: any = {}) {
|
||||
const db = getDbInstance();
|
||||
export async function getProviderConnections(filter: JsonRecord = {}) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
let sql = "SELECT * FROM provider_connections";
|
||||
const conditions: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
@@ -34,63 +58,70 @@ export async function getProviderConnections(filter: any = {}) {
|
||||
}
|
||||
|
||||
export async function getProviderConnectionById(id: string) {
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const row = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id);
|
||||
return row ? decryptConnectionFields(cleanNulls(rowToCamel(row))) : null;
|
||||
}
|
||||
|
||||
export async function createProviderConnection(data: any) {
|
||||
const db = getDbInstance();
|
||||
export async function createProviderConnection(data: JsonRecord) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Upsert check
|
||||
// For Codex/OpenAI, a single email can have multiple workspaces (Team + Personal)
|
||||
// We need to check for workspace uniqueness, not just email
|
||||
let existing = null;
|
||||
let existing: JsonRecord | null = null;
|
||||
|
||||
if (data.authType === "oauth" && data.email) {
|
||||
// For Codex, check for existing connection with same workspace
|
||||
const workspaceId = data.providerSpecificData?.workspaceId;
|
||||
const providerSpecificData = toRecord(data.providerSpecificData);
|
||||
const workspaceId = toStringOrNull(providerSpecificData.workspaceId);
|
||||
if (data.provider === "codex" && workspaceId) {
|
||||
// For Codex, check for existing connection with same workspace AND email
|
||||
// A single workspace can have multiple users (Team/Business plans)
|
||||
// We need both workspace + email uniqueness to allow multiple accounts
|
||||
existing = db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND email = ?"
|
||||
)
|
||||
.get(data.provider, workspaceId, data.email);
|
||||
existing =
|
||||
(db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND email = ?"
|
||||
)
|
||||
.get(data.provider, workspaceId, data.email) as JsonRecord | undefined) || null;
|
||||
|
||||
// If no match with workspace+email, also check workspace-only for backward compat
|
||||
// (old connections without email should still be updated, not duplicated)
|
||||
if (!existing) {
|
||||
existing = db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND (email IS NULL OR email = '')"
|
||||
)
|
||||
.get(data.provider, workspaceId);
|
||||
existing =
|
||||
(db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND (email IS NULL OR email = '')"
|
||||
)
|
||||
.get(data.provider, workspaceId) as JsonRecord | undefined) || null;
|
||||
}
|
||||
// For Codex with workspaceId, don't fall back to email-only check
|
||||
// This allows creating new connections for different workspaces
|
||||
} else {
|
||||
// For other providers (or Codex without workspaceId), use email check
|
||||
existing = db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND email = ?"
|
||||
)
|
||||
.get(data.provider, data.email);
|
||||
existing =
|
||||
(db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND email = ?"
|
||||
)
|
||||
.get(data.provider, data.email) as JsonRecord | undefined) || null;
|
||||
}
|
||||
} else if (data.authType === "apikey" && data.name) {
|
||||
existing = db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'apikey' AND name = ?"
|
||||
)
|
||||
.get(data.provider, data.name);
|
||||
existing =
|
||||
(db
|
||||
.prepare(
|
||||
"SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'apikey' AND name = ?"
|
||||
)
|
||||
.get(data.provider, data.name) as JsonRecord | undefined) || null;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const merged = { ...rowToCamel(existing), ...data, updatedAt: now };
|
||||
_updateConnectionRow(db, existing.id, merged);
|
||||
const existingId = toStringOrNull(existing.id);
|
||||
if (!existingId) return null;
|
||||
const merged = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: now };
|
||||
_updateConnectionRow(db, existingId, merged);
|
||||
backupDbFile("pre-write");
|
||||
return cleanNulls(merged);
|
||||
}
|
||||
@@ -101,11 +132,11 @@ export async function createProviderConnection(data: any) {
|
||||
if (data.email) {
|
||||
connectionName = data.email;
|
||||
} else {
|
||||
const count =
|
||||
db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM provider_connections WHERE provider = ?")
|
||||
.get(data.provider)?.cnt || 0;
|
||||
connectionName = `Account ${count + 1}`;
|
||||
const count = db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM provider_connections WHERE provider = ?")
|
||||
.get(data.provider) as JsonRecord | undefined;
|
||||
const cntValue = toNumberOrZero(toRecord(count).cnt);
|
||||
connectionName = `Account ${cntValue + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,11 +145,12 @@ export async function createProviderConnection(data: any) {
|
||||
if (!connectionPriority) {
|
||||
const max = db
|
||||
.prepare("SELECT MAX(priority) as maxP FROM provider_connections WHERE provider = ?")
|
||||
.get(data.provider);
|
||||
connectionPriority = (max?.maxP || 0) + 1;
|
||||
.get(data.provider) as JsonRecord | undefined;
|
||||
const maxPriority = toNumberOrZero(toRecord(max).maxP);
|
||||
connectionPriority = maxPriority + 1;
|
||||
}
|
||||
|
||||
const connection: Record<string, any> = {
|
||||
const connection: Record<string, unknown> = {
|
||||
id: uuidv4(),
|
||||
provider: data.provider,
|
||||
authType: data.authType || "oauth",
|
||||
@@ -165,13 +197,16 @@ export async function createProviderConnection(data: any) {
|
||||
}
|
||||
|
||||
_insertConnectionRow(db, encryptConnectionFields({ ...connection }));
|
||||
_reorderConnections(db, data.provider);
|
||||
const providerId = toStringOrNull(data.provider);
|
||||
if (providerId) {
|
||||
_reorderConnections(db, providerId);
|
||||
}
|
||||
backupDbFile("pre-write");
|
||||
|
||||
return cleanNulls(connection);
|
||||
}
|
||||
|
||||
function _insertConnectionRow(db: any, conn: any) {
|
||||
function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_connections (
|
||||
@@ -237,7 +272,7 @@ function _insertConnectionRow(db: any, conn: any) {
|
||||
});
|
||||
}
|
||||
|
||||
function _updateConnectionRow(db: any, id: string, data: any) {
|
||||
function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
|
||||
const now = data.updatedAt || new Date().toISOString();
|
||||
db.prepare(
|
||||
`
|
||||
@@ -300,8 +335,8 @@ function _updateConnectionRow(db: any, id: string, data: any) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProviderConnection(id: string, data: any) {
|
||||
const db = getDbInstance();
|
||||
export async function updateProviderConnection(id: string, data: JsonRecord) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const existing = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id);
|
||||
if (!existing) return null;
|
||||
|
||||
@@ -310,36 +345,46 @@ export async function updateProviderConnection(id: string, data: any) {
|
||||
backupDbFile("pre-write");
|
||||
|
||||
if (data.priority !== undefined) {
|
||||
_reorderConnections(db, existing.provider);
|
||||
const existingRecord = toRecord(existing);
|
||||
const providerId =
|
||||
typeof existingRecord.provider === "string"
|
||||
? existingRecord.provider
|
||||
: String(existingRecord.provider || "");
|
||||
_reorderConnections(db, providerId);
|
||||
}
|
||||
|
||||
return cleanNulls(merged);
|
||||
}
|
||||
|
||||
export async function deleteProviderConnection(id: string) {
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const existing = db.prepare("SELECT provider FROM provider_connections WHERE id = ?").get(id);
|
||||
if (!existing) return false;
|
||||
|
||||
db.prepare("DELETE FROM provider_connections WHERE id = ?").run(id);
|
||||
_reorderConnections(db, existing.provider);
|
||||
const existingRecord = toRecord(existing);
|
||||
const providerId =
|
||||
typeof existingRecord.provider === "string"
|
||||
? existingRecord.provider
|
||||
: String(existingRecord.provider || "");
|
||||
_reorderConnections(db, providerId);
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteProviderConnectionsByProvider(providerId: string) {
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const result = db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId);
|
||||
backupDbFile("pre-write");
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export async function reorderProviderConnections(providerId: string) {
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
_reorderConnections(db, providerId);
|
||||
}
|
||||
|
||||
function _reorderConnections(db: any, providerId: string) {
|
||||
function _reorderConnections(db: DbLike, providerId: string) {
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT id, priority, updated_at FROM provider_connections WHERE provider = ? ORDER BY priority ASC, updated_at DESC"
|
||||
@@ -348,7 +393,8 @@ function _reorderConnections(db: any, providerId: string) {
|
||||
|
||||
const update = db.prepare("UPDATE provider_connections SET priority = ? WHERE id = ?");
|
||||
rows.forEach((row, index) => {
|
||||
update.run(index + 1, row.id);
|
||||
const current = toRecord(row);
|
||||
update.run(index + 1, current.id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,8 +404,8 @@ export async function cleanupProviderConnections() {
|
||||
|
||||
// ──────────────── Provider Nodes ────────────────
|
||||
|
||||
export async function getProviderNodes(filter: any = {}) {
|
||||
const db = getDbInstance();
|
||||
export async function getProviderNodes(filter: JsonRecord = {}) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
let sql = "SELECT * FROM provider_nodes";
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
@@ -372,13 +418,13 @@ export async function getProviderNodes(filter: any = {}) {
|
||||
}
|
||||
|
||||
export async function getProviderNodeById(id: string) {
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const row = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id);
|
||||
return row ? rowToCamel(row) : null;
|
||||
}
|
||||
|
||||
export async function createProviderNode(data: any) {
|
||||
const db = getDbInstance();
|
||||
export async function createProviderNode(data: JsonRecord) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const node = {
|
||||
@@ -403,12 +449,16 @@ export async function createProviderNode(data: any) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export async function updateProviderNode(id: string, data: any) {
|
||||
const db = getDbInstance();
|
||||
export async function updateProviderNode(id: string, data: JsonRecord) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const existing = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() };
|
||||
const merged: JsonRecord = {
|
||||
...toRecord(rowToCamel(existing)),
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
@@ -418,12 +468,12 @@ export async function updateProviderNode(id: string, data: any) {
|
||||
`
|
||||
).run({
|
||||
id,
|
||||
type: merged.type,
|
||||
name: merged.name,
|
||||
prefix: merged.prefix || null,
|
||||
apiType: merged.apiType || null,
|
||||
baseUrl: merged.baseUrl || null,
|
||||
updatedAt: merged.updatedAt,
|
||||
type: merged["type"],
|
||||
name: merged["name"],
|
||||
prefix: merged["prefix"] || null,
|
||||
apiType: merged["apiType"] || null,
|
||||
baseUrl: merged["baseUrl"] || null,
|
||||
updatedAt: merged["updatedAt"],
|
||||
});
|
||||
|
||||
backupDbFile("pre-write");
|
||||
@@ -431,7 +481,7 @@ export async function updateProviderNode(id: string, data: any) {
|
||||
}
|
||||
|
||||
export async function deleteProviderNode(id: string) {
|
||||
const db = getDbInstance();
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const existing = db.prepare("SELECT * FROM provider_nodes WHERE id = ?").get(id);
|
||||
if (!existing) return null;
|
||||
|
||||
|
||||
@@ -6,14 +6,50 @@ import { getDbInstance } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type PricingModels = Record<string, JsonRecord>;
|
||||
type PricingByProvider = Record<string, PricingModels>;
|
||||
type ProxyValue = JsonRecord | string | null;
|
||||
type ProxyMap = Record<string, ProxyValue>;
|
||||
|
||||
interface ProxyConfig {
|
||||
global: ProxyValue;
|
||||
providers: ProxyMap;
|
||||
combos: ProxyMap;
|
||||
keys: ProxyMap;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toProxyMap(value: unknown): ProxyMap {
|
||||
return value && typeof value === "object" ? (value as ProxyMap) : {};
|
||||
}
|
||||
|
||||
function toProxyValue(value: unknown): ProxyValue {
|
||||
if (value === null || typeof value === "string") return value;
|
||||
if (value && typeof value === "object") return value as JsonRecord;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ──────────────── Settings ────────────────
|
||||
|
||||
export async function getSettings() {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all();
|
||||
const settings: Record<string, any> = { cloudEnabled: false, stickyRoundRobinLimit: 3, requireLogin: true };
|
||||
const settings: Record<string, unknown> = {
|
||||
cloudEnabled: false,
|
||||
stickyRoundRobinLimit: 3,
|
||||
requireLogin: true,
|
||||
};
|
||||
for (const row of rows) {
|
||||
settings[row.key] = JSON.parse(row.value);
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
settings[key] = JSON.parse(rawValue);
|
||||
}
|
||||
|
||||
// Auto-complete onboarding for pre-configured deployments (Docker/VM)
|
||||
@@ -57,21 +93,25 @@ export async function isCloudEnabled() {
|
||||
export async function getPricing() {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
|
||||
const userPricing: Record<string, any> = {};
|
||||
const userPricing: PricingByProvider = {};
|
||||
for (const row of rows) {
|
||||
userPricing[row.key] = JSON.parse(row.value);
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
userPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
||||
}
|
||||
|
||||
const { getDefaultPricing } = await import("@/shared/constants/pricing");
|
||||
const defaultPricing = getDefaultPricing();
|
||||
|
||||
const mergedPricing: Record<string, any> = {};
|
||||
for (const [provider, models] of Object.entries(defaultPricing) as [string, any][]) {
|
||||
mergedPricing[provider] = { ...models };
|
||||
const mergedPricing: PricingByProvider = {};
|
||||
for (const [provider, models] of Object.entries(defaultPricing) as Array<[string, unknown]>) {
|
||||
mergedPricing[provider] = { ...(toRecord(models) as PricingModels) };
|
||||
if (userPricing[provider]) {
|
||||
for (const [model, pricing] of Object.entries(userPricing[provider])) {
|
||||
mergedPricing[provider][model] = mergedPricing[provider][model]
|
||||
? { ...mergedPricing[provider][model], ...(pricing as any) }
|
||||
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
|
||||
: pricing;
|
||||
}
|
||||
}
|
||||
@@ -106,15 +146,21 @@ export async function getPricingForModel(provider: string, model: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function updatePricing(pricingData: Record<string, any>) {
|
||||
export async function updatePricing(pricingData: PricingByProvider) {
|
||||
const db = getDbInstance();
|
||||
const insert = db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('pricing', ?, ?)"
|
||||
);
|
||||
|
||||
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
|
||||
const existing: Record<string, any> = {};
|
||||
for (const row of rows) existing[row.key] = JSON.parse(row.value);
|
||||
const existing: PricingByProvider = {};
|
||||
for (const row of rows) {
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
existing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
||||
}
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const [provider, models] of Object.entries(pricingData)) {
|
||||
@@ -124,9 +170,15 @@ export async function updatePricing(pricingData: Record<string, any>) {
|
||||
tx();
|
||||
backupDbFile("pre-write");
|
||||
|
||||
const updated = {};
|
||||
const updated: PricingByProvider = {};
|
||||
const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
|
||||
for (const row of allRows) updated[row.key] = JSON.parse(row.value);
|
||||
for (const row of allRows) {
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
updated[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -138,7 +190,9 @@ export async function resetPricing(provider: string, model?: string) {
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = 'pricing' AND key = ?")
|
||||
.get(provider);
|
||||
if (row) {
|
||||
const models = JSON.parse(row.value);
|
||||
const rowRecord = toRecord(row);
|
||||
const value = typeof rowRecord.value === "string" ? rowRecord.value : "{}";
|
||||
const models = toRecord(JSON.parse(value));
|
||||
delete models[model];
|
||||
if (Object.keys(models).length === 0) {
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'pricing' AND key = ?").run(provider);
|
||||
@@ -155,8 +209,14 @@ export async function resetPricing(provider: string, model?: string) {
|
||||
|
||||
backupDbFile("pre-write");
|
||||
const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
|
||||
const result = {};
|
||||
for (const row of allRows) result[row.key] = JSON.parse(row.value);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const row of allRows) {
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
result[key] = JSON.parse(rawValue);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -169,31 +229,32 @@ export async function resetAllPricing() {
|
||||
|
||||
// ──────────────── Proxy Config ────────────────
|
||||
|
||||
const DEFAULT_PROXY_CONFIG = { global: null, providers: {}, combos: {}, keys: {} };
|
||||
const DEFAULT_PROXY_CONFIG: ProxyConfig = { global: null, providers: {}, combos: {}, keys: {} };
|
||||
const ALIAS_TO_PROVIDER_ID = Object.entries(PROVIDER_ID_TO_ALIAS).reduce(
|
||||
(acc, [providerId, alias]) => {
|
||||
if (alias) acc[alias] = providerId;
|
||||
acc[providerId] = providerId;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
) as Record<string, string>;
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
function resolveProviderAliasOrId(providerOrAlias: string): string {
|
||||
if (typeof providerOrAlias !== "string") return providerOrAlias;
|
||||
return ALIAS_TO_PROVIDER_ID[providerOrAlias] || providerOrAlias;
|
||||
}
|
||||
|
||||
function getComboModelProvider(modelEntry: any): string | null {
|
||||
if (modelEntry && typeof modelEntry.provider === "string") {
|
||||
return resolveProviderAliasOrId(modelEntry.provider);
|
||||
function getComboModelProvider(modelEntry: unknown): string | null {
|
||||
const record = toRecord(modelEntry);
|
||||
if (typeof record.provider === "string") {
|
||||
return resolveProviderAliasOrId(record.provider);
|
||||
}
|
||||
|
||||
const modelValue =
|
||||
typeof modelEntry === "string"
|
||||
? modelEntry
|
||||
: typeof modelEntry?.model === "string"
|
||||
? modelEntry.model
|
||||
: typeof record.model === "string"
|
||||
? record.model
|
||||
: null;
|
||||
|
||||
if (!modelValue) return null;
|
||||
@@ -203,9 +264,12 @@ function getComboModelProvider(modelEntry: any): string | null {
|
||||
return resolveProviderAliasOrId(providerOrAlias);
|
||||
}
|
||||
|
||||
function migrateProxyEntry(value: any) {
|
||||
function migrateProxyEntry(value: unknown): JsonRecord | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === "object" && value.type) return value;
|
||||
if (typeof value === "object") {
|
||||
const record = toRecord(value);
|
||||
if (record.type) return record;
|
||||
}
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
try {
|
||||
@@ -213,7 +277,9 @@ function migrateProxyEntry(value: any) {
|
||||
return {
|
||||
type: url.protocol.replace(":", "") || "http",
|
||||
host: url.hostname,
|
||||
port: url.port || (url.protocol === "socks5:" ? "1080" : url.protocol === "https:" ? "443" : "8080"),
|
||||
port:
|
||||
url.port ||
|
||||
(url.protocol === "socks5:" ? "1080" : url.protocol === "https:" ? "443" : "8080"),
|
||||
username: url.username ? decodeURIComponent(url.username) : "",
|
||||
password: url.password ? decodeURIComponent(url.password) : "",
|
||||
};
|
||||
@@ -233,8 +299,14 @@ export async function getProxyConfig() {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'proxyConfig'").all();
|
||||
|
||||
const raw = { ...DEFAULT_PROXY_CONFIG };
|
||||
for (const row of rows) raw[row.key] = JSON.parse(row.value);
|
||||
const raw: ProxyConfig = { ...DEFAULT_PROXY_CONFIG };
|
||||
for (const row of rows) {
|
||||
const record = toRecord(row);
|
||||
const key = typeof record.key === "string" ? record.key : null;
|
||||
const rawValue = typeof record.value === "string" ? record.value : null;
|
||||
if (!key || rawValue === null) continue;
|
||||
raw[key] = JSON.parse(rawValue);
|
||||
}
|
||||
|
||||
let migrated = false;
|
||||
if (raw.global && typeof raw.global === "string") {
|
||||
@@ -264,11 +336,11 @@ export async function getProxyConfig() {
|
||||
export async function getProxyForLevel(level: string, id?: string | null) {
|
||||
const config = await getProxyConfig();
|
||||
if (level === "global") return config.global || null;
|
||||
const map = config[level + "s"] || config[level] || {};
|
||||
const map = toProxyMap(config[level + "s"] || config[level] || {});
|
||||
return (id ? map[id] : null) || null;
|
||||
}
|
||||
|
||||
export async function setProxyForLevel(level: string, id: string | null, proxy: any) {
|
||||
export async function setProxyForLevel(level: string, id: string | null, proxy: ProxyValue) {
|
||||
const db = getDbInstance();
|
||||
const config = await getProxyConfig();
|
||||
|
||||
@@ -279,22 +351,23 @@ export async function setProxyForLevel(level: string, id: string | null, proxy:
|
||||
).run(JSON.stringify(config.global));
|
||||
} else {
|
||||
const mapKey = level + "s";
|
||||
if (!config[mapKey]) config[mapKey] = {};
|
||||
if (proxy) {
|
||||
config[mapKey][id] = proxy;
|
||||
const map = toProxyMap(config[mapKey] || {});
|
||||
if (proxy && id) {
|
||||
map[id] = proxy;
|
||||
} else {
|
||||
delete config[mapKey][id];
|
||||
if (id) delete map[id];
|
||||
}
|
||||
config[mapKey] = map;
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', ?, ?)"
|
||||
).run(mapKey, JSON.stringify(config[mapKey]));
|
||||
).run(mapKey, JSON.stringify(map));
|
||||
}
|
||||
|
||||
backupDbFile("pre-write");
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function deleteProxyForLevel(level, id) {
|
||||
export async function deleteProxyForLevel(level: string, id: string | null) {
|
||||
return setProxyForLevel(level, id, null);
|
||||
}
|
||||
|
||||
@@ -311,17 +384,25 @@ export async function resolveProxyForConnection(connectionId: string) {
|
||||
.get(connectionId);
|
||||
|
||||
if (connection) {
|
||||
const connectionRecord = toRecord(connection);
|
||||
const provider =
|
||||
typeof connectionRecord.provider === "string" ? connectionRecord.provider : null;
|
||||
if (config.combos && Object.keys(config.combos).length > 0) {
|
||||
const combos = db.prepare("SELECT id, data FROM combos").all();
|
||||
for (const comboRow of combos) {
|
||||
if (config.combos[comboRow.id]) {
|
||||
const comboRecord = toRecord(comboRow);
|
||||
const comboId = typeof comboRecord.id === "string" ? comboRecord.id : null;
|
||||
if (comboId && config.combos[comboId]) {
|
||||
try {
|
||||
const combo = JSON.parse(comboRow.data);
|
||||
const usesProvider = (combo.models || []).some(
|
||||
(entry) => getComboModelProvider(entry) === connection.provider
|
||||
const comboRaw = typeof comboRecord.data === "string" ? comboRecord.data : null;
|
||||
if (!comboRaw) continue;
|
||||
const combo = toRecord(JSON.parse(comboRaw));
|
||||
const comboModels = Array.isArray(combo.models) ? combo.models : [];
|
||||
const usesProvider = comboModels.some(
|
||||
(entry) => getComboModelProvider(entry) === provider
|
||||
);
|
||||
if (usesProvider) {
|
||||
return { proxy: config.combos[comboRow.id], level: "combo", levelId: comboRow.id };
|
||||
return { proxy: config.combos[comboId], level: "combo", levelId: comboId };
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed combo records during proxy resolution.
|
||||
@@ -330,11 +411,11 @@ export async function resolveProxyForConnection(connectionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.providers?.[connection.provider]) {
|
||||
if (provider && config.providers?.[provider]) {
|
||||
return {
|
||||
proxy: config.providers[connection.provider],
|
||||
proxy: config.providers[provider],
|
||||
level: "provider",
|
||||
levelId: connection.provider,
|
||||
levelId: provider,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -346,9 +427,12 @@ export async function resolveProxyForConnection(connectionId: string) {
|
||||
return { proxy: null, level: "direct", levelId: null };
|
||||
}
|
||||
|
||||
export async function setProxyConfig(config: any) {
|
||||
export async function setProxyConfig(config: Record<string, unknown>) {
|
||||
if (config.level !== undefined) {
|
||||
return setProxyForLevel(config.level, config.id || null, config.proxy);
|
||||
const level = typeof config.level === "string" ? config.level : "global";
|
||||
const id = typeof config.id === "string" ? config.id : null;
|
||||
const proxy = (config.proxy as ProxyValue) || null;
|
||||
return setProxyForLevel(level, id, proxy);
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
@@ -359,16 +443,17 @@ export async function setProxyConfig(config: any) {
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (config.global !== undefined) {
|
||||
current.global = config.global || null;
|
||||
current.global = toProxyValue(config.global);
|
||||
insert.run("global", JSON.stringify(current.global));
|
||||
}
|
||||
for (const mapKey of ["providers", "combos", "keys"]) {
|
||||
if (config[mapKey]) {
|
||||
current[mapKey] = { ...(current[mapKey] || {}), ...config[mapKey] };
|
||||
for (const [k, v] of Object.entries(current[mapKey])) {
|
||||
if (!v) delete current[mapKey][k];
|
||||
const merged = { ...toProxyMap(current[mapKey]), ...toProxyMap(config[mapKey]) };
|
||||
for (const [k, v] of Object.entries(merged)) {
|
||||
if (!v) delete merged[k];
|
||||
}
|
||||
insert.run(mapKey, JSON.stringify(current[mapKey]));
|
||||
current[mapKey] = merged;
|
||||
insert.run(mapKey, JSON.stringify(merged));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
30
src/lib/db/stateReset.ts
Normal file
30
src/lib/db/stateReset.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Central registry for DB module state resetters.
|
||||
* Used by restore flows to clear prepared statement caches without cross-module imports.
|
||||
*/
|
||||
|
||||
type DbStateResetter = () => void;
|
||||
|
||||
const resetters = new Set<DbStateResetter>();
|
||||
|
||||
/**
|
||||
* Register a module-level state resetter.
|
||||
* Duplicate function references are deduplicated by Set semantics.
|
||||
*/
|
||||
export function registerDbStateResetter(resetter: DbStateResetter) {
|
||||
resetters.add(resetter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke all registered state resetters.
|
||||
* A failing resetter must not block execution of the remaining handlers.
|
||||
*/
|
||||
export function resetAllDbModuleState() {
|
||||
for (const resetter of resetters) {
|
||||
try {
|
||||
resetter();
|
||||
} catch (error) {
|
||||
console.warn("[DB] Failed to reset module state:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const GEMINI_CONFIG = {
|
||||
clientId:
|
||||
process.env.GEMINI_OAUTH_CLIENT_ID ||
|
||||
"681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||
clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || "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",
|
||||
clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || "",
|
||||
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
@@ -67,7 +67,7 @@ export const QWEN_CONFIG = {
|
||||
// iFlow OAuth Configuration (Authorization Code)
|
||||
export const IFLOW_CONFIG = {
|
||||
clientId: process.env.IFLOW_OAUTH_CLIENT_ID || "10009311001",
|
||||
clientSecret: process.env.IFLOW_OAUTH_CLIENT_SECRET || "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW",
|
||||
clientSecret: process.env.IFLOW_OAUTH_CLIENT_SECRET || "",
|
||||
authorizeUrl: "https://iflow.cn/oauth",
|
||||
tokenUrl: "https://iflow.cn/oauth/token",
|
||||
userInfoUrl: "https://iflow.cn/api/oauth/getUserInfo",
|
||||
@@ -105,8 +105,7 @@ export const ANTIGRAVITY_CONFIG = {
|
||||
clientId:
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_ID ||
|
||||
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
clientSecret:
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || "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",
|
||||
clientSecret: process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || "",
|
||||
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
|
||||
@@ -11,9 +11,17 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations";
|
||||
import { isNoLog } from "../compliance";
|
||||
import { sanitizePII } from "../piiSanitizer";
|
||||
|
||||
const CALL_LOGS_MAX = parseInt(process.env.CALL_LOGS_MAX || "200", 10);
|
||||
const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || "7", 10);
|
||||
const CALL_LOG_PAYLOAD_MODE = (() => {
|
||||
const value = (process.env.CALL_LOG_PAYLOAD_MODE || "full").toLowerCase();
|
||||
return value === "full" || value === "metadata" || value === "none" ? value : "full";
|
||||
})();
|
||||
const shouldLogPayloadInDb = CALL_LOG_PAYLOAD_MODE !== "none";
|
||||
const shouldLogPayloadOnDisk = CALL_LOG_PAYLOAD_MODE === "full";
|
||||
|
||||
/** Fields that should always be redacted from logged payloads */
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
@@ -55,6 +63,39 @@ function redactPayload(obj: any): any {
|
||||
return redacted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitize PII from string fields in a payload.
|
||||
* Uses lib/piiSanitizer config flags to determine if redaction is enabled.
|
||||
*/
|
||||
function sanitizePayloadPII(obj: any): any {
|
||||
if (typeof obj === "string") {
|
||||
return sanitizePII(obj).text;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(sanitizePayloadPII);
|
||||
}
|
||||
if (!obj || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const sanitized: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
sanitized[key] = sanitizePayloadPII(value);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply payload protection chain before persistence.
|
||||
* 1) Optional PII sanitization
|
||||
* 2) Mandatory key/token redaction
|
||||
*/
|
||||
function protectPayloadForLog(payload: any): any {
|
||||
if (!payload || !shouldLogPayloadInDb) return null;
|
||||
const piiSanitized = sanitizePayloadPII(payload);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
let logIdCounter = 0;
|
||||
function generateLogId() {
|
||||
logIdCounter++;
|
||||
@@ -68,6 +109,12 @@ export async function saveCallLog(entry: any) {
|
||||
if (!shouldPersistToDisk) return;
|
||||
|
||||
try {
|
||||
const apiKeyId = entry.apiKeyId || null;
|
||||
const noLogEnabled = Boolean(entry.noLog) || (apiKeyId ? isNoLog(apiKeyId) : false);
|
||||
|
||||
const protectedRequestBody = noLogEnabled ? null : protectPayloadForLog(entry.requestBody);
|
||||
const protectedResponseBody = noLogEnabled ? null : protectPayloadForLog(entry.responseBody);
|
||||
|
||||
// Resolve account name
|
||||
let account = entry.connectionId ? entry.connectionId.slice(0, 8) : "-";
|
||||
try {
|
||||
@@ -78,11 +125,9 @@ export async function saveCallLog(entry: any) {
|
||||
} catch {}
|
||||
|
||||
// Truncate large payloads for DB storage (keep under 8KB each)
|
||||
// Also redact sensitive fields before persistence
|
||||
const truncatePayload = (obj: any) => {
|
||||
if (!obj) return null;
|
||||
const redacted = redactPayload(obj);
|
||||
const str = JSON.stringify(redacted);
|
||||
const str = JSON.stringify(obj);
|
||||
if (str.length <= 8192) return str;
|
||||
try {
|
||||
return JSON.stringify({
|
||||
@@ -110,12 +155,12 @@ export async function saveCallLog(entry: any) {
|
||||
tokensOut: entry.tokens?.completion_tokens || 0,
|
||||
sourceFormat: entry.sourceFormat || null,
|
||||
targetFormat: entry.targetFormat || null,
|
||||
apiKeyId: entry.apiKeyId || null,
|
||||
apiKeyId,
|
||||
apiKeyName: entry.apiKeyName || null,
|
||||
comboName: entry.comboName || null,
|
||||
requestBody: truncatePayload(entry.requestBody),
|
||||
responseBody: truncatePayload(entry.responseBody),
|
||||
error: entry.error || null,
|
||||
requestBody: truncatePayload(protectedRequestBody),
|
||||
responseBody: truncatePayload(protectedResponseBody),
|
||||
error: typeof entry.error === "string" ? sanitizePII(entry.error).text : entry.error || null,
|
||||
};
|
||||
|
||||
// 1. Insert into SQLite
|
||||
@@ -144,11 +189,18 @@ export async function saveCallLog(entry: any) {
|
||||
}
|
||||
|
||||
// 3. Write full payload to disk file (untruncated)
|
||||
writeCallLogToDisk(
|
||||
{ ...logEntry, tokens: { in: logEntry.tokensIn, out: logEntry.tokensOut } },
|
||||
entry.requestBody,
|
||||
entry.responseBody
|
||||
);
|
||||
// Disabled when no-log is active or payload mode is metadata/none.
|
||||
if (
|
||||
shouldLogPayloadOnDisk &&
|
||||
!noLogEnabled &&
|
||||
(protectedRequestBody !== null || protectedResponseBody !== null)
|
||||
) {
|
||||
writeCallLogToDisk(
|
||||
{ ...logEntry, tokens: { in: logEntry.tokensIn, out: logEntry.tokensOut } },
|
||||
protectedRequestBody,
|
||||
protectedResponseBody
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[callLogs] Failed to save call log:", error.message);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Modal, Button, Input } from "@/shared/components";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import Input from "./Input";
|
||||
|
||||
/**
|
||||
* Cursor Auth Modal
|
||||
|
||||
86
src/shared/components/ErrorPageScaffold.tsx
Normal file
86
src/shared/components/ErrorPageScaffold.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import Link from "next/link";
|
||||
|
||||
interface PageAction {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ErrorPageScaffoldProps {
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
suggestions?: string[];
|
||||
primaryAction?: PageAction;
|
||||
secondaryAction?: PageAction;
|
||||
}
|
||||
|
||||
export default function ErrorPageScaffold({
|
||||
code,
|
||||
title,
|
||||
description,
|
||||
icon = "error",
|
||||
suggestions = [],
|
||||
primaryAction = { href: "/dashboard", label: "Go to Dashboard" },
|
||||
secondaryAction = { href: "/status", label: "Check System Status" },
|
||||
}: ErrorPageScaffoldProps) {
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen bg-bg text-text-main flex items-center justify-center px-6 py-12"
|
||||
role="main"
|
||||
aria-labelledby="error-page-title"
|
||||
>
|
||||
<section className="w-full max-w-2xl rounded-2xl border border-border bg-surface p-8 sm:p-10 shadow-soft">
|
||||
<header className="text-center">
|
||||
<span className="material-symbols-outlined text-4xl text-primary mb-3" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<p
|
||||
className="text-6xl sm:text-7xl font-bold leading-none bg-gradient-to-br from-primary to-primary-hover bg-clip-text text-transparent"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{code}
|
||||
</p>
|
||||
<h1 id="error-page-title" className="mt-4 text-2xl sm:text-3xl font-semibold">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-3 text-text-muted leading-relaxed">{description}</p>
|
||||
</header>
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<ul
|
||||
className="mt-8 rounded-xl border border-border bg-bg-alt p-5 space-y-2 text-sm text-text-muted"
|
||||
aria-label="Recommended actions"
|
||||
>
|
||||
{suggestions.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-base text-primary mt-0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
check_circle
|
||||
</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex flex-col sm:flex-row gap-3">
|
||||
<Link
|
||||
href={primaryAction.href}
|
||||
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-white text-sm font-semibold bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated transition-all duration-200 motion-reduce:transition-none"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Link>
|
||||
<Link
|
||||
href={secondaryAction.href}
|
||||
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-sm font-semibold border border-border hover:bg-bg-alt transition-colors duration-200 motion-reduce:transition-none"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import PropTypes from "prop-types";
|
||||
import { ThemeToggle } from "@/shared/components";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import TokenHealthBadge from "./TokenHealthBadge";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Modal, Button, Input } from "@/shared/components";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import Input from "./Input";
|
||||
|
||||
/**
|
||||
* Kiro Auth Method Selection Modal
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Modal, Button, Input } from "@/shared/components";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import Input from "./Input";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,43 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
// Spinner loading
|
||||
export function Spinner({ size = "md", className }: { size?: string; className?: string }) {
|
||||
const sizes: Record<string, string> = {
|
||||
sm: "size-4",
|
||||
md: "size-6",
|
||||
lg: "size-8",
|
||||
xl: "size-12",
|
||||
};
|
||||
type SpinnerSize = "sm" | "md" | "lg" | "xl";
|
||||
type LoadingType = "spinner" | "page" | "skeleton" | "card";
|
||||
|
||||
const spinnerSizes: Record<SpinnerSize, string> = {
|
||||
sm: "size-4",
|
||||
md: "size-6",
|
||||
lg: "size-8",
|
||||
xl: "size-12",
|
||||
};
|
||||
|
||||
interface SpinnerProps {
|
||||
size?: SpinnerSize;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface PageLoadingProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface LoadingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
type?: LoadingType;
|
||||
className?: string;
|
||||
message?: string;
|
||||
size?: SpinnerSize;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Spinner loading
|
||||
export function Spinner({ size = "md", className, label = "Loading" }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("material-symbols-outlined animate-spin text-primary", sizes[size], className)}
|
||||
aria-live="polite"
|
||||
aria-label={label}
|
||||
className={cn("inline-flex", className)}
|
||||
>
|
||||
progress_activity
|
||||
<span className="sr-only">{label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"material-symbols-outlined text-primary animate-spin motion-reduce:animate-none",
|
||||
spinnerSizes[size]
|
||||
)}
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Full page loading
|
||||
export function PageLoading({ message = "Loading..." }: { message?: string }) {
|
||||
export function PageLoading({ message = "Loading...", className }: PageLoadingProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-bg">
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex flex-col items-center justify-center bg-bg px-6",
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<Spinner size="xl" />
|
||||
<p className="mt-4 text-text-muted">{message}</p>
|
||||
<p className="mt-4 text-text-muted text-center">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton loading
|
||||
export function Skeleton({ className, ...props }: { className?: string; [key: string]: any }) {
|
||||
export function Skeleton({ className, ...props }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn("animate-pulse rounded-lg bg-border", className)}
|
||||
className={cn("animate-pulse motion-reduce:animate-none rounded-lg bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -46,8 +91,8 @@ export function Skeleton({ className, ...props }: { className?: string; [key: st
|
||||
// Card skeleton
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="p-6 rounded-xl border border-border bg-surface">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-6 rounded-xl border border-border bg-surface" aria-hidden="true">
|
||||
<div className="flex items-center justify-between mb-4 gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
</div>
|
||||
@@ -58,15 +103,22 @@ export function CardSkeleton() {
|
||||
}
|
||||
|
||||
// Default export
|
||||
export default function Loading({ type = "spinner", ...props }: { type?: string; [key: string]: any }) {
|
||||
export default function Loading({
|
||||
type = "spinner",
|
||||
className,
|
||||
message,
|
||||
size,
|
||||
label,
|
||||
...props
|
||||
}: LoadingProps) {
|
||||
switch (type) {
|
||||
case "page":
|
||||
return <PageLoading {...props} />;
|
||||
return <PageLoading message={message} className={className} />;
|
||||
case "skeleton":
|
||||
return <Skeleton {...props} />;
|
||||
return <Skeleton className={className} {...props} />;
|
||||
case "card":
|
||||
return <CardSkeleton {...props} />;
|
||||
return <CardSkeleton />;
|
||||
default:
|
||||
return <Spinner {...props} />;
|
||||
return <Spinner size={size} className={className} label={label} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Modal, Button, Input } from "@/shared/components";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import Input from "./Input";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
|
||||
|
||||
type OAuthModalProps = {
|
||||
isOpen: boolean;
|
||||
provider?: string;
|
||||
providerInfo?: { name: string } | null;
|
||||
onSuccess?: () => void;
|
||||
onClose: () => void;
|
||||
idcConfig?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* OAuth Modal Component
|
||||
* - Localhost: Auto callback via popup message
|
||||
@@ -17,7 +30,7 @@ export default function OAuthModal({
|
||||
onSuccess,
|
||||
onClose,
|
||||
idcConfig,
|
||||
}: any) {
|
||||
}: OAuthModalProps) {
|
||||
const [step, setStep] = useState("waiting"); // waiting | input | success | error
|
||||
const [authData, setAuthData] = useState(null);
|
||||
const [callbackUrl, setCallbackUrl] = useState("");
|
||||
@@ -57,9 +70,6 @@ export default function OAuthModal({
|
||||
|
||||
// Define all useCallback hooks BEFORE the useEffects that reference them
|
||||
|
||||
// Google OAuth providers that only accept pre-registered localhost redirect URIs
|
||||
const GOOGLE_OAUTH_PROVIDERS = ["antigravity", "gemini-cli"];
|
||||
|
||||
// Exchange tokens
|
||||
const exchangeTokens = useCallback(
|
||||
async (code, state) => {
|
||||
@@ -85,7 +95,7 @@ export default function OAuthModal({
|
||||
// Provide actionable guidance for redirect_uri_mismatch on Google OAuth providers
|
||||
if (
|
||||
err.message?.toLowerCase().includes("redirect_uri_mismatch") &&
|
||||
GOOGLE_OAUTH_PROVIDERS.includes(provider)
|
||||
GOOGLE_OAUTH_PROVIDERS.has(provider)
|
||||
) {
|
||||
setError(
|
||||
"redirect_uri_mismatch: As credenciais padrão do Google OAuth só funcionam em localhost. " +
|
||||
@@ -101,7 +111,6 @@ export default function OAuthModal({
|
||||
setStep("error");
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[authData, provider, onSuccess]
|
||||
);
|
||||
|
||||
@@ -249,7 +258,7 @@ export default function OAuthModal({
|
||||
let redirectUri: string;
|
||||
if (provider === "codex" || provider === "openai") {
|
||||
redirectUri = "http://localhost:1455/auth/callback";
|
||||
} else if (GOOGLE_OAUTH_PROVIDERS.includes(provider)) {
|
||||
} else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) {
|
||||
// Google OAuth built-in credentials only accept localhost redirect URIs.
|
||||
// Even in remote deployments we use localhost — user copies the callback URL manually.
|
||||
const port = window.location.port || "20128";
|
||||
@@ -498,7 +507,7 @@ export default function OAuthModal({
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Remote/LAN server info for Google OAuth providers */}
|
||||
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
@@ -519,7 +528,7 @@ export default function OAuthModal({
|
||||
</div>
|
||||
)}
|
||||
{/* Generic remote info for other providers */}
|
||||
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
|
||||
<strong>Remote access:</strong> Since you're accessing OmniRoute remotely,
|
||||
|
||||
@@ -1,37 +1,64 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Tooltip — Lightweight CSS-only Tooltip Component
|
||||
* Tooltip — Lightweight hover/focus tooltip component
|
||||
*
|
||||
* Uses the `title` attribute enhanced with custom CSS positioning.
|
||||
* Renders a positioned tooltip on hover with smooth fade-in.
|
||||
* Renders a positioned tooltip on hover/focus with delayed reveal.
|
||||
* Associates trigger and tooltip through aria-describedby for a11y.
|
||||
*
|
||||
* @module shared/components/Tooltip
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
content?: string;
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
className?: string;
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
export default function Tooltip({ children, content, position = "top", className = "" }: TooltipProps) {
|
||||
interface AriaDescribedElement {
|
||||
"aria-describedby"?: string;
|
||||
}
|
||||
|
||||
export default function Tooltip({
|
||||
children,
|
||||
content,
|
||||
position = "top",
|
||||
className = "",
|
||||
delayMs = 200,
|
||||
}: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const tooltipId = useId();
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setVisible(true), 200);
|
||||
}, []);
|
||||
timeoutRef.current = setTimeout(() => setVisible(true), delayMs);
|
||||
}, [delayMs]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const positionClasses = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
||||
@@ -39,6 +66,22 @@ export default function Tooltip({ children, content, position = "top", className
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
||||
};
|
||||
|
||||
const describedById = content ? tooltipId : undefined;
|
||||
const trigger = isValidElement(children) ? (
|
||||
(() => {
|
||||
const child = children as ReactElement<AriaDescribedElement>;
|
||||
const existingDescribedBy = child.props["aria-describedby"];
|
||||
const mergedDescribedBy = [existingDescribedBy, describedById].filter(Boolean).join(" ");
|
||||
return cloneElement(child, {
|
||||
"aria-describedby": mergedDescribedBy || undefined,
|
||||
});
|
||||
})()
|
||||
) : (
|
||||
<span tabIndex={0} aria-describedby={describedById}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex ${className}`}
|
||||
@@ -46,12 +89,16 @@ export default function Tooltip({ children, content, position = "top", className
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") hide();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{trigger}
|
||||
{visible && content && (
|
||||
<span
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
className={`absolute z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-gray-900/95 rounded-md shadow-lg whitespace-nowrap pointer-events-none animate-in fade-in duration-150 border border-white/10 ${positionClasses[position] || positionClasses.top}`}
|
||||
className={`absolute z-50 px-2.5 py-1.5 text-xs font-medium text-white bg-gray-900/95 rounded-md shadow-lg whitespace-nowrap pointer-events-none animate-in fade-in duration-150 motion-reduce:transition-none motion-reduce:animate-none border border-white/10 ${positionClasses[position] || positionClasses.top}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
|
||||
@@ -744,19 +744,34 @@ export const DEFAULT_PRICING = {
|
||||
},
|
||||
};
|
||||
|
||||
type ProviderPricingTable = Record<string, Record<string, unknown>>;
|
||||
type PricingRow = {
|
||||
input: number;
|
||||
output: number;
|
||||
cached?: number;
|
||||
reasoning?: number;
|
||||
cache_creation?: number;
|
||||
};
|
||||
type TokenUsage = Record<string, number | undefined>;
|
||||
|
||||
/**
|
||||
* Get pricing for a specific provider and model
|
||||
* @param {string} provider - Provider ID (e.g., "openai", "cc", "gc")
|
||||
* @param {string} model - Model ID
|
||||
* @returns {object|null} Pricing object or null if not found
|
||||
*/
|
||||
export function getPricingForModel(provider, model) {
|
||||
export function getPricingForModel(
|
||||
provider: string,
|
||||
model: string
|
||||
): Record<string, unknown> | null {
|
||||
if (!provider || !model) return null;
|
||||
|
||||
const providerPricing = DEFAULT_PRICING[provider];
|
||||
const providerPricing = (DEFAULT_PRICING as ProviderPricingTable)[provider];
|
||||
if (!providerPricing) return null;
|
||||
|
||||
return providerPricing[model] || null;
|
||||
const modelPricing = providerPricing[model];
|
||||
if (!modelPricing || typeof modelPricing !== "object") return null;
|
||||
return modelPricing as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -772,7 +787,7 @@ export function getDefaultPricing() {
|
||||
* @param {number} cost - Cost in dollars
|
||||
* @returns {string} Formatted cost string
|
||||
*/
|
||||
export function formatCost(cost) {
|
||||
export function formatCost(cost: number | null | undefined): string {
|
||||
if (cost === null || cost === undefined || isNaN(cost)) return "$0.00";
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
@@ -783,7 +798,10 @@ export function formatCost(cost) {
|
||||
* @param {object} pricing - Pricing object
|
||||
* @returns {number} Cost in dollars
|
||||
*/
|
||||
export function calculateCostFromTokens(tokens, pricing) {
|
||||
export function calculateCostFromTokens(
|
||||
tokens: TokenUsage | null | undefined,
|
||||
pricing: PricingRow | null | undefined
|
||||
): number {
|
||||
if (!tokens || !pricing) return 0;
|
||||
|
||||
let cost = 0;
|
||||
|
||||
@@ -2,14 +2,21 @@ import crypto from "crypto";
|
||||
|
||||
// FASE-01: No hardcoded fallback — enforced by secretsValidator at startup
|
||||
if (!process.env.API_KEY_SECRET) {
|
||||
console.error("[SECURITY] API_KEY_SECRET is not set. API key CRC will be insecure.");
|
||||
console.error("[SECURITY] API_KEY_SECRET is not set. API key CRC validation is disabled.");
|
||||
}
|
||||
|
||||
function getApiKeySecret(): string {
|
||||
const secret = process.env.API_KEY_SECRET;
|
||||
if (!secret || secret.trim() === "") {
|
||||
throw new Error("API_KEY_SECRET is required for API key CRC operations");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
const API_KEY_SECRET = process.env.API_KEY_SECRET || "omniroute-insecure-default-key";
|
||||
|
||||
/**
|
||||
* Generate 6-char random keyId
|
||||
*/
|
||||
function generateKeyId() {
|
||||
function generateKeyId(): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
@@ -21,9 +28,10 @@ function generateKeyId() {
|
||||
/**
|
||||
* Generate CRC (8-char HMAC)
|
||||
*/
|
||||
function generateCrc(machineId, keyId) {
|
||||
function generateCrc(machineId: string, keyId: string): string {
|
||||
const secret = getApiKeySecret();
|
||||
return crypto
|
||||
.createHmac("sha256", API_KEY_SECRET)
|
||||
.createHmac("sha256", secret)
|
||||
.update(machineId + keyId)
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
@@ -35,7 +43,7 @@ function generateCrc(machineId, keyId) {
|
||||
* @param {string} machineId - 16-char machine ID
|
||||
* @returns {{ key: string, keyId: string }}
|
||||
*/
|
||||
export function generateApiKeyWithMachine(machineId) {
|
||||
export function generateApiKeyWithMachine(machineId: string): { key: string; keyId: string } {
|
||||
const keyId = generateKeyId();
|
||||
const crc = generateCrc(machineId, keyId);
|
||||
const key = `sk-${machineId}-${keyId}-${crc}`;
|
||||
@@ -50,7 +58,9 @@ export function generateApiKeyWithMachine(machineId) {
|
||||
* @param {string} apiKey
|
||||
* @returns {{ machineId: string, keyId: string, isNewFormat: boolean } | null}
|
||||
*/
|
||||
export function parseApiKey(apiKey) {
|
||||
export function parseApiKey(
|
||||
apiKey: string
|
||||
): { machineId: string | null; keyId: string; isNewFormat: boolean } | null {
|
||||
if (!apiKey || !apiKey.startsWith("sk-")) return null;
|
||||
|
||||
const parts = apiKey.split("-");
|
||||
@@ -60,7 +70,12 @@ export function parseApiKey(apiKey) {
|
||||
const [, machineId, keyId, crc] = parts;
|
||||
|
||||
// Validate CRC
|
||||
const expectedCrc = generateCrc(machineId, keyId);
|
||||
let expectedCrc;
|
||||
try {
|
||||
expectedCrc = generateCrc(machineId, keyId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (crc !== expectedCrc) return null;
|
||||
|
||||
return { machineId, keyId, isNewFormat: true };
|
||||
@@ -79,7 +94,7 @@ export function parseApiKey(apiKey) {
|
||||
* @param {string} apiKey
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifyApiKeyCrc(apiKey) {
|
||||
export function verifyApiKeyCrc(apiKey: string): boolean {
|
||||
const parsed = parseApiKey(apiKey);
|
||||
if (!parsed) return false;
|
||||
|
||||
@@ -95,7 +110,7 @@ export function verifyApiKeyCrc(apiKey) {
|
||||
* @param {string} apiKey
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isNewFormatKey(apiKey) {
|
||||
export function isNewFormatKey(apiKey: string): boolean {
|
||||
const parsed = parseApiKey(apiKey);
|
||||
return parsed?.isNewFormat === true;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ApiKeyMetadata {
|
||||
id: string;
|
||||
name?: string;
|
||||
allowedModels?: string[];
|
||||
noLog?: boolean;
|
||||
budget?: number;
|
||||
usedBudget?: number;
|
||||
[key: string]: unknown;
|
||||
@@ -68,9 +69,13 @@ export async function enforceApiKeyPolicy(
|
||||
try {
|
||||
apiKeyInfo = await getApiKeyMetadata(apiKey);
|
||||
} catch (error) {
|
||||
// If metadata fetch fails, don't block — degrade gracefully, but log for debugging
|
||||
log.warn("API_POLICY", "Failed to fetch API key metadata. Request will be allowed.", { error });
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
// Fail-closed: if policy backend fails, reject the request
|
||||
log.error("API_POLICY", "Failed to fetch API key metadata. Request blocked.", { error });
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo: null,
|
||||
rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "API key policy unavailable"),
|
||||
};
|
||||
}
|
||||
|
||||
// Key not found in DB — skip policy (auth layer handles validation)
|
||||
@@ -108,8 +113,13 @@ export async function enforceApiKeyPolicy(
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Budget check is best-effort — don't block on errors, but log them
|
||||
log.warn("API_POLICY", "Budget check failed. Request will be allowed.", { error });
|
||||
// Fail-closed: budget backend error should block request
|
||||
log.error("API_POLICY", "Budget check failed. Request blocked.", { error });
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Budget policy unavailable"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
912
src/shared/validation/schemas.js
Normal file
912
src/shared/validation/schemas.js
Normal file
@@ -0,0 +1,912 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.dbBackupRestoreSchema =
|
||||
exports.testComboSchema =
|
||||
exports.updateComboSchema =
|
||||
exports.cloudSyncActionSchema =
|
||||
exports.cloudModelAliasUpdateSchema =
|
||||
exports.cloudResolveAliasSchema =
|
||||
exports.cloudCredentialUpdateSchema =
|
||||
exports.kiroSocialExchangeSchema =
|
||||
exports.kiroImportSchema =
|
||||
exports.cursorImportSchema =
|
||||
exports.oauthPollSchema =
|
||||
exports.oauthExchangeSchema =
|
||||
exports.translatorTranslateSchema =
|
||||
exports.translatorSendSchema =
|
||||
exports.translatorSaveSchema =
|
||||
exports.translatorDetectSchema =
|
||||
exports.testProxySchema =
|
||||
exports.updateProxyConfigSchema =
|
||||
exports.removeModelAliasSchema =
|
||||
exports.addModelAliasSchema =
|
||||
exports.updateModelAliasesSchema =
|
||||
exports.updateIpFilterSchema =
|
||||
exports.updateThinkingBudgetSchema =
|
||||
exports.updateSystemPromptSchema =
|
||||
exports.updateRequireLoginSchema =
|
||||
exports.updateComboDefaultsSchema =
|
||||
exports.resetStatsActionSchema =
|
||||
exports.jsonObjectSchema =
|
||||
exports.updateResilienceSchema =
|
||||
exports.toggleRateLimitSchema =
|
||||
exports.updatePricingSchema =
|
||||
exports.providerModelMutationSchema =
|
||||
exports.clearModelAvailabilitySchema =
|
||||
exports.updateModelAliasSchema =
|
||||
exports.removeFallbackSchema =
|
||||
exports.registerFallbackSchema =
|
||||
exports.policyActionSchema =
|
||||
exports.setBudgetSchema =
|
||||
exports.v1CountTokensSchema =
|
||||
exports.providerChatCompletionSchema =
|
||||
exports.v1RerankSchema =
|
||||
exports.v1ModerationSchema =
|
||||
exports.v1AudioSpeechSchema =
|
||||
exports.v1ImageGenerationSchema =
|
||||
exports.v1EmbeddingsSchema =
|
||||
exports.loginSchema =
|
||||
exports.updateSettingsSchema =
|
||||
exports.createComboSchema =
|
||||
exports.createKeySchema =
|
||||
exports.createProviderSchema =
|
||||
void 0;
|
||||
exports.guideSettingsSaveSchema =
|
||||
exports.codexProfileIdSchema =
|
||||
exports.codexProfileNameSchema =
|
||||
exports.cliModelConfigSchema =
|
||||
exports.cliSettingsEnvSchema =
|
||||
exports.cliBackupMutationSchema =
|
||||
exports.cliMitmAliasUpdateSchema =
|
||||
exports.cliMitmStopSchema =
|
||||
exports.cliMitmStartSchema =
|
||||
exports.v1betaGeminiGenerateSchema =
|
||||
exports.validateProviderApiKeySchema =
|
||||
exports.providersBatchTestSchema =
|
||||
exports.updateProviderConnectionSchema =
|
||||
exports.providerNodeValidateSchema =
|
||||
exports.updateProviderNodeSchema =
|
||||
exports.createProviderNodeSchema =
|
||||
exports.updateKeyPermissionsSchema =
|
||||
exports.evalRunSuiteSchema =
|
||||
void 0;
|
||||
exports.validateBody = validateBody;
|
||||
var zod_1 = require("zod");
|
||||
// ──── Provider Schemas ────
|
||||
exports.createProviderSchema = zod_1.z.object({
|
||||
provider: zod_1.z.string().min(1).max(100),
|
||||
apiKey: zod_1.z.string().min(1).max(10000),
|
||||
name: zod_1.z.string().min(1).max(200),
|
||||
priority: zod_1.z.number().int().min(1).max(100).optional(),
|
||||
globalPriority: zod_1.z.number().int().min(1).max(100).nullable().optional(),
|
||||
defaultModel: zod_1.z.string().max(200).nullable().optional(),
|
||||
testStatus: zod_1.z.string().max(50).optional(),
|
||||
});
|
||||
// ──── API Key Schemas ────
|
||||
exports.createKeySchema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1, "Name is required").max(200),
|
||||
});
|
||||
// ──── Combo Schemas ────
|
||||
// A model entry can be a plain string (legacy) or an object with weight
|
||||
var comboModelEntry = zod_1.z.union([
|
||||
zod_1.z.string(),
|
||||
zod_1.z.object({
|
||||
model: zod_1.z.string().min(1),
|
||||
weight: zod_1.z.number().min(0).max(100).default(0),
|
||||
}),
|
||||
]);
|
||||
// Per-combo config overrides
|
||||
var comboConfigSchema = zod_1.z
|
||||
.object({
|
||||
maxRetries: zod_1.z.number().int().min(0).max(10).optional(),
|
||||
retryDelayMs: zod_1.z.number().int().min(0).max(60000).optional(),
|
||||
timeoutMs: zod_1.z.number().int().min(1000).max(600000).optional(),
|
||||
healthCheckEnabled: zod_1.z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
var comboStrategySchema = zod_1.z.enum([
|
||||
"priority",
|
||||
"weighted",
|
||||
"round-robin",
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
]);
|
||||
var comboRuntimeConfigSchema = zod_1.z
|
||||
.object({
|
||||
strategy: comboStrategySchema.optional(),
|
||||
maxRetries: zod_1.z.coerce.number().int().min(0).max(10).optional(),
|
||||
retryDelayMs: zod_1.z.coerce.number().int().min(0).max(60000).optional(),
|
||||
timeoutMs: zod_1.z.coerce.number().int().min(1000).max(600000).optional(),
|
||||
concurrencyPerModel: zod_1.z.coerce.number().int().min(1).max(20).optional(),
|
||||
queueTimeoutMs: zod_1.z.coerce.number().int().min(1000).max(120000).optional(),
|
||||
healthCheckEnabled: zod_1.z.boolean().optional(),
|
||||
healthCheckTimeoutMs: zod_1.z.coerce.number().int().min(100).max(30000).optional(),
|
||||
maxComboDepth: zod_1.z.coerce.number().int().min(1).max(10).optional(),
|
||||
trackMetrics: zod_1.z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
exports.createComboSchema = zod_1.z.object({
|
||||
name: zod_1.z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_/.-]+$/, "Name can only contain letters, numbers, -, _, / and ."),
|
||||
models: zod_1.z.array(comboModelEntry).optional().default([]),
|
||||
strategy: comboStrategySchema.optional().default("priority"),
|
||||
config: comboConfigSchema,
|
||||
});
|
||||
// ──── Settings Schemas ────
|
||||
// FASE-01: Removed .passthrough() — only explicitly listed fields are accepted
|
||||
exports.updateSettingsSchema = zod_1.z.object({
|
||||
newPassword: zod_1.z.string().min(1).max(200).optional(),
|
||||
currentPassword: zod_1.z.string().max(200).optional(),
|
||||
theme: zod_1.z.string().max(50).optional(),
|
||||
language: zod_1.z.string().max(10).optional(),
|
||||
requireLogin: zod_1.z.boolean().optional(),
|
||||
enableRequestLogs: zod_1.z.boolean().optional(),
|
||||
enableSocks5Proxy: zod_1.z.boolean().optional(),
|
||||
instanceName: zod_1.z.string().max(100).optional(),
|
||||
corsOrigins: zod_1.z.string().max(500).optional(),
|
||||
logRetentionDays: zod_1.z.number().int().min(1).max(365).optional(),
|
||||
cloudUrl: zod_1.z.string().max(500).optional(),
|
||||
baseUrl: zod_1.z.string().max(500).optional(),
|
||||
setupComplete: zod_1.z.boolean().optional(),
|
||||
requireAuthForModels: zod_1.z.boolean().optional(),
|
||||
blockedProviders: zod_1.z.array(zod_1.z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: zod_1.z.boolean().optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: zod_1.z
|
||||
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
|
||||
.optional(),
|
||||
wildcardAliases: zod_1.z
|
||||
.array(zod_1.z.object({ pattern: zod_1.z.string(), target: zod_1.z.string() }))
|
||||
.optional(),
|
||||
stickyRoundRobinLimit: zod_1.z.number().int().min(0).max(1000).optional(),
|
||||
});
|
||||
// ──── Auth Schemas ────
|
||||
exports.loginSchema = zod_1.z.object({
|
||||
password: zod_1.z.string().min(1, "Password is required").max(200),
|
||||
});
|
||||
// ──── API Route Payload Schemas (T06) ────
|
||||
var modelIdSchema = zod_1.z.string().trim().min(1, "Model is required").max(200);
|
||||
var nonEmptyStringSchema = zod_1.z.string().trim().min(1, "Field is required");
|
||||
var embeddingTokenArraySchema = zod_1.z
|
||||
.array(zod_1.z.number().int().min(0))
|
||||
.min(1, "input token array must contain at least one item");
|
||||
var embeddingInputSchema = zod_1.z.union([
|
||||
nonEmptyStringSchema,
|
||||
zod_1.z.array(nonEmptyStringSchema).min(1, "input must contain at least one item"),
|
||||
embeddingTokenArraySchema,
|
||||
zod_1.z.array(embeddingTokenArraySchema).min(1, "input must contain at least one item"),
|
||||
]);
|
||||
var chatMessageSchema = zod_1.z
|
||||
.object({
|
||||
role: zod_1.z.string().trim().min(1, "messages[].role is required"),
|
||||
content: zod_1.z
|
||||
.union([nonEmptyStringSchema, zod_1.z.array(zod_1.z.unknown()).min(1), zod_1.z.null()])
|
||||
.optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
var countTokensMessageSchema = zod_1.z
|
||||
.object({
|
||||
content: zod_1.z.union([
|
||||
nonEmptyStringSchema,
|
||||
zod_1.z
|
||||
.array(
|
||||
zod_1.z
|
||||
.object({
|
||||
type: zod_1.z.string().optional(),
|
||||
text: zod_1.z.string().optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown())
|
||||
)
|
||||
.min(1, "messages[].content must contain at least one item"),
|
||||
]),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.v1EmbeddingsSchema = zod_1.z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
input: embeddingInputSchema,
|
||||
dimensions: zod_1.z.coerce.number().int().positive().optional(),
|
||||
encoding_format: zod_1.z.enum(["float", "base64"]).optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.v1ImageGenerationSchema = zod_1.z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
prompt: nonEmptyStringSchema,
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.v1AudioSpeechSchema = zod_1.z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
input: nonEmptyStringSchema,
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.v1ModerationSchema = zod_1.z
|
||||
.object({
|
||||
model: modelIdSchema.optional(),
|
||||
input: zod_1.z.unknown().refine(function (value) {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return true;
|
||||
}, "Input is required"),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.v1RerankSchema = zod_1.z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
query: nonEmptyStringSchema,
|
||||
documents: zod_1.z.array(zod_1.z.unknown()).min(1, "documents must contain at least one item"),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.providerChatCompletionSchema = zod_1.z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
messages: zod_1.z.array(chatMessageSchema).min(1).optional(),
|
||||
input: zod_1.z
|
||||
.union([nonEmptyStringSchema, zod_1.z.array(zod_1.z.unknown()).min(1)])
|
||||
.optional(),
|
||||
prompt: nonEmptyStringSchema.optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown())
|
||||
.superRefine(function (value, ctx) {
|
||||
if (value.messages === undefined && value.input === undefined && value.prompt === undefined) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "messages, input or prompt is required",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.v1CountTokensSchema = zod_1.z
|
||||
.object({
|
||||
messages: zod_1.z
|
||||
.array(countTokensMessageSchema)
|
||||
.min(1, "messages must contain at least one item"),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.setBudgetSchema = zod_1.z.object({
|
||||
apiKeyId: zod_1.z.string().trim().min(1, "apiKeyId is required"),
|
||||
dailyLimitUsd: zod_1.z.coerce.number().positive("dailyLimitUsd must be greater than zero"),
|
||||
monthlyLimitUsd: zod_1.z.coerce
|
||||
.number()
|
||||
.positive("monthlyLimitUsd must be greater than zero")
|
||||
.optional(),
|
||||
warningThreshold: zod_1.z.coerce.number().min(0).max(1).optional(),
|
||||
});
|
||||
exports.policyActionSchema = zod_1.z
|
||||
.object({
|
||||
action: zod_1.z.enum(["unlock"]),
|
||||
identifier: zod_1.z.string().trim().min(1).optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (value.action === "unlock" && !value.identifier) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "identifier is required for unlock action",
|
||||
path: ["identifier"],
|
||||
});
|
||||
}
|
||||
});
|
||||
var fallbackChainEntrySchema = zod_1.z
|
||||
.object({
|
||||
provider: zod_1.z.string().trim().min(1, "provider is required"),
|
||||
priority: zod_1.z.number().int().min(1).max(100).optional(),
|
||||
enabled: zod_1.z.boolean().optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.registerFallbackSchema = zod_1.z.object({
|
||||
model: modelIdSchema,
|
||||
chain: zod_1.z.array(fallbackChainEntrySchema).min(1, "chain must contain at least one provider"),
|
||||
});
|
||||
exports.removeFallbackSchema = zod_1.z.object({
|
||||
model: modelIdSchema,
|
||||
});
|
||||
exports.updateModelAliasSchema = zod_1.z.object({
|
||||
model: modelIdSchema,
|
||||
alias: zod_1.z.string().trim().min(1, "Alias is required").max(200),
|
||||
});
|
||||
exports.clearModelAvailabilitySchema = zod_1.z.object({
|
||||
provider: zod_1.z.string().trim().min(1, "provider is required").max(120),
|
||||
model: modelIdSchema,
|
||||
});
|
||||
exports.providerModelMutationSchema = zod_1.z.object({
|
||||
provider: zod_1.z.string().trim().min(1, "provider is required").max(120),
|
||||
modelId: zod_1.z.string().trim().min(1, "modelId is required").max(240),
|
||||
modelName: zod_1.z.string().trim().max(240).optional(),
|
||||
source: zod_1.z.string().trim().max(80).optional(),
|
||||
});
|
||||
var pricingFieldsSchema = zod_1.z
|
||||
.object({
|
||||
input: zod_1.z.number().min(0).optional(),
|
||||
output: zod_1.z.number().min(0).optional(),
|
||||
cached: zod_1.z.number().min(0).optional(),
|
||||
reasoning: zod_1.z.number().min(0).optional(),
|
||||
cache_creation: zod_1.z.number().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
exports.updatePricingSchema = zod_1.z.record(
|
||||
zod_1.z.string().trim().min(1),
|
||||
zod_1.z.record(zod_1.z.string().trim().min(1), pricingFieldsSchema)
|
||||
);
|
||||
exports.toggleRateLimitSchema = zod_1.z.object({
|
||||
connectionId: zod_1.z.string().trim().min(1, "connectionId is required"),
|
||||
enabled: zod_1.z.boolean(),
|
||||
});
|
||||
var resilienceProfileSchema = zod_1.z.object({
|
||||
transientCooldown: zod_1.z.number().min(0),
|
||||
rateLimitCooldown: zod_1.z.number().min(0),
|
||||
maxBackoffLevel: zod_1.z.number().int().min(0),
|
||||
circuitBreakerThreshold: zod_1.z.number().int().min(0),
|
||||
circuitBreakerReset: zod_1.z.number().min(0),
|
||||
});
|
||||
var resilienceDefaultsSchema = zod_1.z
|
||||
.object({
|
||||
requestsPerMinute: zod_1.z.number().int().min(1).optional(),
|
||||
minTimeBetweenRequests: zod_1.z.number().int().min(1).optional(),
|
||||
concurrentRequests: zod_1.z.number().int().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
exports.updateResilienceSchema = zod_1.z
|
||||
.object({
|
||||
profiles: zod_1.z
|
||||
.object({
|
||||
oauth: resilienceProfileSchema.optional(),
|
||||
apikey: resilienceProfileSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
defaults: resilienceDefaultsSchema.optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (!value.profiles && !value.defaults) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "Must provide profiles or defaults",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.jsonObjectSchema = zod_1.z.record(zod_1.z.string(), zod_1.z.unknown());
|
||||
exports.resetStatsActionSchema = zod_1.z.object({
|
||||
action: zod_1.z.literal("reset-stats"),
|
||||
});
|
||||
exports.updateComboDefaultsSchema = zod_1.z
|
||||
.object({
|
||||
comboDefaults: comboRuntimeConfigSchema.optional(),
|
||||
providerOverrides: zod_1.z
|
||||
.record(zod_1.z.string().trim().min(1), comboRuntimeConfigSchema)
|
||||
.optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (!value.comboDefaults && !value.providerOverrides) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "Nothing to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.updateRequireLoginSchema = zod_1.z
|
||||
.object({
|
||||
requireLogin: zod_1.z.boolean().optional(),
|
||||
password: zod_1.z.string().min(4, "Password must be at least 4 characters").optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (value.requireLogin === undefined && !value.password) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.updateSystemPromptSchema = zod_1.z
|
||||
.object({
|
||||
prompt: zod_1.z.string().max(50000).optional(),
|
||||
enabled: zod_1.z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(function (value, ctx) {
|
||||
if (value.prompt === undefined && value.enabled === undefined) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.updateThinkingBudgetSchema = zod_1.z
|
||||
.object({
|
||||
mode: zod_1.z.enum(["passthrough", "auto", "custom", "adaptive"]).optional(),
|
||||
customBudget: zod_1.z.coerce.number().int().min(0).max(131072).optional(),
|
||||
effortLevel: zod_1.z.enum(["none", "low", "medium", "high"]).optional(),
|
||||
baseBudget: zod_1.z.coerce.number().int().min(0).max(131072).optional(),
|
||||
complexityMultiplier: zod_1.z.coerce.number().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(function (value, ctx) {
|
||||
if (
|
||||
value.mode === undefined &&
|
||||
value.customBudget === undefined &&
|
||||
value.effortLevel === undefined &&
|
||||
value.baseBudget === undefined &&
|
||||
value.complexityMultiplier === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
var ipFilterModeSchema = zod_1.z.enum(["blacklist", "whitelist"]);
|
||||
var tempBanSchema = zod_1.z.object({
|
||||
ip: zod_1.z.string().trim().min(1),
|
||||
durationMs: zod_1.z.coerce.number().int().min(1).optional(),
|
||||
reason: zod_1.z.string().max(200).optional(),
|
||||
});
|
||||
exports.updateIpFilterSchema = zod_1.z
|
||||
.object({
|
||||
enabled: zod_1.z.boolean().optional(),
|
||||
mode: ipFilterModeSchema.optional(),
|
||||
blacklist: zod_1.z.array(zod_1.z.string()).optional(),
|
||||
whitelist: zod_1.z.array(zod_1.z.string()).optional(),
|
||||
addBlacklist: zod_1.z.string().optional(),
|
||||
removeBlacklist: zod_1.z.string().optional(),
|
||||
addWhitelist: zod_1.z.string().optional(),
|
||||
removeWhitelist: zod_1.z.string().optional(),
|
||||
tempBan: tempBanSchema.optional(),
|
||||
removeBan: zod_1.z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(function (value, ctx) {
|
||||
if (Object.keys(value).length === 0) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.updateModelAliasesSchema = zod_1.z.object({
|
||||
aliases: zod_1.z.record(zod_1.z.string().trim().min(1), zod_1.z.string().trim().min(1)),
|
||||
});
|
||||
exports.addModelAliasSchema = zod_1.z.object({
|
||||
from: zod_1.z.string().trim().min(1),
|
||||
to: zod_1.z.string().trim().min(1),
|
||||
});
|
||||
exports.removeModelAliasSchema = zod_1.z.object({
|
||||
from: zod_1.z.string().trim().min(1),
|
||||
});
|
||||
var proxyConfigSchema = zod_1.z
|
||||
.object({
|
||||
type: zod_1.z
|
||||
.preprocess(
|
||||
function (value) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : value;
|
||||
},
|
||||
zod_1.z.enum(["http", "https", "socks5"])
|
||||
)
|
||||
.optional(),
|
||||
host: zod_1.z.string().trim().min(1).optional(),
|
||||
port: zod_1.z.coerce.number().int().min(1).max(65535).optional(),
|
||||
username: zod_1.z.string().optional(),
|
||||
password: zod_1.z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
exports.updateProxyConfigSchema = zod_1.z
|
||||
.object({
|
||||
proxy: proxyConfigSchema.nullable().optional(),
|
||||
global: proxyConfigSchema.nullable().optional(),
|
||||
providers: zod_1.z
|
||||
.record(zod_1.z.string().trim().min(1), proxyConfigSchema.nullable())
|
||||
.optional(),
|
||||
combos: zod_1.z.record(zod_1.z.string().trim().min(1), proxyConfigSchema.nullable()).optional(),
|
||||
keys: zod_1.z.record(zod_1.z.string().trim().min(1), proxyConfigSchema.nullable()).optional(),
|
||||
level: zod_1.z.enum(["global", "provider", "combo", "key"]).optional(),
|
||||
id: zod_1.z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(function (value, ctx) {
|
||||
var _a;
|
||||
var hasPayload =
|
||||
value.proxy !== undefined ||
|
||||
value.global !== undefined ||
|
||||
value.providers !== undefined ||
|
||||
value.combos !== undefined ||
|
||||
value.keys !== undefined ||
|
||||
value.level !== undefined;
|
||||
if (!hasPayload) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
if (value.level !== undefined && value.proxy === undefined) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "proxy is required when level is provided",
|
||||
path: ["proxy"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
value.level &&
|
||||
value.level !== "global" &&
|
||||
!((_a = value.id) === null || _a === void 0 ? void 0 : _a.trim())
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "id is required for provider/combo/key level updates",
|
||||
path: ["id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.testProxySchema = zod_1.z.object({
|
||||
proxy: zod_1.z.object({
|
||||
type: zod_1.z.string().optional(),
|
||||
host: zod_1.z.string().trim().min(1, "proxy.host is required"),
|
||||
port: zod_1.z.union([zod_1.z.string(), zod_1.z.number()]),
|
||||
username: zod_1.z.string().optional(),
|
||||
password: zod_1.z.string().optional(),
|
||||
}),
|
||||
});
|
||||
var jsonRecordSchema = zod_1.z.record(zod_1.z.string(), zod_1.z.unknown());
|
||||
var nonEmptyJsonRecordSchema = jsonRecordSchema.refine(function (value) {
|
||||
return Object.keys(value).length > 0;
|
||||
}, "Body must be a non-empty object");
|
||||
var translatorLogFileSchema = zod_1.z.enum([
|
||||
"1_req_client.json",
|
||||
"2_req_source.json",
|
||||
"3_req_openai.json",
|
||||
"4_req_target.json",
|
||||
"5_res_provider.txt",
|
||||
]);
|
||||
exports.translatorDetectSchema = zod_1.z.object({
|
||||
body: nonEmptyJsonRecordSchema,
|
||||
});
|
||||
exports.translatorSaveSchema = zod_1.z.object({
|
||||
file: translatorLogFileSchema,
|
||||
content: zod_1.z.string().min(1, "Content is required").max(1000000, "Content is too large"),
|
||||
});
|
||||
exports.translatorSendSchema = zod_1.z.object({
|
||||
provider: zod_1.z.string().trim().min(1, "Provider is required"),
|
||||
body: nonEmptyJsonRecordSchema,
|
||||
});
|
||||
exports.translatorTranslateSchema = zod_1.z
|
||||
.object({
|
||||
step: zod_1.z.union([zod_1.z.number().int().min(1).max(4), zod_1.z.literal("direct")]),
|
||||
provider: zod_1.z.string().trim().min(1).optional(),
|
||||
body: nonEmptyJsonRecordSchema,
|
||||
sourceFormat: zod_1.z.string().optional(),
|
||||
targetFormat: zod_1.z.string().optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (value.step !== "direct" && !value.provider) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "Step and provider are required",
|
||||
path: ["provider"],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.oauthExchangeSchema = zod_1.z.object({
|
||||
code: zod_1.z.string().trim().min(1),
|
||||
redirectUri: zod_1.z.string().trim().min(1),
|
||||
codeVerifier: zod_1.z.string().trim().min(1),
|
||||
state: zod_1.z.string().optional(),
|
||||
});
|
||||
exports.oauthPollSchema = zod_1.z.object({
|
||||
deviceCode: zod_1.z.string().trim().min(1),
|
||||
codeVerifier: zod_1.z.string().optional(),
|
||||
extraData: zod_1.z.unknown().optional(),
|
||||
});
|
||||
exports.cursorImportSchema = zod_1.z.object({
|
||||
accessToken: zod_1.z.string().trim().min(1, "Access token is required"),
|
||||
machineId: zod_1.z.string().trim().min(1, "Machine ID is required"),
|
||||
});
|
||||
exports.kiroImportSchema = zod_1.z.object({
|
||||
refreshToken: zod_1.z.string().trim().min(1, "Refresh token is required"),
|
||||
});
|
||||
exports.kiroSocialExchangeSchema = zod_1.z.object({
|
||||
code: zod_1.z.string().trim().min(1, "Code is required"),
|
||||
codeVerifier: zod_1.z.string().trim().min(1, "Code verifier is required"),
|
||||
provider: zod_1.z.enum(["google", "github"]),
|
||||
});
|
||||
exports.cloudCredentialUpdateSchema = zod_1.z.object({
|
||||
provider: zod_1.z.string().trim().min(1, "Provider is required"),
|
||||
credentials: zod_1.z
|
||||
.object({
|
||||
accessToken: zod_1.z.string().optional(),
|
||||
refreshToken: zod_1.z.string().optional(),
|
||||
expiresIn: zod_1.z.coerce.number().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(function (value, ctx) {
|
||||
if (
|
||||
value.accessToken === undefined &&
|
||||
value.refreshToken === undefined &&
|
||||
value.expiresIn === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "At least one credential field must be provided",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
exports.cloudResolveAliasSchema = zod_1.z.object({
|
||||
alias: zod_1.z.string().trim().min(1, "Missing alias"),
|
||||
});
|
||||
exports.cloudModelAliasUpdateSchema = zod_1.z.object({
|
||||
model: zod_1.z.string().trim().min(1, "Model and alias required"),
|
||||
alias: zod_1.z.string().trim().min(1, "Model and alias required"),
|
||||
});
|
||||
exports.cloudSyncActionSchema = zod_1.z.object({
|
||||
action: zod_1.z.enum(["enable", "sync", "disable"]),
|
||||
});
|
||||
exports.updateComboSchema = zod_1.z
|
||||
.object({
|
||||
name: zod_1.z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_/.-]+$/, "Name can only contain letters, numbers, -, _, / and .")
|
||||
.optional(),
|
||||
models: zod_1.z.array(comboModelEntry).optional(),
|
||||
strategy: comboStrategySchema.optional(),
|
||||
config: comboRuntimeConfigSchema.optional(),
|
||||
isActive: zod_1.z.boolean().optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (
|
||||
value.name === undefined &&
|
||||
value.models === undefined &&
|
||||
value.strategy === undefined &&
|
||||
value.config === undefined &&
|
||||
value.isActive === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.testComboSchema = zod_1.z.object({
|
||||
comboName: zod_1.z.string().trim().min(1, "comboName is required"),
|
||||
});
|
||||
exports.dbBackupRestoreSchema = zod_1.z.object({
|
||||
backupId: zod_1.z.string().trim().min(1, "backupId is required"),
|
||||
});
|
||||
exports.evalRunSuiteSchema = zod_1.z.object({
|
||||
suiteId: zod_1.z.string().trim().min(1, "suiteId is required"),
|
||||
outputs: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
||||
});
|
||||
exports.updateKeyPermissionsSchema = zod_1.z
|
||||
.object({
|
||||
allowedModels: zod_1.z.array(zod_1.z.string().trim().min(1)).max(1000).optional(),
|
||||
noLog: zod_1.z.boolean().optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (value.allowedModels === undefined && value.noLog === undefined) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.createProviderNodeSchema = zod_1.z
|
||||
.object({
|
||||
name: zod_1.z.string().trim().min(1, "Name is required"),
|
||||
prefix: zod_1.z.string().trim().min(1, "Prefix is required"),
|
||||
apiType: zod_1.z.enum(["chat", "responses"]).optional(),
|
||||
baseUrl: zod_1.z.string().trim().min(1).optional(),
|
||||
type: zod_1.z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
var nodeType = value.type || "openai-compatible";
|
||||
if (nodeType === "openai-compatible" && !value.apiType) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "Invalid OpenAI compatible API type",
|
||||
path: ["apiType"],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.updateProviderNodeSchema = zod_1.z.object({
|
||||
name: zod_1.z.string().trim().min(1, "Name is required"),
|
||||
prefix: zod_1.z.string().trim().min(1, "Prefix is required"),
|
||||
apiType: zod_1.z.enum(["chat", "responses"]).optional(),
|
||||
baseUrl: zod_1.z.string().trim().min(1, "Base URL is required"),
|
||||
});
|
||||
exports.providerNodeValidateSchema = zod_1.z.object({
|
||||
baseUrl: zod_1.z.string().trim().min(1, "Base URL and API key required"),
|
||||
apiKey: zod_1.z.string().trim().min(1, "Base URL and API key required"),
|
||||
type: zod_1.z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
|
||||
});
|
||||
exports.updateProviderConnectionSchema = zod_1.z
|
||||
.object({
|
||||
name: zod_1.z.string().max(200).optional(),
|
||||
priority: zod_1.z.coerce.number().int().min(1).max(100).optional(),
|
||||
globalPriority: zod_1.z
|
||||
.union([zod_1.z.coerce.number().int().min(1).max(100), zod_1.z.null()])
|
||||
.optional(),
|
||||
defaultModel: zod_1.z.union([zod_1.z.string().max(200), zod_1.z.null()]).optional(),
|
||||
isActive: zod_1.z.boolean().optional(),
|
||||
apiKey: zod_1.z.string().max(10000).optional(),
|
||||
testStatus: zod_1.z.string().max(50).optional(),
|
||||
lastError: zod_1.z.union([zod_1.z.string(), zod_1.z.null()]).optional(),
|
||||
lastErrorAt: zod_1.z.union([zod_1.z.string(), zod_1.z.null()]).optional(),
|
||||
lastErrorType: zod_1.z.union([zod_1.z.string(), zod_1.z.null()]).optional(),
|
||||
lastErrorSource: zod_1.z.union([zod_1.z.string(), zod_1.z.null()]).optional(),
|
||||
errorCode: zod_1.z.union([zod_1.z.string(), zod_1.z.null()]).optional(),
|
||||
rateLimitedUntil: zod_1.z.union([zod_1.z.string(), zod_1.z.null()]).optional(),
|
||||
lastTested: zod_1.z.union([zod_1.z.string(), zod_1.z.null()]).optional(),
|
||||
healthCheckInterval: zod_1.z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (Object.keys(value).length === 0) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.providersBatchTestSchema = zod_1.z
|
||||
.object({
|
||||
mode: zod_1.z.enum(["provider", "oauth", "free", "apikey", "compatible", "all"]),
|
||||
providerId: zod_1.z.string().trim().min(1).optional(),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (value.mode === "provider" && !value.providerId) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "providerId is required when mode=provider",
|
||||
path: ["providerId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.validateProviderApiKeySchema = zod_1.z.object({
|
||||
provider: zod_1.z.string().trim().min(1, "Provider and API key required"),
|
||||
apiKey: zod_1.z.string().trim().min(1, "Provider and API key required"),
|
||||
});
|
||||
var geminiPartSchema = zod_1.z
|
||||
.object({
|
||||
text: zod_1.z.string().optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
var geminiContentSchema = zod_1.z
|
||||
.object({
|
||||
role: zod_1.z.string().optional(),
|
||||
parts: zod_1.z.array(geminiPartSchema).optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown());
|
||||
exports.v1betaGeminiGenerateSchema = zod_1.z
|
||||
.object({
|
||||
contents: zod_1.z.array(geminiContentSchema).optional(),
|
||||
systemInstruction: zod_1.z
|
||||
.object({
|
||||
parts: zod_1.z.array(geminiPartSchema).optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown())
|
||||
.optional(),
|
||||
generationConfig: zod_1.z
|
||||
.object({
|
||||
stream: zod_1.z.boolean().optional(),
|
||||
maxOutputTokens: zod_1.z.coerce.number().int().min(1).optional(),
|
||||
temperature: zod_1.z.coerce.number().optional(),
|
||||
topP: zod_1.z.coerce.number().optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown())
|
||||
.optional(),
|
||||
})
|
||||
.catchall(zod_1.z.unknown())
|
||||
.superRefine(function (value, ctx) {
|
||||
if (!value.contents && !value.systemInstruction) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "contents or systemInstruction is required",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.cliMitmStartSchema = zod_1.z.object({
|
||||
apiKey: zod_1.z.string().trim().min(1, "Missing apiKey"),
|
||||
sudoPassword: zod_1.z.string().optional(),
|
||||
});
|
||||
exports.cliMitmStopSchema = zod_1.z.object({
|
||||
sudoPassword: zod_1.z.string().optional(),
|
||||
});
|
||||
exports.cliMitmAliasUpdateSchema = zod_1.z.object({
|
||||
tool: zod_1.z.string().trim().min(1, "tool and mappings required"),
|
||||
mappings: zod_1.z.record(zod_1.z.string(), zod_1.z.string().optional()),
|
||||
});
|
||||
exports.cliBackupMutationSchema = zod_1.z
|
||||
.object({
|
||||
tool: zod_1.z.string().trim().min(1).optional(),
|
||||
toolId: zod_1.z.string().trim().min(1).optional(),
|
||||
backupId: zod_1.z.string().trim().min(1, "tool and backupId are required"),
|
||||
})
|
||||
.superRefine(function (value, ctx) {
|
||||
if (!value.tool && !value.toolId) {
|
||||
ctx.addIssue({
|
||||
code: zod_1.z.ZodIssueCode.custom,
|
||||
message: "tool and backupId are required",
|
||||
path: ["tool"],
|
||||
});
|
||||
}
|
||||
});
|
||||
var envKeySchema = zod_1.z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Environment key is required")
|
||||
.max(120)
|
||||
.regex(/^[A-Z_][A-Z0-9_]*$/, "Invalid environment key format");
|
||||
var envValueSchema = zod_1.z
|
||||
.union([zod_1.z.string(), zod_1.z.number(), zod_1.z.boolean()])
|
||||
.transform(function (value) {
|
||||
return String(value);
|
||||
})
|
||||
.refine(function (value) {
|
||||
return value.length > 0;
|
||||
}, "Environment value is required")
|
||||
.refine(function (value) {
|
||||
return value.length <= 10000;
|
||||
}, "Environment value is too long");
|
||||
exports.cliSettingsEnvSchema = zod_1.z.object({
|
||||
env: zod_1.z.record(envKeySchema, envValueSchema).refine(function (value) {
|
||||
return Object.keys(value).length > 0;
|
||||
}, "env must contain at least one key"),
|
||||
});
|
||||
exports.cliModelConfigSchema = zod_1.z.object({
|
||||
baseUrl: zod_1.z.string().trim().min(1, "baseUrl and model are required"),
|
||||
apiKey: zod_1.z.string().optional(),
|
||||
model: zod_1.z.string().trim().min(1, "baseUrl and model are required"),
|
||||
});
|
||||
exports.codexProfileNameSchema = zod_1.z.object({
|
||||
name: zod_1.z.string().trim().min(1, "Profile name is required"),
|
||||
});
|
||||
exports.codexProfileIdSchema = zod_1.z.object({
|
||||
profileId: zod_1.z.string().trim().min(1, "profileId is required"),
|
||||
});
|
||||
exports.guideSettingsSaveSchema = zod_1.z.object({
|
||||
baseUrl: zod_1.z.string().trim().min(1).optional(),
|
||||
apiKey: zod_1.z.string().optional(),
|
||||
model: zod_1.z.string().trim().min(1, "Model is required"),
|
||||
});
|
||||
// ──── Helper ────
|
||||
/**
|
||||
* Parse and validate request body with a Zod schema.
|
||||
* Returns { success: true, data } or { success: false, error }.
|
||||
*/
|
||||
function validateBody(schema, body) {
|
||||
var _a;
|
||||
var result = schema.safeParse(body);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
var issues = Array.isArray((_a = result.error) === null || _a === void 0 ? void 0 : _a.issues)
|
||||
? result.error.issues
|
||||
: [];
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: issues.map(function (e) {
|
||||
return {
|
||||
field: e.path.join("."),
|
||||
message: e.message,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
type ValidationErrorDetail = {
|
||||
field: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ValidationErrorPayload = {
|
||||
message: string;
|
||||
details: ValidationErrorDetail[];
|
||||
};
|
||||
|
||||
type ValidationSuccess<TData> = {
|
||||
success: true;
|
||||
data: TData;
|
||||
};
|
||||
|
||||
type ValidationFailure = {
|
||||
success: false;
|
||||
error: ValidationErrorPayload;
|
||||
};
|
||||
|
||||
export type ValidationResult<TData> = ValidationSuccess<TData> | ValidationFailure;
|
||||
|
||||
// ──── Provider Schemas ────
|
||||
|
||||
export const createProviderSchema = z.object({
|
||||
@@ -39,6 +61,30 @@ const comboConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const comboStrategySchema = z.enum([
|
||||
"priority",
|
||||
"weighted",
|
||||
"round-robin",
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
]);
|
||||
|
||||
const comboRuntimeConfigSchema = z
|
||||
.object({
|
||||
strategy: comboStrategySchema.optional(),
|
||||
maxRetries: z.coerce.number().int().min(0).max(10).optional(),
|
||||
retryDelayMs: z.coerce.number().int().min(0).max(60000).optional(),
|
||||
timeoutMs: z.coerce.number().int().min(1000).max(600000).optional(),
|
||||
concurrencyPerModel: z.coerce.number().int().min(1).max(20).optional(),
|
||||
queueTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(),
|
||||
healthCheckEnabled: z.boolean().optional(),
|
||||
healthCheckTimeoutMs: z.coerce.number().int().min(100).max(30000).optional(),
|
||||
maxComboDepth: z.coerce.number().int().min(1).max(10).optional(),
|
||||
trackMetrics: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createComboSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
@@ -46,10 +92,7 @@ export const createComboSchema = z.object({
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_/.-]+$/, "Name can only contain letters, numbers, -, _, / and ."),
|
||||
models: z.array(comboModelEntry).optional().default([]),
|
||||
strategy: z
|
||||
.enum(["priority", "weighted", "round-robin", "random", "least-used", "cost-optimized"])
|
||||
.optional()
|
||||
.default("priority"),
|
||||
strategy: comboStrategySchema.optional().default("priority"),
|
||||
config: comboConfigSchema,
|
||||
});
|
||||
|
||||
@@ -87,13 +130,786 @@ export const loginSchema = z.object({
|
||||
password: z.string().min(1, "Password is required").max(200),
|
||||
});
|
||||
|
||||
// ──── API Route Payload Schemas (T06) ────
|
||||
|
||||
const modelIdSchema = z.string().trim().min(1, "Model is required").max(200);
|
||||
const nonEmptyStringSchema = z.string().trim().min(1, "Field is required");
|
||||
const embeddingTokenArraySchema = z
|
||||
.array(z.number().int().min(0))
|
||||
.min(1, "input token array must contain at least one item");
|
||||
const embeddingInputSchema = z.union([
|
||||
nonEmptyStringSchema,
|
||||
z.array(nonEmptyStringSchema).min(1, "input must contain at least one item"),
|
||||
embeddingTokenArraySchema,
|
||||
z.array(embeddingTokenArraySchema).min(1, "input must contain at least one item"),
|
||||
]);
|
||||
const chatMessageSchema = z
|
||||
.object({
|
||||
role: z.string().trim().min(1, "messages[].role is required"),
|
||||
content: z.union([nonEmptyStringSchema, z.array(z.unknown()).min(1), z.null()]).optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
const countTokensMessageSchema = z
|
||||
.object({
|
||||
content: z.union([
|
||||
nonEmptyStringSchema,
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
)
|
||||
.min(1, "messages[].content must contain at least one item"),
|
||||
]),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const v1EmbeddingsSchema = z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
input: embeddingInputSchema,
|
||||
dimensions: z.coerce.number().int().positive().optional(),
|
||||
encoding_format: z.enum(["float", "base64"]).optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const v1ImageGenerationSchema = z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
prompt: nonEmptyStringSchema,
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const v1AudioSpeechSchema = z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
input: nonEmptyStringSchema,
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const v1ModerationSchema = z
|
||||
.object({
|
||||
model: modelIdSchema.optional(),
|
||||
input: z.unknown().refine((value) => {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return true;
|
||||
}, "Input is required"),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const v1RerankSchema = z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
query: nonEmptyStringSchema,
|
||||
documents: z.array(z.unknown()).min(1, "documents must contain at least one item"),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const providerChatCompletionSchema = z
|
||||
.object({
|
||||
model: modelIdSchema,
|
||||
messages: z.array(chatMessageSchema).min(1).optional(),
|
||||
input: z.union([nonEmptyStringSchema, z.array(z.unknown()).min(1)]).optional(),
|
||||
prompt: nonEmptyStringSchema.optional(),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.messages === undefined && value.input === undefined && value.prompt === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "messages, input or prompt is required",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const v1CountTokensSchema = z
|
||||
.object({
|
||||
messages: z.array(countTokensMessageSchema).min(1, "messages must contain at least one item"),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const setBudgetSchema = z.object({
|
||||
apiKeyId: z.string().trim().min(1, "apiKeyId is required"),
|
||||
dailyLimitUsd: z.coerce.number().positive("dailyLimitUsd must be greater than zero"),
|
||||
monthlyLimitUsd: z.coerce
|
||||
.number()
|
||||
.positive("monthlyLimitUsd must be greater than zero")
|
||||
.optional(),
|
||||
warningThreshold: z.coerce.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export const policyActionSchema = z
|
||||
.object({
|
||||
action: z.enum(["unlock"]),
|
||||
identifier: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.action === "unlock" && !value.identifier) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "identifier is required for unlock action",
|
||||
path: ["identifier"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const fallbackChainEntrySchema = z
|
||||
.object({
|
||||
provider: z.string().trim().min(1, "provider is required"),
|
||||
priority: z.number().int().min(1).max(100).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const registerFallbackSchema = z.object({
|
||||
model: modelIdSchema,
|
||||
chain: z.array(fallbackChainEntrySchema).min(1, "chain must contain at least one provider"),
|
||||
});
|
||||
|
||||
export const removeFallbackSchema = z.object({
|
||||
model: modelIdSchema,
|
||||
});
|
||||
|
||||
export const updateModelAliasSchema = z.object({
|
||||
model: modelIdSchema,
|
||||
alias: z.string().trim().min(1, "Alias is required").max(200),
|
||||
});
|
||||
|
||||
export const clearModelAvailabilitySchema = z.object({
|
||||
provider: z.string().trim().min(1, "provider is required").max(120),
|
||||
model: modelIdSchema,
|
||||
});
|
||||
|
||||
export const providerModelMutationSchema = z.object({
|
||||
provider: z.string().trim().min(1, "provider is required").max(120),
|
||||
modelId: z.string().trim().min(1, "modelId is required").max(240),
|
||||
modelName: z.string().trim().max(240).optional(),
|
||||
source: z.string().trim().max(80).optional(),
|
||||
});
|
||||
|
||||
const pricingFieldsSchema = z
|
||||
.object({
|
||||
input: z.number().min(0).optional(),
|
||||
output: z.number().min(0).optional(),
|
||||
cached: z.number().min(0).optional(),
|
||||
reasoning: z.number().min(0).optional(),
|
||||
cache_creation: z.number().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const updatePricingSchema = z.record(
|
||||
z.string().trim().min(1),
|
||||
z.record(z.string().trim().min(1), pricingFieldsSchema)
|
||||
);
|
||||
|
||||
export const toggleRateLimitSchema = z.object({
|
||||
connectionId: z.string().trim().min(1, "connectionId is required"),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
const resilienceProfileSchema = z.object({
|
||||
transientCooldown: z.number().min(0),
|
||||
rateLimitCooldown: z.number().min(0),
|
||||
maxBackoffLevel: z.number().int().min(0),
|
||||
circuitBreakerThreshold: z.number().int().min(0),
|
||||
circuitBreakerReset: z.number().min(0),
|
||||
});
|
||||
|
||||
const resilienceDefaultsSchema = z
|
||||
.object({
|
||||
requestsPerMinute: z.number().int().min(1).optional(),
|
||||
minTimeBetweenRequests: z.number().int().min(1).optional(),
|
||||
concurrentRequests: z.number().int().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const updateResilienceSchema = z
|
||||
.object({
|
||||
profiles: z
|
||||
.object({
|
||||
oauth: resilienceProfileSchema.optional(),
|
||||
apikey: resilienceProfileSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
defaults: resilienceDefaultsSchema.optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.profiles && !value.defaults) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Must provide profiles or defaults",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
export const resetStatsActionSchema = z.object({
|
||||
action: z.literal("reset-stats"),
|
||||
});
|
||||
|
||||
export const updateComboDefaultsSchema = z
|
||||
.object({
|
||||
comboDefaults: comboRuntimeConfigSchema.optional(),
|
||||
providerOverrides: z.record(z.string().trim().min(1), comboRuntimeConfigSchema).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.comboDefaults && !value.providerOverrides) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Nothing to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const updateRequireLoginSchema = z
|
||||
.object({
|
||||
requireLogin: z.boolean().optional(),
|
||||
password: z.string().min(4, "Password must be at least 4 characters").optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.requireLogin === undefined && !value.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const updateSystemPromptSchema = z
|
||||
.object({
|
||||
prompt: z.string().max(50000).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.prompt === undefined && value.enabled === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const updateThinkingBudgetSchema = z
|
||||
.object({
|
||||
mode: z.enum(["passthrough", "auto", "custom", "adaptive"]).optional(),
|
||||
customBudget: z.coerce.number().int().min(0).max(131072).optional(),
|
||||
effortLevel: z.enum(["none", "low", "medium", "high"]).optional(),
|
||||
baseBudget: z.coerce.number().int().min(0).max(131072).optional(),
|
||||
complexityMultiplier: z.coerce.number().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
value.mode === undefined &&
|
||||
value.customBudget === undefined &&
|
||||
value.effortLevel === undefined &&
|
||||
value.baseBudget === undefined &&
|
||||
value.complexityMultiplier === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ipFilterModeSchema = z.enum(["blacklist", "whitelist"]);
|
||||
const tempBanSchema = z.object({
|
||||
ip: z.string().trim().min(1),
|
||||
durationMs: z.coerce.number().int().min(1).optional(),
|
||||
reason: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const updateIpFilterSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
mode: ipFilterModeSchema.optional(),
|
||||
blacklist: z.array(z.string()).optional(),
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
addBlacklist: z.string().optional(),
|
||||
removeBlacklist: z.string().optional(),
|
||||
addWhitelist: z.string().optional(),
|
||||
removeWhitelist: z.string().optional(),
|
||||
tempBan: tempBanSchema.optional(),
|
||||
removeBan: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (Object.keys(value).length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const updateModelAliasesSchema = z.object({
|
||||
aliases: z.record(z.string().trim().min(1), z.string().trim().min(1)),
|
||||
});
|
||||
|
||||
export const addModelAliasSchema = z.object({
|
||||
from: z.string().trim().min(1),
|
||||
to: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const removeModelAliasSchema = z.object({
|
||||
from: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const proxyConfigSchema = z
|
||||
.object({
|
||||
type: z
|
||||
.preprocess(
|
||||
(value) => (typeof value === "string" ? value.trim().toLowerCase() : value),
|
||||
z.enum(["http", "https", "socks5"])
|
||||
)
|
||||
.optional(),
|
||||
host: z.string().trim().min(1).optional(),
|
||||
port: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const updateProxyConfigSchema = z
|
||||
.object({
|
||||
proxy: proxyConfigSchema.nullable().optional(),
|
||||
global: proxyConfigSchema.nullable().optional(),
|
||||
providers: z.record(z.string().trim().min(1), proxyConfigSchema.nullable()).optional(),
|
||||
combos: z.record(z.string().trim().min(1), proxyConfigSchema.nullable()).optional(),
|
||||
keys: z.record(z.string().trim().min(1), proxyConfigSchema.nullable()).optional(),
|
||||
level: z.enum(["global", "provider", "combo", "key"]).optional(),
|
||||
id: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
const hasPayload =
|
||||
value.proxy !== undefined ||
|
||||
value.global !== undefined ||
|
||||
value.providers !== undefined ||
|
||||
value.combos !== undefined ||
|
||||
value.keys !== undefined ||
|
||||
value.level !== undefined;
|
||||
|
||||
if (!hasPayload) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.level !== undefined && value.proxy === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "proxy is required when level is provided",
|
||||
path: ["proxy"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.level && value.level !== "global" && !value.id?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "id is required for provider/combo/key level updates",
|
||||
path: ["id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const testProxySchema = z.object({
|
||||
proxy: z.object({
|
||||
type: z.string().optional(),
|
||||
host: z.string().trim().min(1, "proxy.host is required"),
|
||||
port: z.union([z.string(), z.number()]),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const jsonRecordSchema = z.record(z.string(), z.unknown());
|
||||
const nonEmptyJsonRecordSchema = jsonRecordSchema.refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
"Body must be a non-empty object"
|
||||
);
|
||||
|
||||
const translatorLogFileSchema = z.enum([
|
||||
"1_req_client.json",
|
||||
"2_req_source.json",
|
||||
"3_req_openai.json",
|
||||
"4_req_target.json",
|
||||
"5_res_provider.txt",
|
||||
]);
|
||||
|
||||
export const translatorDetectSchema = z.object({
|
||||
body: nonEmptyJsonRecordSchema,
|
||||
});
|
||||
|
||||
export const translatorSaveSchema = z.object({
|
||||
file: translatorLogFileSchema,
|
||||
content: z.string().min(1, "Content is required").max(1_000_000, "Content is too large"),
|
||||
});
|
||||
|
||||
export const translatorSendSchema = z.object({
|
||||
provider: z.string().trim().min(1, "Provider is required"),
|
||||
body: nonEmptyJsonRecordSchema,
|
||||
});
|
||||
|
||||
export const translatorTranslateSchema = z
|
||||
.object({
|
||||
step: z.union([z.number().int().min(1).max(4), z.literal("direct")]),
|
||||
provider: z.string().trim().min(1).optional(),
|
||||
body: nonEmptyJsonRecordSchema,
|
||||
sourceFormat: z.string().optional(),
|
||||
targetFormat: z.string().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.step !== "direct" && !value.provider) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Step and provider are required",
|
||||
path: ["provider"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const oauthExchangeSchema = z.object({
|
||||
code: z.string().trim().min(1),
|
||||
redirectUri: z.string().trim().min(1),
|
||||
codeVerifier: z.string().trim().min(1),
|
||||
state: z.string().optional(),
|
||||
});
|
||||
|
||||
export const oauthPollSchema = z.object({
|
||||
deviceCode: z.string().trim().min(1),
|
||||
codeVerifier: z.string().optional(),
|
||||
extraData: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const cursorImportSchema = z.object({
|
||||
accessToken: z.string().trim().min(1, "Access token is required"),
|
||||
machineId: z.string().trim().min(1, "Machine ID is required"),
|
||||
});
|
||||
|
||||
export const kiroImportSchema = z.object({
|
||||
refreshToken: z.string().trim().min(1, "Refresh token is required"),
|
||||
});
|
||||
|
||||
export const kiroSocialExchangeSchema = z.object({
|
||||
code: z.string().trim().min(1, "Code is required"),
|
||||
codeVerifier: z.string().trim().min(1, "Code verifier is required"),
|
||||
provider: z.enum(["google", "github"]),
|
||||
});
|
||||
|
||||
export const cloudCredentialUpdateSchema = z.object({
|
||||
provider: z.string().trim().min(1, "Provider is required"),
|
||||
credentials: z
|
||||
.object({
|
||||
accessToken: z.string().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
expiresIn: z.coerce.number().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
value.accessToken === undefined &&
|
||||
value.refreshToken === undefined &&
|
||||
value.expiresIn === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one credential field must be provided",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export const cloudResolveAliasSchema = z.object({
|
||||
alias: z.string().trim().min(1, "Missing alias"),
|
||||
});
|
||||
|
||||
export const cloudModelAliasUpdateSchema = z.object({
|
||||
model: z.string().trim().min(1, "Model and alias required"),
|
||||
alias: z.string().trim().min(1, "Model and alias required"),
|
||||
});
|
||||
|
||||
export const cloudSyncActionSchema = z.object({
|
||||
action: z.enum(["enable", "sync", "disable"]),
|
||||
});
|
||||
|
||||
export const updateComboSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_/.-]+$/, "Name can only contain letters, numbers, -, _, / and .")
|
||||
.optional(),
|
||||
models: z.array(comboModelEntry).optional(),
|
||||
strategy: comboStrategySchema.optional(),
|
||||
config: comboRuntimeConfigSchema.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
value.name === undefined &&
|
||||
value.models === undefined &&
|
||||
value.strategy === undefined &&
|
||||
value.config === undefined &&
|
||||
value.isActive === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const testComboSchema = z.object({
|
||||
comboName: z.string().trim().min(1, "comboName is required"),
|
||||
});
|
||||
|
||||
export const dbBackupRestoreSchema = z.object({
|
||||
backupId: z.string().trim().min(1, "backupId is required"),
|
||||
});
|
||||
|
||||
export const evalRunSuiteSchema = z.object({
|
||||
suiteId: z.string().trim().min(1, "suiteId is required"),
|
||||
outputs: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const updateKeyPermissionsSchema = z
|
||||
.object({
|
||||
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
|
||||
noLog: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.allowedModels === undefined && value.noLog === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const createProviderNodeSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
prefix: z.string().trim().min(1, "Prefix is required"),
|
||||
apiType: z.enum(["chat", "responses"]).optional(),
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const nodeType = value.type || "openai-compatible";
|
||||
if (nodeType === "openai-compatible" && !value.apiType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid OpenAI compatible API type",
|
||||
path: ["apiType"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const updateProviderNodeSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
prefix: z.string().trim().min(1, "Prefix is required"),
|
||||
apiType: z.enum(["chat", "responses"]).optional(),
|
||||
baseUrl: z.string().trim().min(1, "Base URL is required"),
|
||||
});
|
||||
|
||||
export const providerNodeValidateSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1, "Base URL and API key required"),
|
||||
apiKey: z.string().trim().min(1, "Base URL and API key required"),
|
||||
type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(),
|
||||
});
|
||||
|
||||
export const updateProviderConnectionSchema = z
|
||||
.object({
|
||||
name: z.string().max(200).optional(),
|
||||
priority: z.coerce.number().int().min(1).max(100).optional(),
|
||||
globalPriority: z.union([z.coerce.number().int().min(1).max(100), z.null()]).optional(),
|
||||
defaultModel: z.union([z.string().max(200), z.null()]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
apiKey: z.string().max(10000).optional(),
|
||||
testStatus: z.string().max(50).optional(),
|
||||
lastError: z.union([z.string(), z.null()]).optional(),
|
||||
lastErrorAt: z.union([z.string(), z.null()]).optional(),
|
||||
lastErrorType: z.union([z.string(), z.null()]).optional(),
|
||||
lastErrorSource: z.union([z.string(), z.null()]).optional(),
|
||||
errorCode: z.union([z.string(), z.null()]).optional(),
|
||||
rateLimitedUntil: z.union([z.string(), z.null()]).optional(),
|
||||
lastTested: z.union([z.string(), z.null()]).optional(),
|
||||
healthCheckInterval: z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (Object.keys(value).length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "No valid fields to update",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const providersBatchTestSchema = z
|
||||
.object({
|
||||
mode: z.enum(["provider", "oauth", "free", "apikey", "compatible", "all"]),
|
||||
providerId: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.mode === "provider" && !value.providerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerId is required when mode=provider",
|
||||
path: ["providerId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const validateProviderApiKeySchema = z.object({
|
||||
provider: z.string().trim().min(1, "Provider and API key required"),
|
||||
apiKey: z.string().trim().min(1, "Provider and API key required"),
|
||||
});
|
||||
|
||||
const geminiPartSchema = z
|
||||
.object({
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
const geminiContentSchema = z
|
||||
.object({
|
||||
role: z.string().optional(),
|
||||
parts: z.array(geminiPartSchema).optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const v1betaGeminiGenerateSchema = z
|
||||
.object({
|
||||
contents: z.array(geminiContentSchema).optional(),
|
||||
systemInstruction: z
|
||||
.object({
|
||||
parts: z.array(geminiPartSchema).optional(),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
.optional(),
|
||||
generationConfig: z
|
||||
.object({
|
||||
stream: z.boolean().optional(),
|
||||
maxOutputTokens: z.coerce.number().int().min(1).optional(),
|
||||
temperature: z.coerce.number().optional(),
|
||||
topP: z.coerce.number().optional(),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
.optional(),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.contents && !value.systemInstruction) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "contents or systemInstruction is required",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const cliMitmStartSchema = z.object({
|
||||
apiKey: z.string().trim().min(1, "Missing apiKey"),
|
||||
sudoPassword: z.string().optional(),
|
||||
});
|
||||
|
||||
export const cliMitmStopSchema = z.object({
|
||||
sudoPassword: z.string().optional(),
|
||||
});
|
||||
|
||||
export const cliMitmAliasUpdateSchema = z.object({
|
||||
tool: z.string().trim().min(1, "tool and mappings required"),
|
||||
mappings: z.record(z.string(), z.string().optional()),
|
||||
});
|
||||
|
||||
export const cliBackupMutationSchema = z
|
||||
.object({
|
||||
tool: z.string().trim().min(1).optional(),
|
||||
toolId: z.string().trim().min(1).optional(),
|
||||
backupId: z.string().trim().min(1, "tool and backupId are required"),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.tool && !value.toolId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "tool and backupId are required",
|
||||
path: ["tool"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const envKeySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Environment key is required")
|
||||
.max(120)
|
||||
.regex(/^[A-Z_][A-Z0-9_]*$/, "Invalid environment key format");
|
||||
const envValueSchema = z
|
||||
.union([z.string(), z.number(), z.boolean()])
|
||||
.transform((value) => String(value))
|
||||
.refine((value) => value.length > 0, "Environment value is required")
|
||||
.refine((value) => value.length <= 10_000, "Environment value is too long");
|
||||
|
||||
export const cliSettingsEnvSchema = z.object({
|
||||
env: z
|
||||
.record(envKeySchema, envValueSchema)
|
||||
.refine((value) => Object.keys(value).length > 0, "env must contain at least one key"),
|
||||
});
|
||||
|
||||
export const cliModelConfigSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1, "baseUrl and model are required"),
|
||||
apiKey: z.string().optional(),
|
||||
model: z.string().trim().min(1, "baseUrl and model are required"),
|
||||
});
|
||||
|
||||
export const codexProfileNameSchema = z.object({
|
||||
name: z.string().trim().min(1, "Profile name is required"),
|
||||
});
|
||||
|
||||
export const codexProfileIdSchema = z.object({
|
||||
profileId: z.string().trim().min(1, "profileId is required"),
|
||||
});
|
||||
|
||||
export const guideSettingsSaveSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
apiKey: z.string().optional(),
|
||||
model: z.string().trim().min(1, "Model is required"),
|
||||
});
|
||||
|
||||
// ──── Helper ────
|
||||
|
||||
/**
|
||||
* Parse and validate request body with a Zod schema.
|
||||
* Returns { success: true, data } or { success: false, error }.
|
||||
*/
|
||||
export function validateBody(schema, body) {
|
||||
export function validateBody<TSchema extends z.ZodTypeAny>(
|
||||
schema: TSchema,
|
||||
body: unknown
|
||||
): ValidationResult<z.infer<TSchema>> {
|
||||
const result = schema.safeParse(body);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
@@ -110,3 +926,9 @@ export function validateBody(schema, body) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidationFailure<TData>(
|
||||
validation: ValidationResult<TData>
|
||||
): validation is ValidationFailure {
|
||||
return validation.success === false;
|
||||
}
|
||||
|
||||
59
tests/e2e/a11y-resilience.spec.ts
Normal file
59
tests/e2e/a11y-resilience.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const a11yRoutes = [
|
||||
"/400",
|
||||
"/401",
|
||||
"/403",
|
||||
"/408",
|
||||
"/429",
|
||||
"/500",
|
||||
"/502",
|
||||
"/503",
|
||||
"/offline",
|
||||
"/maintenance",
|
||||
"/status",
|
||||
"/route-that-does-not-exist",
|
||||
];
|
||||
|
||||
test.describe("A11y — Resilience Routes", () => {
|
||||
for (const route of a11yRoutes) {
|
||||
test(`${route} exposes semantic main landmark and heading`, async ({ page }) => {
|
||||
await page.goto(route);
|
||||
|
||||
const mainLandmarks = page.locator("main, [role='main']");
|
||||
await expect(mainLandmarks).toHaveCount(1);
|
||||
|
||||
const h1s = page.locator("h1");
|
||||
await expect(h1s).toHaveCount(1);
|
||||
|
||||
const actionableControls = page.locator("a[href], button");
|
||||
await expect(actionableControls.first()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("keyboard navigation reaches first actionable element on error page", async ({ page }) => {
|
||||
await page.goto("/500");
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
const activeTag = await page.evaluate(
|
||||
() => document.activeElement?.tagName?.toLowerCase() || null
|
||||
);
|
||||
|
||||
expect(activeTag).not.toBeNull();
|
||||
expect(["a", "button"]).toContain(activeTag as string);
|
||||
});
|
||||
|
||||
test("status page exposes live region during loading or status section after load", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/status");
|
||||
|
||||
const liveRegion = page.locator("[role='status']");
|
||||
const statusSection = page.getByText("Provider Circuit Breaker State");
|
||||
|
||||
const hasLiveRegion = (await liveRegion.count()) > 0;
|
||||
const hasStatusSection = (await statusSection.count()) > 0;
|
||||
|
||||
expect(hasLiveRegion || hasStatusSection).toBeTruthy();
|
||||
});
|
||||
});
|
||||
101
tests/e2e/error-pages.spec.ts
Normal file
101
tests/e2e/error-pages.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const errorPages = [
|
||||
{
|
||||
path: "/400",
|
||||
heading: "Bad Request",
|
||||
primaryHref: "/docs",
|
||||
secondaryHref: "/dashboard/translator",
|
||||
},
|
||||
{
|
||||
path: "/401",
|
||||
heading: "Unauthorized",
|
||||
primaryHref: "/login",
|
||||
secondaryHref: "/dashboard/api-manager",
|
||||
},
|
||||
{
|
||||
path: "/403",
|
||||
heading: "Forbidden",
|
||||
primaryHref: "/forbidden",
|
||||
secondaryHref: "/dashboard/settings?tab=security",
|
||||
},
|
||||
{
|
||||
path: "/408",
|
||||
heading: "Request Timeout",
|
||||
primaryHref: "/dashboard/endpoint",
|
||||
secondaryHref: "/status",
|
||||
},
|
||||
{
|
||||
path: "/429",
|
||||
heading: "Too Many Requests",
|
||||
primaryHref: "/dashboard/settings?tab=resilience",
|
||||
secondaryHref: "/dashboard/combos",
|
||||
},
|
||||
{
|
||||
path: "/500",
|
||||
heading: "Internal Server Error",
|
||||
primaryHref: "/dashboard/health",
|
||||
secondaryHref: "/dashboard/logs",
|
||||
},
|
||||
{
|
||||
path: "/502",
|
||||
heading: "Bad Gateway",
|
||||
primaryHref: "/dashboard/providers",
|
||||
secondaryHref: "/dashboard/translator",
|
||||
},
|
||||
{
|
||||
path: "/503",
|
||||
heading: "Service Unavailable",
|
||||
primaryHref: "/maintenance",
|
||||
secondaryHref: "/status",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Error and Resilience Pages", () => {
|
||||
for (const pageSpec of errorPages) {
|
||||
test(`${pageSpec.path} renders actionable recovery actions`, async ({ page }) => {
|
||||
const response = await page.goto(pageSpec.path);
|
||||
expect(response).toBeTruthy();
|
||||
const expectedHttpStatus = Number.parseInt(pageSpec.path.slice(1), 10);
|
||||
expect([200, expectedHttpStatus]).toContain(response?.status());
|
||||
|
||||
await expect(page.getByRole("heading", { name: pageSpec.heading })).toBeVisible();
|
||||
await expect(page.locator(`a[href="${pageSpec.primaryHref}"]`).first()).toBeVisible();
|
||||
await expect(page.locator(`a[href="${pageSpec.secondaryHref}"]`).first()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("missing route renders not-found recovery actions", async ({ page }) => {
|
||||
await page.goto("/route-that-does-not-exist");
|
||||
|
||||
await expect(page.getByRole("heading", { name: /Page not found/i })).toBeVisible();
|
||||
await expect(page.locator('a[href="/dashboard"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="/status"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("/offline explains connectivity and offers recovery actions", async ({ page }) => {
|
||||
const response = await page.goto("/offline");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Connectivity Issue" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry Connection" })).toBeVisible();
|
||||
await expect(page.locator('a[href="/status"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("/maintenance provides maintenance guidance and status action", async ({ page }) => {
|
||||
const response = await page.goto("/maintenance");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Scheduled Maintenance" })).toBeVisible();
|
||||
await expect(page.locator('a[href="/status"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="/dashboard/health"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("/status shows monitoring shell and refresh control", async ({ page }) => {
|
||||
const response = await page.goto("/status");
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "System Status" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
20
tests/e2e/visual-resilience-smoke.spec.ts
Normal file
20
tests/e2e/visual-resilience-smoke.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const visualRoutes = ["/400", "/500", "/status", "/offline", "/maintenance"];
|
||||
|
||||
test.describe("Visual Smoke — Resilience Routes", () => {
|
||||
for (const route of visualRoutes) {
|
||||
test(`${route} renders stable viewport without horizontal overflow`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto(route);
|
||||
|
||||
const screenshotBuffer = await page.screenshot({ fullPage: false });
|
||||
expect(screenshotBuffer.byteLength).toBeGreaterThan(10_000);
|
||||
|
||||
const hasOverflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > window.innerWidth + 1
|
||||
);
|
||||
expect(hasOverflow).toBeFalsy();
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user