openvibers/openvibe.tools Commits
Registry consistency test: every catalogue tool (not planned, not a mirror) must resolve to something that exists or be marked unavailable. img/audio/docs: its host in the satellite's domain map naming an operation that apps/<app>/server/tools/index.js defines, with a default format that operation can write; net: an endpoint in net/config.js that the /api/net router serves and net.html calls (or status unavailable, said by the catalogue and the page); dev: an entry and a processor in dev.html; text: its host in the text app's host map (moved to apps/text/server/hosts.js so it can be read) pointing at a page that exists; media, places and pastes: the app (or Community) that serves it. A self-check shows it reports the old gap: merge.openvibe.tools → operation 'merge', which the audio tools did not define. 169 tools resolve today; 3 are marked unavailable (traceroute, mtr, reputation)
Changes
nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++// Every tool the catalogue lists (not planned placeholders, not mirrors) must resolve to something that++// exists, or be marked unavailable:++// img / audio / docs its host in that satellite's domain map, naming an operation its tools/index.js defines++// (and a default format that operation can write)++// net an endpoint in net/config.js that the router serves and net.html calls, or status 'unavailable'++// dev an entry and a processor in dev.html++// text its host in the text app's host map, pointing at a page that exists++// media / places the app knows the host; pastes live on OpenVibe.Community++// merge.openvibe.tools pointed at an audio operation that did not exist for months; this test is what++// catches that (see the self-check at the end).++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++++// The satellites' tool modules are loaded for real; the audio ones make a temp dir on load.++process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-registry-'));++const APPS = path.join(__dirname, '..', '..');++const registry = require('../server/registry');++const { NET_TOOL_MAP } = require('../server/net/config');++const { DEV_TOOL_MAP } = require('../server/dev/config');++const createNetRoutes = require('../server/net/routes');++++const SATELLITES = {++ img: {++ domainMap: require(path.join(APPS, 'img/server/domain-map')).DOMAIN_MAP,++ tools: require(path.join(APPS, 'img/server/tools')).TOOLS,++ formats: (op) => (op === 'convert' ? require(path.join(APPS, 'img/server/tools/convert')).formats : null),++ },++ audio: {++ domainMap: require(path.join(APPS, 'audio/server/domain-map')).DOMAIN_MAP,++ tools: require(path.join(APPS, 'audio/server/tools')).TOOLS,++ formats: (op) => (op === 'convert' ? require(path.join(APPS, 'audio/server/tools/convert')).formats : null),++ },++ docs: {++ domainMap: require(path.join(APPS, 'docs/server/domain-map')).DOMAIN_MAP,++ tools: require(path.join(APPS, 'docs/server/tools')).TOOLS,++ formats: (op) => (op === 'pdf2img' ? ['png', 'jpg'] : null),++ },++};++++/** What is wrong with a satellite tool's host → operation mapping ([] when nothing). */++function satelliteProblems(t, sat) {++ const host = `${t.id}.openvibe.tools`;++ const ctx = sat.domainMap[host];++ if (!ctx) return [`${t.family}/${t.id}: ${host} is not in the ${t.family} satellite's domain map`];++ if (!ctx.defaultOp) return [`${t.family}/${t.id}: ${host} names no operation`];++ if (!sat.tools[ctx.defaultOp]) return [`${t.family}/${t.id}: ${host} → operation "${ctx.defaultOp}", which apps/${t.family}/server/tools/index.js does not define`];++ const formats = ctx.defaultFormat && sat.formats(ctx.defaultOp);++ if (formats && !formats.includes(ctx.defaultFormat)) return [`${t.family}/${t.id}: ${host} → ${ctx.defaultOp} to "${ctx.defaultFormat}", a format it cannot write`];++ return [];++}++++/** Routes the net router serves, as endpoint paths ('/robots', '/myip', …). */++function netEndpoints() {++ const router = createNetRoutes(null, null, { egress: { parseUrl: () => null } });++ return new Set(router.stack.filter(l => l.route).map(l => l.route.path.replace(/\/:target\?$/, '')));++}++++/** net.html's TOOLS: id → { endpoint, unavailable }. */++function netPage() {++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'net.html'), 'utf8');++ const block = html.slice(html.indexOf('const TOOLS = {'), html.indexOf('const CATS = {'));++ const out = new Map();++ for (const m of block.matchAll(/^\s{8}([a-z0-9]+):\s+\{ name: .*$/gm)) {++ const ep = /endpoint: (?:null|'([^']*)')/.exec(m[0]);++ out.set(m[1], { endpoint: ep ? ep[1] || null : undefined, unavailable: /unavailable: '/.test(m[0]) });++ }++ return out;++}++++/** dev.html: the tool entries (T) and the processors (P). */++function devPage() {++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'dev.html'), 'utf8');++ const t = html.slice(html.indexOf('const T={'), html.indexOf('const CATS='));++ return { entries: new Set([...t.matchAll(/^([a-z0-9]+):\{n:/gm)].map(m => m[1])), processors: new Set([...html.matchAll(/^P\.([a-z0-9]+)=/gm)].map(m => m[1])) };++}++++const cat = registry.catalog();++const problems = [];++const endpoints = netEndpoints();++const page = netPage();++const dev = devPage();++const { HOSTNAME_MAP: TEXT_HOSTS } = require(path.join(APPS, 'text/server/hosts'));++const yt = require(path.join(APPS, 'yt/server/seo'));++const counted = {};++++for (const t of cat.tools) {++ counted[t.family] = (counted[t.family] || 0) + 1;++ assert.ok(['available', 'unavailable'].includes(t.status), `${t.id} has a status`);++ if (SATELLITES[t.family]) { problems.push(...satelliteProblems(t, SATELLITES[t.family])); continue; }++ switch (t.family) {++ case 'net': {++ const def = NET_TOOL_MAP.get(t.id);++ const ui = page.get(t.id);++ if (!def) { problems.push(`net/${t.id}: not in net/config.js`); break; }++ if (!ui) { problems.push(`net/${t.id}: net.html has no entry (the page would show the hub)`); break; }++ if (def.status === 'unavailable') {++ if (t.status !== 'unavailable' || !t.unavailable) problems.push(`net/${t.id}: unavailable in net/config.js but not in the catalogue`);++ if (!ui.unavailable) problems.push(`net/${t.id}: unavailable, but net.html does not say so`);++ break;++ }++ if (!def.endpoint || !endpoints.has(def.endpoint)) problems.push(`net/${t.id}: endpoint ${def.endpoint} is not a route of /api/net`);++ if (ui.endpoint !== def.endpoint) problems.push(`net/${t.id}: net.html calls ${ui.endpoint}, net/config.js says ${def.endpoint}`);++ if (t.status !== 'available') problems.push(`net/${t.id}: implemented but marked ${t.status}`);++ break;++ }++ case 'dev': {++ if (!DEV_TOOL_MAP.has(t.id)) problems.push(`dev/${t.id}: not in dev/config.js`);++ if (!dev.entries.has(t.id)) problems.push(`dev/${t.id}: dev.html has no entry`);++ if (!dev.processors.has(t.id)) problems.push(`dev/${t.id}: dev.html has no processor P.${t.id}`);++ break;++ }++ case 'text': {++ const file = TEXT_HOSTS[`${t.id}.openvibe.tools`];++ if (!file) problems.push(`text/${t.id}: ${t.id}.openvibe.tools is not in apps/text/server/hosts.js`);++ else if (!fs.existsSync(path.join(APPS, 'text', 'public', file))) problems.push(`text/${t.id}: page ${file} does not exist`);++ break;++ }++ case 'media':++ if (!yt.knowsHost(t.hosts.canonical)) problems.push(`media/${t.id}: the yt app does not serve ${t.hosts.canonical}`);++ break;++ case 'places': {++ const src = path.join(APPS, t.id, 'server', 'index.js');++ if (!fs.existsSync(src) || !fs.readFileSync(src, 'utf8').includes(`'${t.id}.openvibe.tools'`)) problems.push(`places/${t.id}: no app serving ${t.id}.openvibe.tools`);++ break;++ }++ case 'pastes':++ if (!(registry.get().tools.find(x => x.id === t.id) || {}).external) problems.push(`pastes/${t.id}: pastes are served by OpenVibe.Community (external)`);++ break;++ default:++ problems.push(`${t.family}/${t.id}: unknown family, nothing checks it`);++ }++}++assert.deepStrictEqual(problems, [], `catalogue tools that resolve to nothing:\n ${problems.join('\n ')}`);++for (const f of ['img', 'audio', 'docs', 'net', 'dev', 'text', 'media', 'places', 'pastes']) assert.ok(counted[f] > 0, `the catalogue has ${f} tools (checked)`);++++// Planned placeholders are never listed as tools; mirrors are not tools either.++assert.ok(cat.planned.every(p => !cat.tools.some(t => t.id === p.id)));++++// ── Self-check: the audio merge gap would have been caught ──++const toolsWithoutMerge = { ...SATELLITES.audio.tools };++delete toolsWithoutMerge.merge;++const found = satelliteProblems({ family: 'audio', id: 'merge' }, { ...SATELLITES.audio, tools: toolsWithoutMerge });++assert.deepStrictEqual(found, ['audio/merge: merge.openvibe.tools → operation "merge", which apps/audio/server/tools/index.js does not define']);++assert.match(satelliteProblems({ family: 'img', id: 'bmp' }, { ...SATELLITES.img, formats: () => ['png'] })[0], /cannot write/);++++fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });++console.log(`registry consistency: ${cat.tools.length} catalogue tools resolve (${Object.entries(counted).map(([f, n]) => `${f} ${n}`).join(', ')}); ${cat.tools.filter(t => t.status === 'unavailable').length} marked unavailable`);nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++++// ── Hostname → HTML file mapping ─────────────────────────────++// Each subdomain gets its own static HTML page for SEO + focused UX.++// Every host here must also be in deploy/nginx/text.openvibe.tools.conf, and must not be++// claimed by another app's vhost or by the gateway's net/dev catalogs: json., markdown.,++// escape., diff., compare. and slug. are ours (the gateway's generic versions moved to++// jsonfmt., md., entities., codediff. and slugify.).++const HOSTNAME_MAP = {++ // Text hubs++ 'text.openvibe.tools': 'index.html',++ 'type.openvibe.tools': 'index.html',++ 'fonts.openvibe.tools': 'index.html',++ // Fancy text generators++ 'fancy.openvibe.tools': 'fancy.html',++ 'zalgo.openvibe.tools': 'zalgo.html',++ 'ascii.openvibe.tools': 'ascii.html',++ 'symbols.openvibe.tools': 'symbols.html',++ 'unicode.openvibe.tools': 'unicode.html',++ 'bubble.openvibe.tools': 'bubble.html',++ 'glitch.openvibe.tools': 'zalgo.html',++ 'smallcaps.openvibe.tools': 'fancy.html',++ 'cursive.openvibe.tools': 'fancy.html',++ 'gothic.openvibe.tools': 'fancy.html',++ 'wide.openvibe.tools': 'fancy.html',++ 'monospaced.openvibe.tools': 'fancy.html',++ 'braille.openvibe.tools': 'braille.html',++ 'morse.openvibe.tools': 'morse.html',++ 'binary.openvibe.tools': 'binary.html',++ // Quick-action text tools++ 'case.openvibe.tools': 'case.html',++ 'caps.openvibe.tools': 'case.html',++ 'titlecase.openvibe.tools': 'case.html',++ // reverse.openvibe.tools is the Audio app's (reverse an audio file); the text flipper++ // lives on reversetext. + mirror. instead. Keep this in step with deploy/nginx.++ 'reversetext.openvibe.tools':'reverse.html',++ 'mirror.openvibe.tools': 'reverse.html',++ 'clean.openvibe.tools': 'clean.html',++ 'strip.openvibe.tools': 'clean.html',++ 'count.openvibe.tools': 'count.html',++ 'lines.openvibe.tools': 'count.html',++ 'sort.openvibe.tools': 'sort.html',++ 'dedupe.openvibe.tools': 'sort.html',++ 'slug.openvibe.tools': 'slug.html',++ 'compare.openvibe.tools': 'compare.html',++ 'diff.openvibe.tools': 'compare.html',++ 'markdown.openvibe.tools': 'markdown.html',++ 'json.openvibe.tools': 'json.html',++ 'escape.openvibe.tools': 'escape.html',++ // Identity / social++ 'bio.openvibe.tools': 'bio.html',++ 'nickname.openvibe.tools': 'nickname.html',++ 'username.openvibe.tools': 'nickname.html',++ 'gamertag.openvibe.tools': 'nickname.html',++ 'kaomoji.openvibe.tools': 'kaomoji.html',++ 'emojis.openvibe.tools': 'kaomoji.html',++ 'copypaste.openvibe.tools': 'symbols.html',++ // ASCII art / banners++ 'banner.openvibe.tools': 'ascii.html',++ 'textart.openvibe.tools': 'ascii.html',++ 'figlet.openvibe.tools': 'ascii.html',++ // Logo / title / design++ 'logo.openvibe.tools': 'logo-hub.html',++ 'title.openvibe.tools': 'title.html',++ 'wordmark.openvibe.tools': 'wordmark.html',++ 'textlogo.openvibe.tools': 'wordmark.html',++ 'transparent.openvibe.tools':'transparent.html',++ 'badge.openvibe.tools': 'badge.html',++ 'sticker.openvibe.tools': 'badge.html',++ 'thumbnail.openvibe.tools': 'thumbnail.html',++ 'cover.openvibe.tools': 'thumbnail.html',++ 'channelart.openvibe.tools': 'thumbnail.html',++ 'watermark.openvibe.tools': 'watermark.html',++ 'neon.openvibe.tools': 'wordmark.html',++ 'overlay.openvibe.tools': 'watermark.html',++ 'lowerthird.openvibe.tools': 'watermark.html',++ // Community / Mexican tools++ 'ice.openvibe.tools': 'ice.html',++ 'peso.openvibe.tools': 'peso.html',++ 'currency.openvibe.tools': 'peso.html',++ 'mxn.openvibe.tools': 'peso.html',++ 'spanish.openvibe.tools': 'spanish.html',++ 'espanol.openvibe.tools': 'spanish.html',++ 'slang.openvibe.tools': 'slang.html',++};++++module.exports = { HOSTNAME_MAP };@@@ -77,89 +77,8 @@ app.use(rateLimit({ windowMs: 60_000, max: 200 }));// ── Analytics Middleware ─────────────────────────────────────app.use(analytics.middleware());--// ── Hostname → HTML file mapping ─────────────────────────────--// Each subdomain gets its own static HTML page for SEO + focused UX.--// Every host here must also be in deploy/nginx/text.openvibe.tools.conf, and must not be--// claimed by another app's vhost or by the gateway's net/dev catalogs: json., markdown.,--// escape., diff., compare. and slug. are ours (the gateway's generic versions moved to--// jsonfmt., md., entities., codediff. and slugify.).--const HOSTNAME_MAP = {-- // Text hubs-- 'text.openvibe.tools': 'index.html',-- 'type.openvibe.tools': 'index.html',-- 'fonts.openvibe.tools': 'index.html',-- // Fancy text generators-- 'fancy.openvibe.tools': 'fancy.html',-- 'zalgo.openvibe.tools': 'zalgo.html',-- 'ascii.openvibe.tools': 'ascii.html',-- 'symbols.openvibe.tools': 'symbols.html',-- 'unicode.openvibe.tools': 'unicode.html',-- 'bubble.openvibe.tools': 'bubble.html',-- 'glitch.openvibe.tools': 'zalgo.html',-- 'smallcaps.openvibe.tools': 'fancy.html',-- 'cursive.openvibe.tools': 'fancy.html',-- 'gothic.openvibe.tools': 'fancy.html',-- 'wide.openvibe.tools': 'fancy.html',-- 'monospaced.openvibe.tools': 'fancy.html',-- 'braille.openvibe.tools': 'braille.html',-- 'morse.openvibe.tools': 'morse.html',-- 'binary.openvibe.tools': 'binary.html',-- // Quick-action text tools-- 'case.openvibe.tools': 'case.html',-- 'caps.openvibe.tools': 'case.html',-- 'titlecase.openvibe.tools': 'case.html',-- // reverse.openvibe.tools is the Audio app's (reverse an audio file); the text flipper-- // lives on reversetext. + mirror. instead. Keep this in step with deploy/nginx.-- 'reversetext.openvibe.tools':'reverse.html',-- 'mirror.openvibe.tools': 'reverse.html',-- 'clean.openvibe.tools': 'clean.html',-- 'strip.openvibe.tools': 'clean.html',-- 'count.openvibe.tools': 'count.html',-- 'lines.openvibe.tools': 'count.html',-- 'sort.openvibe.tools': 'sort.html',-- 'dedupe.openvibe.tools': 'sort.html',-- 'slug.openvibe.tools': 'slug.html',-- 'compare.openvibe.tools': 'compare.html',-- 'diff.openvibe.tools': 'compare.html',-- 'markdown.openvibe.tools': 'markdown.html',-- 'json.openvibe.tools': 'json.html',-- 'escape.openvibe.tools': 'escape.html',-- // Identity / social-- 'bio.openvibe.tools': 'bio.html',-- 'nickname.openvibe.tools': 'nickname.html',-- 'username.openvibe.tools': 'nickname.html',-- 'gamertag.openvibe.tools': 'nickname.html',-- 'kaomoji.openvibe.tools': 'kaomoji.html',-- 'emojis.openvibe.tools': 'kaomoji.html',-- 'copypaste.openvibe.tools': 'symbols.html',-- // ASCII art / banners-- 'banner.openvibe.tools': 'ascii.html',-- 'textart.openvibe.tools': 'ascii.html',-- 'figlet.openvibe.tools': 'ascii.html',-- // Logo / title / design-- 'logo.openvibe.tools': 'logo-hub.html',-- 'title.openvibe.tools': 'title.html',-- 'wordmark.openvibe.tools': 'wordmark.html',-- 'textlogo.openvibe.tools': 'wordmark.html',-- 'transparent.openvibe.tools':'transparent.html',-- 'badge.openvibe.tools': 'badge.html',-- 'sticker.openvibe.tools': 'badge.html',-- 'thumbnail.openvibe.tools': 'thumbnail.html',-- 'cover.openvibe.tools': 'thumbnail.html',-- 'channelart.openvibe.tools': 'thumbnail.html',-- 'watermark.openvibe.tools': 'watermark.html',-- 'neon.openvibe.tools': 'wordmark.html',-- 'overlay.openvibe.tools': 'watermark.html',-- 'lowerthird.openvibe.tools': 'watermark.html',-- // Community / Mexican tools-- 'ice.openvibe.tools': 'ice.html',-- 'peso.openvibe.tools': 'peso.html',-- 'currency.openvibe.tools': 'peso.html',-- 'mxn.openvibe.tools': 'peso.html',-- 'spanish.openvibe.tools': 'spanish.html',-- 'espanol.openvibe.tools': 'spanish.html',-- 'slang.openvibe.tools': 'slang.html',--};++// ── Hostname → HTML file mapping (server/hosts.js) ───────────++const { HOSTNAME_MAP } = require('./hosts');// ── Hosts ────────────────────────────────────────────────────// Through the gateway the X-OV-* headers name the tool and its canonical host; aliases the gateway
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+// Every tool the catalogue lists (not planned placeholders, not mirrors) must resolve to something that
+// exists, or be marked unavailable:
+// img / audio / docs its host in that satellite's domain map, naming an operation its tools/index.js defines
+// (and a default format that operation can write)
+// net an endpoint in net/config.js that the router serves and net.html calls, or status 'unavailable'
+// dev an entry and a processor in dev.html
+// text its host in the text app's host map, pointing at a page that exists
+// media / places the app knows the host; pastes live on OpenVibe.Community
+// merge.openvibe.tools pointed at an audio operation that did not exist for months; this test is what
+// catches that (see the self-check at the end).
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+// The satellites' tool modules are loaded for real; the audio ones make a temp dir on load.
+process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-registry-'));
+const APPS = path.join(__dirname, '..', '..');
+const registry = require('../server/registry');
+const { NET_TOOL_MAP } = require('../server/net/config');
+const { DEV_TOOL_MAP } = require('../server/dev/config');
+const createNetRoutes = require('../server/net/routes');
+
+const SATELLITES = {
+ img: {
+ domainMap: require(path.join(APPS, 'img/server/domain-map')).DOMAIN_MAP,
+ tools: require(path.join(APPS, 'img/server/tools')).TOOLS,
+ formats: (op) => (op === 'convert' ? require(path.join(APPS, 'img/server/tools/convert')).formats : null),
+ },
+ audio: {
+ domainMap: require(path.join(APPS, 'audio/server/domain-map')).DOMAIN_MAP,
+ tools: require(path.join(APPS, 'audio/server/tools')).TOOLS,
+ formats: (op) => (op === 'convert' ? require(path.join(APPS, 'audio/server/tools/convert')).formats : null),
+ },
+ docs: {
+ domainMap: require(path.join(APPS, 'docs/server/domain-map')).DOMAIN_MAP,
+ tools: require(path.join(APPS, 'docs/server/tools')).TOOLS,
+ formats: (op) => (op === 'pdf2img' ? ['png', 'jpg'] : null),
+ },
+};
+
+/** What is wrong with a satellite tool's host → operation mapping ([] when nothing). */
+function satelliteProblems(t, sat) {
+ const host = `${t.id}.openvibe.tools`;
+ const ctx = sat.domainMap[host];
+ if (!ctx) return [`${t.family}/${t.id}: ${host} is not in the ${t.family} satellite's domain map`];
+ if (!ctx.defaultOp) return [`${t.family}/${t.id}: ${host} names no operation`];
+ if (!sat.tools[ctx.defaultOp]) return [`${t.family}/${t.id}: ${host} → operation "${ctx.defaultOp}", which apps/${t.family}/server/tools/index.js does not define`];
+ const formats = ctx.defaultFormat && sat.formats(ctx.defaultOp);
+ if (formats && !formats.includes(ctx.defaultFormat)) return [`${t.family}/${t.id}: ${host} → ${ctx.defaultOp} to "${ctx.defaultFormat}", a format it cannot write`];
+ return [];
+}
+
+/** Routes the net router serves, as endpoint paths ('/robots', '/myip', …). */
+function netEndpoints() {
+ const router = createNetRoutes(null, null, { egress: { parseUrl: () => null } });
+ return new Set(router.stack.filter(l => l.route).map(l => l.route.path.replace(/\/:target\?$/, '')));
+}
+
+/** net.html's TOOLS: id → { endpoint, unavailable }. */
+function netPage() {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'net.html'), 'utf8');
+ const block = html.slice(html.indexOf('const TOOLS = {'), html.indexOf('const CATS = {'));
+ const out = new Map();
+ for (const m of block.matchAll(/^\s{8}([a-z0-9]+):\s+\{ name: .*$/gm)) {
+ const ep = /endpoint: (?:null|'([^']*)')/.exec(m[0]);
+ out.set(m[1], { endpoint: ep ? ep[1] || null : undefined, unavailable: /unavailable: '/.test(m[0]) });
+ }
+ return out;
+}
+
+/** dev.html: the tool entries (T) and the processors (P). */
+function devPage() {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'dev.html'), 'utf8');
+ const t = html.slice(html.indexOf('const T={'), html.indexOf('const CATS='));
+ return { entries: new Set([...t.matchAll(/^([a-z0-9]+):\{n:/gm)].map(m => m[1])), processors: new Set([...html.matchAll(/^P\.([a-z0-9]+)=/gm)].map(m => m[1])) };
+}
+
+const cat = registry.catalog();
+const problems = [];
+const endpoints = netEndpoints();
+const page = netPage();
+const dev = devPage();
+const { HOSTNAME_MAP: TEXT_HOSTS } = require(path.join(APPS, 'text/server/hosts'));
+const yt = require(path.join(APPS, 'yt/server/seo'));
+const counted = {};
+
+for (const t of cat.tools) {
+ counted[t.family] = (counted[t.family] || 0) + 1;
+ assert.ok(['available', 'unavailable'].includes(t.status), `${t.id} has a status`);
+ if (SATELLITES[t.family]) { problems.push(...satelliteProblems(t, SATELLITES[t.family])); continue; }
+ switch (t.family) {
+ case 'net': {
+ const def = NET_TOOL_MAP.get(t.id);
+ const ui = page.get(t.id);
+ if (!def) { problems.push(`net/${t.id}: not in net/config.js`); break; }
+ if (!ui) { problems.push(`net/${t.id}: net.html has no entry (the page would show the hub)`); break; }
+ if (def.status === 'unavailable') {
+ if (t.status !== 'unavailable' || !t.unavailable) problems.push(`net/${t.id}: unavailable in net/config.js but not in the catalogue`);
+ if (!ui.unavailable) problems.push(`net/${t.id}: unavailable, but net.html does not say so`);
+ break;
+ }
+ if (!def.endpoint || !endpoints.has(def.endpoint)) problems.push(`net/${t.id}: endpoint ${def.endpoint} is not a route of /api/net`);
+ if (ui.endpoint !== def.endpoint) problems.push(`net/${t.id}: net.html calls ${ui.endpoint}, net/config.js says ${def.endpoint}`);
+ if (t.status !== 'available') problems.push(`net/${t.id}: implemented but marked ${t.status}`);
+ break;
+ }
+ case 'dev': {
+ if (!DEV_TOOL_MAP.has(t.id)) problems.push(`dev/${t.id}: not in dev/config.js`);
+ if (!dev.entries.has(t.id)) problems.push(`dev/${t.id}: dev.html has no entry`);
+ if (!dev.processors.has(t.id)) problems.push(`dev/${t.id}: dev.html has no processor P.${t.id}`);
+ break;
+ }
+ case 'text': {
+ const file = TEXT_HOSTS[`${t.id}.openvibe.tools`];
+ if (!file) problems.push(`text/${t.id}: ${t.id}.openvibe.tools is not in apps/text/server/hosts.js`);
+ else if (!fs.existsSync(path.join(APPS, 'text', 'public', file))) problems.push(`text/${t.id}: page ${file} does not exist`);
+ break;
+ }
+ case 'media':
+ if (!yt.knowsHost(t.hosts.canonical)) problems.push(`media/${t.id}: the yt app does not serve ${t.hosts.canonical}`);
+ break;
+ case 'places': {
+ const src = path.join(APPS, t.id, 'server', 'index.js');
+ if (!fs.existsSync(src) || !fs.readFileSync(src, 'utf8').includes(`'${t.id}.openvibe.tools'`)) problems.push(`places/${t.id}: no app serving ${t.id}.openvibe.tools`);
+ break;
+ }
+ case 'pastes':
+ if (!(registry.get().tools.find(x => x.id === t.id) || {}).external) problems.push(`pastes/${t.id}: pastes are served by OpenVibe.Community (external)`);
+ break;
+ default:
+ problems.push(`${t.family}/${t.id}: unknown family, nothing checks it`);
+ }
+}
+assert.deepStrictEqual(problems, [], `catalogue tools that resolve to nothing:\n ${problems.join('\n ')}`);
+for (const f of ['img', 'audio', 'docs', 'net', 'dev', 'text', 'media', 'places', 'pastes']) assert.ok(counted[f] > 0, `the catalogue has ${f} tools (checked)`);
+
+// Planned placeholders are never listed as tools; mirrors are not tools either.
+assert.ok(cat.planned.every(p => !cat.tools.some(t => t.id === p.id)));
+
+// ── Self-check: the audio merge gap would have been caught ──
+const toolsWithoutMerge = { ...SATELLITES.audio.tools };
+delete toolsWithoutMerge.merge;
+const found = satelliteProblems({ family: 'audio', id: 'merge' }, { ...SATELLITES.audio, tools: toolsWithoutMerge });
+assert.deepStrictEqual(found, ['audio/merge: merge.openvibe.tools → operation "merge", which apps/audio/server/tools/index.js does not define']);
+assert.match(satelliteProblems({ family: 'img', id: 'bmp' }, { ...SATELLITES.img, formats: () => ['png'] })[0], /cannot write/);
+
+fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });
+console.log(`registry consistency: ${cat.tools.length} catalogue tools resolve (${Object.entries(counted).map(([f, n]) => `${f} ${n}`).join(', ')}); ${cat.tools.filter(t => t.status === 'unavailable').length} marked unavailable`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+
+// ── Hostname → HTML file mapping ─────────────────────────────
+// Each subdomain gets its own static HTML page for SEO + focused UX.
+// Every host here must also be in deploy/nginx/text.openvibe.tools.conf, and must not be
+// claimed by another app's vhost or by the gateway's net/dev catalogs: json., markdown.,
+// escape., diff., compare. and slug. are ours (the gateway's generic versions moved to
+// jsonfmt., md., entities., codediff. and slugify.).
+const HOSTNAME_MAP = {
+ // Text hubs
+ 'text.openvibe.tools': 'index.html',
+ 'type.openvibe.tools': 'index.html',
+ 'fonts.openvibe.tools': 'index.html',
+ // Fancy text generators
+ 'fancy.openvibe.tools': 'fancy.html',
+ 'zalgo.openvibe.tools': 'zalgo.html',
+ 'ascii.openvibe.tools': 'ascii.html',
+ 'symbols.openvibe.tools': 'symbols.html',
+ 'unicode.openvibe.tools': 'unicode.html',
+ 'bubble.openvibe.tools': 'bubble.html',
+ 'glitch.openvibe.tools': 'zalgo.html',
+ 'smallcaps.openvibe.tools': 'fancy.html',
+ 'cursive.openvibe.tools': 'fancy.html',
+ 'gothic.openvibe.tools': 'fancy.html',
+ 'wide.openvibe.tools': 'fancy.html',
+ 'monospaced.openvibe.tools': 'fancy.html',
+ 'braille.openvibe.tools': 'braille.html',
+ 'morse.openvibe.tools': 'morse.html',
+ 'binary.openvibe.tools': 'binary.html',
+ // Quick-action text tools
+ 'case.openvibe.tools': 'case.html',
+ 'caps.openvibe.tools': 'case.html',
+ 'titlecase.openvibe.tools': 'case.html',
+ // reverse.openvibe.tools is the Audio app's (reverse an audio file); the text flipper
+ // lives on reversetext. + mirror. instead. Keep this in step with deploy/nginx.
+ 'reversetext.openvibe.tools':'reverse.html',
+ 'mirror.openvibe.tools': 'reverse.html',
+ 'clean.openvibe.tools': 'clean.html',
+ 'strip.openvibe.tools': 'clean.html',
+ 'count.openvibe.tools': 'count.html',
+ 'lines.openvibe.tools': 'count.html',
+ 'sort.openvibe.tools': 'sort.html',
+ 'dedupe.openvibe.tools': 'sort.html',
+ 'slug.openvibe.tools': 'slug.html',
+ 'compare.openvibe.tools': 'compare.html',
+ 'diff.openvibe.tools': 'compare.html',
+ 'markdown.openvibe.tools': 'markdown.html',
+ 'json.openvibe.tools': 'json.html',
+ 'escape.openvibe.tools': 'escape.html',
+ // Identity / social
+ 'bio.openvibe.tools': 'bio.html',
+ 'nickname.openvibe.tools': 'nickname.html',
+ 'username.openvibe.tools': 'nickname.html',
+ 'gamertag.openvibe.tools': 'nickname.html',
+ 'kaomoji.openvibe.tools': 'kaomoji.html',
+ 'emojis.openvibe.tools': 'kaomoji.html',
+ 'copypaste.openvibe.tools': 'symbols.html',
+ // ASCII art / banners
+ 'banner.openvibe.tools': 'ascii.html',
+ 'textart.openvibe.tools': 'ascii.html',
+ 'figlet.openvibe.tools': 'ascii.html',
+ // Logo / title / design
+ 'logo.openvibe.tools': 'logo-hub.html',
+ 'title.openvibe.tools': 'title.html',
+ 'wordmark.openvibe.tools': 'wordmark.html',
+ 'textlogo.openvibe.tools': 'wordmark.html',
+ 'transparent.openvibe.tools':'transparent.html',
+ 'badge.openvibe.tools': 'badge.html',
+ 'sticker.openvibe.tools': 'badge.html',
+ 'thumbnail.openvibe.tools': 'thumbnail.html',
+ 'cover.openvibe.tools': 'thumbnail.html',
+ 'channelart.openvibe.tools': 'thumbnail.html',
+ 'watermark.openvibe.tools': 'watermark.html',
+ 'neon.openvibe.tools': 'wordmark.html',
+ 'overlay.openvibe.tools': 'watermark.html',
+ 'lowerthird.openvibe.tools': 'watermark.html',
+ // Community / Mexican tools
+ 'ice.openvibe.tools': 'ice.html',
+ 'peso.openvibe.tools': 'peso.html',
+ 'currency.openvibe.tools': 'peso.html',
+ 'mxn.openvibe.tools': 'peso.html',
+ 'spanish.openvibe.tools': 'spanish.html',
+ 'espanol.openvibe.tools': 'spanish.html',
+ 'slang.openvibe.tools': 'slang.html',
+};
+
+module.exports = { HOSTNAME_MAP };
@@ -77,89 +77,8 @@ app.use(rateLimit({ windowMs: 60_000, max: 200 }));
@@ -77,89 +77,8 @@ app.use(rateLimit({ windowMs: 60_000, max: 200 }));
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
app.use(analytics.middleware());
app.use(analytics.middleware());
-// ── Hostname → HTML file mapping ─────────────────────────────
-// Each subdomain gets its own static HTML page for SEO + focused UX.
-// Every host here must also be in deploy/nginx/text.openvibe.tools.conf, and must not be
-// claimed by another app's vhost or by the gateway's net/dev catalogs: json., markdown.,
-// escape., diff., compare. and slug. are ours (the gateway's generic versions moved to
-// jsonfmt., md., entities., codediff. and slugify.).
-const HOSTNAME_MAP = {
- // Text hubs
- 'text.openvibe.tools': 'index.html',
- 'type.openvibe.tools': 'index.html',
- 'fonts.openvibe.tools': 'index.html',
- // Fancy text generators
- 'fancy.openvibe.tools': 'fancy.html',
- 'zalgo.openvibe.tools': 'zalgo.html',
- 'ascii.openvibe.tools': 'ascii.html',
- 'symbols.openvibe.tools': 'symbols.html',
- 'unicode.openvibe.tools': 'unicode.html',
- 'bubble.openvibe.tools': 'bubble.html',
- 'glitch.openvibe.tools': 'zalgo.html',
- 'smallcaps.openvibe.tools': 'fancy.html',
- 'cursive.openvibe.tools': 'fancy.html',
- 'gothic.openvibe.tools': 'fancy.html',
- 'wide.openvibe.tools': 'fancy.html',
- 'monospaced.openvibe.tools': 'fancy.html',
- 'braille.openvibe.tools': 'braille.html',
- 'morse.openvibe.tools': 'morse.html',
- 'binary.openvibe.tools': 'binary.html',
- // Quick-action text tools
- 'case.openvibe.tools': 'case.html',
- 'caps.openvibe.tools': 'case.html',
- 'titlecase.openvibe.tools': 'case.html',
- // reverse.openvibe.tools is the Audio app's (reverse an audio file); the text flipper
- // lives on reversetext. + mirror. instead. Keep this in step with deploy/nginx.
- 'reversetext.openvibe.tools':'reverse.html',
- 'mirror.openvibe.tools': 'reverse.html',
- 'clean.openvibe.tools': 'clean.html',
- 'strip.openvibe.tools': 'clean.html',
- 'count.openvibe.tools': 'count.html',
- 'lines.openvibe.tools': 'count.html',
- 'sort.openvibe.tools': 'sort.html',
- 'dedupe.openvibe.tools': 'sort.html',
- 'slug.openvibe.tools': 'slug.html',
- 'compare.openvibe.tools': 'compare.html',
- 'diff.openvibe.tools': 'compare.html',
- 'markdown.openvibe.tools': 'markdown.html',
- 'json.openvibe.tools': 'json.html',
- 'escape.openvibe.tools': 'escape.html',
- // Identity / social
- 'bio.openvibe.tools': 'bio.html',
- 'nickname.openvibe.tools': 'nickname.html',
- 'username.openvibe.tools': 'nickname.html',
- 'gamertag.openvibe.tools': 'nickname.html',
- 'kaomoji.openvibe.tools': 'kaomoji.html',
- 'emojis.openvibe.tools': 'kaomoji.html',
- 'copypaste.openvibe.tools': 'symbols.html',
- // ASCII art / banners
- 'banner.openvibe.tools': 'ascii.html',
- 'textart.openvibe.tools': 'ascii.html',
- 'figlet.openvibe.tools': 'ascii.html',
- // Logo / title / design
- 'logo.openvibe.tools': 'logo-hub.html',
- 'title.openvibe.tools': 'title.html',
- 'wordmark.openvibe.tools': 'wordmark.html',
- 'textlogo.openvibe.tools': 'wordmark.html',
- 'transparent.openvibe.tools':'transparent.html',
- 'badge.openvibe.tools': 'badge.html',
- 'sticker.openvibe.tools': 'badge.html',
- 'thumbnail.openvibe.tools': 'thumbnail.html',
- 'cover.openvibe.tools': 'thumbnail.html',
- 'channelart.openvibe.tools': 'thumbnail.html',
- 'watermark.openvibe.tools': 'watermark.html',
- 'neon.openvibe.tools': 'wordmark.html',
- 'overlay.openvibe.tools': 'watermark.html',
- 'lowerthird.openvibe.tools': 'watermark.html',
- // Community / Mexican tools
- 'ice.openvibe.tools': 'ice.html',
- 'peso.openvibe.tools': 'peso.html',
- 'currency.openvibe.tools': 'peso.html',
- 'mxn.openvibe.tools': 'peso.html',
- 'spanish.openvibe.tools': 'spanish.html',
- 'espanol.openvibe.tools': 'spanish.html',
- 'slang.openvibe.tools': 'slang.html',
-};
+// ── Hostname → HTML file mapping (server/hosts.js) ───────────
+const { HOSTNAME_MAP } = require('./hosts');
// ── Hosts ────────────────────────────────────────────────────
// ── Hosts ────────────────────────────────────────────────────
// Through the gateway the X-OV-* headers name the tool and its canonical host; aliases the gateway
// Through the gateway the X-OV-* headers name the tool and its canonical host; aliases the gateway