openvibers/openvibe.contracts Commits

v0.33.0: Tools platform API contracts: tools.tool@1 descriptor, tools.tool-list@1, tools.run-request@1, tools.run@1, tools.job@1, tools.job-request@1; capabilities tools.tool.read|run and tools.net.probe; tools.job.* routes and schemas; tools manifest 0.4.0

OP OpenVibers committed 23 Sept 2026 · 7e0964c01b · 90 files, +5389 −25

tools.tool@1 describes one catalogue tool:
- id, family, name, summary, and status (stable|beta|preview|unavailable, plus statusReason).
- execution (client|sync|job) and api (whether POST /api/v1/tools/{id}/run exposes it).
- run: { method POST, path, job { type, operation, preset? } | null, legacy? }.
- input: a JSON Schema, embedded or { $ref } to /api/v1/tools/:id/schema#/$defs/input.
- files { min, max, accept, maxBytes } | null.
- output { kind json|file|files|text, schema?, mime? }.
- limits: timeoutMs, and optionally maxDurationSec, maxPixels, maxPages, maxInputBytes, perTargetPerMinute.
- auth { anonymous, capability tools.tool.run|tools.net.probe }.
- quotaClass, cost, egress, hosts, docs.

The schema enforces these rules, held in a single top-level if/then so the generated type stays a plain interface:
- api false means run null; api true means a run and an input schema.
- Only job tools name a job, and an API job tool always does.
- File output through the API comes from a job.
- A probe fetches and is never anonymous.
- An anonymous egress tool declares a per-target throttle.
- A client tool never fetches.
- unavailable needs statusReason.
- JSON output has a schema.

contracts.tools (lib/tools.js) has three helpers:
- checkDescriptor adds the rules that depend on the id: run.path, $ref targets, files.min <= max, legacy never lists the run API, no tier words in quotaClass.
- checkList checks every descriptor, unique ids and hosts, and the counts.
- jobInput returns { ...input, ...preset, tool: operation }.

Run contracts:
- tools.run-request@1 is { input, files: [{ media_id } | { job_id, index }], wait_ms 0-60000, idempotency_key }, as JSON or multipart like POST /api/v1/jobs.
- tools.run@1 is succeeded (result data|text|files) or failed/cancelled (a problem), with took_ms and the job when there is one; or queued/running with the job and its location.
- tools.job@1 is exactly system.view() in apps/_shared/jobs, including the Media fields a result file carries, plus an optional tool.
- tools.job-request@1 is the POST /api/v1/jobs body.

Capabilities (planned until Tools serves the routes):
- tools.tool.read: public, tools-read. GET /api/v1/tools, /:id and /:id/schema.
- tools.tool.run: public, tools-run.
- tools.net.probe: partner, tools-probe, so only a staff-set project allowance grants it.
- tools.job.create adds the retry and references routes and declares tools.job-request@1 -> tools.job@1; tools.job.read and tools.job.cancel declare tools.job@1.

Tools manifest 0.4.0: the new capabilities, ready /api/ready, and notes corrected (results in Media, job events published, no jobs routes on the gateway yet, ADR-027 plans). tools.job.* payload contracts are active; Tools emits them in production.

Fixtures for all six contracts, one invalid descriptor per rule. test/run.js checks the descriptor rules, the capability and descriptor agreement, that run result files are identical to the job's, the shared Idempotency-Key and job type patterns, and the list checks.

