openvibers/openvibe.tools Commits

Tool descriptors: one tools.tool@1 descriptor per catalogue tool (169), kept as pure data next to the code that runs it and merged by the gateway (ADR-027). apps/{img,audio,docs,text,yt,maps}/server/descriptors.js and apps/gateway/server/{net,dev}/descriptors.js hold what each app owns (execution, api, the job and its preset, older routes, input JSON Schema, files, output, limits, auth, quota class, cost, egress), derived from the domain maps (operation, default format), net/config.js (endpoint, unavailable marks) and the engines (text styles, cases, escapes); apps/_shared/tools/descriptor.js joins a spec with the catalogue's family, name, summary, hosts and docs page. Format hosts are presets (img and docs: defaultFormat, which the img job now takes and a caller's format still overrides, as on the page; audio: format, as its hosts force it). Network probes (port, ping, latency, traceroute, mtr) need tools.net.probe and never run anonymously; every other anonymous egress tool has a per-target throttle; audio and PDF jobs need a session or a token; yt is page-only (api false); browser tools with a server engine are execution client with api true; tools needing a DOM, terser or isolation (regex) are api false. apps/gateway/server/registry/descriptors.js merges them with the catalogue (owner domain overrides, the unavailable marks), checks every build with contracts.validate('tools.tool@1'), contracts.tools.checkDescriptor and contracts.tools.checkList, leaves a failing descriptor out and reports it (readyCheck), and takes each satellite's own unavailable marks (a missing qpdf, pdftoppm, heif-dec, ffmpeg or yt-dlp) from its GET /api/v1/tools every minute. Host defaults and folded builds move to registry/hosts.js (pure) so satellites name the same hosts. Tests: descriptors.test.js (every catalogue id has exactly one valid descriptor; schemas compile strictly and examples match; every job tool's operation exists and { ...example, ...preset, tool } passes its app's own job validation, within its upload types, sizes, file counts and timeout; every sync API tool runs through a route the gateway serves; hosts, overrides, statuses, planned and mirrors) and engines.test.js (every browser tool with api true names an engine that a fresh plain Node process requires and runs, its answer matching the output schema; the pages load those engines)

OP OpenVibers committed 23 Sept 2026 · d04dbdac6c · 15 files, +1347 −29


Changes

