Skip to content

Commit fa37700

Browse files
authored
feat(plugin-studio): add native validation engine and API routes (#55)
* feat(plugin-studio): add native validation engine and API routes * fix(plugin-studio): address validation review feedback * fix(plugin-studio): harden validation API follow-ups
1 parent 9bf98ab commit fa37700

6 files changed

Lines changed: 1915 additions & 2 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import {
2+
ValidationEngineError,
3+
validateAgent,
4+
validateHealth,
5+
validateHook,
6+
validatePlugin,
7+
} from '../lib/plugin-runtime/validation.js';
8+
9+
const MAX_BODY_BYTES = 1024 * 1024;
10+
const BODY_TIMEOUT_MS = 10_000;
11+
12+
const POST_VALIDATORS = {
13+
'/plugin': {
14+
requiredFields: ['root'],
15+
run: (body) => validatePlugin(body.root),
16+
},
17+
'/hook': {
18+
requiredFields: ['root', 'path'],
19+
run: (body) => validateHook(body.root, body.path),
20+
},
21+
'/agent': {
22+
requiredFields: ['root', 'path'],
23+
run: (body) => validateAgent(body.root, body.path),
24+
},
25+
};
26+
27+
function formatRequiredFieldsMessage(fields) {
28+
return `Body must include ${fields.join(' and ')}`;
29+
}
30+
31+
function ensureBodyFields(body, fields) {
32+
const hasMissingField = fields.some((field) => !body?.[field]);
33+
if (!hasMissingField) return;
34+
35+
throw new ValidationEngineError(formatRequiredFieldsMessage(fields), {
36+
status: 400,
37+
code: 'EBADREQUEST',
38+
});
39+
}
40+
41+
function parseBody(req) {
42+
return new Promise((resolve, reject) => {
43+
const chunks = [];
44+
let size = 0;
45+
let settled = false;
46+
47+
const timer = setTimeout(() => {
48+
finish(reject, new ValidationEngineError('Request body timeout', {
49+
status: 408,
50+
code: 'EBODYTIMEOUT',
51+
}));
52+
}, BODY_TIMEOUT_MS);
53+
54+
const cleanup = () => {
55+
clearTimeout(timer);
56+
req.off('data', onData);
57+
req.off('end', onEnd);
58+
req.off('error', onError);
59+
};
60+
61+
const finish = (callback, value) => {
62+
if (settled) return;
63+
settled = true;
64+
cleanup();
65+
callback(value);
66+
};
67+
68+
function onData(chunk) {
69+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
70+
size += buffer.length;
71+
if (size > MAX_BODY_BYTES) {
72+
finish(reject, new ValidationEngineError('Request body too large', {
73+
status: 413,
74+
code: 'ETOOLARGE',
75+
}));
76+
return;
77+
}
78+
chunks.push(buffer);
79+
}
80+
81+
function onEnd() {
82+
try {
83+
const raw = Buffer.concat(chunks).toString('utf8');
84+
finish(resolve, JSON.parse(raw || '{}'));
85+
} catch {
86+
finish(reject, new ValidationEngineError('Invalid JSON body', {
87+
status: 400,
88+
code: 'EBADJSON',
89+
}));
90+
}
91+
}
92+
93+
function onError(error) {
94+
finish(reject, error);
95+
}
96+
97+
req.on('data', onData);
98+
req.on('end', onEnd);
99+
req.on('error', onError);
100+
});
101+
}
102+
103+
function sendJson(res, status, data) {
104+
res.writeHead(status, { 'Content-Type': 'application/json' });
105+
res.end(JSON.stringify(data));
106+
}
107+
108+
function sendError(res, status, message) {
109+
sendJson(res, status, { error: message });
110+
}
111+
112+
export async function handleValidationRoute(req, res, url) {
113+
const parsedUrl = new URL(url, 'http://localhost');
114+
const validationPath = parsedUrl.pathname.replace(/^\/api\/validate/, '') || '/';
115+
const method = (req.method ?? 'GET').toUpperCase();
116+
const postValidator = POST_VALIDATORS[validationPath];
117+
118+
try {
119+
if (method === 'POST' && postValidator) {
120+
const body = await parseBody(req);
121+
ensureBodyFields(body, postValidator.requiredFields);
122+
return sendJson(res, 200, await postValidator.run(body));
123+
}
124+
125+
if (validationPath === '/health' && method === 'GET') {
126+
const root = parsedUrl.searchParams.get('root');
127+
if (!root) return sendError(res, 400, 'Missing ?root= query parameter');
128+
return sendJson(res, 200, await validateHealth(root));
129+
}
130+
131+
return sendError(res, 404, `Unknown route: ${method} /api/validate${validationPath}`);
132+
} catch (error) {
133+
if (error instanceof ValidationEngineError) {
134+
return sendError(res, error.status, error.message);
135+
}
136+
137+
if (error.code === 'ENOENT') return sendError(res, 404, 'File not found');
138+
if (error.code === 'EACCES') return sendError(res, 403, 'Permission denied');
139+
if (error.code === 'ETIMEDOUT') return sendError(res, 504, 'Validation timed out');
140+
141+
console.error('[validation-api] Unhandled error:', error);
142+
return sendError(res, 500, 'Internal server error');
143+
}
144+
}

0 commit comments

Comments
 (0)