Changes

ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 9232fec7..1fedb08e 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -725,7 +725,7 @@
"schema": "events/payloads/tools.job.created.v1.json",
"visibility": "first-party",
"compatibility": "backward",
-- "status": "planned"
++ "status": "active"
},
{
"id": "tools.job.started",
@@@ -734,7 +734,7 @@
"schema": "events/payloads/tools.job.started.v1.json",
"visibility": "first-party",
"compatibility": "backward",
-- "status": "planned"
++ "status": "active"
},
{
"id": "tools.job.succeeded",
@@@ -743,7 +743,7 @@
"schema": "events/payloads/tools.job.succeeded.v1.json",
"visibility": "first-party",
"compatibility": "backward",
-- "status": "planned"
++ "status": "active"
},
{
"id": "tools.job.failed",
@@@ -752,7 +752,7 @@
"schema": "events/payloads/tools.job.failed.v1.json",
"visibility": "first-party",
"compatibility": "backward",
-- "status": "planned"
++ "status": "active"
},
{
"id": "live.stream.started",
@@@ -862,6 +862,66 @@
"visibility": "first-party",
"compatibility": "backward",
"status": "active"
++ },
++ {
++ "id": "tools.tool",
++ "version": "1.0.0",
++ "owner": "tools",
++ "schema": "tools/tool.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-027"
++ },
++ {
++ "id": "tools.tool-list",
++ "version": "1.0.0",
++ "owner": "tools",
++ "schema": "tools/tool-list.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-027"
++ },
++ {
++ "id": "tools.run-request",
++ "version": "1.0.0",
++ "owner": "tools",
++ "schema": "tools/run-request.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-027"
++ },
++ {
++ "id": "tools.run",
++ "version": "1.0.0",
++ "owner": "tools",
++ "schema": "tools/run.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-027"
++ },
++ {
++ "id": "tools.job",
++ "version": "1.0.0",
++ "owner": "tools",
++ "schema": "tools/job.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-027"
++ },
++ {
++ "id": "tools.job-request",
++ "version": "1.0.0",
++ "owner": "tools",
++ "schema": "tools/job-request.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-027"
}
]
}
ddiff --git a/contracts/events/payloads/tools.job.created.v1.json b/contracts/events/payloads/tools.job.created.v1.json
iindex 507964d1..8fd51561 100644
---- a/contracts/events/payloads/tools.job.created.v1.json
++++ b/contracts/events/payloads/tools.job.created.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/tools.job.created.v1.json",
"title": "ToolsJobCreatedPayload",
-- "description": "tools.job.created v1 (OpenVibe.Tools apps/_shared/jobs, planned). A job was accepted and queued (POST /api/v1/jobs, or a retry of a failed job, which carries retry_of). An Idempotency-Key replay creates no job and no event. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor the owner when it is a subject (user, service, app or mod), else service:tools.",
++ "description": "tools.job.created v1 (OpenVibe.Tools apps/_shared/jobs/events.js). A job was accepted and queued (POST /api/v1/jobs, or a retry of a failed job, which carries retry_of). An Idempotency-Key replay creates no job and no event. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor the owner when it is a subject (user, service, app or mod), else service:tools.",
"type": "object",
"required": ["job_id", "service", "type", "type_version", "state", "owner", "attempts", "max_attempts", "created_at", "retry_of"],
"additionalProperties": false,
ddiff --git a/contracts/events/payloads/tools.job.failed.v1.json b/contracts/events/payloads/tools.job.failed.v1.json
iindex 8db8e203..18ed4d5b 100644
---- a/contracts/events/payloads/tools.job.failed.v1.json
++++ b/contracts/events/payloads/tools.job.failed.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/tools.job.failed.v1.json",
"title": "ToolsJobFailedPayload",
-- "description": "tools.job.failed v1 (OpenVibe.Tools apps/_shared/jobs, planned). The job failed: the tool refused the input, it timed out, or it was running when the satellite restarted and its type does not requeue. A cancelled job is not a failure and is not announced as one. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.",
++ "description": "tools.job.failed v1 (OpenVibe.Tools apps/_shared/jobs/events.js). The job failed: the tool refused the input, it timed out, or it was running when the satellite restarted and its type does not requeue. A cancelled job is not a failure and is not announced as one. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.",
"type": "object",
"required": ["job_id", "service", "type", "type_version", "state", "owner", "attempts", "max_attempts", "created_at", "retry_of", "started_at", "finished_at", "expires_at", "error", "retryable"],
"additionalProperties": false,
ddiff --git a/contracts/events/payloads/tools.job.started.v1.json b/contracts/events/payloads/tools.job.started.v1.json
iindex d7069558..4f2371df 100644
---- a/contracts/events/payloads/tools.job.started.v1.json
++++ b/contracts/events/payloads/tools.job.started.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/tools.job.started.v1.json",
"title": "ToolsJobStartedPayload",
-- "description": "tools.job.started v1 (OpenVibe.Tools apps/_shared/jobs, planned). A worker claimed the job and it is running (attempts counts this attempt). A job requeued after a restart starts again and is announced again. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor service:tools.",
++ "description": "tools.job.started v1 (OpenVibe.Tools apps/_shared/jobs/events.js). A worker claimed the job and it is running (attempts counts this attempt). A job requeued after a restart starts again and is announced again. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor service:tools.",
"type": "object",
"required": ["job_id", "service", "type", "type_version", "state", "owner", "attempts", "max_attempts", "created_at", "retry_of", "started_at"],
"additionalProperties": false,
ddiff --git a/contracts/events/payloads/tools.job.succeeded.v1.json b/contracts/events/payloads/tools.job.succeeded.v1.json
iindex c14af7c6..818cf384 100644
---- a/contracts/events/payloads/tools.job.succeeded.v1.json
++++ b/contracts/events/payloads/tools.job.succeeded.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/tools.job.succeeded.v1.json",
"title": "ToolsJobSucceededPayload",
-- "description": "tools.job.succeeded v1 (OpenVibe.Tools apps/_shared/jobs, planned). The job finished and its result files are stored (in Media when TOOLS_JOB_RESULTS=media). Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.",
++ "description": "tools.job.succeeded v1 (OpenVibe.Tools apps/_shared/jobs/events.js). The job finished and its result files are stored (in Media when TOOLS_JOB_RESULTS=media). Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.",
"type": "object",
"required": ["job_id", "service", "type", "type_version", "state", "owner", "attempts", "max_attempts", "created_at", "retry_of", "started_at", "finished_at", "expires_at", "result"],
"additionalProperties": false,
ddiff --git a/contracts/tools/job-request.v1.json b/contracts/tools/job-request.v1.json
nnew file mode 100644
iindex 00000000..71b30c52
---- /dev/null
++++ b/contracts/tools/job-request.v1.json
@@@ -0,0 +1,14 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/job-request.v1.json",
++ "title": "ToolsJobRequest",
++ "description": "Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner) or a rate limit, 503 tools.job.unavailable.",
++ "type": "object",
++ "required": ["type"],
++ "additionalProperties": false,
++ "properties": {
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: img.process, audio.process, docs.process." },
++ "input": { "type": "object", "description": "The job type's input; { tool: <operation>, …options }. Default {}; at most 16 KB as JSON." },
++ "idempotency_key": { "type": "string", "pattern": "^[!-~]{8,200}$", "description": "8-200 printable ASCII characters. The Idempotency-Key header is used when both are present." }
++ }
++}
ddiff --git a/contracts/tools/job.v1.json b/contracts/tools/job.v1.json
nnew file mode 100644
iindex 00000000..5156a0a3
---- /dev/null
++++ b/contracts/tools/job.v1.json
@@@ -0,0 +1,91 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/job.v1.json",
++ "title": "ToolsJob",
++ "description": "A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), with server paths removed.",
++ "type": "object",
++ "required": ["id", "object", "service", "type", "type_version", "state", "progress", "attempts", "max_attempts", "cancel_requested", "created_at", "started_at", "finished_at", "expires_at", "result", "error", "retryable", "retry_of", "retried_by", "references", "links"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "object": { "const": "tools.job" },
++ "service": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "The Tools satellite that runs the job (img, audio, docs…)." },
++ "tool": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$", "description": "The tool (tools.tool@1 id) whose run created the job, when it came through POST /api/v1/tools/{id}/run. Absent for a job submitted to POST /api/v1/jobs directly." },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type, e.g. img.process." },
++ "type_version": { "type": "integer", "minimum": 1 },
++ "state": { "enum": ["queued", "running", "succeeded", "failed", "cancelled"], "description": "Terminal: succeeded, failed, cancelled." },
++ "progress": {
++ "type": "object", "required": ["percent", "message"], "additionalProperties": false,
++ "properties": {
++ "percent": { "type": ["number", "null"], "minimum": 0, "maximum": 100 },
++ "message": { "type": ["string", "null"], "maxLength": 500 }
++ }
++ },
++ "attempts": { "type": "integer", "minimum": 0 },
++ "max_attempts": { "type": "integer", "minimum": 1 },
++ "cancel_requested": { "type": "boolean", "description": "DELETE asked a running job to stop; it ends cancelled." },
++ "created_at": { "type": "string", "format": "date-time" },
++ "started_at": { "type": ["string", "null"], "format": "date-time" },
++ "finished_at": { "type": ["string", "null"], "format": "date-time" },
++ "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "When the job, its events and its result files are pruned; null while a reference keeps the result." },
++ "result": {
++ "type": ["object", "null"], "required": ["files", "data"], "additionalProperties": false,
++ "properties": {
++ "files": {
++ "type": "array", "maxItems": 100,
++ "items": {
++ "type": "object", "required": ["name", "mime", "size", "sha256", "storage", "media", "url"], "additionalProperties": false,
++ "properties": {
++ "name": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "mime": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "size": { "type": "integer", "minimum": 0 },
++ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
++ "storage": { "enum": ["local", "media"], "description": "media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires." },
++ "media": {
++ "type": ["object", "null"], "required": ["media_id"], "additionalProperties": false,
++ "description": "The Media object, when storage is media.",
++ "properties": {
++ "media_id": { "type": "string", "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "role": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$" },
++ "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,63}$" },
++ "size_bytes": { "type": "integer", "minimum": 0 },
++ "content_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
++ "mime_type": { "type": "string", "maxLength": 200 },
++ "status": { "type": "string", "maxLength": 40, "description": "The object's Media lifecycle status when it was stored (ready)." }
++ }
++ },
++ "media_error": { "type": "string", "maxLength": 500, "description": "Present when storing the file in Media failed; the file stayed local." },
++ "url": { "type": "string", "pattern": "^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/files/\\d+$", "description": "Where the owner downloads it." }
++ }
++ }
++ },
++ "data": { "type": "object", "description": "The tool's output data (dimensions, durations, page counts…); {} when it has none." }
++ }
++ },
++ "error": { "oneOf": [{ "$ref": "../errors/problem.v1.json" }, { "type": "null" }] },
++ "retryable": { "type": "boolean", "description": "Retrying may succeed (a timeout, a restart); POST links.retry makes a new job." },
++ "retry_of": { "type": ["string", "null"], "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The failed job this one retries." },
++ "retried_by": { "type": ["string", "null"], "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job that retried this failed one; asking again returns it." },
++ "references": {
++ "type": "array", "maxItems": 50,
++ "description": "What keeps the result (PUT /api/v1/jobs/:id/references/:ref), e.g. community:paste:p_123.",
++ "items": {
++ "type": "object", "required": ["ref", "created_at"], "additionalProperties": false,
++ "properties": {
++ "ref": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*(?::[A-Za-z0-9_.-]+){1,4}$", "maxLength": 200 },
++ "created_at": { "type": "string", "format": "date-time" }
++ }
++ }
++ },
++ "links": {
++ "type": "object", "required": ["self", "events", "cancel", "retry", "retried_by"], "additionalProperties": false,
++ "properties": {
++ "self": { "type": "string", "format": "uri-reference" },
++ "events": { "type": "string", "format": "uri-reference" },
++ "cancel": { "type": ["string", "null"], "format": "uri-reference", "description": "DELETE it while the job is queued or running." },
++ "retry": { "type": ["string", "null"], "format": "uri-reference", "description": "POST it when the job failed." },
++ "retried_by": { "type": ["string", "null"], "format": "uri-reference" }
++ }
++ }
++ }
++}
ddiff --git a/contracts/tools/run-request.v1.json b/contracts/tools/run-request.v1.json
nnew file mode 100644
iindex 00000000..9d2e7b78
---- /dev/null
++++ b/contracts/tools/run-request.v1.json
@@@ -0,0 +1,36 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/run-request.v1.json",
++ "title": "ToolsRunRequest",
++ "description": "Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable.",
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "input": { "type": "object", "description": "The tool's parameters, matching its input schema. Default {}." },
++ "files": {
++ "type": "array", "maxItems": 50,
++ "description": "Files that are already somewhere, instead of or besides uploaded parts.",
++ "items": {
++ "oneOf": [
++ {
++ "type": "object", "required": ["media_id"], "additionalProperties": false,
++ "description": "A Media object the caller may read. Tools reads it for the caller.",
++ "properties": {
++ "media_id": { "type": "string", "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$" }
++ }
++ },
++ {
++ "type": "object", "required": ["job_id", "index"], "additionalProperties": false,
++ "description": "Result file index of a job the same caller owns (GET /api/v1/jobs/:id/files/:n), so one tool's output feeds the next.",
++ "properties": {
++ "job_id": { "type": "string", "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "index": { "type": "integer", "minimum": 0, "maximum": 99 }
++ }
++ }
++ ]
++ }
++ },
++ "wait_ms": { "type": "integer", "minimum": 0, "maximum": 60000, "description": "Job tools: wait up to this long for the job to finish before answering (200 finished, else 202 with the job). Default 0. Inline tools always answer finished." },
++ "idempotency_key": { "type": "string", "pattern": "^[!-~]{8,200}$", "description": "Job tools: the same (caller, key) and request returns the same job; a different request under it is 409 tools.job.idempotency_conflict. Inline tools ignore it." }
++ }
++}
ddiff --git a/contracts/tools/run.v1.json b/contracts/tools/run.v1.json
nnew file mode 100644
iindex 00000000..9e2a3599
---- /dev/null
++++ b/contracts/tools/run.v1.json
@@@ -0,0 +1,72 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/run.v1.json",
++ "title": "ToolsRun",
++ "description": "The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…). A request refused before the tool ran is a problem+json answer, not a run.",
++ "oneOf": [
++ {
++ "type": "object", "required": ["state", "tool", "result", "took_ms"], "additionalProperties": false,
++ "properties": {
++ "state": { "const": "succeeded" },
++ "tool": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" },
++ "result": {
++ "type": "object", "additionalProperties": false,
++ "properties": {
++ "data": { "type": "object", "description": "Output data; matches the descriptor's output.schema when output.kind is json." },
++ "text": { "type": "string", "description": "Output text (output.kind text)." },
++ "files": {
++ "type": "array", "maxItems": 100,
++ "description": "Result files (output.kind file or files), exactly as the job lists them.",
++ "items": {
++ "type": "object", "required": ["name", "mime", "size", "sha256", "storage", "media", "url"], "additionalProperties": false,
++ "properties": {
++ "name": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "mime": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "size": { "type": "integer", "minimum": 0 },
++ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
++ "storage": { "enum": ["local", "media"], "description": "media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires." },
++ "media": {
++ "type": ["object", "null"], "required": ["media_id"], "additionalProperties": false,
++ "description": "The Media object, when storage is media.",
++ "properties": {
++ "media_id": { "type": "string", "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "role": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,39}$" },
++ "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,63}$" },
++ "size_bytes": { "type": "integer", "minimum": 0 },
++ "content_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
++ "mime_type": { "type": "string", "maxLength": 200 },
++ "status": { "type": "string", "maxLength": 40, "description": "The object's Media lifecycle status when it was stored (ready)." }
++ }
++ },
++ "media_error": { "type": "string", "maxLength": 500, "description": "Present when storing the file in Media failed; the file stayed local." },
++ "url": { "type": "string", "pattern": "^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/files/\\d+$", "description": "Where the owner downloads it." }
++ }
++ }
++ }
++ }
++ },
++ "took_ms": { "type": "integer", "minimum": 0 },
++ "job": { "oneOf": [{ "$ref": "job.v1.json" }, { "type": "null" }] }
++ }
++ },
++ {
++ "type": "object", "required": ["state", "tool", "error", "took_ms"], "additionalProperties": false,
++ "properties": {
++ "state": { "enum": ["failed", "cancelled"] },
++ "tool": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" },
++ "error": { "$ref": "../errors/problem.v1.json" },
++ "took_ms": { "type": "integer", "minimum": 0 },
++ "job": { "oneOf": [{ "$ref": "job.v1.json" }, { "type": "null" }] }
++ }
++ },
++ {
++ "type": "object", "required": ["state", "tool", "job", "location"], "additionalProperties": false,
++ "properties": {
++ "state": { "enum": ["queued", "running"] },
++ "tool": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" },
++ "job": { "$ref": "job.v1.json" },
++ "location": { "type": "string", "format": "uri-reference", "pattern": "/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job (also the Location header): /api/v1/jobs/:id on the gateway." }
++ }
++ }
++ ]
++}
ddiff --git a/contracts/tools/tool-list.v1.json b/contracts/tools/tool-list.v1.json
nnew file mode 100644
iindex 00000000..43e701d1
---- /dev/null
++++ b/contracts/tools/tool-list.v1.json
@@@ -0,0 +1,26 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/tool-list.v1.json",
++ "title": "ToolList",
++ "description": "The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (name, summary and keywords). Planned placeholders are not tools and are not listed. Cacheable (ETag); updated_at changes when any descriptor does.",
++ "type": "object",
++ "required": ["tools", "count", "updated_at"],
++ "additionalProperties": false,
++ "properties": {
++ "tools": { "type": "array", "items": { "$ref": "tool.v1.json" } },
++ "count": { "type": "integer", "minimum": 0, "description": "The number of tools in this answer." },
++ "updated_at": { "type": "string", "format": "date-time" },
++ "families": {
++ "type": "array",
++ "description": "The families of the tools in this answer.",
++ "items": {
++ "type": "object", "required": ["id", "name", "count"], "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
++ "name": { "type": "string", "minLength": 1, "maxLength": 80 },
++ "count": { "type": "integer", "minimum": 0 }
++ }
++ }
++ }
++ }
++}
ddiff --git a/contracts/tools/tool.v1.json b/contracts/tools/tool.v1.json
nnew file mode 100644
iindex 00000000..90ea932a
---- /dev/null
++++ b/contracts/tools/tool.v1.json
@@@ -0,0 +1,162 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/tool.v1.json",
++ "title": "ToolDescriptor",
++ "description": "One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. RULES this schema enforces: api false has no run, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets) and files.min <= files.max.",
++ "type": "object",
++ "required": ["id", "family", "name", "summary", "status", "execution", "api", "run", "input", "files", "output", "limits", "auth", "quotaClass", "cost", "egress", "hosts", "docs"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$", "description": "The tool's catalogue id, which is its subdomain label (png, jsminify, dns, pdf2jpg). It never changes." },
++ "family": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "The catalogue family: net, dev, img, audio, docs, text, media, places, pastes…" },
++ "name": { "type": "string", "minLength": 1, "maxLength": 80 },
++ "summary": { "type": "string", "minLength": 1, "maxLength": 200, "description": "One plain sentence: what the tool does." },
++ "status": {
++ "enum": ["stable", "beta", "preview", "unavailable"],
++ "description": "stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason)."
++ },
++ "statusReason": { "type": "string", "minLength": 1, "maxLength": 300, "description": "Why the status is what it is, for people (e.g. 'needs qpdf on the host'). Required when unavailable." },
++ "execution": { "enum": ["client", "sync", "job"], "description": "Where the engine runs for the tool's page: client (the browser), sync (a server request answered inline), job (an asynchronous job)." },
++ "api": { "type": "boolean", "description": "POST /api/v1/tools/{id}/run exposes the tool. false for a client tool without a server engine and for page-only tools (yt)." },
++ "run": {
++ "type": ["object", "null"], "required": ["method", "path", "job"], "additionalProperties": false,
++ "description": "How to call it; null when api is false.",
++ "properties": {
++ "method": { "const": "POST" },
++ "path": { "type": "string", "pattern": "^/api/v1/tools/[a-z][a-z0-9-]{0,39}/run$", "description": "/api/v1/tools/{id}/run on https://openvibe.tools; the body is tools.run-request@1, the answer tools.run@1." },
++ "job": {
++ "type": ["object", "null"], "required": ["type", "operation"], "additionalProperties": false,
++ "description": "For a job tool: the job the run creates. null for tools answered inline.",
++ "properties": {
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type, e.g. img.process." },
++ "operation": { "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,39}$", "description": "The job input's tool field: convert, compress, trim, mergepdf…" },
++ "preset": { "type": "object", "description": "Input fields the tool itself fixes, applied over the caller's input (png: { format: png })." }
++ }
++ },
++ "legacy": {
++ "type": "array", "uniqueItems": true, "maxItems": 32,
++ "items": { "type": "string", "pattern": "^(GET|POST|PUT|DELETE) /[^\\s]*$" },
++ "description": "Older routes that run the same engine (GET /api/net/dns/:target, POST /api/process…). They keep working until their Sunset date and answer with Deprecation, Sunset and Link: rel=successor-version pointing at path."
++ }
++ }
++ },
++ "input": {
++ "description": "JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API.",
++ "anyOf": [
++ { "type": "object", "required": ["$ref"], "maxProperties": 1, "properties": { "$ref": { "type": "string", "format": "uri" } } },
++ { "type": "object", "required": ["type"], "properties": { "type": { "const": "object" } } },
++ { "type": "null" }
++ ]
++ },
++ "files": {
++ "type": ["object", "null"], "required": ["min", "max", "accept", "maxBytes"], "additionalProperties": false,
++ "description": "The files a run takes (multipart parts, or tools.run-request@1 files references); null when it takes none.",
++ "properties": {
++ "min": { "type": "integer", "minimum": 0, "maximum": 100 },
++ "max": { "type": "integer", "minimum": 1, "maximum": 100 },
++ "accept": {
++ "type": "array", "minItems": 1, "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z]+/([a-z0-9][a-z0-9.+-]*|\\*)$" },
++ "description": "Accepted media types, checked against the bytes, not the client's Content-Type (image/png, image/*, application/pdf…)."
++ },
++ "maxBytes": { "type": "integer", "minimum": 1, "description": "Largest accepted file." }
++ }
++ },
++ "output": {
++ "type": "object", "required": ["kind"], "additionalProperties": false,
++ "properties": {
++ "kind": { "enum": ["json", "file", "files", "text"], "description": "json: result.data; text: result.text; file or files: result.files (job result files)." },
++ "schema": {
++ "description": "JSON Schema of result.data (kind json): embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.",
++ "anyOf": [
++ { "type": "object", "required": ["$ref"], "maxProperties": 1, "properties": { "$ref": { "type": "string", "format": "uri" } } },
++ { "type": "object", "required": ["type"], "properties": { "type": { "const": "object" } } }
++ ]
++ },
++ "mime": {
++ "type": "array", "minItems": 1, "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z]+/([a-z0-9][a-z0-9.+-]*|\\*)$" },
++ "description": "Media types the result files can have (kind file or files)."
++ }
++ },
++ "if": { "properties": { "kind": { "const": "json" } }, "required": ["kind"] },
++ "then": { "required": ["schema"] }
++ },
++ "limits": {
++ "type": "object", "required": ["timeoutMs"], "additionalProperties": false,
++ "properties": {
++ "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 3600000, "description": "A run (or its job) longer than this fails with tools.job.timeout or 504." },
++ "maxDurationSec": { "type": "integer", "minimum": 1, "description": "Longest audio or video input." },
++ "maxPixels": { "type": "integer", "minimum": 1, "description": "Largest image input (width × height)." },
++ "maxPages": { "type": "integer", "minimum": 1, "description": "Most PDF pages." },
++ "maxInputBytes": { "type": "integer", "minimum": 1, "description": "Largest input as JSON (text to transform, lists of hosts…)." },
++ "perTargetPerMinute": { "type": "integer", "minimum": 1, "description": "Egress tools: most runs per minute against one target host or address, across all callers." }
++ }
++ },
++ "auth": {
++ "type": "object", "required": ["anonymous", "capability"], "additionalProperties": false,
++ "description": "Caller tiers: anonymous < session < user < app/service.",
++ "properties": {
++ "anonymous": { "type": "boolean", "description": "true: a caller with no token and no browser session may run it (keyed by IP, IPv6 by /64). false: a browser session (the ov_tools_jobs cookie), a signed-in person or a token is needed." },
++ "capability": {
++ "enum": ["tools.tool.run", "tools.net.probe"],
++ "description": "What an app or service token must hold. tools.tool.run is public. tools.net.probe (partner) covers network probes: only principals holding it run these through the API; people use their pages, which have a per-target throttle."
++ }
++ }
++ },
++ "quotaClass": {
++ "type": "string", "pattern": "^tools-[a-z0-9]+(-[a-z0-9]+)*$",
++ "description": "The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names."
++ },
++ "cost": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Relative weight of one run within its quota class (1 = a cheap lookup or transform). Quotas count weight, not requests." },
++ "egress": { "type": "boolean", "description": "The server fetches a host or URL the caller chose (DNS, WHOIS, headers, Open Graph, ports…), always through the SSRF guard." },
++ "hosts": {
++ "type": "array", "uniqueItems": true, "maxItems": 50,
++ "items": { "type": "string", "pattern": "^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$" },
++ "description": "The tool's public hostnames, canonical first (png.openvibe.tools, a custom domain…); empty for a tool that lives on another service."
++ },
++ "docs": { "type": "string", "format": "uri", "pattern": "^https://", "description": "Where a person reads about the tool." }
++ },
++ "if": { "required": ["api"] },
++ "then": {
++ "$comment": "The RULES in the description, as one conditional so generated types stay plain (every descriptor has api).",
++ "allOf": [
++ {
++ "if": { "properties": { "api": { "const": false } }, "required": ["api"] },
++ "then": { "properties": { "run": { "type": "null" } } }
++ },
++ {
++ "if": { "properties": { "api": { "const": true } }, "required": ["api"] },
++ "then": { "properties": { "run": { "type": "object" }, "input": { "type": "object" } } }
++ },
++ {
++ "if": { "properties": { "api": { "const": true }, "execution": { "const": "job" } }, "required": ["api", "execution"] },
++ "then": { "properties": { "run": { "type": "object", "properties": { "job": { "type": "object" } } } } }
++ },
++ {
++ "if": { "properties": { "execution": { "enum": ["client", "sync"] } }, "required": ["execution"] },
++ "then": { "properties": { "run": { "type": ["object", "null"], "properties": { "job": { "type": "null" } } } } }
++ },
++ {
++ "if": { "properties": { "api": { "const": true }, "output": { "type": "object", "properties": { "kind": { "enum": ["file", "files"] } }, "required": ["kind"] } }, "required": ["api", "output"] },
++ "then": { "properties": { "execution": { "const": "job" } } }
++ },
++ {
++ "if": { "properties": { "auth": { "type": "object", "properties": { "capability": { "const": "tools.net.probe" } }, "required": ["capability"] } }, "required": ["auth"] },
++ "then": { "properties": { "egress": { "const": true }, "auth": { "type": "object", "properties": { "anonymous": { "const": false } } } } }
++ },
++ {
++ "if": { "properties": { "egress": { "const": true }, "auth": { "type": "object", "properties": { "anonymous": { "const": true } }, "required": ["anonymous"] } }, "required": ["egress", "auth"] },
++ "then": { "properties": { "limits": { "type": "object", "required": ["perTargetPerMinute"] } } }
++ },
++ {
++ "if": { "properties": { "execution": { "const": "client" } }, "required": ["execution"] },
++ "then": { "properties": { "egress": { "const": false } } }
++ },
++ {
++ "if": { "properties": { "status": { "const": "unavailable" } }, "required": ["status"] },
++ "then": { "required": ["statusReason"] }
++ }
++ ]
++ }
++}
ddiff --git a/fixtures/tools.job-request/invalid/input-array.json b/fixtures/tools.job-request/invalid/input-array.json
nnew file mode 100644
iindex 00000000..8c3d782f
---- /dev/null
++++ b/fixtures/tools.job-request/invalid/input-array.json
@@@ -0,0 +1,4 @@
++{
++ "type": "img.process",
++ "input": []
++}
ddiff --git a/fixtures/tools.job-request/invalid/no-type.json b/fixtures/tools.job-request/invalid/no-type.json
nnew file mode 100644
iindex 00000000..ca05cdfa
---- /dev/null
++++ b/fixtures/tools.job-request/invalid/no-type.json
@@@ -0,0 +1,5 @@
++{
++ "input": {
++ "tool": "convert"
++ }
++}
ddiff --git a/fixtures/tools.job-request/invalid/undotted-type.json b/fixtures/tools.job-request/invalid/undotted-type.json
nnew file mode 100644
iindex 00000000..1f9a3651
---- /dev/null
++++ b/fixtures/tools.job-request/invalid/undotted-type.json
@@@ -0,0 +1,4 @@
++{
++ "type": "convert",
++ "input": {}
++}
ddiff --git a/fixtures/tools.job-request/valid/docs-merge-idempotent.json b/fixtures/tools.job-request/valid/docs-merge-idempotent.json
nnew file mode 100644
iindex 00000000..b2f5dc17
---- /dev/null
++++ b/fixtures/tools.job-request/valid/docs-merge-idempotent.json
@@@ -0,0 +1,7 @@
++{
++ "type": "docs.process",
++ "input": {
++ "tool": "merge"
++ },
++ "idempotency_key": "merge-2026-09-23-001"
++}
ddiff --git a/fixtures/tools.job-request/valid/img-convert.json b/fixtures/tools.job-request/valid/img-convert.json
nnew file mode 100644
iindex 00000000..91fdb5ac
---- /dev/null
++++ b/fixtures/tools.job-request/valid/img-convert.json
@@@ -0,0 +1,8 @@
++{
++ "type": "img.process",
++ "input": {
++ "tool": "convert",
++ "format": "webp",
++ "quality": 80
++ }
++}
ddiff --git a/fixtures/tools.job/invalid/file-without-url.json b/fixtures/tools.job/invalid/file-without-url.json
nnew file mode 100644
iindex 00000000..a14e52bf
---- /dev/null
++++ b/fixtures/tools.job/invalid/file-without-url.json
@@@ -0,0 +1,61 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "succeeded",
++ "progress": {
++ "percent": 100,
++ "message": "Done"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": "2026-09-23T09:00:04.000Z",
++ "expires_at": null,
++ "result": {
++ "files": [
++ {
++ "name": "photo.png",
++ "mime": "image/png",
++ "size": 90112,
++ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "storage": "media",
++ "media": {
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "role": "output",
++ "namespace": "tools",
++ "size_bytes": 90112,
++ "content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "mime_type": "image/png",
++ "status": "ready"
++ }
++ }
++ ],
++ "data": {
++ "width": 1024,
++ "height": 768
++ }
++ },
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [
++ {
++ "ref": "community:paste:p_123",
++ "created_at": "2026-09-23T09:05:00.000Z"
++ }
++ ],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": null,
++ "retry": null,
++ "retried_by": null
++ },
++ "tool": "png"
++}
ddiff --git a/fixtures/tools.job/invalid/legacy-media-id.json b/fixtures/tools.job/invalid/legacy-media-id.json
nnew file mode 100644
iindex 00000000..5551250a
---- /dev/null
++++ b/fixtures/tools.job/invalid/legacy-media-id.json
@@@ -0,0 +1,62 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "succeeded",
++ "progress": {
++ "percent": 100,
++ "message": "Done"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": "2026-09-23T09:00:04.000Z",
++ "expires_at": null,
++ "result": {
++ "files": [
++ {
++ "name": "photo.png",
++ "mime": "image/png",
++ "size": 90112,
++ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "storage": "media",
++ "media": {
++ "media_id": "legacy:live:vod:42",
++ "role": "output",
++ "namespace": "tools",
++ "size_bytes": 90112,
++ "content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "mime_type": "image/png",
++ "status": "ready"
++ },
++ "url": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/files/0"
++ }
++ ],
++ "data": {
++ "width": 1024,
++ "height": 768
++ }
++ },
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [
++ {
++ "ref": "community:paste:p_123",
++ "created_at": "2026-09-23T09:05:00.000Z"
++ }
++ ],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": null,
++ "retry": null,
++ "retried_by": null
++ },
++ "tool": "png"
++}
ddiff --git a/fixtures/tools.job/invalid/no-links.json b/fixtures/tools.job/invalid/no-links.json
nnew file mode 100644
iindex 00000000..1fe0cb5a
---- /dev/null
++++ b/fixtures/tools.job/invalid/no-links.json
@@@ -0,0 +1,25 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "queued",
++ "progress": {
++ "percent": null,
++ "message": null
++ },
++ "attempts": 0,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": []
++}
ddiff --git a/fixtures/tools.job/invalid/server-path-in-file.json b/fixtures/tools.job/invalid/server-path-in-file.json
nnew file mode 100644
iindex 00000000..e40bda5d
---- /dev/null
++++ b/fixtures/tools.job/invalid/server-path-in-file.json
@@@ -0,0 +1,63 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "succeeded",
++ "progress": {
++ "percent": 100,
++ "message": "Done"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": "2026-09-23T09:00:04.000Z",
++ "expires_at": null,
++ "result": {
++ "files": [
++ {
++ "name": "photo.png",
++ "mime": "image/png",
++ "size": 90112,
++ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "storage": "media",
++ "media": {
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "role": "output",
++ "namespace": "tools",
++ "size_bytes": 90112,
++ "content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "mime_type": "image/png",
++ "status": "ready"
++ },
++ "url": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/files/0",
++ "path": "/opt/openvibe.tools/data/jobs/x/out/0-photo.png"
++ }
++ ],
++ "data": {
++ "width": 1024,
++ "height": 768
++ }
++ },
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [
++ {
++ "ref": "community:paste:p_123",
++ "created_at": "2026-09-23T09:05:00.000Z"
++ }
++ ],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": null,
++ "retry": null,
++ "retried_by": null
++ },
++ "tool": "png"
++}
ddiff --git a/fixtures/tools.job/invalid/unknown-state.json b/fixtures/tools.job/invalid/unknown-state.json
nnew file mode 100644
iindex 00000000..f4173f61
---- /dev/null
++++ b/fixtures/tools.job/invalid/unknown-state.json
@@@ -0,0 +1,32 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "paused",
++ "progress": {
++ "percent": null,
++ "message": null
++ },
++ "attempts": 0,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "retry": null,
++ "retried_by": null
++ }
++}
ddiff --git a/fixtures/tools.job/valid/failed-retryable.json b/fixtures/tools.job/valid/failed-retryable.json
nnew file mode 100644
iindex 00000000..26f5ae47
---- /dev/null
++++ b/fixtures/tools.job/valid/failed-retryable.json
@@@ -0,0 +1,39 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "audio",
++ "type": "audio.process",
++ "type_version": 1,
++ "state": "failed",
++ "progress": {
++ "percent": 42.5,
++ "message": "Encoding"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": "2026-09-23T09:15:01.000Z",
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": {
++ "type": "https://openvibe.network/problems/tools.job.timeout",
++ "title": "Gateway Timeout",
++ "status": 504,
++ "code": "tools.job.timeout",
++ "detail": "The job ran longer than 900 s",
++ "error": "The job ran longer than 900 s"
++ },
++ "retryable": true,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": null,
++ "retry": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/retry",
++ "retried_by": null
++ }
++}
ddiff --git a/fixtures/tools.job/valid/queued.json b/fixtures/tools.job/valid/queued.json
nnew file mode 100644
iindex 00000000..e1f1dfe3
---- /dev/null
++++ b/fixtures/tools.job/valid/queued.json
@@@ -0,0 +1,32 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "queued",
++ "progress": {
++ "percent": null,
++ "message": null
++ },
++ "attempts": 0,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "retry": null,
++ "retried_by": null
++ }
++}
ddiff --git a/fixtures/tools.job/valid/succeeded-local-after-media-error.json b/fixtures/tools.job/valid/succeeded-local-after-media-error.json
nnew file mode 100644
iindex 00000000..2521edc3
---- /dev/null
++++ b/fixtures/tools.job/valid/succeeded-local-after-media-error.json
@@@ -0,0 +1,49 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "succeeded",
++ "progress": {
++ "percent": 100,
++ "message": "Done"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": "2026-09-23T09:00:04.000Z",
++ "expires_at": "2026-09-24T09:00:04.000Z",
++ "result": {
++ "files": [
++ {
++ "name": "photo.png",
++ "mime": "image/png",
++ "size": 90112,
++ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "storage": "local",
++ "media": null,
++ "media_error": "Could not store the result in OpenVibe.Media; it is kept on this server until the job expires",
++ "url": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/files/0"
++ }
++ ],
++ "data": {
++ "width": 1024,
++ "height": 768
++ }
++ },
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": null,
++ "retry": null,
++ "retried_by": null
++ }
++}
ddiff --git a/fixtures/tools.job/valid/succeeded-media-referenced.json b/fixtures/tools.job/valid/succeeded-media-referenced.json
nnew file mode 100644
iindex 00000000..d41d114b
---- /dev/null
++++ b/fixtures/tools.job/valid/succeeded-media-referenced.json
@@@ -0,0 +1,62 @@
++{
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "succeeded",
++ "progress": {
++ "percent": 100,
++ "message": "Done"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": "2026-09-23T09:00:04.000Z",
++ "expires_at": null,
++ "result": {
++ "files": [
++ {
++ "name": "photo.png",
++ "mime": "image/png",
++ "size": 90112,
++ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "storage": "media",
++ "media": {
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "role": "output",
++ "namespace": "tools",
++ "size_bytes": 90112,
++ "content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "mime_type": "image/png",
++ "status": "ready"
++ },
++ "url": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/files/0"
++ }
++ ],
++ "data": {
++ "width": 1024,
++ "height": 768
++ }
++ },
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [
++ {
++ "ref": "community:paste:p_123",
++ "created_at": "2026-09-23T09:05:00.000Z"
++ }
++ ],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": null,
++ "retry": null,
++ "retried_by": null
++ },
++ "tool": "png"
++}
ddiff --git a/fixtures/tools.run-request/invalid/both-reference-kinds.json b/fixtures/tools.run-request/invalid/both-reference-kinds.json
nnew file mode 100644
iindex 00000000..3104085e
---- /dev/null
++++ b/fixtures/tools.run-request/invalid/both-reference-kinds.json
@@@ -0,0 +1,9 @@
++{
++ "files": [
++ {
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "job_id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "index": 0
++ }
++ ]
++}
ddiff --git a/fixtures/tools.run-request/invalid/input-array.json b/fixtures/tools.run-request/invalid/input-array.json
nnew file mode 100644
iindex 00000000..e2a49601
---- /dev/null
++++ b/fixtures/tools.run-request/invalid/input-array.json
@@@ -0,0 +1,5 @@
++{
++ "input": [
++ "a"
++ ]
++}
ddiff --git a/fixtures/tools.run-request/invalid/job-type-field.json b/fixtures/tools.run-request/invalid/job-type-field.json
nnew file mode 100644
iindex 00000000..59266c35
---- /dev/null
++++ b/fixtures/tools.run-request/invalid/job-type-field.json
@@@ -0,0 +1,4 @@
++{
++ "type": "img.process",
++ "input": {}
++}
ddiff --git a/fixtures/tools.run-request/invalid/short-idempotency-key.json b/fixtures/tools.run-request/invalid/short-idempotency-key.json
nnew file mode 100644
iindex 00000000..a482485e
---- /dev/null
++++ b/fixtures/tools.run-request/invalid/short-idempotency-key.json
@@@ -0,0 +1,3 @@
++{
++ "idempotency_key": "abc"
++}
ddiff --git a/fixtures/tools.run-request/invalid/url-reference.json b/fixtures/tools.run-request/invalid/url-reference.json
nnew file mode 100644
iindex 00000000..e5d8488b
---- /dev/null
++++ b/fixtures/tools.run-request/invalid/url-reference.json
@@@ -0,0 +1,7 @@
++{
++ "files": [
++ {
++ "url": "http://169.254.169.254/latest/meta-data"
++ }
++ ]
++}
ddiff --git a/fixtures/tools.run-request/invalid/wait-too-long.json b/fixtures/tools.run-request/invalid/wait-too-long.json
nnew file mode 100644
iindex 00000000..42efc9e3
---- /dev/null
++++ b/fixtures/tools.run-request/invalid/wait-too-long.json
@@@ -0,0 +1,4 @@
++{
++ "input": {},
++ "wait_ms": 600000
++}
ddiff --git a/fixtures/tools.run-request/valid/chained-job-file.json b/fixtures/tools.run-request/valid/chained-job-file.json
nnew file mode 100644
iindex 00000000..d4a56d99
---- /dev/null
++++ b/fixtures/tools.run-request/valid/chained-job-file.json
@@@ -0,0 +1,13 @@
++{
++ "input": {
++ "width": 512
++ },
++ "files": [
++ {
++ "job_id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "index": 0
++ }
++ ],
++ "wait_ms": 20000,
++ "idempotency_key": "resize-01JAB2C3"
++}
ddiff --git a/fixtures/tools.run-request/valid/empty.json b/fixtures/tools.run-request/valid/empty.json
nnew file mode 100644
iindex 00000000..0967ef42
---- /dev/null
++++ b/fixtures/tools.run-request/valid/empty.json
@@@ -0,0 +1 @@
++{}
ddiff --git a/fixtures/tools.run-request/valid/inline-text.json b/fixtures/tools.run-request/valid/inline-text.json
nnew file mode 100644
iindex 00000000..ab126083
---- /dev/null
++++ b/fixtures/tools.run-request/valid/inline-text.json
@@@ -0,0 +1,5 @@
++{
++ "input": {
++ "text": "{ \"a\": 1 }"
++ }
++}
ddiff --git a/fixtures/tools.run-request/valid/media-object.json b/fixtures/tools.run-request/valid/media-object.json
nnew file mode 100644
iindex 00000000..ff7f756c
---- /dev/null
++++ b/fixtures/tools.run-request/valid/media-object.json
@@@ -0,0 +1,10 @@
++{
++ "input": {
++ "tool_hint": "x"
++ },
++ "files": [
++ {
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPS"
++ }
++ ]
++}
ddiff --git a/fixtures/tools.run/invalid/failed-without-error.json b/fixtures/tools.run/invalid/failed-without-error.json
nnew file mode 100644
iindex 00000000..bbd13fc7
---- /dev/null
++++ b/fixtures/tools.run/invalid/failed-without-error.json
@@@ -0,0 +1,5 @@
++{
++ "state": "failed",
++ "tool": "dns",
++ "took_ms": 3
++}
ddiff --git a/fixtures/tools.run/invalid/location-elsewhere.json b/fixtures/tools.run/invalid/location-elsewhere.json
nnew file mode 100644
iindex 00000000..4498def4
---- /dev/null
++++ b/fixtures/tools.run/invalid/location-elsewhere.json
@@@ -0,0 +1,37 @@
++{
++ "state": "queued",
++ "tool": "png",
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "queued",
++ "progress": {
++ "percent": null,
++ "message": null
++ },
++ "attempts": 0,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "retry": null,
++ "retried_by": null
++ }
++ },
++ "location": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPR/files/0"
++}
ddiff --git a/fixtures/tools.run/invalid/queued-without-location.json b/fixtures/tools.run/invalid/queued-without-location.json
nnew file mode 100644
iindex 00000000..33ae70e8
---- /dev/null
++++ b/fixtures/tools.run/invalid/queued-without-location.json
@@@ -0,0 +1,36 @@
++{
++ "state": "queued",
++ "tool": "png",
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "queued",
++ "progress": {
++ "percent": null,
++ "message": null
++ },
++ "attempts": 0,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "retry": null,
++ "retried_by": null
++ }
++ }
++}
ddiff --git a/fixtures/tools.run/invalid/result-with-unknown-member.json b/fixtures/tools.run/invalid/result-with-unknown-member.json
nnew file mode 100644
iindex 00000000..ce5e2310
---- /dev/null
++++ b/fixtures/tools.run/invalid/result-with-unknown-member.json
@@@ -0,0 +1,8 @@
++{
++ "state": "succeeded",
++ "tool": "dns",
++ "result": {
++ "html": "<b>x</b>"
++ },
++ "took_ms": 3
++}
ddiff --git a/fixtures/tools.run/invalid/running-without-job.json b/fixtures/tools.run/invalid/running-without-job.json
nnew file mode 100644
iindex 00000000..c5314443
---- /dev/null
++++ b/fixtures/tools.run/invalid/running-without-job.json
@@@ -0,0 +1,5 @@
++{
++ "state": "running",
++ "tool": "png",
++ "location": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/tools.run/invalid/succeeded-with-error.json b/fixtures/tools.run/invalid/succeeded-with-error.json
nnew file mode 100644
iindex 00000000..b5b2a1ba
---- /dev/null
++++ b/fixtures/tools.run/invalid/succeeded-with-error.json
@@@ -0,0 +1,14 @@
++{
++ "state": "succeeded",
++ "tool": "dns",
++ "result": {},
++ "error": {
++ "type": "https://openvibe.network/problems/tools.job.timeout",
++ "title": "Gateway Timeout",
++ "status": 504,
++ "code": "tools.job.timeout",
++ "detail": "The job ran longer than 900 s",
++ "error": "The job ran longer than 900 s"
++ },
++ "took_ms": 3
++}
ddiff --git a/fixtures/tools.run/valid/failed-job.json b/fixtures/tools.run/valid/failed-job.json
nnew file mode 100644
iindex 00000000..8f3f928c
---- /dev/null
++++ b/fixtures/tools.run/valid/failed-job.json
@@@ -0,0 +1,52 @@
++{
++ "state": "failed",
++ "tool": "mp3",
++ "error": {
++ "type": "https://openvibe.network/problems/tools.job.timeout",
++ "title": "Gateway Timeout",
++ "status": 504,
++ "code": "tools.job.timeout",
++ "detail": "The job ran longer than 900 s",
++ "error": "The job ran longer than 900 s"
++ },
++ "took_ms": 901000,
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "audio",
++ "type": "audio.process",
++ "type_version": 1,
++ "state": "failed",
++ "progress": {
++ "percent": 42.5,
++ "message": "Encoding"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": "2026-09-23T09:15:01.000Z",
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": {
++ "type": "https://openvibe.network/problems/tools.job.timeout",
++ "title": "Gateway Timeout",
++ "status": 504,
++ "code": "tools.job.timeout",
++ "detail": "The job ran longer than 900 s",
++ "error": "The job ran longer than 900 s"
++ },
++ "retryable": true,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": null,
++ "retry": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/retry",
++ "retried_by": null
++ }
++ }
++}
ddiff --git a/fixtures/tools.run/valid/queued.json b/fixtures/tools.run/valid/queued.json
nnew file mode 100644
iindex 00000000..dd3ef4dc
---- /dev/null
++++ b/fixtures/tools.run/valid/queued.json
@@@ -0,0 +1,37 @@
++{
++ "state": "queued",
++ "tool": "png",
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "queued",
++ "progress": {
++ "percent": null,
++ "message": null
++ },
++ "attempts": 0,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "retry": null,
++ "retried_by": null
++ }
++ },
++ "location": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/tools.run/valid/running-absolute-location.json b/fixtures/tools.run/valid/running-absolute-location.json
nnew file mode 100644
iindex 00000000..6e7f6015
---- /dev/null
++++ b/fixtures/tools.run/valid/running-absolute-location.json
@@@ -0,0 +1,37 @@
++{
++ "state": "running",
++ "tool": "png",
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "running",
++ "progress": {
++ "percent": 12,
++ "message": "Converting"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": null,
++ "expires_at": "2026-09-23T10:00:00.000Z",
++ "result": null,
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "retry": null,
++ "retried_by": null
++ }
++ },
++ "location": "https://openvibe.tools/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++}
ddiff --git a/fixtures/tools.run/valid/succeeded-inline-json.json b/fixtures/tools.run/valid/succeeded-inline-json.json
nnew file mode 100644
iindex 00000000..ca4e17f3
---- /dev/null
++++ b/fixtures/tools.run/valid/succeeded-inline-json.json
@@@ -0,0 +1,15 @@
++{
++ "state": "succeeded",
++ "tool": "dns",
++ "result": {
++ "data": {
++ "name": "openvibe.tools",
++ "records": {
++ "A": [
++ "203.0.113.7"
++ ]
++ }
++ }
++ },
++ "took_ms": 38
++}
ddiff --git a/fixtures/tools.run/valid/succeeded-inline-text.json b/fixtures/tools.run/valid/succeeded-inline-text.json
nnew file mode 100644
iindex 00000000..ef5d47a3
---- /dev/null
++++ b/fixtures/tools.run/valid/succeeded-inline-text.json
@@@ -0,0 +1,9 @@
++{
++ "state": "succeeded",
++ "tool": "jsonminify",
++ "result": {
++ "text": "{\"a\":1}"
++ },
++ "took_ms": 2,
++ "job": null
++}
ddiff --git a/fixtures/tools.run/valid/succeeded-job.json b/fixtures/tools.run/valid/succeeded-job.json
nnew file mode 100644
iindex 00000000..af2c17c9
---- /dev/null
++++ b/fixtures/tools.run/valid/succeeded-job.json
@@@ -0,0 +1,92 @@
++{
++ "state": "succeeded",
++ "tool": "png",
++ "result": {
++ "files": [
++ {
++ "name": "photo.png",
++ "mime": "image/png",
++ "size": 90112,
++ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "storage": "media",
++ "media": {
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "role": "output",
++ "namespace": "tools",
++ "size_bytes": 90112,
++ "content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "mime_type": "image/png",
++ "status": "ready"
++ },
++ "url": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/files/0"
++ }
++ ],
++ "data": {
++ "width": 1024,
++ "height": 768
++ }
++ },
++ "took_ms": 4000,
++ "job": {
++ "id": "job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object": "tools.job",
++ "service": "img",
++ "type": "img.process",
++ "type_version": 1,
++ "state": "succeeded",
++ "progress": {
++ "percent": 100,
++ "message": "Done"
++ },
++ "attempts": 1,
++ "max_attempts": 3,
++ "cancel_requested": false,
++ "created_at": "2026-09-23T09:00:00.000Z",
++ "started_at": "2026-09-23T09:00:01.000Z",
++ "finished_at": "2026-09-23T09:00:04.000Z",
++ "expires_at": null,
++ "result": {
++ "files": [
++ {
++ "name": "photo.png",
++ "mime": "image/png",
++ "size": 90112,
++ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "storage": "media",
++ "media": {
++ "media_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "role": "output",
++ "namespace": "tools",
++ "size_bytes": 90112,
++ "content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
++ "mime_type": "image/png",
++ "status": "ready"
++ },
++ "url": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/files/0"
++ }
++ ],
++ "data": {
++ "width": 1024,
++ "height": 768
++ }
++ },
++ "error": null,
++ "retryable": false,
++ "retry_of": null,
++ "retried_by": null,
++ "references": [
++ {
++ "ref": "community:paste:p_123",
++ "created_at": "2026-09-23T09:05:00.000Z"
++ }
++ ],
++ "links": {
++ "self": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "events": "/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/events",
++ "cancel": null,
++ "retry": null,
++ "retried_by": null
++ },
++ "tool": "png"
++ }
++}
ddiff --git a/fixtures/tools.tool-list/invalid/bad-descriptor.json b/fixtures/tools.tool-list/invalid/bad-descriptor.json
nnew file mode 100644
iindex 00000000..461e6ad1
---- /dev/null
++++ b/fixtures/tools.tool-list/invalid/bad-descriptor.json
@@@ -0,0 +1,204 @@
++{
++ "tools": [
++ {
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/png/schema#/$defs/input"
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++ },
++ {
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++ },
++ {
++ "id": "port",
++ "family": "net",
++ "name": "Port Checker",
++ "summary": "Check whether TCP ports on a host are open.",
++ "status": "beta",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/port/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/port/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/port/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/port/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 15000,
++ "perTargetPerMinute": 6
++ },
++ "auth": {
++ "anonymous": false,
++ "capability": "tools.net.probe"
++ },
++ "quotaClass": "tools-probe",
++ "cost": 5,
++ "egress": true,
++ "hosts": [
++ "port.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/port"
++ },
++ {
++ "id": "yt",
++ "family": "media",
++ "name": "YouTube Downloader",
++ "summary": "Save a YouTube video or its audio.",
++ "status": "stable",
++ "execution": "job",
++ "api": false,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/yt/run",
++ "job": null
++ },
++ "input": null,
++ "files": null,
++ "output": {
++ "kind": "file",
++ "mime": [
++ "video/mp4",
++ "audio/mpeg"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 900000,
++ "maxDurationSec": 3600,
++ "perTargetPerMinute": 20
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-download",
++ "cost": 50,
++ "egress": true,
++ "hosts": [
++ "youtube-downloader.openvibe.tools",
++ "yt.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/yt"
++ }
++ ],
++ "count": 4,
++ "updated_at": "2026-09-23T21:00:00.000Z",
++ "families": [
++ {
++ "id": "img",
++ "name": "Image Tools",
++ "count": 1
++ },
++ {
++ "id": "net",
++ "name": "Network Tools",
++ "count": 2
++ },
++ {
++ "id": "media",
++ "name": "Media",
++ "count": 1
++ }
++ ]
++}
ddiff --git a/fixtures/tools.tool-list/invalid/no-count.json b/fixtures/tools.tool-list/invalid/no-count.json
nnew file mode 100644
iindex 00000000..0b9a6bb1
---- /dev/null
++++ b/fixtures/tools.tool-list/invalid/no-count.json
@@@ -0,0 +1,199 @@
++{
++ "tools": [
++ {
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/png/schema#/$defs/input"
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++ },
++ {
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++ },
++ {
++ "id": "port",
++ "family": "net",
++ "name": "Port Checker",
++ "summary": "Check whether TCP ports on a host are open.",
++ "status": "beta",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/port/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/port/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/port/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/port/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 15000,
++ "perTargetPerMinute": 6
++ },
++ "auth": {
++ "anonymous": false,
++ "capability": "tools.net.probe"
++ },
++ "quotaClass": "tools-probe",
++ "cost": 5,
++ "egress": true,
++ "hosts": [
++ "port.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/port"
++ },
++ {
++ "id": "yt",
++ "family": "media",
++ "name": "YouTube Downloader",
++ "summary": "Save a YouTube video or its audio.",
++ "status": "stable",
++ "execution": "job",
++ "api": false,
++ "run": null,
++ "input": null,
++ "files": null,
++ "output": {
++ "kind": "file",
++ "mime": [
++ "video/mp4",
++ "audio/mpeg"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 900000,
++ "maxDurationSec": 3600,
++ "perTargetPerMinute": 20
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-download",
++ "cost": 50,
++ "egress": true,
++ "hosts": [
++ "youtube-downloader.openvibe.tools",
++ "yt.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/yt"
++ }
++ ],
++ "updated_at": "2026-09-23T21:00:00.000Z",
++ "families": [
++ {
++ "id": "img",
++ "name": "Image Tools",
++ "count": 1
++ },
++ {
++ "id": "net",
++ "name": "Network Tools",
++ "count": 2
++ },
++ {
++ "id": "media",
++ "name": "Media",
++ "count": 1
++ }
++ ]
++}
ddiff --git a/fixtures/tools.tool-list/valid/empty-filter.json b/fixtures/tools.tool-list/valid/empty-filter.json
nnew file mode 100644
iindex 00000000..acb8b6be
---- /dev/null
++++ b/fixtures/tools.tool-list/valid/empty-filter.json
@@@ -0,0 +1,5 @@
++{
++ "tools": [],
++ "count": 0,
++ "updated_at": "2026-09-23T21:00:00.000Z"
++}
ddiff --git a/fixtures/tools.tool-list/valid/four-tools.json b/fixtures/tools.tool-list/valid/four-tools.json
nnew file mode 100644
iindex 00000000..0b8ab325
---- /dev/null
++++ b/fixtures/tools.tool-list/valid/four-tools.json
@@@ -0,0 +1,200 @@
++{
++ "tools": [
++ {
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/png/schema#/$defs/input"
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++ },
++ {
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++ },
++ {
++ "id": "port",
++ "family": "net",
++ "name": "Port Checker",
++ "summary": "Check whether TCP ports on a host are open.",
++ "status": "beta",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/port/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/port/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/port/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/port/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 15000,
++ "perTargetPerMinute": 6
++ },
++ "auth": {
++ "anonymous": false,
++ "capability": "tools.net.probe"
++ },
++ "quotaClass": "tools-probe",
++ "cost": 5,
++ "egress": true,
++ "hosts": [
++ "port.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/port"
++ },
++ {
++ "id": "yt",
++ "family": "media",
++ "name": "YouTube Downloader",
++ "summary": "Save a YouTube video or its audio.",
++ "status": "stable",
++ "execution": "job",
++ "api": false,
++ "run": null,
++ "input": null,
++ "files": null,
++ "output": {
++ "kind": "file",
++ "mime": [
++ "video/mp4",
++ "audio/mpeg"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 900000,
++ "maxDurationSec": 3600,
++ "perTargetPerMinute": 20
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-download",
++ "cost": 50,
++ "egress": true,
++ "hosts": [
++ "youtube-downloader.openvibe.tools",
++ "yt.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/yt"
++ }
++ ],
++ "count": 4,
++ "updated_at": "2026-09-23T21:00:00.000Z",
++ "families": [
++ {
++ "id": "img",
++ "name": "Image Tools",
++ "count": 1
++ },
++ {
++ "id": "net",
++ "name": "Network Tools",
++ "count": 2
++ },
++ {
++ "id": "media",
++ "name": "Media",
++ "count": 1
++ }
++ ]
++}
ddiff --git a/fixtures/tools.tool/invalid/anonymous-egress-without-throttle.json b/fixtures/tools.tool/invalid/anonymous-egress-without-throttle.json
nnew file mode 100644
iindex 00000000..f07805a1
---- /dev/null
++++ b/fixtures/tools.tool/invalid/anonymous-egress-without-throttle.json
@@@ -0,0 +1,42 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/api-false-with-run.json b/fixtures/tools.tool/invalid/api-false-with-run.json
nnew file mode 100644
iindex 00000000..2e30332c
---- /dev/null
++++ b/fixtures/tools.tool/invalid/api-false-with-run.json
@@@ -0,0 +1,69 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "job",
++ "api": false,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/invalid/api-job-tool-without-job.json b/fixtures/tools.tool/invalid/api-job-tool-without-job.json
nnew file mode 100644
iindex 00000000..37f43bed
---- /dev/null
++++ b/fixtures/tools.tool/invalid/api-job-tool-without-job.json
@@@ -0,0 +1,63 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": null,
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/invalid/api-true-without-input.json b/fixtures/tools.tool/invalid/api-true-without-input.json
nnew file mode 100644
iindex 00000000..ffe6b6f3
---- /dev/null
++++ b/fixtures/tools.tool/invalid/api-true-without-input.json
@@@ -0,0 +1,41 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": null,
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/api-true-without-run.json b/fixtures/tools.tool/invalid/api-true-without-run.json
nnew file mode 100644
iindex 00000000..c30aa304
---- /dev/null
++++ b/fixtures/tools.tool/invalid/api-true-without-run.json
@@@ -0,0 +1,55 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": null,
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/invalid/client-tool-fetches.json b/fixtures/tools.tool/invalid/client-tool-fetches.json
nnew file mode 100644
iindex 00000000..f869f4af
---- /dev/null
++++ b/fixtures/tools.tool/invalid/client-tool-fetches.json
@@@ -0,0 +1,48 @@
++{
++ "id": "jsonminify",
++ "family": "dev",
++ "name": "JSON Minifier",
++ "summary": "Strip the whitespace out of JSON.",
++ "status": "stable",
++ "execution": "client",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/jsonminify/run",
++ "job": null
++ },
++ "input": {
++ "type": "object",
++ "required": [
++ "text"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "text": {
++ "type": "string",
++ "maxLength": 1048576
++ }
++ }
++ },
++ "files": null,
++ "output": {
++ "kind": "text"
++ },
++ "limits": {
++ "timeoutMs": 5000,
++ "maxInputBytes": 1048576,
++ "perTargetPerMinute": 10
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-run",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "json-minifier.openvibe.tools",
++ "jsonminify.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/jsonminify"
++}
ddiff --git a/fixtures/tools.tool/invalid/file-output-answered-inline.json b/fixtures/tools.tool/invalid/file-output-answered-inline.json
nnew file mode 100644
iindex 00000000..ea26c8d9
---- /dev/null
++++ b/fixtures/tools.tool/invalid/file-output-answered-inline.json
@@@ -0,0 +1,63 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": null,
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/invalid/input-schema-not-an-object.json b/fixtures/tools.tool/invalid/input-schema-not-an-object.json
nnew file mode 100644
iindex 00000000..1c3a0b61
---- /dev/null
++++ b/fixtures/tools.tool/invalid/input-schema-not-an-object.json
@@@ -0,0 +1,37 @@
++{
++ "id": "jsonminify",
++ "family": "dev",
++ "name": "JSON Minifier",
++ "summary": "Strip the whitespace out of JSON.",
++ "status": "stable",
++ "execution": "client",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/jsonminify/run",
++ "job": null
++ },
++ "input": {
++ "type": "string"
++ },
++ "files": null,
++ "output": {
++ "kind": "text"
++ },
++ "limits": {
++ "timeoutMs": 5000,
++ "maxInputBytes": 1048576
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-run",
++ "cost": 1,
++ "egress": false,
++ "hosts": [
++ "json-minifier.openvibe.tools",
++ "jsonminify.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/jsonminify"
++}
ddiff --git a/fixtures/tools.tool/invalid/json-output-without-schema.json b/fixtures/tools.tool/invalid/json-output-without-schema.json
nnew file mode 100644
iindex 00000000..88d99fbf
---- /dev/null
++++ b/fixtures/tools.tool/invalid/json-output-without-schema.json
@@@ -0,0 +1,40 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json"
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/mime-with-parameters.json b/fixtures/tools.tool/invalid/mime-with-parameters.json
nnew file mode 100644
iindex 00000000..e2cca264
---- /dev/null
++++ b/fixtures/tools.tool/invalid/mime-with-parameters.json
@@@ -0,0 +1,69 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/png; charset=binary"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/invalid/probe-anonymous.json b/fixtures/tools.tool/invalid/probe-anonymous.json
nnew file mode 100644
iindex 00000000..0d0b35da
---- /dev/null
++++ b/fixtures/tools.tool/invalid/probe-anonymous.json
@@@ -0,0 +1,68 @@
++{
++ "id": "port",
++ "family": "net",
++ "name": "Port Checker",
++ "summary": "Check whether TCP ports on a host are open.",
++ "status": "beta",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/port/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/port/:target"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "required": [
++ "host",
++ "ports"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "host": {
++ "type": "string",
++ "maxLength": 253
++ },
++ "ports": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 20,
++ "items": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 65535
++ }
++ }
++ }
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "type": "object",
++ "properties": {
++ "results": {
++ "type": "array"
++ }
++ }
++ }
++ },
++ "limits": {
++ "timeoutMs": 15000,
++ "perTargetPerMinute": 6
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.net.probe"
++ },
++ "quotaClass": "tools-probe",
++ "cost": 5,
++ "egress": true,
++ "hosts": [
++ "port.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/port"
++}
ddiff --git a/fixtures/tools.tool/invalid/probe-without-egress.json b/fixtures/tools.tool/invalid/probe-without-egress.json
nnew file mode 100644
iindex 00000000..4cf6e14b
---- /dev/null
++++ b/fixtures/tools.tool/invalid/probe-without-egress.json
@@@ -0,0 +1,68 @@
++{
++ "id": "port",
++ "family": "net",
++ "name": "Port Checker",
++ "summary": "Check whether TCP ports on a host are open.",
++ "status": "beta",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/port/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/port/:target"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "required": [
++ "host",
++ "ports"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "host": {
++ "type": "string",
++ "maxLength": 253
++ },
++ "ports": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 20,
++ "items": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 65535
++ }
++ }
++ }
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "type": "object",
++ "properties": {
++ "results": {
++ "type": "array"
++ }
++ }
++ }
++ },
++ "limits": {
++ "timeoutMs": 15000,
++ "perTargetPerMinute": 6
++ },
++ "auth": {
++ "anonymous": false,
++ "capability": "tools.net.probe"
++ },
++ "quotaClass": "tools-probe",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "port.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/port"
++}
ddiff --git a/fixtures/tools.tool/invalid/quota-class-without-prefix.json b/fixtures/tools.tool/invalid/quota-class-without-prefix.json
nnew file mode 100644
iindex 00000000..920a2a17
---- /dev/null
++++ b/fixtures/tools.tool/invalid/quota-class-without-prefix.json
@@@ -0,0 +1,43 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/ref-not-a-string.json b/fixtures/tools.tool/invalid/ref-not-a-string.json
nnew file mode 100644
iindex 00000000..45db55f6
---- /dev/null
++++ b/fixtures/tools.tool/invalid/ref-not-a-string.json
@@@ -0,0 +1,43 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": 42
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/run-get-method.json b/fixtures/tools.tool/invalid/run-get-method.json
nnew file mode 100644
iindex 00000000..08d7f17c
---- /dev/null
++++ b/fixtures/tools.tool/invalid/run-get-method.json
@@@ -0,0 +1,43 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "GET",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/sync-tool-with-job.json b/fixtures/tools.tool/invalid/sync-tool-with-job.json
nnew file mode 100644
iindex 00000000..bdfc43c3
---- /dev/null
++++ b/fixtures/tools.tool/invalid/sync-tool-with-job.json
@@@ -0,0 +1,46 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": {
++ "type": "net.lookup",
++ "operation": "dns"
++ },
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/unavailable-without-reason.json b/fixtures/tools.tool/invalid/unavailable-without-reason.json
nnew file mode 100644
iindex 00000000..d1a2439b
---- /dev/null
++++ b/fixtures/tools.tool/invalid/unavailable-without-reason.json
@@@ -0,0 +1,59 @@
++{
++ "id": "protectpdf",
++ "family": "docs",
++ "name": "Protect PDF",
++ "summary": "Put a password on a PDF (AES-256).",
++ "status": "unavailable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/protectpdf/run",
++ "job": {
++ "type": "docs.process",
++ "operation": "protect"
++ }
++ },
++ "input": {
++ "type": "object",
++ "required": [
++ "password"
++ ],
++ "properties": {
++ "password": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 128
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "application/pdf"
++ ],
++ "maxBytes": 104857600
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "application/pdf"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 300000,
++ "maxPages": 2000
++ },
++ "auth": {
++ "anonymous": false,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 10,
++ "egress": false,
++ "hosts": [
++ "protectpdf.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/protectpdf"
++}
ddiff --git a/fixtures/tools.tool/invalid/unknown-capability.json b/fixtures/tools.tool/invalid/unknown-capability.json
nnew file mode 100644
iindex 00000000..684e1073
---- /dev/null
++++ b/fixtures/tools.tool/invalid/unknown-capability.json
@@@ -0,0 +1,43 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.job.create"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/unknown-status.json b/fixtures/tools.tool/invalid/unknown-status.json
nnew file mode 100644
iindex 00000000..d9ec35d3
---- /dev/null
++++ b/fixtures/tools.tool/invalid/unknown-status.json
@@@ -0,0 +1,43 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "deprecated",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/invalid/uppercase-id.json b/fixtures/tools.tool/invalid/uppercase-id.json
nnew file mode 100644
iindex 00000000..8a982bdd
---- /dev/null
++++ b/fixtures/tools.tool/invalid/uppercase-id.json
@@@ -0,0 +1,43 @@
++{
++ "id": "DNS",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/valid/dns-sync-egress.json b/fixtures/tools.tool/valid/dns-sync-egress.json
nnew file mode 100644
iindex 00000000..28bbb228
---- /dev/null
++++ b/fixtures/tools.tool/valid/dns-sync-egress.json
@@@ -0,0 +1,43 @@
++{
++ "id": "dns",
++ "family": "net",
++ "name": "DNS Lookup",
++ "summary": "Look up A, AAAA, MX, TXT, NS, CNAME and other records for a domain.",
++ "status": "stable",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/dns/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/dns/:target"
++ ]
++ },
++ "input": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/input"
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "$ref": "https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output"
++ }
++ },
++ "limits": {
++ "timeoutMs": 10000,
++ "maxInputBytes": 1024,
++ "perTargetPerMinute": 30
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-fetch",
++ "cost": 1,
++ "egress": true,
++ "hosts": [
++ "dns.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/dns"
++}
ddiff --git a/fixtures/tools.tool/valid/jsonminify-client-engine.json b/fixtures/tools.tool/valid/jsonminify-client-engine.json
nnew file mode 100644
iindex 00000000..33ce0274
---- /dev/null
++++ b/fixtures/tools.tool/valid/jsonminify-client-engine.json
@@@ -0,0 +1,47 @@
++{
++ "id": "jsonminify",
++ "family": "dev",
++ "name": "JSON Minifier",
++ "summary": "Strip the whitespace out of JSON.",
++ "status": "stable",
++ "execution": "client",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/jsonminify/run",
++ "job": null
++ },
++ "input": {
++ "type": "object",
++ "required": [
++ "text"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "text": {
++ "type": "string",
++ "maxLength": 1048576
++ }
++ }
++ },
++ "files": null,
++ "output": {
++ "kind": "text"
++ },
++ "limits": {
++ "timeoutMs": 5000,
++ "maxInputBytes": 1048576
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-run",
++ "cost": 1,
++ "egress": false,
++ "hosts": [
++ "json-minifier.openvibe.tools",
++ "jsonminify.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/jsonminify"
++}
ddiff --git a/fixtures/tools.tool/valid/logo-client-only.json b/fixtures/tools.tool/valid/logo-client-only.json
nnew file mode 100644
iindex 00000000..bde275aa
---- /dev/null
++++ b/fixtures/tools.tool/valid/logo-client-only.json
@@@ -0,0 +1,33 @@
++{
++ "id": "logo",
++ "family": "text",
++ "name": "Logo Maker",
++ "summary": "Make a text logo in the browser.",
++ "status": "stable",
++ "execution": "client",
++ "api": false,
++ "run": null,
++ "input": null,
++ "files": null,
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png",
++ "image/svg+xml"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 1000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-run",
++ "cost": 1,
++ "egress": false,
++ "hosts": [
++ "logo.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/logo"
++}
ddiff --git a/fixtures/tools.tool/valid/png-job.json b/fixtures/tools.tool/valid/png-job.json
nnew file mode 100644
iindex 00000000..f45b1517
---- /dev/null
++++ b/fixtures/tools.tool/valid/png-job.json
@@@ -0,0 +1,69 @@
++{
++ "id": "png",
++ "family": "img",
++ "name": "PNG Converter",
++ "summary": "Convert JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF and more to PNG.",
++ "status": "stable",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/png/run",
++ "job": {
++ "type": "img.process",
++ "operation": "convert",
++ "preset": {
++ "format": "png"
++ }
++ },
++ "legacy": [
++ "POST /api/process",
++ "POST /api/process/direct"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "compressionLevel": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 9
++ },
++ "width": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 16384
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "image/*"
++ ],
++ "maxBytes": 52428800
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "image/png"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 120000,
++ "maxPixels": 100000000
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 5,
++ "egress": false,
++ "hosts": [
++ "png.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/png"
++}
ddiff --git a/fixtures/tools.tool/valid/port-probe.json b/fixtures/tools.tool/valid/port-probe.json
nnew file mode 100644
iindex 00000000..ac472a8c
---- /dev/null
++++ b/fixtures/tools.tool/valid/port-probe.json
@@@ -0,0 +1,68 @@
++{
++ "id": "port",
++ "family": "net",
++ "name": "Port Checker",
++ "summary": "Check whether TCP ports on a host are open.",
++ "status": "beta",
++ "execution": "sync",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/port/run",
++ "job": null,
++ "legacy": [
++ "GET /api/net/port/:target"
++ ]
++ },
++ "input": {
++ "type": "object",
++ "required": [
++ "host",
++ "ports"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "host": {
++ "type": "string",
++ "maxLength": 253
++ },
++ "ports": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 20,
++ "items": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 65535
++ }
++ }
++ }
++ },
++ "files": null,
++ "output": {
++ "kind": "json",
++ "schema": {
++ "type": "object",
++ "properties": {
++ "results": {
++ "type": "array"
++ }
++ }
++ }
++ },
++ "limits": {
++ "timeoutMs": 15000,
++ "perTargetPerMinute": 6
++ },
++ "auth": {
++ "anonymous": false,
++ "capability": "tools.net.probe"
++ },
++ "quotaClass": "tools-probe",
++ "cost": 5,
++ "egress": true,
++ "hosts": [
++ "port.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/port"
++}
ddiff --git a/fixtures/tools.tool/valid/protectpdf-unavailable.json b/fixtures/tools.tool/valid/protectpdf-unavailable.json
nnew file mode 100644
iindex 00000000..17405837
---- /dev/null
++++ b/fixtures/tools.tool/valid/protectpdf-unavailable.json
@@@ -0,0 +1,60 @@
++{
++ "id": "protectpdf",
++ "family": "docs",
++ "name": "Protect PDF",
++ "summary": "Put a password on a PDF (AES-256).",
++ "status": "unavailable",
++ "statusReason": "Needs qpdf on the host; not deployed yet.",
++ "execution": "job",
++ "api": true,
++ "run": {
++ "method": "POST",
++ "path": "/api/v1/tools/protectpdf/run",
++ "job": {
++ "type": "docs.process",
++ "operation": "protect"
++ }
++ },
++ "input": {
++ "type": "object",
++ "required": [
++ "password"
++ ],
++ "properties": {
++ "password": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 128
++ }
++ }
++ },
++ "files": {
++ "min": 1,
++ "max": 1,
++ "accept": [
++ "application/pdf"
++ ],
++ "maxBytes": 104857600
++ },
++ "output": {
++ "kind": "file",
++ "mime": [
++ "application/pdf"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 300000,
++ "maxPages": 2000
++ },
++ "auth": {
++ "anonymous": false,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-job",
++ "cost": 10,
++ "egress": false,
++ "hosts": [
++ "protectpdf.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/protectpdf"
++}
ddiff --git a/fixtures/tools.tool/valid/yt-page-only.json b/fixtures/tools.tool/valid/yt-page-only.json
nnew file mode 100644
iindex 00000000..a8786b41
---- /dev/null
++++ b/fixtures/tools.tool/valid/yt-page-only.json
@@@ -0,0 +1,36 @@
++{
++ "id": "yt",
++ "family": "media",
++ "name": "YouTube Downloader",
++ "summary": "Save a YouTube video or its audio.",
++ "status": "stable",
++ "execution": "job",
++ "api": false,
++ "run": null,
++ "input": null,
++ "files": null,
++ "output": {
++ "kind": "file",
++ "mime": [
++ "video/mp4",
++ "audio/mpeg"
++ ]
++ },
++ "limits": {
++ "timeoutMs": 900000,
++ "maxDurationSec": 3600,
++ "perTargetPerMinute": 20
++ },
++ "auth": {
++ "anonymous": true,
++ "capability": "tools.tool.run"
++ },
++ "quotaClass": "tools-download",
++ "cost": 50,
++ "egress": true,
++ "hosts": [
++ "youtube-downloader.openvibe.tools",
++ "yt.openvibe.tools"
++ ],
++ "docs": "https://openvibe.tools/tool/yt"
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex ab88c492..e2cd5800 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -7799,12 +7799,12 @@
"tools.job.created": {
"version": "1.0.0",
"owner": "tools",
-- "status": "planned",
++ "status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/tools.job.created.v1.json",
"title": "ToolsJobCreatedPayload",
-- "description": "tools.job.created v1 (OpenVibe.Tools apps/_shared/jobs, planned). A job was accepted and queued (POST /api/v1/jobs, or a retry of a failed job, which carries retry_of). An Idempotency-Key replay creates no job and no event. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor the owner when it is a subject (user, service, app or mod), else service:tools.",
++ "description": "tools.job.created v1 (OpenVibe.Tools apps/_shared/jobs/events.js). A job was accepted and queued (POST /api/v1/jobs, or a retry of a failed job, which carries retry_of). An Idempotency-Key replay creates no job and no event. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor the owner when it is a subject (user, service, app or mod), else service:tools.",
"type": "object",
"required": [
"job_id",
@@@ -7879,12 +7879,12 @@
"tools.job.started": {
"version": "1.0.0",
"owner": "tools",
-- "status": "planned",
++ "status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/tools.job.started.v1.json",
"title": "ToolsJobStartedPayload",
-- "description": "tools.job.started v1 (OpenVibe.Tools apps/_shared/jobs, planned). A worker claimed the job and it is running (attempts counts this attempt). A job requeued after a restart starts again and is announced again. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor service:tools.",
++ "description": "tools.job.started v1 (OpenVibe.Tools apps/_shared/jobs/events.js). A worker claimed the job and it is running (attempts counts this attempt). A job requeued after a restart starts again and is announced again. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor service:tools.",
"type": "object",
"required": [
"job_id",
@@@ -7964,12 +7964,12 @@
"tools.job.succeeded": {
"version": "1.0.0",
"owner": "tools",
-- "status": "planned",
++ "status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/tools.job.succeeded.v1.json",
"title": "ToolsJobSucceededPayload",
-- "description": "tools.job.succeeded v1 (OpenVibe.Tools apps/_shared/jobs, planned). The job finished and its result files are stored (in Media when TOOLS_JOB_RESULTS=media). Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.",
++ "description": "tools.job.succeeded v1 (OpenVibe.Tools apps/_shared/jobs/events.js). The job finished and its result files are stored (in Media when TOOLS_JOB_RESULTS=media). Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.",
"type": "object",
"required": [
"job_id",
@@@ -8128,12 +8128,12 @@
"tools.job.failed": {
"version": "1.0.0",
"owner": "tools",
-- "status": "planned",
++ "status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/tools.job.failed.v1.json",
"title": "ToolsJobFailedPayload",
-- "description": "tools.job.failed v1 (OpenVibe.Tools apps/_shared/jobs, planned). The job failed: the tool refused the input, it timed out, or it was running when the satellite restarted and its type does not requeue. A cancelled job is not a failure and is not announced as one. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.",
++ "description": "tools.job.failed v1 (OpenVibe.Tools apps/_shared/jobs/events.js). The job failed: the tool refused the input, it timed out, or it was running when the satellite restarted and its type does not requeue. A cancelled job is not a failure and is not announced as one. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.",
"type": "object",
"required": [
"job_id",
@@@ -9634,6 +9634,1382 @@
}
}
}
++ },
++ "tools.tool": {
++ "version": "1.0.0",
++ "owner": "tools",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/tool.v1.json",
++ "title": "ToolDescriptor",
++ "description": "One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. RULES this schema enforces: api false has no run, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets) and files.min <= files.max.",
++ "type": "object",
++ "required": [
++ "id",
++ "family",
++ "name",
++ "summary",
++ "status",
++ "execution",
++ "api",
++ "run",
++ "input",
++ "files",
++ "output",
++ "limits",
++ "auth",
++ "quotaClass",
++ "cost",
++ "egress",
++ "hosts",
++ "docs"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "The tool's catalogue id, which is its subdomain label (png, jsminify, dns, pdf2jpg). It never changes."
++ },
++ "family": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "The catalogue family: net, dev, img, audio, docs, text, media, places, pastes…"
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "summary": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "One plain sentence: what the tool does."
++ },
++ "status": {
++ "enum": [
++ "stable",
++ "beta",
++ "preview",
++ "unavailable"
++ ],
++ "description": "stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason)."
++ },
++ "statusReason": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 300,
++ "description": "Why the status is what it is, for people (e.g. 'needs qpdf on the host'). Required when unavailable."
++ },
++ "execution": {
++ "enum": [
++ "client",
++ "sync",
++ "job"
++ ],
++ "description": "Where the engine runs for the tool's page: client (the browser), sync (a server request answered inline), job (an asynchronous job)."
++ },
++ "api": {
++ "type": "boolean",
++ "description": "POST /api/v1/tools/{id}/run exposes the tool. false for a client tool without a server engine and for page-only tools (yt)."
++ },
++ "run": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "method",
++ "path",
++ "job"
++ ],
++ "additionalProperties": false,
++ "description": "How to call it; null when api is false.",
++ "properties": {
++ "method": {
++ "const": "POST"
++ },
++ "path": {
++ "type": "string",
++ "pattern": "^/api/v1/tools/[a-z][a-z0-9-]{0,39}/run$",
++ "description": "/api/v1/tools/{id}/run on https://openvibe.tools; the body is tools.run-request@1, the answer tools.run@1."
++ },
++ "job": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "type",
++ "operation"
++ ],
++ "additionalProperties": false,
++ "description": "For a job tool: the job the run creates. null for tools answered inline.",
++ "properties": {
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type, e.g. img.process."
++ },
++ "operation": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_-]{0,39}$",
++ "description": "The job input's tool field: convert, compress, trim, mergepdf…"
++ },
++ "preset": {
++ "type": "object",
++ "description": "Input fields the tool itself fixes, applied over the caller's input (png: { format: png })."
++ }
++ }
++ },
++ "legacy": {
++ "type": "array",
++ "uniqueItems": true,
++ "maxItems": 32,
++ "items": {
++ "type": "string",
++ "pattern": "^(GET|POST|PUT|DELETE) /[^\\s]*$"
++ },
++ "description": "Older routes that run the same engine (GET /api/net/dns/:target, POST /api/process…). They keep working until their Sunset date and answer with Deprecation, Sunset and Link: rel=successor-version pointing at path."
++ }
++ }
++ },
++ "input": {
++ "description": "JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "$ref"
++ ],
++ "maxProperties": 1,
++ "properties": {
++ "$ref": {
++ "type": "string",
++ "format": "uri"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type"
++ ],
++ "properties": {
++ "type": {
++ "const": "object"
++ }
++ }
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "files": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "min",
++ "max",
++ "accept",
++ "maxBytes"
++ ],
++ "additionalProperties": false,
++ "description": "The files a run takes (multipart parts, or tools.run-request@1 files references); null when it takes none.",
++ "properties": {
++ "min": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 100
++ },
++ "max": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 100
++ },
++ "accept": {
++ "type": "array",
++ "minItems": 1,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z]+/([a-z0-9][a-z0-9.+-]*|\\*)$"
++ },
++ "description": "Accepted media types, checked against the bytes, not the client's Content-Type (image/png, image/*, application/pdf…)."
++ },
++ "maxBytes": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Largest accepted file."
++ }
++ }
++ },
++ "output": {
++ "type": "object",
++ "required": [
++ "kind"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "kind": {
++ "enum": [
++ "json",
++ "file",
++ "files",
++ "text"
++ ],
++ "description": "json: result.data; text: result.text; file or files: result.files (job result files)."
++ },
++ "schema": {
++ "description": "JSON Schema of result.data (kind json): embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.",
++ "anyOf": [
++ {
++ "type": "object",
++ "required": [
++ "$ref"
++ ],
++ "maxProperties": 1,
++ "properties": {
++ "$ref": {
++ "type": "string",
++ "format": "uri"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type"
++ ],
++ "properties": {
++ "type": {
++ "const": "object"
++ }
++ }
++ }
++ ]
++ },
++ "mime": {
++ "type": "array",
++ "minItems": 1,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z]+/([a-z0-9][a-z0-9.+-]*|\\*)$"
++ },
++ "description": "Media types the result files can have (kind file or files)."
++ }
++ },
++ "if": {
++ "properties": {
++ "kind": {
++ "const": "json"
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ },
++ "then": {
++ "required": [
++ "schema"
++ ]
++ }
++ },
++ "limits": {
++ "type": "object",
++ "required": [
++ "timeoutMs"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "timeoutMs": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 3600000,
++ "description": "A run (or its job) longer than this fails with tools.job.timeout or 504."
++ },
++ "maxDurationSec": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Longest audio or video input."
++ },
++ "maxPixels": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Largest image input (width × height)."
++ },
++ "maxPages": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Most PDF pages."
++ },
++ "maxInputBytes": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Largest input as JSON (text to transform, lists of hosts…)."
++ },
++ "perTargetPerMinute": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Egress tools: most runs per minute against one target host or address, across all callers."
++ }
++ }
++ },
++ "auth": {
++ "type": "object",
++ "required": [
++ "anonymous",
++ "capability"
++ ],
++ "additionalProperties": false,
++ "description": "Caller tiers: anonymous < session < user < app/service.",
++ "properties": {
++ "anonymous": {
++ "type": "boolean",
++ "description": "true: a caller with no token and no browser session may run it (keyed by IP, IPv6 by /64). false: a browser session (the ov_tools_jobs cookie), a signed-in person or a token is needed."
++ },
++ "capability": {
++ "enum": [
++ "tools.tool.run",
++ "tools.net.probe"
++ ],
++ "description": "What an app or service token must hold. tools.tool.run is public. tools.net.probe (partner) covers network probes: only principals holding it run these through the API; people use their pages, which have a per-target throttle."
++ }
++ }
++ },
++ "quotaClass": {
++ "type": "string",
++ "pattern": "^tools-[a-z0-9]+(-[a-z0-9]+)*$",
++ "description": "The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names."
++ },
++ "cost": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 1000,
++ "description": "Relative weight of one run within its quota class (1 = a cheap lookup or transform). Quotas count weight, not requests."
++ },
++ "egress": {
++ "type": "boolean",
++ "description": "The server fetches a host or URL the caller chose (DNS, WHOIS, headers, Open Graph, ports…), always through the SSRF guard."
++ },
++ "hosts": {
++ "type": "array",
++ "uniqueItems": true,
++ "maxItems": 50,
++ "items": {
++ "type": "string",
++ "pattern": "^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$"
++ },
++ "description": "The tool's public hostnames, canonical first (png.openvibe.tools, a custom domain…); empty for a tool that lives on another service."
++ },
++ "docs": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https://",
++ "description": "Where a person reads about the tool."
++ }
++ },
++ "if": {
++ "required": [
++ "api"
++ ]
++ },
++ "then": {
++ "$comment": "The RULES in the description, as one conditional so generated types stay plain (every descriptor has api).",
++ "allOf": [
++ {
++ "if": {
++ "properties": {
++ "api": {
++ "const": false
++ }
++ },
++ "required": [
++ "api"
++ ]
++ },
++ "then": {
++ "properties": {
++ "run": {
++ "type": "null"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "api": {
++ "const": true
++ }
++ },
++ "required": [
++ "api"
++ ]
++ },
++ "then": {
++ "properties": {
++ "run": {
++ "type": "object"
++ },
++ "input": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "api": {
++ "const": true
++ },
++ "execution": {
++ "const": "job"
++ }
++ },
++ "required": [
++ "api",
++ "execution"
++ ]
++ },
++ "then": {
++ "properties": {
++ "run": {
++ "type": "object",
++ "properties": {
++ "job": {
++ "type": "object"
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "execution": {
++ "enum": [
++ "client",
++ "sync"
++ ]
++ }
++ },
++ "required": [
++ "execution"
++ ]
++ },
++ "then": {
++ "properties": {
++ "run": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "properties": {
++ "job": {
++ "type": "null"
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "api": {
++ "const": true
++ },
++ "output": {
++ "type": "object",
++ "properties": {
++ "kind": {
++ "enum": [
++ "file",
++ "files"
++ ]
++ }
++ },
++ "required": [
++ "kind"
++ ]
++ }
++ },
++ "required": [
++ "api",
++ "output"
++ ]
++ },
++ "then": {
++ "properties": {
++ "execution": {
++ "const": "job"
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "auth": {
++ "type": "object",
++ "properties": {
++ "capability": {
++ "const": "tools.net.probe"
++ }
++ },
++ "required": [
++ "capability"
++ ]
++ }
++ },
++ "required": [
++ "auth"
++ ]
++ },
++ "then": {
++ "properties": {
++ "egress": {
++ "const": true
++ },
++ "auth": {
++ "type": "object",
++ "properties": {
++ "anonymous": {
++ "const": false
++ }
++ }
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "egress": {
++ "const": true
++ },
++ "auth": {
++ "type": "object",
++ "properties": {
++ "anonymous": {
++ "const": true
++ }
++ },
++ "required": [
++ "anonymous"
++ ]
++ }
++ },
++ "required": [
++ "egress",
++ "auth"
++ ]
++ },
++ "then": {
++ "properties": {
++ "limits": {
++ "type": "object",
++ "required": [
++ "perTargetPerMinute"
++ ]
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "execution": {
++ "const": "client"
++ }
++ },
++ "required": [
++ "execution"
++ ]
++ },
++ "then": {
++ "properties": {
++ "egress": {
++ "const": false
++ }
++ }
++ }
++ },
++ {
++ "if": {
++ "properties": {
++ "status": {
++ "const": "unavailable"
++ }
++ },
++ "required": [
++ "status"
++ ]
++ },
++ "then": {
++ "required": [
++ "statusReason"
++ ]
++ }
++ }
++ ]
++ }
++ }
++ },
++ "tools.tool-list": {
++ "version": "1.0.0",
++ "owner": "tools",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/tool-list.v1.json",
++ "title": "ToolList",
++ "description": "The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (name, summary and keywords). Planned placeholders are not tools and are not listed. Cacheable (ETag); updated_at changes when any descriptor does.",
++ "type": "object",
++ "required": [
++ "tools",
++ "count",
++ "updated_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "tools": {
++ "type": "array",
++ "items": {
++ "$ref": "tool.v1.json"
++ }
++ },
++ "count": {
++ "type": "integer",
++ "minimum": 0,
++ "description": "The number of tools in this answer."
++ },
++ "updated_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "families": {
++ "type": "array",
++ "description": "The families of the tools in this answer.",
++ "items": {
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "count"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$"
++ },
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 80
++ },
++ "count": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "tools.run-request": {
++ "version": "1.0.0",
++ "owner": "tools",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/run-request.v1.json",
++ "title": "ToolsRunRequest",
++ "description": "Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable.",
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "input": {
++ "type": "object",
++ "description": "The tool's parameters, matching its input schema. Default {}."
++ },
++ "files": {
++ "type": "array",
++ "maxItems": 50,
++ "description": "Files that are already somewhere, instead of or besides uploaded parts.",
++ "items": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "media_id"
++ ],
++ "additionalProperties": false,
++ "description": "A Media object the caller may read. Tools reads it for the caller.",
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "job_id",
++ "index"
++ ],
++ "additionalProperties": false,
++ "description": "Result file index of a job the same caller owns (GET /api/v1/jobs/:id/files/:n), so one tool's output feeds the next.",
++ "properties": {
++ "job_id": {
++ "type": "string",
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "index": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 99
++ }
++ }
++ }
++ ]
++ }
++ },
++ "wait_ms": {
++ "type": "integer",
++ "minimum": 0,
++ "maximum": 60000,
++ "description": "Job tools: wait up to this long for the job to finish before answering (200 finished, else 202 with the job). Default 0. Inline tools always answer finished."
++ },
++ "idempotency_key": {
++ "type": "string",
++ "pattern": "^[!-~]{8,200}$",
++ "description": "Job tools: the same (caller, key) and request returns the same job; a different request under it is 409 tools.job.idempotency_conflict. Inline tools ignore it."
++ }
++ }
++ }
++ },
++ "tools.run": {
++ "version": "1.0.0",
++ "owner": "tools",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/run.v1.json",
++ "title": "ToolsRun",
++ "description": "The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…). A request refused before the tool ran is a problem+json answer, not a run.",
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "state",
++ "tool",
++ "result",
++ "took_ms"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "state": {
++ "const": "succeeded"
++ },
++ "tool": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "result": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "data": {
++ "type": "object",
++ "description": "Output data; matches the descriptor's output.schema when output.kind is json."
++ },
++ "text": {
++ "type": "string",
++ "description": "Output text (output.kind text)."
++ },
++ "files": {
++ "type": "array",
++ "maxItems": 100,
++ "description": "Result files (output.kind file or files), exactly as the job lists them.",
++ "items": {
++ "type": "object",
++ "required": [
++ "name",
++ "mime",
++ "size",
++ "sha256",
++ "storage",
++ "media",
++ "url"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "mime": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "size": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "sha256": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "storage": {
++ "enum": [
++ "local",
++ "media"
++ ],
++ "description": "media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires."
++ },
++ "media": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "media_id"
++ ],
++ "additionalProperties": false,
++ "description": "The Media object, when storage is media.",
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "role": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,39}$"
++ },
++ "namespace": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,63}$"
++ },
++ "size_bytes": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "content_hash": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "mime_type": {
++ "type": "string",
++ "maxLength": 200
++ },
++ "status": {
++ "type": "string",
++ "maxLength": 40,
++ "description": "The object's Media lifecycle status when it was stored (ready)."
++ }
++ }
++ },
++ "media_error": {
++ "type": "string",
++ "maxLength": 500,
++ "description": "Present when storing the file in Media failed; the file stayed local."
++ },
++ "url": {
++ "type": "string",
++ "pattern": "^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/files/\\d+$",
++ "description": "Where the owner downloads it."
++ }
++ }
++ }
++ }
++ }
++ },
++ "took_ms": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "job": {
++ "oneOf": [
++ {
++ "$ref": "job.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "state",
++ "tool",
++ "error",
++ "took_ms"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "state": {
++ "enum": [
++ "failed",
++ "cancelled"
++ ]
++ },
++ "tool": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "error": {
++ "$ref": "../errors/problem.v1.json"
++ },
++ "took_ms": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "job": {
++ "oneOf": [
++ {
++ "$ref": "job.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "state",
++ "tool",
++ "job",
++ "location"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "state": {
++ "enum": [
++ "queued",
++ "running"
++ ]
++ },
++ "tool": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "job": {
++ "$ref": "job.v1.json"
++ },
++ "location": {
++ "type": "string",
++ "format": "uri-reference",
++ "pattern": "/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job (also the Location header): /api/v1/jobs/:id on the gateway."
++ }
++ }
++ }
++ ]
++ }
++ },
++ "tools.job": {
++ "version": "1.0.0",
++ "owner": "tools",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/job.v1.json",
++ "title": "ToolsJob",
++ "description": "A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), with server paths removed.",
++ "type": "object",
++ "required": [
++ "id",
++ "object",
++ "service",
++ "type",
++ "type_version",
++ "state",
++ "progress",
++ "attempts",
++ "max_attempts",
++ "cancel_requested",
++ "created_at",
++ "started_at",
++ "finished_at",
++ "expires_at",
++ "result",
++ "error",
++ "retryable",
++ "retry_of",
++ "retried_by",
++ "references",
++ "links"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "object": {
++ "const": "tools.job"
++ },
++ "service": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "The Tools satellite that runs the job (img, audio, docs…)."
++ },
++ "tool": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$",
++ "description": "The tool (tools.tool@1 id) whose run created the job, when it came through POST /api/v1/tools/{id}/run. Absent for a job submitted to POST /api/v1/jobs directly."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type, e.g. img.process."
++ },
++ "type_version": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "state": {
++ "enum": [
++ "queued",
++ "running",
++ "succeeded",
++ "failed",
++ "cancelled"
++ ],
++ "description": "Terminal: succeeded, failed, cancelled."
++ },
++ "progress": {
++ "type": "object",
++ "required": [
++ "percent",
++ "message"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "percent": {
++ "type": [
++ "number",
++ "null"
++ ],
++ "minimum": 0,
++ "maximum": 100
++ },
++ "message": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 500
++ }
++ }
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "max_attempts": {
++ "type": "integer",
++ "minimum": 1
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "DELETE asked a running job to stop; it ends cancelled."
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "started_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "finished_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time"
++ },
++ "expires_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "When the job, its events and its result files are pruned; null while a reference keeps the result."
++ },
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "files",
++ "data"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "files": {
++ "type": "array",
++ "maxItems": 100,
++ "items": {
++ "type": "object",
++ "required": [
++ "name",
++ "mime",
++ "size",
++ "sha256",
++ "storage",
++ "media",
++ "url"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "name": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "mime": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ },
++ "size": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "sha256": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "storage": {
++ "enum": [
++ "local",
++ "media"
++ ],
++ "description": "media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires."
++ },
++ "media": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "required": [
++ "media_id"
++ ],
++ "additionalProperties": false,
++ "description": "The Media object, when storage is media.",
++ "properties": {
++ "media_id": {
++ "type": "string",
++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "role": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,39}$"
++ },
++ "namespace": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_.-]{0,63}$"
++ },
++ "size_bytes": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "content_hash": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{64}$"
++ },
++ "mime_type": {
++ "type": "string",
++ "maxLength": 200
++ },
++ "status": {
++ "type": "string",
++ "maxLength": 40,
++ "description": "The object's Media lifecycle status when it was stored (ready)."
++ }
++ }
++ },
++ "media_error": {
++ "type": "string",
++ "maxLength": 500,
++ "description": "Present when storing the file in Media failed; the file stayed local."
++ },
++ "url": {
++ "type": "string",
++ "pattern": "^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/files/\\d+$",
++ "description": "Where the owner downloads it."
++ }
++ }
++ }
++ },
++ "data": {
++ "type": "object",
++ "description": "The tool's output data (dimensions, durations, page counts…); {} when it has none."
++ }
++ }
++ },
++ "error": {
++ "oneOf": [
++ {
++ "$ref": "../errors/problem.v1.json"
++ },
++ {
++ "type": "null"
++ }
++ ]
++ },
++ "retryable": {
++ "type": "boolean",
++ "description": "Retrying may succeed (a timeout, a restart); POST links.retry makes a new job."
++ },
++ "retry_of": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The failed job this one retries."
++ },
++ "retried_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^job_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job that retried this failed one; asking again returns it."
++ },
++ "references": {
++ "type": "array",
++ "maxItems": 50,
++ "description": "What keeps the result (PUT /api/v1/jobs/:id/references/:ref), e.g. community:paste:p_123.",
++ "items": {
++ "type": "object",
++ "required": [
++ "ref",
++ "created_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "ref": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_-]*(?::[A-Za-z0-9_.-]+){1,4}$",
++ "maxLength": 200
++ },
++ "created_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "links": {
++ "type": "object",
++ "required": [
++ "self",
++ "events",
++ "cancel",
++ "retry",
++ "retried_by"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "self": {
++ "type": "string",
++ "format": "uri-reference"
++ },
++ "events": {
++ "type": "string",
++ "format": "uri-reference"
++ },
++ "cancel": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri-reference",
++ "description": "DELETE it while the job is queued or running."
++ },
++ "retry": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri-reference",
++ "description": "POST it when the job failed."
++ },
++ "retried_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri-reference"
++ }
++ }
++ }
++ }
++ }
++ },
++ "tools.job-request": {
++ "version": "1.0.0",
++ "owner": "tools",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/tools/job-request.v1.json",
++ "title": "ToolsJobRequest",
++ "description": "Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner) or a rate limit, 503 tools.job.unavailable.",
++ "type": "object",
++ "required": [
++ "type"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: img.process, audio.process, docs.process."
++ },
++ "input": {
++ "type": "object",
++ "description": "The job type's input; { tool: <operation>, …options }. Default {}; at most 16 KB as JSON."
++ },
++ "idempotency_key": {
++ "type": "string",
++ "pattern": "^[!-~]{8,200}$",
++ "description": "8-200 printable ASCII characters. The Idempotency-Key header is used when both are present."
++ }
++ }
++ }
}
}
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex f6a4d9de..e4438d63 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -6721,7 +6721,7 @@ export interface TradeAlertTriggeredPayload {
/** [email protected] (owner: tools) */
/**
-- * tools.job.created v1 (OpenVibe.Tools apps/_shared/jobs, planned). A job was accepted and queued (POST /api/v1/jobs, or a retry of a failed job, which carries retry_of). An Idempotency-Key replay creates no job and no event. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor the owner when it is a subject (user, service, app or mod), else service:tools.
++ * tools.job.created v1 (OpenVibe.Tools apps/_shared/jobs/events.js). A job was accepted and queued (POST /api/v1/jobs, or a retry of a failed job, which carries retry_of). An Idempotency-Key replay creates no job and no event. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor the owner when it is a subject (user, service, app or mod), else service:tools.
*/
export interface ToolsJobCreatedPayload {
job_id: string;
@@@ -6750,7 +6750,7 @@ export interface ToolsJobCreatedPayload {
/** [email protected] (owner: tools) */
/**
-- * tools.job.started v1 (OpenVibe.Tools apps/_shared/jobs, planned). A worker claimed the job and it is running (attempts counts this attempt). A job requeued after a restart starts again and is announced again. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor service:tools.
++ * tools.job.started v1 (OpenVibe.Tools apps/_shared/jobs/events.js). A worker claimed the job and it is running (attempts counts this attempt). A job requeued after a restart starts again and is announced again. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority low, actor service:tools.
*/
export interface ToolsJobStartedPayload {
job_id: string;
@@@ -6780,7 +6780,7 @@ export interface ToolsJobStartedPayload {
/** [email protected] (owner: tools) */
/**
-- * tools.job.succeeded v1 (OpenVibe.Tools apps/_shared/jobs, planned). The job finished and its result files are stored (in Media when TOOLS_JOB_RESULTS=media). Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.
++ * tools.job.succeeded v1 (OpenVibe.Tools apps/_shared/jobs/events.js). The job finished and its result files are stored (in Media when TOOLS_JOB_RESULTS=media). Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.
*/
export interface ToolsJobSucceededPayload {
job_id: string;
@@@ -6840,7 +6840,7 @@ export interface ToolsJobSucceededPayload {
/** [email protected] (owner: tools) */
/**
-- * tools.job.failed v1 (OpenVibe.Tools apps/_shared/jobs, planned). The job failed: the tool refused the input, it timed out, or it was running when the satellite restarted and its type does not requeue. A cancelled job is not a failure and is not announced as one. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.
++ * tools.job.failed v1 (OpenVibe.Tools apps/_shared/jobs/events.js). The job failed: the tool refused the input, it timed out, or it was running when the satellite restarted and its type does not requeue. A cancelled job is not a failure and is not announced as one. Emitted in the transaction that records the transition, beside the job's own SSE event. Carries ids, type, owner, state, times and result locations; never the job's input, its files, its output data or a session. Jobs of sandbox app tokens are not announced. Envelope: subject { type: job, id: <job_id> }, visibility internal, priority important, actor service:tools.
*/
export interface ToolsJobFailedPayload {
job_id: string;
@@@ -7502,3 +7502,452 @@ export interface NetworkModuleUpdatedPayload {
id: string;
};
}
++
++/** [email protected] (owner: tools) */
++/**
++ * One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. RULES this schema enforces: api false has no run, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets) and files.min <= files.max.
++ */
++export interface ToolDescriptor {
++ /**
++ * The tool's catalogue id, which is its subdomain label (png, jsminify, dns, pdf2jpg). It never changes.
++ */
++ id: string;
++ /**
++ * The catalogue family: net, dev, img, audio, docs, text, media, places, pastes…
++ */
++ family: string;
++ name: string;
++ /**
++ * One plain sentence: what the tool does.
++ */
++ summary: string;
++ /**
++ * stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason).
++ */
++ status: "stable" | "beta" | "preview" | "unavailable";
++ /**
++ * Why the status is what it is, for people (e.g. 'needs qpdf on the host'). Required when unavailable.
++ */
++ statusReason?: string;
++ /**
++ * Where the engine runs for the tool's page: client (the browser), sync (a server request answered inline), job (an asynchronous job).
++ */
++ execution: "client" | "sync" | "job";
++ /**
++ * POST /api/v1/tools/{id}/run exposes the tool. false for a client tool without a server engine and for page-only tools (yt).
++ */
++ api: boolean;
++ /**
++ * How to call it; null when api is false.
++ */
++ run: {
++ method: "POST";
++ /**
++ * /api/v1/tools/{id}/run on https://openvibe.tools; the body is tools.run-request@1, the answer tools.run@1.
++ */
++ path: string;
++ /**
++ * For a job tool: the job the run creates. null for tools answered inline.
++ */
++ job: {
++ /**
++ * Job type, e.g. img.process.
++ */
++ type: string;
++ /**
++ * The job input's tool field: convert, compress, trim, mergepdf…
++ */
++ operation: string;
++ /**
++ * Input fields the tool itself fixes, applied over the caller's input (png: { format: png }).
++ */
++ preset?: {};
++ } | null;
++ /**
++ * Older routes that run the same engine (GET /api/net/dns/:target, POST /api/process…). They keep working until their Sunset date and answer with Deprecation, Sunset and Link: rel=successor-version pointing at path.
++ *
++ * @maxItems 32
++ */
++ legacy?: string[];
++ } | null;
++ /**
++ * JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API.
++ */
++ input:
++ | {
++ $ref: string;
++ }
++ | {
++ type: "object";
++ }
++ | null;
++ /**
++ * The files a run takes (multipart parts, or tools.run-request@1 files references); null when it takes none.
++ */
++ files: {
++ min: number;
++ max: number;
++ /**
++ * Accepted media types, checked against the bytes, not the client's Content-Type (image/png, image/*, application/pdf…).
++ *
++ * @minItems 1
++ */
++ accept: [string, ...string[]];
++ /**
++ * Largest accepted file.
++ */
++ maxBytes: number;
++ } | null;
++ output: {
++ /**
++ * json: result.data; text: result.text; file or files: result.files (job result files).
++ */
++ kind: "json" | "file" | "files" | "text";
++ /**
++ * JSON Schema of result.data (kind json): embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.
++ */
++ schema?:
++ | {
++ $ref: string;
++ }
++ | {
++ type: "object";
++ };
++ /**
++ * Media types the result files can have (kind file or files).
++ *
++ * @minItems 1
++ */
++ mime?: [string, ...string[]];
++ };
++ limits: {
++ /**
++ * A run (or its job) longer than this fails with tools.job.timeout or 504.
++ */
++ timeoutMs: number;
++ /**
++ * Longest audio or video input.
++ */
++ maxDurationSec?: number;
++ /**
++ * Largest image input (width × height).
++ */
++ maxPixels?: number;
++ /**
++ * Most PDF pages.
++ */
++ maxPages?: number;
++ /**
++ * Largest input as JSON (text to transform, lists of hosts…).
++ */
++ maxInputBytes?: number;
++ /**
++ * Egress tools: most runs per minute against one target host or address, across all callers.
++ */
++ perTargetPerMinute?: number;
++ };
++ /**
++ * Caller tiers: anonymous < session < user < app/service.
++ */
++ auth: {
++ /**
++ * true: a caller with no token and no browser session may run it (keyed by IP, IPv6 by /64). false: a browser session (the ov_tools_jobs cookie), a signed-in person or a token is needed.
++ */
++ anonymous: boolean;
++ /**
++ * What an app or service token must hold. tools.tool.run is public. tools.net.probe (partner) covers network probes: only principals holding it run these through the API; people use their pages, which have a per-target throttle.
++ */
++ capability: "tools.tool.run" | "tools.net.probe";
++ };
++ /**
++ * The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names.
++ */
++ quotaClass: string;
++ /**
++ * Relative weight of one run within its quota class (1 = a cheap lookup or transform). Quotas count weight, not requests.
++ */
++ cost: number;
++ /**
++ * The server fetches a host or URL the caller chose (DNS, WHOIS, headers, Open Graph, ports…), always through the SSRF guard.
++ */
++ egress: boolean;
++ /**
++ * The tool's public hostnames, canonical first (png.openvibe.tools, a custom domain…); empty for a tool that lives on another service.
++ *
++ * @maxItems 50
++ */
++ hosts: string[];
++ /**
++ * Where a person reads about the tool.
++ */
++ docs: string;
++}
++
++/** [email protected] (owner: tools) */
++/**
++ * The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (name, summary and keywords). Planned placeholders are not tools and are not listed. Cacheable (ETag); updated_at changes when any descriptor does.
++ */
++export interface ToolList {
++ tools: ToolDescriptor[];
++ /**
++ * The number of tools in this answer.
++ */
++ count: number;
++ updated_at: string;
++ /**
++ * The families of the tools in this answer.
++ */
++ families?: {
++ id: string;
++ name: string;
++ count: number;
++ }[];
++}
++
++/** [email protected] (owner: tools) */
++/**
++ * Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable.
++ */
++export interface ToolsRunRequest {
++ /**
++ * The tool's parameters, matching its input schema. Default {}.
++ */
++ input?: {};
++ /**
++ * Files that are already somewhere, instead of or besides uploaded parts.
++ *
++ * @maxItems 50
++ */
++ files?: (
++ | {
++ media_id: string;
++ }
++ | {
++ job_id: string;
++ index: number;
++ }
++ )[];
++ /**
++ * Job tools: wait up to this long for the job to finish before answering (200 finished, else 202 with the job). Default 0. Inline tools always answer finished.
++ */
++ wait_ms?: number;
++ /**
++ * Job tools: the same (caller, key) and request returns the same job; a different request under it is 409 tools.job.idempotency_conflict. Inline tools ignore it.
++ */
++ idempotency_key?: string;
++}
++
++/** [email protected] (owner: tools) */
++/**
++ * The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…). A request refused before the tool ran is a problem+json answer, not a run.
++ */
++export type ToolsRun =
++ | {
++ state: "succeeded";
++ tool: string;
++ result: {
++ /**
++ * Output data; matches the descriptor's output.schema when output.kind is json.
++ */
++ data?: {};
++ /**
++ * Output text (output.kind text).
++ */
++ text?: string;
++ /**
++ * Result files (output.kind file or files), exactly as the job lists them.
++ *
++ * @maxItems 100
++ */
++ files?: {
++ name: string;
++ mime: string;
++ size: number;
++ sha256: string;
++ /**
++ * media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires.
++ */
++ storage: "local" | "media";
++ /**
++ * The Media object, when storage is media.
++ */
++ media: {
++ media_id: string;
++ role?: string;
++ namespace?: string;
++ size_bytes?: number;
++ content_hash?: string;
++ mime_type?: string;
++ /**
++ * The object's Media lifecycle status when it was stored (ready).
++ */
++ status?: string;
++ } | null;
++ /**
++ * Present when storing the file in Media failed; the file stayed local.
++ */
++ media_error?: string;
++ /**
++ * Where the owner downloads it.
++ */
++ url: string;
++ }[];
++ };
++ took_ms: number;
++ job?: ToolsJob | null;
++ }
++ | {
++ state: "failed" | "cancelled";
++ tool: string;
++ error: Problem;
++ took_ms: number;
++ job?: ToolsJob | null;
++ }
++ | {
++ state: "queued" | "running";
++ tool: string;
++ job: ToolsJob;
++ /**
++ * The job (also the Location header): /api/v1/jobs/:id on the gateway.
++ */
++ location: string;
++ };
++
++/** [email protected] (owner: tools) */
++/**
++ * A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), with server paths removed.
++ */
++export interface ToolsJob {
++ id: string;
++ object: "tools.job";
++ /**
++ * The Tools satellite that runs the job (img, audio, docs…).
++ */
++ service: string;
++ /**
++ * The tool (tools.tool@1 id) whose run created the job, when it came through POST /api/v1/tools/{id}/run. Absent for a job submitted to POST /api/v1/jobs directly.
++ */
++ tool?: string;
++ /**
++ * Job type, e.g. img.process.
++ */
++ type: string;
++ type_version: number;
++ /**
++ * Terminal: succeeded, failed, cancelled.
++ */
++ state: "queued" | "running" | "succeeded" | "failed" | "cancelled";
++ progress: {
++ percent: number | null;
++ message: string | null;
++ };
++ attempts: number;
++ max_attempts: number;
++ /**
++ * DELETE asked a running job to stop; it ends cancelled.
++ */
++ cancel_requested: boolean;
++ created_at: string;
++ started_at: string | null;
++ finished_at: string | null;
++ /**
++ * When the job, its events and its result files are pruned; null while a reference keeps the result.
++ */
++ expires_at: string | null;
++ result: {
++ /**
++ * @maxItems 100
++ */
++ files: {
++ name: string;
++ mime: string;
++ size: number;
++ sha256: string;
++ /**
++ * media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires.
++ */
++ storage: "local" | "media";
++ /**
++ * The Media object, when storage is media.
++ */
++ media: {
++ media_id: string;
++ role?: string;
++ namespace?: string;
++ size_bytes?: number;
++ content_hash?: string;
++ mime_type?: string;
++ /**
++ * The object's Media lifecycle status when it was stored (ready).
++ */
++ status?: string;
++ } | null;
++ /**
++ * Present when storing the file in Media failed; the file stayed local.
++ */
++ media_error?: string;
++ /**
++ * Where the owner downloads it.
++ */
++ url: string;
++ }[];
++ /**
++ * The tool's output data (dimensions, durations, page counts…); {} when it has none.
++ */
++ data: {};
++ } | null;
++ error: Problem | null;
++ /**
++ * Retrying may succeed (a timeout, a restart); POST links.retry makes a new job.
++ */
++ retryable: boolean;
++ /**
++ * The failed job this one retries.
++ */
++ retry_of: string | null;
++ /**
++ * The job that retried this failed one; asking again returns it.
++ */
++ retried_by: string | null;
++ /**
++ * What keeps the result (PUT /api/v1/jobs/:id/references/:ref), e.g. community:paste:p_123.
++ *
++ * @maxItems 50
++ */
++ references: {
++ ref: string;
++ created_at: string;
++ }[];
++ links: {
++ self: string;
++ events: string;
++ /**
++ * DELETE it while the job is queued or running.
++ */
++ cancel: string | null;
++ /**
++ * POST it when the job failed.
++ */
++ retry: string | null;
++ retried_by: string | null;
++ };
++}
++
++/** [email protected] (owner: tools) */
++/**
++ * Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner) or a rate limit, 503 tools.job.unavailable.
++ */
++export interface ToolsJobRequest {
++ /**
++ * Job type: img.process, audio.process, docs.process.
++ */
++ type: string;
++ /**
++ * The job type's input; { tool: <operation>, …options }. Default {}; at most 16 KB as JSON.
++ */
++ input?: {};
++ /**
++ * 8-200 printable ASCII characters. The Idempotency-Key header is used when both are present.
++ */
++ idempotency_key?: string;
++}
ddiff --git a/index.js b/index.js
iindex c292bdde..457b43d6 100644
---- a/index.js
++++ b/index.js
@@@ -22,4 +22,5 @@ module.exports = {
services: require('./lib/services'),
serviceAuth: require('./lib/service-auth'),
modules: require('./lib/modules'),
++ tools: require('./lib/tools'),
};
ddiff --git a/lib/tools.js b/lib/tools.js
nnew file mode 100644
iindex 00000000..eb164252
---- /dev/null
++++ b/lib/tools.js
@@@ -0,0 +1,70 @@
++'use strict';
++/**
++ * Tools registry helpers (ADR-027). tools.tool@1 holds every descriptor rule JSON Schema can express;
++ * these checks add the ones that depend on the tool's id or compare two values, so a registry (and its
++ * tests) can hold every descriptor to the same bar:
++ *
++ * contracts.tools.checkDescriptor(d) -> { valid, errors: [{ path, message }] }
++ * contracts.tools.checkList(list) -> the same for a tools.tool-list@1 answer, every item included
++ * contracts.tools.jobInput(d, input) -> the job input a job tool's run submits
++ */
++const registry = require('./registry');
++
++const SCHEMA_REF = /\/api\/v1\/tools\/([a-z][a-z0-9-]{0,39})\/schema#\/\$defs\/(input|output)$/;
++// A quota class names the work. Tiers (anonymous < session < user < app/service) and any later paid
++// tier are allowances inside a class, so a class name never carries one.
++const TIER_WORDS = /(^|-)(free|paid|pro|plus|premium|trial|tier|anon|anonymous|session|user|app|service|sandbox)(-|$)/;
++
++const isRefOnly = (s) => !!s && typeof s.$ref === 'string' && Object.keys(s).length === 1;
++
++/** Every rule for one descriptor: the schema's, then the ones that depend on its id. */
++function checkDescriptor(d) {
++ const r = registry.validate('tools.tool@1', d);
++ if (!r.valid) return r;
++ const errors = [];
++ const err = (path, message) => errors.push({ path, message });
++ if (d.run && d.run.path !== `/api/v1/tools/${d.id}/run`) err('/run/path', `must be /api/v1/tools/${d.id}/run`);
++ if (d.run && (d.run.legacy || []).some(l => l.split(' ')[1].startsWith('/api/v1/tools/'))) err('/run/legacy', 'lists older routes, never the run API');
++ if (d.files && d.files.min > d.files.max) err('/files', 'min must not exceed max');
++ for (const [at, s, want] of [['/input', d.input, 'input'], ['/output/schema', d.output.schema, 'output']]) {
++ if (!isRefOnly(s)) continue;
++ const m = SCHEMA_REF.exec(s.$ref);
++ if (!m || m[1] !== d.id || m[2] !== want) err(`${at}/$ref`, `must point at /api/v1/tools/${d.id}/schema#/$defs/${want}`);
++ }
++ if (TIER_WORDS.test(d.quotaClass)) err('/quotaClass', 'names the work, never a caller tier or a price');
++ return { valid: errors.length === 0, errors };
++}
++
++/** A GET /api/v1/tools answer: the schema, every descriptor, unique ids and hosts, honest counts. */
++function checkList(list) {
++ const r = registry.validate('tools.tool-list@1', list);
++ if (!r.valid) return r;
++ const errors = [];
++ const err = (path, message) => errors.push({ path, message });
++ if (list.count !== list.tools.length) err('/count', `is ${list.count} for ${list.tools.length} tools`);
++ const ids = new Set();
++ const hosts = new Map();
++ list.tools.forEach((d, i) => {
++ if (ids.has(d.id)) err(`/tools/${i}/id`, `${d.id} is listed twice`);
++ ids.add(d.id);
++ for (const h of d.hosts) {
++ if (hosts.has(h)) err(`/tools/${i}/hosts`, `${h} is already ${hosts.get(h)}'s`);
++ else hosts.set(h, d.id);
++ }
++ for (const e of checkDescriptor(d).errors) err(`/tools/${i}${e.path === '/' ? '' : e.path}`, e.message);
++ });
++ for (const [i, f] of (list.families || []).entries()) {
++ const n = list.tools.filter(t => t.family === f.id).length;
++ if (n !== f.count) err(`/families/${i}/count`, `is ${f.count} for ${n} ${f.id} tools`);
++ }
++ return { valid: errors.length === 0, errors };
++}
++
++/** The job input a job tool's run submits: { ...input, ...preset, tool: operation }; null for other tools. */
++function jobInput(d, input = {}) {
++ const job = d && d.run && d.run.job;
++ if (!job) return null;
++ return { ...(input || {}), ...(job.preset || {}), tool: job.operation };
++}
++
++module.exports = { checkDescriptor, checkList, jobInput };
ddiff --git a/manifests/capabilities/tools.job.cancel.json b/manifests/capabilities/tools.job.cancel.json
iindex 11a3c220..0af9bfdc 100644
---- a/manifests/capabilities/tools.job.cancel.json
++++ b/manifests/capabilities/tools.job.cancel.json
@@@ -5,6 +5,7 @@
"status": "active",
"visibility": "public",
"description": "Cancel a queued or running job the calling principal created.",
++ "outputSchema": "tools.job@1",
"permissions": [
"tools:jobs:write"
],
ddiff --git a/manifests/capabilities/tools.job.create.json b/manifests/capabilities/tools.job.create.json
iindex 4ef06b3a..7252920b 100644
---- a/manifests/capabilities/tools.job.create.json
++++ b/manifests/capabilities/tools.job.create.json
@@@ -4,7 +4,9 @@
"owner": "tools",
"status": "active",
"visibility": "public",
-- "description": "Submit an asynchronous tool job (img.process, audio.process, docs.process) with its input files; the job is owned by the calling principal. Idempotency-Key supported.",
++ "description": "Submit an asynchronous tool job (img.process, audio.process, docs.process) with its input files (tools.job-request@1, JSON or multipart); the job is owned by the calling principal. Idempotency-Key supported. Also the owner's other writes on a job: retry a failed job as a new one (idempotent: asking again returns that retry), and keep a succeeded job's result while a reference such as community:paste:p_123 points at it (PUT), or let it expire again (DELETE, after the last reference).",
++ "inputSchema": "tools.job-request@1",
++ "outputSchema": "tools.job@1",
"permissions": [
"tools:jobs:write"
],
@@@ -19,6 +21,8 @@
"tools.job.failed"
],
"implementedBy": [
-- "POST /api/v1/jobs (Tools satellites: img, audio, docs)"
++ "POST /api/v1/jobs (Tools satellites: img, audio, docs)",
++ "POST /api/v1/jobs/:id/retry",
++ "PUT|DELETE /api/v1/jobs/:id/references/:ref"
]
}
ddiff --git a/manifests/capabilities/tools.job.read.json b/manifests/capabilities/tools.job.read.json
iindex ac7b3ffa..3531d575 100644
---- a/manifests/capabilities/tools.job.read.json
++++ b/manifests/capabilities/tools.job.read.json
@@@ -5,6 +5,7 @@
"status": "active",
"visibility": "public",
"description": "Read a job the calling principal created: state, progress, result (Media object references or local files), SSE progress with Last-Event-ID resume, result files.",
++ "outputSchema": "tools.job@1",
"permissions": [
"tools:jobs:read"
],
ddiff --git a/manifests/capabilities/tools.net.probe.json b/manifests/capabilities/tools.net.probe.json
nnew file mode 100644
iindex 00000000..569c5e8e
---- /dev/null
++++ b/manifests/capabilities/tools.net.probe.json
@@@ -0,0 +1,21 @@
++{
++ "id": "tools.net.probe",
++ "version": "1.0",
++ "owner": "tools",
++ "status": "planned",
++ "visibility": "partner",
++ "description": "Run the network probe tools through the run API (ADR-027): the tools whose descriptor names auth.capability tools.net.probe (port checks, ping, traceroute, mtr, latency, bulk header and TLS checks), which send traffic to a host the caller chose. Only app and service principals holding this capability run them through the API or the SDK: partner visibility, so Network grants it to a developer project only when staff add it to that project's allowance by hand, and first-party services hold it through their own grants. People keep using the probe pages, which have a per-target throttle. Every run is throttled per target across all callers (limits.perTargetPerMinute) and written to the pseudonymous abuse log. Planned until the gateway serves the route.",
++ "inputSchema": "tools.run-request@1",
++ "outputSchema": "tools.run@1",
++ "permissions": [
++ "tools:net:probe"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "tools-probe",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/tools/:id/run (tools with auth.capability tools.net.probe)"
++ ]
++}
ddiff --git a/manifests/capabilities/tools.tool.read.json b/manifests/capabilities/tools.tool.read.json
nnew file mode 100644
iindex 00000000..778a0415
---- /dev/null
++++ b/manifests/capabilities/tools.tool.read.json
@@@ -0,0 +1,22 @@
++{
++ "id": "tools.tool.read",
++ "version": "1.0",
++ "owner": "tools",
++ "status": "planned",
++ "visibility": "public",
++ "description": "Read the tool registry (ADR-027): every tool's descriptor (tools.tool@1: execution, whether the run API exposes it, how to call it, input and output schemas, files, limits, auth, quota class, cost, egress, hosts, docs), filtered by family, execution, api, status or text. GET /api/v1/tools answers tools.tool-list@1 with schemas as { $ref }; GET /api/v1/tools/:id has them embedded; GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }. Open to everyone, anonymous callers included; cacheable. Planned until the gateway serves these routes.",
++ "outputSchema": "tools.tool@1",
++ "permissions": [
++ "tools:catalog:read"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "tools-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/tools",
++ "GET /api/v1/tools/:id",
++ "GET /api/v1/tools/:id/schema"
++ ]
++}
ddiff --git a/manifests/capabilities/tools.tool.run.json b/manifests/capabilities/tools.tool.run.json
nnew file mode 100644
iindex 00000000..101f6128
---- /dev/null
++++ b/manifests/capabilities/tools.tool.run.json
@@@ -0,0 +1,26 @@
++{
++ "id": "tools.tool.run",
++ "version": "1.0",
++ "owner": "tools",
++ "status": "planned",
++ "visibility": "public",
++ "description": "Run a tool through the uniform run API (ADR-027): POST /api/v1/tools/:id/run with tools.run-request@1 (JSON, or multipart with uploaded files), answered inline (tools.run@1 succeeded or failed) or, for a job tool that has not finished within wait_ms, with its job (202 + Location). Every tool whose descriptor has api true and auth.capability tools.tool.run. Callers are tiered, anonymous < session < user < app/service: tools with auth.anonymous true take anonymous callers (keyed by IP, IPv6 by /64); the others need a browser session, a signed-in person or a token. An app or service token needs this capability; people and browser sessions do not. Quotas count each tool's cost within its quota class, per caller. A job tool's run creates a job owned by the caller, like tools.job.create. Planned until the gateway serves the route.",
++ "inputSchema": "tools.run-request@1",
++ "outputSchema": "tools.run@1",
++ "permissions": [
++ "tools:run"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "tools-run",
++ "events": [
++ "tools.job.created",
++ "tools.job.started",
++ "tools.job.succeeded",
++ "tools.job.failed"
++ ],
++ "implementedBy": [
++ "POST /api/v1/tools/:id/run"
++ ]
++}
ddiff --git a/manifests/services/tools.json b/manifests/services/tools.json
iindex 5bdfa040..f1f75df5 100644
---- a/manifests/services/tools.json
++++ b/manifests/services/tools.json
@@@ -1,5 +1,5 @@
{
-- "version": "0.3.0",
++ "version": "0.4.0",
"contractRanges": {
"openvibe-contracts": ">=0.7.0 <1.0.0"
},
@@@ -12,10 +12,14 @@
],
"publicOrigin": "https://openvibe.tools",
"health": "/api/health",
++ "ready": "/api/ready",
"capabilities": [
"tools.job.create",
"tools.job.read",
-- "tools.job.cancel"
++ "tools.job.cancel",
++ "tools.tool.read",
++ "tools.tool.run",
++ "tools.net.probe"
],
"eventsProduced": [
"tools.job.created",
@@@ -27,5 +31,5 @@
"namespacesOwned": [
"tools.*"
],
-- "notes": "Wave 11: asynchronous jobs on the img, audio and docs satellites (POST/GET/DELETE /api/v1/jobs, SSE events). Job lifecycle events tools.job.created|started|succeeded|failed have payload contracts (v0.30.0); Tools publishes them to OpenVibe.Events next. Tools consumes media.object.upload and media.object.read (namespace tools) when TOOLS_JOB_RESULTS=media."
++ "notes": "Public at openvibe.tools: 169 tools in 9 families on the gateway (port 4001) and its satellites, catalogue at GET /api/catalog.json (re-served by Network). Asynchronous jobs (Wave 11) run on the img, audio and docs satellites only (POST /api/v1/jobs, GET/DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT|DELETE /api/v1/jobs/:id/references/:ref, SSE events, result files); the gateway has no /api/v1/jobs yet. Job results are stored as private Media objects (TOOLS_JOB_RESULTS=media: Tools holds media.object.upload and media.object.read, namespace tools) and job lifecycle events tools.job.created|started|succeeded|failed are published to OpenVibe.Events through an outbox in each satellite's jobs.db (EVENTS_URL set; events.event.publish). Planned (ADR-027): the tool registry GET /api/v1/tools[/:id[/schema]] (tools.tool.read), the uniform run API POST /api/v1/tools/:id/run (tools.tool.run; network probes need tools.net.probe), a jobs facade on the gateway, tiered quotas and a pseudonymous abuse log; the legacy /api/process, /api/net/* and /api/dev/* routes keep working with Deprecation and Sunset (2026-12-31) headers. GET /api/ready is truthful on the gateway (it checks every satellite) and on each satellite."
}
ddiff --git a/test/run.js b/test/run.js
iindex 2e43b81c..f4e7f5d3 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -181,6 +181,83 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
ok(contracts.modules.get('chat.preferences').owner === 'chat' && services.get('chat').namespacesOwned.includes('chat.preferences'), 'chat.preferences is owned by chat, and its manifest says so');
}
++// ── Tools platform API (v0.33, ADR-027) ──────────────────────────────────
++// The descriptor says how every tool may be called; the run API, the SDK and the docs read it. What
++// JSON Schema can hold is in tools.tool@1 (and its invalid fixtures); what depends on the id is in
++// contracts.tools.checkDescriptor, which the Tools registry runs over every descriptor.
++{
++ const { tools } = contracts;
++ const fixture = (id, kind, f) => JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures', id, kind, f), 'utf8'));
++ const valid = fs.readdirSync(path.join(ROOT, 'fixtures/tools.tool/valid')).filter(f => f.endsWith('.json')).map(f => fixture('tools.tool', 'valid', f));
++ const byId = Object.fromEntries(valid.map(d => [d.id, d]));
++ const { png, dns, port, yt } = byId;
++ const T = (d) => contracts.validate('tools.tool@1', d).valid;
++ const D = (d) => tools.checkDescriptor(d).valid;
++ for (const d of valid) ok(D(d), `${d.id} passes checkDescriptor: ${JSON.stringify(tools.checkDescriptor(d).errors)}`);
++
++ // api false never has a run endpoint, api true always has its own.
++ for (const d of valid) ok(d.api ? d.run && d.run.path === `/api/v1/tools/${d.id}/run` && d.input !== null : d.run === null, `${d.id}: run exists exactly when api is true`);
++ ok(yt.api === false && !T({ ...yt, run: { method: 'POST', path: '/api/v1/tools/yt/run', job: null } }), 'yt is page-only: no run endpoint (ADR-027)');
++ ok(!D({ ...dns, run: { ...dns.run, path: '/api/v1/tools/whois/run' } }), 'run.path is the tool\'s own');
++ ok(!D({ ...dns, run: { ...dns.run, legacy: ['POST /api/v1/tools/dns/run'] } }), 'legacy lists older routes, never the run API');
++ ok(!D({ ...dns, input: { $ref: 'https://openvibe.tools/api/v1/tools/whois/schema#/$defs/input' } }), 'an input $ref points at the tool\'s own schema');
++ ok(!D({ ...dns, input: { $ref: 'https://openvibe.tools/api/v1/tools/dns/schema#/$defs/output' } }), 'an input $ref points at $defs/input');
++ ok(!D({ ...png, files: { ...png.files, min: 2, max: 1 } }), 'files.min <= files.max');
++ // Execution: only job tools name a job, and a file result always comes from one.
++ for (const d of valid) ok(!d.run || (d.execution === 'job') === !!d.run.job, `${d.id}: names a job exactly when it runs as one`);
++ for (const d of valid) ok(!d.api || !['file', 'files'].includes(d.output.kind) || d.execution === 'job', `${d.id}: file output through the API comes from a job`);
++ ok(JSON.stringify(tools.jobInput(png, { format: 'jpg', tool: 'crop', width: 10 })) === '{"format":"png","tool":"convert","width":10}', 'a job tool\'s operation and preset win over the caller\'s input');
++ ok(tools.jobInput(dns, {}) === null, 'an inline tool submits no job');
++
++ // Egress and auth: a tool that fetches for anonymous callers throttles per target; probes are never anonymous.
++ for (const d of valid) if (d.egress) ok(!d.auth.anonymous || d.limits.perTargetPerMinute > 0, `${d.id}: egress needs a non-anonymous caller or a per-target throttle`);
++ for (const d of valid) if (d.auth.capability === 'tools.net.probe') ok(d.egress && !d.auth.anonymous, `${d.id}: a probe fetches and is never anonymous`);
++ ok(T({ ...dns, auth: { anonymous: false, capability: 'tools.tool.run' }, limits: { timeoutMs: 10000 } }), 'a signed-in-only egress tool may leave the per-target throttle out');
++ ok(!T({ ...port, auth: { anonymous: false, capability: 'tools.tool.run' }, execution: 'client' }) && !T({ ...byId.jsonminify, egress: true, limits: { timeoutMs: 1, perTargetPerMinute: 1 } }), 'a client tool never fetches');
++ for (const q of ['tools-free', 'tools-anon-run', 'tools-paid-job', 'tools-user', 'tools-pro']) ok(!D({ ...dns, quotaClass: q }), `quota class ${q} names a tier, not the work`);
++ for (const d of valid) ok(d.quotaClass.startsWith('tools-') && Number.isInteger(d.cost) && d.cost >= 1, `${d.id}: quota class and cost`);
++
++ // Capabilities: the descriptor names exactly the run capabilities, and they answer the run contracts.
++ const capEnum = [...contracts.schema('tools.tool').properties.auth.properties.capability.enum].sort();
++ ok(JSON.stringify(capEnum) === '["tools.net.probe","tools.tool.run"]', 'auth.capability is tools.tool.run or tools.net.probe');
++ for (const id of capEnum) {
++ const c = capabilities.get(id);
++ ok(c && c.owner === 'tools' && c.inputSchema === 'tools.run-request@1' && c.outputSchema === 'tools.run@1' && c.implementedBy.some(r => r.startsWith('POST /api/v1/tools/:id/run')), `${id} is the run API`);
++ }
++ const [readCap, runCap, probeCap] = ['tools.tool.read', 'tools.tool.run', 'tools.net.probe'].map(id => capabilities.get(id));
++ ok(readCap.visibility === 'public' && readCap.quotaClass === 'tools-read' && readCap.outputSchema === 'tools.tool@1' && ['GET /api/v1/tools', 'GET /api/v1/tools/:id', 'GET /api/v1/tools/:id/schema'].every(r => readCap.implementedBy.includes(r)), 'tools.tool.read: public registry routes');
++ ok(runCap.visibility === 'public' && runCap.quotaClass === 'tools-run', 'tools.tool.run is public');
++ ok(probeCap.visibility === 'partner' && probeCap.quotaClass === 'tools-probe', 'tools.net.probe is partner: staff-set allowances only, never a default one');
++ const jobCreate = capabilities.get('tools.job.create');
++ ok(['POST /api/v1/jobs/:id/retry', 'PUT|DELETE /api/v1/jobs/:id/references/:ref'].every(r => jobCreate.implementedBy.includes(r)) && jobCreate.inputSchema === 'tools.job-request@1', 'tools.job.create covers submit, retry and references');
++ ok(['tools.job.create', 'tools.job.read', 'tools.job.cancel'].every(id => capabilities.get(id).outputSchema === 'tools.job@1'), 'the job capabilities answer tools.job@1');
++ const svc = services.get('tools');
++ ok(svc.ready === '/api/ready' && ['tools.tool.read', 'tools.tool.run', 'tools.net.probe'].every(c => svc.capabilities.includes(c)), 'the tools manifest lists the registry and run capabilities and its ready path');
++ for (const t of ['created', 'started', 'succeeded', 'failed']) ok(contracts.resolve(`tools.job.${t}`).status === 'active', `tools.job.${t} is emitted (active)`);
++
++ // Runs and jobs agree: a run's result files are the job's, one idempotency rule, one job type pattern.
++ const job = contracts.schema('tools.job'), run = contracts.schema('tools.run'), req = contracts.schema('tools.run-request'), jreq = contracts.schema('tools.job-request');
++ ok(JSON.stringify(job.properties.result.properties.files.items) === JSON.stringify(run.oneOf[0].properties.result.properties.files.items), 'a run\'s result files are exactly the job\'s');
++ ok(req.properties.idempotency_key.pattern === jreq.properties.idempotency_key.pattern, 'runs and jobs take the same Idempotency-Key');
++ const typeRe = job.properties.type.pattern;
++ ok([jreq.properties.type.pattern, contracts.schema('tools.tool').properties.run.properties.job.properties.type.pattern, contracts.schema('tools.job.created').properties.type.pattern].every(p => p === typeRe), 'one job type pattern everywhere');
++ const done = fixture('tools.job', 'valid', 'succeeded-media-referenced.json');
++ ok(contracts.validate('tools.run@1', { state: 'succeeded', tool: done.tool, result: done.result, took_ms: 3000, job: done }).valid, 'a succeeded job\'s result is a run result');
++ const payloadFile = Object.keys(contracts.schema('tools.job.succeeded').properties.result.properties.files.items.properties).filter(k => k !== 'index');
++ ok(payloadFile.every(k => k in job.properties.result.properties.files.items.properties), 'the tools.job.succeeded event carries a subset of what the job shows its owner');
++ ok(!contracts.validate('tools.job@1', { ...done, result: { ...done.result, files: [{ ...done.result.files[0], _path: '/x' }] } }).valid, 'a job never shows server paths');
++
++ // The registry list: every descriptor checked, ids and hosts unique, counts honest.
++ const list = fixture('tools.tool-list', 'valid', 'four-tools.json');
++ ok(tools.checkList(list).valid, `the list fixture passes checkList: ${JSON.stringify(tools.checkList(list).errors)}`);
++ ok(list.tools.every(d => !d.input || d.input.$ref) && list.tools.every(d => !d.output.schema || d.output.schema.$ref), 'the list carries schemas as $ref');
++ ok(!tools.checkList({ ...list, count: 5 }).valid, 'count is the number of tools listed');
++ ok(!tools.checkList({ ...list, tools: [...list.tools, list.tools[0]], count: list.tools.length + 1 }).valid, 'a tool is listed once');
++ ok(!tools.checkList({ ...list, tools: [list.tools[0], { ...list.tools[1], hosts: [list.tools[0].hosts[0]] }], count: 2, families: undefined }).valid, 'a host belongs to one tool');
++ ok(!tools.checkList({ ...list, families: [{ id: 'img', name: 'Image Tools', count: 2 }] }).valid, 'family counts are honest');
++ ok(!tools.checkList({ ...list, tools: [{ ...list.tools[0], run: { ...list.tools[0].run, path: '/api/v1/tools/jpg/run' } }], count: 1, families: undefined }).valid, 'checkList applies checkDescriptor to every tool');
++}
++
// ── Ids ──────────────────────────────────────────────────────────────────
for (const kind of ['user', 'guest', 'app', 'mod']) {
const id = ids.newId(kind);