openvibers/openvibe.tools Commits

Tools: the legacy routes are gone (plan T8, phase 6; not deployed yet): /api/process, /api/process/direct and /api/process/multi on img, docs and audio, and the /api/net and /api/dev mounts on the gateway, with their deprecation shims, successors and descriptor legacy lists; the net and dev handlers stay behind the run API, and /api/dev/webhook/* keeps its URL on its own router. The SSRF test runs through the run route with every case kept; the other legacy-route tests are converted or removed. npm test 57/57 and test:pg 57/57.

OP OpenVibers committed 29 Sept 2026 · d2124f4705 · 21 files, +189 −505


Changes

ddiff --git a/apps/_shared/tools/deprecation.js b/apps/_shared/tools/deprecation.js
ddeleted file mode 100644
iindex 0af731a..0000000
---- a/apps/_shared/tools/deprecation.js
++++ /dev/null
@@@ -1,32 +0,0 @@
--'use strict';
--// ═══════════════════════════════════════════════════════════════
--// The older tool endpoints (/api/process…, /api/net/*, /api/dev/*) keep working with their own
--// response shapes, and say what replaces them (RFC 9745 Deprecation, RFC 8594 Sunset, RFC 8288 Link):
--//
--// Deprecation: true
--// Sunset: Thu, 31 Dec 2026 23:59:59 GMT
--// Link: </api/v1/tools/{id}/run>; rel="successor-version"
--// ═══════════════════════════════════════════════════════════════
--
--const SUNSET = 'Thu, 31 Dec 2026 23:59:59 GMT';
--
--/** Mark an answer deprecated; `successor` is a path (the tool's run route, or a registry listing). */
--function deprecate(res, successor) {
-- if (res.headersSent) return;
-- res.setHeader('Deprecation', 'true');
-- res.setHeader('Sunset', SUNSET);
-- if (successor) res.setHeader('Link', `<${successor}>; rel="successor-version"`);
--}
--
--/** Middleware: successorOf(req) → a path or null (null: deprecated without a named successor is not said). */
--function deprecated(successorOf) {
-- return (req, res, next) => {
-- const s = successorOf(req);
-- if (s) deprecate(res, s);
-- next();
-- };
--}
--
--const runPath = (id) => (id ? `/api/v1/tools/${id}/run` : null);
--
--module.exports = { deprecate, deprecated, runPath, SUNSET };
ddiff --git a/apps/audio/server/descriptors.js b/apps/audio/server/descriptors.js
iindex 7c5c63c..813c50a 100644
---- a/apps/audio/server/descriptors.js
++++ b/apps/audio/server/descriptors.js
@@@ -89,7 +89,6 @@ function tool(id, extra = {}) {
return {
id, execution: 'job', api: true,
job: { type: 'audio.process', operation: op, ...(fixed && { preset: { format: ctx.defaultFormat } }) },
-- legacy: [op === 'merge' ? 'POST /api/process/multi' : 'POST /api/process'],
input: INPUT[op](),
files: { min: op === 'merge' ? 2 : 1, max: op === 'merge' ? 5 : 1, accept: op === 'extract' ? ACCEPT : AUDIO_ACCEPT, maxBytes: MAX_BYTES },
output: { kind: 'file', mime, schema: RESULT },
ddiff --git a/apps/audio/server/index.js b/apps/audio/server/index.js
iindex a4e9800..565d2bb 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satellitePorts } = require('../../_shared/tools/satellites');
--const { deprecated, runPath } = require('../../_shared/tools/deprecation');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const jobsRuntime = require('../../_shared/jobs');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@@ -204,76 +203,10 @@ app.post('/api/probe', guard.originCheck(), burstLimiter, processLimiter, guard.
}
});
--// ── Main Processing Endpoint ─────────────────────────────────
--app.post('/api/process', deprecated((req) => runPath(hostTool(req))), guard.originCheck(), burstLimiter, processLimiter, guard.toolQuota(hostTool), uploadSingle, guard.admitUpload(toolOf), guard.heavy(toolOf), async (req, res) => {
-- try {
-- const toolId = req.body.tool || req.ctx.defaultOp || 'convert';
-- const tool = getTool(toolId);
-- if (!tool) {
-- cleanTmp(req.file.path);
-- return res.status(400).json({ error: `Unknown tool: ${toolId}` });
-- }
-- if (tool.multiFile) {
-- cleanTmp(req.file.path);
-- return res.status(400).json({ error: `${tool.label} takes several files: send them in "files" to /api/process/multi` });
-- }
--
-- // Execute the tool (same options, hardening and code path as the audio.process job; killed at its timeout)
-- const result = await runSync(res, toolId, req.file.path, buildOptions(req.body, toolId, req.ctx));
--
-- // Clean up the uploaded input file
-- cleanTmp(req.file.path);
--
-- // Save to retention
-- const saved = retention.saveOutputFromFile(
-- result.outputPath,
-- result.ext,
-- result.mime,
-- !!req.user,
-- req.file.originalname,
-- );
--
-- // Probe input for size comparison
-- const inputSize = req.file.size;
--
-- res.json({ success: true, download: saved, ...describe(toolId, result, saved.size, inputSize) });
-- } catch (err) {
-- cleanTmp(req.file?.path);
-- if (guard.toolRefused(req, res, err, toolOf(req))) return;
-- console.error('[Process] Error:', err.message);
-- res.status(422).json({ error: err.message || 'Audio processing failed' });
-- }
--});
--
--// ── Multi-File Processing Endpoint (merge) ───────────────────
--app.post('/api/process/multi', deprecated((req) => runPath(hostTool(req))), guard.originCheck(), burstLimiter, processLimiter, guard.toolQuota(hostTool), uploadMultiple, guard.admitUpload(toolOf), guard.heavy(toolOf), async (req, res) => {
-- const paths = req.files.map(f => f.path);
-- try {
-- const toolId = req.body.tool || req.ctx.defaultOp;
-- const tool = getTool(toolId);
-- if (!tool || !tool.multiFile) {
-- cleanTmp(...paths);
-- return res.status(400).json({ error: tool ? `Tool "${toolId}" takes one file: use /api/process` : `Unknown tool: ${toolId}` });
-- }
-- const result = await runSync(res, toolId, req.files.map(f => f.path), buildOptions(req.body, toolId, req.ctx));
-- cleanTmp(...paths);
-- const first = req.files[0].originalname || 'audio';
-- const saved = retention.saveOutputFromFile(result.outputPath, result.ext, result.mime, !!req.user,
-- `${path.basename(first, path.extname(first))}-merged${path.extname(first)}`);
-- const inputSize = req.files.reduce((n, f) => n + f.size, 0);
-- res.json({ success: true, download: saved, ...describe(toolId, result, saved.size, inputSize), fileCount: req.files.length });
-- } catch (err) {
-- cleanTmp(...req.files.map(f => f.path));
-- if (guard.toolRefused(req, res, err, toolOf(req))) return;
-- console.error('[Process/Multi] Error:', err.message);
-- res.status(422).json({ error: err.message || 'Audio processing failed' });
-- }
--});
--
// ── Jobs (/api/v1/jobs) ──────────────────────────────────────
--// The same operation as /api/process, asynchronous and durable: accepted into data/jobs.db,
--// followed over SSE (ffmpeg's own progress), cancellable (ffmpeg is killed), reattachable by id
--// after a reload or a restart (apps/_shared/jobs).
++// The audio operations, asynchronous and durable: accepted into data/jobs.db, followed over SSE
++// (ffmpeg's own progress), cancellable (ffmpeg is killed), reattachable by id after a reload or a
++// restart (apps/_shared/jobs).
const jobs = jobsRuntime.setupJobs({
app, service: 'audio', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
ddiff --git a/apps/audio/test/hardening.test.js b/apps/audio/test/hardening.test.js
iindex cf46642..d099ddf 100644
---- a/apps/audio/test/hardening.test.js
++++ b/apps/audio/test/hardening.test.js
@@@ -82,20 +82,20 @@ try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); } catch {
const ctx = await fetch(`${app.base}/api/context`);
const cookie = String(ctx.headers.get('set-cookie') || '').split(';')[0];
assert.match(cookie, /^ov_tools_jobs=/, 'the page\'s context call starts the session audio tools need');
-- const post = (buf, name, type, tool = 'convert') => {
++ const run = (buf, name, type) => {
const f = new FormData();
++ f.append('input', JSON.stringify({}));
f.append('file', new Blob([buf], { type }), name);
-- f.append('tool', tool);
-- f.append('format', 'mp3');
-- return fetch(`${app.base}/api/process`, { method: 'POST', body: f, headers: { cookie } });
++ return fetch(`${app.base}/api/v1/tools/mp3/run?wait_ms=30000`, { method: 'POST', body: f, headers: { cookie } });
};
-- let r = await post(fs.readFileSync(hls), 'song.mp3', 'audio/mpeg');
-- assert.strictEqual(r.status, 415);
++ let r = await run(fs.readFileSync(hls), 'song.mp3', 'audio/mpeg');
++ assert.strictEqual(r.status, 415, 'the run API refuses the playlist at the door');
assert.strictEqual((await r.json()).code, 'tools.file.unsupported_type');
-- r = await post(fs.readFileSync(secret), 'take.wav', 'application/octet-stream');
++ r = await run(fs.readFileSync(secret), 'take.wav', 'application/octet-stream');
const body = await r.json();
assert.strictEqual(r.status, 200, JSON.stringify(body));
-- assert.strictEqual(body.output.ext, 'mp3');
++ assert.strictEqual(body.state, 'succeeded', JSON.stringify(body.error));
++ assert.strictEqual(body.result.data.output.ext, 'mp3');
// A job with the playlist is refused the same way.
const f = new FormData();
f.append('type', 'audio.process');
ddiff --git a/apps/audio/test/merge.test.js b/apps/audio/test/merge.test.js
iindex 006ed94..c1df466 100644
---- a/apps/audio/test/merge.test.js
++++ b/apps/audio/test/merge.test.js
@@@ -81,17 +81,6 @@ const seconds = (file) => parseFloat(execFileSync('ffprobe', ['-v', 'error', '-s
r = await fetch(`${app.base}/api/v1/jobs`, { method: 'POST', body: two, headers: { cookie } });
assert.strictEqual(r.status, 400, 'single-file tools take one file');
-- // The synchronous endpoint (after the burst limiter's window: the jobs above count too).
-- await sleep(5100);
-- const sync = new FormData();
-- sync.append('tool', 'merge');
-- sync.append('files', new Blob([fs.readFileSync(wav)], { type: 'audio/wav' }), 'x.wav');
-- sync.append('files', new Blob([fs.readFileSync(mp3)], { type: 'audio/mpeg' }), 'y.mp3');
-- r = await fetch(`${app.base}/api/process/multi`, { method: 'POST', body: sync });
-- const body = await r.json();
-- assert.strictEqual(r.status, 200, JSON.stringify(body));
-- assert.strictEqual(body.fileCount, 2);
-- assert.strictEqual(body.output.ext, 'mp3');
} finally {
await app.kill();
fs.rmSync(data, { recursive: true, force: true });
ddiff --git a/apps/docs/server/descriptors.js b/apps/docs/server/descriptors.js
iindex 9277e27..c4439ed 100644
---- a/apps/docs/server/descriptors.js
++++ b/apps/docs/server/descriptors.js
@@@ -79,7 +79,6 @@ function tool(id, extra = {}) {
return {
id, execution: 'job', api: true,
job: { type: 'docs.process', operation: op, ...(ctx.defaultFormat && { preset: { defaultFormat: ctx.defaultFormat } }) },
-- legacy: [o.files.max > 1 ? 'POST /api/process/multi' : 'POST /api/process'],
input: o.input,
files: { ...o.files, maxBytes: MAX_BYTES },
output: { kind: 'file', mime: o.mime, schema: RESULT },
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex 6fb9248..ca6d709 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satellitePorts } = require('../../_shared/tools/satellites');
--const { deprecated, runPath } = require('../../_shared/tools/deprecation');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const jobsRuntime = require('../../_shared/jobs');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@@ -232,99 +231,9 @@ app.post('/api/info', guard.originCheck(), burstLimiter, processLimiter, guard.t
}
});
--// ── Main Processing Endpoint (single file) ───────────────────
--app.post('/api/process', deprecated((req) => runPath(hostTool(req))), guard.originCheck(), burstLimiter, processLimiter, guard.toolQuota(hostTool), uploadSingle, guard.admitUpload(toolOf), guard.heavy(toolOf), async (req, res) => {
-- try {
-- const toolId = req.body.tool || req.ctx.defaultOp;
-- if (!toolId) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: 'No tool specified.' }); }
--
-- const tool = getTool(toolId);
-- if (!tool) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: `Unknown tool: ${toolId}` }); }
--
-- // Same options and code path as the docs.process job (the worker pool, its cap and its limits)
-- const [buffer] = await readUploads([req.file]);
-- const result = await runTool(pool, tool, buffer, buildOptions(req.body, req.ctx, false), syncRun(req, res));
--
-- // Some tools return view-only results (like metadata view)
-- if (result.viewOnly) {
-- return res.json({ success: true, tool: toolId, ...result });
-- }
--
-- // Save output file
-- const saved = retention.saveOutput(
-- result.buffer,
-- result.ext,
-- result.mime,
-- !!req.user,
-- req.file.originalname,
-- );
--
-- res.json({
-- success: true,
-- tool: toolId,
-- download: saved,
-- output: {
-- mime: result.mime,
-- ext: result.ext,
-- size: result.buffer.length,
-- sizeKB: Math.round(result.buffer.length / 1024 * 10) / 10,
-- },
-- ...(result.pageCount !== undefined && { pageCount: result.pageCount }),
-- ...(result.savings && { savings: result.savings }),
-- ...(result.metadata && { metadata: result.metadata }),
-- ...(result.note && { note: result.note }),
-- });
-- } catch (err) {
-- sendToolError(req, res, err, 'Process');
-- }
--});
--
--// ── Multi-File Processing Endpoint (merge, img2pdf) ──────────
--app.post('/api/process/multi', deprecated((req) => runPath(hostTool(req))), guard.originCheck(), burstLimiter, processLimiter, guard.toolQuota(hostTool), uploadMultiple, guard.admitUpload(toolOf), guard.heavy(toolOf), async (req, res) => {
-- try {
-- const toolId = req.body.tool || req.ctx.defaultOp;
-- const tool = toolId ? getTool(toolId) : null;
-- if (!tool || !tool.multiFile) {
-- for (const f of req.files) fs.unlink(f.path, () => {});
-- if (!toolId) return res.status(400).json({ error: 'No tool specified.' });
-- if (!tool) return res.status(400).json({ error: `Unknown tool: ${toolId}` });
-- return res.status(400).json({ error: `Tool "${toolId}" does not support multiple files. Use /api/process instead.` });
-- }
--
-- const buffers = await readUploads(req.files);
-- const result = await runTool(pool, tool, buffers, buildOptions(req.body, req.ctx, true), syncRun(req, res));
--
-- // Save output
-- const firstName = req.files[0]?.originalname || 'output';
-- const saved = retention.saveOutput(
-- result.buffer,
-- result.ext,
-- result.mime,
-- !!req.user,
-- firstName,
-- );
--
-- res.json({
-- success: true,
-- tool: toolId,
-- download: saved,
-- output: {
-- mime: result.mime,
-- ext: result.ext,
-- size: result.buffer.length,
-- sizeKB: Math.round(result.buffer.length / 1024 * 10) / 10,
-- },
-- fileCount: req.files.length,
-- ...(result.pageCount !== undefined && { pageCount: result.pageCount }),
-- });
-- } catch (err) {
-- sendToolError(req, res, err, 'Process/Multi');
-- }
--});
--
// ── Jobs (/api/v1/jobs) ──────────────────────────────────────
--// The same operations as /api/process and /api/process/multi, asynchronous and durable: accepted
--// into data/jobs.db, followed over SSE, reattachable by id after a reload or a restart.
++// The document operations, asynchronous and durable: accepted into data/jobs.db, followed over SSE,
++// reattachable by id after a reload or a restart.
const jobs = jobsRuntime.setupJobs({
app, service: 'docs', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
ddiff --git a/apps/docs/test/guard.test.js b/apps/docs/test/guard.test.js
iindex 3ed0b86..05f8fcd 100644
---- a/apps/docs/test/guard.test.js
++++ b/apps/docs/test/guard.test.js
@@@ -26,40 +26,40 @@ async function makePdf(pages) {
const app = await startApp('docs', { DATA_DIR: data, UPLOADS_DIR: uploads, OUTPUT_DIR: path.join(data, 'output'), TOOLS_GUARD: mode });
// Each call from its own address, so the older burst limiter (4 in 5 s per address) stays out of it.
let n = 0;
-- const post = (files, fields, cookie, p = '/api/process') => {
++ const post = (id, files, input, cookie) => {
const f = new FormData();
-- for (const [k, v] of Object.entries(fields)) f.append(k, v);
++ f.append('input', JSON.stringify(input || {}));
for (const [buf, name, type, field = 'file'] of files) f.append(field, new Blob([buf], { type }), name);
-- return fetch(`${app.base}${p}`, { method: 'POST', body: f, headers: { 'X-Forwarded-For': `198.51.100.${++n}`, ...(cookie ? { cookie } : {}) } });
++ return fetch(`${app.base}/api/v1/tools/${id}/run?wait_ms=30000`, { method: 'POST', body: f, headers: { 'X-Forwarded-For': `198.51.100.${++n}`, ...(cookie ? { cookie } : {}) } });
};
try {
const ctx = await fetch(`${app.base}/api/context`, { headers: { Host: 'compresspdf.openvibe.tools' } });
const cookie = String(ctx.headers.get('set-cookie') || '').split(';')[0];
assert.match(cookie, /^ov_tools_jobs=/);
-- let r = await post([[pdf, 'doc.pdf', 'application/pdf']], { tool: 'compress' }, cookie);
++ let r = await post('compresspdf', [[pdf, 'doc.pdf', 'application/pdf']], {}, cookie);
let body = await r.json();
assert.strictEqual(r.status, 200, JSON.stringify(body));
-- assert.strictEqual(body.output.ext, 'pdf');
++ assert.strictEqual(body.state, 'succeeded', JSON.stringify(body.error));
++ assert.strictEqual(body.result.data.output.ext, 'pdf');
assert.deepStrictEqual(fs.readdirSync(uploads), [], 'the upload was on disk and is gone after the call');
-- r = await post([[png, 'doc.pdf', 'application/pdf']], { tool: 'compress' }, cookie);
++ r = await post('compresspdf', [[png, 'doc.pdf', 'application/pdf']], {}, cookie);
assert.strictEqual(r.status, 415, 'a PNG called doc.pdf is refused (in both modes)');
assert.strictEqual((await r.json()).code, 'tools.file.unsupported_type');
-- r = await post([[png, 'a.png', 'image/png', 'files'], [pdf, 'b.png', 'image/png', 'files']], { tool: 'img2pdf' }, cookie, '/api/process/multi');
++ r = await post('image2pdf', [[png, 'a.png', 'image/png', 'files'], [pdf, 'b.png', 'image/png', 'files']], {}, cookie);
assert.strictEqual(r.status, 415, 'Image to PDF takes pictures only: file 2 is a PDF');
assert.match((await r.json()).detail, /File 2 is a PDF file/);
-- r = await post([[png, 'a.png', 'image/png', 'files'], [png, 'b.png', 'image/png', 'files']], { tool: 'img2pdf' }, cookie, '/api/process/multi');
++ r = await post('image2pdf', [[png, 'a.png', 'image/png', 'files'], [png, 'b.png', 'image/png', 'files']], {}, cookie);
assert.strictEqual(r.status, 200);
++ assert.strictEqual((await r.json()).state, 'succeeded');
assert.deepStrictEqual(fs.readdirSync(uploads), []);
-- // No session, no sign-in, no token: PDF tools (auth.anonymous false) record or refuse it.
-- r = await post([[pdf, 'doc.pdf', 'application/pdf']], { tool: 'compress' }, null);
-- if (mode === 'report') assert.strictEqual(r.status, 200, 'report mode lets it through');
-- else {
-- assert.strictEqual(r.status, 401);
-- assert.strictEqual((await r.json()).code, 'tools.session_required');
-- }
++ // No session, no sign-in, no token: PDF tools (auth.anonymous false) are refused by the run
++ // API in both modes (authorization is always enforced, not a quota).
++ r = await post('compresspdf', [[pdf, 'doc.pdf', 'application/pdf']], {}, null);
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual((await r.json()).code, 'tools.session_required');
const m = await (await fetch(`${app.base}/metrics`)).text();
assert.match(m, /tools_guard_refused_total\{reason="session",tool="compresspdf"\} 1/);
assert.match(m, /tools_guard_refused_total\{reason="sniff",tool="compresspdf"\} 1/);
ddiff --git a/apps/docs/test/jobs.test.js b/apps/docs/test/jobs.test.js
iindex 2751b43..802544a 100644
---- a/apps/docs/test/jobs.test.js
++++ b/apps/docs/test/jobs.test.js
@@@ -75,15 +75,15 @@ async function finished(app, id, cookie) {
r = await fetch(`${app.base}/api/v1/jobs`, { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'docs.process', input: { tool: 'merge' } }) });
assert.strictEqual(r.status, 400, 'a job without files is refused');
-- // The synchronous endpoint is unchanged (after the burst limiter's 5 s window: jobs count too).
++ // The run API merges too (after the burst limiter's 5 s window: jobs count too).
await sleep(5100);
const sync = new FormData();
++ sync.append('input', JSON.stringify({}));
sync.append('files', new Blob([a], { type: 'application/pdf' }), 'a.pdf');
sync.append('files', new Blob([b], { type: 'application/pdf' }), 'b.pdf');
-- sync.append('tool', 'merge');
-- r = await fetch(`${app.base}/api/process/multi`, { method: 'POST', body: sync });
++ r = await fetch(`${app.base}/api/v1/tools/mergepdf/run?wait_ms=30000`, { method: 'POST', body: sync, headers: { cookie } });
const body = await r.json();
-- assert.strictEqual(body.success, true, JSON.stringify(body)); assert.strictEqual(body.pageCount, 5);
++ assert.strictEqual(body.state, 'succeeded', JSON.stringify(body.error)); assert.strictEqual(body.result.data.pageCount, 5);
// Canonical host through the gateway.
const html = await new Promise((resolve, reject) => http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: 'mergepdf.openvibe.tools', 'X-OV-Tool': 'mergepdf', 'X-OV-Host-Role': 'short', 'X-OV-Canonical-Host': 'merge-pdf.openvibe.tools' } }, (res) => {
ddiff --git a/apps/docs/test/pdf-tools.test.js b/apps/docs/test/pdf-tools.test.js
iindex 793a6f6..acd0b05 100644
---- a/apps/docs/test/pdf-tools.test.js
++++ b/apps/docs/test/pdf-tools.test.js
@@@ -157,12 +157,12 @@ const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /bein
assert.match(p.detail, /being set up/);
const sync = new FormData();
-- sync.append('tool', 'unlock');
++ sync.append('input', JSON.stringify({ password: 'pw' }));
sync.append('file', new Blob([three], { type: 'application/pdf' }), 'doc.pdf');
-- res = await fetch(`${app.base}/api/process`, { method: 'POST', body: sync });
++ res = await fetch(`${app.base}/api/v1/tools/unlockpdf/run`, { method: 'POST', body: sync });
assert.strictEqual(res.status, 503);
p = await res.json();
-- assert.strictEqual(p.code, 'tools.unavailable');
++ assert.strictEqual(p.code, 'tools.tool.unavailable');
const ctx = await (await fetch(`${app.base}/api/context`, { headers: { 'X-OV-Tool': 'protectpdf' } })).json();
assert.strictEqual(ctx.defaultOp, 'protect');
ddiff --git a/apps/docs/test/pool.test.js b/apps/docs/test/pool.test.js
iindex 9a9dfea..1155398 100644
---- a/apps/docs/test/pool.test.js
++++ b/apps/docs/test/pool.test.js
@@@ -31,7 +31,7 @@ async function finished(app, id, cookie) {
function mergeForm(files, job) {
const fd = new FormData();
-- if (job) { fd.append('type', 'docs.process'); fd.append('input', JSON.stringify({ tool: 'merge' })); } else fd.append('tool', 'merge');
++ if (job) { fd.append('type', 'docs.process'); fd.append('input', JSON.stringify({ tool: 'merge' })); } else fd.append('input', JSON.stringify({}));
files.forEach((b, i) => fd.append('files', new Blob([b], { type: 'application/pdf' }), `part-${i}.pdf`));
return fd;
}
@@@ -48,7 +48,7 @@ function mergeForm(files, job) {
const ctx = await fetch(`${app.base}/api/context`);
const cookie = ctx.headers.get('set-cookie').split(';')[0];
const t0 = Date.now();
-- const merging = fetch(`${app.base}/api/process/multi`, { method: 'POST', body: mergeForm([big, big]), headers: { cookie } }).then(async r => ({ status: r.status, body: await r.json(), at: Date.now() }));
++ const merging = fetch(`${app.base}/api/v1/tools/mergepdf/run?wait_ms=60000`, { method: 'POST', body: mergeForm([big, big]), headers: { cookie } }).then(async r => ({ status: r.status, body: await r.json(), at: Date.now() }));
const latencies = [];
let done = null;
merging.then(r => { done = r; });
@@@ -64,7 +64,8 @@ function mergeForm(files, job) {
}
const took = done.at - t0;
assert.strictEqual(done.status, 200, JSON.stringify(done.body));
-- assert.strictEqual(done.body.pageCount, 5000, 'every page of both documents');
++ assert.strictEqual(done.body.state, 'succeeded', JSON.stringify(done.body.error));
++ assert.strictEqual(done.body.result.data.pageCount, 5000, 'every page of both documents');
assert.ok(took > 700, `the merge is large enough to measure (${took} ms)`);
assert.ok(latencies.length >= 5, `health was asked during the merge (${latencies.length} times)`);
const worst = Math.max(...latencies);
@@@ -94,10 +95,12 @@ function mergeForm(files, job) {
assert.strictEqual(next.result.data.pageCount, 4);
const stats = (await (await fetch(`${small.base}/api/health`)).json()).workers;
assert.strictEqual(stats.out_of_memory, 1);
-- // The synchronous endpoint says the same (413, the legacy { error, code } shape).
-- r = await fetch(`${small.base}/api/process/multi`, { method: 'POST', body: mergeForm([big, big, big]), headers: { cookie: c2 } });
-- assert.strictEqual(r.status, 413);
-- assert.strictEqual((await r.json()).code, 'tools.input.too_large');
++ // A run says the same: the tool fails with 413 tools.input.too_large.
++ r = await fetch(`${small.base}/api/v1/tools/mergepdf/run?wait_ms=60000`, { method: 'POST', body: mergeForm([big, big, big]), headers: { cookie: c2 } });
++ assert.strictEqual(r.status, 200);
++ const oomRun = await r.json();
++ assert.strictEqual(oomRun.state, 'failed');
++ assert.strictEqual(oomRun.error.code, 'tools.input.too_large');
} finally {
for (const a of apps) await a.kill('SIGTERM');
}
ddiff --git a/apps/gateway/server/dev/descriptors.js b/apps/gateway/server/dev/descriptors.js
iindex 12c0685..82d215f 100644
---- a/apps/gateway/server/dev/descriptors.js
++++ b/apps/gateway/server/dev/descriptors.js
@@@ -84,7 +84,6 @@ const SPECS = [
},
limits: { timeoutMs: 12000, maxInputBytes: 4 * KiB, perTargetPerMinute: 10 },
auth: ANYONE, quotaClass: 'tools-fetch', cost: 2, egress: true,
-- legacy: ['GET /api/dev/opengraph'],
route: { method: 'GET', path: '/api/dev/opengraph', query: { url: '{url}' } },
example: { input: { url: 'https://openvibe.tools' } },
},
ddiff --git a/apps/gateway/server/dev/routes.js b/apps/gateway/server/dev/routes.js
iindex 767a92c..3b119a6 100644
---- a/apps/gateway/server/dev/routes.js
++++ b/apps/gateway/server/dev/routes.js
@@@ -56,18 +56,113 @@ function extractOGTags(html) {
}
// ═════════════════════════════════════════════════════════════
--// `opts.egress` is for tests: the shared SSRF guard with a mock resolver and transports.
--// `opts.guard` is the gateway's guard (identify() has run before these routes; quotas, the per-target
--// throttle, the abuse log). Without one (tests), bins are still owned: by the session cookie.
--module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
++// The webhook bins' own API (it was part of /api/dev, which is gone): /api/dev/webhook/bins… on the
++// outside. `opts.guard` is the gateway's guard (identify() has run before these routes; bin caps and the
++// abuse log). Without one (tests), bins are still owned: by the session cookie.
++function createWebhookRouter(opts = {}) {
const router = Router();
-- const egress = opts.egress || createEgress();
const guard = opts.guard || null;
const callers = guard ? null : createCallerResolver({ getPublicKey: () => null });
const whoIs = (req, res, create) => (guard ? guard.resolveCaller(req, res, { create }) : callers.resolve(req, res, { create }));
const caps = guardLimits.bounds().webhook;
const refuse = (req, res, r) => (guard ? guard.refuse(req, res, r) : (res.status(r.status).json({ error: r.detail, code: r.code }), true));
++ /** The bin, if it exists AND belongs to the caller; otherwise null (the same 404 either way). */
++ function ownBin(req, res) {
++ const bin = webhookBins.get(req.params.binId);
++ const who = whoIs(req, res, false);
++ return bin && who.owner && bin.owner === who.owner ? bin : null;
++ }
++
++ // Create a new bin. Its owner is the caller: a browser gets the session cookie here (the page
++ // calls with credentials), a signed-in person or a token is itself.
++ router.post('/bins', (req, res) => {
++ cleanupBins();
++ if (webhookBins.size >= caps.total) {
++ return res.status(429).json({ error: 'Too many active bins. Try again later.' });
++ }
++ const who = whoIs(req, res, true);
++ let mine = 0, fromHere = 0;
++ for (const b of webhookBins.values()) { if (b.owner === who.owner) mine++; if (b.ipKey === who.ipKey) fromHere++; }
++ if (mine >= caps.perOwner && refuse(req, res, { status: 429, code: 'tools.quota.exceeded', reason: 'webhook', tool: 'webhook', retryAfter: 300, detail: `At most ${caps.perOwner} webhook bins at a time; old ones expire after an hour.`, extra: { scope: 'webhook' } })) return undefined;
++ if (fromHere >= caps.perIp && refuse(req, res, { status: 429, code: 'tools.quota.exceeded', reason: 'webhook', tool: 'webhook', retryAfter: 300, detail: `At most ${caps.perIp} webhook bins from one address at a time.`, extra: { scope: 'address' } })) return undefined;
++
++ const binId = crypto.randomBytes(12).toString('hex');
++ webhookBins.set(binId, { created: Date.now(), requests: [], owner: who.owner, ipKey: who.ipKey });
++
++ res.json({
++ ok: true,
++ binId,
++ url: `https://openvibe.tools/api/dev/webhook/bins/${binId}/in`,
++ expiresIn: '1 hour',
++ });
++ });
++
++ // Get bin requests (its owner only)
++ router.get('/bins/:binId', (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const bin = ownBin(req, res);
++ if (!bin) return res.status(404).json({ error: 'Bin not found or expired' });
++
++ res.json({
++ ok: true,
++ binId: req.params.binId,
++ created: bin.created,
++ requestCount: bin.requests.length,
++ requests: bin.requests.slice().reverse(), // newest first
++ });
++ });
++
++ // Receive a webhook (any HTTP method)
++ router.all('/bins/:binId/in', (req, res) => {
++ const bin = webhookBins.get(req.params.binId);
++ if (!bin) return res.status(404).json({ error: 'Bin not found or expired' });
++
++ if (bin.requests.length >= WEBHOOK_MAX_REQUESTS) {
++ bin.requests.shift(); // drop oldest
++ }
++
++ const entry = {
++ id: crypto.randomBytes(6).toString('hex'),
++ timestamp: Date.now(),
++ method: req.method,
++ path: req.path,
++ query: req.query,
++ headers: { ...req.headers },
++ body: req.body || null,
++ ip: req.ip,
++ contentType: req.get('content-type') || '',
++ size: req.get('content-length') || 0,
++ };
++
++ // Remove sensitive proxy headers
++ delete entry.headers['cookie'];
++ delete entry.headers['authorization'];
++
++ bin.requests.push(entry);
++
++ res.status(200).json({ ok: true, message: 'Received' });
++ });
++
++ // Delete bin (its owner only; anyone else learns nothing)
++ router.delete('/bins/:binId', (req, res) => {
++ const bin = ownBin(req, res);
++ const deleted = bin ? webhookBins.delete(req.params.binId) : false;
++ res.json({ ok: true, deleted });
++ });
++
++ return router;
++}
++
++// ═════════════════════════════════════════════════════════════
++// `opts.egress` is for tests: the shared SSRF guard with a mock resolver and transports.
++// `opts.guard` is the gateway's guard. The webhook bins live in their own router (createWebhookRouter),
++// mounted at /webhook here; the run API still calls the Open Graph route in this process.
++module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
++ const router = Router();
++ const egress = opts.egress || createEgress();
++ const guard = opts.guard || null;
++
// Every dev API call (and the Open Graph fetcher's own tools-fetch quota, below).
if (guard) router.use(guard.toolQuota((req) => (req.method === 'GET' && req.path === '/opengraph' ? 'opengraph' : null)));
@@@ -152,95 +247,10 @@ module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
});
// ── Webhook Bins ─────────────────────────────────────────
--
-- /** The bin, if it exists AND belongs to the caller; otherwise null (the same 404 either way). */
-- function ownBin(req, res) {
-- const bin = webhookBins.get(req.params.binId);
-- const who = whoIs(req, res, false);
-- return bin && who.owner && bin.owner === who.owner ? bin : null;
-- }
--
-- // Create a new bin. Its owner is the caller: a browser gets the session cookie here (the page
-- // calls with credentials), a signed-in person or a token is itself.
-- router.post('/webhook/bins', (req, res) => {
-- cleanupBins();
-- if (webhookBins.size >= caps.total) {
-- return res.status(429).json({ error: 'Too many active bins. Try again later.' });
-- }
-- const who = whoIs(req, res, true);
-- let mine = 0, fromHere = 0;
-- for (const b of webhookBins.values()) { if (b.owner === who.owner) mine++; if (b.ipKey === who.ipKey) fromHere++; }
-- if (mine >= caps.perOwner && refuse(req, res, { status: 429, code: 'tools.quota.exceeded', reason: 'webhook', tool: 'webhook', retryAfter: 300, detail: `At most ${caps.perOwner} webhook bins at a time; old ones expire after an hour.`, extra: { scope: 'webhook' } })) return undefined;
-- if (fromHere >= caps.perIp && refuse(req, res, { status: 429, code: 'tools.quota.exceeded', reason: 'webhook', tool: 'webhook', retryAfter: 300, detail: `At most ${caps.perIp} webhook bins from one address at a time.`, extra: { scope: 'address' } })) return undefined;
--
-- const binId = crypto.randomBytes(12).toString('hex');
-- webhookBins.set(binId, {
-- created: Date.now(),
-- requests: [],
-- owner: who.owner,
-- ipKey: who.ipKey,
-- });
--
-- res.json({
-- ok: true,
-- binId,
-- url: `https://openvibe.tools/api/dev/webhook/bins/${binId}/in`,
-- expiresIn: '1 hour',
-- });
-- });
--
-- // Get bin requests (its owner only)
-- router.get('/webhook/bins/:binId', (req, res) => {
-- res.set('Cache-Control', 'private, no-store');
-- const bin = ownBin(req, res);
-- if (!bin) return res.status(404).json({ error: 'Bin not found or expired' });
--
-- res.json({
-- ok: true,
-- binId: req.params.binId,
-- created: bin.created,
-- requestCount: bin.requests.length,
-- requests: bin.requests.slice().reverse(), // newest first
-- });
-- });
--
-- // Receive a webhook (any HTTP method)
-- router.all('/webhook/bins/:binId/in', (req, res) => {
-- const bin = webhookBins.get(req.params.binId);
-- if (!bin) return res.status(404).json({ error: 'Bin not found or expired' });
--
-- if (bin.requests.length >= WEBHOOK_MAX_REQUESTS) {
-- bin.requests.shift(); // drop oldest
-- }
--
-- const entry = {
-- id: crypto.randomBytes(6).toString('hex'),
-- timestamp: Date.now(),
-- method: req.method,
-- path: req.path,
-- query: req.query,
-- headers: { ...req.headers },
-- body: req.body || null,
-- ip: req.ip,
-- contentType: req.get('content-type') || '',
-- size: req.get('content-length') || 0,
-- };
--
-- // Remove sensitive proxy headers
-- delete entry.headers['cookie'];
-- delete entry.headers['authorization'];
--
-- bin.requests.push(entry);
--
-- res.status(200).json({ ok: true, message: 'Received' });
-- });
--
-- // Delete bin (its owner only; anyone else learns nothing)
-- router.delete('/webhook/bins/:binId', (req, res) => {
-- const bin = ownBin(req, res);
-- const deleted = bin ? webhookBins.delete(req.params.binId) : false;
-- res.json({ ok: true, deleted });
-- });
++ // Their own router (above): the bins' API, reachable at /api/dev/webhook/* on the gateway.
++ router.use('/webhook', createWebhookRouter(opts));
return router;
};
++
++module.exports.createWebhookRouter = createWebhookRouter;
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex fccdbe9..a48bb3d 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -275,29 +275,6 @@ app.post(['/api/v1/jobs', '/api/v1/jobs/:id/retry'], actorLimits.backstop('tools
app.use(runApi.handle);
app.use((req, res, next) => { jobsFacade.handle(req, res, next); });
--// ── The older tool endpoints: still answered, and they say what replaces them ──
--const { deprecated, runPath } = require('../../_shared/tools/deprecation');
--const NET_BY_ENDPOINT = new Map();
--for (const sp of require('./net/descriptors').SPECS) {
-- if (!sp.route || !sp.api) continue;
-- const ep = sp.route.path.replace(/^\/api\/net/, '');
-- (NET_BY_ENDPOINT.get(ep) || NET_BY_ENDPOINT.set(ep, []).get(ep)).push(sp.id);
--}
--/** /api/net/<endpoint>… → the run route of the tool it serves here (the host's own, else the one named after it). */
--function netSuccessor(req) {
-- const ep = `/${String(req.path || '').split('/')[1] || ''}`;
-- if (ep === '/tools') return '/api/v1/tools?family=net';
-- const ids = NET_BY_ENDPOINT.get(ep);
-- if (!ids) return null;
-- const host = req.ovHost && req.ovHost.tool;
-- return runPath(ids.find(i => i === host) || ids.find(i => i === ep.slice(1)) || ids[0]);
--}
--function devSuccessor(req) {
-- if (req.path === '/opengraph') return runPath('opengraph');
-- if (req.path === '/tools') return '/api/v1/tools?family=dev';
-- return null; // webhook bins: page-only (api false), no successor
--}
--
// ── Auth (OAuth2 client of OpenVibe.Network) ─────────────────
auth = createAuthClient(config);
app.locals.auth = auth;
@@@ -362,14 +339,13 @@ app.use('/api/pastes', guard.legacyLimiter(rateLimit, { windowMs: 60_000, anonym
}
});
--// ── Net.OpenVibe — Network Tools API ─────────────────────────
--app.use('/api/net', deprecated(netSuccessor), guard.legacyLimiter(rateLimit, { windowMs: 60_000, anonymous: 60, signedIn: 120, message: 'Too many requests. Please try again later.' }), netRouter);
--
--// ── Dev.OpenVibe — Developer & SEO Tools API ─────────────────
++// ── Dev.OpenVibe — the webhook bins' own API ─────────────────
++// The rest of /api/dev is gone (network probes and lookups run through /api/v1/tools/<id>/run).
// Webhook bins are created and deleted with the browser's session cookie: those writes must come from
// our pages (the /in URL a bin receives on is anyone's to call).
++const { createWebhookRouter } = require('./dev/routes');
const devOrigin = guard.originCheck();
--app.use('/api/dev', deprecated(devSuccessor), (req, res, next) => (/^\/webhook\/bins\/[^/]+\/in(\/|$)/.test(req.path) ? next() : devOrigin(req, res, next)), guard.legacyLimiter(rateLimit, { windowMs: 60_000, anonymous: 60, signedIn: 120, message: 'Too many requests. Please try again later.' }), devRouter);
++app.use('/api/dev/webhook', (req, res, next) => (/^\/bins\/[^/]+\/in(\/|$)/.test(req.path) ? next() : devOrigin(req, res, next)), guard.legacyLimiter(rateLimit, { windowMs: 60_000, anonymous: 60, signedIn: 120, message: 'Too many requests. Please try again later.' }), createWebhookRouter({ guard }));
// ── Host-header subdomain routing ────────────────────────────
function subdomainOf(req) {
ddiff --git a/apps/gateway/server/net/descriptors.js b/apps/gateway/server/net/descriptors.js
iindex b09aca2..858f9a9 100644
---- a/apps/gateway/server/net/descriptors.js
++++ b/apps/gateway/server/net/descriptors.js
@@@ -136,7 +136,6 @@ function spec(id) {
auth: { anonymous, capability: probe ? 'tools.net.probe' : 'tools.tool.run' },
quotaClass: probe ? 'tools-probe' : egress ? 'tools-fetch' : 'tools-run', cost: t.cost || 1, egress,
...(endpoint && {
-- legacy: [`GET /api/net${endpoint}${endpoint === '/myip' ? '' : '/:target'}`],
route: { method: 'GET', path: `/api/net${endpoint}`, ...(t.query && { query: t.query }), ...(t.targetTemplate && { target: t.targetTemplate }) },
}),
example: { input: endpoint === '/myip' ? {} : { target: 'example.com' } },
ddiff --git a/apps/gateway/test/run-api.test.js b/apps/gateway/test/run-api.test.js
iindex 6d46320..0c9e0ab 100644
---- a/apps/gateway/test/run-api.test.js
++++ b/apps/gateway/test/run-api.test.js
@@@ -256,9 +256,9 @@ function checkProblem(r, status, code, where) {
assert.strictEqual((await run('slug', { input: { text: 'x' } }, { headers: { ...evil, Authorization: `Bearer ${net.service(['tools.tool.run'])}` } })).status, 200, 'a Bearer token is not a cookie');
r = await call('/api/v1/tools/png/run', { form: upload({}, [{ name: 'x.png', data: png }]), headers: evil });
checkProblem(r, 403, 'tools.origin.refused', 'a job tool, checked on its satellite');
-- // The older endpoints' CORS already turns a foreign Origin away; a foreign Referer alone is refused here.
-- r = await call('/api/process', { base: img.base, form: upload({ tool: 'convert', format: 'webp' }, [{ name: 'x.png', data: png, type: 'image/png' }]), headers: { Cookie: session, Referer: 'https://evil.example/page' } });
-- checkProblem(r, 403, 'tools.origin.refused', 'the older endpoints too');
++ // A foreign Referer alone is refused too.
++ r = await call('/api/v1/tools/png/run', { base: img.base, form: upload({}, [{ name: 'x.png', data: png, type: 'image/png' }]), headers: { Cookie: session, Referer: 'https://evil.example/page' } });
++ checkProblem(r, 403, 'tools.origin.refused', 'a foreign Referer too');
r = await call('/api/v1/jobs', { base: img.base, form: upload({ type: 'img.process', input: { tool: 'convert' } }, [{ name: 'x.png', data: png, type: 'image/png' }]), headers: { Cookie: session, Origin: 'https://evil.example' } });
assert.strictEqual(r.status, 403, 'and the job routes');
// Preflight from another site's page.
@@@ -267,31 +267,6 @@ function checkProblem(r, status, code, where) {
assert.strictEqual(r.h.get('access-control-allow-origin'), '*');
assert.match(r.h.get('access-control-allow-headers'), /Idempotency-Key/);
-- // ── The older endpoints: same answers, and what replaces them ──
-- r = await call('/api/net/myip', { method: 'GET' });
-- assert.strictEqual(r.status, 200);
-- assert.strictEqual(r.body.ok, true, 'the legacy shape');
-- assert.strictEqual(r.h.get('deprecation'), 'true');
-- assert.strictEqual(r.h.get('sunset'), 'Thu, 31 Dec 2026 23:59:59 GMT');
-- assert.strictEqual(r.h.get('link'), '</api/v1/tools/myip/run>; rel="successor-version"');
-- assert.strictEqual((await call('/api/net/dns/example.invalid?types=MX', { method: 'GET' })).h.get('link'), '</api/v1/tools/dns/run>; rel="successor-version"');
-- assert.strictEqual((await call('/api/dev/tools', { method: 'GET' })).h.get('link'), '</api/v1/tools?family=dev>; rel="successor-version"');
-- r = await call('/api/process', { base: img.base, form: upload({ tool: 'convert', format: 'webp' }, [{ name: 'x.png', data: png, type: 'image/png' }]) });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.strictEqual(r.body.success, true, 'the legacy shape');
-- assert.strictEqual(r.h.get('deprecation'), 'true');
-- assert.strictEqual(r.h.get('link'), '</api/v1/tools/convert/run>; rel="successor-version"');
-- {
-- // On png.openvibe.tools the successor is png's run route (fetch cannot set Host: node:http).
-- const req = new Request('http://x/', { method: 'POST', body: upload({ tool: 'convert' }, [{ name: 'x.png', data: png, type: 'image/png' }]) });
-- const bodyBuf = Buffer.from(await req.arrayBuffer());
-- const link = await new Promise((resolve, reject) => {
-- const q = require('http').request({ host: '127.0.0.1', port: img.port, method: 'POST', path: '/api/process', headers: { Host: 'png.openvibe.tools', 'Content-Type': req.headers.get('content-type'), 'Content-Length': bodyBuf.length, 'X-Forwarded-For': freshIp() } }, (res) => { res.resume(); resolve(res.headers.link); });
-- q.on('error', reject); q.end(bodyBuf);
-- });
-- assert.strictEqual(link, '</api/v1/tools/png/run>; rel="successor-version"', 'the host\'s own tool');
-- }
--
// ── Health shows the run API ──
const health = (await call('/api/health', { method: 'GET' })).body;
assert.ok(health.run.runs > 50 && health.run.cached >= 2 && health.run.proxied >= 5, JSON.stringify(health.run));
@@@ -300,5 +275,5 @@ function checkProblem(r, status, code, where) {
for (const p of procs) await p.kill('SIGTERM');
await net.close();
}
-- console.log('run API: inline engines (cache, fresh), net routes (myip, SSRF), refusals and contract codes, probes need tools.net.probe, quotas, inline and job idempotency, job tools via the gateway (wait, 202, sniffing, schema), tools.unavailable vs tools.tool.unavailable, session rule, cross-satellite job references, CSRF origin check + CORS, deprecation headers: all checks passed');
++ console.log('run API: inline engines (cache, fresh), net routes (myip, SSRF), refusals and contract codes, probes need tools.net.probe, quotas, inline and job idempotency, job tools via the gateway (wait, 202, sniffing, schema), tools.unavailable vs tools.tool.unavailable, session rule, cross-satellite job references, CSRF origin check + CORS: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/img/server/descriptors.js b/apps/img/server/descriptors.js
iindex 94b3537..5147b9c 100644
---- a/apps/img/server/descriptors.js
++++ b/apps/img/server/descriptors.js
@@@ -72,7 +72,6 @@ function tool(id, extra = {}) {
return {
id, execution: 'job', api: true,
job: { type: 'img.process', operation: op, ...(ctx.defaultFormat && { preset: { defaultFormat: ctx.defaultFormat } }) },
-- legacy: ['POST /api/process', 'POST /api/process/direct'],
input: INPUT[op](ctx.defaultFormat),
files: { min: 1, max: 1, accept: ACCEPT, maxBytes: MAX_BYTES },
output: { kind: 'file', mime: out, schema: RESULT },
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex 543c1a6..6e28392 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satellitePorts } = require('../../_shared/tools/satellites');
--const { deprecated, runPath } = require('../../_shared/tools/deprecation');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const jobsRuntime = require('../../_shared/jobs');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@@ -64,24 +63,6 @@ const toolOf = (req) => { const d = guard.toolForJob('img.process', String((req.
// once for the synchronous endpoints and the jobs together, each with a heap limit
// (TOOLS_WORKER_MEMORY_MB, 512) and terminated at its timeout, on cancel, or when the client goes away.
const pool = poolFromEnv('img', path.join(__dirname, 'worker.js'));
--/** The sync endpoints' bounds for a run: the tool's descriptor timeout, and the client leaving. */
--function syncRun(req, res) {
-- const ac = new AbortController();
-- res.on('close', () => { if (!res.writableFinished) ac.abort(); });
-- const d = guard.tool(toolOf(req));
-- return { signal: ac.signal, timeoutMs: (d && d.limits && d.limits.timeoutMs) || 5 * 60 * 1000, transfer: true };
--}
--/** A tool's failure on the synchronous endpoints: its own 503 (with Retry-After when busy), else { error }. */
--function syncError(req, res, err, label) {
-- if (guard.toolRefused(req, res, err, toolOf(req))) return;
-- if (err.status === 503) {
-- if (err.retryAfter) res.set('Retry-After', String(err.retryAfter));
-- return contracts.http.sendProblem(res, 503, err.code, { detail: err.message, ctx: req.ov });
-- }
-- if (err.code === 'tools.job.cancelled') return; // the client went away
-- console.error(`[${label}] Error:`, err.message);
-- res.status(422).json({ error: err.message || 'Image processing failed' });
--}
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
@@@ -217,57 +198,9 @@ app.get('/api/tools', (_req, res) => {
res.json({ tools: listTools() });
});
--// ── Main Processing Endpoint ─────────────────────────────────
--app.post('/api/process', deprecated((req) => runPath(hostTool(req))), guard.originCheck(), burstLimiter, processLimiter, guard.toolQuota(hostTool), uploadSingle, guard.admitUpload(toolOf), guard.heavy(toolOf), async (req, res) => {
-- try {
-- const toolId = req.body.tool || req.ctx.defaultOp || 'convert';
-- const tool = getTool(toolId);
-- if (!tool) {
-- return res.status(400).json({ error: `Unknown tool: ${toolId}` });
-- }
--
-- // Execute the tool (the same code, pool and limits the img.process job uses)
-- const result = await runProcess(pool, req.file.buffer, toolId, buildOptions(req.body, req.ctx, req.file.mimetype), syncRun(req, res));
--
-- // Save to retention
-- const saved = retention.saveOutput(
-- result.buffer,
-- result.ext,
-- result.mime,
-- !!req.user,
-- req.file.originalname,
-- );
--
-- res.json({ success: true, download: saved, ...describe(toolId, result) });
-- } catch (err) {
-- syncError(req, res, err, 'Process');
-- }
--});
--
--// ── Direct Download (inline preview) ─────────────────────────
--app.post('/api/process/direct', deprecated((req) => runPath(hostTool(req))), guard.originCheck(), burstLimiter, processLimiter, guard.toolQuota(hostTool), uploadSingle, guard.admitUpload(toolOf), guard.heavy(toolOf), async (req, res) => {
-- try {
-- const toolId = req.body.tool || req.ctx.defaultOp || 'convert';
-- const tool = getTool(toolId);
-- if (!tool) return res.status(400).json({ error: `Unknown tool: ${toolId}` });
--
-- const result = await runProcess(pool, req.file.buffer, toolId, buildOptions(req.body, req.ctx, req.file.mimetype), syncRun(req, res));
-- const baseName = path.basename(req.file.originalname, path.extname(req.file.originalname));
--
-- res.set({
-- 'Content-Type': result.mime,
-- 'Content-Disposition': `attachment; filename="${baseName}.${result.ext}"`,
-- 'Content-Length': result.buffer.length,
-- });
-- res.send(result.buffer);
-- } catch (err) {
-- syncError(req, res, err, 'Process/Direct');
-- }
--});
--
// ── Jobs (/api/v1/jobs) ──────────────────────────────────────
--// The same operation as /api/process, asynchronous and durable: accepted into data/jobs.db,
--// followed over SSE, reattachable by id after a reload or a restart (apps/_shared/jobs).
++// The image operations, asynchronous and durable: accepted into data/jobs.db, followed over SSE,
++// reattachable by id after a reload or a restart (apps/_shared/jobs).
const jobs = jobsRuntime.setupJobs({
app, service: 'img', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
ddiff --git a/apps/img/test/codec.test.js b/apps/img/test/codec.test.js
iindex 0009e77..b56e00a 100644
---- a/apps/img/test/codec.test.js
++++ b/apps/img/test/codec.test.js
@@@ -125,23 +125,27 @@ function bmp({ width, height, bpp, compression = 0, palette = [], rows }) {
HEIF_DEC_PATH: '/nonexistent/heif-dec',
});
try {
-- let fd = new FormData();
-- fd.append('tool', 'convert'); fd.append('format', 'bmp');
-- fd.append('file', new Blob([opaque], { type: 'image/png' }), 'x.png');
-- let res = await fetch(`${app.base}/api/process/direct`, { method: 'POST', body: fd });
++ const run = (buf, name, type, input) => {
++ const f = new FormData();
++ f.append('input', JSON.stringify(input));
++ f.append('file', new Blob([buf], { type }), name);
++ return fetch(`${app.base}/api/v1/tools/convert/run?wait_ms=30000`, { method: 'POST', body: f });
++ };
++ let res = await run(opaque, 'x.png', 'image/png', { format: 'bmp' });
assert.strictEqual(res.status, 200);
-- assert.strictEqual(res.headers.get('content-type'), 'image/bmp');
-- assert.strictEqual(Buffer.from(await res.arrayBuffer()).toString('latin1', 0, 2), 'BM');
++ const cookie = String(res.headers.get('set-cookie') || '').split(';')[0];
++ const made = await res.json();
++ assert.strictEqual(made.state, 'succeeded', JSON.stringify(made.error));
++ const out = await fetch(`${app.base}${made.result.files[0].url}`, { headers: cookie ? { cookie } : {} });
++ assert.strictEqual(out.headers.get('content-type'), 'image/bmp');
++ assert.strictEqual(Buffer.from(await out.arrayBuffer()).toString('latin1', 0, 2), 'BM');
-- fd = new FormData();
-- fd.append('tool', 'convert'); fd.append('format', 'jpg');
-- fd.append('file', new Blob([HEIC], { type: 'image/heic' }), 'photo.heic');
-- res = await fetch(`${app.base}/api/process`, { method: 'POST', body: fd });
-- assert.strictEqual(res.status, 503);
-- assert.strictEqual(res.headers.get('content-type'), 'application/problem+json');
++ res = await run(HEIC, 'photo.heic', 'image/heic', { format: 'jpg' });
++ assert.strictEqual(res.status, 200);
const p = await res.json();
-- assert.strictEqual(p.code, 'tools.unavailable');
-- assert.match(p.error, /being set up/);
++ assert.strictEqual(p.state, 'failed');
++ assert.strictEqual(p.error.code, 'tools.unavailable');
++ assert.match(p.error.detail, /being set up/);
const ctx = await (await fetch(`${app.base}/api/context`, { headers: { 'X-OV-Tool': 'heic' } })).json();
assert.strictEqual(ctx.inputs.heic, false, 'the HEIC page is told the decoder is missing');
ddiff --git a/apps/img/test/guard.test.js b/apps/img/test/guard.test.js
iindex 1f00e9e..2a5ee58 100644
---- a/apps/img/test/guard.test.js
++++ b/apps/img/test/guard.test.js
@@@ -37,26 +37,26 @@ const { startApp } = require('../../_shared/test/spawn');
// ── Through the app ──
const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-guard-'));
const app = await startApp('img', { DATA_DIR: data, UPLOADS_DIR: path.join(data, 'uploads'), OUTPUT_DIR: path.join(data, 'output'), TOOLS_MAX_INPUT_PIXELS: '10000' });
-- const post = (buf, name, type, p = '/api/process') => {
++ const run = (buf, name, type) => {
const f = new FormData();
++ f.append('input', JSON.stringify({ format: 'png' }));
f.append('file', new Blob([buf], { type }), name);
-- f.append('tool', 'convert');
-- f.append('format', 'png');
-- return fetch(`${app.base}${p}`, { method: 'POST', body: f });
++ return fetch(`${app.base}/api/v1/tools/convert/run?wait_ms=30000`, { method: 'POST', body: f });
};
try {
-- let r = await post(big, 'big.png', 'image/png');
-- assert.strictEqual(r.status, 413, 'the pixel limit applies in report mode too');
++ let r = await run(big, 'big.png', 'image/png');
++ assert.strictEqual(r.status, 200);
let body = await r.json();
-- assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
-- assert.strictEqual(body.code, 'tools.file.too_large');
-- r = await post(Buffer.from('%PDF-1.4\n%%EOF\n'), 'photo.png', 'image/png', '/api/process/direct');
++ assert.strictEqual(body.state, 'failed', 'the pixel limit applies in report mode too');
++ assert.strictEqual(body.error.status, 413);
++ assert.strictEqual(body.error.code, 'tools.file.too_large');
++ r = await run(Buffer.from('%PDF-1.4\n%%EOF\n'), 'photo.png', 'image/png');
assert.strictEqual(r.status, 415, 'a PDF called photo.png is refused');
assert.strictEqual((await r.json()).code, 'tools.file.unsupported_type');
-- r = await post(small, 'photo.gif', 'image/gif');
++ r = await run(small, 'photo.gif', 'image/gif');
body = await r.json();
-- assert.strictEqual(r.status, 200, JSON.stringify(body));
-- assert.strictEqual(body.output.ext, 'png');
++ assert.strictEqual(body.state, 'succeeded', JSON.stringify(body.error));
++ assert.strictEqual(body.result.data.output.ext, 'png');
// The job API refuses the same picture the same way (hard limit): the job fails with the code.
const f = new FormData();
f.append('type', 'img.process');
@@@ -67,7 +67,6 @@ const { startApp } = require('../../_shared/test/spawn');
// Answers carry RateLimit headers and the sync endpoint is behind the semaphore.
assert.ok(r.headers.get('ratelimit-limit'), 'RateLimit-Limit');
const metrics = await (await fetch(`${app.base}/metrics`)).text();
-- assert.match(metrics, /tools_guard_refused_total\{reason="pixels",tool="convert"\} 1/);
assert.match(metrics, /tools_guard_refused_total\{reason="sniff",tool="convert"\} [12]/);
assert.match(metrics, /tools_guard_sync\{kind="limit"\} 2/);
assert.match(metrics, /tools_guard_enforcing 0/);
ddiff --git a/apps/img/test/jobs.test.js b/apps/img/test/jobs.test.js
iindex f079ec5..a516db2 100644
---- a/apps/img/test/jobs.test.js
++++ b/apps/img/test/jobs.test.js
@@@ -63,16 +63,6 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
assert.match(r.headers.get('content-type'), /problem\+json/);
assert.strictEqual((await r.json()).code, 'tools.job.invalid');
-- // The synchronous endpoint is unchanged.
-- const sync = new FormData();
-- sync.append('tool', 'resize'); sync.append('width', '32');
-- sync.append('file', new Blob([png], { type: 'image/png' }), 'red.png');
-- r = await fetch(`${app.base}/api/process`, { method: 'POST', body: sync });
-- const body = await r.json();
-- assert.strictEqual(body.success, true);
-- assert.match(body.download.downloadUrl, /^\/api\/download\/[a-f0-9]{32}$/);
-- assert.strictEqual(body.dimensions.resized.width, 32);
--
// Canonical host: through the gateway the page names the host the gateway says.
// (fetch cannot set Host, so pages are requested with http.request)
const page = (headers) => new Promise((resolve, reject) => {