ddiff --git a/apps/_shared/tools/descriptor.js b/apps/_shared/tools/descriptor.js
nnew file mode 100644
iindex 0000000..1040ef4
---- /dev/null
++++ b/apps/_shared/tools/descriptor.js
@@@ -0,0 +1,88 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Tool descriptors (openvibe-contracts tools.tool@1, ADR-027): one per catalogue tool.
++//
++// Each app keeps a SPEC per tool in server/descriptors.js, next to the code that runs it: how it
++// runs (execution, api, the job it submits, the older routes), what it takes (input schema, files),
++// what it gives (output), its limits, who may call it (auth), its quota class and cost, and whether
++// it reaches out to a host the caller chose (egress). The catalogue owns the rest: family, name,
++// summary, hosts (with the owner's domain overrides) and the docs page. compose() joins the two.
++//
++// A spec may also carry fields that are never published (INTERNAL below):
++// engine the name of its server engine in the app's engines module (a client tool with api true)
++// route the server route that runs a sync tool today ({ method, path, query })
++// requires programs the tool needs on the host ('qpdf', 'heif-dec', …); the satellite marks it
++// unavailable while one is missing
++// example { input } that runs: used by the tests, later by the docs
++//
++// Like the rest of apps/_shared this file has no dependencies.
++// ═══════════════════════════════════════════════════════════════
++
++const SITE = 'https://openvibe.tools';
++const JSON_SCHEMA = 'https://json-schema.org/draft/2020-12/schema';
++const INTERNAL = new Set(['id', 'engine', 'route', 'requires', 'example', 'job', 'legacy', 'status', 'statusReason']);
++const ANYONE = Object.freeze({ anonymous: true, capability: 'tools.tool.run' });
++
++const runPath = (id) => `/api/v1/tools/${id}/run`;
++const schemaUrl = (id) => `${SITE}/api/v1/tools/${id}/schema`;
++const docsUrl = (id) => `${SITE}/tool/${id}`;
++const clone = (v) => (v == null ? v : JSON.parse(JSON.stringify(v)));
++
++/** One plain sentence for the catalogue record: its description's first sentence when that fits, else the tagline. */
++function summaryOf(rec) {
++ const first = String((rec && rec.description) || '').split(/(?<=[.!?])\s/)[0].trim();
++ if (first && first.length <= 200) return /[.!?]$/.test(first) ? first : `${first}.`;
++ const t = String((rec && rec.tagline) || (rec && rec.name) || '').trim().slice(0, 199);
++ return /[.!?]$/.test(t) ? t : `${t}.`;
++}
++
++/**
++ * spec + catalogue facts → a tools.tool@1 descriptor with its schemas embedded.
++ * @param {object} spec the app's spec (see the header)
++ * @param {object} meta { family, name, summary, hosts: string[], docs?, status?, statusReason? } — a
++ * status here (the host is missing a program, the catalogue marks it) wins
++ */
++function compose(spec, meta) {
++ const api = spec.api === true;
++ const status = meta.status || spec.status || 'stable';
++ const reason = meta.status ? meta.statusReason : spec.statusReason;
++ const d = { id: spec.id, family: meta.family, name: meta.name, summary: meta.summary, status };
++ if (reason) d.statusReason = String(reason).slice(0, 300);
++ d.execution = spec.execution;
++ d.api = api;
++ d.run = api ? { method: 'POST', path: runPath(spec.id), job: spec.job ? clone(spec.job) : null, ...(spec.legacy && spec.legacy.length && { legacy: [...spec.legacy] }) } : null;
++ d.input = api ? clone(spec.input) : null;
++ d.files = clone(spec.files || null);
++ d.output = clone(spec.output);
++ d.limits = clone(spec.limits);
++ d.auth = clone(spec.auth || ANYONE);
++ d.quotaClass = spec.quotaClass;
++ d.cost = spec.cost || 1;
++ d.egress = spec.egress === true;
++ d.hosts = [...(meta.hosts || [])];
++ d.docs = meta.docs || docsUrl(spec.id);
++ return d;
++}
++
++/** The list form: input and output.schema as { $ref } into GET /api/v1/tools/:id/schema. */
++function refForm(d) {
++ const out = { ...d };
++ if (d.input && typeof d.input === 'object') out.input = { $ref: `${schemaUrl(d.id)}#/$defs/input` };
++ if (d.output && d.output.schema) out.output = { ...d.output, schema: { $ref: `${schemaUrl(d.id)}#/$defs/output` } };
++ return out;
++}
++
++/** GET /api/v1/tools/:id/schema: { $schema, $id, $defs: { input, output } }. No API → input false (nothing is accepted). */
++function schemaDoc(d) {
++ const output = d.output.schema || (d.output.kind === 'text' ? { type: 'string', description: 'result.text' } : { description: d.output.kind === 'json' ? 'result.data' : 'result.files' });
++ return { $schema: JSON_SCHEMA, $id: schemaUrl(d.id), $defs: { input: d.input || false, output } };
++}
++
++/** A spec without the fields that are never published (tests use it to compare specs to descriptors). */
++function publicFields(spec) {
++ const out = {};
++ for (const [k, v] of Object.entries(spec)) if (!INTERNAL.has(k)) out[k] = v;
++ return out;
++}
++
++module.exports = { SITE, JSON_SCHEMA, ANYONE, compose, refForm, schemaDoc, summaryOf, publicFields, runPath, schemaUrl, docsUrl };
ddiff --git a/apps/audio/server/descriptors.js b/apps/audio/server/descriptors.js
nnew file mode 100644
iindex 0000000..d9c3e89
---- /dev/null
++++ b/apps/audio/server/descriptors.js
@@@ -0,0 +1,103 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Audio.OpenVibe — what each audio tool is, for the tool registry (tools.tool@1 specs, ADR-027;
++// apps/_shared/tools/descriptor.js joins them with the catalogue's name, summary and hosts).
++//
++// Every audio tool is its host's operation (domain-map.js) run as an audio.process job. A format host
++// (mp3., wav., …) forces its format on the page, so it is the job's preset here too. The option
++// names and ranges are the ones each tools/<op>.js reads (and clamps to). Pure data: nothing here
++// loads ffmpeg. apps/gateway/test/descriptors.test.js holds it to tools/index.js, process.js and config.js.
++// ═══════════════════════════════════════════════════════════════
++
++const { DOMAIN_MAP } = require('./domain-map');
++
++// tools/convert.js FORMAT_CONFIG and tools/ffmpeg-helper.js FORMAT_MIME.
++const FORMAT_MIME = { mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac', ogg: 'audio/ogg', m4a: 'audio/mp4', aac: 'audio/aac', opus: 'audio/opus', wma: 'audio/x-ms-wma', aiff: 'audio/aiff', ac3: 'audio/ac3', webm: 'audio/webm' };
++const FORMATS = Object.keys(FORMAT_MIME);
++// config.js upload.allowedMimes (video types are there for the extractor; octet-stream for mis-typed uploads).
++const ACCEPT = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/flac', 'audio/x-flac', 'audio/ogg', 'audio/vorbis', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/opus', 'audio/x-ms-wma', 'audio/aiff', 'audio/x-aiff', 'audio/ac3', 'audio/webm', 'audio/amr', 'video/mp4', 'video/webm', 'video/x-matroska', 'video/avi', 'video/x-msvideo', 'video/quicktime', 'video/x-flv', 'video/ogg', 'application/ogg', 'application/octet-stream'];
++const MAX_BYTES = 100 * 1024 * 1024; // config.js upload.maxFileSize
++const TIMEOUT_MS = 15 * 60 * 1000; // process.js audio.process timeoutMs
++const MERGE_MAX_SECONDS = 3 * 60 * 60; // tools/merge.js MAX_TOTAL_SECONDS
++// An effect keeps the input's own format unless `format` names one (ffmpeg-helper getMime: amr too, anything else octet-stream).
++const ALL_OUT = [...new Set([...Object.values(FORMAT_MIME), 'audio/amr', 'application/octet-stream'])];
++
++const RESULT = {
++ type: 'object',
++ description: 'result.data: what the operation made (the file itself is result.files[0])',
++ required: ['tool', 'output'],
++ properties: {
++ tool: { type: 'string' },
++ output: { type: 'object', properties: { mime: { type: 'string' }, ext: { type: 'string' }, size: { type: 'integer' }, sizeKB: { type: 'number' }, duration: { type: ['number', 'null'] } } },
++ input: { type: 'object', properties: { size: { type: 'integer' }, sizeKB: { type: 'number' } } },
++ metadata: { type: 'object', description: 'metadata: the tags written' },
++ preset: { type: ['string', 'object'], description: 'the preset applied' },
++ fileCount: { type: 'integer', description: 'merge: how many files were joined' },
++ },
++};
++
++const fmt = { enum: FORMATS, description: 'Output format (default: the input\'s own)' };
++const bitrate = { type: 'integer', minimum: 32, maximum: 320, description: 'kbps, lossy formats only' };
++const time = { type: ['number', 'string'], pattern: '^\\d+(\\.\\d+)?$|^(\\d+:)?\\d{1,2}:\\d{2}(\\.\\d+)?$', minimum: 0, description: 'Seconds, or [HH:]MM:SS' };
++const obj = (properties, required) => ({ type: 'object', additionalProperties: false, ...(required && { required }), properties });
++const choice = (values, dflt, description) => ({ enum: values, default: dflt, ...(description && { description }) });
++
++// The options each operation reads (tools/<op>.js), with the ranges it clamps to.
++const INPUT = {
++ convert: () => obj({ bitrate: { ...bitrate, default: 192 }, sampleRate: { type: 'integer', minimum: 8000, maximum: 192000, description: 'Hz (default: the input\'s)' }, channels: { enum: [1, 2], description: '1 = mono, 2 = stereo (default: the input\'s)' } }),
++ extract: () => obj({ format: { enum: ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus', 'wma'], default: 'mp3' }, bitrate: { ...bitrate, default: 192 } }),
++ trim: () => obj({ start: { ...time, default: 0 }, end: { ...time, description: 'Seconds or [HH:]MM:SS (default: the end)' }, format: fmt }),
++ merge: () => obj({ format: { enum: FORMATS, default: 'mp3' }, bitrate: { ...bitrate, default: 192 } }),
++ ringtone: () => obj({ start: { ...time, default: 0 }, end: { ...time, description: 'Default: 30 s after start' }, device: choice(['iphone', 'android'], 'iphone', 'iphone → M4R, android → MP3'), fadeIn: { type: 'number', minimum: 0, maximum: 30, default: 0 }, fadeOut: { type: 'number', minimum: 0, maximum: 30, default: 0.5 } }),
++ speed: () => obj({ speed: { type: 'number', minimum: 0.25, maximum: 4, default: 1, description: 'Multiplier; the pitch is kept' }, format: fmt }),
++ pitch: () => obj({ semitones: { type: 'number', minimum: -24, maximum: 24, default: 0 }, format: fmt }),
++ reverse: () => obj({ format: fmt }),
++ normalize: () => obj({ mode: choice(['peak', 'loudness', 'rms'], 'peak', 'peak, loudness (EBU R128) or rms'), target: { type: 'number', minimum: -70, maximum: 0, description: 'dB (default -1 peak, -16 loudness, -20 rms)' }, format: fmt }),
++ noise: () => obj({ strength: choice(['light', 'medium', 'heavy'], 'medium'), format: fmt }),
++ silence: () => obj({ threshold: { type: 'integer', minimum: -100, maximum: 0, default: -40, description: 'dB below which it counts as silence' }, minDuration: { type: 'number', minimum: 0.1, maximum: 60, default: 0.5, description: 'Shortest gap removed, seconds' }, format: fmt }),
++ vocal: () => obj({ mode: choice(['remove', 'isolate'], 'remove', 'remove (karaoke) or isolate (vocals only)'), format: fmt }),
++ fade: () => obj({ fadeIn: { type: 'number', minimum: 0, default: 0, description: 'Seconds' }, fadeOut: { type: 'number', minimum: 0, default: 0, description: 'Seconds' }, format: fmt }),
++ loop: () => obj({ count: { type: 'integer', minimum: 2, maximum: 20, default: 2, description: 'How many times it plays in total' }, format: fmt }),
++ bass: () => obj({ gain: { type: 'number', minimum: -20, maximum: 20, default: 6, description: 'dB' }, frequency: { type: 'integer', minimum: 20, maximum: 300, default: 100, description: 'Hz' }, format: fmt }),
++ equalizer: () => obj({ preset: choice(['bass_boost', 'treble_boost', 'vocal_boost', 'flat', 'rock', 'pop', 'jazz', 'classical', 'electronic', 'hip_hop', 'podcast', 'telephone'], 'bass_boost'), format: fmt }),
++ compressor: () => obj({ preset: choice(['light', 'medium', 'heavy', 'broadcast'], 'medium'), format: fmt }),
++ podcast: () => obj({ format: { enum: FORMATS, default: 'mp3' }, bitrate: { ...bitrate, default: 128 }, mono: { type: 'boolean', default: true } }),
++ voice: () => obj({ preset: choice(['chipmunk', 'deep', 'robot', 'echo', 'cave', 'radio', 'underwater', 'whisper', 'megaphone', 'demon'], 'chipmunk'), format: fmt }),
++ echo: () => obj({ delay: { type: 'integer', minimum: 50, maximum: 2000, default: 250, description: 'ms' }, decay: { type: 'number', minimum: 0.1, maximum: 0.9, default: 0.5 }, repeats: { type: 'integer', minimum: 1, maximum: 10, default: 3 }, format: fmt }),
++ reverb: () => obj({ preset: choice(['room', 'hall', 'cathedral', 'plate'], 'hall'), mix: { type: 'integer', minimum: 0, maximum: 100, default: 50, description: 'Wet/dry mix, %' }, format: fmt }),
++ chorus: () => obj({ intensity: choice(['light', 'medium', 'heavy'], 'medium'), format: fmt }),
++ distortion: () => obj({ gain: { type: 'integer', minimum: 1, maximum: 100, default: 20, description: 'Drive' }, format: fmt }),
++ bitcrusher: () => obj({ bits: { type: 'integer', minimum: 2, maximum: 16, default: 8 }, sampleRate: { type: 'integer', minimum: 1000, maximum: 44100, default: 8000 }, format: fmt }),
++ stereo: () => obj({ mode: choice(['mono', 'stereo', 'wide', 'narrow'], 'mono'), format: fmt }),
++ waveform: () => obj({ width: { type: 'integer', minimum: 200, maximum: 4000, default: 1200 }, height: { type: 'integer', minimum: 60, maximum: 800, default: 200 }, color: { type: 'string', pattern: '^#?[0-9a-fA-F]{3,8}$', default: '#c0965c' }, bgColor: { type: 'string', pattern: '^#?[0-9a-fA-F]{3,8}(@[0-9.]+)?$', default: '#1a1a22' } }),
++ metadata: () => obj(Object.fromEntries(['title', 'artist', 'album', 'genre', 'year', 'track', 'comment'].map(k => [k, { type: 'string', maxLength: 500 }]).concat([['format', fmt]]))),
++};
++
++/** One audio tool: its host's operation as an audio.process job. */
++function tool(id, extra = {}) {
++ const ctx = DOMAIN_MAP[`${id}.openvibe.tools`];
++ if (!ctx || !ctx.defaultOp) throw new Error(`audio: ${id}.openvibe.tools has no operation in domain-map.js`);
++ const op = ctx.defaultOp;
++ const fixed = op === 'convert' && ctx.defaultFormat;
++ const mime = fixed ? [FORMAT_MIME[ctx.defaultFormat]] : op === 'waveform' ? ['image/png'] : op === 'ringtone' ? ['audio/x-m4r', 'audio/mpeg'] : op === 'convert' || op === 'extract' || op === 'merge' || op === 'podcast' ? [...new Set(Object.values(FORMAT_MIME))] : ALL_OUT;
++ 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: op === 'merge' ? { min: 2, max: 5, accept: ACCEPT, maxBytes: MAX_BYTES } : { min: 1, max: 1, accept: ACCEPT, maxBytes: MAX_BYTES },
++ output: { kind: 'file', mime, schema: RESULT },
++ limits: { timeoutMs: TIMEOUT_MS, ...(op === 'merge' && { maxDurationSec: MERGE_MAX_SECONDS }) },
++ // ffmpeg over an upload is heavy work: a browser session (the page has one), a person or a token.
++ auth: { anonymous: false, capability: 'tools.tool.run' },
++ quotaClass: 'tools-job', cost: op === 'merge' ? 20 : op === 'metadata' || op === 'waveform' ? 5 : 10, egress: false,
++ requires: ['ffmpeg'],
++ example: { input: {} },
++ ...extra,
++ };
++}
++
++const IDS = ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'opus', 'aac', 'wma', 'aiff', 'ac3', 'extract', 'trim', 'merge', 'ringtone', 'speed', 'pitch', 'reverse', 'normalize', 'noise', 'silence', 'vocal', 'fade', 'loop', 'bass', 'equalizer', 'compressor', 'podcast', 'voice', 'echo', 'reverb', 'chorus', 'distortion', 'bitcrusher', 'stereo', 'waveform', 'metadata'];
++const SPECS = IDS.map(id => tool(id));
++
++module.exports = { SPECS, FORMAT_MIME, ACCEPT, MAX_BYTES, TIMEOUT_MS };
ddiff --git a/apps/docs/server/descriptors.js b/apps/docs/server/descriptors.js
nnew file mode 100644
iindex 0000000..f7d5972
---- /dev/null
++++ b/apps/docs/server/descriptors.js
@@@ -0,0 +1,99 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Docs.OpenVibe — what each PDF tool is, for the tool registry (tools.tool@1 specs, ADR-027;
++// apps/_shared/tools/descriptor.js joins them with the catalogue's name, summary and hosts).
++//
++// Every PDF tool is its host's operation (domain-map.js) run as a docs.process job; pdf2jpg's host
++// default (JPG) is its preset. Protect and Unlock need qpdf, PDF to image needs poppler's pdftoppm
++// and pdfinfo (`requires`): while one is missing on the host the satellite lists the tool as
++// unavailable, as its page and /api/process already say. Pure data: nothing here loads pdf-lib.
++// apps/gateway/test/descriptors.test.js holds it to tools/index.js, process.js and config.js.
++// ═══════════════════════════════════════════════════════════════
++
++const { DOMAIN_MAP } = require('./domain-map');
++
++const PDF = 'application/pdf';
++// What Image to PDF takes: config.js upload.allowedMimes without the PDF, and without HEIC/HEIF, which the
++// upload accepts but sharp (tools/img2pdf.js) cannot decode.
++const IMAGES = ['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/tiff', 'image/bmp', 'image/gif'];
++const MAX_BYTES = 100 * 1024 * 1024; // config.js upload.maxFileSize
++const MAX_FILES = 50; // process.js docs.process maxFiles (and the upload limit)
++const TIMEOUT_MS = 10 * 60 * 1000; // process.js docs.process timeoutMs
++// tools/pdf.js MAX_PAGES and tools/pdf2img.js MAX_PAGES (both from the environment, like theirs).
++const MAX_PAGES = Math.max(1, parseInt(process.env.PDF_MAX_PAGES, 10) || 500);
++const PDF2IMG_PAGES = Math.max(1, parseInt(process.env.PDF2IMG_MAX_PAGES, 10) || 50);
++
++const RESULT = {
++ type: 'object',
++ description: 'result.data: what the operation made (the file itself is result.files[0])',
++ required: ['tool'],
++ properties: {
++ tool: { type: 'string' },
++ output: { type: 'object', properties: { mime: { type: 'string' }, ext: { type: 'string' }, size: { type: 'integer' }, sizeKB: { type: 'number' } } },
++ fileCount: { type: 'integer' }, pageCount: { type: 'integer' },
++ savings: { type: 'object' }, metadata: { type: 'object' }, note: { type: 'string' },
++ parts: { type: 'array', description: 'split: the page ranges of each part' },
++ pages: { type: 'array', description: 'pdf2img: the pages rendered' },
++ encryption: { type: 'object', description: 'protect: the encryption applied (never the passwords)' },
++ },
++};
++
++const pages = { type: 'string', pattern: '^\\s*(all|\\d+(\\s*-\\s*\\d+)?(\\s*,\\s*\\d+(\\s*-\\s*\\d+)?)*)\\s*$', description: 'Pages: all, or a list like 1-3,5 (1-based)', default: 'all' };
++const obj = (properties, required) => ({ type: 'object', additionalProperties: false, ...(required && { required }), properties });
++const password = { type: 'string', minLength: 1, maxLength: 127, description: 'At most 127 bytes of UTF-8' };
++
++// The options each operation reads (tools/<op>.js; process.js SINGLE_KEYS / MULTI_KEYS).
++const OPS = {
++ merge: { input: obj({ order: { type: 'array', items: { type: 'integer', minimum: 0, maximum: MAX_FILES - 1 }, maxItems: MAX_FILES, description: 'The files\' order by their index (0-based); default: as uploaded' } }), files: { min: 2, max: MAX_FILES, accept: [PDF] }, mime: [PDF], cost: 8 },
++ split: { input: obj({ ranges: { type: 'string', pattern: '^\\s*(all|half|\\d+(\\s*-\\s*\\d+)?(\\s*,\\s*\\d+(\\s*-\\s*\\d+)?)*)\\s*$', default: 'all', description: 'all (every page on its own), half, or ranges like 1-3,5,7-9: one part each; several parts come back as a ZIP' } }), files: { min: 1, max: 1, accept: [PDF] }, mime: [PDF, 'application/zip'], cost: 5 },
++ compress: { input: obj({ level: { enum: ['low', 'medium', 'high'], default: 'medium', description: 'high also strips the document metadata' } }), files: { min: 1, max: 1, accept: [PDF] }, mime: [PDF], cost: 5 },
++ rotate: { input: obj({ angle: { enum: [90, 180, 270], default: 90 }, pages }), files: { min: 1, max: 1, accept: [PDF] }, mime: [PDF], cost: 5 },
++ reorder: { input: obj({ order: { type: 'string', pattern: '^\\s*\\d+(\\s*,\\s*\\d+)*\\s*$', description: 'The new page order, 1-based (3,1,2); pages left out are dropped' } }, ['order']), files: { min: 1, max: 1, accept: [PDF] }, mime: [PDF], cost: 5 },
++ watermark: {
++ input: obj({
++ text: { type: 'string', minLength: 1, maxLength: 200, default: 'WATERMARK' }, fontSize: { type: 'integer', minimum: 6, maximum: 400, default: 48 },
++ opacity: { type: 'number', minimum: 0.01, maximum: 1, default: 0.15 }, rotation: { type: 'integer', minimum: -360, maximum: 360, default: -45 },
++ color: { type: 'string', pattern: '^#?[0-9a-fA-F]{6}$', default: '#888888' }, pages,
++ }),
++ files: { min: 1, max: 1, accept: [PDF] }, mime: [PDF], cost: 5,
++ },
++ protect: {
++ input: obj({ password, ownerPassword: { ...password, description: 'Owner password (default: a random one, never shown)' }, allowPrint: { type: 'boolean', default: true }, allowCopy: { type: 'boolean', default: true } }, ['password']),
++ files: { min: 1, max: 1, accept: [PDF] }, mime: [PDF], cost: 10, requires: ['qpdf'],
++ },
++ unlock: { input: obj({ password }, ['password']), files: { min: 1, max: 1, accept: [PDF] }, mime: [PDF], cost: 10, requires: ['qpdf'] },
++ img2pdf: { input: obj({ pageSize: { enum: ['a4', 'letter', 'legal', 'fit'], default: 'a4', description: 'fit: each page is its picture\'s size' } }), files: { min: 1, max: MAX_FILES, accept: IMAGES }, mime: [PDF], cost: 8 },
++ pdf2img: {
++ input: obj({ format: { enum: ['jpg', 'png'], description: 'Default: jpg' }, dpi: { type: 'integer', minimum: 72, maximum: 600, default: 150 }, pages }),
++ files: { min: 1, max: 1, accept: [PDF] }, mime: ['image/jpeg', 'image/png', 'application/zip'], cost: 10, requires: ['pdftoppm', 'pdfinfo'],
++ maxPages: PDF2IMG_PAGES,
++ },
++};
++
++/** One PDF tool: its host's operation as a docs.process job. */
++function tool(id, extra = {}) {
++ const ctx = DOMAIN_MAP[`${id}.openvibe.tools`];
++ if (!ctx || !ctx.defaultOp) throw new Error(`docs: ${id}.openvibe.tools has no operation in domain-map.js`);
++ const op = ctx.defaultOp;
++ const o = OPS[op];
++ 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 },
++ limits: { timeoutMs: TIMEOUT_MS, ...(op !== 'img2pdf' && { maxPages: o.maxPages || MAX_PAGES }) },
++ // PDF work runs on the satellite's main thread today: a browser session, a person or a token.
++ auth: { anonymous: false, capability: 'tools.tool.run' },
++ quotaClass: 'tools-job', cost: o.cost, egress: false,
++ ...(o.requires && { requires: o.requires }),
++ example: { input: op === 'reorder' ? { order: '1' } : op === 'protect' || op === 'unlock' ? { password: 'example' } : {} },
++ ...extra,
++ };
++}
++
++const IDS = ['mergepdf', 'splitpdf', 'compresspdf', 'rotatepdf', 'reorderpdf', 'watermarkpdf', 'protectpdf', 'unlockpdf', 'image2pdf', 'pdf2jpg'];
++const SPECS = IDS.map(id => tool(id));
++
++module.exports = { SPECS, OPS, IMAGES, MAX_BYTES, MAX_FILES, TIMEOUT_MS, MAX_PAGES };
ddiff --git a/apps/gateway/server/dev/descriptors.js b/apps/gateway/server/dev/descriptors.js
nnew file mode 100644
iindex 0000000..7606174
---- /dev/null
++++ b/apps/gateway/server/dev/descriptors.js
@@@ -0,0 +1,106 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Dev.OpenVibe — what each developer tool is, for the tool registry (tools.tool@1 specs, ADR-027;
++// apps/_shared/tools/descriptor.js joins them with the catalogue's name, summary and hosts).
++//
++// The pages (public/dev.html) run the tools in the browser. A pure transform also has a server engine
++// for the API (server/dev/engines.js over public/js/dev-engine.js, named by `engine`): api true,
++// answered inline, the page unchanged. api false where the engine needs the browser: the XML
++// minifier and HTML to Markdown (DOMParser), the JavaScript minifier and beautifier (terser, loaded
++// from a CDN by the page), and the regex tester (a caller's pattern would run unbounded on the server;
++// it can come back once runs are isolated). Open Graph fetches a URL from the server (sync, egress);
++// the webhook tester keeps request bins on the server, which a single run cannot express (api false).
++// ═══════════════════════════════════════════════════════════════
++
++const KiB = 1024;
++const MAX = 1024 * KiB; // the gateway's JSON body limit
++const obj = (properties, required = ['text']) => ({ type: 'object', additionalProperties: false, required, properties });
++const text = (description) => ({ type: 'string', maxLength: MAX, ...(description && { description }) });
++const mode = (values, dflt, description) => ({ enum: values, default: dflt, ...(description && { description }) });
++const TEXT = { kind: 'text' };
++const ANYONE = { anonymous: true, capability: 'tools.tool.run' };
++
++/** A browser tool with a server engine: api true, answered inline. */
++function engine(id, input, output, example, extra = {}) {
++ return {
++ id, execution: 'client', api: true, input, files: null, output,
++ limits: { timeoutMs: 5000, maxInputBytes: MAX },
++ auth: ANYONE, quotaClass: 'tools-run', cost: 1, egress: false,
++ engine: id, example: { input: example },
++ ...extra,
++ };
++}
++
++/** A browser-only tool: api false. */
++function page(id, output = TEXT) {
++ return { id, execution: 'client', api: false, input: null, files: null, output, limits: { timeoutMs: 10000 }, auth: ANYONE, quotaClass: 'tools-run', cost: 1, egress: false };
++}
++
++const SPECS = [
++ engine('yaml', obj({ text: text(), to: mode(['json', 'yaml'], 'json', 'json: YAML → JSON; yaml: JSON → YAML') }), TEXT, { text: 'name: openvibe\nactive: true' }),
++ engine('xml', obj({ text: text(), mode: mode(['format', 'minify'], 'format') }), TEXT, { text: '<a><b>1</b></a>' }),
++ engine('csv', obj({ text: text('CSV with a header row') }),
++ { kind: 'json', schema: { type: 'object', required: ['rows'], properties: { rows: { type: 'array', items: { type: 'object', additionalProperties: { type: 'string' } }, description: 'One object per row, keyed by the header' } } } },
++ { text: 'name,age\nAda,36' }),
++ engine('sql', obj({ text: text(), mode: mode(['format', 'uppercase', 'compact'], 'format') }), TEXT, { text: 'select * from t where a = 1' }),
++ engine('html', obj({ text: text(), mode: mode(['format', 'minify', 'entities'], 'format', 'entities: encode characters as numeric HTML entities') }), TEXT, { text: '<div><p>Hi</p></div>' }),
++ engine('base64', obj({ text: text(), mode: mode(['encode', 'decode'], 'encode') }), TEXT, { text: 'Hello, World!' }),
++ engine('url', obj({ text: text(), mode: mode(['encode', 'decode', 'parse'], 'encode', 'parse: break a URL into its parts') }), TEXT, { text: 'a b&c', mode: 'encode' }),
++ engine('jwt', obj({ token: { type: 'string', maxLength: 64 * KiB, pattern: '^[A-Za-z0-9_=-]+\\.[A-Za-z0-9_=-]+\\.[A-Za-z0-9_=-]*$' } }, ['token']),
++ { kind: 'json', schema: { type: 'object', required: ['header', 'payload', 'signature'], description: 'Decoded, never verified', properties: { header: { type: 'object' }, payload: { type: 'object' }, signature: { type: 'string' }, expires_at: { type: ['string', 'null'], format: 'date-time' }, expired: { type: ['boolean', 'null'] } } } },
++ { token: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.sig' }),
++ engine('uuid', obj({ mode: mode(['generate', 'validate'], 'generate'), count: { type: 'integer', minimum: 1, maximum: 100, default: 1 }, text: { type: 'string', maxLength: 100, description: 'validate: the UUID to check' } }, []),
++ { kind: 'json', schema: { type: 'object', properties: { uuids: { type: 'array', items: { type: 'string', format: 'uuid' } }, valid: { type: 'boolean', description: 'validate: is it a v4 UUID' } } } },
++ { count: 2 }),
++ engine('hash', obj({ text: text(), algorithm: mode(['SHA-256', 'SHA-1', 'SHA-512'], 'SHA-256') }),
++ { kind: 'json', schema: { type: 'object', required: ['algorithm', 'hex'], properties: { algorithm: { type: 'string' }, hex: { type: 'string', pattern: '^[0-9a-f]+$' } } } },
++ { text: 'Hello, World!' }),
++ engine('hex', obj({ text: text(), mode: mode(['to-hex', 'from-hex'], 'to-hex') }), TEXT, { text: 'Hello' }),
++ engine('timestamp', obj({ mode: mode(['now', 'to-date', 'to-unix'], 'now', 'to-date: a Unix time (s or ms) → dates; to-unix: a date → Unix time'), text: { type: 'string', maxLength: 100 } }, []), TEXT, { mode: 'to-date', text: '1700000000' }),
++ engine('cron', obj({ expression: { type: 'string', maxLength: 200, description: 'minute hour day month weekday' } }, ['expression']), TEXT, { expression: '*/15 9-17 * * 1-5' }),
++ engine('beautify', obj({ text: text(), language: mode(['html', 'css', 'json'], 'html') }), TEXT, { text: '{"a":1}', language: 'json' }),
++ engine('minify', obj({ text: text(), language: { enum: ['json', 'html', 'css'] } }, ['text', 'language']), TEXT, { text: 'a { color: red; }', language: 'css' }),
++ page('regex'),
++ engine('lorem', obj({ unit: mode(['paragraphs', 'sentences', 'words'], 'paragraphs'), count: { type: 'integer', minimum: 1, maximum: 100, description: 'Default: 3 paragraphs, 10 sentences or 50 words' } }, []), TEXT, { unit: 'words', count: 5 }),
++ engine('curlconvert', obj({ command: { type: 'string', maxLength: 64 * KiB }, to: mode(['fetch', 'python', 'node', 'parsed'], 'fetch') }, ['command']), TEXT, { command: 'curl -X POST https://api.example.com -d x=1' }),
++ {
++ id: 'webhook', execution: 'sync', api: false, input: null, files: null,
++ output: { kind: 'json', schema: { type: 'object', description: 'A request bin: its id and the requests it caught (in memory, 1 hour)', properties: { binId: { type: 'string' }, requests: { type: 'array' } } } },
++ limits: { timeoutMs: 5000 }, auth: ANYONE, quotaClass: 'tools-run', cost: 1, egress: false,
++ route: { method: 'POST', path: '/api/dev/webhook/bins' },
++ },
++ engine('color', obj({ color: { type: 'string', maxLength: 64, description: '#rrggbb or rgb(r, g, b)' } }, ['color']),
++ { kind: 'json', schema: { type: 'object', required: ['hex', 'rgb', 'hsl', 'complement'], properties: { hex: { type: 'string' }, r: { type: 'integer' }, g: { type: 'integer' }, b: { type: 'integer' }, rgb: { type: 'string' }, hsl: { type: 'string' }, complement: { type: 'string' } } } },
++ { color: '#a78bfa' }),
++ {
++ id: 'opengraph', execution: 'sync', api: true,
++ input: obj({ url: { type: 'string', minLength: 1, maxLength: 2048, description: 'A page URL (https:// is assumed when missing)' } }, ['url']),
++ files: null,
++ output: {
++ kind: 'json', schema: {
++ type: 'object', required: ['url', 'tags', 'preview'],
++ properties: { url: { type: 'string' }, status: { type: 'integer' }, tags: { type: 'object', additionalProperties: { type: 'string' } }, preview: { type: 'object' }, recommendations: { type: 'array' } },
++ },
++ },
++ 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' } },
++ },
++ page('jsminify'), page('jsformat'),
++ engine('cssminify', obj({ text: text() }), TEXT, { text: '.a { color: #aabbcc; margin: 0px; }' }),
++ engine('cssformat', obj({ text: text() }), TEXT, { text: '.a{color:red}' }),
++ engine('htmlminify', obj({ text: text() }), TEXT, { text: '<!-- c -->\n<div>\n <p>Hi</p>\n</div>' }),
++ page('xmlminify'),
++ engine('jsonminify', obj({ text: text() }), TEXT, { text: '{\n "a": 1\n}' }),
++ engine('jsonparse', obj({ text: text(), view: mode(['tree', 'paths', 'types'], 'paths') }), TEXT, { text: '{"a":{"b":[1]}}' }),
++ engine('jsonvalidate', obj({ text: text() }),
++ { kind: 'json', schema: { type: 'object', required: ['valid'], properties: { valid: { type: 'boolean' }, error: { type: 'string', description: 'Where and why it does not parse' }, kind: { enum: ['object', 'array', 'string', 'number', 'boolean', 'null'] }, size: { type: ['integer', 'null'] }, values: { type: 'integer' }, depth: { type: 'integer' } } } },
++ { text: '{"a":1,}' }),
++ engine('jsonstringify', obj({ text: text(), mode: mode(['stringify', 'parse'], 'stringify', 'parse: turn an escaped string literal back into JSON') }), TEXT, { text: '{"a":"b"}' }),
++ page('html2md'),
++ engine('md2html', obj({ text: text() }), TEXT, { text: '# Hi\n\n- one' }),
++];
++
++module.exports = { SPECS };
ddiff --git a/apps/gateway/server/net/descriptors.js b/apps/gateway/server/net/descriptors.js
nnew file mode 100644
iindex 0000000..ec1abe9
---- /dev/null
++++ b/apps/gateway/server/net/descriptors.js
@@@ -0,0 +1,139 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Net.OpenVibe — what each network tool is, for the tool registry (tools.tool@1 specs, ADR-027;
++// apps/_shared/tools/descriptor.js joins them with the catalogue's name, summary and hosts).
++//
++// Every net tool is a server lookup answered inline (execution sync) by the /api/net endpoint that
++// net/config.js names for it; `route` says how its input becomes that request (the query the page
++// adds: MX for mx., _dmarc. for dmarc., ten pings for latency…). They all reach a host the caller
++// chose (egress), always through the SSRF guard, except myip.
++//
++// Who may call them (ADR-027, lead decision 1):
++// probes port, ping, latency, traceroute, mtr: they send traffic to the target itself, so through
++// the API they need the partner capability tools.net.probe and never run anonymously;
++// people keep the pages.
++// lookups everything else is open, with a per-target throttle across all callers
++// (limits.perTargetPerMinute). The SMTP test talks to a mail server, which a mail server
++// can hold against our address, so it needs at least a browser session or a token.
++// Unavailable tools (net/config.js status) have no engine and no API until they are built.
++// ═══════════════════════════════════════════════════════════════
++
++const { NET_TOOL_MAP, getNetConfig } = require('./config');
++const { PROP_TYPES, SMTP_PORTS } = require('./checks');
++
++const DNS_TYPES = getNetConfig(null).dnsTypes;
++const DEFAULT_DNS_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA']; // routes.js /dns without ?types=
++const TLS_PORTS = [443, 465, 636, 853, 990, 993, 995, 2083, 2087, 5061, 8443]; // direct-TLS ports the SSL check may use through the API
++const PROBES = new Set(['port', 'ping', 'latency', 'traceroute', 'mtr']);
++
++const KiB = 1024;
++const target = (description) => ({ type: 'string', minLength: 1, maxLength: 253 + 2048, description });
++const HOST = 'A domain or an IP address';
++const URL_OR_DOMAIN = 'A URL, or a domain (https:// is assumed)';
++const obj = (properties, required = ['target']) => ({ type: 'object', additionalProperties: false, required, properties });
++const out = (properties, required) => ({ type: 'object', ...(required && { required }), properties });
++const S = { type: 'string' }, I = { type: 'integer' }, O = { type: 'object' }, A = { type: 'array' }, B = { type: 'boolean' };
++const nul = (t) => ({ type: [t.type, 'null'] });
++
++// What each endpoint answers (routes.js and checks.js), as result.data. Extra fields may be added.
++const OUTPUT = {
++ '/lookup': out({ target: S, badges: { type: 'array', items: S }, ip: nul(O), dns: nul(O), rdap: nul(O), ssl: nul(O), headers: nul(O) }, ['target']),
++ '/myip': out({ ip: S, version: nul(I) }, ['ip']),
++ '/ip': out({ ip: S, hostname: nul(S), geo: O, network: O, reverse: nul(S) }, ['ip']),
++ '/ipv4': out({ ip: S, hostname: nul(S), ipv4: O, cidr: O, note: S, geo: O, network: O }, ['ip']),
++ '/ipv6': out({ ip: S, hostname: nul(S), ipv6: out({ type: S, scope: nul(S), prefix: nul(S), expanded: S, compressed: S, ptr: nul(S) }), geo: O, network: O }, ['ip', 'ipv6']),
++ '/rdns': out({ ip: S, hostnames: { type: 'array', items: S }, ptr: nul(S) }, ['ip']),
++ '/rdap': out({ target: S, type: { enum: ['domain', 'ip'] }, summary: O, raw: O }, ['target', 'summary']),
++ '/dns': out({ target: S, server: S, records: { type: 'object', description: 'record type → answers' } }, ['target', 'records']),
++ '/dnsprop': out({ target: S, type: S, resolvers: A, agree: B, answered: I, majority: nul(O) }, ['target', 'resolvers']),
++ '/ping': out({ target: S, ip: S, count: I, results: A, stats: out({ min: { type: 'number' }, max: { type: 'number' }, avg: { type: 'number' }, loss: { type: 'number' } }) }, ['target', 'stats']),
++ '/port': out({ target: S, ip: S, ports: { type: 'array', items: out({ port: I, status: S, ms: { type: ['number', 'null'] } }) } }, ['target', 'ports']),
++ '/headers': out({ url: S, status: I, statusText: S, headers: O, timing: O, security: O, securityScore: S, server: nul(S), poweredBy: nul(S), contentType: nul(S) }, ['url', 'status', 'headers']),
++ '/redirects': out({ originalUrl: S, finalUrl: S, hops: I, chain: A, stopped: S }, ['originalUrl', 'finalUrl', 'chain']),
++ '/ssl': out({ target: S, ip: S, port: I, protocol: S, cipher: nul(O), certificate: O, chain: A }, ['target', 'certificate']),
++ '/blacklist': out({ target: S, kind: { enum: ['ip', 'domain'] }, ip: nul(S), domain: S, listedCount: I, cleanCount: I, unknownCount: I, lists: A }, ['target', 'lists']),
++ '/smtp': out({ target: S, host: S, mx: A, ip: S, port: I, connected: B, banner: S, extensions: A, starttls: O, error: S }, ['target', 'host']),
++ '/uptime': out({ url: S, state: { enum: ['up', 'down'] }, status: nul(I), finalUrl: S, redirects: I, ms: I, checkedAt: S }, ['url', 'state']),
++ '/robots': out({ url: S, finalUrl: S, status: I, found: B, groups: A, sitemaps: A, warnings: A, verdict: S, test: O }, ['url', 'found']),
++ '/sitemap': out({ url: S, finalUrl: S, status: I, kind: nul(S), count: I, sample: A, children: A, issues: A, valid: B }, ['url', 'valid']),
++};
++
++// Per tool: its input schema, the fixed query the page adds (route.query) or how the target is built
++// (route.target), the timeout, the per-target throttle and the cost. Endpoints come from net/config.js.
++const TOOLS = {
++ lookup: { input: obj({ target: target(`${HOST}, or a URL`) }), timeoutMs: 30000, perTarget: 10, cost: 5 },
++ myip: { input: obj({}, []), timeoutMs: 2000, cost: 1 },
++ ip: { input: obj({ target: target(HOST) }) }, geoip: { input: obj({ target: target(HOST) }) },
++ isp: { input: obj({ target: target(HOST) }) }, asn: { input: obj({ target: target(HOST) }) },
++ ipv4: { input: obj({ target: target('An IPv4 address, a CIDR block (192.168.1.0/24) or a domain') }) },
++ ipv6: { input: obj({ target: target('An IPv6 address or a domain') }) },
++ hostname: { input: obj({ target: target(HOST) }) }, rdns: { input: obj({ target: target(HOST) }) },
++ whois: { input: obj({ target: target(HOST) }) }, rdap: { input: obj({ target: target(HOST) }) },
++ dns: { input: dnsInput() }, dig: { input: dnsInput() }, nslookup: { input: dnsInput() },
++ mx: { input: obj({ target: target('A domain') }), query: { types: 'MX' } },
++ txt: { input: obj({ target: target('A domain') }), query: { types: 'TXT' } },
++ ns: { input: obj({ target: target('A domain') }), query: { types: 'NS' } },
++ spf: { input: obj({ target: target('A domain') }), query: { types: 'TXT' } },
++ dmarc: { input: obj({ target: target('A domain') }), query: { types: 'TXT' }, targetTemplate: '_dmarc.{target}' },
++ dkim: { input: obj({ target: target('A domain'), selector: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,63}$', default: 'default', description: 'The DKIM selector (default, google, s1…)' } }), query: { types: 'TXT' }, targetTemplate: '{selector}._domainkey.{target}' },
++ dnspropagation: { input: obj({ target: target('A domain'), type: { enum: PROP_TYPES, default: 'A' } }), timeoutMs: 25000, perTarget: 10, cost: 3 },
++ ping: { input: obj({ target: target(HOST), count: { type: 'integer', minimum: 1, maximum: 10, default: 4 } }), timeoutMs: 60000, perTarget: 6, cost: 3 },
++ latency: { input: obj({ target: target(HOST) }), query: { count: '10' }, timeoutMs: 60000, perTarget: 6, cost: 5 },
++ traceroute: {}, mtr: {},
++ port: { input: obj({ target: target(HOST), ports: { type: 'array', minItems: 1, maxItems: 20, uniqueItems: true, items: { type: 'integer', minimum: 1, maximum: 65535 }, description: 'Default: 80, 443, 22, 21, 25, 53, 3306, 5432, 8080, 8443' } }), timeoutMs: 15000, perTarget: 6, cost: 5 },
++ headers: { input: obj({ target: target(URL_OR_DOMAIN), ua: { type: 'string', maxLength: 300, description: 'User-Agent to send' } }), perTarget: 10, cost: 2 },
++ curl: { input: obj({ target: target(URL_OR_DOMAIN), ua: { type: 'string', maxLength: 300 } }), perTarget: 10, cost: 2 },
++ httpstatus: { input: obj({ target: target(URL_OR_DOMAIN) }), perTarget: 10, cost: 2 },
++ redirects: { input: obj({ target: target(URL_OR_DOMAIN) }), timeoutMs: 60000, perTarget: 10, cost: 3 },
++ ssl: { input: obj({ target: target('A domain or an IP address'), port: { enum: TLS_PORTS, default: 443 } }), perTarget: 10, cost: 2 },
++ blacklist: { input: obj({ target: target('An IPv4 address or a domain') }), timeoutMs: 20000, perTarget: 10, cost: 3 },
++ reputation: {},
++ smtp: { input: obj({ target: target('A mail server, or a domain (its first MX is tested)'), port: { enum: SMTP_PORTS, default: 25 } }), timeoutMs: 40000, perTarget: 3, cost: 5, anonymous: false },
++ uptime: { input: obj({ target: target(URL_OR_DOMAIN) }), timeoutMs: 20000, perTarget: 10, cost: 2 },
++ robots: { input: obj({ target: target(URL_OR_DOMAIN), path: { type: 'string', maxLength: 2048, description: 'A path (or URL) to test against the rules' }, ua: { type: 'string', maxLength: 100, default: '*', description: 'The crawler to test as' } }), perTarget: 10, cost: 2 },
++ sitemap: { input: obj({ target: target('A sitemap URL, or a domain (its robots.txt Sitemap line, else /sitemap.xml)') }), timeoutMs: 45000, perTarget: 5, cost: 5 },
++};
++
++function dnsInput() {
++ return obj({
++ target: target('A domain'),
++ types: { type: 'array', minItems: 1, uniqueItems: true, items: { enum: DNS_TYPES }, default: DEFAULT_DNS_TYPES },
++ server: { type: 'string', maxLength: 45, pattern: '^[0-9A-Fa-f:.]+$', description: 'Ask this public DNS server (an IP address) instead of the system resolver' },
++ doh: { type: 'boolean', default: false, description: 'Ask over DNS-over-HTTPS' },
++ });
++}
++
++function spec(id) {
++ const def = NET_TOOL_MAP.get(id);
++ if (!def || def.hub) throw new Error(`net: ${id} is not a tool in net/config.js`);
++ const t = TOOLS[id];
++ if (!t) throw new Error(`net: ${id} has no descriptor`);
++ const probe = PROBES.has(id);
++ const egress = id !== 'myip';
++ const anonymous = !probe && t.anonymous !== false;
++ const unavailable = def.status === 'unavailable';
++ const endpoint = def.endpoint;
++ return {
++ id, execution: 'sync', api: !unavailable,
++ ...(unavailable && { status: 'unavailable', statusReason: def.unavailable }),
++ input: unavailable ? null : t.input,
++ files: null,
++ output: { kind: 'json', schema: OUTPUT[endpoint] || out({}) },
++ limits: {
++ timeoutMs: t.timeoutMs || 15000, ...(!unavailable && { maxInputBytes: 4 * KiB }),
++ ...(egress && { perTargetPerMinute: t.perTarget || 20 }),
++ },
++ 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' } },
++ };
++}
++
++const IDS = ['lookup', 'myip', 'ip', 'ipv4', 'ipv6', 'geoip', 'hostname', 'isp', 'asn', 'rdns', 'whois', 'rdap', 'dns', 'dig', 'nslookup', 'dnspropagation', 'mx', 'txt', 'ns', 'spf', 'dkim', 'dmarc', 'ping', 'traceroute', 'mtr', 'port', 'headers', 'redirects', 'ssl', 'curl', 'httpstatus', 'latency', 'blacklist', 'reputation', 'smtp', 'uptime', 'robots', 'sitemap'];
++const SPECS = IDS.map(spec);
++
++module.exports = { SPECS, OUTPUT, PROBES, TLS_PORTS };
ddiff --git a/apps/gateway/server/registry/descriptors.js b/apps/gateway/server/registry/descriptors.js
nnew file mode 100644
iindex 0000000..9f5980d
---- /dev/null
++++ b/apps/gateway/server/registry/descriptors.js
@@@ -0,0 +1,169 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// The merged tool registry: one tools.tool@1 descriptor per catalogue tool (ADR-027), for
++// GET /api/v1/tools[/:id[/schema]] on the gateway.
++//
++// specs each app's server/descriptors.js (net, dev and pastes here; img, audio, docs, text, yt
++// and maps/food from the satellites, required by path: pure data, no sharp or ffmpeg)
++// catalogue ./index.js: family, name, keywords, and hosts with the owner's domain overrides
++// status the catalogue's unavailable marks (net/config.js), and what each satellite says about
++// itself: GET /api/v1/tools on 127.0.0.1:<port> every minute, so a tool whose program is
++// missing on the host (qpdf, pdftoppm, heif-dec, ffmpeg, yt-dlp) is unavailable here too.
++//
++// Every build is checked with openvibe-contracts: contracts.validate('tools.tool@1') and
++// contracts.tools.checkDescriptor per descriptor, contracts.tools.checkList for the list. A descriptor
++// that fails is left out of the answers and the failure is a readiness problem (/api/ready degraded,
++// check tool_registry), never a crash. Planned placeholders are not tools (tools.tool-list@1): asking
++// for one is 404 with a detail saying it is planned.
++// ═══════════════════════════════════════════════════════════════
++
++const crypto = require('crypto');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { compose, summaryOf, refForm } = require('../../../_shared/tools/descriptor');
++const registry = require('./index');
++const { MERGED } = require('./hosts');
++
++const APPS = path.join(__dirname, '..', '..', '..');
++const PASTE = {
++ id: 'pastes', execution: 'sync', api: false, input: null, files: null,
++ output: { kind: 'json', schema: { type: 'object', description: 'Pastes live on OpenVibe.Community, which has its own API' } },
++ limits: { timeoutMs: 10000 }, auth: { anonymous: true, capability: 'tools.tool.run' }, quotaClass: 'tools-run', cost: 1, egress: false,
++};
++
++// Where each tool's spec lives, and the module its server engines are in (client tools with api true).
++const SOURCES = [
++ { app: 'net', specs: () => require('../net/descriptors').SPECS },
++ { app: 'dev', specs: () => require('../dev/descriptors').SPECS, engines: path.join(__dirname, '..', 'dev', 'engines.js') },
++ { app: 'img', specs: () => require(path.join(APPS, 'img/server/descriptors')).SPECS, satellite: 'img' },
++ { app: 'audio', specs: () => require(path.join(APPS, 'audio/server/descriptors')).SPECS, satellite: 'audio' },
++ { app: 'docs', specs: () => require(path.join(APPS, 'docs/server/descriptors')).SPECS, satellite: 'docs' },
++ { app: 'text', specs: () => require(path.join(APPS, 'text/server/descriptors')).SPECS, engines: path.join(APPS, 'text/server/engines.js'), satellite: 'text' },
++ { app: 'yt', specs: () => require(path.join(APPS, 'yt/server/descriptors')).SPECS, satellite: 'yt' },
++ { app: 'maps', specs: () => require(path.join(APPS, 'maps/server/descriptors')).SPECS.filter(s => s.id === 'maps'), satellite: 'maps' },
++ { app: 'food', specs: () => require(path.join(APPS, 'maps/server/descriptors')).SPECS.filter(s => s.id === 'food'), satellite: 'food' },
++ { app: 'community', specs: () => [PASTE] },
++];
++
++let specs = null; // id → { spec, app, engines, satellite }
++let loadProblems = [];
++function loadSpecs() {
++ if (specs) return specs;
++ specs = new Map();
++ loadProblems = [];
++ for (const src of SOURCES) {
++ let list;
++ try { list = src.specs(); } catch (err) { loadProblems.push(`${src.app}: descriptors could not be loaded (${err.message})`); continue; }
++ for (const spec of list) {
++ if (specs.has(spec.id)) loadProblems.push(`${spec.id}: described twice (${specs.get(spec.id).app} and ${src.app})`);
++ else specs.set(spec.id, { spec, app: src.app, engines: src.engines || null, satellite: src.satellite || null });
++ }
++ }
++ return specs;
++}
++
++const live = new Map(); // id → { status, statusReason } a satellite reports (only 'unavailable' is kept)
++let liveAt = {}; // satellite → last good poll time
++let built = null;
++
++/** Rebuild when the catalogue (hosts, overrides) or a satellite's report changed. */
++function key() {
++ return `${registry.get().updated}|${JSON.stringify([...live])}`;
++}
++
++function build() {
++ const all = loadSpecs();
++ const cat = registry.get();
++ const problems = [...loadProblems];
++ const tools = [];
++ const meta = new Map();
++ for (const t of cat.tools) {
++ const s = all.get(t.id);
++ if (!s) { problems.push(`${t.id}: a catalogue tool without a descriptor`); continue; }
++ const own = live.get(t.id) || (t.status === 'unavailable' ? { status: 'unavailable', statusReason: t.unavailable } : null);
++ const d = compose(s.spec, {
++ family: t.family, name: t.name, summary: summaryOf(t),
++ hosts: t.external ? [] : [t.hosts.canonical, t.hosts.short].filter(Boolean),
++ ...(own && { status: own.status, statusReason: own.statusReason }),
++ });
++ const v = contracts.validate('tools.tool@1', d);
++ const c = v.valid ? contracts.tools.checkDescriptor(d) : v;
++ if (!c.valid) { problems.push(`${t.id}: ${c.errors.slice(0, 3).map(e => `${e.path || '/'} ${e.message}`).join('; ')}`); continue; }
++ tools.push(d);
++ meta.set(t.id, { keywords: t.keywords || [], alsoIn: t.alsoIn || [] });
++ }
++ for (const id of all.keys()) if (!cat.tools.some(t => t.id === id)) problems.push(`${id}: described, but not a catalogue tool`);
++ const version = crypto.createHash('sha1').update(JSON.stringify(tools)).digest('hex').slice(0, 16);
++ const updatedAt = built && built.version === version ? built.updatedAt : new Date().toISOString();
++ const list = { tools: tools.map(refForm), count: tools.length, updated_at: updatedAt };
++ const lc = contracts.tools.checkList(list);
++ if (!lc.valid) problems.push(...lc.errors.slice(0, 5).map(e => `list ${e.path}: ${e.message}`));
++ const gone = new Map();
++ for (const p of cat.planned) gone.set(p.id, `${p.name} is planned and not built yet, so it has no descriptor.`);
++ for (const [mirror, m] of Object.entries(MERGED)) gone.set(mirror, `${mirror} is a second build of ${m.into}, served as a mirror; its descriptor is /api/v1/tools/${m.into}.`);
++ built = {
++ key: key(), version, updatedAt, tools, meta, gone, problems,
++ families: new Map(cat.families.map(f => [f.id, f.name])),
++ catalogueCount: cat.tools.length,
++ };
++ return built;
++}
++
++/** The registry now (rebuilt when its inputs changed), for the routes and the readiness check. */
++function snapshot() {
++ if (!built || built.key !== key()) build();
++ return built;
++}
++
++/** The internal spec of a tool (engine, route, requires, example) and where its engines live. */
++function internal(id) {
++ return loadSpecs().get(id) || null;
++}
++
++/** Readiness row: every catalogue tool has a descriptor and every descriptor meets the contracts. */
++function readyCheck() {
++ return {
++ name: 'tool_registry', required: false,
++ description: 'GET /api/v1/tools: one tools.tool@1 descriptor per catalogue tool, checked with openvibe-contracts',
++ check: () => {
++ const s = snapshot();
++ if (s.problems.length) return `${s.problems.length} problem${s.problems.length === 1 ? '' : 's'}: ${s.problems.slice(0, 5).join(' | ')}`;
++ return { ok: true, detail: { tools: s.tools.length, api: s.tools.filter(d => d.api).length, unavailable: s.tools.filter(d => d.status === 'unavailable').length, satellites_polled: Object.keys(liveAt).length } };
++ },
++ };
++}
++
++/**
++ * Ask each satellite what it says about its own tools (GET /api/v1/tools on loopback) now and every
++ * minute; keep the unavailable marks. A satellite that does not answer keeps its last report.
++ * @param {object} ports { img: 4012, … } (the gateway's SATELLITES)
++ * @param {Function} [fetchImpl]
++ */
++function startSatellitePolling(ports, { fetchImpl = fetch, everyMs = 60_000 } = {}) {
++ const bySatellite = new Map();
++ for (const [id, s] of loadSpecs()) if (s.satellite) (bySatellite.get(s.satellite) || bySatellite.set(s.satellite, []).get(s.satellite)).push(id);
++ async function pollOne(name, port) {
++ try {
++ const r = await fetchImpl(`http://127.0.0.1:${port}/api/v1/tools`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
++ if (!r.ok) return false;
++ const body = await r.json();
++ const ids = new Set(bySatellite.get(name) || []);
++ for (const d of body.tools || []) {
++ if (!ids.has(d.id)) continue;
++ if (d.status === 'unavailable') live.set(d.id, { status: 'unavailable', statusReason: d.statusReason || 'Not available on the server right now.' });
++ else live.delete(d.id);
++ }
++ liveAt[name] = new Date().toISOString();
++ return true;
++ } catch { return false; }
++ }
++ const poll = () => Promise.all([...bySatellite.keys()].filter(n => ports[n]).map(n => pollOne(n, ports[n])));
++ poll();
++ const t = setInterval(poll, everyMs); t.unref();
++ return { poll, stop: () => clearInterval(t) };
++}
++
++/** Tests: forget satellite reports. */
++function resetLive() { live.clear(); liveAt = {}; built = null; }
++
++module.exports = { snapshot, internal, readyCheck, startSatellitePolling, resetLive, SOURCES, live };
ddiff --git a/apps/gateway/server/registry/hosts.js b/apps/gateway/server/registry/hosts.js
nnew file mode 100644
iindex 0000000..18e1181
---- /dev/null
++++ b/apps/gateway/server/registry/hosts.js
@@@ -0,0 +1,48 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Tool hosts as the code defines them (pure data, no requires): the zone, which tools have a
++// descriptive canonical host and a short one, and which builds are folded into another tool.
++// The registry (./index.js) adds the owner's domain overrides on top; the satellites read this file
++// through apps/_shared/tools to name the same default hosts in their own GET /api/v1/tools.
++// ═══════════════════════════════════════════════════════════════
++
++const ZONE = 'openvibe.tools';
++const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
++
++// Human-first defaults: the descriptive host is canonical, the one people already type is short.
++const HOST_DEFAULTS = {
++ jsminify: { canonical: 'js-minifier', short: 'jsminify', aliases: ["minifyjs", "javascript-minifier", "jsmin"] },
++ jsformat: { canonical: 'js-beautifier', short: 'jsformat', aliases: ["jsbeautify", "javascript-beautifier", "unminify"] },
++ cssminify: { canonical: 'css-minifier', short: 'cssminify', aliases: ["minifycss", "cssmin", "css-compressor"] },
++ cssformat: { canonical: 'css-beautifier', short: 'cssformat', aliases: ["cssbeautify", "css-formatter", "unminifycss"] },
++ htmlminify: { canonical: 'html-minifier', short: 'htmlminify', aliases: ["minifyhtml", "htmlmin", "html-compressor"] },
++ xmlminify: { canonical: 'xml-minifier', short: 'xmlminify', aliases: ["minifyxml", "xmlmin"] },
++ jsonminify: { canonical: 'json-minifier', short: 'jsonminify', aliases: ["minifyjson", "jsonmin", "json-compressor"] },
++ jsonparse: { canonical: 'json-parser', short: 'jsonparse', aliases: ["jsonviewer", "json-viewer", "jsontree", "parsejson"] },
++ jsonvalidate: { canonical: 'json-validator', short: 'jsonvalidate', aliases: ["jsonlint", "validatejson", "json-lint", "jsoncheck"] },
++ jsonstringify: { canonical: 'json-stringify', short: 'jsonstringify', aliases: ["jsonescape", "json-escape", "jsonunescape", "stringifyjson"] },
++ html2md: { canonical: 'html-to-markdown', short: 'html2md', aliases: ["htmltomarkdown", "html2markdown", "htmltomd"] },
++ md2html: { canonical: 'markdown-to-html', short: 'md2html', aliases: ["markdowntohtml", "md2htm", "mdtohtml"] },
++ yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },
++};
++
++// The same tool built twice (once in Text, once in Developer Tools). People see ONE entry, under the
++// friendliest address; the other build keeps serving as a **mirror**: 200, rel=canonical → the primary,
++// never listed, never in a sitemap. `alsoIn` lets the primary show up on the second family's page.
++const MERGED = {
++ jsonfmt: { into: 'json', alsoIn: 'dev' }, md: { into: 'markdown', alsoIn: 'dev' }, codediff: { into: 'compare', alsoIn: 'dev' },
++ slugify: { into: 'slug', alsoIn: 'dev' }, entities: { into: 'escape', alsoIn: 'dev' },
++};
++
++/** 'yt' → 'yt.openvibe.tools'; a full hostname is kept as it is. */
++const toHost = (h) => { const s = String(h || '').trim().toLowerCase(); return s.includes('.') && HOST_RE.test(s) ? s : `${s}.${ZONE}`; };
++
++/** A tool's code-default hosts: { canonical, short|null } (no owner overrides). */
++function defaultHosts(id) {
++ const d = HOST_DEFAULTS[id] || {};
++ const canonical = toHost(d.canonical || id);
++ const short = d.short ? toHost(d.short) : null;
++ return { canonical, short: short === canonical ? null : short };
++}
++
++module.exports = { ZONE, HOST_RE, HOST_DEFAULTS, MERGED, toHost, defaultHosts };
ddiff --git a/apps/gateway/server/registry/index.js b/apps/gateway/server/registry/index.js
iindex 8d1e7dd..a18c7f5 100644
---- a/apps/gateway/server/registry/index.js
++++ b/apps/gateway/server/registry/index.js
@@@ -16,41 +16,15 @@ const { CATALOG: SEO } = require('../seo/catalog');
const { NET_TOOL_MAP } = require('../net/config');
const { createServiceDirectory } = require('./services');
--const ZONE = 'openvibe.tools';
++const { ZONE, HOST_RE, HOST_DEFAULTS, MERGED, toHost } = require('./hosts'); // code-default hosts and folded builds (pure data)
const APEX = ZONE;
const DOMAINS_URL = process.env.OV_DOMAINS_URL || 'https://openvibe.network/api/domains';
--const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
--// Human-first defaults: the descriptive host is canonical, the one people already type is short.
--const HOST_DEFAULTS = {
-- jsminify: { canonical: 'js-minifier', short: 'jsminify', aliases: ["minifyjs", "javascript-minifier", "jsmin"] },
-- jsformat: { canonical: 'js-beautifier', short: 'jsformat', aliases: ["jsbeautify", "javascript-beautifier", "unminify"] },
-- cssminify: { canonical: 'css-minifier', short: 'cssminify', aliases: ["minifycss", "cssmin", "css-compressor"] },
-- cssformat: { canonical: 'css-beautifier', short: 'cssformat', aliases: ["cssbeautify", "css-formatter", "unminifycss"] },
-- htmlminify: { canonical: 'html-minifier', short: 'htmlminify', aliases: ["minifyhtml", "htmlmin", "html-compressor"] },
-- xmlminify: { canonical: 'xml-minifier', short: 'xmlminify', aliases: ["minifyxml", "xmlmin"] },
-- jsonminify: { canonical: 'json-minifier', short: 'jsonminify', aliases: ["minifyjson", "jsonmin", "json-compressor"] },
-- jsonparse: { canonical: 'json-parser', short: 'jsonparse', aliases: ["jsonviewer", "json-viewer", "jsontree", "parsejson"] },
-- jsonvalidate: { canonical: 'json-validator', short: 'jsonvalidate', aliases: ["jsonlint", "validatejson", "json-lint", "jsoncheck"] },
-- jsonstringify: { canonical: 'json-stringify', short: 'jsonstringify', aliases: ["jsonescape", "json-escape", "jsonunescape", "stringifyjson"] },
-- html2md: { canonical: 'html-to-markdown', short: 'html2md', aliases: ["htmltomarkdown", "html2markdown", "htmltomd"] },
-- md2html: { canonical: 'markdown-to-html', short: 'md2html', aliases: ["markdowntohtml", "md2htm", "mdtohtml"] },
-- yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },
--};
--// The same tool built twice (once in Text, once in Developer Tools). People see ONE entry, under the
--// friendliest address; the other build keeps serving as a **mirror**: 200, rel=canonical → the primary,
--// never listed, never in a sitemap. `alsoIn` lets the primary show up on the second family's page.
--const MERGED = {
-- jsonfmt: { into: 'json', alsoIn: 'dev' }, md: { into: 'markdown', alsoIn: 'dev' }, codediff: { into: 'compare', alsoIn: 'dev' },
-- slugify: { into: 'slug', alsoIn: 'dev' }, entities: { into: 'escape', alsoIn: 'dev' },
--};
// Two different tools must never share a display name.
const RENAME = { curl: 'HTTP Request Tester', curlconvert: 'cURL Converter' };
const FAMILY_ICON = { net: 'dns', dev: 'code' };
const NET_ICONS = { ip: 'ip', myip: 'ip', dns: 'dns', whois: 'whois', ssl: 'ssl', ping: 'ping', traceroute: 'ping' };
--/** 'yt' → 'yt.openvibe.tools'; a full hostname is kept as it is. */
--const toHost = (h) => { const s = String(h || '').trim().toLowerCase(); return s.includes('.') && HOST_RE.test(s) ? s : `${s}.${ZONE}`; };
const firstSentence = (s, max = 60) => { const t = String(s || '').split(/(?<=[.!?])\s/)[0].replace(/\.$/, ''); return t.length <= max ? t : t.slice(0, max).replace(/\s+\S*$/, ''); };
function baseTools() {
ddiff --git a/apps/gateway/test/descriptors.test.js b/apps/gateway/test/descriptors.test.js
nnew file mode 100644
iindex 0000000..6bc11e4
---- /dev/null
++++ b/apps/gateway/test/descriptors.test.js
@@@ -0,0 +1,185 @@
++'use strict';
++// The tool registry (ADR-027): one tools.tool@1 descriptor per catalogue tool, built from each app's
++// server/descriptors.js and the catalogue, and held to what the code really does.
++// • every catalogue id has exactly one descriptor, every descriptor passes contracts.validate and
++// contracts.tools.checkDescriptor, and the list passes contracts.tools.checkList
++// • every input and output schema compiles (Ajv 2020-12, strict) and every example matches its input
++// • a job tool's operation is one its app defines (getTool), and { ...example, ...preset, tool }
++// passes that app's own job validation; files, sizes and timeouts match the app's upload and job limits
++// • a sync tool with an API runs through a route the gateway serves today (the net router, /api/dev)
++// • hosts come from the catalogue (owner overrides included), status from the unavailable marks and
++// from what a satellite reports; planned placeholders and mirrors are not tools
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-descriptors-'));
++const APPS = path.join(__dirname, '..', '..');
++const contracts = require('openvibe-contracts');
++const Ajv2020 = require('ajv/dist/2020');
++const addFormats = require('ajv-formats');
++const registry = require('../server/registry');
++const reg = require('../server/registry/descriptors');
++const { publicFields } = require('../../_shared/tools/descriptor');
++const createNetRoutes = require('../server/net/routes');
++const createDevRoutes = require('../server/dev/routes');
++
++const ajv = new Ajv2020({ strict: true, strictRequired: false, allowUnionTypes: true, allErrors: true });
++addFormats(ajv);
++
++const snap = reg.snapshot();
++const cat = registry.catalog();
++const byId = new Map(snap.tools.map(d => [d.id, d]));
++
++// ── One descriptor per catalogue tool, all valid ──
++assert.deepStrictEqual(snap.problems, [], `registry problems:\n ${snap.problems.join('\n ')}`);
++assert.strictEqual(snap.tools.length, cat.tools.length, 'as many descriptors as catalogue tools');
++assert.strictEqual(new Set(snap.tools.map(d => d.id)).size, snap.tools.length, 'ids are unique');
++for (const t of cat.tools) assert.ok(byId.has(t.id), `${t.id} has a descriptor`);
++for (const d of snap.tools) {
++ const v = contracts.validate('tools.tool@1', d);
++ assert.ok(v.valid, `${d.id}: ${JSON.stringify(v.errors)}`);
++ const c = contracts.tools.checkDescriptor(d);
++ assert.ok(c.valid, `${d.id}: ${JSON.stringify(c.errors)}`);
++}
++const { refForm } = require('../../_shared/tools/descriptor');
++const list = { tools: snap.tools.map(refForm), count: snap.tools.length, updated_at: snap.updatedAt, families: [...new Set(snap.tools.map(d => d.family))].map(id => ({ id, name: snap.families.get(id), count: snap.tools.filter(d => d.family === id).length })) };
++assert.ok(contracts.tools.checkList(list).valid, JSON.stringify(contracts.tools.checkList(list).errors));
++assert.ok(reg.readyCheck().check().ok, 'the readiness row is ok');
++
++// Every spec was published: nothing a spec says is lost on the way (internal fields aside).
++for (const d of snap.tools) {
++ const spec = publicFields(reg.internal(d.id).spec);
++ for (const k of ['execution', 'files', 'output', 'limits', 'auth', 'quotaClass', 'egress']) if (k in spec) assert.deepStrictEqual(d[k], spec[k] === undefined ? null : spec[k], `${d.id}.${k}`);
++}
++
++// ── Schemas compile; examples match their input ──
++for (const d of snap.tools) {
++ if (d.input) {
++ const validate = ajv.compile(d.input);
++ const ex = reg.internal(d.id).spec.example;
++ assert.ok(ex && ex.input, `${d.id}: an API tool has an example input`);
++ assert.ok(validate(ex.input), `${d.id}: example ${JSON.stringify(ex.input)} → ${JSON.stringify(validate.errors)}`);
++ assert.ok(Buffer.byteLength(JSON.stringify(ex.input)) <= (d.limits.maxInputBytes || Infinity), `${d.id}: example within maxInputBytes`);
++ }
++ if (d.output.schema) ajv.compile(d.output.schema);
++}
++
++// ── Job tools: their app's operation, job type, validation, files and limits ──
++const JOB_APPS = {
++ img: { type: 'img.process', tools: () => require(path.join(APPS, 'img/server/tools')), process: () => require(path.join(APPS, 'img/server/process')), config: () => require(path.join(APPS, 'img/server/config')) },
++ audio: { type: 'audio.process', tools: () => require(path.join(APPS, 'audio/server/tools')), process: () => require(path.join(APPS, 'audio/server/process')), config: () => require(path.join(APPS, 'audio/server/config')) },
++ docs: { type: 'docs.process', tools: () => require(path.join(APPS, 'docs/server/tools')), process: () => require(path.join(APPS, 'docs/server/process')), config: () => require(path.join(APPS, 'docs/server/config')) },
++};
++class JobError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
++const defs = {};
++for (const [app, a] of Object.entries(JOB_APPS)) a.process().defineJobs({ define: (def) => { defs[app] = def; }, JobError });
++
++let jobs = 0;
++for (const d of snap.tools.filter(x => x.run && x.run.job)) {
++ const app = reg.internal(d.id).app;
++ const a = JOB_APPS[app];
++ assert.ok(a, `${d.id}: a job tool on ${app}, which has no jobs`);
++ const def = defs[app];
++ assert.strictEqual(d.run.job.type, def.type, `${d.id}: job type`);
++ assert.ok(a.tools().getTool(d.run.job.operation), `${d.id}: operation ${d.run.job.operation} is defined by apps/${app}/server/tools/index.js`);
++ assert.strictEqual(d.limits.timeoutMs, def.timeoutMs, `${d.id}: timeoutMs is the job's`);
++ assert.ok(d.files.min >= def.minFiles && d.files.max <= def.maxFiles, `${d.id}: file count within the job's`);
++ const cfg = a.config().upload;
++ assert.strictEqual(d.files.maxBytes, cfg.maxFileSize, `${d.id}: maxBytes is the upload limit`);
++ for (const m of d.files.accept) assert.ok(cfg.allowedMimes.includes(m), `${d.id}: ${m} is accepted by the upload`);
++ // The run's job input passes the app's own validation (a tool whose program is missing: 503, not a 400).
++ const input = contracts.tools.jobInput(d, reg.internal(d.id).spec.example.input);
++ const files = Array.from({ length: d.files.min }, (_, i) => ({ name: `f${i}`, mime: d.files.accept[0] }));
++ let res;
++ try { res = def.validate(input, files); } catch (err) { res = err; }
++ if (res instanceof JobError) assert.strictEqual(res.status, 503, `${d.id}: only a missing program may refuse the job (${res.message})`);
++ else assert.strictEqual(res, null, `${d.id}: job validate(${JSON.stringify(input)}) → ${res}`);
++ // Every option the schema offers is one the job takes.
++ const keys = Object.keys((d.input && d.input.properties) || {}).concat(Object.keys(d.run.job.preset || {}));
++ if (app === 'img') for (const k of keys) assert.ok(a.process().OPTION_KEYS.includes(k), `${d.id}: img option ${k}`);
++ if (app === 'docs') for (const k of keys) assert.ok(a.process().SINGLE_KEYS.includes(k), `${d.id}: docs option ${k}`);
++ jobs++;
++}
++// Formats named by the schemas and presets are ones the operation can write.
++const imgFormats = require(path.join(APPS, 'img/server/tools/convert')).formats;
++const audioFormats = require(path.join(APPS, 'audio/server/tools/convert')).formats;
++for (const d of snap.tools.filter(x => x.family === 'img')) {
++ for (const f of (d.input.properties.format || {}).enum || []) assert.ok(imgFormats.includes(f), `${d.id}: format ${f}`);
++ const pre = d.run.job.preset && d.run.job.preset.defaultFormat;
++ if (pre) assert.ok(imgFormats.includes(pre), `${d.id}: default format ${pre}`);
++}
++for (const d of snap.tools.filter(x => x.family === 'audio' && x.run.job.preset)) assert.ok(audioFormats.includes(d.run.job.preset.format), `${d.id}: preset format`);
++assert.deepStrictEqual(require(path.join(APPS, 'img/server/descriptors')).ACCEPT, require(path.join(APPS, 'img/server/config')).upload.allowedMimes, 'img takes every upload type');
++assert.strictEqual(require(path.join(APPS, 'docs/server/descriptors')).MAX_PAGES, require(path.join(APPS, 'docs/server/tools/pdf')).MAX_PAGES, 'docs page limit');
++
++// The img job takes the preset's defaultFormat, and an explicit format still wins (as on the page).
++{
++ const { buildOptions } = JOB_APPS.img.process();
++ assert.strictEqual(buildOptions({ defaultFormat: 'gif' }, null, 'image/png').format, 'gif');
++ assert.strictEqual(buildOptions({ format: 'webp', defaultFormat: 'gif' }, null, 'image/png').format, 'webp');
++ assert.strictEqual(buildOptions({}, { defaultFormat: 'bmp' }, 'image/png').format, 'bmp', 'the host default still applies to the page\'s own requests');
++ assert.ok(!('defaultFormat' in buildOptions({ defaultFormat: 'gif' }, null, 'image/png')), 'the handlers never see defaultFormat');
++}
++
++// ── Sync tools with an API run through a route the gateway serves ──
++const served = new Set();
++for (const [base, router] of [['/api/net', createNetRoutes(null, null, { egress: { parseUrl: () => null } })], ['/api/dev', createDevRoutes(null, (q, s, n) => n())]]) {
++ for (const l of router.stack.filter(x => x.route)) for (const m of Object.keys(l.route.methods)) served.add(`${m.toUpperCase()} ${base}${l.route.path}`);
++}
++const serves = (method, p) => served.has(`${method} ${p}`) || served.has(`${method} ${p}/:target?`);
++let syncApi = 0;
++for (const d of snap.tools.filter(x => x.execution === 'sync' && x.api)) {
++ const { route } = reg.internal(d.id).spec;
++ assert.ok(route && serves(route.method, route.path), `${d.id}: ${route && route.method} ${route && route.path} is a route of the gateway`);
++ for (const l of d.run.legacy || []) { const [m, p] = l.split(' '); assert.ok(serves(m, p.replace(/\/:target$/, '')), `${d.id}: legacy ${l} is served`); }
++ syncApi++;
++}
++// Egress and throttles: probes need tools.net.probe; anonymous egress tools have a per-target throttle.
++for (const d of snap.tools) {
++ if (d.auth.capability === 'tools.net.probe') assert.ok(d.egress && !d.auth.anonymous && d.quotaClass === 'tools-probe', `${d.id}: a probe`);
++ if (d.egress && d.auth.anonymous) assert.ok(d.limits.perTargetPerMinute > 0, `${d.id}: per-target throttle`);
++}
++assert.deepStrictEqual(snap.tools.filter(d => d.auth.capability === 'tools.net.probe').map(d => d.id).sort(), ['latency', 'mtr', 'ping', 'port', 'traceroute']);
++assert.strictEqual(byId.get('yt').api, false, 'yt is page-only');
++assert.strictEqual(byId.get('yt').run, null);
++
++// ── Status: the catalogue's marks, a satellite's report ──
++for (const t of cat.tools.filter(x => x.status === 'unavailable')) {
++ const d = byId.get(t.id);
++ assert.strictEqual(d.status, 'unavailable', `${t.id} is unavailable`);
++ assert.strictEqual(d.statusReason, t.unavailable);
++}
++reg.live.set('protectpdf', { status: 'unavailable', statusReason: 'This tool is being set up on the server (it needs qpdf) and is not available yet.' });
++{
++ const d = reg.snapshot().tools.find(x => x.id === 'protectpdf');
++ assert.deepStrictEqual([d.status, d.api, !!d.run], ['unavailable', true, true], 'unavailable, still described with its run (runs answer 503)');
++ assert.match(d.statusReason, /qpdf/);
++}
++reg.resetLive();
++assert.strictEqual(reg.snapshot().tools.find(x => x.id === 'protectpdf').status, 'stable');
++
++// ── Hosts: canonical first, the short host, the owner's overrides ──
++for (const t of cat.tools) {
++ const d = byId.get(t.id);
++ if (t.id === 'pastes') { assert.deepStrictEqual(d.hosts, [], 'pastes live on another service'); continue; }
++ assert.deepStrictEqual(d.hosts, [t.hosts.canonical, t.hosts.short].filter(Boolean), `${t.id} hosts`);
++ assert.strictEqual(d.docs, `https://openvibe.tools/tool/${t.id}`);
++}
++registry.setOverrides([{ tool_id: 'png', host: 'png-converter.example.com', role: 'canonical' }]);
++assert.deepStrictEqual(reg.snapshot().tools.find(d => d.id === 'png').hosts, ['png-converter.example.com', 'png.openvibe.tools'], 'an owner\'s canonical domain comes first');
++registry.setOverrides([]);
++assert.deepStrictEqual(reg.snapshot().tools.find(d => d.id === 'png').hosts, ['png.openvibe.tools']);
++
++// ── Not tools: planned placeholders and mirrors ──
++for (const p of cat.planned) { assert.ok(!byId.has(p.id), `${p.id} is planned`); assert.match(snap.gone.get(p.id), /planned/); }
++for (const m of ['jsonfmt', 'md', 'codediff', 'slugify', 'entities']) { assert.ok(!byId.has(m)); assert.match(snap.gone.get(m), /mirror/); }
++
++// ── Counts ──
++const tally = (f) => snap.tools.reduce((o, d) => { const k = f(d); o[k] = (o[k] || 0) + 1; return o; }, {});
++const fam = tally(d => d.family);
++assert.deepStrictEqual(fam, { dev: 33, net: 38, img: 15, audio: 36, docs: 10, text: 33, media: 1, places: 2, pastes: 1 });
++
++fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });
++console.log(`descriptors: ${snap.tools.length} tools valid (${Object.entries(tally(d => `${d.execution}${d.api ? '+api' : ''}`)).map(([k, n]) => `${k} ${n}`).join(', ')}); ${jobs} job tools pass their app's validation; ${syncApi} sync tools run through served routes`);
ddiff --git a/apps/gateway/test/engines.test.js b/apps/gateway/test/engines.test.js
nnew file mode 100644
iindex 0000000..261bd18
---- /dev/null
++++ b/apps/gateway/test/engines.test.js
@@@ -0,0 +1,121 @@
++'use strict';
++// Server engines for browser tools (ADR-027): every descriptor with api true that is answered inline
++// without a server route (execution client) names an engine that plain Node can require and run.
++// • a fresh `node` process, with no DOM and no polyfills, requires each engines module and runs each
++// tool's example input; its answer has the descriptor's output kind and matches its output schema
++// • the engines are the pages' own code: a few spot checks that the API answers what the page shows
++// • the engines refuse what they must (a diff too long, JSON that does not parse)
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync } = require('child_process');
++
++process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-engines-'));
++const Ajv2020 = require('ajv/dist/2020');
++const addFormats = require('ajv-formats');
++const reg = require('../server/registry/descriptors');
++
++const ajv = new Ajv2020({ strict: true, strictRequired: false, allowUnionTypes: true, allErrors: true });
++addFormats(ajv);
++
++const snap = reg.snapshot();
++const engineTools = snap.tools.filter(d => d.api && d.execution === 'client');
++assert.ok(engineTools.length >= 40, `browser tools with a server engine: ${engineTools.length}`);
++
++// Which module and which export: every such tool names one, and the module is one of the known engine files.
++const jobsFor = engineTools.map(d => {
++ const s = reg.internal(d.id);
++ assert.ok(s.engines, `${d.id}: its app has an engines module`);
++ assert.ok(s.spec.engine, `${d.id}: names its engine`);
++ return { id: d.id, module: s.engines, name: s.spec.engine, input: s.spec.example.input };
++});
++// Client tools without an API have no engine; nothing else names one.
++for (const d of snap.tools.filter(x => !(x.api && x.execution === 'client'))) assert.ok(!reg.internal(d.id).spec.engine, `${d.id}: only API browser tools name an engine`);
++
++// ── Plain Node: require every engines module and run every example in a fresh process ──
++const child = `
++const jobs = JSON.parse(process.argv[1]);
++(async () => {
++ const out = {};
++ for (const j of jobs) {
++ try {
++ const mod = require(j.module);
++ const fn = (mod.ENGINES || {})[j.name];
++ if (typeof fn !== 'function') { out[j.id] = { missing: true }; continue; }
++ out[j.id] = { result: await fn(JSON.parse(JSON.stringify(j.input))) };
++ } catch (err) { out[j.id] = { error: err.message }; }
++ }
++ process.stdout.write(JSON.stringify(out));
++})();`;
++const r = spawnSync(process.execPath, ['-e', child, JSON.stringify(jobsFor)], { encoding: 'utf8', env: { PATH: process.env.PATH }, timeout: 30_000 });
++assert.strictEqual(r.status, 0, r.stderr);
++const results = JSON.parse(r.stdout);
++for (const d of engineTools) {
++ const got = results[d.id];
++ assert.ok(got && !got.missing, `${d.id}: the engine exists`);
++ assert.ok(!got.error, `${d.id}: the example runs (${got.error})`);
++ if (d.output.kind === 'text') {
++ assert.ok(typeof got.result.text === 'string' && !('data' in got.result), `${d.id}: answers { text }`);
++ } else {
++ assert.strictEqual(d.output.kind, 'json');
++ assert.ok(got.result.data && typeof got.result.data === 'object' && !('text' in got.result), `${d.id}: answers { data }`);
++ const validate = ajv.compile(d.output.schema);
++ assert.ok(validate(got.result.data), `${d.id}: output matches its schema: ${JSON.stringify(validate.errors)}`);
++ }
++}
++
++// ── The pages run the same engines (so the page and the API cannot drift apart) ──
++{
++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'dev.html'), 'utf8');
++ const engineTag = html.indexOf('<script src="/js/dev-engine.js"></script>');
++ assert.ok(engineTag > 0 && engineTag < html.indexOf("const API='https://openvibe.tools/api/dev'"), 'dev.html loads the engine before its own script');
++ for (const d of engineTools.filter(x => x.family === 'dev')) {
++ const at = html.search(new RegExp(`^P\\.${d.id}=`, 'm'));
++ const block = at < 0 ? '' : html.slice(at, html.indexOf('\nP.', at + 1));
++ assert.match(block, /DevEngine\./, `dev.html's P.${d.id} runs DevEngine`);
++ }
++ const { HOSTNAME_MAP } = require('../../text/server/hosts');
++ for (const d of engineTools.filter(x => x.family === 'text')) {
++ const page = fs.readFileSync(path.join(__dirname, '..', '..', 'text', 'public', HOSTNAME_MAP[`${d.id}.openvibe.tools`]), 'utf8');
++ assert.ok(/<script src="\/js\/(text|format)-engine\.js"><\/script>/.test(page), `${d.id}: its page loads the engine the API runs`);
++ }
++}
++
++// ── The pages' own transforms: spot checks ──
++const dev = require('../server/dev/engines').ENGINES;
++const text = require('../../text/server/engines').ENGINES;
++(async () => {
++ assert.strictEqual(dev.jsonminify({ text: '{\n "a": [1, 2]\n}' }).text, '{"a":[1,2]}');
++ assert.strictEqual(dev.base64({ text: 'hé ✓', mode: 'encode' }).text, 'aMOpIOKckw==');
++ assert.strictEqual(dev.base64({ text: 'aMOpIOKckw==', mode: 'decode' }).text, 'hé ✓');
++ assert.strictEqual(dev.cssminify({ text: '.a { color: #aabbcc; margin: 0px; }' }).text, '.a{color:#abc;margin:0}');
++ assert.strictEqual((await dev.hash({ text: 'abc', algorithm: 'SHA-256' })).data.hex, 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
++ assert.deepStrictEqual(dev.csv({ text: 'a,b\n"1,2",3' }).data.rows, [{ a: '1,2', b: '3' }]);
++ assert.deepStrictEqual(dev.jsonvalidate({ text: '{"a":1,}' }).data.valid, false, 'invalid JSON is an answer, not a failure');
++ assert.match(dev.jsonvalidate({ text: '{"a":1,}' }).data.error, /trailing comma/);
++ assert.throws(() => dev.jsonminify({ text: '{' }), /Line 1/, 'a transform that cannot read its input throws (the run fails with the message)');
++ assert.strictEqual(dev.jwt({ token: 'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjF9.x' }).data.expired, true);
++ assert.strictEqual(dev.uuid({ mode: 'validate', text: '123e4567-e89b-42d3-a456-426614174000' }).data.valid, true);
++ assert.strictEqual(dev.uuid({ count: 3 }).data.uuids.length, 3);
++ assert.strictEqual(dev.yaml({ text: 'a: 1\nb: true' }).text, '{\n "a": 1,\n "b": true\n}');
++ assert.strictEqual(dev.minify({ text: '<a> <b>x</b> </a>', language: 'html' }).text, '<a><b>x</b></a>');
++
++ assert.strictEqual(text.case({ text: 'hello big world', to: 'camel' }).text, 'helloBigWorld');
++ assert.strictEqual(text.slug({ text: 'Crème Brûlée, 2 ways!' }).text, 'creme-brulee-2-ways');
++ assert.strictEqual(text.braille({ text: text.braille({ text: 'hi 42!' }).text, mode: 'decode' }).text, 'hi 42!', 'braille round trip');
++ assert.strictEqual(text.binary({ text: 'Az', to: 'hex' }).text, '41 7A');
++ assert.strictEqual(text.escape({ text: '<b>&</b>', transform: 'htmlEscape' }).text, '&lt;b&gt;&amp;&lt;/b&gt;');
++ assert.strictEqual(text.clean({ text: 'a\u200b “b”' }).text, 'a "b"', 'the cleaner\'s default steps');
++ assert.strictEqual(text.clean({ text: 'a\u200b', invisible: false }).text, 'a\u200b');
++ assert.deepStrictEqual(text.fancy({ text: 'ab', styles: ['bold'] }).data.styles, [{ id: 'bold', name: 'Bold', text: '𝐚𝐛' }]);
++ assert.strictEqual(text.bubble({ text: 'x' }).data.styles.length, 5);
++ assert.strictEqual(text.json({ text: '{"b":1,"a":{"d":2,"c":3}}', mode: 'sort-keys', indent: 0 }).text, '{"a":{"c":3,"d":2},"b":1}');
++ assert.deepStrictEqual(text.compare({ a: 'x\ny', b: 'x\nz' }).data, { lines: [{ type: 'same', text: 'x' }, { type: 'del', text: 'y' }, { type: 'add', text: 'z' }], added: 1, removed: 1, unchanged: 1 });
++ assert.throws(() => text.compare({ a: 'x\n'.repeat(2500), b: 'y' }), (e) => e.status === 422 && /at most 2000 lines/.test(e.message), 'a diff too big for the server is refused');
++ assert.deepStrictEqual(text.unicode({ text: '\u200b' }).data.characters[0], { char: '\u200b', codePoint: 'U+200B', hex: '0x200B', utf8Bytes: 3, category: 'Invisible', invisible: true });
++ assert.deepStrictEqual(Object.keys(text.kaomoji({ mood: 'shrug' }).data.moods), ['shrug']);
++
++ fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });
++ console.log(`engines: ${engineTools.length} browser tools run in plain Node (${engineTools.filter(d => d.family === 'dev').length} dev, ${engineTools.filter(d => d.family === 'text').length} text), outputs match their schemas`);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/img/server/descriptors.js b/apps/img/server/descriptors.js
nnew file mode 100644
iindex 0000000..4a775f8
---- /dev/null
++++ b/apps/img/server/descriptors.js
@@@ -0,0 +1,93 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Img.OpenVibe — what each image tool is, for the tool registry (tools.tool@1 specs, ADR-027;
++// apps/_shared/tools/descriptor.js joins them with the catalogue's name, summary and hosts).
++//
++// Every image tool is its host's operation (domain-map.js: convert, compress, resize or crop) run as an
++// img.process job, with the host's default format as the job's preset (a caller's `format` still wins,
++// as it does on the page). Pure data: nothing here loads sharp. apps/gateway/test/descriptors.test.js
++// holds it to tools/index.js (the operations exist), tools/convert.js (the formats), process.js (the
++// option names, the job timeout) and config.js (the accepted uploads and their size).
++// ═══════════════════════════════════════════════════════════════
++
++const { DOMAIN_MAP } = require('./domain-map');
++
++// tools/convert.js FORMAT_CONFIG: what convert can write, and the media type each one is.
++const FORMAT_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', avif: 'image/avif', tiff: 'image/tiff', bmp: 'image/bmp', gif: 'image/gif', ico: 'image/x-icon' };
++// config.js upload.allowedMimes: every image tool takes any of these (HEIC needs libheif's decoder).
++const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/tiff', 'image/bmp', 'image/gif', 'image/heic', 'image/heif', 'image/svg+xml', 'image/x-icon', 'image/vnd.microsoft.icon'];
++const MAX_BYTES = 50 * 1024 * 1024; // config.js upload.maxFileSize
++const TIMEOUT_MS = 5 * 60 * 1000; // process.js img.process timeoutMs
++const SHARP_PIXELS = 268402689; // sharp's default limitInputPixels (0x3FFF²), not raised here
++const CODEC_PIXELS = 100 * 1024 * 1024; // tools/codec.js MAX_PIXELS: BMP written or read by our codec
++const SAME_AS_INPUT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/tiff', 'image/gif', 'image/bmp'];
++
++const RESULT = {
++ type: 'object',
++ description: 'result.data: what the operation made (the file itself is result.files[0])',
++ required: ['tool', 'output'],
++ properties: {
++ tool: { type: 'string' },
++ output: { type: 'object', properties: { mime: { type: 'string' }, ext: { type: 'string' }, size: { type: 'integer' }, sizeKB: { type: 'number' } } },
++ savings: { type: 'object', description: 'compress: before and after sizes' },
++ dimensions: { type: 'object', description: 'resize: { original, output } width and height' },
++ crop: { type: 'object', description: 'crop: the rectangle that was kept' },
++ },
++};
++
++const quality = (dflt) => ({ type: 'integer', minimum: 1, maximum: 100, default: dflt, description: 'Quality for lossy formats (JPG, WebP, AVIF); PNG maps it to its compression level' });
++const format = (dflt) => ({ enum: Object.keys(FORMAT_MIME), ...(dflt && { default: dflt }), description: 'Output format' });
++
++const INPUT = {
++ convert: (dflt) => ({ type: 'object', additionalProperties: false, properties: { format: format(dflt), quality: quality(80) } }),
++ compress: () => ({ type: 'object', additionalProperties: false, properties: { quality: quality(75) } }),
++ resize: () => ({
++ type: 'object', additionalProperties: false,
++ properties: {
++ width: { type: 'integer', minimum: 1, maximum: 16384, description: 'Target width in pixels' },
++ height: { type: 'integer', minimum: 1, maximum: 16384, description: 'Target height in pixels' },
++ percentage: { type: 'number', minimum: 1, maximum: 1000, description: 'Scale by a percentage instead (wins over width and height)' },
++ fit: { enum: ['cover', 'contain', 'fill', 'inside', 'outside'], default: 'inside', description: 'How the picture fits the box; never enlarged past its own size' },
++ background: { type: 'string', pattern: '^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$', description: 'Fill for contain and fill (hex, alpha allowed)' },
++ },
++ }),
++ crop: () => ({
++ type: 'object', additionalProperties: false,
++ properties: {
++ aspect: { enum: ['16:9', '4:3', '1:1', '3:2', '21:9', '9:16', '3:4', '2:3'], description: 'Crop the largest centred area of this ratio (wins over the rectangle)' },
++ left: { type: 'integer', minimum: 0 }, top: { type: 'integer', minimum: 0 },
++ width: { type: 'integer', minimum: 1 }, height: { type: 'integer', minimum: 1 },
++ },
++ }),
++};
++
++/** One image tool: its host's operation and default format, as an img.process job. */
++function tool(id, extra = {}) {
++ const ctx = DOMAIN_MAP[`${id}.openvibe.tools`];
++ if (!ctx || !ctx.defaultOp) throw new Error(`img: ${id}.openvibe.tools has no operation in domain-map.js`);
++ const op = ctx.defaultOp;
++ const out = op === 'convert' ? Object.values(FORMAT_MIME).filter((m, i, a) => a.indexOf(m) === i) : SAME_AS_INPUT;
++ 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 },
++ limits: { timeoutMs: TIMEOUT_MS, maxPixels: ctx.defaultFormat === 'bmp' ? CODEC_PIXELS : SHARP_PIXELS },
++ auth: { anonymous: true, capability: 'tools.tool.run' },
++ quotaClass: 'tools-job', cost: op === 'convert' && ['avif', 'ico'].includes(ctx.defaultFormat) ? 8 : 5, egress: false,
++ example: { input: {} },
++ ...extra,
++ };
++}
++
++const SPECS = [
++ tool('convert'), tool('compress'), tool('resize', { example: { input: { width: 8 } } }), tool('crop', { example: { input: { aspect: '1:1' } } }),
++ tool('png'), tool('jpg'), tool('webp'), tool('avif'),
++ // heic.openvibe.tools exists to read iPhone photos: without libheif's decoder it cannot do its job.
++ tool('heic', { requires: ['heif-dec'] }),
++ tool('svg'), tool('gif'), tool('ico'), tool('favicon'), tool('tiff'), tool('bmp'),
++];
++
++module.exports = { SPECS, FORMAT_MIME, ACCEPT, MAX_BYTES, TIMEOUT_MS };
ddiff --git a/apps/img/server/process.js b/apps/img/server/process.js
iindex 1523cd2..0819d96 100644
---- a/apps/img/server/process.js
++++ b/apps/img/server/process.js
@@@ -10,13 +10,16 @@ const fsp = require('fs/promises');
const path = require('path');
const { getTool, listTools } = require('./tools');
--const OPTION_KEYS = ['format', 'quality', 'width', 'height', 'percentage', 'fit', 'background', 'left', 'top', 'aspect'];
++// defaultFormat: the output format when the caller names none (a format tool's own, e.g. png for
++// png.openvibe.tools; its descriptor's run.job.preset). An explicit format always wins, as on the page.
++const OPTION_KEYS = ['format', 'defaultFormat', 'quality', 'width', 'height', 'percentage', 'fit', 'background', 'left', 'top', 'aspect'];
/** Tool options from a request body or a job input, with the host's defaults. */
function buildOptions(src, ctx, inputMime) {
const options = {};
for (const k of OPTION_KEYS) options[k] = src[k] != null && src[k] !== '' ? src[k] : undefined;
-- if (!options.format && ctx && ctx.defaultFormat) options.format = ctx.defaultFormat;
++ if (!options.format) options.format = options.defaultFormat || (ctx && ctx.defaultFormat) || undefined;
++ delete options.defaultFormat;
options.inputFormat = String(inputMime || '').split('/')[1];
return options;
}
ddiff --git a/apps/maps/server/descriptors.js b/apps/maps/server/descriptors.js
nnew file mode 100644
iindex 0000000..7658cc8
---- /dev/null
++++ b/apps/maps/server/descriptors.js
@@@ -0,0 +1,33 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Maps & Food — the descriptors of the two places tools (tools.tool@1 specs, ADR-027): the survival
++// map (this app, maps.openvibe.tools) and the budget food finder (apps/food, which serves its page and
++// proxies every data call to this app's /api routes).
++//
++// Both are map applications rather than single transforms: their pages call several server routes
++// (geocoding, place search, weather, food banks, meal plans) answered inline, so execution is sync, and
++// there is no run API for them (api false). Their lookups go to OpenStreetMap and weather services,
++// never to a host the caller chose (egress false).
++// ═══════════════════════════════════════════════════════════════
++
++const PLACES = {
++ type: 'object',
++ description: 'The pages\' own routes answer JSON: /api/geocode, /api/search, /api/weather, /api/terrain, /api/food-banks, /api/stores, /api/foods, /api/meal-plan',
++};
++
++const place = (id, legacy) => ({
++ id, execution: 'sync', api: false,
++ input: null, files: null,
++ output: { kind: 'json', schema: PLACES },
++ limits: { timeoutMs: 30000 },
++ auth: { anonymous: true, capability: 'tools.tool.run' },
++ quotaClass: 'tools-map', cost: 2, egress: false,
++ route: { method: 'GET', paths: legacy },
++});
++
++const SPECS = [
++ place('maps', ['/api/geocode', '/api/search', '/api/search/stream', '/api/weather', '/api/terrain', '/api/food-banks', '/api/stores']),
++ place('food', ['/api/food-banks', '/api/stores', '/api/foods', '/api/meal-plan', '/api/geocode']),
++];
++
++module.exports = { SPECS };
ddiff --git a/apps/text/server/descriptors.js b/apps/text/server/descriptors.js
nnew file mode 100644
iindex 0000000..6553be8
---- /dev/null
++++ b/apps/text/server/descriptors.js
@@@ -0,0 +1,131 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Text.OpenVibe + Logo.OpenVibe — what each text tool is, for the tool registry (tools.tool@1 specs,
++// ADR-027; apps/_shared/tools/descriptor.js joins them with the catalogue's name, summary and hosts).
++//
++// Every page here works in the browser (execution client, nothing leaves it). A tool whose engine is
++// a pure transform (public/js/text-engine.js, format-engine.js) also has a server engine for the API
++// (server/engines.js, named by `engine`): api true, answered inline. The graphics makers draw on a
++// canvas, and the phrasebooks, the slang dictionary, the ICE card and the peso converter (which asks
++// an exchange-rate service from the browser) have no engine to run elsewhere: api false.
++// The enums below come from the engines themselves, so a new style or case shows up here too.
++// ═══════════════════════════════════════════════════════════════
++
++const { STYLE_SETS, MAX_DIFF_LINES, CASES, ESCAPES, SYMBOL_CATEGORIES, MOODS } = require('./engines');
++
++const KiB = 1024;
++const obj = (properties, required = ['text']) => ({ type: 'object', additionalProperties: false, required, properties });
++const text = (maxLength, description) => ({ type: 'string', maxLength, ...(description && { description }) });
++const STYLES_OUT = {
++ type: 'object', required: ['styles'],
++ properties: { styles: { type: 'array', items: { type: 'object', required: ['id', 'name', 'text'], properties: { id: { type: 'string' }, name: { type: 'string' }, text: { type: 'string' } } } } },
++};
++
++/** A browser tool with a server engine: api true, answered inline. */
++function engine(id, { input, output, maxChars = 100000, example, cost = 1 }) {
++ return {
++ id, execution: 'client', api: true,
++ input, files: null, output,
++ limits: { timeoutMs: 5000, maxInputBytes: Math.min(1024 * KiB, Math.ceil(maxChars * 4 + KiB)) }, // the gateway takes JSON bodies up to 1 MiB
++ auth: { anonymous: true, capability: 'tools.tool.run' },
++ quotaClass: 'tools-run', cost, egress: false,
++ engine: id, example: { input: example },
++ };
++}
++
++/** A browser-only tool: api false. */
++function page(id, output) {
++ return {
++ id, execution: 'client', api: false,
++ input: null, files: null, output,
++ limits: { timeoutMs: 5000 },
++ auth: { anonymous: true, capability: 'tools.tool.run' },
++ quotaClass: 'tools-run', cost: 1, egress: false,
++ };
++}
++
++const styled = (id, maxChars, example) => engine(id, {
++ input: obj({ text: text(maxChars), styles: { type: 'array', uniqueItems: true, items: { enum: STYLE_SETS[id] }, description: 'Only these styles (default: all of them)' } }),
++ output: { kind: 'json', schema: STYLES_OUT }, maxChars, example: { text: example },
++});
++const IMAGE = { kind: 'file', mime: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'] };
++const HTML_TEXT = { kind: 'text' };
++
++const SPECS = [
++ styled('fancy', 2000, 'OpenVibe'),
++ engine('zalgo', {
++ input: obj({
++ text: text(2000), mode: { enum: ['add', 'remove'], default: 'add', description: 'remove strips the marks again' },
++ intensity: { enum: ['low', 'medium', 'high', 'insane'], default: 'medium' },
++ up: { type: 'boolean', default: true }, mid: { type: 'boolean', default: true }, down: { type: 'boolean', default: true },
++ }),
++ output: { kind: 'text' }, maxChars: 2000, example: { text: 'hello' },
++ }),
++ styled('bubble', 5000, 'bubble'),
++ engine('ascii', { input: obj({ text: text(40), blockChar: { type: 'string', minLength: 1, maxLength: 2, default: '█' }, spaceChar: { type: 'string', minLength: 1, maxLength: 2, default: ' ' } }), output: { kind: 'text' }, maxChars: 40, example: { text: 'HI' } }),
++ engine('symbols', {
++ input: obj({ category: { enum: SYMBOL_CATEGORIES, description: 'One category (default: every one)' } }, []),
++ output: { kind: 'json', schema: { type: 'object', required: ['categories'], properties: { categories: { type: 'object', additionalProperties: { type: 'array', items: { type: 'string' } } } } } },
++ maxChars: 0, example: { category: 'arrows' },
++ }),
++ engine('kaomoji', {
++ input: obj({ mood: { enum: MOODS, description: 'One mood (default: every one)' } }, []),
++ output: { kind: 'json', schema: { type: 'object', required: ['moods'], properties: { moods: { type: 'object', additionalProperties: { type: 'array', items: { type: 'string' } } } } } },
++ maxChars: 0, example: { mood: 'shrug' },
++ }),
++ engine('unicode', {
++ input: obj({ text: text(10000) }),
++ output: {
++ kind: 'json', schema: {
++ type: 'object', required: ['characters'],
++ properties: { characters: { type: 'array', items: { type: 'object', properties: { char: { type: 'string' }, codePoint: { type: 'string' }, hex: { type: 'string' }, utf8Bytes: { type: 'integer' }, category: { type: 'string' }, invisible: { type: 'boolean' } } } } },
++ },
++ },
++ maxChars: 10000, example: { text: 'A\u200b' },
++ }),
++ engine('braille', { input: obj({ text: text(100000), mode: { enum: ['encode', 'decode'], default: 'encode' } }), output: { kind: 'text' }, example: { text: 'hello 42' } }),
++ engine('morse', { input: obj({ text: text(100000), mode: { enum: ['encode', 'decode'], default: 'encode' } }), output: { kind: 'text' }, example: { text: 'SOS' } }),
++ engine('binary', { input: obj({ text: text(100000), to: { enum: ['binary', 'hex', 'decimal', 'octal', 'text'], default: 'binary', description: 'text: read binary back into text' } }), output: { kind: 'text' }, example: { text: 'Hi' } }),
++ engine('case', { input: obj({ text: text(100000), to: { enum: CASES } }, ['text', 'to']), output: { kind: 'text' }, example: { text: 'hello world', to: 'title' } }),
++ engine('count', {
++ input: obj({ text: text(1000000) }), maxChars: 1000000,
++ output: { kind: 'json', schema: { type: 'object', properties: Object.fromEntries(['characters', 'charactersNoSpaces', 'words', 'lines', 'sentences', 'paragraphs', 'readingTimeMinutes', 'speakingTimeMinutes', 'bytes'].map(k => [k, { type: 'integer' }]).concat([['avgWordLength', { type: 'number' }]])) } },
++ example: { text: 'One two. Three.' },
++ }),
++ styled('reversetext', 5000, 'hello'),
++ engine('clean', {
++ input: obj({
++ text: text(1000000),
++ invisible: { type: 'boolean', default: true, description: 'Remove zero-width and invisible characters' }, zalgo: { type: 'boolean', default: false, description: 'Remove stacked combining marks' },
++ quotes: { type: 'boolean', default: true, description: 'Straighten curly quotes' }, hyphens: { type: 'boolean', default: true, description: 'Dashes to plain hyphens' },
++ html: { type: 'boolean', default: false, description: 'Strip HTML tags' }, spaces: { type: 'boolean', default: true, description: 'Collapse runs of spaces' },
++ trim: { type: 'boolean', default: true, description: 'Trim every line' }, blank: { type: 'boolean', default: false, description: 'Remove blank lines' },
++ lines: { type: 'boolean', default: true, description: 'Collapse three or more line breaks to two' },
++ }),
++ output: { kind: 'text' }, maxChars: 1000000, example: { text: 'a\u200b “b”' },
++ }),
++ engine('sort', { input: obj({ text: text(1000000), mode: { enum: ['asc', 'desc', 'shuffle', 'dedupe', 'reverse'], default: 'asc' } }), output: { kind: 'text' }, maxChars: 1000000, example: { text: 'b\na\nb', mode: 'dedupe' } }),
++ engine('compare', {
++ input: obj({ a: text(262144, `First text (at most ${MAX_DIFF_LINES} lines)`), b: text(262144, `Second text (at most ${MAX_DIFF_LINES} lines)`) }, ['a', 'b']),
++ output: {
++ kind: 'json', schema: {
++ type: 'object', required: ['lines', 'added', 'removed', 'unchanged'],
++ properties: { lines: { type: 'array', items: { type: 'object', properties: { type: { enum: ['same', 'add', 'del'] }, text: { type: 'string' } } } }, added: { type: 'integer' }, removed: { type: 'integer' }, unchanged: { type: 'integer' } },
++ },
++ },
++ maxChars: 524288, cost: 2, example: { a: 'one\ntwo', b: 'one\n2' },
++ }),
++ engine('json', { input: obj({ text: text(1000000), mode: { enum: ['format', 'minify', 'sort-keys'], default: 'format' }, indent: { type: 'integer', minimum: 0, maximum: 8, default: 2 } }), output: { kind: 'text' }, maxChars: 1000000, example: { text: '{"b":1,"a":2}', mode: 'sort-keys' } }),
++ engine('markdown', { input: obj({ text: text(262144) }), output: HTML_TEXT, maxChars: 262144, example: { text: '# Hi\n\n**bold**' } }),
++ engine('escape', { input: obj({ text: text(262144), transform: { enum: ESCAPES } }, ['text', 'transform']), output: { kind: 'text' }, maxChars: 262144, example: { text: '<b>', transform: 'htmlEscape' } }),
++ engine('slug', { input: obj({ text: text(2000) }), output: { kind: 'text' }, maxChars: 2000, example: { text: 'Hello, World!' } }),
++ engine('bio', { input: obj({}, []), output: { kind: 'text' }, maxChars: 0, example: {} }),
++ engine('nickname', { input: obj({}, []), output: { kind: 'text' }, maxChars: 0, example: {} }),
++ // Canvas graphics: made and exported in the browser.
++ page('logo', IMAGE), page('wordmark', IMAGE), page('title', IMAGE), page('thumbnail', IMAGE),
++ page('badge', IMAGE), page('watermark', IMAGE), page('transparent', IMAGE),
++ // Reference pages and a converter that asks an exchange-rate service from the browser.
++ page('peso', { kind: 'text' }), page('spanish', { kind: 'text' }), page('slang', { kind: 'text' }), page('ice', { kind: 'text' }),
++];
++
++module.exports = { SPECS };
ddiff --git a/apps/yt/server/descriptors.js b/apps/yt/server/descriptors.js
nnew file mode 100644
iindex 0000000..e7a18af
---- /dev/null
++++ b/apps/yt/server/descriptors.js
@@@ -0,0 +1,26 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// YT.OpenVibe — the YouTube downloader's descriptor, for the tool registry (tools.tool@1 spec, ADR-027).
++//
++// Page-only by decision (YouTube's terms and the legal exposure): api false and no run. It is listed so
++// the registry is complete and says truthfully what the page does: a server download (egress to
++// YouTube) of one video, as MP4/WebM video or MP3/M4A audio, within the length and size limits
++// config.js enforces. While yt-dlp is missing on the host the satellite lists it as unavailable.
++// ═══════════════════════════════════════════════════════════════
++
++// config.js download.maxDuration (YT_MAX_DURATION) and download.timeout, read the same way.
++const MAX_DURATION = parseInt(process.env.YT_MAX_DURATION, 10) || 3 * 60 * 60;
++const TIMEOUT_MS = 10 * 60 * 1000;
++
++const SPECS = [{
++ id: 'yt', execution: 'job', api: false,
++ input: null, files: null,
++ // downloader.js mimeMap for the formats the page offers (mp4, webm, mp3, m4a).
++ output: { kind: 'file', mime: ['video/mp4', 'video/webm', 'audio/mpeg', 'audio/mp4'] },
++ limits: { timeoutMs: TIMEOUT_MS, maxDurationSec: MAX_DURATION, perTargetPerMinute: 10 },
++ auth: { anonymous: true, capability: 'tools.tool.run' },
++ quotaClass: 'tools-download', cost: 50, egress: true,
++ requires: ['yt-dlp'],
++}];
++
++module.exports = { SPECS, MAX_DURATION, TIMEOUT_MS };