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.
Changes
ddeleted file mode 100644@@@ -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 };@@@ -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 },@@@ -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,@@@ -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');@@@ -81,17 +81,6 @@ const seconds = (file) => parseFloat(execFileSync('ffprobe', ['-v', 'error', '-sr = 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 });@@@ -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 },@@@ -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,@@@ -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/);@@@ -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) => {@@@ -157,12 +157,12 @@ const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /beinassert.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');@@@ -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');}@@@ -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' } },},@@@ -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;@@@ -275,29 +275,6 @@ app.post(['/api/v1/jobs', '/api/v1/jobs/:id/retry'], actorLimits.backstop('toolsapp.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) {@@@ -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' } },@@@ -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); });@@@ -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 },@@@ -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,@@@ -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');@@@ -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/);@@@ -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) => {
deleted file mode 100644
deleted file mode 100644
@@ -1,32 +0,0 @@
@@ -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 };
@@ -89,7 +89,6 @@ function tool(id, extra = {}) {
@@ -89,7 +89,6 @@ function tool(id, extra = {}) {
return {
return {
id, execution: 'job', api: true,
id, execution: 'job', api: true,
job: { type: 'audio.process', operation: op, ...(fixed && { preset: { format: ctx.defaultFormat } }) },
job: { type: 'audio.process', operation: op, ...(fixed && { preset: { format: ctx.defaultFormat } }) },
- legacy: [op === 'merge' ? 'POST /api/process/multi' : 'POST /api/process'],
input: INPUT[op](),
input: INPUT[op](),
files: { min: op === 'merge' ? 2 : 1, max: op === 'merge' ? 5 : 1, accept: op === 'extract' ? ACCEPT : AUDIO_ACCEPT, maxBytes: MAX_BYTES },
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 },
output: { kind: 'file', mime, schema: RESULT },
@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satellitePorts } = require('../../_shared/tools/satellites');
const { satellitePorts } = require('../../_shared/tools/satellites');
-const { deprecated, runPath } = require('../../_shared/tools/deprecation');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const jobsRuntime = require('../../_shared/jobs');
const jobsRuntime = require('../../_shared/jobs');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@ -204,76 +203,10 @@ app.post('/api/probe', guard.originCheck(), burstLimiter, processLimiter, 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) ──────────────────────────────────────
// ── 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({
const jobs = jobsRuntime.setupJobs({
app, service: 'audio', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
app, service: 'audio', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
@@ -82,20 +82,20 @@ try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); } catch {
@@ -82,20 +82,20 @@ try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); } catch {
const ctx = await fetch(`${app.base}/api/context`);
const ctx = await fetch(`${app.base}/api/context`);
const cookie = String(ctx.headers.get('set-cookie') || '').split(';')[0];
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');
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();
const f = new FormData();
+ f.append('input', JSON.stringify({}));
f.append('file', new Blob([buf], { type }), name);
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');
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();
const body = await r.json();
assert.strictEqual(r.status, 200, JSON.stringify(body));
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.
// A job with the playlist is refused the same way.
const f = new FormData();
const f = new FormData();
f.append('type', 'audio.process');
f.append('type', 'audio.process');
@@ -81,17 +81,6 @@ const seconds = (file) => parseFloat(execFileSync('ffprobe', ['-v', 'error', '-s
@@ -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 } });
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');
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 {
} finally {
await app.kill();
await app.kill();
fs.rmSync(data, { recursive: true, force: true });
fs.rmSync(data, { recursive: true, force: true });
@@ -79,7 +79,6 @@ function tool(id, extra = {}) {
@@ -79,7 +79,6 @@ function tool(id, extra = {}) {
return {
return {
id, execution: 'job', api: true,
id, execution: 'job', api: true,
job: { type: 'docs.process', operation: op, ...(ctx.defaultFormat && { preset: { defaultFormat: ctx.defaultFormat } }) },
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,
input: o.input,
files: { ...o.files, maxBytes: MAX_BYTES },
files: { ...o.files, maxBytes: MAX_BYTES },
output: { kind: 'file', mime: o.mime, schema: RESULT },
output: { kind: 'file', mime: o.mime, schema: RESULT },
@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satellitePorts } = require('../../_shared/tools/satellites');
const { satellitePorts } = require('../../_shared/tools/satellites');
-const { deprecated, runPath } = require('../../_shared/tools/deprecation');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const jobsRuntime = require('../../_shared/jobs');
const jobsRuntime = require('../../_shared/jobs');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@ -232,99 +231,9 @@ app.post('/api/info', guard.originCheck(), burstLimiter, processLimiter, guard.t
@@ -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) ──────────────────────────────────────
// ── 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({
const jobs = jobsRuntime.setupJobs({
app, service: 'docs', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
app, service: 'docs', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
@@ -26,40 +26,40 @@ async function makePdf(pages) {
@@ -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 });
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.
// Each call from its own address, so the older burst limiter (4 in 5 s per address) stays out of it.
let n = 0;
let n = 0;
- const post = (files, fields, cookie, p = '/api/process') => {
+ const post = (id, files, input, cookie) => {
const f = new FormData();
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);
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 {
try {
const ctx = await fetch(`${app.base}/api/context`, { headers: { Host: 'compresspdf.openvibe.tools' } });
const ctx = await fetch(`${app.base}/api/context`, { headers: { Host: 'compresspdf.openvibe.tools' } });
const cookie = String(ctx.headers.get('set-cookie') || '').split(';')[0];
const cookie = String(ctx.headers.get('set-cookie') || '').split(';')[0];
assert.match(cookie, /^ov_tools_jobs=/);
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();
let body = await r.json();
assert.strictEqual(r.status, 200, JSON.stringify(body));
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');
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(r.status, 415, 'a PNG called doc.pdf is refused (in both modes)');
assert.strictEqual((await r.json()).code, 'tools.file.unsupported_type');
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.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/);
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(r.status, 200);
+ assert.strictEqual((await r.json()).state, 'succeeded');
assert.deepStrictEqual(fs.readdirSync(uploads), []);
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();
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="session",tool="compresspdf"\} 1/);
assert.match(m, /tools_guard_refused_total\{reason="sniff",tool="compresspdf"\} 1/);
assert.match(m, /tools_guard_refused_total\{reason="sniff",tool="compresspdf"\} 1/);
@@ -75,15 +75,15 @@ async function finished(app, id, cookie) {
@@ -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' } }) });
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');
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);
await sleep(5100);
const sync = new FormData();
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([a], { type: 'application/pdf' }), 'a.pdf');
sync.append('files', new Blob([b], { type: 'application/pdf' }), 'b.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();
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.
// 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) => {
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) => {
@@ -157,12 +157,12 @@ const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /bein
@@ -157,12 +157,12 @@ const is503 = (e) => e.status === 503 && e.code === 'tools.unavailable' && /bein
assert.match(p.detail, /being set up/);
assert.match(p.detail, /being set up/);
const sync = new FormData();
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');
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);
assert.strictEqual(res.status, 503);
p = await res.json();
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();
const ctx = await (await fetch(`${app.base}/api/context`, { headers: { 'X-OV-Tool': 'protectpdf' } })).json();
assert.strictEqual(ctx.defaultOp, 'protect');
assert.strictEqual(ctx.defaultOp, 'protect');
@@ -31,7 +31,7 @@ async function finished(app, id, cookie) {
@@ -31,7 +31,7 @@ async function finished(app, id, cookie) {
function mergeForm(files, job) {
function mergeForm(files, job) {
const fd = new FormData();
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`));
files.forEach((b, i) => fd.append('files', new Blob([b], { type: 'application/pdf' }), `part-${i}.pdf`));
return fd;
return fd;
}
}
@@ -48,7 +48,7 @@ function mergeForm(files, job) {
@@ -48,7 +48,7 @@ function mergeForm(files, job) {
const ctx = await fetch(`${app.base}/api/context`);
const ctx = await fetch(`${app.base}/api/context`);
const cookie = ctx.headers.get('set-cookie').split(';')[0];
const cookie = ctx.headers.get('set-cookie').split(';')[0];
const t0 = Date.now();
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 = [];
const latencies = [];
let done = null;
let done = null;
merging.then(r => { done = r; });
merging.then(r => { done = r; });
@@ -64,7 +64,8 @@ function mergeForm(files, job) {
@@ -64,7 +64,8 @@ function mergeForm(files, job) {
}
}
const took = done.at - t0;
const took = done.at - t0;
assert.strictEqual(done.status, 200, JSON.stringify(done.body));
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(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)`);
assert.ok(latencies.length >= 5, `health was asked during the merge (${latencies.length} times)`);
const worst = Math.max(...latencies);
const worst = Math.max(...latencies);
@@ -94,10 +95,12 @@ function mergeForm(files, job) {
@@ -94,10 +95,12 @@ function mergeForm(files, job) {
assert.strictEqual(next.result.data.pageCount, 4);
assert.strictEqual(next.result.data.pageCount, 4);
const stats = (await (await fetch(`${small.base}/api/health`)).json()).workers;
const stats = (await (await fetch(`${small.base}/api/health`)).json()).workers;
assert.strictEqual(stats.out_of_memory, 1);
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 {
} finally {
for (const a of apps) await a.kill('SIGTERM');
for (const a of apps) await a.kill('SIGTERM');
}
}
@@ -84,7 +84,6 @@ const SPECS = [
@@ -84,7 +84,6 @@ const SPECS = [
},
},
limits: { timeoutMs: 12000, maxInputBytes: 4 * KiB, perTargetPerMinute: 10 },
limits: { timeoutMs: 12000, maxInputBytes: 4 * KiB, perTargetPerMinute: 10 },
auth: ANYONE, quotaClass: 'tools-fetch', cost: 2, egress: true,
auth: ANYONE, quotaClass: 'tools-fetch', cost: 2, egress: true,
- legacy: ['GET /api/dev/opengraph'],
route: { method: 'GET', path: '/api/dev/opengraph', query: { url: '{url}' } },
route: { method: 'GET', path: '/api/dev/opengraph', query: { url: '{url}' } },
example: { input: { url: 'https://openvibe.tools' } },
example: { input: { url: 'https://openvibe.tools' } },
},
},
@@ -56,18 +56,113 @@ function extractOGTags(html) {
@@ -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 router = Router();
- const egress = opts.egress || createEgress();
const guard = opts.guard || null;
const guard = opts.guard || null;
const callers = guard ? null : createCallerResolver({ getPublicKey: () => 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 whoIs = (req, res, create) => (guard ? guard.resolveCaller(req, res, { create }) : callers.resolve(req, res, { create }));
const caps = guardLimits.bounds().webhook;
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));
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).
// 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)));
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 = {}) {
@@ -152,95 +247,10 @@ module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
});
});
// ── Webhook Bins ─────────────────────────────────────────
// ── 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;
return router;
};
};
+
+module.exports.createWebhookRouter = createWebhookRouter;
@@ -275,29 +275,6 @@ app.post(['/api/v1/jobs', '/api/v1/jobs/:id/retry'], actorLimits.backstop('tools
@@ -275,29 +275,6 @@ app.post(['/api/v1/jobs', '/api/v1/jobs/:id/retry'], actorLimits.backstop('tools
app.use(runApi.handle);
app.use(runApi.handle);
app.use((req, res, next) => { jobsFacade.handle(req, res, next); });
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 (OAuth2 client of OpenVibe.Network) ─────────────────
auth = createAuthClient(config);
auth = createAuthClient(config);
app.locals.auth = auth;
app.locals.auth = auth;
@@ -362,14 +339,13 @@ app.use('/api/pastes', guard.legacyLimiter(rateLimit, { windowMs: 60_000, anonym
@@ -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
// 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).
// our pages (the /in URL a bin receives on is anyone's to call).
+const { createWebhookRouter } = require('./dev/routes');
const devOrigin = guard.originCheck();
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 ────────────────────────────
// ── Host-header subdomain routing ────────────────────────────
function subdomainOf(req) {
function subdomainOf(req) {
@@ -136,7 +136,6 @@ function spec(id) {
@@ -136,7 +136,6 @@ function spec(id) {
auth: { anonymous, capability: probe ? 'tools.net.probe' : 'tools.tool.run' },
auth: { anonymous, capability: probe ? 'tools.net.probe' : 'tools.tool.run' },
quotaClass: probe ? 'tools-probe' : egress ? 'tools-fetch' : 'tools-run', cost: t.cost || 1, egress,
quotaClass: probe ? 'tools-probe' : egress ? 'tools-fetch' : 'tools-run', cost: t.cost || 1, egress,
...(endpoint && {
...(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 }) },
route: { method: 'GET', path: `/api/net${endpoint}`, ...(t.query && { query: t.query }), ...(t.targetTemplate && { target: t.targetTemplate }) },
}),
}),
example: { input: endpoint === '/myip' ? {} : { target: 'example.com' } },
example: { input: endpoint === '/myip' ? {} : { target: 'example.com' } },
@@ -256,9 +256,9 @@ function checkProblem(r, status, code, where) {
@@ -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');
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 });
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');
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' } });
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');
assert.strictEqual(r.status, 403, 'and the job routes');
// Preflight from another site's page.
// Preflight from another site's page.
@@ -267,31 +267,6 @@ function checkProblem(r, status, code, where) {
@@ -267,31 +267,6 @@ function checkProblem(r, status, code, where) {
assert.strictEqual(r.h.get('access-control-allow-origin'), '*');
assert.strictEqual(r.h.get('access-control-allow-origin'), '*');
assert.match(r.h.get('access-control-allow-headers'), /Idempotency-Key/);
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 ──
// ── Health shows the run API ──
const health = (await call('/api/health', { method: 'GET' })).body;
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));
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) {
@@ -300,5 +275,5 @@ function checkProblem(r, status, code, where) {
for (const p of procs) await p.kill('SIGTERM');
for (const p of procs) await p.kill('SIGTERM');
await net.close();
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); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -72,7 +72,6 @@ function tool(id, extra = {}) {
@@ -72,7 +72,6 @@ function tool(id, extra = {}) {
return {
return {
id, execution: 'job', api: true,
id, execution: 'job', api: true,
job: { type: 'img.process', operation: op, ...(ctx.defaultFormat && { preset: { defaultFormat: ctx.defaultFormat } }) },
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),
input: INPUT[op](ctx.defaultFormat),
files: { min: 1, max: 1, accept: ACCEPT, maxBytes: MAX_BYTES },
files: { min: 1, max: 1, accept: ACCEPT, maxBytes: MAX_BYTES },
output: { kind: 'file', mime: out, schema: RESULT },
output: { kind: 'file', mime: out, schema: RESULT },
@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
@@ -27,7 +27,6 @@ const { hostGuard, ownHost } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satelliteRunApi, ajvFrom } = require('../../_shared/tools/run');
const { satellitePorts } = require('../../_shared/tools/satellites');
const { satellitePorts } = require('../../_shared/tools/satellites');
-const { deprecated, runPath } = require('../../_shared/tools/deprecation');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const jobsRuntime = require('../../_shared/jobs');
const jobsRuntime = require('../../_shared/jobs');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@ -64,24 +63,6 @@ const toolOf = (req) => { const d = guard.toolForJob('img.process', String((req.
@@ -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
// 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.
// (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'));
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();
const app = express();
// What this deploy runs (ADR-016); the shared navbar's release-watch polls it on every tool host.
// 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) => {
@@ -217,57 +198,9 @@ app.get('/api/tools', (_req, res) => {
res.json({ tools: listTools() });
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) ──────────────────────────────────────
// ── 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({
const jobs = jobsRuntime.setupJobs({
app, service: 'img', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
app, service: 'img', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
getPublicKey: guard.keys.get, issuer: config.networkUrl, guard,
@@ -125,23 +125,27 @@ function bmp({ width, height, bpp, compression = 0, palette = [], rows }) {
@@ -125,23 +125,27 @@ function bmp({ width, height, bpp, compression = 0, palette = [], rows }) {
HEIF_DEC_PATH: '/nonexistent/heif-dec',
HEIF_DEC_PATH: '/nonexistent/heif-dec',
});
});
try {
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.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();
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();
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');
assert.strictEqual(ctx.inputs.heic, false, 'the HEIC page is told the decoder is missing');
@@ -37,26 +37,26 @@ const { startApp } = require('../../_shared/test/spawn');
@@ -37,26 +37,26 @@ const { startApp } = require('../../_shared/test/spawn');
// ── Through the app ──
// ── Through the app ──
const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-guard-'));
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 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();
const f = new FormData();
+ f.append('input', JSON.stringify({ format: 'png' }));
f.append('file', new Blob([buf], { type }), name);
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 {
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();
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(r.status, 415, 'a PDF called photo.png is refused');
assert.strictEqual((await r.json()).code, 'tools.file.unsupported_type');
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();
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.
// The job API refuses the same picture the same way (hard limit): the job fails with the code.
const f = new FormData();
const f = new FormData();
f.append('type', 'img.process');
f.append('type', 'img.process');
@@ -67,7 +67,6 @@ const { startApp } = require('../../_shared/test/spawn');
@@ -67,7 +67,6 @@ const { startApp } = require('../../_shared/test/spawn');
// Answers carry RateLimit headers and the sync endpoint is behind the semaphore.
// Answers carry RateLimit headers and the sync endpoint is behind the semaphore.
assert.ok(r.headers.get('ratelimit-limit'), 'RateLimit-Limit');
assert.ok(r.headers.get('ratelimit-limit'), 'RateLimit-Limit');
const metrics = await (await fetch(`${app.base}/metrics`)).text();
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_refused_total\{reason="sniff",tool="convert"\} [12]/);
assert.match(metrics, /tools_guard_sync\{kind="limit"\} 2/);
assert.match(metrics, /tools_guard_sync\{kind="limit"\} 2/);
assert.match(metrics, /tools_guard_enforcing 0/);
assert.match(metrics, /tools_guard_enforcing 0/);
@@ -63,16 +63,6 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
@@ -63,16 +63,6 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
assert.match(r.headers.get('content-type'), /problem\+json/);
assert.match(r.headers.get('content-type'), /problem\+json/);
assert.strictEqual((await r.json()).code, 'tools.job.invalid');
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.
// Canonical host: through the gateway the page names the host the gateway says.
// (fetch cannot set Host, so pages are requested with http.request)
// (fetch cannot set Host, so pages are requested with http.request)
const page = (headers) => new Promise((resolve, reject) => {
const page = (headers) => new Promise((resolve, reject) => {