- JavaScript 77.6%
- HTML 19%
- CSS 3.1%
- Shell 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| apps | ||
| deploy/scripts | ||
| docs | ||
| migrations | ||
| scripts | ||
| test | ||
| .gitignore | ||
| .gitleaks.toml | ||
| package.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| SECURITY.md | ||
| STATUS.json | ||
OpenVibe.Tools
Purpose
The tools suite of the OpenVibe network — openvibe.tools plus dozens of
tool subdomains under *.openvibe.tools.
Identity is provided by OpenVibe.Network (https://openvibe.network).
This repo contains no identity provider: the gateway is an OAuth2 client
(client_id: tools) that sets the shared ov_token cookie on
.openvibe.tools; every satellite reads that cookie and verifies the JWT
offline against the Network's public key (JWKS).
Owns
- the tool registry (families, tools, descriptors
tools.tool@1, canonical, short and alias hosts), the catalog and every tool page onopenvibe.toolsand*.openvibe.tools - the run API and the jobs runtime (img, audio, docs jobs with their files and SSE progress), the guard (callers, quotas, sniffing, egress throttles, PostgreSQL abuse log)
- the
tools.*events, Tools' Search documents and thetools.usageuser module
Does not own
- identity and sign-in (OpenVibe.Network), pastes (OpenVibe.Community:
pastes.openvibe.toolshands over), result storage (OpenVibe.Media keeps job results), custom-domain records (Network's domains admin)
Depends on
- OpenVibe.Network (SSO as OAuth client
tools, JWKS, client-credentials tokens, the domains registry), OpenVibe.Media (job results), OpenVibe.Events (job and usage events, revocation cutoffs), OpenVibe.Search (tool documents), OpenVibe.Community (the paste hand-over), OpenVibe.Billing (job usage readings) - host programs some tools need (ffmpeg, yt-dlp, qpdf, poppler, libheif; Host packages)
- in each app:
openvibe-contractsv0.92.0 (img, audio, docs, gateway),openvibe-sdkv0.12.0 andopenvibe-sharedv4.15.2, pinned by release tarball, andapps/_shared(openvibe-tools-shared) by relative path
Capabilities
Implemented here (the service manifest's capabilities, audience openvibe.tools): tools.tool.read
(the public registry API), tools.tool.run (the run API), tools.job.create, tools.job.read and
tools.job.cancel (jobs), and tools.net.probe (network probes).
Called elsewhere, as the service principal tools (the OAuth client tools):
| Service | Grant | Why |
|---|---|---|
| OpenVibe.Media | media.object.upload, media.object.read |
job results |
| OpenVibe.Events | events.event.publish, events.subscription.manage |
job and usage events; the network.user.token_valid_after subscription |
| OpenVibe.Search | search.document.write |
tool documents for the network index |
| OpenVibe.Billing | billing.usage.record (audience openvibe.billing) |
one usage reading per ended job |
Layout
apps/
├── gateway # apex (server-rendered index, family + tool pages, catalog.json), the tool registry
│ # and host roles, Net.OpenVibe + Dev.OpenVibe tools, proxy to the satellites
├── maps # Maps.OpenVibe — survival map for North America
├── food # the food finder API (proxies maps backend); its page moved to OpenVibe.Food (301 to openvibe.food)
├── img # Img.OpenVibe — image converter & processing tools
├── yt # YT.OpenVibe — YouTube downloader
├── audio # Audio.OpenVibe — audio converter & effects
├── text # Text.OpenVibe + Logo.OpenVibe — text generators & logo makers
└── docs # Docs.OpenVibe — PDF & document tools
apps/_shared # openvibe-tools-shared, a versioned package (package.json, CHANGELOG.md): each app declares the
# range it needs ("openvibeToolsShared": "^1.0.0") and refuses to boot when this checkout does not
# satisfy it (version.js). The apps require it by relative path: host roles, internal auth, the job runtime (jobs/),
# the guard (guard/: callers, quotas, sniffing, ffmpeg hardening, egress throttles, abuse log),
# the SSRF guard (egress.js) that every tool reaching a visitor-chosen host or URL goes
# through (public addresses only, checked after DNS and dialled as checked, redirect hops
# re-checked). Visit analytics come from openvibe-shared/analytics.
scripts/analytics-prune.js # operator CLI: PostgreSQL raw analytics retention (dry run by default)
(openvibe-shared is a pinned OpenVibe.Shared release in each app's package.json; no vendor/ copy)
Ports
| App | Domain | Port |
|---|---|---|
| gateway | openvibe.tools + net./dev./pastes. + tool aliases | 4001 |
| maps | maps.openvibe.tools | 4010 |
| food | food.openvibe.tools | 4011 |
| img | img.openvibe.tools + format aliases | 4012 |
| yt | yt.openvibe.tools | 4013 |
| audio | audio.openvibe.tools + effect aliases | 4014 |
| text | text.openvibe.tools + logo.openvibe.tools + aliases | 4015 |
| docs | docs.openvibe.tools + pdf.openvibe.tools | 4016 |
Related services: Network 4000 (SSO/JWKS/themes/shared assets),
Media 4100, Live 3000, Community 4200 (pastes live on openvibe.community;
pastes.openvibe.tools hands over to it).
Registry, hosts and domains
apps/gateway/server/registry is the one list of tools and families. Each has a canonical host
(links, sitemaps, rel=canonical), an optional short host people type, and aliases that 301
to the short host. Unknown *.openvibe.tools hosts 301 to the index; unknown custom domains 404.
The owner can attach hosts (including bought domains) to a tool in openvibe.network/admin →
Domains; the gateway merges those overrides every minute. New custom domain on the host:
sudo deploy/scripts/add-custom-domain.sh <domain>.
Public, cacheable outputs on the apex: / (every tool by family, planned tools as placeholders),
/<family>-tools, /tool/<id>, /all-tools, /search?q=, /sitemap.xml, /robots.txt,
/llms.txt, /api/catalog.json, /terms, /privacy, /dmca. All of it renders without JavaScript.
Deploy with deploy/scripts/deploy.sh: it runs ovhost deploy tools (OpenVibe.Host, strategy
multi-app; --wait-idle, --restart, --rollback, DRY_RUN=1 for ovhost plan tools). ovhost pulls
as the checkout owner, installs in each app whose dependencies changed (apps/_* are packages, not apps),
checks every dependency resolves and that the jobs runtime and the guard load, restarts every
openvibe-tools* unit, waits for the gateway's /api/ready and its /release.json to name the new sha
with every unit active, rolls back if they do not, and announces the release (host.release.published
to OpenVibe.Events, so open tabs check /release.json within seconds). If ovhost is missing, the wrapper exits with an error.
Tool registry API (ADR-027)
Every catalogue tool has one descriptor (openvibe-contracts tools.tool@1): how it runs (execution:
client in the browser, sync answered inline, job), whether the run API exposes it (api, run), its input
JSON Schema, files, output, limits, auth (anonymous or not; tools.net.probe for network probes), quotaClass,
cost, egress, hosts and docs page. Public, read-only, cacheable (capability tools.tool.read):
| Route | Answer |
|---|---|
GET /api/v1/tools |
tools.tool-list@1, schemas as { $ref }. Filters ?family= ?execution=client|sync|job ?api=true|false ?status= ?q= (comma lists allowed); a bad value → 400 tools.query.invalid |
GET /api/v1/tools/:id |
tools.tool@1 with the schemas embedded |
GET /api/v1/tools/:id/schema |
{ $schema, $id, $defs: { input, output } } (what the list's $refs point at) |
All three answer Access-Control-Allow-Origin: * (the apps' own CORS rules do not apply to them), an ETag
(If-None-Match → 304) and Cache-Control: public, max-age=300, and count against the /api/ limiter. An unknown
id is 404 problem+json tools.tool.not_found (planned placeholders and mirrors say what they are). The gateway
answers for every tool; each satellite answers the same routes for its own tools, with its own live status.
/api/catalog.json is unchanged.
- Specs next to the code.
apps/<app>/server/descriptors.js(img, audio, docs, text, yt; maps holds maps and food) andapps/gateway/server/{net,dev}/descriptors.jsare pure data: the operation, job type and preset come from the app's domain map, net endpoints fromnet/config.js;apps/_shared/tools/descriptor.jsjoins a spec with the catalogue's family, name, summary and hosts (owner overrides included). - Checked.
apps/gateway/server/registry/descriptors.jsmerges them and checks every build withcontracts.validate('tools.tool@1'),contracts.tools.checkDescriptorandcontracts.tools.checkList. A failing descriptor is left out and/api/readylists it (tool_registry, degraded), never a crash. - Status. Unavailable when the catalogue says so (net tools without an implementation) or when the satellite
lacks a program the tool needs (
requires: qpdf, pdftoppm/pdfinfo, heif-dec, ffmpeg, yt-dlp). The gateway reads each satellite'sGET /api/v1/toolson loopback every minute and keeps its unavailable marks. - Server engines for browser tools. The pure transforms behind the dev and text pages live in
apps/gateway/public/js/dev-engine.js,apps/text/public/js/text-engine.jsandformat-engine.js, which the pages load;apps/gateway/server/dev/engines.jsandapps/text/server/engines.jsrun the same code in Node, so those tools sayexecution: client, api: true(the pages stay browser-only). Tools that need a DOM, a CDN library or isolation (XML minify, HTML to Markdown, the JS minifier/beautifier, the regex tester, the canvas graphics makers) sayapi: false, as does the YouTube downloader (page-only by decision).
Run API (ADR-027)
POST /api/v1/tools/:id/run runs any tool whose descriptor says api: true (capability tools.tool.run;
tools.net.probe for the network probes). The gateway (https://openvibe.tools) answers for every tool; img,
audio and docs answer the same route for their own tools. openvibe-sdk/tools (v0.6.0) is its client.
- Request (
tools.run-request@1): JSON{ input, files, wait_ms, idempotency_key }, ormultipart/form-datawith each upload as afilepart and the text partsinput(JSON),files(the file references' JSON),wait_ms,idempotency_key— exactly what the SDK sends.?wait_ms=does the same as the field; theIdempotency-Keyheader wins overidempotency_key. A file reference is{ job_id, index }(a result file of one of your own jobs, on any satellite: one tool's output feeds the next) or{ media_id }(a result of one of your own jobs stored in OpenVibe.Media). Uploads come first, then references, in order; the count must fit the descriptor'sfiles. - Answers (
tools.run@1):200 { state: succeeded, tool, result: { data | text | files }, took_ms[, job] };200 { state: failed | cancelled, tool, error, took_ms[, job] }when the tool itself fails (its problem+json:tools.job.failed,tools.pdf.wrong_password,tools.net.target_not_public, …);202 { state: queued | running, tool, job, location }+Location: /api/v1/jobs/:idwhen a job tool has not finished withinwait_ms(default 0);200 + Idempotent-Replayed: truefor a replay. - Refusals (problem+json, before the tool runs):
400 tools.run.invalid,401 token.missing | token.invalid | tools.session_required,403 capability.denied | tools.origin.refused,404 tools.tool.not_found | tools.tool.not_runnable(api: false: the YouTube downloader, the regex tester, the canvas makers…)| tools.run.file_not_found,405,409 tools.job.idempotency_conflict,413 tools.file.too_large | tools.input.too_large(overlimits.maxInputBytes),415 tools.file.unsupported_type(the bytes are checked),422 tools.input.invalid(errors[]with JSON pointers, against the descriptor's input schema),429 tools.quota.exceeded(Retry-After),503 tools.tool.unavailable(the descriptor's status isunavailable: Protect PDF while qpdf is missing),503 tools.unavailable(the engine found a program it needs missing: e.g. an HEIC upload without libheif),503 tools.busy(Retry-After). - Who. Always enforced (authorization, not a quota): an app or service token needs the descriptor's
auth.capability; the network probes (port, ping, latency) run only for tokens holdingtools.net.probe(anonymous401, a person403: people use the probe pages);auth.anonymous: falsetools (audio, PDF, SMTP) need a browser session, a sign-in or a token; a Bearer token that does not verify is401 token.invalid, not anonymity. Then the guard: the tool'squotaClass× the caller's tier, weighted bycost(mode-dependent), the per-target throttle for egress tools and upload sniffing (hard). - Where it runs. Dev and text tools with a server engine: the page's own engine code in the gateway's
worker pool (
TOOLS_WORKERS_GATEWAY, default 2, 256 MB each; the descriptor'stimeoutMsterminates a runaway input). Net tools and Open Graph: the gateway's net and dev handlers (once/api/netand/api/dev, now reachable only through the run API), in process, with the run's input as their query (myipis the caller's address). Job tools (img, audio, docs): a job of the satellite that owns the tool ({ ...input, ...preset, tool: operation }), the run streamed there by the gateway, uploads included; the job'stoolnames the tool. - Cache and replays. An inline answer is reused per tool and input where that is safe (spec
cacheTtlMs: engines 10 minutes unless random or time-dependent — uuid, lorem, timestamp, cron, jwt, zalgo, case, sort, bio, nickname never; DNS 1 minute, IP 10, RDAP/whois 60, a few other lookups 5; probes, headers, redirects, uptime, SMTP and myip never), answered withX-OV-Cache: hitand without touching the target. An inline run'sIdempotency-Keyreplays its first answer for 15 minutes (per caller); a job tool's key is its job's. - CORS. First-party pages keep the credentialed rules; any other site's page gets
Access-Control-Allow-Origin: *without credentials on the run and jobs routes (so its calls carry a token, never our cookies). GET /api/healthon the gateway showsrun(counts, cache, engine pool) andjobs(the facade).
Jobs facade (gateway)
https://openvibe.tools/api/v1/jobs… fronts every satellite's job routes (below), so one origin serves the
run API's Location, openvibe-sdk/jobs without a baseUrl, and anything else. A submit is routed by its job
type's prefix (img.process → img …): ?type=, the JSON body's type, or the multipart type part (read from
the first 64 KB of the stream — send it before the files; the uploads then stream through untouched). Every other
route (GET/DELETE /:id, /:id/events, /:id/files/:n, /:id/retry, /:id/references/:ref) is routed by the
job id: the satellite that answered its submit or run (remembered, 50,000 ids), else the one that says it holds
the job when asked on loopback (GET /api/internal/jobs/:id, direct loopback callers only, never rate
limited). Events (SSE) and files stream through. Owners, quotas, the Origin check and idempotency stay the
satellite's: the request reaches it with the caller's credentials and address.
Removed endpoints (plan T8, phase 6)
/api/process, /api/process/direct, /api/process/multi (img, audio, docs), /api/net/* and /api/dev/*
(with the /api/net/tools and /api/dev/tools lists) are gone; nothing answers them any more. Their
successors: POST /api/v1/tools/{id}/run for any tool (or the job API below for img, audio and docs), and
GET /api/v1/tools?family=… for the lists. The webhook bins (/api/dev/webhook/…, page-only) keep
their URL on their own router.
Jobs (Img, Audio, Docs)
Heavy operations run as durable asynchronous jobs (apps/_shared/jobs, roadmap Wave 11). The same
routes answer on every host of the img, audio and docs satellites:
| Route | What it does |
|---|---|
POST /api/v1/jobs |
Submit: multipart type, input (JSON text), file / files, or JSON { type, input }. Idempotency-Key header (8–200 chars). → 202 + Location; a repeat with the same key and request → 200 + Idempotent-Replayed: true (same job); same key, different request → 409 tools.job.idempotency_conflict. |
GET /api/v1/jobs/:id |
The job: state (queued, running, succeeded, failed, cancelled), progress {percent, message}, attempts, result {files, data}, error (problem+json), retryable, expires_at. |
DELETE /api/v1/jobs/:id |
Cancel: queued → 200 cancelled at once; running → 202 (signalled; ffmpeg is killed, a sharp/pdf-lib worker thread is terminated) and ends cancelled; finished → 409 tools.job.already_finished. |
GET /api/v1/jobs/:id/events |
SSE: job.queued, job.running, job.progress, job.cancel_requested, job.succeeded, job.failed, job.cancelled, each with an id. Reconnecting with Last-Event-ID (or ?last_event_id=) replays only later events; a finished job with nothing newer answers 204. |
GET /api/v1/jobs/:id/files/:n |
A result file (attachment); ?inline=1 for previews. |
POST /api/v1/jobs/:id/retry |
Retry a failed job: a new job with the same type, input and files (retry_of → the failed one, which gets retried_by) → 202 + Location. Idempotent: asking again returns that same retry with 200 + Idempotent-Replayed: true. Not failed → 409 tools.job.not_failed; inputs gone → 410 tools.job.inputs_gone. A failed job keeps its input files until it expires. |
PUT /api/v1/jobs/:id/references/:ref |
Keep a succeeded job's result while <ref> (<service>:<kind>:<id>, e.g. community:paste:p_123) points at it → 201 (200 if already there). The job then shows references and expires_at: null. At most 50 per job; not for sandbox jobs. |
DELETE /api/v1/jobs/:id/references/:ref |
Drop it → 200; after the last one the job expires one ttl later at the earliest. |
Job types: img.process (input { tool: convert|compress|resize|crop, format, quality, width, … }, one image),
audio.process ({ tool, …options }, one audio/video file; progress from ffmpeg),
docs.process ({ tool, …options }, one PDF, or several files for merge / img2pdf). A format host fills
in its format (webp.openvibe.tools converts to WebP). The run API (POST /api/v1/tools/{id}/run) submits the
same job and waits for it; the pages use jobs and keep the job id in the address (?job=) and in sessionStorage, so a
reload reattaches.
- Durable. A job and its uploaded input are in the
toolsdatabase (tool_jobs) anddata/jobs/<id>/before the202is sent. After a restart, queued jobs run; jobs that were running are re-queued (all three types, up to their attempt limit) — a type can instead declareonRestart: 'fail', which fails them withretryable: true. - Owner-scoped. A job is visible only to whoever created it: a signed-in person (
user:usr_…from theov_tokensubject), an app/service principal (Network client-credentials token, audienceopenvibe.tools, capabilitiestools.job.create|read|cancel), or else this browser'sov_tools_jobscookie (only its hash is stored). Anyone else gets404 tools.job.not_found. - Worker threads. sharp (img) and pdf-lib (docs) never run on the event loop:
apps/_shared/jobs/pool.jsruns them inworker_threads,TOOLS_WORKERSat once per satellite (default 2;TOOLS_WORKERS_<APP>), shared by the jobs and the synchronous calls that remain (docs'/api/info; they submit to the pool and wait), withTOOLS_WORKER_QUEUE(64) more waiting before503 tools.busy. Each worker has a V8 heap limit (TOOLS_WORKER_MEMORY_MB, 512;TOOLS_WORKER_MEMORY_MB_<APP>): a run that needs more fails with413 tools.input.too_largeand its worker is replaced. A run past its timeout (the job's, or the descriptor'slimits.timeoutMson the sync endpoints:504 tools.run.timeout), a cancelled job or a sync client that went away has its worker terminated at once. Idle workers stop afterTOOLS_WORKER_IDLE_MS(60 s). qpdf and poppler tools stay on the main thread (their child processes have their own timeouts).GET /api/healthshowsworkers. - Bounded.
TOOLS_JOBS_CONCURRENCYjobs run at once per satellite (default 2;TOOLS_JOBS_CONCURRENCY_<APP>overrides),TOOLS_JOBS_MAX_ACTIVEunfinished jobs per owner (default 10, then429), and all browser sessions from one address togetherTOOLS_JOBS_MAX_ACTIVE_PER_ADDRESS(default 3 × that; jobs keep only the guard's hashed address key), at mostTOOLS_JOBS_MAX_QUEUEDqueued jobs in all (default 200) andTOOLS_DISK_BUDGET_MBunderdata/jobs(default 8192) before a submit answers503 tools.busywithRetry-After(through the guard, below), plus the guard's quotas on submit. - Retention. Finished jobs expire after 1 hour (browser sessions) or 24 hours (signed-in people, principals);
the pruner deletes the row, its events, its files and its Media objects. It never touches a job that has a
reference (above). If Media will not delete a result object (a retention hold,
409 media.object.held), cannot be reached, or is no longer configured here, the job is kept, record and all, and looked at again 24 hours later, so no object is left without the record that would delete it. - Results in OpenVibe.Media when
TOOLS_JOB_RESULTS=media: each result file becomes a private Media object (v2 object API, namespacetools, ownerX-OV-Subjectfor signed-in people) and the job's result carries itsmedia.media_id; previews redirect to a short-lived signed Media URL, downloads stream through the satellite. This needsOV_OAUTH_CLIENT_SECRETand Network grants for thetoolsclient:media.object.uploadandmedia.object.readfor audienceopenvibe.media, namespacetools, and atoolstenant in Media. Without them (or if an upload fails) results stay on local disk and the file saysstorage: "local". Default:local. - Lifecycle events to OpenVibe.Events when
EVENTS_URLis set (apps/_shared/jobs/events.js):tools.job.created(submit or retry; actor the owner),tools.job.started(a worker claimed it; a job re-queued after a restart is announced again when it starts),tools.job.succeededandtools.job.failed(payloadsopenvibe-contractstools.job.*@1, validated before they are queued; subjectjob <id>, visibilityinternal, sourcetools). Each is written to the shared PostgreSQLevent_outboxtable (openvibe-sdkcreatePgOutbox) in the same transaction as the state change, so an event exists exactly when its transition committed, and relayed toEVENTS_URL/api/v1/events(at least once; Events dedupes onevent_id). Payloads carry ids, type, owner, state, attempts, times, where result files are (index, mime, size, sha256,localor a Mediamedia_id) and the error (status,code,detailwith server paths and the job's file names taken out) — never the input, file names, output data or a browser session: a session's job hasowner: null. Cancelled jobs, progress and sandbox app jobs are not announced; an Idempotency-Key replay creates no job and no event. The relay uses thetoolsclient (OV_OAUTH_CLIENT_ID,OV_OAUTH_CLIENT_SECRET) withevents.event.publishon audienceopenvibe.events. WithoutEVENTS_URL(or withEVENTS_PUBLISH=off, or no client secret) nothing is written or sent.GET /api/healthshowsjobs.events(pending, rejected). - Project usage to OpenVibe.Events on the same outbox (
apps/_shared/jobs/usage.js, roadmap WS-N task 4): a developer app's job (it carries a project) that ends is counted in the transaction that records its end, per project, environment, capability (tools.tool.runfor a tool run,tools.job.createforPOST /api/v1/jobs), job type or tool and UTC hour: the jobs that ended (succeeded or failed; cancelled ones are not counted) and the failed ones by problem code, with the last ten failures' job id, status and the trace id of the request that submitted the job (tool_jobs.trace_id). A minute after an hour closes, the pruner's timer writes each rollup once astools.usage.recorded(common.usage-recorded@1, openvibe-contracts 0.63.0; subject the project, visibilityinternal, prioritylow) in the transaction that marks it sent. Sandbox jobs count underenv: sandbox. No owner, session, address, input, file name or output leaves. OpenVibe.Network adds the rollups up for the project's usage page on openvibe.services. Off withoutEVENTS_URL, like the job events;GET /api/healthshowsjobs.usage(pending, invalid). - Usage readings to OpenVibe.Billing (
apps/_shared/billing.js,apps/_shared/jobs/usage.js, plan T5 step 7): every job that succeeds or fails (anyone's, not only a project's; a cancelled one is not billed) gets oneplatform.usage-sample@1reading — idtools-job-<id>,idempotency_keytools:job:<id>, servicetools(the Contracts manifest id, so Billing keys ratings and budgets on the reading) and sourceopenvibe.tools, operationtools.tool.run(a tool run) ortools.job.create, resource the tool or job type, quantity 1, unitjobs,atthe job's end, plusprojectandtrace_idwhen the job has them andsubject(user:usr_…) only for a signed-in person's job, never an app or a browser session. It is stored intool_job_billing_readingsin the transaction that records the end (no network call there); the pruner's timer posts the unsent ones (500 at most a tick) toPOST /api/v1/usageand marks each sent. A refused post is logged, never thrown: the reading keeps its error and attempt count and is posted again on the next tick (Billing answers a replay with 200), and an outage, a 401/403, a 429 or a refused token stops that tick's batch. The hourlytools.usage.recordedrollups above are unchanged and stay subject-less. Off withoutOV_BILLING_URL(nothing is stored). The token is Networkclient_credentialsfor thetoolsclient on audienceOV_BILLING_AUDIENCE(defaultopenvibe.billing); Network must grant that clientbilling.usage.recordon it — until the grant exists every token request is refused (400 invalid_scope) and only logged, and the readings wait in the table.GET /api/healthshowsjobs.billing(pending, posted, refused, invalid, last_error) when it is on.
Environment (all in /etc/openvibe/tools.env): TOOLS_WORKERS, TOOLS_WORKERS_<APP>, TOOLS_WORKER_MEMORY_MB,
TOOLS_WORKER_MEMORY_MB_<APP>, TOOLS_WORKER_QUEUE, TOOLS_WORKER_IDLE_MS, TOOLS_JOBS_CONCURRENCY, TOOLS_JOBS_CONCURRENCY_<APP>,
TOOLS_JOBS_MAX_ACTIVE, TOOLS_JOB_RESULTS, TOOLS_MEDIA_NAMESPACE, OV_MEDIA_INTERNAL_URL, OV_MEDIA_URL,
OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET, EVENTS_URL, EVENTS_PUBLISH,
EVENTS_RELAY_INTERVAL_MS, OV_BILLING_URL, OV_BILLING_AUDIENCE.
Not done yet: quotas for external developer apps are the guard's service and sandbox tiers, not a Codes-issued quota.
Guard (anti-abuse, apps/_shared/guard)
One module used by the gateway and every satellite, driven by each tool's descriptor (quotaClass, cost, auth,
files.accept, limits) and the numbers in apps/_shared/guard/limits.js (the one place they live).
- Who is asking (
caller.js, replacing the four copiedauth.jsfiles): a service or app principal (Bearer client-credentials token, audienceopenvibe.tools; a developer app's sandbox token is tiersandbox), a user (theov_tokencookie or a Bearer Network token, verified offline, issuer and audienceopenvibe.tools— the Network puts that audience on every browser sign-in; its/internal/issue-tokentokens carry none and are not believed), a session (this browser'sov_tools_jobscookie; the page's/api/contextcall and the first job or webhook bin start one) or else anonymous, counted by address (IPv6 by its /64). Tiers: anonymous < session < user < service. - One address source. Every app sets
trust proxytoTRUST_PROXY: exactly one hop, and only on loopback (the host's nginx, which setsX-Forwarded-Forfrom$remote_addrafter Cloudflare's real IP).req.ipis the only address anything reads. The gateway's proxy to a satellite passes its ownreq.ipas that one hop; food passes its visitor's to maps. A first-party service calling on loopback is identified by its service token (each has its own bucket at the service tier); without one it is anonymous, counted by the address it forwarded or its own. - Quotas. A token bucket per quota class × tier (
perMinute,burst) and a UTC-day allowance in Valkey when configured (process-local otherwise), both counting the tool'scost. Browser sessions from one address share 3 × one session's allowance, so dropping the cookie never resets anything. Counted answers carryRateLimit-Limit/-Remaining/-Reset; a refusal is429problem+jsontools.quota.exceededwithRetry-Afterandquota_class,scope,tier. - Heavy work. A synchronous heavy call (docs'
/api/info) holds one ofTOOLS_SYNC_CONCURRENCYslots (default 2;TOOLS_SYNC_QUEUEmay wait, 8, forTOOLS_SYNC_WAIT_MS, 30 s), else503 tools.busy. Every sync path is stopped at the descriptor'stimeoutMs(504 tools.run.timeout) or when the client goes away: audio's ffmpeg is killed, img's and docs' worker thread is terminated (Jobs → Worker threads). Liveness and readiness probes (GET /api/health,/api/ready) are never rate limited. - Uploads. The bytes are checked against the descriptor's
files.accept(415 tools.file.unsupported_type; a misleading extension is corrected); docs uploads go to disk. Tools whose descriptor saysauth.anonymous: false(audio, PDF) need a session, a sign-in or a token (401 tools.session_required). Images: no input overTOOLS_MAX_INPUT_PIXELS(40 MP, as Media) is decoded (header first, sharplimitInputPixelsbehind it). - ffmpeg. Every input and probe:
-protocol_whitelist file,pipe,-format_whitelistof the operation's demuxers (never hls, concat, image2, lavfi…),-t= the descriptor'slimits.maxDurationSec(3 h); inputs are probed first and a longer one is refused (413). - Egress. A per-target throttle across all callers and tools (
limits.perTargetPerMinute; a host's minute is shared by headers, SSL, ping…); the port checker takes at most 20 ports a request and 100 ports / 10 hosts per caller in 10 minutes (sessions counted by address). Probes through a token needtools.net.probe. Webhook bins belong to their maker (session, sign-in or token; anyone else gets 404), at most 5 per owner and 10 per address. YouTube downloads belong to whoever started them. Nominatim is paced to one request a second; Overpass waits are capped. - Abuse log. Every refusal, and every would-be refusal in report mode, goes to PostgreSQL
guard_abuse: time,HMAC-SHA256of the address (or /64) with a random daily salt (only today's is kept, so older hashes cannot be tied to anything), principal or user id, tool, reason, enforced or not; repeats within a minute are one row with a count; kept 30 days. No raw address is stored anywhere (pseudonymous, ADR-021). Metrics:tools_guard_refused_total{reason,tool},tools_guard_enforcing,tools_guard_sync{kind}. - Challenge hook.
createGuard({ challenge })takes a person-check provider (required,verify; Turnstile later); the default challenges nobody. - Cross-site requests (CSRF). A mutation that rides on the browser's cookies (
ov_token, the jobs session) must come from an OpenVibe.Tools page:Origin(elseReferer)https://openvibe.tools,https://*.openvibe.tools, one of the tool's own hosts (custom domains included) or the request's own host. Calls with a Bearer token, calls without those cookies and requests with neither header pass. Checked on the run API, the job routes' writes,/api/info,/api/probeand the webhook bins' create and delete;403 tools.origin.refused, recorded as reasonorigin. - Mode.
TOOLS_GUARD=enforce(default, production) refuses what is past a limit with429problem+jsontools.quota.exceeded.TOOLS_GUARD=report(local debugging) records and counts what it would refuse and refuses nothing. Hard limits apply in both modes: the pixel limit, ffmpeg's whitelists and duration cap, upload sniffing, the port-scan cap and the per-target throttle.
Default quotas (units = descriptor cost per run; perMinute / burst / perDay, 0 = none):
| Class | anonymous | session | user | service | sandbox |
|---|---|---|---|---|---|
tools-api (every /api/ call, cost 1) |
120 / 60 / – | 180 / 90 / – | 480 / 240 / – | 2400 / 1200 / – | 60 / 30 / – |
tools-run (text, dev engines) |
120 / 60 / 5000 | 180 / 90 / 10000 | 480 / 240 / 50000 | 2400 / 1200 / 500000 | 60 / 30 / 1000 |
tools-fetch (lookups, Open Graph) |
60 / 30 / 2000 | 90 / 45 / 4000 | 240 / 120 / 20000 | 1200 / 600 / 200000 | 30 / 15 / 500 |
tools-probe (port, ping, latency) |
30 / 15 / 600 | 45 / 25 / 1000 | 120 / 60 / 5000 | 600 / 300 / 50000 | 15 / 10 / 200 |
tools-job (img, audio, docs) |
60 / 30 / 1500 | 90 / 45 / 3000 | 240 / 120 / 20000 | 1200 / 600 / 200000 | 30 / 15 / 300 |
tools-map (maps, food) |
60 / 40 / 6000 | 90 / 60 / 8000 | 240 / 120 / 20000 | 1200 / 600 / 200000 | 30 / 20 / 500 |
tools-download (yt, cost 50) |
50 / 100 / 1500 | 75 / 150 / 2500 | 150 / 300 / 5000 | 300 / 600 / 10000 | 25 / 50 / 100 |
Environment: TOOLS_GUARD, TOOLS_GUARD_LIMITS (JSON merged into the table, e.g.
{"tools-job":{"anonymous":{"perDay":800}}}), TOOLS_SYNC_CONCURRENCY, TOOLS_SYNC_QUEUE, TOOLS_SYNC_WAIT_MS,
TOOLS_JOBS_MAX_QUEUED, TOOLS_JOBS_MAX_ACTIVE_PER_ADDRESS, TOOLS_DISK_BUDGET_MB, TOOLS_MAX_INPUT_PIXELS, TOOLS_PORTS_PER_CALLER,
TOOLS_PORT_TARGETS_PER_CALLER, AUDIO_MAX_DURATION, OV_TOOLS_AUDIENCE (white-label installs).
Per-actor limits (apps/_shared/actor-limits.js)
Roadmap WS-R task 4, openvibe-sdk/limits, one limiter per app, only where the guard has no per-caller
limit of its own. The guard, its quotas and TOOLS_GUARD are unchanged; these limits
read the guard's resolved caller (guard.caller): a person as user:usr_…, a service or app by its
principal, a browser session or nobody by the guard's hashed address key (ip:<HMAC>, never a raw
address). Past a limit: 429 problem+json rate_limited with Retry-After, one [Limits] log line and
tools_rate_limited_total{limit,window}.
| Where | Routes | Per caller, a minute / an hour |
|---|---|---|
| every app | Registry reads GET/HEAD /api/v1/tools[/:id[/schema]] by a signed-in person or a third-party principal (app:…, mod:…) |
TOOLS_LIMITS_MINUTE / TOOLS_LIMITS_HOUR (120 / 3000) |
| gateway, img, audio, docs | Backstop on job submits and retries (POST /api/v1/jobs, …/:id/retry) and runs (POST /api/v1/tools/:id/run) |
a person or an address 600 / 20 000; a principal 3000 / 100 000 |
| gateway | GET /api/internal/analytics (the Network admin's analytics page, with a Network service token) |
30 / 300 |
Signed-out registry reads keep only the per-address /api/ limit (many visitors share a carrier or campus
address); a first-party service (svc:…) reading for itself is not counted. The backstop sits above the
guard's highest allowances (tools-run: a person 480 a minute, a service 2400, one address's sessions
together 540; the day allowances average at most about 2100 and 21 000 an hour), so the guard still
decides every normal caller's allowance, and a runaway caller is stopped even while the guard only
reports. A run the gateway streams to a satellite is counted there again. Never limited: /api/health,
/api/ready, /release.json, /metrics, the pages, /api/internal/jobs/:id and the signed
/internal/events deliveries. Tests: apps/_shared/test/actor-limits.test.js (fixed clock) and
apps/gateway/test/actor-limits.test.js (the real gateway).
Canonical hosts and the service registry
Every satellite honours the gateway's X-OV-Tool / X-OV-Host-Role / X-OV-Canonical-Host / X-OV-Short-Host
headers (apps/_shared/host-role.js): canonical links, og:url and JSON-LD use the canonical host (a custom
domain included), a custom domain gets its tool's page through X-OV-Tool, aliases the gateway missed are
redirected, and a host a satellite does not serve goes to the tools index. Reached directly, each host is its
own canonical as before.
The gateway resolves the other services' origins (Community for pastes, the "elsewhere on OpenVibe" links)
through Network's registry, GET /api/v1/registry/services (fetched on boot and every 10 minutes; the last good
answer is kept). Until it answers, a local list is used and /api/catalog.json says services.source: "fallback".
Services the registry marks placeholder or retired are not linked. OV_REGISTRY_URL overrides the URL.
Metrics and readiness
Every server (gateway and satellites) mounts apps/_shared/observe.js with openvibe-shared/metrics and
openvibe-shared/ready:
GET /metrics— Prometheus text for direct loopback callers only (127.0.0.1/::1 with noX-Forwarded-For/X-Real-IP); anything through nginx or the gateway gets 404, and each nginx server block also answerslocation = /metricswith 404. HTTP golden signals are labelled by route template (/api/v1/jobs/:id), proxied satellite traffic on the gateway by satellite (proxy:img), SPA pages and static files by a fixed label — never the raw URL.release_info{service="tools"|"tools-<app>"}, process metrics, and on img/audio/docstools_jobs{app,state}(a group-by on the shared job store) andtools_jobs_executing{app,kind="executing"|"limit"}.GET /api/ready— named checks withstatus,required,latency_ms,checked_at; HTTP 503 only when a required check fails, otherwise 200 with failed optional checks listed indegraded./api/healthis unchanged.- Request telemetry — counted by route template, method and status class, not raw URL;
/shared, static assets, health, readiness and/metricsdo not count. Once a minute onehttp.requestsample per key plus the active- requests, p95 latency and event-loop-lag gauges goes totelemetry_samples(openvibe-sdk 0.46,platform.telemetry-sample@1,migrations/0006_telemetry.sql); the gateway alone deletes rows older than 14 days daily. Wired byapps/_shared/observe.js; eachserver/index.jscallstelemetry.init()with its service id (tools,tools-img, …) into the onetoolsdatabase.
| Server | Required | Optional (degraded when failing) |
|---|---|---|
| gateway (4001) | catalog, tools_db |
network_key, service_directory, community, satellite_<app>, tool_registry |
| img, docs (4012, 4016) | tools_db, job_runtime, data_dir, uploads_dir, output_dir |
network_key, media_results (when enabled); img: heif_decoder; docs: qpdf, pdftoppm, pdfinfo |
| audio (4014) | same as img | same as img, plus ffmpeg |
| yt (4013) | tools_db, downloads_dir |
yt_dlp, ffmpeg, yt_cookies (when set), network_key |
| food (4011) | tools_db, maps |
— |
| maps, text (4010, 4015) | tools_db |
— |
TOOLS_SATELLITE_PORTS="img=5012,…" overrides the gateway's satellite ports (tests, a moved unit); every app reads
it (apps/_shared/tools/satellites.js), so set it for all units if a port moves.
Releases (ADR-016). Every app mounts openvibe-shared's release.mount (apps/_shared/release.js): GET /release.json and POST /release-metrics (the shared navbar's release-watch outcomes →
release_client_updates_total on /metrics). The gateway, img, audio and docs (which pin openvibe-contracts) serve
manifest 1.1.0 components: shell (what their pages are made of: public/, the server files that stamp the pages,
the job helper) and server (the rest), so a release that changes only API code no longer reloads open tabs. Maps,
food, text and yt serve the 1.0.0 fields.
Tests
npm test (Node 22) syntax-checks every server file and runs every apps/*/test/*.test.js, each in its own
process: the job runtime end to end (restart, reattach, cancel, idempotency, owner scoping, SSE resume, pruning,
Media results with a stand-in Media), img/audio/docs as real processes killed with SIGKILL mid-job, canonical
hosts on every satellite, the registry-driven catalog, and registry-consistency.test.js: every catalogue
tool resolves to an operation, endpoint or page that exists, or is marked unavailable. descriptors.test.js
holds every descriptor to the contracts and to the code (a job's operation, validation, files and limits; a sync
tool's route), engines.test.js runs every browser tool's server engine in plain Node against its output schema,
and tools-api.test.js checks the three routes on the gateway and all seven satellites. run-api.test.js runs
the run API on the gateway, img and docs (with a stand-in Network for tokens, apps/_shared/test/network.js):
every answer against tools.run@1, the refusal codes, probes, quotas, idempotency, job tools and references
across satellites, the Origin check and the deprecation headers; jobs-facade.test.js routes jobs through the
gateway (by type, by id after a restart, events, files, retry, cancel); sdk-client.test.js drives
the pinned openvibe-sdk's createToolsClient against the real gateway (it requires a checkout of OpenVibe.SDK next to
this repo, or OV_SDK_DIR, and says it skipped without one); pool.test.js (shared and docs) hold the worker
pool to its limits. The audio tests need
ffmpeg and skip without it. The docs and img tests always check the 503 path with qpdf, poppler and libheif
pointed at nothing, and also run the real encrypt/decrypt, page rendering and HEIC decoding when those programs
are installed (or QPDF_PATH / HEIF_DEC_PATH point at them). Install first with npm run install:all.
Analytics (ADR-021)
All eight apps store visit analytics in the shared PostgreSQL database through
openvibe-shared/analytics/pg (ADR-021). Raw events include the service, route template,
method, status, response time, session identifier and coarse visitor metadata. GPC and DNT opt-outs
are excluded. Daily and hourly rollups remain after raw events expire.
- Retention: the application prunes raw rows older than 30 days; rollups stay.
- CLI:
node scripts/analytics-prune.jscounts old rows by service. Pass--days Nto choose retention and--applyto delete them. It requiresDATABASE_URLand applies to every service.
Host packages some tools need
A few operations run a program that is not part of Node. Each satellite looks for it at boot (on PATH,
or at the path in its environment variable) and again every minute while it is missing; until it is
there, the operation answers 503 problem tools.unavailable ("this tool is being set up") on
the run API and at job submit, its page says so instead of offering the upload, and /api/ready
lists it under degraded. Nothing pretends to work.
| Package (Ubuntu) | Program | Used by | Override |
|---|---|---|---|
qpdf |
qpdf |
Protect PDF (AES-256), Unlock PDF | QPDF_PATH |
poppler-utils |
pdftoppm, pdfinfo |
PDF to image | PDFTOPPM_PATH, PDFINFO_PATH |
libheif-examples + libheif-plugin-libde265 |
heif-dec (or heif-convert) with an HEVC decoder |
HEIC (iPhone) photos on every image tool | HEIF_DEC_PATH |
ffmpeg |
ffmpeg, ffprobe |
every audio tool | FFMPEG_PATH |
mtr-tiny (0.95+) |
mtr |
Traceroute, MTR (on the gateway, which answers 502 trace_unavailable without it) |
NET_MTR_BIN |
sharp's prebuilt libvips cannot decode HEIC (HEVC) at all, and it cannot read or write BMP or read ICO:
BMP and ICO are handled in JavaScript (apps/img/server/tools/codec.js), HEIC by libheif's CLI.
Limits (all in /etc/openvibe/tools.env): PDF_MAX_PAGES (500 pages per document, every PDF tool),
PDF2IMG_MAX_PAGES (50 pages per conversion up to 150 dpi; 40 % of that up to 300 dpi, 10 % above),
YT_MAX_DURATION (seconds, default 3 hours), YT_MAX_FILESIZE_MB (per downloaded part, default 2048),
YT_INFO_CONCURRENCY (yt-dlp lookups at once for /api/info, default 3; 20 more wait, then 503).
Net tools: NET_IPINFO_TOKEN makes IP lookups use ipinfo.io over HTTPS instead of ip-api.com's free tier
(HTTP only); ip-api/ipinfo, RDAP and DNS-over-HTTPS answers are cached either way. NET_GLOBALPING_TOKEN
is read but not used yet. Traceroute and MTR run mtr in report mode without raw sockets: its mtr-packet
sends ICMP echo through Linux's unprivileged ICMP sockets (net.ipv4.ping_group_range), on the address the SSRF guard
checked, at most two runs at once (apps/gateway/server/net/trace.js, NET_MTR_BIN). Domain Reputation is one report
from public signals (blacklists, SPF, DMARC, MX, HTTPS, domain age, reverse DNS), each finding with its cost. Ping and
latency are timed TCP connections to port 443 from this server.
YouTube downloader: when YouTube refuses the server
YouTube blocks some datacentre addresses outright ("Sign in to confirm you're not a bot"). The app
checks every 30 minutes (GET /api/health → youtube: ok | blocked | unknown) and shows a banner
while blocked. Player-client switches, IPv6, proof-of-origin tokens, the nightly yt-dlp and Cloudflare
WARP were all tried on 2026-09-18 and all refused; what works is a different network identity, set in
/etc/openvibe/tools.env and followed by sudo systemctl restart openvibe-tools-yt:
YT_PROXY=socks5://user:pass@host:port— a residential or mobile proxy (http, https, socks4/5).YT_COOKIES_FILE=/etc/openvibe/yt-cookies.txt— a Netscape cookies.txt exported from a throwaway YouTube account, readable by the service user. Accounts used this way can be suspended.
Dev quickstart
# install every app's dependencies (openvibe-shared comes from the pinned release)
npm run install:all
# gateway — http://localhost:4001
cd apps/gateway && cp .env.example .env # set OV_OAUTH_CLIENT_SECRET
npm run start:gateway
# any satellite, e.g. maps on http://localhost:4010
npm run start:maps
Host-header routing without DNS:
curl -H 'Host: net.openvibe.tools' http://localhost:4001/
The full OAuth round-trip needs OpenVibe.Network running on port 4000
(BOOTSTRAP_PROFILE=local-dev seeds a tools client with a
http://localhost:4001/auth/callback redirect).
Auth model
GET /auth/loginon the gateway → Network/oauth/authorize(client_idtools, scopeprofile theme, state cookie).GET /auth/callback→ server-side code exchange → setsov_token(JS-readable,Domain=.openvibe.tools, SameSite=Lax, Secure) andov_refresh(httpOnly, Path=/auth).GET /auth/login?silent=1&next=…addsprompt=none: the shared navbar uses it for one silent sign-in attempt per tab when the browser carriesov_sso_hint=account. No Network session → straight back tonextwith?sso=none. A successful callback setsov_sso_hint=account(1 year, JS-readable,Domain=.openvibe.tools);/auth/logoutsets it toguest.nextmust be a relative path, anhttps://*.openvibe.toolsURL or anhttps://openvibe.network/...URL (the Network's sign-in/sign-out everywhere chain hops through the gateway).GET /auth/me,POST /auth/refresh,GET /auth/logout.- Satellites never talk OAuth — they read
ov_tokenand verify offline via the Network JWKS (GET /api/.well-known/jwks). - Browser pages load the shared files (theme-loader, navbar, …) from their
own app's pinned copy at
/shared/(apps/_shared/release.js,openvibe-shared/serve), not from openvibe.network.
Security
Reporting a vulnerability: SECURITY.md. The rules are in Guard
and Auth model; in short: every caller is identified (principal, user with audience
openvibe.tools, session or address) and held to per-tier quotas and per-actor limits; uploads are
sniffed against the descriptor; every tool that reaches a visitor-chosen host goes through the SSRF guard
(apps/_shared/egress.js: public addresses only, checked after DNS, redirect hops re-checked); trust proxy
is one loopback hop; sign-in next accepts only relative paths, *.openvibe.tools and openvibe.network.
Secrets (OV_OAUTH_CLIENT_SECRET, TOOLS_EVENTS_SECRET, NET_IPINFO_TOKEN, RIDB_API_KEY, NPS_API_KEY,
OPEN_CHARGE_MAP_KEY, YT_COOKIES_FILE) live in /etc/openvibe/tools.env
(0600), by name only.
Deploy
Production deploys with sudo ovhost deploy tools (deploy/scripts/deploy.sh is the wrapper; strategy
multi-app, described under Registry, hosts and domains). Rollback: automatic
when the gateway's /api/ready or /release.json does not come up with every unit active; afterwards
sudo ovhost rollback tools --to <sha>. Nothing blocks a rollback: the schema code only adds.
- Production path:
/opt/openvibe.tools(apps underapps/<name>) - Env file:
/etc/openvibe/tools.env(0600) — shared by all units; per-appPORTis set in each systemd unit, so tools.env must NOT define PORT. - Units:
openvibe-tools.service(gateway) andopenvibe-tools-<name>.serviceper satellite (apps/<name>/deploy/systemd/). Resource bounds:MemoryMax=2G(img, audio, docs),1G(yt),768M(gateway, text, maps, food);TasksMax=256;Nice=5for img, audio, docs and yt. The gateway data directory remains writable for local runtime files. - Nginx: satellites have specific
server_nameblocks; the gateway's wildcard*.openvibe.toolsblock catches everything else. TLS via/etc/letsencrypt/live/openvibe.tools/.
Database and guard state
All eight apps use one PostgreSQL database through openvibe-sdk/db. Production requires
DATABASE_URL; DATABASE_DIRECT_URL lets the owner apply the immutable SQL files in migrations/
at boot. Development and tests use embedded PGlite through the same API. Job records, abuse logs,
revocations, analytics, and the event outbox live in PostgreSQL. The guard's transient throttle
counters and salt use Valkey through VALKEY_URL, with process-local state when it is unset.
- openvibe-contracts: v0.215.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2