wip: ScriptEngine

This commit is contained in:
Kyush 2026-03-07 19:18:23 +09:00
commit 5d46a304a1
20 changed files with 1805 additions and 14 deletions

View file

@ -13,13 +13,14 @@
"author": "",
"license": "MIT",
"dependencies": {
"@solidjs/router": "^0.15.4",
"solid-js": "^1.9.11",
"@solidjs/router": "^0.15.4"
"solid-monaco": "^0.3.0"
},
"devDependencies": {
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10",
"@types/node": "^25.3.3",
"typescript": "^5.9.3",
"@types/node": "^25.3.3"
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}
}

View file

@ -4,6 +4,7 @@ import { Users } from './routes/Users';
import { Backends } from './routes/Backends';
import { Permissions } from './routes/Permissions';
import { Analytics } from './routes/Analytics';
import { Scripts } from './routes/Scripts';
export default function App() {
return (
@ -13,6 +14,7 @@ export default function App() {
<Route path="/backends" component={Backends} />
<Route path="/permissions" component={Permissions} />
<Route path="/analytics" component={Analytics} />
<Route path="/scripts" component={Scripts} />
</Router>
);
}

View file

@ -1,4 +1,4 @@
import type { User, Backend, Permission, RequestLog, UsageStats, BackendMetrics } from '../types';
import type { User, Backend, Permission, RequestLog, UsageStats, BackendMetrics, UserScript, CreateScriptData, UpdateScriptData } from '../types';
const API_BASE = '/api';
@ -60,6 +60,23 @@ export const api = {
fetchJson<void>(`${API_BASE}/admin/permissions?user_id=${userId}&backend_id=${backendId}`, { method: 'DELETE' }),
},
scripts: {
getAll: (): Promise<UserScript[]> => fetchJson<UserScript[]>(`${API_BASE}/admin/scripts`),
getById: (id: number): Promise<UserScript> => fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}`),
create: (data: CreateScriptData): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: UpdateScriptData): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/scripts/${id}`, { method: 'DELETE' }),
activate: (id: number): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}/activate`, { method: 'POST' }),
deactivate: (id: number): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}/deactivate`, { method: 'POST' }),
test: (id: number, context: { user?: User; backend?: Backend; request: { method: string; path: string; headers: Record<string, string>; body: unknown; isStream: boolean } }): Promise<{ success: boolean; error?: string; executionTime?: number }> =>
fetchJson(`${API_BASE}/admin/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(context) }),
},
analytics: {
getUsage: (userId?: number, backendId?: number, days: number = 30): Promise<UsageStats[]> => {
const params = new URLSearchParams();

View file

@ -11,6 +11,7 @@ const navItems = [
{ path: '/backends', label: 'Backends', icon: '🔧' },
{ path: '/permissions', label: 'Permissions', icon: '🔐' },
{ path: '/analytics', label: 'Analytics', icon: '📈' },
{ path: '/scripts', label: 'Scripts', icon: '📝' },
];
export const Layout: ParentComponent<LayoutProps> = (props) => {

View file

@ -0,0 +1,82 @@
import { MonacoEditor } from 'solid-monaco';
import { createSignal, onMount } from 'solid-js';
interface ScriptEditorProps {
value: string;
onChange: (value: string) => void;
readonly?: boolean;
}
export function ScriptEditor(props: ScriptEditorProps) {
const defaultCode = `// User-defined middleware script
// Available functions: onRequest, onResponse
/**
* Called before the request is forwarded to the backend
* @param ctx - Script context with user, backend, and request information
* @returns Modified context
*/
export async function onRequest(ctx) {
// Example: Add custom header
// ctx.request.headers['X-Custom-Header'] = 'value';
// Example: Log request
// console.log('Request:', ctx.request.method, ctx.request.path);
return ctx;
}
/**
* Called after receiving response from the backend
* @param ctx - Script context with response information
* @returns Modified context
*/
export async function onResponse(ctx) {
// Example: Log response
// console.log('Response status:', ctx.response?.status);
// Example: Handle streaming responses
// if (ctx.response?.isStream && ctx.onChunk) {
// const originalOnChunk = ctx.onChunk;
// ctx.onChunk = (chunk) => {
// console.log('Stream chunk:', chunk);
// originalOnChunk(chunk);
// };
// }
return ctx;
}
`;
const [editorValue, setEditorValue] = createSignal(props.value || defaultCode);
onMount(() => {
if (props.value) {
setEditorValue(props.value);
}
});
const handleChange = (value: string) => {
setEditorValue(value);
props.onChange(value);
};
return (
<div style={{ height: '600px', width: '100%' }}>
<MonacoEditor
language="typescript"
value={editorValue()}
onChange={handleChange}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 14,
wordWrap: 'on',
automaticLayout: true,
scrollBeyondLastLine: false,
padding: { top: 16, bottom: 16 },
}}
/>
</div>
);
}

View file

@ -0,0 +1,456 @@
import { Component, createResource, For, createSignal } from 'solid-js';
import { api } from '../api/client';
import type { UserScript, ScriptType } from '../types';
import { Layout } from '../components/Layout';
import { ScriptEditor } from '../components/ScriptEditor';
export const Scripts: Component = () => {
const [scripts, { refetch: refetchScripts }] = createResource(() => api.scripts.getAll());
const [users, { refetch: refetchUsers }] = createResource(() => api.users.getAll());
const [backends, { refetch: refetchBackends }] = createResource(() => api.backends.getAll());
const [showModal, setShowModal] = createSignal(false);
const [editingScript, setEditingScript] = createSignal<UserScript | null>(null);
const [formData, setFormData] = createSignal({
name: '',
script_type: 'per-user-backend' as ScriptType,
target_user_id: '',
target_backend_id: '',
script_code: '',
is_active: true,
});
const [showTestModal, setShowTestModal] = createSignal(false);
const [testScript, setTestScript] = createSignal<UserScript | null>(null);
const [testResult, setTestResult] = createSignal<{ success: boolean; error?: string; executionTime?: number } | null>(null);
const resetForm = () => {
setFormData({
name: '',
script_type: 'per-user-backend',
target_user_id: '',
target_backend_id: '',
script_code: '',
is_active: true,
});
setEditingScript(null);
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
const data = formData();
let targetUserId: number | null = null;
let targetBackendId: number | null = null;
if (data.script_type === 'per-user-backend') {
if (!data.target_user_id || !data.target_backend_id) {
alert('Please select both user and backend');
return;
}
targetUserId = Number(data.target_user_id);
targetBackendId = Number(data.target_backend_id);
} else if (data.script_type === 'per-backend') {
if (!data.target_backend_id) {
alert('Please select backend');
return;
}
targetBackendId = Number(data.target_backend_id);
} else if (data.script_type === 'per-user') {
if (!data.target_user_id) {
alert('Please select user');
return;
}
targetUserId = Number(data.target_user_id);
}
if (editingScript()) {
await api.scripts.update(editingScript()!.id, {
name: data.name,
script_type: data.script_type,
target_user_id: targetUserId,
target_backend_id: targetBackendId,
script_code: data.script_code,
is_active: data.is_active,
});
} else {
await api.scripts.create({
name: data.name,
script_type: data.script_type,
target_user_id: targetUserId,
target_backend_id: targetBackendId,
script_code: data.script_code,
is_active: data.is_active,
});
}
resetForm();
setShowModal(false);
refetchScripts();
refetchUsers();
refetchBackends();
};
const handleEdit = (script: UserScript) => {
setEditingScript(script);
setFormData({
name: script.name,
script_type: script.script_type,
target_user_id: script.target_user_id?.toString() || '',
target_backend_id: script.target_backend_id?.toString() || '',
script_code: script.script_code,
is_active: script.is_active,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this script?')) return;
await api.scripts.delete(id);
refetchScripts();
};
const handleToggleActive = async (script: UserScript) => {
if (script.is_active) {
await api.scripts.deactivate(script.id);
} else {
await api.scripts.activate(script.id);
}
refetchScripts();
};
const handleTest = async (script: UserScript) => {
setTestScript(script);
setTestResult(null);
setShowTestModal(true);
};
const runTest = async () => {
const script = testScript();
if (!script) return;
try {
const result = await api.scripts.test(script.id, {
user: users()?.[0] || undefined,
backend: backends()?.[0] || undefined,
request: {
method: 'POST',
path: '/v1/chat/completions',
headers: { 'Content-Type': 'application/json' },
body: { model: 'test', messages: [{ role: 'user', content: 'test' }] },
isStream: false,
},
});
setTestResult(result);
} catch (error) {
setTestResult({
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
};
const getScriptTypeLabel = (type: ScriptType) => {
switch (type) {
case 'per-user-backend': return 'Per User + Backend';
case 'per-backend': return 'Per Backend';
case 'per-user': return 'Per User';
default: return type;
}
};
return (
<Layout>
<div style={{ padding: '30px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '20px' }}>
<h2 style={{ margin: 0 }}>User Scripts</h2>
<button
onClick={() => { resetForm(); setShowModal(true); }}
style={{ padding: '10px 20px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '6px', cursor: 'pointer' }}
>
Create Script
</button>
</div>
<p style={{ color: '#64748b', 'margin-bottom': '20px' }}>
Create custom middleware scripts that run before requests are sent to backends (onRequest)
and after responses are received (onResponse).
</p>
{scripts.loading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', 'border-collapse': 'collapse', background: 'white', 'border-radius': '8px', overflow: 'hidden', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<thead style={{ background: '#f8fafc' }}>
<tr>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Name</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Type</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Target</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Status</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Created At</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Actions</th>
</tr>
</thead>
<tbody>
<For each={scripts()}>{(script) => {
const user = users()?.find(u => u.id === script.target_user_id);
const backend = backends()?.find(b => b.id === script.target_backend_id);
let targetText = '-';
if (script.script_type === 'per-user-backend') {
targetText = `${user?.name || 'N/A'} + ${backend?.name || 'N/A'}`;
} else if (script.script_type === 'per-backend') {
targetText = backend?.name || 'N/A';
} else if (script.script_type === 'per-user') {
targetText = user?.name || 'N/A';
}
return (
<tr style={{ 'border-bottom': '1px solid #e2e8f0' }}>
<td style={{ padding: '12px', 'font-weight': '500' }}>{script.name}</td>
<td style={{ padding: '12px' }}>
<span style={{
padding: '4px 8px',
background: script.script_type === 'per-user-backend' ? '#dbeafe' : script.script_type === 'per-backend' ? '#fef3c7' : '#d1fae5',
color: script.script_type === 'per-user-backend' ? '#1e40af' : script.script_type === 'per-backend' ? '#92400e' : '#065f46',
'border-radius': '4px',
'font-size': '0.85rem'
}}>
{getScriptTypeLabel(script.script_type)}
</span>
</td>
<td style={{ padding: '12px', color: '#64748b' }}>{targetText}</td>
<td style={{ padding: '12px' }}>
<span style={{
padding: '4px 8px',
background: script.is_active ? '#dcfce7' : '#fee2e2',
color: script.is_active ? '#166534' : '#991b1b',
'border-radius': '4px',
'font-size': '0.85rem'
}}>
{script.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td style={{ padding: '12px' }}>{new Date(script.created_at).toLocaleString()}</td>
<td style={{ padding: '12px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => handleTest(script)}
style={{ padding: '4px 8px', background: '#8b5cf6', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer', 'font-size': '0.8rem' }}
>
Test
</button>
<button
onClick={() => handleEdit(script)}
style={{ padding: '4px 8px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer', 'font-size': '0.8rem' }}
>
Edit
</button>
<button
onClick={() => handleToggleActive(script)}
style={{ padding: '4px 8px', background: script.is_active ? '#f59e0b' : '#10b981', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer', 'font-size': '0.8rem' }}
>
{script.is_active ? 'Deactivate' : 'Activate'}
</button>
<button
onClick={() => handleDelete(script.id)}
style={{ padding: '4px 8px', background: '#ef4444', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer', 'font-size': '0.8rem' }}
>
Delete
</button>
</div>
</td>
</tr>
);
}}</For>
</tbody>
</table>
)}
{showModal() && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'z-index': 1000 }}>
<div style={{ background: 'white', padding: '30px', 'border-radius': '8px', width: '800px', 'max-height': '90vh', overflow: 'auto' }}>
<h3 style={{ margin: '0 0 20px 0' }}>{editingScript() ? 'Edit Script' : 'Create Script'}</h3>
<form onSubmit={handleSubmit}>
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Name *</label>
<input
type="text"
value={formData().name}
onChange={(e) => setFormData({ ...formData(), name: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px' }}
required
/>
</div>
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Script Type *</label>
<select
value={formData().script_type}
onChange={(e) => setFormData({ ...formData(), script_type: e.target.value as ScriptType, target_user_id: '', target_backend_id: '' })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px' }}
required
>
<option value="per-user-backend">Per User + Backend</option>
<option value="per-backend">Per Backend</option>
<option value="per-user">Per User</option>
</select>
</div>
{formData().script_type === 'per-user-backend' && (
<>
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Target User *</label>
<select
value={formData().target_user_id}
onChange={(e) => setFormData({ ...formData(), target_user_id: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px' }}
required
>
<option value="">Select a user</option>
<For each={users()}>{(user) => (
<option value={user.id}>{user.name}</option>
)}</For>
</select>
</div>
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Target Backend *</label>
<select
value={formData().target_backend_id}
onChange={(e) => setFormData({ ...formData(), target_backend_id: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px' }}
required
>
<option value="">Select a backend</option>
<For each={backends()}>{(backend) => (
<option value={backend.id}>{backend.name}</option>
)}</For>
</select>
</div>
</>
)}
{formData().script_type === 'per-backend' && (
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Target Backend *</label>
<select
value={formData().target_backend_id}
onChange={(e) => setFormData({ ...formData(), target_backend_id: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px' }}
required
>
<option value="">Select a backend</option>
<For each={backends()}>{(backend) => (
<option value={backend.id}>{backend.name}</option>
)}</For>
</select>
</div>
)}
{formData().script_type === 'per-user' && (
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Target User *</label>
<select
value={formData().target_user_id}
onChange={(e) => setFormData({ ...formData(), target_user_id: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px' }}
required
>
<option value="">Select a user</option>
<For each={users()}>{(user) => (
<option value={user.id}>{user.name}</option>
)}</For>
</select>
</div>
)}
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Script Code *</label>
<ScriptEditor
value={formData().script_code}
onChange={(value) => setFormData({ ...formData(), script_code: value })}
/>
</div>
<div style={{ 'margin-bottom': '20px' }}>
<label style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
<input
type="checkbox"
checked={formData().is_active}
onChange={(e) => setFormData({ ...formData(), is_active: e.target.checked })}
/>
<span style={{ 'font-weight': 'bold' }}>Active</span>
</label>
</div>
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
<button
type="button"
onClick={() => { setShowModal(false); resetForm(); }}
style={{ padding: '8px 16px', background: '#e2e8f0', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Cancel
</button>
<button
type="submit"
style={{ padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
{editingScript() ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{showTestModal() && testScript() && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'z-index': 1000 }}>
<div style={{ background: 'white', padding: '30px', 'border-radius': '8px', width: '600px' }}>
<h3 style={{ margin: '0 0 20px 0' }}>Test Script: {testScript()?.name}</h3>
<div style={{ 'margin-bottom': '20px', padding: '15px', background: '#f8fafc', 'border-radius': '4px' }}>
<p style={{ margin: '0 0 10px 0', 'font-weight': 'bold' }}>Test Context:</p>
<p style={{ margin: 0, 'font-size': '0.9rem', color: '#64748b' }}>
User: {users()?.[0]?.name || 'N/A'} | Backend: {backends()?.[0]?.name || 'N/A'}
</p>
</div>
{testResult() && (
<div style={{
'margin-bottom': '20px',
padding: '15px',
background: testResult()!.success ? '#dcfce7' : '#fee2e2',
'border-radius': '4px',
color: testResult()!.success ? '#166534' : '#991b1b'
}}>
<p style={{ margin: '0 0 5px 0', 'font-weight': 'bold' }}>
{testResult()!.success ? '✓ Success' : '✗ Failed'}
</p>
{testResult()!.error && <p style={{ margin: 0, 'font-size': '0.9rem' }}>{testResult()!.error}</p>}
{testResult()!.executionTime && (
<p style={{ margin: '5px 0 0 0', 'font-size': '0.85rem' }}>
Execution time: {testResult()!.executionTime}ms
</p>
)}
</div>
)}
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
<button
onClick={() => { setShowTestModal(false); setTestResult(null); }}
style={{ padding: '8px 16px', background: '#e2e8f0', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Close
</button>
<button
onClick={runTest}
style={{ padding: '8px 16px', background: '#8b5cf6', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Run Test
</button>
</div>
</div>
</div>
)}
</div>
</Layout>
);
};

View file

@ -60,3 +60,35 @@ export type BackendMetrics = {
error_count: number;
success_rate: number;
};
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
export type UserScript = {
id: number;
name: string;
script_type: ScriptType;
target_user_id: number | null;
target_backend_id: number | null;
script_code: string;
is_active: boolean;
created_at: string;
updated_at: string;
};
export type CreateScriptData = {
name: string;
script_type: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code: string;
is_active?: boolean;
};
export type UpdateScriptData = {
name?: string;
script_type?: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code?: string;
is_active?: boolean;
};

View file

@ -38,3 +38,23 @@ CREATE TABLE IF NOT EXISTS permissions (
CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key);
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_permissions_backend ON permissions(backend_id);
-- User Scripts table
CREATE TABLE IF NOT EXISTS user_scripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
script_type TEXT NOT NULL CHECK(script_type IN ('per-user-backend', 'per-backend', 'per-user')),
target_user_id INTEGER,
target_backend_id INTEGER,
script_code TEXT NOT NULL,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (target_backend_id) REFERENCES backends(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_scripts_type ON user_scripts(script_type);
CREATE INDEX IF NOT EXISTS idx_user_scripts_active ON user_scripts(is_active);
CREATE INDEX IF NOT EXISTS idx_user_scripts_target_user ON user_scripts(target_user_id);
CREATE INDEX IF NOT EXISTS idx_user_scripts_target_backend ON user_scripts(target_backend_id);

44
pnpm-lock.yaml generated
View file

@ -23,6 +23,9 @@ importers:
solid-js:
specifier: ^1.9.11
version: 1.9.11
solid-monaco:
specifier: ^0.3.0
version: 0.3.0(monaco-editor@0.48.0)(solid-js@1.9.11)
devDependencies:
'@types/node':
specifier: ^25.3.3
@ -51,6 +54,9 @@ importers:
express:
specifier: ^5.2.1
version: 5.2.1
isolated-vm:
specifier: ^6.0.2
version: 6.0.2
node-fetch:
specifier: ^3.3.2
version: 3.3.2
@ -354,6 +360,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
@ -1006,6 +1015,10 @@ packages:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
isolated-vm@6.0.2:
resolution: {integrity: sha512-Qw6AJuagG/VJuh2AIcSWmQPsAArti/L+lKhjXU+lyhYkbt3J57XZr+ZjgfTnOr4NJcY1r3f8f0eePS7MRGp+pg==}
engines: {node: '>=22.0.0'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -1076,6 +1089,9 @@ packages:
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
monaco-editor@0.48.0:
resolution: {integrity: sha512-goSDElNqFfw7iDHMg8WDATkfcyeLTNpBHQpO8incK6p5qZt5G/1j41X0xdGzpIkGojGXM+QiRQyLjnfDVvrpwA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -1269,6 +1285,13 @@ packages:
solid-js@1.9.11:
resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==}
solid-monaco@0.3.0:
resolution: {integrity: sha512-MsJLrCWysv5ONdOjC4kShNoBXJWuwjP6JplJLQWG7pL00XSfvqBEKUF0wLVdaKfntf50Qz8NAd7Yx7dq67bjPA==}
engines: {node: '>=18', pnpm: '>=8.6.0'}
peerDependencies:
monaco-editor: ^0.48.0
solid-js: ^1.8.0
solid-refresh@0.6.3:
resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==}
peerDependencies:
@ -1281,6 +1304,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@ -1728,6 +1754,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
'@noble/hashes@1.8.0': {}
'@paralleldrive/cuid2@2.3.1':
@ -2368,6 +2398,10 @@ snapshots:
is-what@4.1.16: {}
isolated-vm@6.0.2:
dependencies:
prebuild-install: 7.1.3
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
@ -2414,6 +2448,8 @@ snapshots:
mkdirp-classic@0.5.3: {}
monaco-editor@0.48.0: {}
ms@2.1.3: {}
nanoid@3.3.11: {}
@ -2657,6 +2693,12 @@ snapshots:
seroval: 1.5.0
seroval-plugins: 1.5.0(seroval@1.5.0)
solid-monaco@0.3.0(monaco-editor@0.48.0)(solid-js@1.9.11):
dependencies:
'@monaco-editor/loader': 1.7.0
monaco-editor: 0.48.0
solid-js: 1.9.11
solid-refresh@0.6.3(solid-js@1.9.11):
dependencies:
'@babel/generator': 7.29.1
@ -2670,6 +2712,8 @@ snapshots:
stackback@0.0.2: {}
state-local@1.0.7: {}
statuses@2.0.2: {}
std-env@3.10.0: {}

View file

@ -1,7 +1,8 @@
packages:
- 'server'
- 'client'
- server
- client
onlyBuiltDependencies:
- 'better-sqlite3'
- 'esbuild'
- better-sqlite3
- esbuild
- isolated-vm

View file

@ -10,7 +10,8 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"bench": "tsx benchmarks/index.ts"
"bench": "tsx benchmarks/index.ts",
"sandbox": "tsx src/services/sandbox.ts"
},
"keywords": [],
"author": "",
@ -20,6 +21,7 @@
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"isolated-vm": "^6.0.2",
"node-fetch": "^3.3.2",
"zod": "^4.3.6"
},

160
server/src/models/Script.ts Normal file
View file

@ -0,0 +1,160 @@
import { getDb } from '../config/database';
import { UserScript, CreateScriptData, UpdateScriptData } from '../../../shared/types';
export class ScriptModel {
static findAll(): UserScript[] {
return getDb().prepare('SELECT * FROM user_scripts ORDER BY created_at DESC').all() as UserScript[];
}
static findById(id: number): UserScript | undefined {
return getDb().prepare('SELECT * FROM user_scripts WHERE id = ?').get(id) as UserScript | undefined;
}
static findByName(name: string): UserScript | undefined {
return getDb().prepare('SELECT * FROM user_scripts WHERE name = ?').get(name) as UserScript | undefined;
}
static findByScriptType(scriptType: string): UserScript[] {
return getDb().prepare('SELECT * FROM user_scripts WHERE script_type = ? ORDER BY created_at DESC').all(scriptType) as UserScript[];
}
static findActive(): UserScript[] {
return getDb().prepare('SELECT * FROM user_scripts WHERE is_active = 1 ORDER BY created_at DESC').all() as UserScript[];
}
static create(data: CreateScriptData): UserScript {
try {
const stmt = getDb().prepare(
'INSERT INTO user_scripts (name, script_type, target_user_id, target_backend_id, script_code, is_active) VALUES (?, ?, ?, ?, ?, ?)'
);
const isActive = data.is_active ?? true;
const result = stmt.run(
data.name,
data.script_type,
data.target_user_id ?? null,
data.target_backend_id ?? null,
data.script_code,
isActive ? 1 : 0
);
return {
id: result.lastInsertRowid as number,
name: data.name,
script_type: data.script_type,
target_user_id: data.target_user_id ?? null,
target_backend_id: data.target_backend_id ?? null,
script_code: data.script_code,
is_active: isActive,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
throw new Error('Script name already exists');
}
throw error;
}
}
static update(id: number, data: UpdateScriptData): UserScript | undefined {
const updates: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) {
updates.push('name = ?');
values.push(data.name);
}
if (data.script_type !== undefined) {
updates.push('script_type = ?');
values.push(data.script_type);
}
if (data.target_user_id !== undefined) {
updates.push('target_user_id = ?');
values.push(data.target_user_id ?? null);
}
if (data.target_backend_id !== undefined) {
updates.push('target_backend_id = ?');
values.push(data.target_backend_id ?? null);
}
if (data.script_code !== undefined) {
updates.push('script_code = ?');
values.push(data.script_code);
}
if (data.is_active !== undefined) {
updates.push('is_active = ?');
values.push(data.is_active ? 1 : 0);
}
if (updates.length === 0) {
return this.findById(id);
}
updates.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
getDb().prepare(`UPDATE user_scripts SET ${updates.join(', ')} WHERE id = ?`).run(...values);
return this.findById(id);
}
static delete(id: number): boolean {
const result = getDb().prepare('DELETE FROM user_scripts WHERE id = ?').run(id);
return result.changes > 0;
}
static activate(id: number): boolean {
const result = getDb().prepare('UPDATE user_scripts SET is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
return result.changes > 0;
}
static deactivate(id: number): boolean {
const result = getDb().prepare('UPDATE user_scripts SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
return result.changes > 0;
}
static getMatchingScripts(userId: number, backendId: number): UserScript[] {
const db = getDb();
const allScripts = db.prepare('SELECT * FROM user_scripts WHERE is_active = 1').all() as UserScript[];
return allScripts.filter(script => {
if (script.script_type === 'per-user-backend') {
return script.target_user_id === userId && script.target_backend_id === backendId;
} else if (script.script_type === 'per-backend') {
return script.target_backend_id === backendId;
} else if (script.script_type === 'per-user') {
return script.target_user_id === userId;
}
return false;
});
}
static getMatchingBackendScripts(backendId: number): UserScript[] {
const db = getDb();
const allScripts = db.prepare('SELECT * FROM user_scripts WHERE is_active = 1').all() as UserScript[];
return allScripts.filter(script => {
if (script.script_type === 'per-backend') {
return script.target_backend_id === backendId;
} else if (script.script_type === 'per-user-backend') {
return script.target_backend_id === backendId;
}
return false;
});
}
static getMatchingUserScripts(userId: number): UserScript[] {
const db = getDb();
const allScripts = db.prepare('SELECT * FROM user_scripts WHERE is_active = 1').all() as UserScript[];
return allScripts.filter(script => {
if (script.script_type === 'per-user') {
return script.target_user_id === userId;
} else if (script.script_type === 'per-user-backend') {
return script.target_user_id === userId;
}
return false;
});
}
}

View file

@ -2,11 +2,14 @@ import { Router, Request, Response } from 'express';
import { UserModel } from '../models/User';
import { BackendModel } from '../models/Backend';
import { PermissionModel } from '../models/Permission';
import scriptRoutes from './scripts';
import { generateApiKey } from '../utils/apiKey';
import { CreateUserData, CreateBackendData, CreatePermissionData, UpdateUserData, UpdateBackendData } from '../../../shared/types';
const router: Router = Router();
router.use('/scripts', scriptRoutes);
// ============ User Management ============
router.get('/users', (req: Request, res: Response) => {

View file

@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
import { authenticate, AuthenticatedRequest } from './auth';
import { RouterService } from '../services/RouterService';
import { AnalyticsService } from '../services/AnalyticsService';
import { ScriptEngine } from '../services/ScriptEngine';
import { logger } from '../utils/logger';
const router: Router = Router();
@ -27,16 +28,52 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
try {
const { model, messages, ...rest } = req.body;
const execContext = {
user: { id: user.id, name: user.name, email: user.email },
backend: { id: backend.id, name: backend.name, base_url: backend.base_url },
request: {
method: 'POST',
path: '/v1/chat/completions',
headers: { 'Content-Type': 'application/json' },
body: req.body,
isStream: req.body.stream === true,
},
};
const { context: modifiedContext, errors: requestErrors } = await ScriptEngine.applyOnRequestScripts(
execContext,
user.id,
backend.id
);
if (requestErrors.length > 0) {
logger.warn(`Script warnings for user ${user.id}: ${requestErrors.join('; ')}`);
}
const response = await RouterService.forwardRequest(
backend,
'/v1/chat/completions',
'POST',
{ 'Content-Type': 'application/json' },
req.body
modifiedContext.request.headers,
modifiedContext.request.body
);
const responseTime = Date.now() - startTime;
const responseContext = {
status: response.status,
headers: {},
body: response.data,
isStream: req.body.stream === true,
};
await ScriptEngine.applyOnResponseScripts(
execContext,
responseContext,
user.id,
backend.id
);
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,

View file

@ -0,0 +1,232 @@
import { Router, Request, Response } from 'express';
import { ScriptModel } from '../models/Script';
import { UserModel } from '../models/User';
import { BackendModel } from '../models/Backend';
import { CompiledScript } from '../services/ScriptExecutor';
import { CreateScriptData, UpdateScriptData, ScriptContextData } from '../../../shared/types';
const router: Router = Router();
// ============ Script Management ============
router.get('/', (req: Request, res: Response) => {
const scripts = ScriptModel.findAll();
res.json(scripts);
});
router.get('/active', (req: Request, res: Response) => {
const scripts = ScriptModel.findActive();
res.json(scripts);
});
router.get('/type/:type', (req: Request, res: Response) => {
const scriptType = String(req.params.type);
const scripts = ScriptModel.findByScriptType(scriptType);
res.json(scripts);
});
router.get('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
}
res.json(script);
});
router.post('/', (req: Request, res: Response) => {
const { name, script_type, target_user_id, target_backend_id, script_code, is_active } = req.body as CreateScriptData;
if (!name || !script_type || !script_code) {
res.status(400).json({ error: 'name, script_type, and script_code are required' });
return;
}
if (script_type === 'per-user-backend') {
if (!target_user_id || !target_backend_id) {
res.status(400).json({ error: 'target_user_id and target_backend_id are required for per-user-backend scripts' });
return;
}
} else if (script_type === 'per-backend') {
if (!target_backend_id) {
res.status(400).json({ error: 'target_backend_id is required for per-backend scripts' });
return;
}
} else if (script_type === 'per-user') {
if (!target_user_id) {
res.status(400).json({ error: 'target_user_id is required for per-user scripts' });
return;
}
}
try {
const script = ScriptModel.create({
name,
script_type,
target_user_id: target_user_id ?? null,
target_backend_id: target_backend_id ?? null,
script_code,
is_active: is_active ?? true,
});
res.status(201).json(script);
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
return;
} else {
console.error('Unexpected error creating script:', error);
res.status(500).json({ error: 'Failed to create script' });
}
}
});
router.put('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
}
const { name, script_type, target_user_id, target_backend_id, script_code, is_active } = req.body as UpdateScriptData;
if (script_type) {
if (script_type === 'per-user-backend') {
if (!target_user_id || !target_backend_id) {
res.status(400).json({ error: 'target_user_id and target_backend_id are required for per-user-backend scripts' });
return;
}
} else if (script_type === 'per-backend') {
if (!target_backend_id) {
res.status(400).json({ error: 'target_backend_id is required for per-backend scripts' });
return;
}
} else if (script_type === 'per-user') {
if (!target_user_id) {
res.status(400).json({ error: 'target_user_id is required for per-user scripts' });
return;
}
}
}
const updatedScript = ScriptModel.update(id, {
name,
script_type,
target_user_id,
target_backend_id,
script_code,
is_active,
});
res.json(updatedScript);
});
router.delete('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const success = ScriptModel.delete(id);
if (!success) {
res.status(404).json({ error: 'Script not found' });
return;
}
res.status(204).send();
});
router.post('/:id/activate', (req: Request, res: Response) => {
const id = Number(req.params.id);
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
}
const success = ScriptModel.activate(id);
if (!success) {
res.status(500).json({ error: 'Failed to activate script' });
return;
}
res.json({ ...script, is_active: true });
});
router.post('/:id/deactivate', (req: Request, res: Response) => {
const id = Number(req.params.id);
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
}
const success = ScriptModel.deactivate(id);
if (!success) {
res.status(500).json({ error: 'Failed to deactivate script' });
return;
}
res.json({ ...script, is_active: false });
});
// ============ Script Testing ============
router.post('/:id/test', async (req: Request, res: Response) => {
const id = Number(req.params.id);
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
}
const { user, backend, request } = req.body as {
user?: { id: number; name: string; email?: string };
backend?: { id: number; name: string; base_url: string };
request: { method: string; path: string; headers: Record<string, string>; body: unknown; isStream: boolean };
};
if (!request) {
res.status(400).json({ error: 'request is required' });
return;
}
const testContext: ScriptContextData = {
user: user ?? null,
backend: backend ?? null,
request,
};
let compiled: CompiledScript | null = null;
try {
const startTime = Date.now();
compiled = await CompiledScript.compile(script.script_code);
if (compiled.hasOnRequest) {
await compiled.callOnRequest(testContext);
}
if (compiled.hasOnResponse) {
await compiled.callOnResponse(testContext);
}
res.json({
success: true,
executionTime: Date.now() - startTime,
hasOnRequest: compiled.hasOnRequest,
hasOnResponse: compiled.hasOnResponse,
});
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : String(error),
});
} finally {
compiled?.dispose();
}
});
export default router;

View file

@ -0,0 +1,96 @@
import { ScriptContextData } from '../../../shared/types';
import { CompiledScript } from './ScriptExecutor';
import { ScriptModel } from '../models/Script';
import { logger } from '../utils/logger';
export interface ScriptChainResult {
success: boolean;
context: ScriptContextData;
errors: string[];
executionTimes: number[];
}
export class ScriptEngine {
static async applyOnRequestScripts(
context: ScriptContextData,
userId: number,
backendId: number
): Promise<{ context: ScriptContextData; errors: string[]; executionTimes: number[] }> {
const scripts = ScriptModel.getMatchingScripts(userId, backendId);
const errors: string[] = [];
const executionTimes: number[] = [];
let current = context;
for (const script of scripts) {
const startTime = Date.now();
let compiled: CompiledScript | null = null;
try {
compiled = await CompiledScript.compile(script.script_code);
if (compiled.hasOnRequest) {
current = await compiled.callOnRequest(current);
logger.info(`Script "${script.name}" onRequest executed in ${Date.now() - startTime}ms`);
}
} catch (error) {
const msg = `Script "${script.name}" onRequest failed: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
logger.error(msg);
} finally {
compiled?.dispose();
executionTimes.push(Date.now() - startTime);
}
}
return { context: current, errors, executionTimes };
}
static async applyOnResponseScripts(
context: ScriptContextData,
response: { status: number; headers: Record<string, string>; body: unknown; isStream: boolean },
userId: number,
backendId: number
): Promise<{ context: ScriptContextData; errors: string[]; executionTimes: number[] }> {
const scripts = ScriptModel.getMatchingScripts(userId, backendId);
const errors: string[] = [];
const executionTimes: number[] = [];
let current: ScriptContextData = { ...context, response };
for (const script of scripts) {
const startTime = Date.now();
let compiled: CompiledScript | null = null;
try {
compiled = await CompiledScript.compile(script.script_code);
if (compiled.hasOnResponse) {
current = await compiled.callOnResponse(current);
logger.info(`Script "${script.name}" onResponse executed in ${Date.now() - startTime}ms`);
}
} catch (error) {
const msg = `Script "${script.name}" onResponse failed: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
logger.error(msg);
} finally {
compiled?.dispose();
executionTimes.push(Date.now() - startTime);
}
}
return { context: current, errors, executionTimes };
}
static async executeScriptChain(
userId: number,
backendId: number,
phase: 'onRequest' | 'onResponse',
context: ScriptContextData,
response?: { status: number; headers: Record<string, string>; body: unknown; isStream: boolean }
): Promise<ScriptChainResult> {
if (phase === 'onRequest') {
const result = await this.applyOnRequestScripts(context, userId, backendId);
return { success: result.errors.length === 0, ...result };
}
if (phase === 'onResponse' && response) {
const result = await this.applyOnResponseScripts(context, response, userId, backendId);
return { success: result.errors.length === 0, ...result };
}
return { success: true, context, errors: [], executionTimes: [] };
}
}

View file

@ -0,0 +1,124 @@
import * as ivm from 'isolated-vm';
import { ScriptContextData } from '../../../shared/types';
import { logger } from '../utils/logger';
const SCRIPT_TIMEOUT_MS = 5000;
const MEMORY_LIMIT_MB = 50;
/**
* Strip `export` keywords from user script code so it can run in eval() context.
* Supports: `export const`, `export function`, `export default`, `export {`.
*/
function preprocessScript(code: string): string {
return code.replace(/\bexport\s+(default\s+)?/g, '');
}
/**
* A compiled user script running in an isolated-vm isolate.
* The isolate stays alive between hook calls and must be explicitly disposed.
*/
export class CompiledScript {
private constructor(
private isolate: ivm.Isolate,
private ctx: ivm.Context,
private onRequestRef: ivm.Reference<Function> | null,
private onResponseRef: ivm.Reference<Function> | null,
) {}
get hasOnRequest(): boolean {
return this.onRequestRef !== null;
}
get hasOnResponse(): boolean {
return this.onResponseRef !== null;
}
/**
* Compile user script code in a new isolate.
* Detects onRequest / onResponse hooks and stores References to them.
*/
static async compile(code: string): Promise<CompiledScript> {
const isolate = new ivm.Isolate({ memoryLimit: MEMORY_LIMIT_MB });
const ctx = await isolate.createContext();
const jail = ctx.global;
// Provide console via Reference callbacks (only primitives can cross applySync boundary)
const logFns = {
_logInfo: new ivm.Reference((...args: string[]) => logger.info(`[script] ${args.join(' ')}`)),
_logWarn: new ivm.Reference((...args: string[]) => logger.warn(`[script] ${args.join(' ')}`)),
_logError: new ivm.Reference((...args: string[]) => logger.error(`[script] ${args.join(' ')}`)),
_logDebug: new ivm.Reference((...args: string[]) => logger.debug(`[script] ${args.join(' ')}`)),
};
for (const [name, ref] of Object.entries(logFns)) {
await jail.set(name, ref);
}
await ctx.eval(`
globalThis.console = {
log: (...a) => _logInfo.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
info: (...a) => _logInfo.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
warn: (...a) => _logWarn.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
error: (...a) => _logError.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
debug: (...a) => _logDebug.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
};
`, { timeout: SCRIPT_TIMEOUT_MS });
// Evaluate user script (with export keywords stripped)
const processedCode = preprocessScript(code);
await ctx.eval(processedCode, { timeout: SCRIPT_TIMEOUT_MS });
// Check which hooks exist, then grab References only for defined ones
const hasOnRequest = await ctx.eval(
'typeof onRequest === "function"',
{ timeout: SCRIPT_TIMEOUT_MS },
) as boolean;
const hasOnResponse = await ctx.eval(
'typeof onResponse === "function"',
{ timeout: SCRIPT_TIMEOUT_MS },
) as boolean;
const onRequestRef = hasOnRequest
? await ctx.eval('onRequest', { timeout: SCRIPT_TIMEOUT_MS, reference: true }) as ivm.Reference<Function>
: null;
const onResponseRef = hasOnResponse
? await ctx.eval('onResponse', { timeout: SCRIPT_TIMEOUT_MS, reference: true }) as ivm.Reference<Function>
: null;
return new CompiledScript(isolate, ctx, onRequestRef, onResponseRef);
}
/**
* Call the script's onRequest hook with the given context data.
* Data is transferred via structured clone ({ copy: true }) no JSON overhead.
*/
async callOnRequest(data: ScriptContextData): Promise<ScriptContextData> {
if (!this.onRequestRef) {
return data;
}
const result = await this.onRequestRef.apply(undefined, [data], {
arguments: { copy: true },
result: { promise: true, copy: true },
timeout: SCRIPT_TIMEOUT_MS,
});
return (result ?? data) as ScriptContextData;
}
/**
* Call the script's onResponse hook with the given context data.
*/
async callOnResponse(data: ScriptContextData): Promise<ScriptContextData> {
if (!this.onResponseRef) {
return data;
}
const result = await this.onResponseRef.apply(undefined, [data], {
arguments: { copy: true },
result: { promise: true, copy: true },
timeout: SCRIPT_TIMEOUT_MS,
});
return (result ?? data) as ScriptContextData;
}
dispose(): void {
try { this.ctx.release(); } catch {}
try { this.isolate.dispose(); } catch {}
}
}

View file

@ -0,0 +1,426 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createTestApp } from '../utils/testApp';
import { initDb } from '../../src/config/database';
describe('Script API Endpoints', () => {
let app: ReturnType<typeof createTestApp>;
let userId: number;
let backendId: number;
let scriptId: number;
beforeAll(() => {
initDb();
app = createTestApp();
});
// Setup: Create user and backend for testing
beforeAll(async () => {
const userResponse = await request(app).post('/admin/users').send({ name: 'Script Test User' });
userId = userResponse.body.id;
const backendResponse = await request(app).post('/admin/backends').send({
name: 'Script Test Backend',
base_url: 'http://localhost:8006/v1'
});
backendId = backendResponse.body.id;
});
afterAll(async () => {
// Cleanup: Delete created resources
await request(app).delete(`/admin/users/${userId}`);
await request(app).delete(`/admin/backends/${backendId}`);
});
describe('GET /admin/scripts', () => {
it('should return empty array initially', async () => {
const response = await request(app).get('/admin/scripts');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('POST /admin/scripts', () => {
it('should create a new per-user-backend script', async () => {
const scriptData = {
name: 'Test Per-User-Backend Script',
script_type: 'per-user-backend',
target_user_id: userId,
target_backend_id: backendId,
script_code: `
export const onRequest = (context) => {
console.log('Request intercepted');
return context;
};
export const onResponse = (context) => {
console.log('Response intercepted');
return context;
};
`,
is_active: true
};
const response = await request(app).post('/admin/scripts').send(scriptData);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(scriptData.name);
expect(response.body.script_type).toBe(scriptData.script_type);
expect(response.body.target_user_id).toBe(userId);
expect(response.body.target_backend_id).toBe(backendId);
expect(response.body.is_active).toBe(true);
scriptId = response.body.id;
});
it('should create a per-backend script', async () => {
const scriptData = {
name: 'Test Per-Backend Script',
script_type: 'per-backend',
target_backend_id: backendId,
script_code: `
export const onRequest = (context) => {
return context;
};
`,
is_active: true
};
const response = await request(app).post('/admin/scripts').send(scriptData);
expect(response.status).toBe(201);
expect(response.body.script_type).toBe(scriptData.script_type);
expect(response.body.target_user_id).toBeNull();
});
it('should create a per-user script', async () => {
const scriptData = {
name: 'Test Per-User Script',
script_type: 'per-user',
target_user_id: userId,
script_code: `
export const onResponse = (context) => {
return context;
};
`,
is_active: true
};
const response = await request(app).post('/admin/scripts').send(scriptData);
expect(response.status).toBe(201);
expect(response.body.script_type).toBe(scriptData.script_type);
expect(response.body.target_backend_id).toBeNull();
});
it('should return 400 if name is missing', async () => {
const response = await request(app).post('/admin/scripts').send({
script_type: 'per-backend',
target_backend_id: backendId,
script_code: 'export const onRequest = (context) => context;'
});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 if script_code is missing', async () => {
const response = await request(app).post('/admin/scripts').send({
name: 'Missing Code',
script_type: 'per-backend',
target_backend_id: backendId
});
expect(response.status).toBe(400);
});
it('should return 400 if script_type is missing', async () => {
const response = await request(app).post('/admin/scripts').send({
name: 'Missing Target Type',
script_code: 'export const onRequest = (context) => context;'
});
expect(response.status).toBe(400);
});
it('should return 400 for per-user-backend without user_id and backend_id', async () => {
const response = await request(app).post('/admin/scripts').send({
name: 'Invalid Per-User-Backend',
script_code: 'export const onRequest = (context) => context;',
script_type: 'per-user-backend'
});
expect(response.status).toBe(400);
});
it('should accept invalid JavaScript code (validation happens at execution time)', async () => {
const scriptData = {
name: 'Invalid Code',
script_code: 'this is invalid javascript {{{',
script_type: 'per-backend',
target_backend_id: backendId
};
const response = await request(app).post('/admin/scripts').send(scriptData);
// Code is saved, but will fail at execution time
expect(response.status).toBe(201);
});
});
describe('GET /admin/scripts/:id', () => {
let testScriptId: number;
beforeAll(async () => {
const response = await request(app).post('/admin/scripts').send({
name: 'Script for Get Test',
script_code: 'export const onRequest = (context) => context;',
script_type: 'per-backend',
target_backend_id: backendId
});
testScriptId = response.body.id;
});
afterAll(async () => {
await request(app).delete(`/admin/scripts/${testScriptId}`);
});
it('should return a script by id', async () => {
const response = await request(app).get(`/admin/scripts/${testScriptId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(testScriptId);
expect(response.body).toHaveProperty('name');
expect(response.body).toHaveProperty('script_code');
});
it('should return 404 for non-existent script', async () => {
const response = await request(app).get('/admin/scripts/99999');
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error');
});
});
describe('PUT /admin/scripts/:id', () => {
let testScriptId: number;
beforeAll(async () => {
const response = await request(app).post('/admin/scripts').send({
name: 'Script for Update Test',
script_code: 'export const onRequest = (context) => context;',
script_type: 'per-backend',
target_backend_id: backendId
});
testScriptId = response.body.id;
});
afterAll(async () => {
await request(app).delete(`/admin/scripts/${testScriptId}`);
});
it('should update script name', async () => {
const response = await request(app)
.put(`/admin/scripts/${testScriptId}`)
.send({
name: 'Updated Script Name'
});
expect(response.status).toBe(200);
expect(response.body.name).toBe('Updated Script Name');
});
it('should update script code', async () => {
const response = await request(app)
.put(`/admin/scripts/${testScriptId}`)
.send({
script_code: 'export const onResponse = async (context) => context;'
});
expect(response.status).toBe(200);
expect(response.body.script_code).toBe('export const onResponse = async (context) => context;');
});
it('should toggle is_active', async () => {
const response = await request(app)
.put(`/admin/scripts/${testScriptId}`)
.send({ is_active: false });
expect(response.status).toBe(200);
expect(response.body.is_active).toBe(0);
});
it('should return 404 for non-existent script', async () => {
const response = await request(app).put('/admin/scripts/99999').send({ name: 'Test' });
expect(response.status).toBe(404);
});
it('should accept invalid JavaScript code (validation happens at execution time)', async () => {
const response = await request(app)
.put(`/admin/scripts/${testScriptId}`)
.send({ script_code: 'invalid javascript {{{' });
// Code is saved, but will fail at execution time
expect(response.status).toBe(200);
});
});
describe('POST /admin/scripts/:id/test', () => {
let testScriptId: number;
beforeAll(async () => {
const response = await request(app).post('/admin/scripts').send({
name: 'Script for Test',
script_code: `
export const onRequest = (context) => {
return context;
};
export const onResponse = (context) => {
return context;
};
`,
script_type: 'per-backend',
target_backend_id: backendId
});
testScriptId = response.body.id;
});
afterAll(async () => {
await request(app).delete(`/admin/scripts/${testScriptId}`);
});
it('should load and validate script syntax', async () => {
// Note: Full execution testing may fail due to isolated-vm transfer limitations
// with certain object types. This test verifies the API endpoint works.
const testPayload = {
user: { id: userId, name: 'Test User' },
backend: { id: backendId, name: 'Test Backend', base_url: 'http://localhost:8006/v1' },
request: {
method: 'POST',
path: '/v1/chat/completions',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'test-model',
messages: [{ role: 'user', content: 'Hello' }]
}),
isStream: false
}
};
const response = await request(app)
.post(`/admin/scripts/${testScriptId}/test`)
.send(testPayload);
// Script should be loaded (syntax valid), execution may fail due to transfer limitations
// Accept both 200 (success) and 400 (execution error but script loaded)
expect([200, 400]).toContain(response.status);
if (response.status === 200) {
expect(response.body).toHaveProperty('hasOnRequest');
expect(response.body).toHaveProperty('hasOnResponse');
}
});
it('should return 404 for non-existent script', async () => {
const response = await request(app)
.post('/admin/scripts/99999/test')
.send({ request: {} });
expect(response.status).toBe(404);
});
});
describe('DELETE /admin/scripts/:id', () => {
let testScriptId: number;
beforeAll(async () => {
const response = await request(app).post('/admin/scripts').send({
name: 'Script for Delete',
script_code: 'export const onRequest = (context) => context;',
script_type: 'per-backend',
target_backend_id: backendId
});
testScriptId = response.body.id;
});
it('should delete a script', async () => {
const response = await request(app).delete(`/admin/scripts/${testScriptId}`);
expect(response.status).toBe(204);
});
it('should return 404 for already deleted script', async () => {
const response = await request(app).delete(`/admin/scripts/${testScriptId}`);
expect(response.status).toBe(404);
});
});
describe('Script Execution Integration', () => {
let testScriptId: number;
let userApiKey: string;
beforeAll(async () => {
// Create script that modifies requests
const scriptResponse = await request(app).post('/admin/scripts').send({
name: 'Integration Test Script',
script_code: `
export const onRequest = (context) => {
const body = JSON.parse(context.request.body);
body.messages.push({ role: 'system', content: 'Modified by middleware' });
context.request.body = JSON.stringify(body);
return context;
};
export const onResponse = (context) => {
return context;
};
`,
script_type: 'per-backend',
target_backend_id: backendId,
is_active: true
});
testScriptId = scriptResponse.body.id;
// Create user with permission
const userResponse = await request(app).post('/admin/users').send({ name: 'Integration User' });
userApiKey = userResponse.body.api_key;
await request(app)
.post('/admin/permissions')
.send({ user_id: userResponse.body.id, backend_id: backendId });
});
afterAll(async () => {
await request(app).delete(`/admin/scripts/${testScriptId}`);
// Note: User cleanup handled in other tests
});
it('should execute script when making request to backend', async () => {
// This test verifies that scripts are executed during actual API calls
// The script should modify the request before forwarding to backend
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userApiKey}`)
.send({
model: 'test-model',
messages: [{ role: 'user', content: 'Hello' }]
});
// Request will fail (502) because backend is not actually running,
// but we can verify the script was executed by checking logs
expect(response.status).toBe(502); // Backend unreachable
// Check that request was logged with script execution
const analyticsResponse = await request(app).get('/admin/analytics/requests?limit=10');
const loggedRequest = analyticsResponse.body.find((r: any) =>
r.user_id === parseInt(userApiKey.split('-')[1]) || r.endpoint === '/v1/chat/completions'
);
expect(loggedRequest).toBeDefined();
});
});
});

View file

@ -4,7 +4,7 @@
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"rootDir": "..",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@ -15,6 +15,6 @@
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"include": ["src/**/*", "../shared/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -129,3 +129,58 @@ export interface OpenAIModel {
created: number;
owned_by: string;
}
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
export interface UserScript {
id: number;
name: string;
script_type: ScriptType;
target_user_id: number | null;
target_backend_id: number | null;
script_code: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateScriptData {
name: string;
script_type: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code: string;
is_active?: boolean;
}
export interface UpdateScriptData {
name?: string;
script_type?: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code?: string;
is_active?: boolean;
}
/**
* Serializable script context data that can be transferred across isolate boundaries
* via structured clone ({ copy: true }).
* Non-serializable values (ReadableStream, callbacks) are NOT included here.
*/
export interface ScriptContextData {
user: { id: number; name: string; email?: string } | null;
backend: { id: number; name: string; base_url: string } | null;
request: {
method: string;
path: string;
headers: Record<string, string>;
body: unknown;
isStream: boolean;
};
response?: {
status: number;
headers: Record<string, string>;
body: unknown;
isStream: boolean;
};
}