openvibers/openvibe.run Commits
OpenVibe.Run: the service skeleton and the job API (submit, read, list, cancel, stream ticket + SSE, admin) on the released run contracts (plan T14 R1a/R1b). (#1)
Changes
nnew file mode 100644@@@ -0,0 +1,67 @@++# ═══════════════════════════════════════════════════════════════++# openvibe.run — Environment Configuration++# Copy to .env for development. In production this lives at /etc/openvibe/run.env (0600).++# Names only here; never commit a value.++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4920++HOST=127.0.0.1++NODE_ENV=development++BASE_URL=http://localhost:4920++TRUST_PROXY=1++++# ── Data: PostgreSQL + Valkey (ADR-035) ──────────────────────++# In production OpenVibe.Host writes all four (roles/data/add-service.sh run). Unset in development:++# an embedded PGlite database in data/pglite (one process). Tests need neither.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=ov:run:++++# ── Identity: OpenVibe.Network ──────────────────────────────++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++#OV_NETWORK_ISSUER=https://openvibe.network++# Optional: pin the Network's RS256 public key (PEM, \n escaped) instead of fetching the JWKS.++#OV_NETWORK_PUBLIC_KEY=++# Audience Run accepts on incoming service tokens; a token must also name a project (project_id).++RUN_AUDIENCE=openvibe.run++OV_OAUTH_CLIENT_ID=run++OV_OAUTH_CLIENT_SECRET=++#COOKIE_SECURE=true++++# ── OpenVibe.Events ─────────────────────────────────────────++# Relay of Run's outbox (run.job.*); off when unset (rows wait in run_events_outbox).++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=2000++++# ── Jobs ────────────────────────────────────────────────────++#RUN_JOBS=on++#RUN_JOBS_INTERVAL_MS=5000++++# ── Project caps on a job's limits (run.job-create-request@1); over one is 422 run.limits.exceeded ──++#RUN_LIMITS_MAX_WALL_MS=3600000++#RUN_LIMITS_MAX_CPU_MS=3600000++#RUN_LIMITS_MAX_MEM_BYTES=4gb++#RUN_LIMITS_MAX_TTL_MS=86400000++#RUN_LIMITS_MAX_ARGS_BYTES=512kb++++# ── The job stream's ticket (a two-minute single-use RS256 compact JWS Run signs) ──++# A PEM in the environment, or a key file. In production with neither set, stream tickets answer 503++# (every other route still serves); in development a key is generated in memory at boot.++#RUN_STREAM_PRIVATE_KEY=++#RUN_STREAM_KEY_FILE=data/keys/run-stream.pem++#RUN_STREAM_KEY_ID=run-stream-1++#RUN_STREAM_TTL_S=120++#RUN_STREAM_RING_BYTES=1mb++#RUN_STREAM_MAX_PER_JOB=64++#RUN_STREAM_HEARTBEAT_MS=25000++++# ── The dispatcher bridge to Bot (plan T14 step 6; a no-op seam until then) ──++#RUN_BOT_URL=http://127.0.0.1:4630++#RUN_BOT_AUDIENCE=openvibe.bot++#RUN_DISPATCH_INTERVAL_MS=1000++#RUN_DISPATCH_TIMEOUT_MS=8000++++# Run holds no Billing token: Bot owns the node link and the per-second metering (plan T14 L1).nnew file mode 100644@@@ -0,0 +1,18 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ contracts-service: run++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all on the++ # containers through PgBouncer (RUN_TEST_STORE=pg).++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,15 @@++node_modules/++# A linked dependency tree is a symlink, which the pattern above (directory-only) does not match: the++# tooling that links one shared copy into a worktree must never be committable.++node_modules++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.keynnew file mode 100644@@@ -0,0 +1,9 @@++# gitleaks configuration, used by the shared security workflow++# (OpenVibers/OpenVibe.Shared/.github/workflows/security.yml). The default rules stay on; every++# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.++#++# Run stores no credential of its own: a stream ticket is signed at request time with the key at++# RUN_STREAM_PRIVATE_KEY / RUN_STREAM_KEY_FILE (.env.example carries names only) and is never written++# to disk or to a log. Nothing here needs an allowlist yet.++[extend]++useDefault = truennew file mode 100644@@@ -0,0 +1,27 @@++# Changelog++++Notable changes to OpenVibe.Run. The service is unreleased; everything below is `0.1.0` in progress.++++## Unreleased++++### Added++++- The repository skeleton (plan T14 step 4, R1a): `server/config.js` on port 4920, `server/db.js`,++ `server/app.js`, `server/index.js`, `server/observability.js` (`/api/health`, `/api/ready`, `/release.json`,++ loopback-only `/metrics`), `server/api/auth.js`, `server/events/outbox.js`, `migrations/0001_jobs.sql`++ (`run_jobs`, `run_events_outbox`), `STATUS.json`, the nginx vhost and the systemd unit.++- The job API (plan T14 step 5, R1b): `POST /api/v1/jobs` (`run.job.submit`), `GET /api/v1/jobs/:id`++ (`run.job.read`), `GET /api/v1/jobs` (`run.job.list`), `POST /api/v1/jobs/:id/cancel` (`run.job.cancel`),++ `POST /api/v1/jobs/:id/stream/ticket` and `GET /api/v1/jobs/:id/stream` (`run.job.stream`) and++ `GET /api/v1/admin/jobs` (`run.job.admin`) — each validated against its released contract, with the project and++ the requester taken from the token alone.++- `run.job.queued|started|succeeded|failed|cancelled|expired` through `run_events_outbox`, written in the same++ transaction as the state change, and the two-minute single-use stream ticket with the SSE stream (replay,++ monotonic `seq`, `Last-Event-ID`).++- `server/dispatch/index.js`: the dispatcher-bridge seam (plan T14 step 6) as a no-op, so every job stays `queued`.++++### Notes++++- Run holds no Billing token: OpenVibe.Bot owns the node link and the per-second metering (plan T14 L1).++- The repo is not deployed; the first deploy needs `ovhost`'s inventory, the `ov_run` database,++ `/etc/openvibe/run.env` and `openvibe.run` DNS (see STATUS.json).nnew file mode 100644@@@ -0,0 +1,147 @@++# OpenVibe.Run++++The job service of the network (openvibe.run). Design: OpenVibe.Contracts docs/adr/ADR-034-platform-north-star.md;++plan track T14.++++## Purpose++++OpenVibe.Run accepts jobs, places each one on a node that advertises the runtime class it needs, and follows it to++its end. A job belongs to a **project**, is billed per wall-clock second it runs, and is visible only to the token++that project presents. Run owns the API, the project scope and the placement; OpenVibe.Bot owns the node link and the++per-second metering, so Run holds no Billing token at all (plan T14 L1).++++This repository is at plan **T14 step 5 (R1b)**: the API is complete and every job stays `queued`. The dispatcher++bridge to Bot is plan T14 step 6 (R1c) and lives behind the seam in `server/dispatch/index.js`.++++## Owns++++- The job API (`server/api/v1.js`, one capability per route — the table below) and the job's lifecycle record++ (`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.++- The project scope: a job's `project_id` and `requester` come from the caller's Network token++ (`claims.project_id`, `claims.sub`) and never from a request body; another project's job is `404`, never `403`.++- The `job_<ULID>` id (minted here, passed unchanged to Bot as `platform.job@1` `id`, so every metering reading of++ the job is `run:<id>:<n>`).++- Idempotent submit: one row per `(project_id, idempotency_key)`, a repeat answering the first job, a different body++ with the same key refused `409 run.idempotency.conflict`.++- The `run.job.*` events (`server/jobs/events.js`, `run_events_outbox`), written in the same transaction as the++ state change they describe.++- The job stream (`server/jobs/stream.js`): one ring buffer per open job, the SSE `id` being the job's own monotonic++ `seq`, plus the two-minute single-use stream ticket (`server/jobs/ticket.js`).++- The `platform.workload-requirements@1` default (`{ kind: run.<class>, mobility: job, latency_class: background,++ objective: balanced }`, the contract's own default) and the project caps on a job's limits.++++## Does not own++++- The node link, the device credentials and the per-second metering: OpenVibe.Bot (`bot.job.dispatch`), which places++ the job on a node advertising `worker:<class>`.++- Identity, tokens and projects (OpenVibe.Network); Run only verifies the tokens it is the audience of.++- The artifacts a job runs and the media its inputs come from (OpenVibe.Media; Run never fetches a caller-chosen++ URL).++- Money: Run writes no usage record and holds no Billing token.++++## Depends on++++- PostgreSQL 18: `run_jobs` and the `run_events_outbox`; `migrations/` applies on boot.++- Valkey (`VALKEY_URL`, optional): shared state between processes; unset, nothing is shared.++- OpenVibe.Network: the RS256 signing key (JWKS) every token is verified against; Run's client `run` mints the++ events relay's service token.++- OpenVibe.Events: `run.job.*` events go to `events.event.publish`; unset, they wait in `run_events_outbox`.++- OpenVibe.Bot (plan T14 step 6): the dispatcher bridge the jobs wait for.++- `openvibe-contracts` v0.106.0, `openvibe-sdk` v0.34.0 and `openvibe-shared` v2.11.0 (package.json).++++## Capabilities and routes++++Every route checks exactly the capability its manifest names (`contracts/manifests/capabilities/run.job.*.json`),++from the caller's service token. A Network user token names no project and holds no capability here, so it is++refused like any other token without the grant.++++| Method and path | Capability | Request → answer |++|---------------------------------|------------------|--------------------------------------------------------|++| `POST /api/v1/jobs` | `run.job.submit` | `run.job-create-request@1` → `run.job-create-result@1` (201 created; 200, `created:false` on an idempotent repeat) |++| `GET /api/v1/jobs/:id` | `run.job.read` | `common.no-body@1` → `run.job-read-result@1` |++| `GET /api/v1/jobs?state=&limit=&cursor=` | `run.job.list` | `run.job-list-query@1` → `run.job-list-result@1` |++| `POST /api/v1/jobs/:id/cancel` | `run.job.cancel` | `common.no-body@1` → `run.job-read-result@1` |++| `POST /api/v1/jobs/:id/stream/ticket` | `run.job.stream` | `common.no-body@1` → `run.job-stream-ticket-result@1` |++| `GET /api/v1/jobs/:id/stream?ticket=` | `run.job.stream` | a ticket (never a service token) → `text/event-stream` of `run.job-stream-event@1` |++| `GET /api/v1/admin/jobs` | `run.job.admin` | `run.job-list-query@1` (with `project_id`) → `run.job-list-result@1` |++++`run.job.admin` additionally reads and cancels any project's job and is the only way to pass `project_id` to a list++(the manifest says so); it is internal, so only a first-party `svc:` token may hold it — an app, mod or agent token++with the grant is refused. Every request body and query string is validated against its released contract, and so is++every answer before it is sent; errors are RFC 9457 `application/problem+json`.++++## Jobs++++- **Submit.** `Idempotency-Key` (or `body.idempotency_key`) makes a retry safe; with neither, Run mints its own key++ and returns it in the read result (`auto:<ulid>`). The row is unique per `(project_id, idempotency_key)` and that++ index is permanent, so a key is spent for good: a repeat always answers the first job, and the same key with++ different work is `409` — the contract promising that "within 24 h" is answered the same way, forever, rather than++ reused. `limits` are capped by the project's (`RUN_LIMITS_*`, `422 run.limits.exceeded`) — the lifetime too, whether++ named or defaulted — `args` over 512 KiB are refused `413 run.args_too_large`, and `ttl_ms` defaults to++ `limits.wall_ms + 600000` exactly as the contract says.++- **States.** `queued → placed → running → succeeded | failed | cancelled | expired`. Run moves a job to `queued`++ and, on a cancel, to `cancelled`; the rest arrives from Bot (plan T14 step 6). An end state never changes, so a++ replay of a cancel is a no-op rather than a second event.++- **Events.** `run.job.queued|started|succeeded|failed|cancelled|expired`, payloads being the++ `run.job-read-result@1` projection their contracts describe. The outbox row is keyed `run:<job id>:<state>` (one++ row per job and state, so a replay is one row); the published envelope carries a contract-valid `evt_<ULID>`++ document id, because `events.event-envelope@1` pins that pattern and OpenVibe.Events validates it on publish.++- **Stream.** `POST /jobs/:id/stream/ticket` mints a two-minute single-use RS256 ticket for that one job (audience++ `openvibe.run`, `typ run-stream`, its own key — never Network's signing key, so it is never a session anywhere).++ `GET /jobs/:id/stream?ticket=…` replays what is kept (the last 1 MiB) and follows; `Last-Event-ID` resumes after++ the last `seq` seen; an ended job replays what is kept and closes with `type: 'end'`. Reconnecting never re-runs a++ job. The ring is this process's memory (reclaimed once it ended or went idle, and rebuilt from the job's row when++ a stream opens again), so `seq` is this process's counter: a resume across a restart gets the current state rather++ than a gap, and output is best effort either way.++- **The `job` record.** `run_jobs.job` holds the `platform.job@1` body Run will send (id, class, artifact, args,++ ttl_ms, limits, net, inputs) plus the fields `platform.job@1` has no place for and `0001_jobs.sql` has no column++ for: `requirements` (required by `run.job-read-result@1` and by placement), `placed_at` (`timings.placed_at`) and++ `cancel_requested` (a cancel of a placed or running job). `store.frameOf(record)` builds the frame body from the++ `platform.job@1` keys alone, and the dispatcher bridge validates it before anything is sent.++- **Cancelling.** A queued or placed job is cancelled at once (`exit` stays null, usage stays 0); a running job is++ marked `cancel_requested` and ends when its `job_exit` arrives (step 6). Cancelling an ended job answers it as it++ is.++++## Tests++++```++npm test # every file in test/, PGlite (in-process PostgreSQL)++npm run test:pg # the same on the production-shaped containers (openvibe-sdk scripts/test-services.sh up)++node test/jobs.test.js # the job API against the released contracts++node test/app.test.js # the skeleton: health, readiness, release.json, /metrics, the dispatcher seam++```++++`test/helpers/app.js` boots Run against an in-process OpenVibe.Network stub (its JWKS and its tokens), so nothing++needs the network, OpenVibe.Bot or a running service.++++## Security++++- Every route checks one capability; the project and the requester come from the token, never the body.++- Another project's job is `404 run.job_not_found`, never `403`: an id's existence is never confirmed.++- The stream route accepts only a valid, unexpired, unused ticket for that job — a service token in the query string++ is refused like any other non-ticket.++- Limits are bounded by the contract and the project's caps; nothing is silently lowered.++- No secret is logged (no `Authorization` header, no ticket, no job output), and nothing starts at module load.++- See SECURITY.md to report a problem.++++## Deploy++++Not deployed yet: `openvibe.run` has no DNS, no `ovhost` inventory entry, no `ov_run` database and no++`/etc/openvibe/run.env`. The release is here ready for them:++++- `deploy/nginx/openvibe.run.conf` — the vhost (TLS, www → apex, rate limits, client IP from `$remote_addr` only,++ `/internal/` and `/metrics` never proxied).++- `deploy/systemd/openvibe-run.service` — unit `openvibe-run`, `WorkingDirectory=/opt/openvibe.run`,++ `EnvironmentFile=/etc/openvibe/run.env`, `PORT=4920`.++- `/api/health` and `/api/ready` are for the loopback caller; `/metrics` refuses anything that arrived through a++ proxy.++++## Deploy files++++| File | What it is |++|------|------------|++| `deploy/nginx/openvibe.run.conf` | The nginx vhost for openvibe.run (reference; deployed to `/etc/nginx/sites-available/`) |++| `deploy/systemd/openvibe-run.service` | The systemd unit (release layout `/opt/openvibe.run`) |++| `.github/workflows/ci.yml` | CI: the shared test job (syntax, tests, `openvibe-contracts-check --service run`) and the shared security job |++| `migrations/0001_jobs.sql` | `run_jobs` and `run_events_outbox` |++| `STATUS.json` | What this repository is, what it does today and where it runs |nnew file mode 100644@@@ -0,0 +1,36 @@++# Security policy++++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.++++## Reporting a vulnerability++++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for++a vulnerability, and do not test against other people's accounts, projects, jobs or data.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (openvibe.run, openvibe.live, openvibe.network, openvibe.tools, openvibe.media,++openvibe.bot and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable++contact details are at `/.well-known/security.txt` on each site.++++## This service++++OpenVibe.Run executes other people's work and its usage is billed, so a defect here is money and execution. What++Run guarantees, and what a report should assume:++++- every route checks exactly one capability from the caller's Network service token (audience `openvibe.run`), and++ a job's project and requester come from that token — never from a request body or a query string;++- another project's job is always `404` (`run.job_not_found`), never `403`: Run never confirms that an id exists;++- request bodies and query strings are validated against the released `openvibe-contracts` schemas, and so is every++ answer before it is sent;++- `GET /api/v1/jobs/:id/stream` accepts only a two-minute, single-use ticket that Run itself signed for that one job++ (audience `openvibe.run`, `typ run-stream`) — never a service token in a query string;++- no secret is logged: not the `Authorization` header, not a stream ticket, not a job's stdout;++- nothing starts at module load (timers, relays and listeners start in `server/index.js` `main()`).++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,48 @@++{++ "repository": "OpenVibers/OpenVibe.Run",++ "stage": "alpha",++ "code": true,++ "wave": "W20",++ "plan": "OpenVibe Updated Plan Based On Codebase (plan T14, ADR-034)",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db) + Valkey 9",++ "port": 4920,++ "domain": "openvibe.run",++ "contracts": "openvibe-contracts v0.106.0",++ "packages": [++ "openvibe-contracts v0.106.0",++ "openvibe-sdk v0.34.0",++ "openvibe-shared v2.11.0"++ ],++ "capabilities": [++ "run.job.submit",++ "run.job.read",++ "run.job.list",++ "run.job.cancel",++ "run.job.stream",++ "run.job.admin"++ ],++ "events": [++ "run.job.queued",++ "run.job.started",++ "run.job.succeeded",++ "run.job.failed",++ "run.job.cancelled",++ "run.job.expired"++ ],++ "deployed": false,++ "deployment": "Not deployed: openvibe.run has no DNS, no ovhost inventory entry, no ov_run database and no /etc/openvibe/run.env yet. The release is deploy/ (nginx vhost deploy/nginx/openvibe.run.conf, unit deploy/systemd/openvibe-run.service, WorkingDirectory /opt/openvibe.run, EnvironmentFile /etc/openvibe/run.env, PORT 4920). The vhost proxies /api/health, /api/ready and /release.json and the job API, takes the client address from $remote_addr only (never appending X-Forwarded-For), and refuses /internal/ and /metrics at the edge; the app refuses a proxied /metrics caller itself.",++ "features": [++ "the job API (/api/v1, one capability per route): POST /jobs (run.job.submit), GET /jobs/:id (run.job.read), GET /jobs/ (run.job.list), POST /jobs/:id/cancel (run.job.cancel), POST /jobs/:id/stream/ticket and GET /jobs/:id/stream (run.job.stream), GET /admin/jobs (run.job.admin)",++ "the project and the requester come from the token (claims.project_id, claims.sub), never from the body; another project's job is 404 run.job_not_found, never 403",++ "idempotent submit: the same Idempotency-Key (or body.idempotency_key) from the same project with the same body answers the first job (200, created false); with a different body it is 409 run.idempotency.conflict",++ "every request body and query is validated against its released contract (run.job-create-request@1, run.job-list-query@1) and every answer against run.job-read-result@1, run.job-create-result@1, run.job-list-result@1, run.job-stream-ticket-result@1",++ "limits bounded by the project's caps (RUN_LIMITS_*, 422 run.limits.exceeded) and args over 512 KiB refused 413 run.args_too_large, never silently lowered; requirements defaults to the contract's platform.workload-requirements@1 default (run.<class>, mobility job, latency_class background, objective balanced)",++ "the run.job.queued|started|succeeded|failed|cancelled|expired events written in the same transaction as the state change, the outbox row keyed run:<job id>:<state> (a replay is one row) with a contract-valid evt_ document id on the published envelope, relayed from run_events_outbox by the openvibe-sdk outbox",++ "the job stream: a two-minute single-use RS256 stream ticket (audience openvibe.run, typ run-stream, its own key — never Network's) opens text/event-stream of run.job-stream-event@1, replayed from the last 1 MiB with monotonic seq as the SSE id and Last-Event-ID resume",++ "keyset paging on (created_at, id) with next_cursor null on the last page; run.job.admin lists any project's jobs (project_id in the query) and reads and cancels any job",++ "run_jobs (migrations/0001_jobs.sql) holds every job, its state, placement, timings, exit, result and the metered wall clock; jobs stay queued until the dispatcher bridge to Bot exists (plan T14 step 6, server/dispatch/index.js — a no-op seam today)",++ "Run holds no Billing token at all: Bot owns the node link and the per-second metering (plan T14 L1)",++ "truthful readiness (GET /api/ready: db required; valkey, Network JWKS, Events and Bot reported, never claimed) and the loopback-only /metrics gauges (jobs queued, active, ended, outbox backlog, streams)"++ ],++ "updated": "2026-10-07"++}nnew file mode 100644@@@ -0,0 +1,130 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.run — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.run.conf — keep them in sync and reload nginx++## after changing it. The Node app (port 4920, systemd unit openvibe-run) serves:++##++## /api/v1/jobs… the job API (run.job.submit|read|list|cancel|stream|admin); every++## request needs a Network service token for audience openvibe.run++## /api/v1/jobs/{id}/stream the job's Server-Sent Events (long-lived: never buffered or cached,++## Last-Event-ID forwarded so a reconnect resumes where it left)++## /api/health, /api/ready, /release.json++## /metrics Prometheus text, answered only to direct loopback callers (never proxied)++## /internal/ host-local service endpoints, refused here++##++## The client address reaches the app as $remote_addr only, same in X-Forwarded-For and CF-Connecting-IP:++## the header is set here, never appended to or passed through from the client, so the app's own view of++## who is calling cannot be spoofed. Run serves no page, no form and no cookie session.++##++## No proxy_cache: the API answers no-store, and an event stream must never be cached or buffered.++## Never log request bodies or the Authorization header: a service token and a stream ticket both ride++## in one.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=ovrun_api:10m rate=20r/s;++limit_req_zone $binary_remote_addr zone=ovrun_stream:10m rate=2r/s;++++# ── HTTP → HTTPS ────────────────────────────────────────────++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.run www.openvibe.run;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.run$request_uri; }++}++++# ── www → apex ──────────────────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.run;++++ ssl_certificate /etc/letsencrypt/live/openvibe.run/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.run/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.run$request_uri;++}++++# ── The service ─────────────────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.run;++++ ssl_certificate /etc/letsencrypt/live/openvibe.run/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.run/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.run.access.log;++ error_log /var/log/nginx/openvibe.run.error.log;++++ # The API accepts at most 768 kB of JSON (server/app.js; a job's args may be 512 KiB of it); 1 MB++ # leaves room for headers.++ client_max_body_size 1m;++++ add_header X-Content-Type-Options nosniff always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types application/json application/problem+json text/plain;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr; # the client address, from $remote_addr only++ proxy_set_header X-Forwarded-For $remote_addr; # never appended to, never passed through++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── The job stream: Server-Sent Events ──────────────────++ # The ticket rides in ?ticket= (a browser cannot put a header on an EventSource), and a reconnect++ # resumes with Last-Event-ID, which is forwarded verbatim.++ location ~ ^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/stream$ {++ limit_req zone=ovrun_stream burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4920;++ proxy_set_header Connection "";++ proxy_set_header Last-Event-ID $http_last_event_id;++ proxy_buffering off;++ proxy_cache off;++ gzip off;++ chunked_transfer_encoding on;++ proxy_read_timeout 1h;++ proxy_send_timeout 1h;++ send_timeout 1h;++ }++++ # ── Health, readiness, the release manifest ─────────────++ location = /api/health { proxy_pass http://127.0.0.1:4920; }++ location = /api/ready { proxy_pass http://127.0.0.1:4920; }++ location = /release.json { proxy_pass http://127.0.0.1:4920; }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4920/metrics) ──++ # The app refuses a request that arrived through a proxy; the vhost refuses it at the edge too.++ location = /metrics { return 404; }++++ # ── Host-local service endpoints (Events deliveries, other services on this host) ──++ location /internal/ { return 404; }++++ # ── The job API ─────────────────────────────────────────++ location /api/ {++ limit_req zone=ovrun_api burst=40 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4920;++ }++++ # ── Nothing else is served ──────────────────────────────++ location / { return 404; }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,33 @@++[Unit]++Description=OpenVibe.Run — the job service (jobs, placement, streaming; plan T14, ADR-034)++# Network (sign-in, JWKS, OAuth) should be up first, and Run's own PostgreSQL (pooled runtime role plus++# the direct owner role for migrations) and Valkey (state shared between processes) are required to++# serve; the events relay waits in the outbox and the dispatcher bridge to Bot (plan T14 step 6) is a++# no-op until it exists. Run degrades honestly without any of them: /api/ready says which (STATUS).++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.run++EnvironmentFile=/etc/openvibe/run.env++Environment=NODE_ENV=production++Environment=PORT=4920++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.run/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++TimeoutStopSec=10++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# Run's own database is PostgreSQL on the host's data role; the code tree stays read-only. The stream++# ticket key is read from RUN_STREAM_PRIVATE_KEY or RUN_STREAM_KEY_FILE (default++# /opt/openvibe.run/data/keys/run-stream.pem), never from the code tree.++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,15 @@++# OpenVibe.Run — docs++++The canonical design lives with the contracts, not here: OpenVibe.Contracts `docs/adr/ADR-034-platform-north-star.md`++(the job service, the cells and the Fabric) and the build plan's **T14** section. The API is generated from the++capability manifests: `contracts/generated/openapi/run.json` once `manifests/services/run.json` leaves++`status: "placeholder"` (a separate contracts change, plan T14 step 5 item 6).++++What does live here:++++- `protocol.md` — how Run and Bot talk when the dispatcher bridge lands (plan T14 step 6): the++ `platform.job@1` body it sends, the `platform.job-frame@1` answers it mirrors, and the state mapping. Not written++ yet: the bridge does not exist.++++Nothing else is a document here. The README's route table is the API as it is served today, and `STATUS.json` is++what this repository is.nnew file mode 100644@@@ -0,0 +1,43 @@++-- phase: expand++-- OpenVibe.Run's own record of a job (plan T14 R1; run.job.*). Bot owns the node link and the++-- per-second metering (OpenVibe.Bot/server/jobs); Run owns the API, the placement and the project scope.++CREATE TABLE IF NOT EXISTS run_jobs (++ id text PRIMARY KEY CHECK (id ~ '^job_[0-9A-HJKMNP-TV-Z]{26}$'),++ project_id text NOT NULL,++ requester text NOT NULL, -- the token principal that submitted it++ idempotency_key text NOT NULL,++ class text NOT NULL,++ job jsonb NOT NULL, -- the platform.job@1 body sent to the node++ ttl_ms bigint NOT NULL,++ state text NOT NULL CHECK (state IN ('queued','placed','running','succeeded','failed','cancelled','expired')),++ node_id text,++ provider text,++ region text,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ started_ms bigint,++ finished_ms bigint,++ exit_reason text,++ exit_code integer,++ result jsonb,++ wall_ms bigint NOT NULL DEFAULT 0,++ usage_read integer NOT NULL DEFAULT 0,++ error_code text,++ error_detail text++);++CREATE UNIQUE INDEX IF NOT EXISTS run_jobs_idem ON run_jobs (project_id, idempotency_key);++CREATE INDEX IF NOT EXISTS run_jobs_project_state ON run_jobs (project_id, created_at DESC);++-- run.job.* events waiting for the bus: the openvibe-sdk outbox shape (outboxSchema('run_events_outbox')).++CREATE TABLE IF NOT EXISTS run_events_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);nnew file mode 100644@@@ -0,0 +1,1405 @@++{++ "name": "openvibe-run",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-run",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.2"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/body-parser": {++ "version": "1.20.8",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/express-rate-limit": {++ "version": "7.5.1",++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",++ "license": "MIT",++ "engines": {++ "node": ">= 16"++ },++ "funding": {++ "url": "https://github.com/sponsors/express-rate-limit"++ },++ "peerDependencies": {++ "express": ">= 4.11"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/helmet": {++ "version": "8.3.0",++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",++ "license": "MIT",++ "engines": {++ "node": ">=18.0.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/EvanHahn"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.106.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",++ "integrity": "sha512-w3HV+CmqtJYTUT97FsWziSOzM0KnA81PnAJCG9w68T8jJMRIDk96FJJKFckFQbFLFfZWF7jAINf0OSEzalabaQ==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "integrity": "sha512-vDwnDV1e2xbWqShjTr4p6sNehoUAQwW8RyQKrwvoqX1pJbxks86u2DjF9LGSlAoAdMZhmJbgVaAEm0r18Qmt5g==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "2.11.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,41 @@++{++ "name": "openvibe-run",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Run — accepts jobs, places each on a node that advertises worker:<class>, and follows its platform.job-frame@1 frames (plan T14, ADR-034).",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "test:pg": "RUN_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe",++ "run",++ "jobs",++ "execution"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Run",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.2"++ },++ "engines": {++ "node": ">=22.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++++/**++ * Who is calling /api/v1 — resolved into req.principal:++ *++ * { kind: 'service', sub: 'svc:live', cap: [...], claims, project_id, jti } a Network client-credentials++ * token for audience openvibe.run; each route checks ONE++ * { kind: 'user', subject: 'usr_…', username, name, avatar, role } capability (see v1.js)++ * { kind: 'anonymous' }++ *++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.++ * A node principal (nod_…) is not a Run principal and is refused like any other non-service token.++ *++ * The project a job belongs to comes from the token's own project_id claim (a developer app or agent++ * token names one), never from the request body or a query: the requester and the billed project are++ * the token's, always.++ */++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const ANON = Object.freeze({ kind: 'anonymous' });++++/** Run's six capabilities (contracts/manifests/capabilities/run.job.*.json). */++const CAPABILITIES = ['run.job.submit', 'run.job.read', 'run.job.list', 'run.job.cancel', 'run.job.stream', 'run.job.admin'];++++function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }++}++++/** A Network user token's claims → the user principal (null when it names no subject). */++function userPrincipal(claims) {++ if (!claims) return null;++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (!subject) return null;++ return {++ kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,++ avatar: claims.avatar_url || null, role: claims.role || 'user', project_id: null, cap: [], claims,++ };++}++++/** Verify a service token (audience openvibe.run). Returns { ok, claims } or { ok:false, code, reason }. */++function verifyService(token, { publicKey, issuer, audience }) {++ if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };++ const payload = decodePayload(token);++ if (!payload || typeof payload.sub !== 'string' || !PRINCIPAL_SUB.test(payload.sub)) return { ok: false, code: 'token.invalid', reason: 'not a service token' };++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });++ if (!r.ok) return { ok: false, code: r.code, reason: r.reason };++ return { ok: true, claims: r.claims };++}++++/** The project a principal acts for: its token's project_id claim, or null (no project named). */++function projectOf(principal) {++ const id = principal && principal.claims && principal.claims.project_id;++ return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;++}++++/**++ * The principal as an identity.subject-ref@1 (the run_jobs.requester column holds 'type:id'). A service++ * token's sub is `svc:<slug>` — the wire spelling of the SubjectRef `{ type: 'service', id: '<slug>' }`++ * (ids.principalSub is the other direction); app, mod and agent subs already carry their type.++ */++function requesterOf(principal) {++ if (!principal) return null;++ if (principal.kind === 'user') return { type: 'user', id: principal.subject };++ if (principal.kind !== 'service') return null;++ const sub = String((principal.claims && principal.claims.sub) || principal.sub || '');++ if (sub.startsWith('svc:')) return { type: 'service', id: sub.slice(4) };++ return ids.parseSubject(sub);++}++++function createApiAuth({ config, keys, userAuth }) {++ async function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return { principal: ANON };++ const token = header.slice(7).trim();++ const publicKey = keys.get();++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const r = verifyService(token, { publicKey, issuer: config.network.issuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code, r.reason] };++ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], claims: r.claims, project_id: projectOf({ claims: r.claims }), jti: r.claims.jti } };++ }++ const claims = await userAuth.verify(token);++ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };++ const p = userPrincipal(claims);++ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: p };++ }++++ async function middleware(req, res, next) {++ const r = await resolve(req);++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }++++ /** A service principal holding `cap` (a trailing `.*` grant covers a family). */++ const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);++++ /**++ * requireCapability('run.job.submit') — the Express middleware of ONE route of the map in v1.js: the++ * token's grant list is judged by openvibe-contracts capabilities.check (never a route's own list, and++ * never the body). No token → 401; a token without the grant → 403 capability.denied.++ */++ function requireCapability(id) {++ if (!CAPABILITIES.includes(id)) throw new Error(`requireCapability(${id}): not one of Run's capabilities`);++ return function capabilityGuard(req, res, next) {++ const p = req.principal;++ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `${id} needs a Network service token for ${config.audience}`, ctx: req.ov });++ const decision = p.kind === 'service'++ ? capabilities.check(p.claims, id)++ : { allowed: false, code: 'capability.denied', reason: `${id} is granted to service principals only` };++ // run.job.admin is `internal` and, the manifest says, "never delegated and never in an app or mod++ // token": an app, mod or agent principal that somehow holds the grant is refused here too.++ const internalService = id !== 'run.job.admin' || String(p.sub || '').startsWith('svc:');++ if (decision.allowed && internalService) return next();++ return http.sendProblem(res, 403, decision.code || 'capability.denied', {++ detail: decision.allowed ? 'run.job.admin is internal: it is never an app, mod or agent token' : decision.reason || `${id} not granted`,++ ctx: req.ov,++ });++ };++ }++++ /**++ * The project and the requester this call acts for, both from the token alone. 403 run.project_required++ * when the token names no project (a first-party service token without the project_id claim, or a user++ * token): jobs belong to a project, and the billed project is never taken from a request body.++ */++ function requireProject(req) {++ const project = projectOf(req.principal);++ const requester = requesterOf(req.principal);++ if (!project || !requester) {++ const err = new Error('this token names no project: jobs belong to the project a token names (project_id)');++ err.status = 403;++ err.code = 'run.project_required';++ throw err;++ }++ return { project, requester: ids.formatSubject(requester) };++ }++++ return { middleware, resolve, granted, requireCapability, requireProject, projectOf, requesterOf };++}++++module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, projectOf, requesterOf, CAPABILITIES, PRINCIPAL_SUB, PROJECT_RE };nnew file mode 100644@@@ -0,0 +1,252 @@++'use strict';++++/**++ * /api/v1 — Run's job API for services (capability-guarded Network tokens for audience openvibe.run).++ * The route → capability map is fixed by the six capability manifests (contracts/manifests/capabilities/++ * run.job.*.json) and must not be invented:++ *++ * | Method & path | Capability | Request → answer |++ * |-----------------------------------|------------------|-----------------------------------------------|++ * | POST /jobs | run.job.submit | run.job-create-request@1 → run.job-create-result@1 (201 created, 200 idempotent repeat) |++ * | GET /jobs/:id | run.job.read | common.no-body@1 → run.job-read-result@1 |++ * | GET /jobs?state=&limit=&cursor= | run.job.list | run.job-list-query@1 → run.job-list-result@1 |++ * | POST /jobs/:id/cancel | run.job.cancel | common.no-body@1 → run.job-read-result@1 |++ * | POST /jobs/:id/stream/ticket | run.job.stream | common.no-body@1 → run.job-stream-ticket-result@1 |++ * | GET /jobs/:id/stream | run.job.stream | a ticket (never a service token) → text/event-stream of run.job-stream-event@1 |++ * | GET /admin/jobs | run.job.admin | run.job-list-query@1 (with project_id) → run.job-list-result@1 |++ *++ * The project and the requester always come from the token (claims.project_id, claims.sub), NEVER from the++ * body: a body field is never read for either. run.job.admin additionally reads and cancels any project's++ * job (the manifest says so) and is the only way to pass project_id to a list. Another project's job is++ * always 404 run.job_not_found, never 403: the answer must not say whether the id exists.++ *++ * Errors are RFC 9457 problem+json: 400 run.invalid_request (the body does not match its contract), 401++ * token.required / ticket.*, 403 capability.denied / run.project_required, 404 run.job_not_found, 409++ * run.idempotency.conflict, 413 run.args_too_large, 422 run.limits.exceeded / run.invalid_input.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { ids } = contracts;++const { fail, iso, isJobId } = require('../util');++const store = require('../jobs/store');++++// The six capabilities this router enforces, one per route above (contracts/manifests/capabilities).++const CAP = {++ submit: 'run.job.submit',++ read: 'run.job.read',++ list: 'run.job.list',++ cancel: 'run.job.cancel',++ stream: 'run.job.stream',++ admin: 'run.job.admin',++};++const IDEMPOTENCY_RE = /^[A-Za-z0-9._:-]{1,128}$/;++const STATES = ['queued', 'placed', 'running', 'succeeded', 'failed', 'cancelled', 'expired'];++++/** POST /jobs: validate a run.job-create-request@1 body, or answer 400 with the contract's own errors. */++function validCreate(body) {++ if (!body || typeof body !== 'object' || Array.isArray(body)) fail(400, 'run.invalid_request', 'the body must be a JSON object (run.job-create-request@1)');++ const v = contracts.validate('run.job-create-request@1', body);++ if (!v.valid) {++ const err = new Error('the body does not match run.job-create-request@1');++ err.status = 400;++ err.code = 'run.invalid_request';++ err.detail = v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 1000);++ err.extra = { errors: v.errors.slice(0, 20) };++ throw err;++ }++ return body;++}++++/** The project's own caps (server/config.js): a request over one is refused 422, never silently lowered. */++function checkLimits(body, caps) {++ if (body.limits.wall_ms > caps.maxWallMs) fail(422, 'run.limits.exceeded', `limits.wall_ms is at most ${caps.maxWallMs} ms for a project`);++ if (body.limits.cpu_ms > caps.maxCpuMs) fail(422, 'run.limits.exceeded', `limits.cpu_ms is at most ${caps.maxCpuMs} ms for a project`);++ if (body.limits.mem_bytes > caps.maxMemBytes) fail(422, 'run.limits.exceeded', `limits.mem_bytes is at most ${caps.maxMemBytes} bytes for a project`);++ // The lifetime is checked whether the caller named it or took the contract's default++ // (limits.wall_ms + 600000): a default that lands over the cap is refused, never quietly shortened.++ const ttl = body.ttl_ms != null ? body.ttl_ms : body.limits.wall_ms + 600_000;++ if (ttl > caps.maxTtlMs) fail(422, 'run.limits.exceeded', `ttl_ms is at most ${caps.maxTtlMs} ms for a project (${body.ttl_ms != null ? 'asked for' : 'the default, limits.wall_ms + 600000, is'} ${ttl} ms)`);++ if (body.args !== undefined && Buffer.byteLength(JSON.stringify(body.args)) > caps.maxArgsBytes) fail(413, 'run.args_too_large', `args serialize to at most ${caps.maxArgsBytes} bytes`);++ const names = (body.inputs || []).map((i) => i.name);++ if (new Set(names).size !== names.length) fail(422, 'run.invalid_input', 'input names must be unique');++ // egress public/openvibe-only is not refused here: which node may run it is placement's decision and++ // the job waits queued until a node offers it (the dispatcher bridge, plan T14 step 6).++}++++/** The idempotency key of a submit: body.idempotency_key or the Idempotency-Key header, never disagreeing. */++function idempotencyKey(req, body) {++ const header = req.get('Idempotency-Key');++ const inBody = body.idempotency_key;++ if (header != null && inBody != null && header !== inBody) fail(422, 'run.invalid_input', 'the Idempotency-Key header and body.idempotency_key disagree');++ const key = header != null ? header : inBody;++ if (key == null) return `auto:${ids.ulid().toLowerCase()}`;++ if (!IDEMPOTENCY_RE.test(key)) fail(422, 'run.invalid_input', 'idempotency_key must match ^[A-Za-z0-9._:-]{1,128}$');++ return key;++}++++/** GET /jobs and GET /admin/jobs: a run.job-list-query@1 query string, or 400. */++function listQuery(query) {++ const q = {++ ...(query.state !== undefined ? { state: query.state } : {}),++ ...(query.project_id !== undefined ? { project_id: query.project_id } : {}),++ ...(query.limit !== undefined ? { limit: query.limit } : {}),++ ...(query.cursor !== undefined ? { cursor: query.cursor } : {}),++ };++ const v = contracts.validate('run.job-list-query@1', q);++ if (!v.valid) fail(400, 'run.invalid_request', `the query does not match run.job-list-query@1: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);++ return { state: q.state ?? null, project_id: q.project_id ?? null, limit: q.limit === undefined ? 50 : Number(q.limit), cursor: q.cursor ?? null };++}++++/** The archived run.job-stream-ticket-result@1 answer (validated against its contract before it is sent). */++function ticketResult({ ticket, expiresAtMs, expiresIn, streamUrl, jobId }) {++ const body = { ticket, expires_at: iso(expiresAtMs), expires_in: expiresIn, stream_url: streamUrl, job_id: jobId };++ contracts.assertValid('run.job-stream-ticket-result@1', body);++ return body;++}++++/**++ * v1Router({ config, db, valkey, apiAuth, stream, tickets, events, dispatch, now, log }).++ * Every route is mounted with apiAuth.requireCapability('run.job.…') — exactly the capability of its++ * manifest — and the middleware of the app ran first, so req.principal is always resolved.++ */++function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now = () => Date.now(), log = console }) {++ const r = express.Router();++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);++ /** The route's capability, from the token's grant list (openvibe-contracts capabilities.check). */++ const guard = apiAuth.requireCapability;++ const admin = (req) => apiAuth.granted(req.principal, 'run.job.admin');++++ /**++ * One job, scoped to the token's project — or to any project with run.job.admin. Null when the id is++ * not a job id, does not exist, or belongs to another project: all three are the same 404 to the caller.++ */++ async function getJob(req, id) {++ if (!isJobId(id)) return null;++ if (admin(req)) return store.get(db, id, null);++ return store.get(db, id, apiAuth.requireProject(req).project);++ }++++ r.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });++++ // ── POST /jobs — run.job.submit ────────────────────────────────────────────++ r.post('/jobs', guard('run.job.submit'), wrap(async (req, res) => {++ const { project: projectId, requester } = apiAuth.requireProject(req);++ const body = validCreate(req.body);++ checkLimits(body, config.limits);++ const key = idempotencyKey(req, body);++ const at = now();++ const out = await db.tx(async (t) => {++ const made = await store.mint(t, body, { projectId, requester, idempotencyKey: key, now: at });++ // The queued event is written in the same transaction as the row (never for an idempotent repeat:++ // the row key run:<job id>:queued is already taken, and that replay is the row that exists).++ if (made.created) await events.queued(t, made.row, { traceparent: req.ov && req.ov.traceparent });++ return made;++ });++ // The job stays queued: the dispatcher bridge is plan T14 step 6 (server/dispatch/index.js).++ if (out.created && dispatch.enabled) await dispatch.place(out.row);++ if (out.created) stream.seed(out.row);++ res.status(out.created ? 201 : 200).json({ created: out.created, job: store.readResult(out.row) });++ }));++++ // ── GET /jobs — run.job.list ───────────────────────────────────────────────++ r.get('/jobs', guard('run.job.list'), wrap(async (req, res) => {++ const q = listQuery(req.query);++ // "project_id: Admin only (run.job.admin): list this project's jobs; with run.job.list it is refused 403."++ if (q.project_id && !admin(req)) fail(403, 'capability.denied', 'run.job.admin is required to list another project\'s jobs');++ const projectId = q.project_id || apiAuth.requireProject(req).project;++ const { rows, nextCursor } = await store.list(db, projectId, q);++ res.json({ jobs: rows.map(store.readResult), next_cursor: nextCursor });++ }));++++ // ── GET /jobs/:id — run.job.read ───────────────────────────────────────────++ r.get('/jobs/:id', guard('run.job.read'), wrap(async (req, res) => {++ const row = await getJob(req, req.params.id);++ if (!row) fail(404, 'run.job_not_found', 'no such job');++ res.json(store.readResult(row));++ }));++++ // ── POST /jobs/:id/cancel — run.job.cancel ─────────────────────────────────++ r.post('/jobs/:id/cancel', guard('run.job.cancel'), wrap(async (req, res) => {++ const row = await getJob(req, req.params.id);++ if (!row) fail(404, 'run.job_not_found', 'no such job');++ const at = now();++ const out = await db.tx(async (t) => {++ const moved = await store.cancel(t, row.id, row.project_id, at);++ if (moved.cancelled) await events.cancelled(t, moved.row, { traceparent: req.ov && req.ov.traceparent }); // run:<id>:cancelled — a replay is the same row++ return moved;++ });++ // A running job is only marked: the dispatcher bridge sends job_cancel and its job_exit ends it (step 6).++ if (out.cancelRequested && dispatch.enabled) await dispatch.cancel(out.row);++ if (out.cancelled) stream.state(out.row.id, out.row.state, at);++ res.json(store.readResult(out.row));++ }));++++ // ── POST /jobs/:id/stream/ticket — run.job.stream ──────────────────────────++ r.post('/jobs/:id/stream/ticket', guard('run.job.stream'), wrap(async (req, res) => {++ const row = await getJob(req, req.params.id);++ if (!row) fail(404, 'run.job_not_found', 'no such job');++ if (!tickets.get()) fail(503, 'run.stream.disabled', 'stream tickets are disabled: no stream key is configured (RUN_STREAM_PRIVATE_KEY or RUN_STREAM_KEY_FILE)');++ const { ticket, claims } = tickets.mint({ jobId: row.id, projectId: row.project_id });++ stream.seed(row); // the stream that follows replays this job's state even if this process never saw it++ res.json(ticketResult({++ ticket, expiresAtMs: claims.exp * 1000, expiresIn: claims.exp - claims.iat,++ streamUrl: `${config.baseUrl}/api/v1/jobs/${row.id}/stream`, jobId: row.id,++ }));++ }));++++ // ── GET /jobs/:id/stream — run.job.stream, ticket only ─────────────────────++ // A browser cannot send a header on an EventSource, and a service token must never ride in a query++ // string (it would land in logs): this route accepts the two-minute ticket the POST above mints, and++ // nothing else. The ticket names the job and the project, so the stream is scoped without any query.++ r.get('/jobs/:id/stream', wrap(async (req, res) => {++ const token = Array.isArray(req.query.ticket) ? null : req.query.ticket;++ if (token == null || token === '') fail(401, 'ticket.required', 'open the stream with ?ticket= from POST /api/v1/jobs/:id/stream/ticket');++ const verified = tickets.verify(token); // single use: the jti is consumed here, so one ticket opens one stream++ if (!verified.ok) fail(401, verified.code, verified.reason);++ if (verified.claims.sub !== req.params.id) fail(403, 'run.stream.ticket_mismatch', 'this ticket is for another job');++ const row = await store.get(db, req.params.id, verified.claims.project);++ if (!row) fail(404, 'run.job_not_found', 'no such job');++ if (!stream.canAttach(row.id)) fail(503, 'run.stream.busy', 'too many streams on this job; try again shortly');++++ const lastEventId = Number.parseInt(String(req.get('Last-Event-ID') || req.query.last_event_id || '0'), 10) || 0;++ stream.seed(row); // a restart or another process: replay the state the row is in++++ res.status(200);++ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');++ res.setHeader('Cache-Control', 'no-store, no-transform');++ res.setHeader('Connection', 'keep-alive');++ res.setHeader('X-Accel-Buffering', 'no'); // nginx: never buffer an event stream++ res.flushHeaders?.();++++ let sub = null;++ const finish = () => {++ clearInterval(beat);++ if (sub) sub.unsubscribe();++ if (!res.writableEnded) res.end();++ };++ const write = (event) => {++ res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);++ // The contract: end is the job's last event and the server closes the stream after it.++ if (event.type === 'end') finish();++ };++ // A comment every heartbeat keeps proxies and the client from timing the stream out; the replay++ // happens inside subscribe, before the first beat.++ const beat = setInterval(() => { res.write(': keep-alive\n\n'); }, config.stream.heartbeatMs);++ beat.unref?.();++ sub = stream.subscribe(row.id, { lastEventId, onEvent: write });++ req.on('close', finish);++ res.on('close', finish);++ if (sub.ended) finish(); // an ended job: the replay is the whole answer++ }));++++ // ── GET /admin/jobs — run.job.admin ────────────────────────────────────────++ r.get('/admin/jobs', guard('run.job.admin'), wrap(async (req, res) => {++ const q = listQuery(req.query);++ const { rows, nextCursor } = await store.adminList(db, q);++ res.json({ jobs: rows.map(store.readResult), next_cursor: nextCursor });++ }));++++ return r;++}++++module.exports = { v1Router, CAP, IDEMPOTENCY_RE, validCreate, checkLimits, idempotencyKey, listQuery, ticketResult };nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++++/**++ * OpenVibe.Run — the job API (plan T14, ADR-034). Express app factory; server/index.js listens, tests++ * build their own instance. Run serves no page: every route is a machine route.++ *++ * GET /api/health, /api/ready, /release.json liveness, truthful readiness, the release manifest++ * GET /metrics direct loopback callers only (never proxied)++ * /api/v1/* the job API (api/v1.js: one capability per route)++ *++ * createApp({ config, db, valkey, keys, outbox, stream, tickets, events, dispatch, now, fetchImpl, log })++ * — everything injectable. `db` is an openvibe-sdk/db handle with the schema migrated. Nothing starts at++ * module load: the events relay, the key refresher and the stream heartbeat are started by server/index.js++ * (the heartbeat lives on each stream's own response).++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const { http } = require('openvibe-contracts');++const { isLoopbackDirect } = require('openvibe-shared/metrics');++const { loadConfig } = require('./config');++const { openDb } = require('./db');++const { createKeyProvider, createUserAuth } = require('./network');++const { createRunOutbox } = require('./events/outbox');++const { createStreamTickets } = require('./jobs/ticket');++const { createJobStream } = require('./jobs/stream');++const { createRunEvents } = require('./jobs/events');++const { createDispatcher } = require('./dispatch');++const { createApiAuth } = require('./api/auth');++const { v1Router } = require('./api/v1');++const { inputError } = require('./util');++const { createRunReadiness, registerRunGauges } = require('./observability');++++const VERSION = require('../package.json').version;++// A create request may carry 512 KiB of args (run.job-create-request@1) on top of its inputs and limits,++// so the JSON body cap is above that and args are checked against their own limit (413 run.args_too_large).++const JSON_LIMIT = '768kb';++++function createApp(opts = {}) {++ const config = opts.config || loadConfig();++ const db = opts.db || openDb(config);++ const valkey = opts.valkey || null;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const log = opts.log || console;++ const now = opts.now || (() => Date.now());++ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });++ const userAuth = createUserAuth(config, keys);++ const outbox = opts.outbox || createRunOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });++ const events = opts.events || createRunEvents({ outbox, now, log });++ const stream = opts.stream || createJobStream({ config, now, log });++ const tickets = opts.tickets || createStreamTickets({ config, now, log });++ // The dispatcher bridge to Bot: a no-op until plan T14 step 6 (server/dispatch/index.js), so a job++ // stays queued and no node ever sees it.++ const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now });++ const apiAuth = createApiAuth({ config, keys, userAuth });++ const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ // Run answers JSON and server-sent events only: no page, no form, no script — so helmet's defaults++ // carry everything that matters here and there is no CSP to widen.++ app.use(helmet({ contentSecurityPolicy: false }));++++ const registry = opts.registry || require('openvibe-shared/metrics').createRegistry();++ const gauges = registerRunGauges(registry, { db, stream, now, log });++ app.get('/metrics', (req, res, next) => (isLoopbackDirect(req) ? gauges.refresh().then(() => next(), () => next()) : next()));++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'run', release: release.release, registry });++++ app.use(http.middleware());++ app.use(cookieParser());++++ // Liveness: the process answers. What it can and cannot do is /api/ready's job, and the counts are++ // /metrics' and /api/ready's — this route says nothing it cannot back with a value it just read.++ app.get('/api/health', (req, res, next) => outbox.status().then((queuedEvents) => res.json({++ ok: true, service: 'run', version: VERSION, streams: stream.status().streams, events: queuedEvents, dispatcher: dispatch.status(),++ }), next));++ const readiness = createRunReadiness({ db, valkey, keys, config, outbox, stream, dispatch, release: release.release, fetchImpl });++ app.get('/api/ready', readiness.handler);++ release.mount(app, { registry: metrics.registry });++++ app.use('/api/v1',++ // A per-address cap on the API (the per-project quotas are the project caps on a job's limits); a++ // refusal is an RFC 9457 problem like every other answer here, never a plain-text 429.++ rateLimit({++ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false,++ handler: (req, res) => http.sendProblem(res, 429, 'run.rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),++ }),++ express.json({ limit: JSON_LIMIT }),++ (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },++ apiAuth.middleware,++ v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now, log }));++++ app.use((req, res) => {++ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });++ return res.status(404).type('text/plain').send('Not found\n');++ });++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, next) => {++ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });++ const refused = inputError(err);++ if (refused && !res.headersSent && req.path.startsWith('/api/')) return http.sendProblem(res, 422, refused.code, { detail: refused.detail, ctx: req.ov });++ if (err && err.status && err.code && !res.headersSent && req.path.startsWith('/api/')) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov, extra: err.extra });++ log.error('[Run] unhandled error:', err);++ if (res.headersSent) return undefined;++ if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'run.internal', { ctx: req.ov });++ return res.status(500).type('text/plain').send('Something went wrong\n');++ });++++ Object.assign(app.locals, { config, db, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });++ return app;++}++++module.exports = { createApp, VERSION, JSON_LIMIT };nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++++/**++ * OpenVibe.Run configuration. Everything comes from the environment (.env in development,++ * /etc/openvibe/run.env in production). loadConfig(env) is pure so tests build their own.++ *++ * Run is the job service of the network (plan T14, ADR-034): it owns the API, the project scope and the++ * placement, on its own PostgreSQL (ADR-035). Bot owns the node link and the per-second metering, so Run++ * holds no Billing token at all — Run never moves money (plan T14 L1).++ */++require('dotenv').config();++++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };++const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));++const trim = (u) => String(u || '').replace(/\/+$/, '');++/** A PEM in an environment variable carries its newlines escaped. */++const pem = (v) => (v ? String(v).replace(/\\n/g, '\n') : null);++++/** A byte size or a plain integer: RUN_LIMITS_MAX_MEM_BYTES=4gb or 4294967296. */++function bytes(v, d) {++ const m = /^(\d+)\s*(b|kb|mb|gb)?$/i.exec(String(v || '').trim());++ if (!m) return d;++ const n = Number(m[1]) * ({ b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }[(m[2] || 'b').toLowerCase()] || 1);++ return Number.isFinite(n) && n > 0 ? n : d;++}++++function loadConfig(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4920); // 4920 is Run's; 4000-4910 are taken++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.run' : `http://localhost:${port}`));++ return {++ nodeEnv,++ isProduction,++ port,++ host: env.HOST || '127.0.0.1',++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,++++ // PostgreSQL (ADR-035): DATABASE_URL is the pooled runtime role through PgBouncer (transaction++ // mode), DATABASE_DIRECT_URL the owner role on a direct connection, for migrations at boot.++ db: {++ url: env.DATABASE_URL || '',++ directUrl: env.DATABASE_DIRECT_URL || '',++ },++ // Valkey: shared, never authoritative. Unset: this process only.++ valkey: {++ url: env.VALKEY_URL || '',++ prefix: env.VALKEY_PREFIX || 'ov:run:',++ },++ // Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network; Run answers++ // for the audience openvibe.run only.++ network: {++ url: networkUrl,++ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ publicKey: pem(env.OV_NETWORK_PUBLIC_KEY),++ },++ audience: env.RUN_AUDIENCE || 'openvibe.run',++ // Run's client in the Network (client `run`): the events relay mints its service token with++ // events.event.publish through it. Unset: run.job.* events wait in the outbox.++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'run',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ },++ cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },++++ events: {++ url: trim(env.EVENTS_URL || ''),++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ },++++ jobs: {++ enabled: env.RUN_JOBS !== 'off',++ intervalMs: int(env.RUN_JOBS_INTERVAL_MS, 5000),++ },++++ // What a project's token may ask for (run.job-create-request@1 $defs.limits): a request over one of++ // these is refused 422 run.limits.exceeded, never silently lowered (the contract's own ceiling for++ // ttl_ms is 24 h). The node merges its own, stricter caps on top.++ limits: {++ maxWallMs: Math.max(1, int(env.RUN_LIMITS_MAX_WALL_MS, 3600_000)),++ maxCpuMs: Math.max(1, int(env.RUN_LIMITS_MAX_CPU_MS, 3600_000)),++ maxMemBytes: bytes(env.RUN_LIMITS_MAX_MEM_BYTES, 4 * 1024 ** 3),++ maxTtlMs: Math.max(1000, int(env.RUN_LIMITS_MAX_TTL_MS, 86_400_000)),++ // The contract: args serialize to at most 512 KiB, else 413 (the JSON body cap is above it).++ maxArgsBytes: bytes(env.RUN_LIMITS_MAX_ARGS_BYTES, 512 * 1024),++ },++++ // The job stream (run.job-stream-event@1). The ticket is a two-minute RS256 compact JWS Run signs++ // with a key of its own — never the Network signing key, and never a session token (audience++ // openvibe.run, typ run-stream). Unset in production: stream tickets answer 503 (the API still++ // serves); in development a key is generated in memory at boot.++ stream: {++ ttlS: Math.max(1, Math.min(300, int(env.RUN_STREAM_TTL_S, 120))),++ keyFile: env.RUN_STREAM_KEY_FILE != null ? String(env.RUN_STREAM_KEY_FILE) : 'data/keys/run-stream.pem',++ privateKey: pem(env.RUN_STREAM_PRIVATE_KEY),++ keyId: env.RUN_STREAM_KEY_ID || 'run-stream-1',++ ringBytes: Math.max(4096, bytes(env.RUN_STREAM_RING_BYTES, 1024 * 1024)), // the contract: replay at most the last 1 MiB++ maxPerJob: Math.max(1, int(env.RUN_STREAM_MAX_PER_JOB, 64)),++ heartbeatMs: Math.max(1000, int(env.RUN_STREAM_HEARTBEAT_MS, 25_000)),++ },++++ // The dispatcher bridge to Bot (plan T14 step 6, R1c): Bot holds the node link and the per-second++ // metering; Run calls it as the service principal `svc:run` for audience openvibe.bot. Until step 6++ // server/dispatch/index.js is a no-op and every job stays queued.++ dispatch: {++ botUrl: trim(env.RUN_BOT_URL || 'http://127.0.0.1:4630'),++ botAudience: env.RUN_BOT_AUDIENCE || 'openvibe.bot',++ intervalMs: int(env.RUN_DISPATCH_INTERVAL_MS, 1000),++ timeoutMs: int(env.RUN_DISPATCH_TIMEOUT_MS, 8000),++ },++ };++}++++module.exports = { loadConfig };nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++/**++ * Run's own PostgreSQL database (ADR-035), through openvibe-sdk/db: async, pooled, one dialect.++ *++ * The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection++ * (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in++ * transaction mode). Run is new: migrations/0001_jobs.sql is written as the final schema, no window.++ *++ * run_jobs one row per job (plan T14 R1; run.job-create-request@1)++ * run_events_outbox the openvibe-sdk transactional outbox (run.job.* events)++ *++ * openDb(config) the serving handle: DATABASE_URL; in development without it, an++ * embedded PGlite database in data/pglite (one process)++ * migrate(config, {serving}) apply migrations/ with the owner role, then close that connection++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++function openDb(config, { registry, log = console } = {}) {++ if (!config.db.url && !config.isProduction) {++ log.warn(`[Run] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);++ fs.mkdirSync(DEV_PGLITE, { recursive: true });++ return createDb({ pglite: DEV_PGLITE, service: 'run', registry, log });++ }++ return createDb({ url: config.db.url, service: 'run', registry, log });++}++++/** Apply pending migrations; several processes starting together are serialised by the SDK's lock. */++async function migrate(config, { serving = null, log = console } = {}) {++ if (serving && serving.store === 'pglite') return serving.migrate({ dir: MIGRATIONS, log });++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'run-migrate', max: 1, log });++ try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++}++++module.exports = { openDb, migrate, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++++/**++ * The dispatcher bridge Run → Bot — SEAM ONLY: plan T14 step 6 (R1c) builds this, and every method here++ * is a no-op until then. Run accepts jobs, scopes them to a project and queues them (plan T14 step 5,++ * R1b); it does not yet hand them to a node, so every job stays `queued` and no node ever sees it.++ *++ * Bot owns the node link and the per-second metering (OpenVibe.Bot server/jobs: dispatch(), cancel(),++ * createJobFrames(); migrations/0004_run_jobs.sql). Step 6 replaces this module with the real bridge:++ *++ * place(row) the platform.job@1 body — store.frameOf(row.job), the job record's own platform keys,++ * validated against platform.job@1, never the requirements/placed_at/cancel_requested Run++ * keeps beside it — sent to Bot with Run's service token `svc:run` for audience++ * openvibe.bot holding bot.job.dispatch, which places it on a node advertising++ * worker:<class>++ * poll() what Bot answered since the last poll; store.mirror() copies state, placement, timings,++ * exit, result and the metered wall clock back, store.settle() reaches the end state, and++ * jobs/events.js emits run.job.started|succeeded|failed|cancelled|expired in the same++ * transaction; stream.js carries the job's stdout++ * cancel(row) a job whose cancel_requested marker is set: Bot's job_cancel++ * sweep() ttl_ms ran out before placement: settle expired with run.job.unplaceable or run.job.ttl++ *++ * The interface is fixed here so server/index.js and the API already call it the way the bridge will be++ * called; only this file changes in step 6. The seam is never enabled by configuration: RUN_BOT_URL and++ * RUN_BOT_AUDIENCE (server/config.js) are read only by the real bridge.++ */++++/** The runtime class a job needs a node to advertise (platform.resource-offer@1 `worker:<class>`). */++const workerCapability = (runtimeClass) => `worker:${runtimeClass}`;++++/**++ * createDispatcher({ config, db, store, events, stream, log, now }) → the dispatcher interface.++ * `enabled` is false until plan T14 step 6: nothing is sent to a node, nothing is mirrored.++ */++function createDispatcher({ config = null, db = null, store = null, events = null, stream = null, log = console, now = () => Date.now() } = {}) {++ const enabled = false;++ const note = 'the dispatcher bridge to Bot is not built yet (plan T14 step 6, R1c): every job stays queued';++++ /** Send a queued job to a node. → { placed, reason }. No-op until step 6. */++ async function place(row) {++ return { placed: false, reason: 'dispatcher.noop', job_id: row && row.id, worker: row && workerCapability(row.class) };++ }++++ /** Cancel a placed or running job on its node (after the API marked cancel_requested). → { cancelled, reason }. */++ async function cancel(row) {++ return { cancelled: false, reason: 'dispatcher.noop', job_id: row && row.id };++ }++++ /** What Bot answered since the last poll. → [ { job_id, state, … } ]. Empty until step 6. */++ async function poll() { return []; }++++ /** The ttl sweep: jobs whose ttl_ms ran out before placement (run.job.unplaceable / run.job.ttl). → [ { job_id, state } ]. */++ async function sweep() { return []; }++++ function start() { /* step 6 starts the poller here; nothing runs at module load */ }++ function stop() {}++++ return {++ enabled, note, place, cancel, poll, sweep, start, stop,++ status: () => ({ enabled, note, bot_url: config ? config.dispatch.botUrl : null, audience: config ? config.dispatch.botAudience : null }),++ now,++ // Kept so step 6's bridge has its dependencies in one place without changing the seam's signature.++ deps: { config, db, store, events, stream, log },++ };++}++++module.exports = { createDispatcher, workerCapability };nnew file mode 100644@@@ -0,0 +1,43 @@++'use strict';++++/**++ * Run → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * run.job.queued a job was stored queued (POST /api/v1/jobs; never for an idempotent repeat)++ * run.job.started the node sent job_started++ * run.job.succeeded job_exit reason exited with code 0++ * run.job.failed job_exit with another code, or reason limit, stopped or failed++ * run.job.cancelled POST /api/v1/jobs/:id/cancel, or job_exit reason cancelled++ * run.job.expired ttl_ms ran out (run.job.unplaceable or run.job.ttl)++ *++ * A row is written inside the transaction that makes the change (jobs/events.js: the row is keyed by++ * `run:<job id>:<state>`, and it validates its envelope against events.event-envelope@1 and its payload++ * against run.job.<state>@1 itself), so an event exists if and only if its change committed. This module++ * owns the relay: it publishes with Run's service token (events.event.publish) when EVENTS_URL and++ * OV_OAUTH_CLIENT_SECRET are set, and otherwise rows wait in run_events_outbox and status() says so.++ * Payloads carry ids, states and results only — never a credential, never the job's stdout.++ */++const { createServiceOutbox } = require('openvibe-sdk/events');++++const TABLE = 'run_events_outbox'; // migrations/0001_jobs.sql++const ACTOR = { type: 'service', id: 'run' };++const EVENT_TYPES = ['run.job.queued', 'run.job.started', 'run.job.succeeded', 'run.job.failed', 'run.job.cancelled', 'run.job.expired'];++++function createRunOutbox({ db, config, fetchImpl, now, log = console }) {++ return createServiceOutbox({++ db,++ source: 'run',++ table: TABLE,++ eventsUrl: config.events.url,++ networkInternalUrl: config.network.internalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ intervalMs: config.events.intervalMs,++ log,++ eventTypes: EVENT_TYPES,++ ...(fetchImpl ? { fetch: fetchImpl } : {}),++ ...(now ? { now } : {}),++ });++}++++module.exports = { createRunOutbox, TABLE, ACTOR, EVENT_TYPES };nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++++/**++ * OpenVibe.Run — process entry. `node server/index.js`++ * Listens on PORT (4920) behind nginx (openvibe.run); see deploy/.++ *++ * Boot (ADR-035): apply migrations/ with the owner role (DATABASE_DIRECT_URL, one direct connection,++ * closed afterwards), then serve on the pooled runtime role (DATABASE_URL). Several processes may start++ * together: the migration run is serialised by an advisory lock. Nothing starts at module load — the key++ * refresher, the stream key, the events outbox relay and the dispatcher poller all start here, and the++ * signal handlers stop them in order.++ *++ * Background jobs (RUN_JOBS=off disables them): the events outbox relay and the job loop. The job loop++ * calls the dispatcher's poll() and sweep() — a no-op until the dispatcher bridge to Bot exists (plan T14++ * step 6, server/dispatch/index.js) — so today every job stays queued and no node ever sees one.++ */++const { loadConfig } = require('./config');++const { openDb, migrate } = require('./db');++const { createApp } = require('./app');++const { createValkey } = require('openvibe-sdk/valkey');++const { gracefulStop } = require('openvibe-sdk/service');++const { createRegistry } = require('openvibe-shared/metrics');++++async function main() {++ const config = loadConfig();++ const registry = createRegistry();++ const db = openDb(config, { registry });++ const m = await migrate(config, { serving: db });++ if (m.held.length) console.warn(`[Run] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);++ const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });++++ const app = createApp({ config, db, valkey, registry });++ const { keys, outbox, stream, tickets, dispatch } = app.locals;++ keys.start();++ const ticketKey = tickets.start();++++ const timers = [];++ if (config.jobs.enabled) {++ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Run] job:', e.message)); }, ms); t.unref(); timers.push(t); };++ // The dispatcher bridge is a no-op until plan T14 step 6: poll() and sweep() answer [] and nothing++ // changes, but the loop is wired the way the real bridge will run it.++ every(config.dispatch.intervalMs, async () => {++ const answers = await dispatch.poll();++ if (answers.length) console.warn(`[Run] ${answers.length} dispatcher answer(s) ignored: ${dispatch.note}`);++ });++ every(config.jobs.intervalMs, () => dispatch.sweep());++ outbox.start();++ dispatch.start();++ }++++ const server = app.listen(config.port, config.host, () => {++ console.log(`[Run] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: this process only'})`);++ console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `→ ${config.dispatch.botUrl}` : 'no-op until plan T14 step 6 (jobs stay queued)'}`);++ });++ server.keepAliveTimeout = 65_000;++++ // systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the++ // stop steps in order (nothing new starts), drains the HTTP server — event streams are destroyed, so a++ // client reconnects — then the close steps and exits. Run's manifest declares no lifecycle.shutdown++ // deadline, so the kit's 5000 ms default is the right value.++ const { stop: shutdown } = gracefulStop({++ name: 'Run',++ server,++ drainMs: 4000,++ deadlineMs: 5000,++ deadlineExitCode: 0,++ stop: [++ () => timers.forEach(clearInterval),++ () => keys.stop(),++ () => tickets.stop(),++ () => dispatch.stop(),++ () => stream.close(),++ () => outbox.stop(),++ ],++ close: [++ () => db.close().catch(() => {}),++ () => valkey && valkey.close().catch(() => {}),++ ],++ });++ return { app, server, shutdown };++}++++if (require.main === module) {++ main().catch((e) => {++ console.error(`[Run] could not start: ${e.message}`);++ process.exit(1);++ });++}++++module.exports = { main };nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++++/**++ * run.job.* events (contracts/events/payloads/run.job.*@1; migrations/0001_jobs.sql, run_events_outbox).++ *++ * Every emitter takes the caller's transaction handle t and the row the change produced, so the event is++ * written in the same transaction as the state change: an event exists if and only if its change++ * committed. The outbox row's event_id is `run:<job id>:<state>` — one row per (job, state), so a replay++ * (a retry, a double cancel) is the row that is already there and never a second event:++ *++ * run.job.queued state queued event_id run:<id>:queued++ * run.job.started state running event_id run:<id>:running++ * run.job.succeeded state succeeded event_id run:<id>:succeeded++ * run.job.failed state failed event_id run:<id>:failed++ * run.job.cancelled state cancelled event_id run:<id>:cancelled++ * run.job.expired state expired event_id run:<id>:expired++ *++ * The payload is the run.job-read-result@1 projection the payload contract describes — job_id,++ * project_id, requester, state, class, artifact, egress, placement, timings, exit, usage, error — never++ * args, inputs, the result body or the job's stdout, and never a credential.++ *++ * The row's key and the envelope's event_id are two different things on purpose. run_events_outbox is++ * keyed by Run's own `run:<job id>:<state>` (above), while the published envelope carries an `evt_<ULID>`++ * document id: events.event-envelope@1 (additionalProperties false) pins event_id to++ * `^evt_[0-9A-HJKMNP-TV-Z]{26}$`, and OpenVibe.Events validates every published envelope against it, so++ * the dossier's key cannot itself be the envelope's event_id — a row inserted with it would be published++ * and then refused. The row is written here, in the caller's transaction (the openvibe-sdk relay is the++ * only thing that moves rows to the bus); `enqueueOnce` returns whether the row is new, so a replay is a++ * quiet no-op rather than an error.++ */++const contracts = require('openvibe-contracts');++const { toJob } = require('./store');++const { ACTOR, TABLE } = require('../events/outbox');++++const TRACEPARENT_RE = /^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/;++++/**++ * Event name per state, the state its payload carries, and its priority when the payload contract names++ * one (queued and started are low, failed is important; the other three say "normal", which++ * events.event-envelope@1 has no value for — the envelope's own default stands).++ */++const EVENT_OF_STATE = {++ queued: { event: 'run.job.queued', state: 'queued', contract: 'run.job.queued@1', priority: 'low' },++ // The job's state is `running`; its event — and so its payload contract — is run.job.started.++ running: { event: 'run.job.started', state: 'running', contract: 'run.job.started@1', priority: 'low' },++ succeeded: { event: 'run.job.succeeded', state: 'succeeded', contract: 'run.job.succeeded@1', priority: null },++ failed: { event: 'run.job.failed', state: 'failed', contract: 'run.job.failed@1', priority: 'important' },++ cancelled: { event: 'run.job.cancelled', state: 'cancelled', contract: 'run.job.cancelled@1', priority: null },++ expired: { event: 'run.job.expired', state: 'expired', contract: 'run.job.expired@1', priority: null },++};++++/** The payload: run.job-read-result@1 without args, inputs, result or output. */++function payloadOf(row) {++ const job = toJob(row);++ return {++ job_id: job.id,++ project_id: job.project_id,++ requester: job.requester,++ state: job.state,++ class: job.class,++ artifact: job.artifact,++ egress: job.egress,++ placement: job.placement,++ timings: job.timings,++ exit: job.exit,++ usage: job.usage,++ error: job.error,++ };++}++++/**++ * createRunEvents({ outbox, now }) → one emitter per state plus emit(t, row, state). Every emitter++ * validates the payload against its released contract before it reaches the outbox, so a projection bug++ * is an error here rather than a malformed event on the bus.++ */++function createRunEvents({ outbox, now = () => Date.now(), log = console }) {++ /**++ * The outbox row for one (job, state), written in the caller's transaction. The row's key is++ * `run:<job id>:<state>`; a row that is already there is left alone (ON CONFLICT DO NOTHING), which is++ * what makes a replay one row. → { envelope, enqueued }.++ */++ async function enqueueOnce(t, row, spec, payload, traceparent) {++ const key = `run:${row.id}:${spec.state}`;++ const prepared = outbox.events.prepare({++ event_type: spec.event,++ version: 1,++ source: 'run',++ actor: ACTOR,++ timestamp: new Date(now()).toISOString(),++ subject: { type: 'job', id: row.id },++ visibility: 'internal',++ ...(spec.priority ? { priority: spec.priority } : {}),++ payload,++ }, { now: now() });++ contracts.assertValid('events.event-envelope@1', prepared);++ const trace = traceparent && TRACEPARENT_RE.test(traceparent) ? traceparent : null;++ const inserted = await t.maybe(++ `INSERT INTO ${TABLE} (event_id, envelope, traceparent, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (event_id) DO NOTHING RETURNING id`,++ [key, JSON.stringify(prepared), trace, now()]);++ if (inserted && log && log.debug) log.debug(`[Run] ${spec.event} ${row.id}`);++ return { envelope: prepared, enqueued: Boolean(inserted) };++ }++++ async function emit(t, row, state, { traceparent } = {}) {++ const spec = EVENT_OF_STATE[state];++ if (!spec) throw new Error(`run.job.*: ${state} is not a state Run emits (${Object.keys(EVENT_OF_STATE).join(', ')})`);++ const payload = payloadOf(row);++ // The payload contract is the event's own (run.job.started@1 for the running state: a state and++ // its event name are not always the same word, and run.job.running@1 does not exist).++ contracts.assertValid(spec.contract, payload);++ return enqueueOnce(t, row, spec, payload, traceparent);++ }++++ return {++ emit,++ queued: (t, row, opts) => emit(t, row, 'queued', opts),++ started: (t, row, opts) => emit(t, row, 'running', opts),++ succeeded: (t, row, opts) => emit(t, row, 'succeeded', opts),++ failed: (t, row, opts) => emit(t, row, 'failed', opts),++ cancelled: (t, row, opts) => emit(t, row, 'cancelled', opts),++ expired: (t, row, opts) => emit(t, row, 'expired', opts),++ /** The emitter for a row that just reached its state (used by the dispatcher bridge, step 6). */++ ofState: (t, row, opts) => emit(t, row, row.state, opts),++ };++}++++module.exports = { createRunEvents, payloadOf, EVENT_OF_STATE, TRACEPARENT_RE };nnew file mode 100644@@@ -0,0 +1,309 @@++'use strict';++++/**++ * run_jobs (migrations/0001_jobs.sql): Run's own record of a job — the API, the placement and the project++ * scope (plan T14 R1). Bot owns the node link and the per-second metering, and Run never holds a Billing++ * token.++ *++ * queued stored, waiting for a node++ * placed sent to a node as platform.job@1, not yet started++ * running the node sent job_started++ * succeeded / failed / cancelled / expired one end state, from job_exit / the API's cancel / the ttl++ *++ * The `job` jsonb is the job record: the platform.job@1 body Run sends to the node (id, class, args,++ * ttl_ms, limits, net, inputs) plus the fields platform.job@1 has no place for and 0001_jobs.sql has no++ * column for — `requirements` (platform.workload-requirements@1, required by run.job-read-result@1 and by++ * placement), `placed_at` (timings.placed_at) and `cancel_requested` (a cancel of a placed or running job,++ * which the dispatcher bridge turns into a job_cancel in plan T14 step 6). server/dispatch/index.js++ * assembles the frame from the platform.job@1 keys alone and validates it before anything is sent.++ *++ * The store is row access only: every state change happens inside the caller's transaction, so the++ * run.job.* event (server/jobs/events.js) exists if and only if the change committed.++ */++const contracts = require('openvibe-contracts');++const { fail, iso, readCursor, cursorOf, requirementsDefault, newJobId } = require('../util');++++// Run's own error code per platform.job-frame@1 job_exit reason (run.job-read-result@1 $defs.error).++const ERROR_OF_EXIT = {++ limit: 'run.job.limit',++ stopped: 'run.job.stopped',++ failed: 'run.job.worker_failed',++ ttl: 'run.job.ttl',++ exited: 'run.job.exit_nonzero',++ cancelled: null,++};++const END_STATES = ['succeeded', 'failed', 'cancelled', 'expired'];++const isEndState = (state) => END_STATES.includes(state);++++// The keys of platform.job@1 — the frame body of the job, and nothing Run keeps beside it.++const FRAME_KEYS = ['id', 'class', 'artifact', 'args', 'ttl_ms', 'limits', 'net', 'inputs'];++++/** The platform.job@1 body a node is sent: the job record's own platform keys (build with this, not a spread). */++function frameOf(record) {++ const out = {};++ for (const k of FRAME_KEYS) if (record && record[k] !== undefined) out[k] = record[k];++ return out;++}++++const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});++++/** Canonical JSON (keys sorted): two requests that differ only in key order are the same request. */++function canonical(v) {++ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;++ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;++ return JSON.stringify(v === undefined ? null : v);++}++++/** The comparable part of a request / a stored row: an idempotent repeat is judged on exactly this. */++function recordOf(row) {++ const job = obj(row.job);++ return {++ class: row.class,++ artifact: job.artifact ?? null,++ args: job.args ?? {},++ inputs: job.inputs ?? [],++ limits: job.limits,++ ttl_ms: Number(row.ttl_ms),++ egress: job.net ?? 'none',++ requirements: job.requirements ?? requirementsDefault(row.class),++ };++}++++/**++ * Normalize a validated run.job-create-request@1 body into the row to store: defaults filled (args {},++ * inputs [], egress none, requirements the contract's default, ttl_ms limits.wall_ms + 600000), the id++ * minted by Run (job_<ULID>; Run owns run.job.* and passes it to Bot unchanged).++ */++function normalize(body, { projectId, requester, idempotencyKey, now }) {++ const job = {++ id: newJobId(now),++ class: body.class,++ ...(body.artifact ? { artifact: body.artifact } : {}),++ args: body.args || {},++ ttl_ms: body.ttl_ms != null ? body.ttl_ms : body.limits.wall_ms + 600_000, // the contract's default++ limits: body.limits,++ net: body.egress || 'none',++ inputs: body.inputs || [],++ requirements: body.requirements || requirementsDefault(body.class),++ };++ // The platform.job@1 body the node will be sent (frameOf drops requirements — platform.job@1 has no++ // field for it), validated here once so a node can never be handed something off-contract.++ contracts.assertValid('platform.job@1', frameOf(job));++ return {++ id: job.id,++ project_id: projectId,++ requester,++ idempotency_key: idempotencyKey,++ class: job.class,++ job,++ ttl_ms: job.ttl_ms,++ state: 'queued',++ created_at: now,++ updated_at: now,++ wall_ms: 0,++ usage_read: 0,++ };++}++++/** Insert a new job. The same (project_id, idempotency_key) already there returns that row, created:false. */++async function put(db, row) {++ const inserted = await db.maybe(++ `INSERT INTO run_jobs (id, project_id, requester, idempotency_key, class, job, ttl_ms, state, created_at, updated_at, wall_ms, usage_read)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)++ ON CONFLICT (project_id, idempotency_key) DO NOTHING++ RETURNING *`,++ [row.id, row.project_id, row.requester, row.idempotency_key, row.class, JSON.stringify(row.job), row.ttl_ms, row.state, row.created_at, row.updated_at, row.wall_ms, row.usage_read]);++ if (inserted) return { row: inserted, created: true };++ const existing = await db.maybe('SELECT * FROM run_jobs WHERE project_id = $1 AND idempotency_key = $2', [row.project_id, row.idempotency_key]);++ if (!existing) throw new Error('run_jobs: the insert conflicted but no row is visible'); // only a crashed transaction does this++ return { row: existing, created: false };++}++++/**++ * Create a job from a validated run.job-create-request@1 body (plan T14 R1: the dispatcher bridge mints++ * the job id in Run, not in Bot). A repeat of the same idempotency_key with the same body returns the++ * first job (created:false); with a different body it is 409 run.idempotency.conflict.++ *++ * The unique index is permanent: a key is spent for its project for good, where run.job-create-request@1++ * promises the first-job answer "within 24 h". The 24 h window cannot be implemented on this table (no++ * migration may add a column or relax the index), and answering a repeat with the first job forever is the++ * safe direction — it can never run the work twice — so a key older than 24 h answers the same way rather++ * than being recycled. A caller that reuses a key for different work is refused, not silently served.++ */++async function mint(db, body, { projectId, requester, idempotencyKey, now }) {++ const row = normalize(body, { projectId, requester, idempotencyKey, now });++ const out = await put(db, row);++ if (out.created) return out;++ if (canonical(recordOf(out.row)) !== canonical(recordOf(row))) {++ fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was first used by ${out.row.id} with a different body`);++ }++ return out;++}++++/** Project-scoped get: null when the job does not exist or belongs to another project (→ 404, never 403). */++const get = (db, id, projectId = null) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1${projectId ? ' AND project_id = $2' : ''}`, projectId ? [id, projectId] : [id]);++++/** One page, newest first, keyset cursor (created_at, id) → { rows, nextCursor }. */++async function page(db, where, values, limit, cursor) {++ const conds = [...where];++ const params = [...values];++ if (cursor) {++ params.push(cursor.at, cursor.id);++ conds.push(`(created_at < $${params.length - 1} OR (created_at = $${params.length - 1} AND id < $${params.length}))`);++ }++ params.push(limit + 1);++ const rows = await db.many(`SELECT * FROM run_jobs${conds.length ? ` WHERE ${conds.join(' AND ')}` : ''} ORDER BY created_at DESC, id DESC LIMIT $${params.length}`, params);++ const more = rows.length > limit;++ const out = more ? rows.slice(0, limit) : rows;++ return { rows: out, nextCursor: more && out.length ? cursorOf(out[out.length - 1]) : null };++}++++/** The token project's jobs: GET /api/v1/jobs (run.job.list). */++function list(db, projectId, { state = null, limit = 50, cursor = null } = {}) {++ const where = ['project_id = $1'];++ const values = [projectId];++ if (state) { values.push(state); where.push(`state = $${values.length}`); }++ return page(db, where, values, limit, cursor ? readCursor(cursor) : null);++}++++/** Every project's jobs: GET /api/v1/admin/jobs (run.job.admin); project_id narrows it. */++function adminList(db, { project_id: projectId = null, state = null, limit = 50, cursor = null } = {}) {++ const where = [];++ const values = [];++ if (projectId) { values.push(projectId); where.push(`project_id = $${values.length}`); }++ if (state) { values.push(state); where.push(`state = $${values.length}`); }++ return page(db, where, values, limit, cursor ? readCursor(cursor) : null);++}++++/**++ * Cancel one job (POST /api/v1/jobs/:id/cancel, run.job.cancel). A queued or placed job never starts:++ * cancelled now. A running one is marked cancel_requested and stays running until its job_exit arrives++ * (the dispatcher bridge sends job_cancel in plan T14 step 6). An ended job changes nothing.++ * → { row, cancelled, cancelRequested } — cancelled means the row moved to 'cancelled' now.++ */++async function cancel(db, id, projectId, at) {++ const moved = await db.maybe(++ `UPDATE run_jobs SET state = 'cancelled', updated_at = $3, finished_ms = $3++ WHERE id = $1 AND project_id = $2 AND state IN ('queued', 'placed') RETURNING *`,++ [id, projectId, at]);++ if (moved) return { row: moved, cancelled: true, cancelRequested: false };++ const asked = await db.maybe(++ `UPDATE run_jobs SET job = jsonb_set(job, '{cancel_requested}', 'true'::jsonb), updated_at = $3++ WHERE id = $1 AND project_id = $2 AND state = 'running' AND job->>'cancel_requested' IS DISTINCT FROM 'true' RETURNING *`,++ [id, projectId, at]);++ if (asked) return { row: asked, cancelled: false, cancelRequested: true };++ return { row: await get(db, id, projectId), cancelled: false, cancelRequested: false };++}++++/**++ * Copy what Bot answers for a job (plan T14 step 6, R1c): state, placement, timings, exit, result and the++ * metered wall clock. Run keeps its own job record otherwise untouched; the first placement stamps++ * placed_at, which run.job-read-result@1 requires from `placed` on. → the row, or null.++ */++async function mirror(db, id, bot = {}, at) {++ const placed = bot.node_id ? at : null;++ // Bot's answer moves the row only into a state the row can back: run.job-read-result@1 requires a++ // placement for placed/running/succeeded and started_at for running, so an answer missing them leaves++ // the state as it is (the bridge sends the placement with the state it describes).++ return db.maybe(++ `UPDATE run_jobs SET++ state = CASE++ WHEN $2::text IS NULL THEN state++ WHEN $2::text IN ('placed', 'running', 'succeeded') AND COALESCE($3, node_id) IS NULL THEN state++ WHEN $2::text = 'running' AND COALESCE($6, started_ms) IS NULL THEN state++ ELSE $2::text END,++ node_id = COALESCE($3, node_id),++ provider = COALESCE($4, provider),++ region = COALESCE($5, region),++ started_ms = COALESCE($6, started_ms),++ finished_ms = COALESCE($7, finished_ms),++ exit_reason = COALESCE($8, exit_reason),++ exit_code = COALESCE($9, exit_code),++ result = COALESCE($10, result),++ wall_ms = GREATEST(wall_ms, COALESCE($11, 0)),++ usage_read = GREATEST(usage_read, COALESCE($12, 0)),++ job = CASE WHEN $13::bigint IS NULL OR job ? 'placed_at' THEN job++ ELSE jsonb_set(job, '{placed_at}', to_jsonb($13::bigint)) END,++ updated_at = $14++ WHERE id = $1 RETURNING *`,++ [id, bot.state ?? null, bot.node_id ?? null, bot.provider ?? null, bot.region ?? null, bot.started_ms ?? null,++ bot.finished_at ?? null, bot.exit_reason ?? null, bot.exit_code ?? null,++ bot.result === undefined || bot.result === null ? null : JSON.stringify(bot.result),++ bot.wall_ms ?? null, bot.usage_read ?? null, placed, at]);++}++++/**++ * Reach an end state (the API's cancel uses cancel(), the ttl sweeper and the dispatcher bridge use this).++ * The first settlement wins; a second is a no-op (changed:false), so the run.job.* event of a state is++ * enqueued once. `exit` is the platform.job-frame@1 job_exit reason, `code` its exit status; the error++ * code is derived from the reason unless the caller names one (run.job.unplaceable, run.job.ttl).++ * → { row, changed }++ */++async function settle(db, id, { state, exit = null, code = null, result = null, wallMs = null, errorCode = null, errorDetail = null, at }) {++ if (!isEndState(state)) fail(422, 'run.invalid_input', `settle: ${state} is not an end state`);++ // run.job-read-result@1 requires `error` on failed and expired and null everywhere else, so an error++ // code is only ever written for those two: a caller asking for one on a cancel or a success is ignored++ // rather than allowed to store a row this service could no longer render.++ const failed = state === 'failed' || state === 'expired';++ const derived = state === 'failed' ? ERROR_OF_EXIT[exit] || 'run.job.worker_failed' : state === 'expired' ? ERROR_OF_EXIT[exit] || 'run.job.unplaceable' : null;++ const row = await db.maybe(++ `UPDATE run_jobs SET state = $2, exit_reason = COALESCE($3, exit_reason), exit_code = $4, result = $5,++ wall_ms = GREATEST(wall_ms, COALESCE($6, 0)), finished_ms = $7, updated_at = $7,++ error_code = CASE WHEN $8::boolean THEN COALESCE($9, $10) ELSE NULL END,++ error_detail = CASE WHEN $8::boolean THEN $11 ELSE NULL END++ WHERE id = $1 AND state NOT IN ('succeeded', 'failed', 'cancelled', 'expired')++ RETURNING *`,++ [id, state, exit, code, result === null || result === undefined ? null : JSON.stringify(result), wallMs,++ at, failed, errorCode, derived, errorDetail]);++ if (row) return { row, changed: true };++ return { row: await get(db, id), changed: false };++}++++/**++ * One job as run.job-read-result@1 — exactly the contract's shape: placement null while queued, timings++ * from the row (and the job record's placed_at), usage.seconds the metered wall clock / 1000, result only++ * when succeeded, error only when failed or expired.++ */++function toJob(row) {++ const job = obj(row.job);++ const state = row.state;++ const wallMs = Number(row.wall_ms || 0);++ const requester = contracts.ids.parseSubject(String(row.requester));++ return {++ id: row.id,++ project_id: row.project_id,++ requester,++ state,++ class: row.class,++ artifact: job.artifact ?? null,++ inputs: job.inputs ?? [],++ limits: job.limits,++ ttl_ms: Number(row.ttl_ms),++ egress: job.net ?? 'none',++ requirements: job.requirements ?? requirementsDefault(row.class),++ idempotency_key: row.idempotency_key ?? null,++ placement: row.node_id ? { node: row.node_id, provider: row.provider ?? null, region: row.region ?? null } : null,++ timings: {++ created_at: iso(row.created_at),++ placed_at: job.placed_at != null ? iso(job.placed_at) : null,++ started_at: iso(row.started_ms),++ finished_at: iso(row.finished_ms),++ },++ exit: row.exit_reason ? { reason: row.exit_reason, code: row.exit_code == null ? null : Number(row.exit_code) } : null,++ result: state === 'succeeded' ? (row.result ?? null) : null,++ usage: { seconds: wallMs / 1000, wall_ms: wallMs, cpu_ms: null, mem_peak_bytes: null },++ error: row.error_code ? { code: row.error_code, detail: row.error_detail ?? null } : null,++ };++}++++/** The read result, validated against the contract (a store bug is a 500 here, never a bad body on the wire). */++function readResult(row) {++ const job = toJob(row);++ contracts.assertValid('run.job-read-result@1', job);++ return job;++}++++module.exports = {++ // The store surface the API and the dispatcher bridge (plan T14 step 6) use.++ put, mint, get, list, adminList, cancel, mirror, settle, toJob, readResult, frameOf,++};nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++++/**++ * The job stream's fan-out (run.job-stream-event@1; plan T14 R1). One ring buffer per open job plus the++ * subscribers on it:++ *++ * state(jobId, state) the job moved to a state; an end state is followed by one `end` event++ * output(jobId, chunk) a chunk of the job's stdout (platform.job-frame@1 job_stdout, step 6) — best++ * effort: a chunk lost on the node's link is never resent++ * seed(row) a stream opened on a job this process holds no ring for: one `state` event from++ * the row (and `end` when the job already ended), so a reconnect or a restart++ * never re-runs anything and still says where the job is++ *++ * The sequence is the job's own, from 1, monotonic, and it is the SSE `id`: a reconnect with Last-Event-ID++ * resumes after it. At most the last RUN_STREAM_RING_BYTES (1 MiB, the contract's replay window) is kept;++ * an event that fell out of the ring is simply not replayed (output is never resent anyway).++ *++ * A ring is memory, so it is reclaimed: `prune()` drops every ring that has no subscriber and either ended++ * or has been idle for IDLE_MS. Nothing is lost by dropping one — `seed(row)` rebuilds it from the job's own++ * row, so a stream opened later replays the state the job is in (and its `end`, when it ended). It also means++ * a seq is this process's (a restart or a second worker starts the job's stream counter again): a client++ * that resumes with an id from another process's ring gets the current state rather than a gap, and output++ * is best effort anyway. The dispatcher bridge (plan T14 step 6) is what would make replay durable.++ *++ * Nothing here touches the database or the network: the API and the dispatcher bridge are its only callers.++ */++const JOB_EVENT_TYPES = ['output', 'state', 'end'];++/** A ring with no subscriber and no traffic for this long is dropped (a reconnect re-seeds from the row). */++const IDLE_MS = 10 * 60 * 1000;++++function createJobStream({ config, now = () => Date.now(), log = console }) {++ const ringBytes = config.stream.ringBytes;++ const maxPerJob = config.stream.maxPerJob;++ const jobs = new Map(); // jobId → { seq, events: [], bytes, ended, state, touched, subscribers: Set }++++ function ringOf(jobId) {++ let ring = jobs.get(jobId);++ if (!ring) { ring = { seq: 0, events: [], bytes: 0, ended: false, state: null, touched: now(), subscribers: new Set() }; jobs.set(jobId, ring); }++ return ring;++ }++++ /** Drop what nothing is watching: an ended ring, or one that has said nothing for IDLE_MS. */++ function prune() {++ const t = now();++ for (const [jobId, ring] of jobs) {++ if (ring.subscribers.size) continue;++ if (ring.ended || t - ring.touched > IDLE_MS) jobs.delete(jobId);++ }++ }++++ /** Append one event to a job's ring and hand it to every subscriber. → the event, or null when the job already ended. */++ function emit(jobId, event, at = now()) {++ if (!JOB_EVENT_TYPES.includes(event.type)) throw new Error(`run.job-stream-event@1: ${event.type} is not an event type`);++ const ring = ringOf(jobId);++ if (ring.ended) return null; // an end state is final: nothing follows it++ const out = { ...event, job_id: jobId, seq: ++ring.seq, at: new Date(at).toISOString() };++ const size = JSON.stringify(out).length + 1;++ ring.events.push(out);++ ring.bytes += size;++ while (ring.bytes > ringBytes && ring.events.length > 1) ring.bytes -= JSON.stringify(ring.events.shift()).length + 1;++ if (out.type === 'state') ring.state = out.state;++ if (out.type === 'end') ring.ended = true;++ ring.touched = now();++ for (const sub of ring.subscribers) { try { sub(out); } catch (e) { log.warn(`[Run] stream subscriber failed: ${e && e.message}`); } }++ // The ring stays where it is: a subscriber may be replaying it, and a stream opened right after this++ // (a ticket minted while the job ended) must find it. prune() reclaims it once nothing watches it.++ return out;++ }++++ /** The job moved to `state`; an end state also closes the stream with `end`. → the events appended. */++ function state(jobId, state, at = now()) {++ const first = emit(jobId, { type: 'state', state }, at);++ if (!first) return [];++ if (state === 'succeeded' || state === 'failed' || state === 'cancelled' || state === 'expired') return [first, emit(jobId, { type: 'end', state }, at)].filter(Boolean);++ return [first];++ }++++ const output = (jobId, chunk, at) => emit(jobId, { type: 'output', chunk: String(chunk).slice(0, 65536) }, at);++++ /**++ * A stream opened on a job this process holds no ring for (a restart, another process created the job,++ * or the ring was pruned): one `state` event from the row, and the closing `end` when the job already++ * ended. A ring that has anything to say is left alone, so seeding never rewinds a live stream's seq.++ */++ function seed(row) {++ prune();++ const ring = ringOf(row.id);++ if (ring.events.length || ring.seq || ring.subscribers.size) return ring;++ state(row.id, row.state, Number(row.updated_at || row.created_at || now()));++ return ringOf(row.id);++ }++++ /**++ * Replay what is kept from after lastEventId, then follow. → { ended, unsubscribe }. `ended` is true++ * when the job's stream already closed: the caller writes the replay and ends the response.++ */++ function subscribe(jobId, { lastEventId = 0, onEvent }) {++ // The ring is taken before anything is pruned: the ring we are about to replay (an ended job's, kept++ // for exactly this) must not be reclaimed under us. The prune below runs with the subscriber in++ // place, so nothing that is being followed can be dropped.++ const ring = ringOf(jobId);++ ring.touched = now();++ const sub = (e) => { try { onEvent(e); } catch (err) { log.warn(`[Run] stream subscriber failed: ${err && err.message}`); } };++ for (const e of ring.events) if (e.seq > lastEventId) sub(e);++ // An ended job: the replay above was the whole answer (the caller closes the response).++ if (ring.ended) return { ended: true, unsubscribe() {} };++ ring.subscribers.add(sub);++ prune();++ return { ended: false, unsubscribe: () => { ring.subscribers.delete(sub); ring.touched = now(); } };++ }++++ const canAttach = (jobId) => ringOf(jobId).subscribers.size < maxPerJob;++ const status = () => ({ jobs: jobs.size, streams: [...jobs.values()].reduce((n, r) => n + r.subscribers.size, 0), events: [...jobs.values()].reduce((n, r) => n + r.events.length, 0) });++++ /** Shutdown: every ring goes, so a stream in flight ends and its client reconnects (with a fresh ticket). */++ function close() {++ for (const [jobId, ring] of jobs) {++ ring.ended = true;++ ring.subscribers.clear();++ jobs.delete(jobId);++ }++ }++++ return { state, output, seed, subscribe, canAttach, status, close, emit, prune, maxPerJob, ringOf };++}++++module.exports = { createJobStream, JOB_EVENT_TYPES };nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++++/**++ * The job stream ticket (run.job-stream-ticket-result@1; plan T14 R1).++ *++ * A browser cannot put a header on an EventSource, so it asks POST /api/v1/jobs/:id/stream/ticket++ * (capability run.job.stream) for a ticket and opens `${stream_url}?ticket=…` at once, resuming with++ * Last-Event-ID. The ticket is a two-minute RS256 compact JWS Run signs with a key of its own:++ *++ * iss <BASE_URL> aud openvibe.run typ run-stream purpose run-stream++ * sub <job id> project <project id> exp now + 120 s jti rsk_<24 hex>++ *++ * It is never a session anywhere: besides the signature it carries the audience openvibe.run AND the++ * typ/purpose claims, so a receiver that checks either refuses it, and Run itself refuses it on any route++ * but GET /api/v1/jobs/:id/stream. A ticket opens one stream (the jti is remembered while the ticket is++ * valid) and is never logged or stored.++ *++ * The key is Run's own, never Network's signing key: RUN_STREAM_PRIVATE_KEY (a PEM in the environment) or++ * the file RUN_STREAM_KEY_FILE (data/keys/run-stream.pem by default). In production, with neither set,++ * stream tickets answer 503 and every other route still serves — Run's jobs API never depends on it. In++ * development (and in tests) a key is generated in memory at boot.++ *++ * The used-jti set lives in this process's memory, as OpenVibe.Events' realtime tickets do: it covers the++ * ticket's own two minutes, a restart forgets it (and a ticket that old has expired), and Run runs one++ * process per host today. A second worker would need the set shared (Valkey) to keep "one ticket, one++ * stream" across processes — worth doing when Run is deployed behind more than one.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++++const JOB_RE = /^job_[0-9A-HJKMNP-TV-Z]{26}$/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const TYP = 'run-stream';++const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');++const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');++const decode = (part) => { try { return JSON.parse(fromB64url(part).toString('utf8')); } catch { return null; } };++++function createStreamTickets({ config, log = console, now = () => Date.now() }) {++ const ttlS = config.stream.ttlS;++ let privateKey = null;++ let ephemeral = false;++ // jti → exp (seconds). A ticket opens one stream; the map is pruned on every verification.++ const seen = new Map();++++ /** Read Run's stream key (never Network's). Production with no key: null, and tickets answer 503. */++ function load() {++ if (config.stream.privateKey) {++ try { privateKey = crypto.createPrivateKey(config.stream.privateKey); return privateKey; }++ catch (e) { log.error(`[Run] RUN_STREAM_PRIVATE_KEY is not a usable private key: ${e.message}`); return null; }++ }++ const file = config.stream.keyFile && path.resolve(process.cwd(), config.stream.keyFile);++ if (file && fs.existsSync(file)) {++ try { privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8')); return privateKey; }++ catch (e) { log.error(`[Run] ${file} is not a usable private key: ${e.message}`); return null; }++ }++ if (config.isProduction) return null;++ privateKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;++ ephemeral = true;++ log.warn(`[Run] no RUN_STREAM_PRIVATE_KEY or ${config.stream.keyFile}: an ephemeral stream key is in memory (development only; every ticket stops verifying at a restart)`);++ return privateKey;++ }++++ const get = () => privateKey;++ const status = () => ({ enabled: Boolean(privateKey), key_id: config.stream.keyId, ttl_s: ttlS, ephemeral });++++ /**++ * Mint a ticket for one job. → { ticket, claims } (never logged).++ */++ function mint({ jobId, projectId }) {++ if (!privateKey) throw new Error('stream tickets are disabled: no stream key (RUN_STREAM_PRIVATE_KEY or RUN_STREAM_KEY_FILE)');++ if (!JOB_RE.test(String(jobId))) throw new TypeError(`stream ticket: ${jobId} is not a job id`);++ if (!PROJECT_RE.test(String(projectId))) throw new TypeError(`stream ticket: ${projectId} is not a project id`);++ const iat = Math.floor(now() / 1000);++ const claims = {++ iss: config.baseUrl, sub: jobId, aud: config.audience, typ: TYP, purpose: TYP,++ project: projectId, job_id: jobId, iat, exp: iat + ttlS, jti: `rsk_${crypto.randomBytes(12).toString('hex')}`,++ };++ const header = { alg: 'RS256', typ: 'JWT', kid: config.stream.keyId };++ const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;++ const signature = b64url(crypto.sign('RSA-SHA256', Buffer.from(input), privateKey));++ return { ticket: `${input}.${signature}`, claims };++ }++++ function prune(t = Math.floor(now() / 1000)) {++ for (const [jti, exp] of seen) if (exp < t) seen.delete(jti);++ }++++ /**++ * Verify a ticket: signature, header, audience, typ, expiry — and, with consume (the default), that its++ * jti was never used. → { ok: true, claims } or { ok: false, code, reason } (problem codes ticket.*).++ */++ function verify(token, { consume = true } = {}) {++ const bad = (code, reason) => ({ ok: false, code, reason });++ if (!privateKey) return bad('ticket.unavailable', 'stream tickets are disabled: no stream key is configured');++ if (typeof token !== 'string' || !token.length || token.length > 4096) return bad('ticket.invalid', 'not a ticket');++ const parts = token.split('.');++ if (parts.length !== 3) return bad('ticket.invalid', 'not a ticket');++ const header = decode(parts[0]);++ const claims = decode(parts[1]);++ if (!header || !claims || header.alg !== 'RS256') return bad('ticket.invalid', 'not a ticket');++ if (config.stream.keyId && header.kid !== config.stream.keyId) return bad('ticket.invalid', 'not a ticket (key)');++ let good = false;++ try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), crypto.createPublicKey(privateKey), fromB64url(parts[2])); } catch { good = false; }++ if (!good) return bad('ticket.invalid', 'the ticket signature does not verify');++ if (claims.typ !== TYP || claims.purpose !== TYP) return bad('ticket.invalid', 'not a stream ticket');++ if (claims.aud !== config.audience) return bad('ticket.invalid', `not for ${config.audience}`);++ if (!JOB_RE.test(String(claims.sub)) || claims.job_id !== claims.sub || !PROJECT_RE.test(String(claims.project))) return bad('ticket.invalid', 'the ticket names no job');++ const t = Math.floor(now() / 1000);++ // Run mints and verifies its own tickets, so exp is a hard deadline (no receiver-side skew): a++ // ticket that is even a second past two minutes is refused. Only a clock that jumped backwards++ // gets a small allowance on iat.++ if (typeof claims.exp !== 'number' || claims.exp < t) return bad('ticket.expired', 'the ticket expired; ask for a new one');++ if (typeof claims.iat === 'number' && claims.iat - 5 > t) return bad('ticket.invalid', 'the ticket is not valid yet');++ if (typeof claims.jti !== 'string' || !claims.jti) return bad('ticket.invalid', 'the ticket has no jti');++ prune(t);++ if (seen.has(claims.jti)) return bad('ticket.used', 'this ticket already opened a stream; ask for a new one');++ if (consume) seen.set(claims.jti, claims.exp);++ return { ok: true, claims };++ }++++ /** Load the key at boot (server/index.js); a second call is a no-op. */++ function start() { if (!privateKey) load(); return status(); }++ function stop() { seen.clear(); }++++ return { start, stop, load, get, status, mint, verify, ttlS };++}++++module.exports = { createStreamTickets, TYP, JOB_RE, PROJECT_RE };nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++++/**++ * Run's side of OpenVibe.Network: the signing key every token is verified against, and offline++ * verification of a person's user token.++ *++ * Run calls no Network route: it verifies service tokens (audience openvibe.run) with the JWKS key and++ * accepts a Network user token as a Bearer exactly as Bot does. Only a token that names a project++ * (an app, agent or service principal with project_id) can act on jobs — every route says so.++ */++const crypto = require('crypto');++const { verifyUserToken } = require('openvibe-sdk/auth');++++/** Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins it, else the JWKS at boot. */++function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {++ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;++ let timer = null;++ async function load() {++ const url = `${config.network.internalUrl}/api/.well-known/jwks`;++ try {++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });++ if (!res.ok) throw new Error(`JWKS ${res.status}`);++ const body = await res.json();++ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');++ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });++ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });++ else throw new Error('JWKS contained no keys');++ return pem;++ } catch (e) {++ log.warn(`[Run] Network key not loaded from ${url}: ${e.message}`);++ return null;++ }++ }++ function start() {++ if (config.network.publicKey || timer) return;++ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });++ retry();++ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);++ timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++ return { get: () => pem, load, start, stop };++}++++/**++ * Offline verification of a Network user token (openvibe-sdk/auth: RS256, issuer, expiry, and a typed++ * token — a realtime or stream ticket — is never a session). Service principals are not user tokens.++ * Returns the claims, or null.++ */++function createUserAuth(config, keys) {++ async function verify(token) {++ if (!token) return null;++ const publicKey = keys.get();++ if (!publicKey) return null;++ try {++ const claims = await verifyUserToken(token, { publicKey, issuer: config.network.issuer });++ return claims && typeof claims === 'object' ? claims : null;++ } catch { return null; }++ }++ return { verify };++}++++module.exports = { createKeyProvider, createUserAuth };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * Truthful readiness (GET /api/ready) and the Run gauges (GET /metrics, loopback only).++ *++ * db required a real round trip through the pool (db.ready(): the store that answered)++ * valkey optional a real PING; without it nothing is shared between processes++ * network_jwks optional the Network signing key has loaded; without it no token can be verified++ * events optional OpenVibe.Events answers /api/health; without it run.job.* events wait in the outbox++ * bot optional OpenVibe.Bot answers /api/health; skipped until the dispatcher bridge exists++ *++ * Gauges: jobs queued, placed or running, ended, and the outbox backlog. Counts only, never subjects, and++ * never a job's payload; a scrape whose read failed leaves them out.++ */++const { sql } = require('openvibe-sdk/db');++const { createReadiness, skip } = require('openvibe-shared/ready');++const { TABLE: OUTBOX } = require('./events/outbox');++++const PING_TTL_MS = 15_000;++++function probe(url, fetchImpl) {++ return async () => {++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });++ try { await res.body?.cancel(); } catch { /* not needed */ }++ return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}`, detail: { http_status: res.status } };++ };++}++++function createRunReadiness({ db, valkey = null, keys, config, outbox, stream = null, dispatch = null, release = null, fetchImpl = globalThis.fetch }) {++ const events = outbox.enabled++ ? probe(`${config.events.url}/api/health`, fetchImpl)++ : () => skip('events relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset): run.job.* events wait in the outbox');++ const bot = dispatch && dispatch.enabled++ ? probe(`${config.dispatch.botUrl}/api/health`, fetchImpl)++ : () => skip('the dispatcher bridge to Bot is not built yet (plan T14 step 6): jobs stay queued');++ return createReadiness({++ service: 'run',++ release,++ checks: [++ { name: 'db', required: true, check: () => db.ready() },++ { name: 'valkey', required: false, check: () => (valkey ? valkey.ready() : skip('VALKEY_URL unset: nothing is shared between processes (one process only)')) },++ { name: 'network_jwks', required: false, check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified') },++ { name: 'events', required: false, cacheMs: outbox.enabled ? PING_TTL_MS : 0, timeoutMs: 2500, check: events },++ { name: 'bot', required: false, cacheMs: dispatch && dispatch.enabled ? PING_TTL_MS : 0, timeoutMs: 2500, check: bot },++ ],++ details: async (body) => (body.checks.db.status === 'ok'++ ? { jobs_queued: await queued(db), events_outbox: await outbox.status(), streams: stream ? stream.status().streams : null }++ : { jobs_queued: null, events_outbox: null, streams: null }),++ });++}++++async function queued(db) {++ return Number(await db.value(`SELECT count(*)::int FROM run_jobs WHERE state = 'queued'`) || 0);++}++++/** Run's gauges on the openvibe-shared/metrics registry; refresh() reads them all in one query. */++function registerRunGauges(registry, { db, stream = null, now = () => Date.now(), log = console }) {++ let snap = null;++ const read = (f) => () => (snap ? f(snap) : undefined);++ registry.gauge({ name: 'run_jobs_queued', help: 'Jobs stored and waiting for a node', collect: read((s) => s.queued) });++ registry.gauge({ name: 'run_jobs_active', help: 'Jobs placed or running on a node', collect: read((s) => s.active) });++ registry.gauge({ name: 'run_jobs_ended', help: 'Jobs in an end state', collect: read((s) => s.ended) });++ registry.gauge({ name: 'run_outbox_pending', help: 'run.job.* events waiting in the outbox', collect: read((s) => s.outbox) });++ registry.gauge({ name: 'run_streams', help: 'Open job streams on this process', collect: () => (stream ? stream.status().streams : undefined) });++ return {++ async refresh() {++ try {++ snap = await Promise.race([++ db.one(sql`SELECT++ (SELECT count(*) FROM run_jobs WHERE state = 'queued')::int AS queued,++ (SELECT count(*) FROM run_jobs WHERE state IN ('placed', 'running'))::int AS active,++ (SELECT count(*) FROM run_jobs WHERE state IN ('succeeded', 'failed', 'cancelled', 'expired'))::int AS ended,++ (SELECT count(*) FROM ${sql.ident(OUTBOX)} WHERE sent_at IS NULL AND rejected_at IS NULL)::int AS outbox`),++ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000).unref()),++ ]);++ } catch (e) {++ snap = null;++ log.warn(`[Run] gauges not read: ${e.message}`);++ }++ },++ };++}++++module.exports = { createRunReadiness, registerRunGauges };nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++++/** Small shared pieces: time, the error type, input checks, the keyset cursor. */++const { ids } = require('openvibe-contracts');++++class RunError extends Error {++ constructor(status, code, detail, extra) {++ super(detail || code);++ this.status = status;++ this.code = code;++ this.detail = detail;++ this.extra = extra;++ }++}++++function fail(status, code, detail, extra) { throw new RunError(status, code, detail, extra); }++++const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());++const isJobId = (v) => typeof v === 'string' && /^job_[0-9A-HJKMNP-TV-Z]{26}$/.test(v);++++/** A database refusal the caller caused (a value PostgreSQL cannot store) → the RunError to answer with, else null. */++function inputError(e) {++ const code = e && (e.code || (e.cause && e.cause.code));++ return ['22021', '22P05', '22P02', '22007', '22008'].includes(code)++ ? new RunError(422, 'run.invalid_input', 'the request carries a value that cannot be stored (such as a NUL character)') : null;++}++++/**++ * The keyset cursor of run_jobs (created_at, id), newest first: base64url JSON [created_at, id] → the pair,++ * or 422 run.invalid_input. `cursorOf(row)` makes one; a page's next_cursor is the last row of the page.++ */++function readCursor(cursor) {++ let cur = null;++ try { cur = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8')); } catch { /* refused below */ }++ if (!Array.isArray(cur) || !Number.isInteger(cur[0]) || typeof cur[1] !== 'string' || !isJobId(cur[1])) {++ fail(422, 'run.invalid_input', 'bad cursor');++ }++ return { at: cur[0], id: cur[1] };++}++const cursorOf = (row) => Buffer.from(JSON.stringify([Number(row.created_at), row.id])).toString('base64url');++++/**++ * The platform.workload-requirements@1 default the contract names for an absent requirements++ * (run.job-create-request@1: "absent means { kind: run.<class>, mobility: job, latency_class: background,++ * objective: balanced }"); it is validated against the contract wherever it is filled in.++ */++const requirementsDefault = (runtimeClass) => ({ kind: `run.${runtimeClass}`, mobility: 'job', latency_class: 'background', objective: 'balanced' });++++module.exports = { RunError, fail, iso, isJobId, inputError, readCursor, cursorOf, requirementsDefault, newJobId: () => ids.newId('job') };nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++/**++ * The skeleton around the job API (plan T14 step 4, R1a and step 5): liveness, truthful readiness, the++ * release manifest, the loopback-only /metrics, the JSON body limits and the dispatcher seam.++ *++ * node test/app.test.js++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++async function main() {++ const t = await boot();++ const project = contracts.ids.newId('project');++ try {++ await check('liveness: GET /api/health is ok and says what it can back', async () => {++ const r = await t.call('GET', '/api/health', { token: null });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.ok, true);++ assert.strictEqual(r.json.service, 'run');++ assert.strictEqual(r.json.version, require('../package.json').version);++ assert.strictEqual(r.json.streams, 0);++ assert.strictEqual(r.json.events.enabled, false); // EVENTS_URL unset in the test++ assert.strictEqual(r.json.dispatcher.enabled, false); // plan T14 step 6 is not built++ assert.match(r.json.dispatcher.note, /T14 step 6/);++ });++++ await check('readiness: GET /api/ready reports the database, and honestly skips what is absent', async () => {++ const r = await t.call('GET', '/api/ready', { token: null });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.ready, true, r.text);++ assert.strictEqual(r.json.status, 'ready', r.text);++ assert.strictEqual(r.json.service, 'run');++ assert.strictEqual(r.json.checks.db.status, 'ok');++ assert.strictEqual(r.json.checks.db.required, true);++ assert.deepStrictEqual(r.json.failed, []);++ assert.deepStrictEqual(r.json.skipped, ['valkey', 'events', 'bot']); // nothing is claimed that is not read++ assert.strictEqual(r.json.checks.valkey.status, 'skipped'); // VALKEY_URL unset++ assert.strictEqual(r.json.checks.events.status, 'skipped'); // the relay is off++ assert.strictEqual(r.json.checks.bot.status, 'skipped');++ assert.match(r.json.checks.bot.reason, /T14 step 6/);++ assert.strictEqual(r.json.jobs_queued, 0);++ assert.strictEqual(r.json.events_outbox.pending, 0);++ assert.strictEqual(r.json.checks.network_jwks.status, 'ok'); // the stub Network's key loaded++ });++++ await check('GET /release.json serves the manifest for this release', async () => {++ const r = await t.call('GET', '/release.json', { token: null });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.service, 'run');++ assert.strictEqual(r.json.contracts_version, require('openvibe-contracts/package.json').version);++ assert.strictEqual(r.json.packages['openvibe-sdk'], require('openvibe-sdk/package.json').version);++ assert.ok(r.json.components.server, 'the release names its server component');++ });++++ await check('GET /metrics is for a direct loopback caller, never a forwarded one', async () => {++ const direct = await t.call('GET', '/metrics', { token: null });++ assert.strictEqual(direct.status, 200, direct.text);++ assert.match(direct.headers.get('content-type') || '', /text\/plain/);++ assert.match(direct.text, /run_jobs_queued/);++ for (const header of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'forwarded']) {++ const forwarded = await t.call('GET', '/metrics', { token: null, headers: { [header]: '203.0.113.7' } });++ assert.strictEqual(forwarded.status, 404, `${header} must not reach /metrics`);++ }++ const proxied = await fetch(`${t.base}/metrics`, { headers: { 'X-Forwarded-For': '203.0.113.7' } });++ assert.strictEqual(proxied.status, 404);++ });++++ await check('a job that stays queued: the dispatcher seam hands nothing to a node', async () => {++ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), cap: ['run.job.submit'], project, sub: 'svc:builder' });++ assert.strictEqual(made.status, 201, made.text);++ assert.strictEqual(made.json.job.state, 'queued');++ assert.strictEqual(t.app.locals.dispatch.enabled, false);++ assert.deepStrictEqual(await t.app.locals.dispatch.poll(), []);++ assert.deepStrictEqual(await t.app.locals.dispatch.sweep(), []);++ assert.strictEqual((await t.app.locals.dispatch.place({ id: made.json.job.id, class: 'function' })).placed, false);++ const read = await t.call('GET', `/api/v1/jobs/${made.json.job.id}`, { cap: ['run.job.read'], project, sub: 'svc:builder' });++ assert.strictEqual(read.json.state, 'queued');++ assert.strictEqual(read.json.placement, null);++ });++++ await check('a body that is not JSON is 400 problem+json', async () => {++ const token = t.network.signService({ cap: ['run.job.submit'], project_id: project, sub: 'svc:builder' });++ const res = await fetch(`${t.base}/api/v1/jobs`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: '{not json' });++ const text = await res.text();++ assert.strictEqual(res.status, 400, text);++ assert.match(res.headers.get('content-type') || '', /application\/problem\+json/);++ const body = JSON.parse(text);++ assert.strictEqual(body.code, 'request.malformed_json');++ assert.ok(body.request_id && body.trace_id); // the problem carries the request context++ });++++ await check('an unknown API route is a problem+json 404, never a page', async () => {++ const r = await t.call('GET', '/api/v1/nope', { token: null });++ assert.strictEqual(r.status, 404, r.text);++ assert.match(r.headers.get('content-type') || '', /application\/problem\+json/);++ assert.strictEqual(r.json.code, 'not_found');++ const internal = await t.call('GET', '/internal/events', { token: null });++ assert.strictEqual(internal.status, 404);++ const underMetrics = await t.call('GET', '/metrics/nope', { token: null });++ assert.strictEqual(underMetrics.status, 404);++ });++++ await check('the skeleton sends the security headers an API should', async () => {++ const r = await t.call('GET', '/api/health', { token: null });++ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');++ assert.ok(r.headers.get('x-frame-options'));++ assert.strictEqual(r.headers.get('x-powered-by'), null);++ assert.match(r.headers.get('x-openvibe-request-id') || '', /^req_[0-9a-f]{24}$/);++ });++ } finally {++ await t.close();++ }++++ // A second boot, in production, with no stream key: the API still serves, stream tickets answer 503.++ const strict = await boot({ env: { NODE_ENV: 'production', RUN_STREAM_PRIVATE_KEY: '', RUN_STREAM_KEY_FILE: '' } });++ try {++ await check('production with no stream key: the API serves, stream tickets answer 503', async () => {++ const project2 = contracts.ids.newId('project');++ assert.strictEqual(strict.app.locals.tickets.get(), null);++ const made = await strict.call('POST', '/api/v1/jobs', { body: strict.request(), cap: ['run.job.submit'], project: project2, sub: 'svc:builder' });++ assert.strictEqual(made.status, 201, made.text);++ const ticket = await strict.call('POST', `/api/v1/jobs/${made.json.job.id}/stream/ticket`, { cap: ['run.job.stream'], project: project2, sub: 'svc:builder' });++ assert.strictEqual(ticket.status, 503, ticket.text);++ assert.strictEqual(ticket.json.code, 'run.stream.disabled');++ const read = await strict.call('GET', `/api/v1/jobs/${made.json.job.id}`, { cap: ['run.job.read'], project: project2, sub: 'svc:builder' });++ assert.strictEqual(read.status, 200, read.text);++ const health = await strict.call('GET', '/api/health', { token: null });++ assert.strictEqual(health.status, 200);++ });++ } finally {++ await strict.close();++ }++ done();++}++++main().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,232 @@++'use strict';++/**++ * Boots Run against a Network stub on a random port with a migrated database of its own (helpers/db.js:++ * PGlite, or the containers with RUN_TEST_STORE=pg). Background jobs are off: tests drive the outbox and++ * the stream explicitly. The events relay is off too (EVENTS_URL unset), so run.job.* events are read++ * from run_events_outbox where they were enqueued — the transactional path, without a network.++ *++ * t.call(method, path, { body, cap, sub, project, token, headers, key }) a service token for audience++ * openvibe.run with `cap`++ * t.sse(path, { headers }) an SSE client: .status, .events, .waitFor(pred), .waitForClose()++ * t.outboxRows(type) what the outbox holds (envelope.payload, newest last)++ * t.mintTicket(jobId, ...) open a stream as a browser would++ *++ * Every token is signed by the stub Network's own key, which the app's key provider loads (the JWKS++ * route) — the real verification path, never a bypass.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { serviceAuth, ids } = require('openvibe-contracts');++const { testDb } = require('./db');++++function listen(server) {++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));++}++++/** OpenVibe.Network's two routes Run uses: the JWKS the key provider loads, and the tokens it verifies. */++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const issuer = 'http://network.test';++ const server = http.createServer((req, res) => {++ if (req.url === '/api/.well-known/jwks') {++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'n1', ...publicKey.export({ format: 'jwk' }) }], public_key: publicPem }));++ }++ res.writeHead(404, { 'Content-Type': 'application/json' });++ return res.end('{}');++ });++ const url = await listen(server);++ return {++ url, issuer, publicPem, privatePem, close: () => new Promise((r) => server.close(r)),++ /** A Network service token (identity.service-token-claims@1). */++ signService({ sub = 'svc:live', aud = ['openvibe.run'], cap = ['run.*'], project_id, actor_type = 'service', expSec = 300, env: tokenEnv } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss: issuer, sub, actor_type, aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex'),++ ...(project_id ? { project_id } : {}), ...(tokenEnv ? { env: tokenEnv } : {}),++ }, privatePem);++ },++ /** A person's SSO token (a user token has no capability and names no project). */++ signUser({ subject = ids.newId('user'), username = 'ada', role = 'user' } = {}) {++ return jwt.sign({ sub: String(Date.parse('2026-01-01T00:00:00Z')), subject_id: subject, username, display_name: username, role }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });++ },++ };++}++++async function boot(opts = {}) {++ const network = await startNetwork();++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-test-'));++ const env = {++ NODE_ENV: 'test',++ BASE_URL: 'http://run.test',++ OV_NETWORK_URL: network.url,++ OV_NETWORK_INTERNAL_URL: network.url,++ OV_NETWORK_ISSUER: network.issuer,++ OV_OAUTH_CLIENT_ID: 'run',++ OV_OAUTH_CLIENT_SECRET: '',++ RUN_JOBS: 'off',++ RUN_STREAM_PRIVATE_KEY: crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).replace(/\n/g, '\\n'),++ ...(opts.env || {}),++ };++ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];++ const { loadConfig } = require('../../server/config');++ const { createApp } = require('../../server/app');++ const config = loadConfig(env);++ const clock = { offset: 0 };++ const logs = [];++ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++ const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });++ const app = createApp({ config, db: store.db, valkey: opts.valkey || null, now: () => Date.now() + clock.offset, log, ...(opts.appOpts || {}) });++ await app.locals.keys.load();++ app.locals.tickets.start();++ const server = await new Promise((resolve) => {++ const s = http.createServer(app);++ s.listen(0, '127.0.0.1', () => resolve(s));++ });++ const base = `http://127.0.0.1:${server.address().port}`;++ const { stream, outbox, tickets, events: runEvents, dispatch } = app.locals;++++ let n = 0;++ async function call(method, p, { body, token, cap = ['run.*'], sub = 'svc:live', project, headers = {}, key } = {}) {++ const h = { ...headers };++ if (token !== null) h.Authorization = `Bearer ${token || network.signService({ cap, sub, ...(project ? { project_id: project } : {}) })}`;++ if (body !== undefined) h['Content-Type'] = 'application/json';++ // The Idempotency-Key header is sent only when the test names one (like a caller who means it).++ if (key != null && h['Idempotency-Key'] === undefined) h['Idempotency-Key'] = key;++ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });++ const text = await res.text();++ let json = null; try { json = JSON.parse(text); } catch { /* not json */ }++ return { status: res.status, headers: res.headers, json, text };++ }++++ /** The body of a valid run.job-create-request@1 (PROJECT is not part of it: the token names the project). */++ function request(over = {}) {++ return {++ class: 'function',++ artifact: { name: 'thumbnail', version: '1.2.0' },++ args: { width: 320 },++ limits: { wall_ms: 30_000, cpu_ms: 30_000, mem_bytes: 268_435_456 },++ ...over,++ };++ }++++ /** Submit a job through the API (a service token for `project`). */++ async function submit(over = {}, opts2 = {}) {++ const r = await call('POST', '/api/v1/jobs', {++ body: request(over), project: opts2.project || ids.newId('project'), cap: opts2.cap || ['run.job.submit'], sub: opts2.sub || 'svc:builder', key: opts2.key,++ });++ if (r.status !== 201) throw new Error(`submit setup: ${r.status} ${r.text}`);++ return r.json.job;++ }++++ const SOCKET_WAIT_MS = 20_000;++++ /**++ * An SSE client (GET /jobs/:id/stream?ticket=). Non-2xx answers resolve with .status and .json and no++ * reader; a 200 carries .events and waitFor/waitForClose.++ */++ function sse(p, { headers = {}, ticket } = {}) {++ // No ticket at all (undefined) sends no query parameter: that is the case the API must refuse.++ const url = ticket === undefined ? `${base}${p}` : `${base}${p}${p.includes('?') ? '&' : '?'}ticket=${encodeURIComponent(ticket)}`;++ return fetch(url, { headers })++ .then(async (res) => {++ const client = { status: res.status, events: [], ended: false, closeCode: null, waiters: [] };++ if (!res.ok) { client.text = await res.text(); try { client.json = JSON.parse(client.text); } catch { /* not json */ } return client; }++ client.res = res;++ const push = (e) => {++ client.events.push(e);++ client.waiters = client.waiters.filter((w) => (w.pred(e) ? (w.resolve(e), false) : true));++ };++ client.waitFor = (pred, ms = SOCKET_WAIT_MS) => new Promise((ok, fail) => {++ const hit = client.events.find(pred);++ if (hit) return ok(hit);++ if (client.ended) return ok(null);++ const w = { pred, resolve: ok };++ client.waiters.push(w);++ setTimeout(() => { client.waiters = client.waiters.filter((x) => x !== w); fail(new Error(`waitFor timed out; got ${JSON.stringify(client.events)}`)); }, ms).unref();++ });++ client.waitForClose = (ms = SOCKET_WAIT_MS) => new Promise((ok) => {++ if (client.ended) return ok(true);++ const t = setTimeout(() => ok(false), ms);++ client.onEnd = () => { clearTimeout(t); ok(true); };++ });++ client.close = () => { try { if (client.reader) client.reader.cancel().catch(() => {}); } catch { /* already closed */ } };++ const reader = res.body.getReader();++ client.reader = reader;++ const decoder = new TextDecoder();++ let buf = '';++ (async () => {++ try {++ for (;;) {++ const { done, value } = await reader.read();++ if (done) break;++ buf += decoder.decode(value, { stream: true });++ let i;++ while ((i = buf.indexOf('\n\n')) >= 0) {++ const block = buf.slice(0, i); buf = buf.slice(i + 2);++ const ev = { id: null, event: null, data: null };++ for (const line of block.split('\n')) {++ if (line.startsWith('id: ')) ev.id = line.slice(4);++ else if (line.startsWith('event: ')) ev.event = line.slice(7);++ else if (line.startsWith('data: ')) { try { ev.data = JSON.parse(line.slice(6)); } catch { ev.data = line.slice(6); } }++ }++ if (ev.event) push(ev);++ }++ }++ } catch { /* the client closed us */ }++ client.ended = true;++ client.waiters.forEach((w) => w.resolve(null));++ client.waiters = [];++ if (client.onEnd) client.onEnd();++ })();++ return client;++ });++ }++++ /**++ * Mint a stream ticket through the API and open the stream with it, as a browser would. The project++ * the job belongs to is read from the row when the caller does not name it (the token names it in++ * production; a test usually has the job it just submitted).++ */++ async function openStream(jobId, { project, cap = ['run.job.stream'], headers = {} } = {}) {++ const owner = project || (await store.db.value('SELECT project_id FROM run_jobs WHERE id = $1', [jobId]));++ const t = await call('POST', `/api/v1/jobs/${jobId}/stream/ticket`, { cap, project: owner, headers });++ if (t.status !== 200) throw new Error(`ticket setup: ${t.status} ${t.text}`);++ return sse(`/api/v1/jobs/${jobId}/stream`, { ticket: t.json.ticket, headers });++ }++++ /** What the outbox holds: every row, or the rows of one event type / one job. */++ const outboxRows = async (type, jobId) => (await store.db.many('SELECT event_id, envelope FROM run_events_outbox ORDER BY id'))++ .map((r) => ({ ...r.envelope, outbox_key: r.event_id }))++ .filter((e) => (!type || e.event_type === type) && (!jobId || e.payload.job_id === jobId));++++ return {++ app, base, call, sse, openStream, submit, request, db: app.locals.db, store, config, clock, network, logs, dir, stream, outbox, tickets, runEvents, dispatch,++ outboxRows, streamState: (jobId) => stream.ringOf(jobId),++ wait: (ms) => new Promise((r) => setTimeout(r, ms)),++ close: async () => {++ stream.close();++ server.closeAllConnections();++ await new Promise((r) => server.close(r));++ await app.locals.outbox.stop();++ await network.close();++ await store.close();++ fs.rmSync(dir, { recursive: true, force: true });++ },++ };++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); }++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done, startNetwork };nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035).++ *++ * store 'pglite' (the default): real PostgreSQL in-process, migrated.++ * store 'pg': the production-shaped containers (openvibe-sdk scripts/test-services.sh up), when++ * OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL are set. The run gets its own roles and schema.++ *++ * TIPS-style: RUN_TEST_STORE=pg runs every test file on the containers instead of PGlite.++ */++const crypto = require('crypto');++const { createDb } = require('openvibe-sdk/db');++const { MIGRATIONS } = require('../../server/db');++++const quiet = { log() {}, warn() {}, error: (...a) => console.error(...a) };++const pgAvailable = () => !!(process.env.OV_TEST_PG_URL && process.env.OV_TEST_PG_DIRECT_URL);++++async function testDb({ store = process.env.RUN_TEST_STORE || 'pglite', max = 4 } = {}) {++ if (store !== 'pg') {++ const db = createDb({ pglite: true, service: 'run-test', log: quiet });++ await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });++ return { db, store: 'pglite', close: () => db.close().catch(() => {}) };++ }++ if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');++ const name = `run_t${process.pid}_${crypto.randomBytes(4).toString('hex')}`;++ const owner = `${name}_owner`;++ const pw = crypto.randomBytes(16).toString('hex');++ const su = createDb({ url: process.env.OV_TEST_PG_DIRECT_URL, service: 'run-test-admin', max: 1, log: quiet });++ const database = await su.value('SELECT current_database()');++ for (const stmt of [++ `CREATE ROLE ${owner} LOGIN PASSWORD '${pw}'`,++ `CREATE ROLE ${name} LOGIN PASSWORD '${pw}'`,++ `GRANT CONNECT ON DATABASE ${database} TO ${owner}, ${name}`,++ `CREATE SCHEMA ${name} AUTHORIZATION ${owner}`,++ `ALTER ROLE ${owner} SET search_path = ${name}`,++ `ALTER ROLE ${name} SET search_path = ${name}`,++ `ALTER ROLE ${name} SET statement_timeout = '15s'`,++ `ALTER ROLE ${name} SET lock_timeout = '5s'`,++ `GRANT USAGE ON SCHEMA ${name} TO ${name}`,++ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${name}`,++ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${name}`,++ ]) await su.query(stmt);++ const as = (url, user) => { const u = new URL(url); u.username = user; u.password = pw; return u.toString(); };++ async function drop() {++ try {++ await su.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);++ for (let i = 0; i < 100; i++) {++ const left = await su.query('SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);++ if (!Number(left.rows[0].n)) break;++ await new Promise((ok) => setTimeout(ok, 50));++ }++ const ddl = async (stmt) => {++ for (let i = 0; ; i++) {++ try { return await su.query(stmt); } catch (err) { if (err.code !== 'XX000' || i >= 5) throw err; await new Promise((ok) => setTimeout(ok, 100 * (i + 1))); }++ }++ };++ await ddl(`DROP SCHEMA IF EXISTS ${name} CASCADE`);++ for (const r of [name, owner]) { await ddl(`DROP OWNED BY ${r}`); await ddl(`DROP ROLE ${r}`); }++ } finally { await su.close(); }++ }++ let db;++ try {++ const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'run-test-migrate', max: 1, log: quiet });++ try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 }); } finally { await ownerDb.close(); }++ db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'run-test', max, log: quiet });++ } catch (e) { await drop().catch(() => {}); throw e; }++ return {++ db, store: 'postgresql', schema: name,++ open: (o = {}) => createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'run-test', max, log: quiet, ...o }),++ async close() { await db.close().catch(() => {}); await drop(); },++ };++}++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,473 @@++'use strict';++/**++ * The Run job API (plan T14 step 5, R1b) against the released contracts: one capability per route, the++ * project and the requester from the token, idempotent submit, project-scoped 404s, keyset paging,++ * cancel, the run.job.* events in the submitting transaction, the stream ticket and the SSE stream.++ *++ * node test/jobs.test.js++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++const PROBLEM = /application\/problem\+json/;++const decodeClaims = (token) => JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));++/** An outbox row as the published envelope (the helper attaches the row's own key beside it). */++const envelopeOf = (row) => { const { outbox_key, ...envelope } = row; return envelope; };++++async function checks(t) {++ const projectA = contracts.ids.newId('project');++ const projectB = contracts.ids.newId('project');++ const cap = (list, project, sub) => ({ cap: list, project, sub });++++ await check('a valid run.job-create-request@1 → 201 created, queued, run.job.queued in the outbox', async () => {++ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.created, true);++ assert.ok(contracts.validate('run.job-create-result@1', r.json).valid, JSON.stringify(r.json));++ const job = r.json.job;++ assert.match(job.id, /^job_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(job.state, 'queued');++ assert.strictEqual(job.project_id, projectA);++ assert.deepStrictEqual(job.requester, { type: 'service', id: 'builder' });++ assert.strictEqual(job.ttl_ms, 30_000 + 600_000); // limits.wall_ms + 600000++ assert.strictEqual(job.egress, 'none');++ assert.deepStrictEqual(job.requirements, { kind: 'run.function', mobility: 'job', latency_class: 'background', objective: 'balanced' });++ assert.strictEqual(job.placement, null);++ assert.match(job.idempotency_key, /^auto:[0-9a-z]+$/); // no key given: Run's own++ const queued = await t.outboxRows('run.job.queued', job.id);++ assert.strictEqual(queued.length, 1);++ // The outbox row's key is Run's own (the dossier's run:<job id>:<state>); the published envelope++ // carries a contract-valid evt_ document id (events.event-envelope@1 pins the pattern).++ assert.strictEqual(queued[0].outbox_key, `run:${job.id}:queued`);++ assert.match(queued[0].event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.deepStrictEqual(queued[0].subject, { type: 'job', id: job.id });++ assert.deepStrictEqual(queued[0].actor, { type: 'service', id: 'run' });++ assert.strictEqual(queued[0].visibility, 'internal');++ assert.ok(contracts.validate('run.job.queued@1', queued[0].payload).valid);++ assert.ok(contracts.validate('events.event-envelope@1', envelopeOf(queued[0])).valid);++ });++++ await check('the same Idempotency-Key answers the first job (200, created false) with one row', async () => {++ const body = t.request();++ const first = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'thumb:photo-7' });++ assert.strictEqual(first.status, 201, first.text);++ const again = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'thumb:photo-7' });++ assert.strictEqual(again.status, 200, again.text);++ assert.strictEqual(again.json.created, false);++ assert.strictEqual(again.json.job.id, first.json.job.id);++ const rows = await t.db.many('SELECT * FROM run_jobs WHERE project_id = $1 AND idempotency_key = $2', [projectA, 'thumb:photo-7']);++ assert.strictEqual(rows.length, 1);++ assert.strictEqual(rows[0].id, first.json.job.id);++ assert.strictEqual((await t.outboxRows('run.job.queued', first.json.job.id)).length, 1); // never a second queued event++ });++++ await check('body.idempotency_key works too, and the header disagreeing with it is 422', async () => {++ const body = { ...t.request(), idempotency_key: 'run:one' };++ const first = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'run:one' });++ assert.strictEqual(first.status, 201, first.text);++ assert.strictEqual(first.json.job.idempotency_key, 'run:one');++ const again = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'run:one' });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json.job.id, first.json.job.id);++ const clash = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'run:two' });++ assert.strictEqual(clash.status, 422, clash.text);++ assert.strictEqual(clash.json.code, 'run.invalid_input');++ });++++ await check('the same key with a different body is 409 run.idempotency.conflict', async () => {++ await t.call('POST', '/api/v1/jobs', { body: t.request({ args: { width: 64 } }), ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'conflict:1' });++ const r = await t.call('POST', '/api/v1/jobs', { body: t.request({ args: { width: 128 } }), ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'conflict:1' });++ assert.strictEqual(r.status, 409, r.text);++ assert.strictEqual(r.json.code, 'run.idempotency.conflict');++ assert.match(r.headers.get('content-type') || '', PROBLEM);++ });++++ await check('the idempotency key is scoped per project: another project may reuse it', async () => {++ const body = t.request();++ const mine = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'shared:key' });++ const theirs = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectB, 'svc:other'), key: 'shared:key' });++ assert.strictEqual(mine.status, 201, mine.text);++ assert.strictEqual(theirs.status, 201, theirs.text);++ assert.notStrictEqual(mine.json.job.id, theirs.json.job.id);++ assert.strictEqual(theirs.json.job.project_id, projectB);++ });++++ await check('a second project never sees the row: 404, never 403', async () => {++ const job = t.request();++ const mine = await t.call('POST', '/api/v1/jobs', { body: job, ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const theirs = await t.call('GET', `/api/v1/jobs/${mine.json.job.id}`, cap(['run.job.read'], projectB, 'svc:other'));++ assert.strictEqual(theirs.status, 404, theirs.text);++ assert.strictEqual(theirs.json.code, 'run.job_not_found');++ const cancelTheirs = await t.call('POST', `/api/v1/jobs/${mine.json.job.id}/cancel`, cap(['run.job.cancel'], projectB, 'svc:other'));++ assert.strictEqual(cancelTheirs.status, 404);++ const ticketTheirs = await t.call('POST', `/api/v1/jobs/${mine.json.job.id}/stream/ticket`, cap(['run.job.stream'], projectB, 'svc:other'));++ assert.strictEqual(ticketTheirs.status, 404);++ const mineRead = await t.call('GET', `/api/v1/jobs/${mine.json.job.id}`, cap(['run.job.read'], projectA, 'svc:builder'));++ assert.strictEqual(mineRead.status, 200, mineRead.text);++ assert.ok(contracts.validate('run.job-read-result@1', mineRead.json).valid);++ });++++ await check('the project and the requester come from the token, never the body', async () => {++ const body = { ...t.request(), project_id: projectB, requester: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };++ const r = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(r.status, 400, r.text); // additionalProperties: false++ assert.strictEqual(r.json.code, 'run.invalid_request');++ assert.match(r.headers.get('content-type') || '', PROBLEM);++ const other = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectB, 'svc:builder') });++ assert.strictEqual(other.json.job.project_id, projectB);++ assert.deepStrictEqual(other.json.job.requester, { type: 'service', id: 'builder' });++ });++++ await check('a body that fails run.job-create-request@1 is 400 problem+json with the contract errors', async () => {++ const r = await t.call('POST', '/api/v1/jobs', { body: { class: 'function' }, ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(r.status, 400, r.text);++ assert.match(r.headers.get('content-type') || '', PROBLEM);++ assert.strictEqual(r.json.code, 'run.invalid_request');++ assert.ok(Array.isArray(r.json.errors) && r.json.errors.length, JSON.stringify(r.json));++ const bad = await t.call('POST', '/api/v1/jobs', { body: t.request({ egress: 'everywhere' }), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(bad.status, 400);++ const unbalanced = await t.call('POST', '/api/v1/jobs', { body: t.request({ inputs: [{ name: 'a.txt', media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPS', sha256: 'a'.repeat(64) }, { name: 'a.txt', media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPS', sha256: 'b'.repeat(64) }] }), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(unbalanced.status, 422, unbalanced.text); // duplicate input name++ assert.strictEqual(unbalanced.json.code, 'run.invalid_input');++ });++++ await check('limits are bounded by the project caps, never silently lowered', async () => {++ const over = await t.call('POST', '/api/v1/jobs', { body: t.request({ limits: { wall_ms: 10_000_000, cpu_ms: 30_000, mem_bytes: 1 } }), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(over.status, 422, over.text);++ assert.strictEqual(over.json.code, 'run.limits.exceeded');++ const mem = await t.call('POST', '/api/v1/jobs', { body: t.request({ limits: { wall_ms: 1000, cpu_ms: 1000, mem_bytes: 8 * 1024 ** 3 } }), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(mem.status, 422);++ // The default lifetime (limits.wall_ms + 600000) is capped too: a project whose cap is lower refuses++ // the request rather than storing a job that outlives it.++ const kept = t.config.limits.maxTtlMs;++ t.config.limits.maxTtlMs = 600_000;++ try {++ const byDefault = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(byDefault.status, 422, byDefault.text);++ assert.strictEqual(byDefault.json.code, 'run.limits.exceeded');++ assert.match(byDefault.json.detail, /limits\.wall_ms \+ 600000/);++ } finally { t.config.limits.maxTtlMs = kept; }++ const args = await t.call('POST', '/api/v1/jobs', { body: t.request({ args: { blob: 'x'.repeat(520 * 1024) } }), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(args.status, 413, args.text);++ assert.strictEqual(args.json.code, 'run.args_too_large');++ const ttl = await t.call('POST', '/api/v1/jobs', { body: t.request({ ttl_ms: 86_400_000 * 2 }), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ assert.strictEqual(ttl.status, 400); // the contract's own ceiling++ });++++ await check('a token without the route capability is 403, a missing token 401, a user token 403', async () => {++ const wrong = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.list'], projectA, 'svc:builder') });++ assert.strictEqual(wrong.status, 403, wrong.text);++ assert.strictEqual(wrong.json.code, 'capability.denied');++ const listWithRead = await t.call('GET', '/api/v1/jobs', cap(['run.job.read'], projectA, 'svc:builder'));++ assert.strictEqual(listWithRead.status, 403);++ const anonymous = await t.call('GET', '/api/v1/jobs', { token: null });++ assert.strictEqual(anonymous.status, 401, anonymous.text);++ assert.strictEqual(anonymous.json.code, 'token.required');++ const user = await t.call('GET', '/api/v1/jobs', { token: t.network.signUser({}) });++ assert.strictEqual(user.status, 403, user.text);++ assert.strictEqual(user.json.code, 'capability.denied');++ const sandbox = await t.call('GET', '/api/v1/jobs', { token: t.network.signService({ cap: ['run.*'], project_id: projectA, actor_type: 'app', sub: `app:${contracts.ids.newId('app')}`, env: 'sandbox' }) });++ assert.strictEqual(sandbox.status, 401, sandbox.text);++ assert.strictEqual(sandbox.json.code, 'token.sandbox_refused');++ });++++ await check('a service token with no project_id is refused 403 run.project_required', async () => {++ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), cap: ['run.job.submit'], sub: 'svc:run' });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'run.project_required');++ });++++ await check('list is newest first with a keyset cursor, null on the last page', async () => {++ const project = contracts.ids.newId('project');++ const made = [];++ for (let i = 0; i < 3; i++) {++ const r = await t.call('POST', '/api/v1/jobs', { body: t.request({ args: { i } }), ...cap(['run.job.submit'], project, 'svc:builder') });++ assert.strictEqual(r.status, 201, r.text);++ made.push(r.json.job.id);++ }++ const page1 = await t.call('GET', '/api/v1/jobs?limit=2', cap(['run.job.list'], project, 'svc:builder'));++ assert.strictEqual(page1.status, 200, page1.text);++ assert.ok(contracts.validate('run.job-list-result@1', page1.json).valid, page1.text);++ assert.strictEqual(page1.json.jobs.length, 2);++ assert.ok(page1.json.next_cursor);++ const page2 = await t.call('GET', `/api/v1/jobs?limit=2&cursor=${encodeURIComponent(page1.json.next_cursor)}`, cap(['run.job.list'], project, 'svc:builder'));++ assert.strictEqual(page2.json.jobs.length, 1);++ assert.strictEqual(page2.json.next_cursor, null);++ const seen = [...page1.json.jobs, ...page2.json.jobs].map((j) => j.id);++ assert.deepStrictEqual(seen.slice().sort(), made.slice().sort());++ assert.strictEqual(new Set(seen).size, 3);++ const filtered = await t.call('GET', '/api/v1/jobs?state=queued', cap(['run.job.list'], project, 'svc:builder'));++ assert.strictEqual(filtered.json.jobs.length, 3);++ const none = await t.call('GET', '/api/v1/jobs?state=cancelled', cap(['run.job.list'], project, 'svc:builder'));++ assert.deepStrictEqual(none.json.jobs, []);++ assert.strictEqual(none.json.next_cursor, null);++ const bad = await t.call('GET', '/api/v1/jobs?state=gone', cap(['run.job.list'], project, 'svc:builder'));++ assert.strictEqual(bad.status, 400, bad.text);++ const badLimit = await t.call('GET', '/api/v1/jobs?limit=500', cap(['run.job.list'], project, 'svc:builder'));++ assert.strictEqual(badLimit.status, 400);++ const otherProject = await t.call('GET', '/api/v1/jobs', cap(['run.job.list'], project, 'svc:builder'));++ assert.strictEqual(otherProject.json.jobs.length, 3); // only this project's++ const snoop = await t.call('GET', `/api/v1/jobs?project_id=${projectB}`, cap(['run.job.list'], project, 'svc:builder'));++ assert.strictEqual(snoop.status, 403, snoop.text); // project_id is run.job.admin only++ assert.strictEqual(snoop.json.code, 'capability.denied');++ });++++ await check('cancel of a queued job → cancelled + run.job.cancelled, and a repeat is one event', async () => {++ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const id = made.json.job.id;++ const first = await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));++ assert.strictEqual(first.status, 200, first.text);++ assert.ok(contracts.validate('run.job-read-result@1', first.json).valid, first.text);++ assert.strictEqual(first.json.state, 'cancelled');++ assert.strictEqual(first.json.finished_at, undefined);++ assert.ok(first.json.timings.finished_at);++ assert.strictEqual(first.json.exit, null); // never placed, never ran++ assert.strictEqual(first.json.placement, null);++ assert.deepStrictEqual(first.json.usage, { seconds: 0, wall_ms: 0, cpu_ms: null, mem_peak_bytes: null });++ assert.strictEqual(first.json.error, null);++ const events = await t.outboxRows('run.job.cancelled', id);++ assert.strictEqual(events.length, 1);++ assert.strictEqual(events[0].outbox_key, `run:${id}:cancelled`);++ assert.ok(contracts.validate('run.job.cancelled@1', events[0].payload).valid);++ const again = await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json.state, 'cancelled');++ assert.strictEqual((await t.outboxRows('run.job.cancelled', id)).length, 1); // a replay is one row++ const unknown = await t.call('POST', `/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));++ assert.strictEqual(unknown.status, 404);++ const notAnId = await t.call('POST', '/api/v1/jobs/nope/cancel', cap(['run.job.cancel'], projectA, 'svc:builder'));++ assert.strictEqual(notAnId.status, 404);++ });++++ await check('the stream ticket is a two-minute run-stream JWS for this one job', async () => {++ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const id = made.json.job.id;++ const r = await t.call('POST', `/api/v1/jobs/${id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'));++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(contracts.validate('run.job-stream-ticket-result@1', r.json).valid, r.text);++ assert.strictEqual(r.json.expires_in, 120);++ assert.strictEqual(r.json.job_id, id);++ assert.match(r.json.stream_url, new RegExp(`/api/v1/jobs/${id}/stream$`));++ const claims = decodeClaims(r.json.ticket);++ assert.strictEqual(claims.exp - claims.iat, 120);++ assert.strictEqual(claims.aud, 'openvibe.run');++ assert.strictEqual(claims.typ, 'run-stream');++ assert.strictEqual(claims.sub, id);++ assert.strictEqual(claims.project, projectA);++ assert.match(claims.jti, /^rsk_[0-9a-f]{24}$/);++ assert.strictEqual(new Date(r.json.expires_at).getTime(), claims.exp * 1000);++ const other = await t.call('POST', '/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/stream/ticket', cap(['run.job.stream'], projectA, 'svc:builder'));++ assert.strictEqual(other.status, 404);++ });++++ await check('GET /jobs/:id/stream without a valid, unexpired ticket for that job is refused', async () => {++ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const id = made.json.job.id;++ const none = await t.sse(`/api/v1/jobs/${id}/stream`);++ assert.strictEqual(none.status, 401, JSON.stringify(none));++ assert.strictEqual(none.json.code, 'ticket.required');++ const junk = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: 'not.a.ticket' });++ assert.strictEqual(junk.status, 401);++ assert.strictEqual(junk.json.code, 'ticket.invalid');++ // A service token is not a ticket, even a valid one that holds run.job.stream.++ const token = t.network.signService({ cap: ['run.job.stream'], project_id: projectA, sub: 'svc:builder' });++ const asToken = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: token });++ assert.strictEqual(asToken.status, 401, asToken.text);++ assert.strictEqual(asToken.json.code, 'ticket.invalid');++ // Another job's ticket is refused for this job.++ const otherJob = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const otherTicket = await t.call('POST', `/api/v1/jobs/${otherJob.json.job.id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'));++ const mismatched = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: otherTicket.json.ticket });++ assert.strictEqual(mismatched.status, 403, mismatched.text);++ assert.strictEqual(mismatched.json.code, 'run.stream.ticket_mismatch');++ // An expired ticket is refused (the ticket lives 120 s).++ const fresh = await t.call('POST', `/api/v1/jobs/${id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'));++ t.clock.offset = 121_000;++ try {++ const expired = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: fresh.json.ticket });++ assert.strictEqual(expired.status, 401, expired.text);++ assert.strictEqual(expired.json.code, 'ticket.expired');++ } finally { t.clock.offset = 0; }++ // A ticket opens exactly one stream: the second use finds the jti consumed.++ const used = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: fresh.json.ticket });++ assert.strictEqual(used.status, 200, used.text);++ used.close();++ const replayed = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: fresh.json.ticket });++ assert.strictEqual(replayed.status, 401, replayed.text);++ assert.strictEqual(replayed.json.code, 'ticket.used');++ });++++ await check('a streamed output event matches run.job-stream-event@1 with a monotonic seq', async () => {++ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const id = made.json.job.id;++ const s = await t.openStream(id, { project: projectA });++ assert.strictEqual(s.status, 200, s.text);++ const state = await s.waitFor((e) => e.event === 'state');++ assert.strictEqual(state.data.state, 'queued');++ assert.strictEqual(state.data.job_id, id);++ assert.strictEqual(state.id, String(state.data.seq));++ assert.ok(contracts.validate('run.job-stream-event@1', state.data).valid, JSON.stringify(state.data));++ // The dispatcher bridge feeds the job's stdout (plan T14 step 6); the stream just carries it.++ t.stream.output(id, 'resized 1 of 1\n');++ t.stream.output(id, 'done\n');++ const out1 = await s.waitFor((e) => e.event === 'output' && e.data.chunk === 'resized 1 of 1\n');++ const out2 = await s.waitFor((e) => e.event === 'output' && e.data.chunk === 'done\n');++ assert.ok(contracts.validate('run.job-stream-event@1', out1.data).valid, JSON.stringify(out1.data));++ assert.strictEqual(out1.data.seq + 1, out2.data.seq);++ assert.strictEqual(out1.id, String(out1.data.seq));++ await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));++ const end = await s.waitFor((e) => e.event === 'end');++ assert.strictEqual(end.data.state, 'cancelled');++ assert.ok(contracts.validate('run.job-stream-event@1', end.data).valid, JSON.stringify(end.data));++ assert.ok(end.data.seq > out2.data.seq);++ assert.strictEqual(await s.waitForClose(5000), true);++ });++++ await check('a reconnect replays what is kept and resumes after Last-Event-ID', async () => {++ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const id = made.json.job.id;++ const first = await t.openStream(id, { project: projectA });++ const seen = await first.waitFor((e) => e.event === 'state');++ t.stream.output(id, 'first chunk\n');++ const chunk = await first.waitFor((e) => e.event === 'output');++ first.close();++ const second = await t.openStream(id, { project: projectA, headers: { 'Last-Event-ID': String(chunk.data.seq) } });++ assert.strictEqual(second.status, 200, second.text);++ await t.wait(100);++ assert.deepStrictEqual(second.events.filter((e) => e.data && e.data.seq <= chunk.data.seq), [], 'nothing already seen is replayed');++ t.stream.output(id, 'after the reconnect\n');++ const next = await second.waitFor((e) => e.event === 'output');++ assert.strictEqual(next.data.chunk, 'after the reconnect\n');++ assert.ok(next.data.seq > chunk.data.seq);++ await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));++ const end = await second.waitFor((e) => e.event === 'end');++ assert.ok(end.data.seq > next.data.seq);++ assert.strictEqual(await second.waitForClose(5000), true);++ });++++ await check('a stream opened on an ended job replays what is kept and closes', async () => {++ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const id = made.json.job.id;++ await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));++ // The reconnect a browser makes after a drop, with the id it last saw: everything kept is replayed++ // and the server ends the response after `end` (a ring that ended and lost its subscriber must not++ // leave the next caller with an empty, never-closing stream).++ const opened = await t.openStream(id, { project: projectA });++ assert.strictEqual(opened.status, 200, opened.text);++ const end = await opened.waitFor((e) => e.event === 'end');++ assert.strictEqual(end.data.state, 'cancelled');++ assert.strictEqual(await opened.waitForClose(5000), true);++ const resumed = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: (await t.call('POST', `/api/v1/jobs/${id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'))).json.ticket, headers: { 'Last-Event-ID': String(end.data.seq) } });++ assert.strictEqual(resumed.status, 200, resumed.text);++ assert.deepStrictEqual(resumed.events.filter((e) => e.data && e.data.seq > end.data.seq), []);++ assert.strictEqual(await resumed.waitForClose(5000), true);++ // A resume that missed the end still gets it, with the state it missed.++ const behind = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: (await t.call('POST', `/api/v1/jobs/${id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'))).json.ticket, headers: { 'Last-Event-ID': '1' } });++ const late = await behind.waitFor((e) => e.event === 'end');++ assert.strictEqual(late.data.state, 'cancelled');++ assert.strictEqual(await behind.waitForClose(5000), true);++ });++++ await check('run.job.admin reads another project\'s job and lists every project', async () => {++ const mine = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const admin = cap(['run.job.read', 'run.job.admin']);++ const read = await t.call('GET', `/api/v1/jobs/${mine.json.job.id}`, admin);++ assert.strictEqual(read.status, 200, read.text);++ assert.strictEqual(read.json.project_id, projectA);++ const listed = await t.call('GET', `/api/v1/admin/jobs?project_id=${projectA}`, cap(['run.job.admin']));++ assert.strictEqual(listed.status, 200, listed.text);++ assert.ok(contracts.validate('run.job-list-result@1', listed.json).valid);++ assert.ok(listed.json.jobs.every((j) => j.project_id === projectA));++ const all = await t.call('GET', '/api/v1/admin/jobs?limit=200', cap(['run.job.admin']));++ assert.ok(all.json.jobs.length >= listed.json.jobs.length);++ const notAdmin = await t.call('GET', '/api/v1/admin/jobs', cap(['run.job.list'], projectA, 'svc:builder'));++ assert.strictEqual(notAdmin.status, 403, notAdmin.text);++ assert.strictEqual(notAdmin.json.code, 'capability.denied');++ // run.job.admin is internal: the manifest says it is never a delegated app or mod token, so an app++ // principal holding the grant is refused here too.++ const appToken = t.network.signService({ sub: `app:${contracts.ids.newId('app')}`, actor_type: 'app', env: 'production', cap: ['run.job.admin'], project_id: projectA });++ const viaApp = await t.call('GET', '/api/v1/admin/jobs', { token: appToken });++ assert.strictEqual(viaApp.status, 403, viaApp.text);++ assert.strictEqual(viaApp.json.code, 'capability.denied');++ assert.match(viaApp.json.detail, /internal/);++ // and an admin cancel reaches another project's job++ const cancelled = await t.call('POST', `/api/v1/jobs/${mine.json.job.id}/cancel`, cap(['run.job.cancel', 'run.job.admin']));++ assert.strictEqual(cancelled.status, 200, cancelled.text);++ assert.strictEqual(cancelled.json.state, 'cancelled');++ });++++ await check('a running job (mirrored from Bot) is settled with its event and its error code', async () => {++ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const id = made.json.job.id;++ // What the dispatcher bridge does in plan T14 step 6: mirror Bot's answer, then settle the end state.++ const store = require('../server/jobs/store');++ const at = Date.now();++ const running = await store.mirror(t.db, id, { state: 'running', node_id: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', provider: null, region: 'eu-west', started_ms: at, exit_reason: null, exit_code: null, result: null, wall_ms: 0, usage_read: 0 }, at);++ assert.strictEqual(running.state, 'running');++ const read = await t.call('GET', `/api/v1/jobs/${id}`, cap(['run.job.read'], projectA, 'svc:builder'));++ assert.strictEqual(read.status, 200, read.text);++ assert.ok(contracts.validate('run.job-read-result@1', read.json).valid, read.text);++ assert.deepStrictEqual(read.json.placement, { node: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', provider: null, region: 'eu-west' });++ assert.ok(read.json.timings.placed_at && read.json.timings.started_at);++ // run.job.started carries state `running`: the event's payload contract is run.job.started@1, not a++ // run.job.running@1 (which does not exist) — the emitter and the row key follow that.++ const started = await t.db.tx((tx) => t.runEvents.started(tx, running));++ const startedRow = (await t.outboxRows('run.job.started', id))[0];++ assert.strictEqual(started.enqueued, true);++ assert.strictEqual(startedRow.outbox_key, `run:${id}:running`);++ assert.strictEqual(startedRow.event_type, 'run.job.started');++ assert.strictEqual(startedRow.payload.state, 'running');++ assert.ok(contracts.validate('run.job.started@1', startedRow.payload).valid, JSON.stringify(startedRow.payload));++ const settled = await t.db.tx((tx) => store.settle(tx, id, { state: 'failed', exit: 'limit', code: null, wallMs: 2350, errorDetail: 'mem_bytes exceeded', at: at + 2350 }));++ assert.strictEqual(settled.changed, true);++ const after = await t.call('GET', `/api/v1/jobs/${id}`, cap(['run.job.read'], projectA, 'svc:builder'));++ assert.strictEqual(after.json.state, 'failed');++ assert.deepStrictEqual(after.json.exit, { reason: 'limit', code: null });++ assert.deepStrictEqual(after.json.error, { code: 'run.job.limit', detail: 'mem_bytes exceeded' });++ assert.strictEqual(after.json.usage.wall_ms, 2350);++ assert.strictEqual(after.json.usage.seconds, 2.35);++ const again = await t.db.tx((tx) => store.settle(tx, id, { state: 'failed', exit: 'limit', at: at + 3000 }));++ assert.strictEqual(again.changed, false); // an end state never changes++ assert.strictEqual(after.json.result, null);++ // The failed event is the run.job-read-result@1 projection, keyed run:<id>:failed, and a replay of++ // the same state is the row that is already there.++ const first = await t.db.tx((tx) => t.runEvents.failed(tx, settled.row));++ const row = (await t.outboxRows('run.job.failed', id))[0];++ assert.strictEqual(row.outbox_key, `run:${id}:failed`);++ assert.strictEqual(row.payload.state, 'failed');++ assert.deepStrictEqual(row.payload.error, { code: 'run.job.limit', detail: 'mem_bytes exceeded' });++ assert.ok(contracts.validate('run.job.failed@1', row.payload).valid);++ assert.strictEqual(first.enqueued, true);++ assert.strictEqual((await t.db.tx((tx) => t.runEvents.failed(tx, settled.row))).enqueued, false);++ assert.strictEqual((await t.outboxRows('run.job.failed', id)).length, 1);++ // An error code on a state whose contract requires `error: null` is never stored: a row this service++ // could not render must not be reachable.++ const other = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });++ const odd = await t.db.tx((tx) => store.settle(tx, other.json.job.id, { state: 'cancelled', exit: 'cancelled', code: null, errorCode: 'run.job.stopped', errorDetail: 'nope', at: at + 10 }));++ assert.strictEqual(odd.changed, true);++ assert.strictEqual(odd.row.error_code, null);++ assert.strictEqual(odd.row.error_detail, null);++ const readable = await t.call('GET', `/api/v1/jobs/${other.json.job.id}`, cap(['run.job.read'], projectA, 'svc:builder'));++ assert.strictEqual(readable.status, 200, readable.text);++ assert.strictEqual(readable.json.error, null);++ });++}++++async function main() {++ const t = await boot();++ try {++ await checks(t);++ await check('no secret and no job output is ever logged', async () => {++ const secretish = 'Bearer-not-a-token-should-never-be-logged';++ await t.call('POST', '/api/v1/jobs', { body: t.request(), token: secretish });++ assert.ok(!t.logs.some((l) => l.includes(secretish)), t.logs.join('\n'));++ assert.ok(!t.logs.some((l) => /resized 1 of 1/.test(l)), t.logs.join('\n'));++ });++ } finally {++ await t.close();++ }++ done();++}++++main().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,14 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if any of++ * them fails. They use an in-process stub of OpenVibe.Network, temp databases and random ports; none++ * needs the network, OpenVibe.Bot or a running service.++ *++ * npm test # everything (PGlite)++ * npm run test:pg # everything on the containers (RUN_TEST_STORE=pg)++ * npm test -- jobs app # only files whose name contains one of the words++ */++'use strict';++// A file's wall timeout is generous: the test host is shared with other jobs, so a healthy file can take++// several times its usual time. Files still run one at a time (parallel: 1) to keep the host's load down.++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 180000, pad: 28, parallel: 1 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+# ═══════════════════════════════════════════════════════════════
+# openvibe.run — Environment Configuration
+# Copy to .env for development. In production this lives at /etc/openvibe/run.env (0600).
+# Names only here; never commit a value.
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4920
+HOST=127.0.0.1
+NODE_ENV=development
+BASE_URL=http://localhost:4920
+TRUST_PROXY=1
+
+# ── Data: PostgreSQL + Valkey (ADR-035) ──────────────────────
+# In production OpenVibe.Host writes all four (roles/data/add-service.sh run). Unset in development:
+# an embedded PGlite database in data/pglite (one process). Tests need neither.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=ov:run:
+
+# ── Identity: OpenVibe.Network ──────────────────────────────
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+#OV_NETWORK_ISSUER=https://openvibe.network
+# Optional: pin the Network's RS256 public key (PEM, \n escaped) instead of fetching the JWKS.
+#OV_NETWORK_PUBLIC_KEY=
+# Audience Run accepts on incoming service tokens; a token must also name a project (project_id).
+RUN_AUDIENCE=openvibe.run
+OV_OAUTH_CLIENT_ID=run
+OV_OAUTH_CLIENT_SECRET=
+#COOKIE_SECURE=true
+
+# ── OpenVibe.Events ─────────────────────────────────────────
+# Relay of Run's outbox (run.job.*); off when unset (rows wait in run_events_outbox).
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=2000
+
+# ── Jobs ────────────────────────────────────────────────────
+#RUN_JOBS=on
+#RUN_JOBS_INTERVAL_MS=5000
+
+# ── Project caps on a job's limits (run.job-create-request@1); over one is 422 run.limits.exceeded ──
+#RUN_LIMITS_MAX_WALL_MS=3600000
+#RUN_LIMITS_MAX_CPU_MS=3600000
+#RUN_LIMITS_MAX_MEM_BYTES=4gb
+#RUN_LIMITS_MAX_TTL_MS=86400000
+#RUN_LIMITS_MAX_ARGS_BYTES=512kb
+
+# ── The job stream's ticket (a two-minute single-use RS256 compact JWS Run signs) ──
+# A PEM in the environment, or a key file. In production with neither set, stream tickets answer 503
+# (every other route still serves); in development a key is generated in memory at boot.
+#RUN_STREAM_PRIVATE_KEY=
+#RUN_STREAM_KEY_FILE=data/keys/run-stream.pem
+#RUN_STREAM_KEY_ID=run-stream-1
+#RUN_STREAM_TTL_S=120
+#RUN_STREAM_RING_BYTES=1mb
+#RUN_STREAM_MAX_PER_JOB=64
+#RUN_STREAM_HEARTBEAT_MS=25000
+
+# ── The dispatcher bridge to Bot (plan T14 step 6; a no-op seam until then) ──
+#RUN_BOT_URL=http://127.0.0.1:4630
+#RUN_BOT_AUDIENCE=openvibe.bot
+#RUN_DISPATCH_INTERVAL_MS=1000
+#RUN_DISPATCH_TIMEOUT_MS=8000
+
+# Run holds no Billing token: Bot owns the node link and the per-second metering (plan T14 L1).
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ contracts-service: run
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all on the
+ # containers through PgBouncer (RUN_TEST_STORE=pg).
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+node_modules/
+# A linked dependency tree is a symlink, which the pattern above (directory-only) does not match: the
+# tooling that links one shared copy into a worktree must never be committable.
+node_modules
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+# gitleaks configuration, used by the shared security workflow
+# (OpenVibers/OpenVibe.Shared/.github/workflows/security.yml). The default rules stay on; every
+# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.
+#
+# Run stores no credential of its own: a stream ticket is signed at request time with the key at
+# RUN_STREAM_PRIVATE_KEY / RUN_STREAM_KEY_FILE (.env.example carries names only) and is never written
+# to disk or to a log. Nothing here needs an allowlist yet.
+[extend]
+useDefault = true
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+# Changelog
+
+Notable changes to OpenVibe.Run. The service is unreleased; everything below is `0.1.0` in progress.
+
+## Unreleased
+
+### Added
+
+- The repository skeleton (plan T14 step 4, R1a): `server/config.js` on port 4920, `server/db.js`,
+ `server/app.js`, `server/index.js`, `server/observability.js` (`/api/health`, `/api/ready`, `/release.json`,
+ loopback-only `/metrics`), `server/api/auth.js`, `server/events/outbox.js`, `migrations/0001_jobs.sql`
+ (`run_jobs`, `run_events_outbox`), `STATUS.json`, the nginx vhost and the systemd unit.
+- The job API (plan T14 step 5, R1b): `POST /api/v1/jobs` (`run.job.submit`), `GET /api/v1/jobs/:id`
+ (`run.job.read`), `GET /api/v1/jobs` (`run.job.list`), `POST /api/v1/jobs/:id/cancel` (`run.job.cancel`),
+ `POST /api/v1/jobs/:id/stream/ticket` and `GET /api/v1/jobs/:id/stream` (`run.job.stream`) and
+ `GET /api/v1/admin/jobs` (`run.job.admin`) — each validated against its released contract, with the project and
+ the requester taken from the token alone.
+- `run.job.queued|started|succeeded|failed|cancelled|expired` through `run_events_outbox`, written in the same
+ transaction as the state change, and the two-minute single-use stream ticket with the SSE stream (replay,
+ monotonic `seq`, `Last-Event-ID`).
+- `server/dispatch/index.js`: the dispatcher-bridge seam (plan T14 step 6) as a no-op, so every job stays `queued`.
+
+### Notes
+
+- Run holds no Billing token: OpenVibe.Bot owns the node link and the per-second metering (plan T14 L1).
+- The repo is not deployed; the first deploy needs `ovhost`'s inventory, the `ov_run` database,
+ `/etc/openvibe/run.env` and `openvibe.run` DNS (see STATUS.json).
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+# OpenVibe.Run
+
+The job service of the network (openvibe.run). Design: OpenVibe.Contracts docs/adr/ADR-034-platform-north-star.md;
+plan track T14.
+
+## Purpose
+
+OpenVibe.Run accepts jobs, places each one on a node that advertises the runtime class it needs, and follows it to
+its end. A job belongs to a **project**, is billed per wall-clock second it runs, and is visible only to the token
+that project presents. Run owns the API, the project scope and the placement; OpenVibe.Bot owns the node link and the
+per-second metering, so Run holds no Billing token at all (plan T14 L1).
+
+This repository is at plan **T14 step 5 (R1b)**: the API is complete and every job stays `queued`. The dispatcher
+bridge to Bot is plan T14 step 6 (R1c) and lives behind the seam in `server/dispatch/index.js`.
+
+## Owns
+
+- The job API (`server/api/v1.js`, one capability per route — the table below) and the job's lifecycle record
+ (`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.
+- The project scope: a job's `project_id` and `requester` come from the caller's Network token
+ (`claims.project_id`, `claims.sub`) and never from a request body; another project's job is `404`, never `403`.
+- The `job_<ULID>` id (minted here, passed unchanged to Bot as `platform.job@1` `id`, so every metering reading of
+ the job is `run:<id>:<n>`).
+- Idempotent submit: one row per `(project_id, idempotency_key)`, a repeat answering the first job, a different body
+ with the same key refused `409 run.idempotency.conflict`.
+- The `run.job.*` events (`server/jobs/events.js`, `run_events_outbox`), written in the same transaction as the
+ state change they describe.
+- The job stream (`server/jobs/stream.js`): one ring buffer per open job, the SSE `id` being the job's own monotonic
+ `seq`, plus the two-minute single-use stream ticket (`server/jobs/ticket.js`).
+- The `platform.workload-requirements@1` default (`{ kind: run.<class>, mobility: job, latency_class: background,
+ objective: balanced }`, the contract's own default) and the project caps on a job's limits.
+
+## Does not own
+
+- The node link, the device credentials and the per-second metering: OpenVibe.Bot (`bot.job.dispatch`), which places
+ the job on a node advertising `worker:<class>`.
+- Identity, tokens and projects (OpenVibe.Network); Run only verifies the tokens it is the audience of.
+- The artifacts a job runs and the media its inputs come from (OpenVibe.Media; Run never fetches a caller-chosen
+ URL).
+- Money: Run writes no usage record and holds no Billing token.
+
+## Depends on
+
+- PostgreSQL 18: `run_jobs` and the `run_events_outbox`; `migrations/` applies on boot.
+- Valkey (`VALKEY_URL`, optional): shared state between processes; unset, nothing is shared.
+- OpenVibe.Network: the RS256 signing key (JWKS) every token is verified against; Run's client `run` mints the
+ events relay's service token.
+- OpenVibe.Events: `run.job.*` events go to `events.event.publish`; unset, they wait in `run_events_outbox`.
+- OpenVibe.Bot (plan T14 step 6): the dispatcher bridge the jobs wait for.
+- `openvibe-contracts` v0.106.0, `openvibe-sdk` v0.34.0 and `openvibe-shared` v2.11.0 (package.json).
+
+## Capabilities and routes
+
+Every route checks exactly the capability its manifest names (`contracts/manifests/capabilities/run.job.*.json`),
+from the caller's service token. A Network user token names no project and holds no capability here, so it is
+refused like any other token without the grant.
+
+| Method and path | Capability | Request → answer |
+|---------------------------------|------------------|--------------------------------------------------------|
+| `POST /api/v1/jobs` | `run.job.submit` | `run.job-create-request@1` → `run.job-create-result@1` (201 created; 200, `created:false` on an idempotent repeat) |
+| `GET /api/v1/jobs/:id` | `run.job.read` | `common.no-body@1` → `run.job-read-result@1` |
+| `GET /api/v1/jobs?state=&limit=&cursor=` | `run.job.list` | `run.job-list-query@1` → `run.job-list-result@1` |
+| `POST /api/v1/jobs/:id/cancel` | `run.job.cancel` | `common.no-body@1` → `run.job-read-result@1` |
+| `POST /api/v1/jobs/:id/stream/ticket` | `run.job.stream` | `common.no-body@1` → `run.job-stream-ticket-result@1` |
+| `GET /api/v1/jobs/:id/stream?ticket=` | `run.job.stream` | a ticket (never a service token) → `text/event-stream` of `run.job-stream-event@1` |
+| `GET /api/v1/admin/jobs` | `run.job.admin` | `run.job-list-query@1` (with `project_id`) → `run.job-list-result@1` |
+
+`run.job.admin` additionally reads and cancels any project's job and is the only way to pass `project_id` to a list
+(the manifest says so); it is internal, so only a first-party `svc:` token may hold it — an app, mod or agent token
+with the grant is refused. Every request body and query string is validated against its released contract, and so is
+every answer before it is sent; errors are RFC 9457 `application/problem+json`.
+
+## Jobs
+
+- **Submit.** `Idempotency-Key` (or `body.idempotency_key`) makes a retry safe; with neither, Run mints its own key
+ and returns it in the read result (`auto:<ulid>`). The row is unique per `(project_id, idempotency_key)` and that
+ index is permanent, so a key is spent for good: a repeat always answers the first job, and the same key with
+ different work is `409` — the contract promising that "within 24 h" is answered the same way, forever, rather than
+ reused. `limits` are capped by the project's (`RUN_LIMITS_*`, `422 run.limits.exceeded`) — the lifetime too, whether
+ named or defaulted — `args` over 512 KiB are refused `413 run.args_too_large`, and `ttl_ms` defaults to
+ `limits.wall_ms + 600000` exactly as the contract says.
+- **States.** `queued → placed → running → succeeded | failed | cancelled | expired`. Run moves a job to `queued`
+ and, on a cancel, to `cancelled`; the rest arrives from Bot (plan T14 step 6). An end state never changes, so a
+ replay of a cancel is a no-op rather than a second event.
+- **Events.** `run.job.queued|started|succeeded|failed|cancelled|expired`, payloads being the
+ `run.job-read-result@1` projection their contracts describe. The outbox row is keyed `run:<job id>:<state>` (one
+ row per job and state, so a replay is one row); the published envelope carries a contract-valid `evt_<ULID>`
+ document id, because `events.event-envelope@1` pins that pattern and OpenVibe.Events validates it on publish.
+- **Stream.** `POST /jobs/:id/stream/ticket` mints a two-minute single-use RS256 ticket for that one job (audience
+ `openvibe.run`, `typ run-stream`, its own key — never Network's signing key, so it is never a session anywhere).
+ `GET /jobs/:id/stream?ticket=…` replays what is kept (the last 1 MiB) and follows; `Last-Event-ID` resumes after
+ the last `seq` seen; an ended job replays what is kept and closes with `type: 'end'`. Reconnecting never re-runs a
+ job. The ring is this process's memory (reclaimed once it ended or went idle, and rebuilt from the job's row when
+ a stream opens again), so `seq` is this process's counter: a resume across a restart gets the current state rather
+ than a gap, and output is best effort either way.
+- **The `job` record.** `run_jobs.job` holds the `platform.job@1` body Run will send (id, class, artifact, args,
+ ttl_ms, limits, net, inputs) plus the fields `platform.job@1` has no place for and `0001_jobs.sql` has no column
+ for: `requirements` (required by `run.job-read-result@1` and by placement), `placed_at` (`timings.placed_at`) and
+ `cancel_requested` (a cancel of a placed or running job). `store.frameOf(record)` builds the frame body from the
+ `platform.job@1` keys alone, and the dispatcher bridge validates it before anything is sent.
+- **Cancelling.** A queued or placed job is cancelled at once (`exit` stays null, usage stays 0); a running job is
+ marked `cancel_requested` and ends when its `job_exit` arrives (step 6). Cancelling an ended job answers it as it
+ is.
+
+## Tests
+
+```
+npm test # every file in test/, PGlite (in-process PostgreSQL)
+npm run test:pg # the same on the production-shaped containers (openvibe-sdk scripts/test-services.sh up)
+node test/jobs.test.js # the job API against the released contracts
+node test/app.test.js # the skeleton: health, readiness, release.json, /metrics, the dispatcher seam
+```
+
+`test/helpers/app.js` boots Run against an in-process OpenVibe.Network stub (its JWKS and its tokens), so nothing
+needs the network, OpenVibe.Bot or a running service.
+
+## Security
+
+- Every route checks one capability; the project and the requester come from the token, never the body.
+- Another project's job is `404 run.job_not_found`, never `403`: an id's existence is never confirmed.
+- The stream route accepts only a valid, unexpired, unused ticket for that job — a service token in the query string
+ is refused like any other non-ticket.
+- Limits are bounded by the contract and the project's caps; nothing is silently lowered.
+- No secret is logged (no `Authorization` header, no ticket, no job output), and nothing starts at module load.
+- See SECURITY.md to report a problem.
+
+## Deploy
+
+Not deployed yet: `openvibe.run` has no DNS, no `ovhost` inventory entry, no `ov_run` database and no
+`/etc/openvibe/run.env`. The release is here ready for them:
+
+- `deploy/nginx/openvibe.run.conf` — the vhost (TLS, www → apex, rate limits, client IP from `$remote_addr` only,
+ `/internal/` and `/metrics` never proxied).
+- `deploy/systemd/openvibe-run.service` — unit `openvibe-run`, `WorkingDirectory=/opt/openvibe.run`,
+ `EnvironmentFile=/etc/openvibe/run.env`, `PORT=4920`.
+- `/api/health` and `/api/ready` are for the loopback caller; `/metrics` refuses anything that arrived through a
+ proxy.
+
+## Deploy files
+
+| File | What it is |
+|------|------------|
+| `deploy/nginx/openvibe.run.conf` | The nginx vhost for openvibe.run (reference; deployed to `/etc/nginx/sites-available/`) |
+| `deploy/systemd/openvibe-run.service` | The systemd unit (release layout `/opt/openvibe.run`) |
+| `.github/workflows/ci.yml` | CI: the shared test job (syntax, tests, `openvibe-contracts-check --service run`) and the shared security job |
+| `migrations/0001_jobs.sql` | `run_jobs` and `run_events_outbox` |
+| `STATUS.json` | What this repository is, what it does today and where it runs |
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+# Security policy
+
+OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
+
+## Reporting a vulnerability
+
+Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
+repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
+a vulnerability, and do not test against other people's accounts, projects, jobs or data.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (openvibe.run, openvibe.live, openvibe.network, openvibe.tools, openvibe.media,
+openvibe.bot and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable
+contact details are at `/.well-known/security.txt` on each site.
+
+## This service
+
+OpenVibe.Run executes other people's work and its usage is billed, so a defect here is money and execution. What
+Run guarantees, and what a report should assume:
+
+- every route checks exactly one capability from the caller's Network service token (audience `openvibe.run`), and
+ a job's project and requester come from that token — never from a request body or a query string;
+- another project's job is always `404` (`run.job_not_found`), never `403`: Run never confirms that an id exists;
+- request bodies and query strings are validated against the released `openvibe-contracts` schemas, and so is every
+ answer before it is sent;
+- `GET /api/v1/jobs/:id/stream` accepts only a two-minute, single-use ticket that Run itself signed for that one job
+ (audience `openvibe.run`, `typ run-stream`) — never a service token in a query string;
+- no secret is logged: not the `Authorization` header, not a stream ticket, not a job's stdout;
+- nothing starts at module load (timers, relays and listeners start in `server/index.js` `main()`).
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+{
+ "repository": "OpenVibers/OpenVibe.Run",
+ "stage": "alpha",
+ "code": true,
+ "wave": "W20",
+ "plan": "OpenVibe Updated Plan Based On Codebase (plan T14, ADR-034)",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db) + Valkey 9",
+ "port": 4920,
+ "domain": "openvibe.run",
+ "contracts": "openvibe-contracts v0.106.0",
+ "packages": [
+ "openvibe-contracts v0.106.0",
+ "openvibe-sdk v0.34.0",
+ "openvibe-shared v2.11.0"
+ ],
+ "capabilities": [
+ "run.job.submit",
+ "run.job.read",
+ "run.job.list",
+ "run.job.cancel",
+ "run.job.stream",
+ "run.job.admin"
+ ],
+ "events": [
+ "run.job.queued",
+ "run.job.started",
+ "run.job.succeeded",
+ "run.job.failed",
+ "run.job.cancelled",
+ "run.job.expired"
+ ],
+ "deployed": false,
+ "deployment": "Not deployed: openvibe.run has no DNS, no ovhost inventory entry, no ov_run database and no /etc/openvibe/run.env yet. The release is deploy/ (nginx vhost deploy/nginx/openvibe.run.conf, unit deploy/systemd/openvibe-run.service, WorkingDirectory /opt/openvibe.run, EnvironmentFile /etc/openvibe/run.env, PORT 4920). The vhost proxies /api/health, /api/ready and /release.json and the job API, takes the client address from $remote_addr only (never appending X-Forwarded-For), and refuses /internal/ and /metrics at the edge; the app refuses a proxied /metrics caller itself.",
+ "features": [
+ "the job API (/api/v1, one capability per route): POST /jobs (run.job.submit), GET /jobs/:id (run.job.read), GET /jobs/ (run.job.list), POST /jobs/:id/cancel (run.job.cancel), POST /jobs/:id/stream/ticket and GET /jobs/:id/stream (run.job.stream), GET /admin/jobs (run.job.admin)",
+ "the project and the requester come from the token (claims.project_id, claims.sub), never from the body; another project's job is 404 run.job_not_found, never 403",
+ "idempotent submit: the same Idempotency-Key (or body.idempotency_key) from the same project with the same body answers the first job (200, created false); with a different body it is 409 run.idempotency.conflict",
+ "every request body and query is validated against its released contract (run.job-create-request@1, run.job-list-query@1) and every answer against run.job-read-result@1, run.job-create-result@1, run.job-list-result@1, run.job-stream-ticket-result@1",
+ "limits bounded by the project's caps (RUN_LIMITS_*, 422 run.limits.exceeded) and args over 512 KiB refused 413 run.args_too_large, never silently lowered; requirements defaults to the contract's platform.workload-requirements@1 default (run.<class>, mobility job, latency_class background, objective balanced)",
+ "the run.job.queued|started|succeeded|failed|cancelled|expired events written in the same transaction as the state change, the outbox row keyed run:<job id>:<state> (a replay is one row) with a contract-valid evt_ document id on the published envelope, relayed from run_events_outbox by the openvibe-sdk outbox",
+ "the job stream: a two-minute single-use RS256 stream ticket (audience openvibe.run, typ run-stream, its own key — never Network's) opens text/event-stream of run.job-stream-event@1, replayed from the last 1 MiB with monotonic seq as the SSE id and Last-Event-ID resume",
+ "keyset paging on (created_at, id) with next_cursor null on the last page; run.job.admin lists any project's jobs (project_id in the query) and reads and cancels any job",
+ "run_jobs (migrations/0001_jobs.sql) holds every job, its state, placement, timings, exit, result and the metered wall clock; jobs stay queued until the dispatcher bridge to Bot exists (plan T14 step 6, server/dispatch/index.js — a no-op seam today)",
+ "Run holds no Billing token at all: Bot owns the node link and the per-second metering (plan T14 L1)",
+ "truthful readiness (GET /api/ready: db required; valkey, Network JWKS, Events and Bot reported, never claimed) and the loopback-only /metrics gauges (jobs queued, active, ended, outbox backlog, streams)"
+ ],
+ "updated": "2026-10-07"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.run — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.run.conf — keep them in sync and reload nginx
+## after changing it. The Node app (port 4920, systemd unit openvibe-run) serves:
+##
+## /api/v1/jobs… the job API (run.job.submit|read|list|cancel|stream|admin); every
+## request needs a Network service token for audience openvibe.run
+## /api/v1/jobs/{id}/stream the job's Server-Sent Events (long-lived: never buffered or cached,
+## Last-Event-ID forwarded so a reconnect resumes where it left)
+## /api/health, /api/ready, /release.json
+## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
+## /internal/ host-local service endpoints, refused here
+##
+## The client address reaches the app as $remote_addr only, same in X-Forwarded-For and CF-Connecting-IP:
+## the header is set here, never appended to or passed through from the client, so the app's own view of
+## who is calling cannot be spoofed. Run serves no page, no form and no cookie session.
+##
+## No proxy_cache: the API answers no-store, and an event stream must never be cached or buffered.
+## Never log request bodies or the Authorization header: a service token and a stream ticket both ride
+## in one.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=ovrun_api:10m rate=20r/s;
+limit_req_zone $binary_remote_addr zone=ovrun_stream:10m rate=2r/s;
+
+# ── HTTP → HTTPS ────────────────────────────────────────────
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.run www.openvibe.run;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.run$request_uri; }
+}
+
+# ── www → apex ──────────────────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.run;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.run/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.run/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.run$request_uri;
+}
+
+# ── The service ─────────────────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.run;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.run/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.run/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.run.access.log;
+ error_log /var/log/nginx/openvibe.run.error.log;
+
+ # The API accepts at most 768 kB of JSON (server/app.js; a job's args may be 512 KiB of it); 1 MB
+ # leaves room for headers.
+ client_max_body_size 1m;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types application/json application/problem+json text/plain;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr; # the client address, from $remote_addr only
+ proxy_set_header X-Forwarded-For $remote_addr; # never appended to, never passed through
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── The job stream: Server-Sent Events ──────────────────
+ # The ticket rides in ?ticket= (a browser cannot put a header on an EventSource), and a reconnect
+ # resumes with Last-Event-ID, which is forwarded verbatim.
+ location ~ ^/api/v1/jobs/job_[0-9A-HJKMNP-TV-Z]{26}/stream$ {
+ limit_req zone=ovrun_stream burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4920;
+ proxy_set_header Connection "";
+ proxy_set_header Last-Event-ID $http_last_event_id;
+ proxy_buffering off;
+ proxy_cache off;
+ gzip off;
+ chunked_transfer_encoding on;
+ proxy_read_timeout 1h;
+ proxy_send_timeout 1h;
+ send_timeout 1h;
+ }
+
+ # ── Health, readiness, the release manifest ─────────────
+ location = /api/health { proxy_pass http://127.0.0.1:4920; }
+ location = /api/ready { proxy_pass http://127.0.0.1:4920; }
+ location = /release.json { proxy_pass http://127.0.0.1:4920; }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4920/metrics) ──
+ # The app refuses a request that arrived through a proxy; the vhost refuses it at the edge too.
+ location = /metrics { return 404; }
+
+ # ── Host-local service endpoints (Events deliveries, other services on this host) ──
+ location /internal/ { return 404; }
+
+ # ── The job API ─────────────────────────────────────────
+ location /api/ {
+ limit_req zone=ovrun_api burst=40 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4920;
+ }
+
+ # ── Nothing else is served ──────────────────────────────
+ location / { return 404; }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+[Unit]
+Description=OpenVibe.Run — the job service (jobs, placement, streaming; plan T14, ADR-034)
+# Network (sign-in, JWKS, OAuth) should be up first, and Run's own PostgreSQL (pooled runtime role plus
+# the direct owner role for migrations) and Valkey (state shared between processes) are required to
+# serve; the events relay waits in the outbox and the dispatcher bridge to Bot (plan T14 step 6) is a
+# no-op until it exists. Run degrades honestly without any of them: /api/ready says which (STATUS).
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.run
+EnvironmentFile=/etc/openvibe/run.env
+Environment=NODE_ENV=production
+Environment=PORT=4920
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.run/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=10
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# Run's own database is PostgreSQL on the host's data role; the code tree stays read-only. The stream
+# ticket key is read from RUN_STREAM_PRIVATE_KEY or RUN_STREAM_KEY_FILE (default
+# /opt/openvibe.run/data/keys/run-stream.pem), never from the code tree.
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+# OpenVibe.Run — docs
+
+The canonical design lives with the contracts, not here: OpenVibe.Contracts `docs/adr/ADR-034-platform-north-star.md`
+(the job service, the cells and the Fabric) and the build plan's **T14** section. The API is generated from the
+capability manifests: `contracts/generated/openapi/run.json` once `manifests/services/run.json` leaves
+`status: "placeholder"` (a separate contracts change, plan T14 step 5 item 6).
+
+What does live here:
+
+- `protocol.md` — how Run and Bot talk when the dispatcher bridge lands (plan T14 step 6): the
+ `platform.job@1` body it sends, the `platform.job-frame@1` answers it mirrors, and the state mapping. Not written
+ yet: the bridge does not exist.
+
+Nothing else is a document here. The README's route table is the API as it is served today, and `STATUS.json` is
+what this repository is.
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+-- phase: expand
+-- OpenVibe.Run's own record of a job (plan T14 R1; run.job.*). Bot owns the node link and the
+-- per-second metering (OpenVibe.Bot/server/jobs); Run owns the API, the placement and the project scope.
+CREATE TABLE IF NOT EXISTS run_jobs (
+ id text PRIMARY KEY CHECK (id ~ '^job_[0-9A-HJKMNP-TV-Z]{26}$'),
+ project_id text NOT NULL,
+ requester text NOT NULL, -- the token principal that submitted it
+ idempotency_key text NOT NULL,
+ class text NOT NULL,
+ job jsonb NOT NULL, -- the platform.job@1 body sent to the node
+ ttl_ms bigint NOT NULL,
+ state text NOT NULL CHECK (state IN ('queued','placed','running','succeeded','failed','cancelled','expired')),
+ node_id text,
+ provider text,
+ region text,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ started_ms bigint,
+ finished_ms bigint,
+ exit_reason text,
+ exit_code integer,
+ result jsonb,
+ wall_ms bigint NOT NULL DEFAULT 0,
+ usage_read integer NOT NULL DEFAULT 0,
+ error_code text,
+ error_detail text
+);
+CREATE UNIQUE INDEX IF NOT EXISTS run_jobs_idem ON run_jobs (project_id, idempotency_key);
+CREATE INDEX IF NOT EXISTS run_jobs_project_state ON run_jobs (project_id, created_at DESC);
+-- run.job.* events waiting for the bus: the openvibe-sdk outbox shape (outboxSchema('run_events_outbox')).
+CREATE TABLE IF NOT EXISTS run_events_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1405 @@
@@ -0,0 +1,1405 @@
+{
+ "name": "openvibe-run",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-run",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.106.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",
+ "integrity": "sha512-w3HV+CmqtJYTUT97FsWziSOzM0KnA81PnAJCG9w68T8jJMRIDk96FJJKFckFQbFLFfZWF7jAINf0OSEzalabaQ==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "integrity": "sha512-vDwnDV1e2xbWqShjTr4p6sNehoUAQwW8RyQKrwvoqX1pJbxks86u2DjF9LGSlAoAdMZhmJbgVaAEm0r18Qmt5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "2.11.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+{
+ "name": "openvibe-run",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Run — accepts jobs, places each on a node that advertises worker:<class>, and follows its platform.job-frame@1 frames (plan T14, ADR-034).",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "test:pg": "RUN_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe",
+ "run",
+ "jobs",
+ "execution"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Run",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+
+/**
+ * Who is calling /api/v1 — resolved into req.principal:
+ *
+ * { kind: 'service', sub: 'svc:live', cap: [...], claims, project_id, jti } a Network client-credentials
+ * token for audience openvibe.run; each route checks ONE
+ * { kind: 'user', subject: 'usr_…', username, name, avatar, role } capability (see v1.js)
+ * { kind: 'anonymous' }
+ *
+ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
+ * A node principal (nod_…) is not a Run principal and is refused like any other non-service token.
+ *
+ * The project a job belongs to comes from the token's own project_id claim (a developer app or agent
+ * token names one), never from the request body or a query: the requester and the billed project are
+ * the token's, always.
+ */
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ANON = Object.freeze({ kind: 'anonymous' });
+
+/** Run's six capabilities (contracts/manifests/capabilities/run.job.*.json). */
+const CAPABILITIES = ['run.job.submit', 'run.job.read', 'run.job.list', 'run.job.cancel', 'run.job.stream', 'run.job.admin'];
+
+function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+/** A Network user token's claims → the user principal (null when it names no subject). */
+function userPrincipal(claims) {
+ if (!claims) return null;
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (!subject) return null;
+ return {
+ kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,
+ avatar: claims.avatar_url || null, role: claims.role || 'user', project_id: null, cap: [], claims,
+ };
+}
+
+/** Verify a service token (audience openvibe.run). Returns { ok, claims } or { ok:false, code, reason }. */
+function verifyService(token, { publicKey, issuer, audience }) {
+ if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
+ const payload = decodePayload(token);
+ if (!payload || typeof payload.sub !== 'string' || !PRINCIPAL_SUB.test(payload.sub)) return { ok: false, code: 'token.invalid', reason: 'not a service token' };
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });
+ if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
+ return { ok: true, claims: r.claims };
+}
+
+/** The project a principal acts for: its token's project_id claim, or null (no project named). */
+function projectOf(principal) {
+ const id = principal && principal.claims && principal.claims.project_id;
+ return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;
+}
+
+/**
+ * The principal as an identity.subject-ref@1 (the run_jobs.requester column holds 'type:id'). A service
+ * token's sub is `svc:<slug>` — the wire spelling of the SubjectRef `{ type: 'service', id: '<slug>' }`
+ * (ids.principalSub is the other direction); app, mod and agent subs already carry their type.
+ */
+function requesterOf(principal) {
+ if (!principal) return null;
+ if (principal.kind === 'user') return { type: 'user', id: principal.subject };
+ if (principal.kind !== 'service') return null;
+ const sub = String((principal.claims && principal.claims.sub) || principal.sub || '');
+ if (sub.startsWith('svc:')) return { type: 'service', id: sub.slice(4) };
+ return ids.parseSubject(sub);
+}
+
+function createApiAuth({ config, keys, userAuth }) {
+ async function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return { principal: ANON };
+ const token = header.slice(7).trim();
+ const publicKey = keys.get();
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const r = verifyService(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code, r.reason] };
+ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], claims: r.claims, project_id: projectOf({ claims: r.claims }), jti: r.claims.jti } };
+ }
+ const claims = await userAuth.verify(token);
+ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
+ const p = userPrincipal(claims);
+ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: p };
+ }
+
+ async function middleware(req, res, next) {
+ const r = await resolve(req);
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }
+
+ /** A service principal holding `cap` (a trailing `.*` grant covers a family). */
+ const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);
+
+ /**
+ * requireCapability('run.job.submit') — the Express middleware of ONE route of the map in v1.js: the
+ * token's grant list is judged by openvibe-contracts capabilities.check (never a route's own list, and
+ * never the body). No token → 401; a token without the grant → 403 capability.denied.
+ */
+ function requireCapability(id) {
+ if (!CAPABILITIES.includes(id)) throw new Error(`requireCapability(${id}): not one of Run's capabilities`);
+ return function capabilityGuard(req, res, next) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `${id} needs a Network service token for ${config.audience}`, ctx: req.ov });
+ const decision = p.kind === 'service'
+ ? capabilities.check(p.claims, id)
+ : { allowed: false, code: 'capability.denied', reason: `${id} is granted to service principals only` };
+ // run.job.admin is `internal` and, the manifest says, "never delegated and never in an app or mod
+ // token": an app, mod or agent principal that somehow holds the grant is refused here too.
+ const internalService = id !== 'run.job.admin' || String(p.sub || '').startsWith('svc:');
+ if (decision.allowed && internalService) return next();
+ return http.sendProblem(res, 403, decision.code || 'capability.denied', {
+ detail: decision.allowed ? 'run.job.admin is internal: it is never an app, mod or agent token' : decision.reason || `${id} not granted`,
+ ctx: req.ov,
+ });
+ };
+ }
+
+ /**
+ * The project and the requester this call acts for, both from the token alone. 403 run.project_required
+ * when the token names no project (a first-party service token without the project_id claim, or a user
+ * token): jobs belong to a project, and the billed project is never taken from a request body.
+ */
+ function requireProject(req) {
+ const project = projectOf(req.principal);
+ const requester = requesterOf(req.principal);
+ if (!project || !requester) {
+ const err = new Error('this token names no project: jobs belong to the project a token names (project_id)');
+ err.status = 403;
+ err.code = 'run.project_required';
+ throw err;
+ }
+ return { project, requester: ids.formatSubject(requester) };
+ }
+
+ return { middleware, resolve, granted, requireCapability, requireProject, projectOf, requesterOf };
+}
+
+module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, projectOf, requesterOf, CAPABILITIES, PRINCIPAL_SUB, PROJECT_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
+'use strict';
+
+/**
+ * /api/v1 — Run's job API for services (capability-guarded Network tokens for audience openvibe.run).
+ * The route → capability map is fixed by the six capability manifests (contracts/manifests/capabilities/
+ * run.job.*.json) and must not be invented:
+ *
+ * | Method & path | Capability | Request → answer |
+ * |-----------------------------------|------------------|-----------------------------------------------|
+ * | POST /jobs | run.job.submit | run.job-create-request@1 → run.job-create-result@1 (201 created, 200 idempotent repeat) |
+ * | GET /jobs/:id | run.job.read | common.no-body@1 → run.job-read-result@1 |
+ * | GET /jobs?state=&limit=&cursor= | run.job.list | run.job-list-query@1 → run.job-list-result@1 |
+ * | POST /jobs/:id/cancel | run.job.cancel | common.no-body@1 → run.job-read-result@1 |
+ * | POST /jobs/:id/stream/ticket | run.job.stream | common.no-body@1 → run.job-stream-ticket-result@1 |
+ * | GET /jobs/:id/stream | run.job.stream | a ticket (never a service token) → text/event-stream of run.job-stream-event@1 |
+ * | GET /admin/jobs | run.job.admin | run.job-list-query@1 (with project_id) → run.job-list-result@1 |
+ *
+ * The project and the requester always come from the token (claims.project_id, claims.sub), NEVER from the
+ * body: a body field is never read for either. run.job.admin additionally reads and cancels any project's
+ * job (the manifest says so) and is the only way to pass project_id to a list. Another project's job is
+ * always 404 run.job_not_found, never 403: the answer must not say whether the id exists.
+ *
+ * Errors are RFC 9457 problem+json: 400 run.invalid_request (the body does not match its contract), 401
+ * token.required / ticket.*, 403 capability.denied / run.project_required, 404 run.job_not_found, 409
+ * run.idempotency.conflict, 413 run.args_too_large, 422 run.limits.exceeded / run.invalid_input.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { ids } = contracts;
+const { fail, iso, isJobId } = require('../util');
+const store = require('../jobs/store');
+
+// The six capabilities this router enforces, one per route above (contracts/manifests/capabilities).
+const CAP = {
+ submit: 'run.job.submit',
+ read: 'run.job.read',
+ list: 'run.job.list',
+ cancel: 'run.job.cancel',
+ stream: 'run.job.stream',
+ admin: 'run.job.admin',
+};
+const IDEMPOTENCY_RE = /^[A-Za-z0-9._:-]{1,128}$/;
+const STATES = ['queued', 'placed', 'running', 'succeeded', 'failed', 'cancelled', 'expired'];
+
+/** POST /jobs: validate a run.job-create-request@1 body, or answer 400 with the contract's own errors. */
+function validCreate(body) {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) fail(400, 'run.invalid_request', 'the body must be a JSON object (run.job-create-request@1)');
+ const v = contracts.validate('run.job-create-request@1', body);
+ if (!v.valid) {
+ const err = new Error('the body does not match run.job-create-request@1');
+ err.status = 400;
+ err.code = 'run.invalid_request';
+ err.detail = v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 1000);
+ err.extra = { errors: v.errors.slice(0, 20) };
+ throw err;
+ }
+ return body;
+}
+
+/** The project's own caps (server/config.js): a request over one is refused 422, never silently lowered. */
+function checkLimits(body, caps) {
+ if (body.limits.wall_ms > caps.maxWallMs) fail(422, 'run.limits.exceeded', `limits.wall_ms is at most ${caps.maxWallMs} ms for a project`);
+ if (body.limits.cpu_ms > caps.maxCpuMs) fail(422, 'run.limits.exceeded', `limits.cpu_ms is at most ${caps.maxCpuMs} ms for a project`);
+ if (body.limits.mem_bytes > caps.maxMemBytes) fail(422, 'run.limits.exceeded', `limits.mem_bytes is at most ${caps.maxMemBytes} bytes for a project`);
+ // The lifetime is checked whether the caller named it or took the contract's default
+ // (limits.wall_ms + 600000): a default that lands over the cap is refused, never quietly shortened.
+ const ttl = body.ttl_ms != null ? body.ttl_ms : body.limits.wall_ms + 600_000;
+ if (ttl > caps.maxTtlMs) fail(422, 'run.limits.exceeded', `ttl_ms is at most ${caps.maxTtlMs} ms for a project (${body.ttl_ms != null ? 'asked for' : 'the default, limits.wall_ms + 600000, is'} ${ttl} ms)`);
+ if (body.args !== undefined && Buffer.byteLength(JSON.stringify(body.args)) > caps.maxArgsBytes) fail(413, 'run.args_too_large', `args serialize to at most ${caps.maxArgsBytes} bytes`);
+ const names = (body.inputs || []).map((i) => i.name);
+ if (new Set(names).size !== names.length) fail(422, 'run.invalid_input', 'input names must be unique');
+ // egress public/openvibe-only is not refused here: which node may run it is placement's decision and
+ // the job waits queued until a node offers it (the dispatcher bridge, plan T14 step 6).
+}
+
+/** The idempotency key of a submit: body.idempotency_key or the Idempotency-Key header, never disagreeing. */
+function idempotencyKey(req, body) {
+ const header = req.get('Idempotency-Key');
+ const inBody = body.idempotency_key;
+ if (header != null && inBody != null && header !== inBody) fail(422, 'run.invalid_input', 'the Idempotency-Key header and body.idempotency_key disagree');
+ const key = header != null ? header : inBody;
+ if (key == null) return `auto:${ids.ulid().toLowerCase()}`;
+ if (!IDEMPOTENCY_RE.test(key)) fail(422, 'run.invalid_input', 'idempotency_key must match ^[A-Za-z0-9._:-]{1,128}$');
+ return key;
+}
+
+/** GET /jobs and GET /admin/jobs: a run.job-list-query@1 query string, or 400. */
+function listQuery(query) {
+ const q = {
+ ...(query.state !== undefined ? { state: query.state } : {}),
+ ...(query.project_id !== undefined ? { project_id: query.project_id } : {}),
+ ...(query.limit !== undefined ? { limit: query.limit } : {}),
+ ...(query.cursor !== undefined ? { cursor: query.cursor } : {}),
+ };
+ const v = contracts.validate('run.job-list-query@1', q);
+ if (!v.valid) fail(400, 'run.invalid_request', `the query does not match run.job-list-query@1: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
+ return { state: q.state ?? null, project_id: q.project_id ?? null, limit: q.limit === undefined ? 50 : Number(q.limit), cursor: q.cursor ?? null };
+}
+
+/** The archived run.job-stream-ticket-result@1 answer (validated against its contract before it is sent). */
+function ticketResult({ ticket, expiresAtMs, expiresIn, streamUrl, jobId }) {
+ const body = { ticket, expires_at: iso(expiresAtMs), expires_in: expiresIn, stream_url: streamUrl, job_id: jobId };
+ contracts.assertValid('run.job-stream-ticket-result@1', body);
+ return body;
+}
+
+/**
+ * v1Router({ config, db, valkey, apiAuth, stream, tickets, events, dispatch, now, log }).
+ * Every route is mounted with apiAuth.requireCapability('run.job.…') — exactly the capability of its
+ * manifest — and the middleware of the app ran first, so req.principal is always resolved.
+ */
+function v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now = () => Date.now(), log = console }) {
+ const r = express.Router();
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
+ /** The route's capability, from the token's grant list (openvibe-contracts capabilities.check). */
+ const guard = apiAuth.requireCapability;
+ const admin = (req) => apiAuth.granted(req.principal, 'run.job.admin');
+
+ /**
+ * One job, scoped to the token's project — or to any project with run.job.admin. Null when the id is
+ * not a job id, does not exist, or belongs to another project: all three are the same 404 to the caller.
+ */
+ async function getJob(req, id) {
+ if (!isJobId(id)) return null;
+ if (admin(req)) return store.get(db, id, null);
+ return store.get(db, id, apiAuth.requireProject(req).project);
+ }
+
+ r.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
+
+ // ── POST /jobs — run.job.submit ────────────────────────────────────────────
+ r.post('/jobs', guard('run.job.submit'), wrap(async (req, res) => {
+ const { project: projectId, requester } = apiAuth.requireProject(req);
+ const body = validCreate(req.body);
+ checkLimits(body, config.limits);
+ const key = idempotencyKey(req, body);
+ const at = now();
+ const out = await db.tx(async (t) => {
+ const made = await store.mint(t, body, { projectId, requester, idempotencyKey: key, now: at });
+ // The queued event is written in the same transaction as the row (never for an idempotent repeat:
+ // the row key run:<job id>:queued is already taken, and that replay is the row that exists).
+ if (made.created) await events.queued(t, made.row, { traceparent: req.ov && req.ov.traceparent });
+ return made;
+ });
+ // The job stays queued: the dispatcher bridge is plan T14 step 6 (server/dispatch/index.js).
+ if (out.created && dispatch.enabled) await dispatch.place(out.row);
+ if (out.created) stream.seed(out.row);
+ res.status(out.created ? 201 : 200).json({ created: out.created, job: store.readResult(out.row) });
+ }));
+
+ // ── GET /jobs — run.job.list ───────────────────────────────────────────────
+ r.get('/jobs', guard('run.job.list'), wrap(async (req, res) => {
+ const q = listQuery(req.query);
+ // "project_id: Admin only (run.job.admin): list this project's jobs; with run.job.list it is refused 403."
+ if (q.project_id && !admin(req)) fail(403, 'capability.denied', 'run.job.admin is required to list another project\'s jobs');
+ const projectId = q.project_id || apiAuth.requireProject(req).project;
+ const { rows, nextCursor } = await store.list(db, projectId, q);
+ res.json({ jobs: rows.map(store.readResult), next_cursor: nextCursor });
+ }));
+
+ // ── GET /jobs/:id — run.job.read ───────────────────────────────────────────
+ r.get('/jobs/:id', guard('run.job.read'), wrap(async (req, res) => {
+ const row = await getJob(req, req.params.id);
+ if (!row) fail(404, 'run.job_not_found', 'no such job');
+ res.json(store.readResult(row));
+ }));
+
+ // ── POST /jobs/:id/cancel — run.job.cancel ─────────────────────────────────
+ r.post('/jobs/:id/cancel', guard('run.job.cancel'), wrap(async (req, res) => {
+ const row = await getJob(req, req.params.id);
+ if (!row) fail(404, 'run.job_not_found', 'no such job');
+ const at = now();
+ const out = await db.tx(async (t) => {
+ const moved = await store.cancel(t, row.id, row.project_id, at);
+ if (moved.cancelled) await events.cancelled(t, moved.row, { traceparent: req.ov && req.ov.traceparent }); // run:<id>:cancelled — a replay is the same row
+ return moved;
+ });
+ // A running job is only marked: the dispatcher bridge sends job_cancel and its job_exit ends it (step 6).
+ if (out.cancelRequested && dispatch.enabled) await dispatch.cancel(out.row);
+ if (out.cancelled) stream.state(out.row.id, out.row.state, at);
+ res.json(store.readResult(out.row));
+ }));
+
+ // ── POST /jobs/:id/stream/ticket — run.job.stream ──────────────────────────
+ r.post('/jobs/:id/stream/ticket', guard('run.job.stream'), wrap(async (req, res) => {
+ const row = await getJob(req, req.params.id);
+ if (!row) fail(404, 'run.job_not_found', 'no such job');
+ if (!tickets.get()) fail(503, 'run.stream.disabled', 'stream tickets are disabled: no stream key is configured (RUN_STREAM_PRIVATE_KEY or RUN_STREAM_KEY_FILE)');
+ const { ticket, claims } = tickets.mint({ jobId: row.id, projectId: row.project_id });
+ stream.seed(row); // the stream that follows replays this job's state even if this process never saw it
+ res.json(ticketResult({
+ ticket, expiresAtMs: claims.exp * 1000, expiresIn: claims.exp - claims.iat,
+ streamUrl: `${config.baseUrl}/api/v1/jobs/${row.id}/stream`, jobId: row.id,
+ }));
+ }));
+
+ // ── GET /jobs/:id/stream — run.job.stream, ticket only ─────────────────────
+ // A browser cannot send a header on an EventSource, and a service token must never ride in a query
+ // string (it would land in logs): this route accepts the two-minute ticket the POST above mints, and
+ // nothing else. The ticket names the job and the project, so the stream is scoped without any query.
+ r.get('/jobs/:id/stream', wrap(async (req, res) => {
+ const token = Array.isArray(req.query.ticket) ? null : req.query.ticket;
+ if (token == null || token === '') fail(401, 'ticket.required', 'open the stream with ?ticket= from POST /api/v1/jobs/:id/stream/ticket');
+ const verified = tickets.verify(token); // single use: the jti is consumed here, so one ticket opens one stream
+ if (!verified.ok) fail(401, verified.code, verified.reason);
+ if (verified.claims.sub !== req.params.id) fail(403, 'run.stream.ticket_mismatch', 'this ticket is for another job');
+ const row = await store.get(db, req.params.id, verified.claims.project);
+ if (!row) fail(404, 'run.job_not_found', 'no such job');
+ if (!stream.canAttach(row.id)) fail(503, 'run.stream.busy', 'too many streams on this job; try again shortly');
+
+ const lastEventId = Number.parseInt(String(req.get('Last-Event-ID') || req.query.last_event_id || '0'), 10) || 0;
+ stream.seed(row); // a restart or another process: replay the state the row is in
+
+ res.status(200);
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-store, no-transform');
+ res.setHeader('Connection', 'keep-alive');
+ res.setHeader('X-Accel-Buffering', 'no'); // nginx: never buffer an event stream
+ res.flushHeaders?.();
+
+ let sub = null;
+ const finish = () => {
+ clearInterval(beat);
+ if (sub) sub.unsubscribe();
+ if (!res.writableEnded) res.end();
+ };
+ const write = (event) => {
+ res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
+ // The contract: end is the job's last event and the server closes the stream after it.
+ if (event.type === 'end') finish();
+ };
+ // A comment every heartbeat keeps proxies and the client from timing the stream out; the replay
+ // happens inside subscribe, before the first beat.
+ const beat = setInterval(() => { res.write(': keep-alive\n\n'); }, config.stream.heartbeatMs);
+ beat.unref?.();
+ sub = stream.subscribe(row.id, { lastEventId, onEvent: write });
+ req.on('close', finish);
+ res.on('close', finish);
+ if (sub.ended) finish(); // an ended job: the replay is the whole answer
+ }));
+
+ // ── GET /admin/jobs — run.job.admin ────────────────────────────────────────
+ r.get('/admin/jobs', guard('run.job.admin'), wrap(async (req, res) => {
+ const q = listQuery(req.query);
+ const { rows, nextCursor } = await store.adminList(db, q);
+ res.json({ jobs: rows.map(store.readResult), next_cursor: nextCursor });
+ }));
+
+ return r;
+}
+
+module.exports = { v1Router, CAP, IDEMPOTENCY_RE, validCreate, checkLimits, idempotencyKey, listQuery, ticketResult };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+
+/**
+ * OpenVibe.Run — the job API (plan T14, ADR-034). Express app factory; server/index.js listens, tests
+ * build their own instance. Run serves no page: every route is a machine route.
+ *
+ * GET /api/health, /api/ready, /release.json liveness, truthful readiness, the release manifest
+ * GET /metrics direct loopback callers only (never proxied)
+ * /api/v1/* the job API (api/v1.js: one capability per route)
+ *
+ * createApp({ config, db, valkey, keys, outbox, stream, tickets, events, dispatch, now, fetchImpl, log })
+ * — everything injectable. `db` is an openvibe-sdk/db handle with the schema migrated. Nothing starts at
+ * module load: the events relay, the key refresher and the stream heartbeat are started by server/index.js
+ * (the heartbeat lives on each stream's own response).
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const { http } = require('openvibe-contracts');
+const { isLoopbackDirect } = require('openvibe-shared/metrics');
+const { loadConfig } = require('./config');
+const { openDb } = require('./db');
+const { createKeyProvider, createUserAuth } = require('./network');
+const { createRunOutbox } = require('./events/outbox');
+const { createStreamTickets } = require('./jobs/ticket');
+const { createJobStream } = require('./jobs/stream');
+const { createRunEvents } = require('./jobs/events');
+const { createDispatcher } = require('./dispatch');
+const { createApiAuth } = require('./api/auth');
+const { v1Router } = require('./api/v1');
+const { inputError } = require('./util');
+const { createRunReadiness, registerRunGauges } = require('./observability');
+
+const VERSION = require('../package.json').version;
+// A create request may carry 512 KiB of args (run.job-create-request@1) on top of its inputs and limits,
+// so the JSON body cap is above that and args are checked against their own limit (413 run.args_too_large).
+const JSON_LIMIT = '768kb';
+
+function createApp(opts = {}) {
+ const config = opts.config || loadConfig();
+ const db = opts.db || openDb(config);
+ const valkey = opts.valkey || null;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const log = opts.log || console;
+ const now = opts.now || (() => Date.now());
+ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
+ const userAuth = createUserAuth(config, keys);
+ const outbox = opts.outbox || createRunOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
+ const events = opts.events || createRunEvents({ outbox, now, log });
+ const stream = opts.stream || createJobStream({ config, now, log });
+ const tickets = opts.tickets || createStreamTickets({ config, now, log });
+ // The dispatcher bridge to Bot: a no-op until plan T14 step 6 (server/dispatch/index.js), so a job
+ // stays queued and no node ever sees it.
+ const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now });
+ const apiAuth = createApiAuth({ config, keys, userAuth });
+ const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ // Run answers JSON and server-sent events only: no page, no form, no script — so helmet's defaults
+ // carry everything that matters here and there is no CSP to widen.
+ app.use(helmet({ contentSecurityPolicy: false }));
+
+ const registry = opts.registry || require('openvibe-shared/metrics').createRegistry();
+ const gauges = registerRunGauges(registry, { db, stream, now, log });
+ app.get('/metrics', (req, res, next) => (isLoopbackDirect(req) ? gauges.refresh().then(() => next(), () => next()) : next()));
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'run', release: release.release, registry });
+
+ app.use(http.middleware());
+ app.use(cookieParser());
+
+ // Liveness: the process answers. What it can and cannot do is /api/ready's job, and the counts are
+ // /metrics' and /api/ready's — this route says nothing it cannot back with a value it just read.
+ app.get('/api/health', (req, res, next) => outbox.status().then((queuedEvents) => res.json({
+ ok: true, service: 'run', version: VERSION, streams: stream.status().streams, events: queuedEvents, dispatcher: dispatch.status(),
+ }), next));
+ const readiness = createRunReadiness({ db, valkey, keys, config, outbox, stream, dispatch, release: release.release, fetchImpl });
+ app.get('/api/ready', readiness.handler);
+ release.mount(app, { registry: metrics.registry });
+
+ app.use('/api/v1',
+ // A per-address cap on the API (the per-project quotas are the project caps on a job's limits); a
+ // refusal is an RFC 9457 problem like every other answer here, never a plain-text 429.
+ rateLimit({
+ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false,
+ handler: (req, res) => http.sendProblem(res, 429, 'run.rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
+ }),
+ express.json({ limit: JSON_LIMIT }),
+ (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
+ apiAuth.middleware,
+ v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now, log }));
+
+ app.use((req, res) => {
+ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
+ return res.status(404).type('text/plain').send('Not found\n');
+ });
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, next) => {
+ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
+ const refused = inputError(err);
+ if (refused && !res.headersSent && req.path.startsWith('/api/')) return http.sendProblem(res, 422, refused.code, { detail: refused.detail, ctx: req.ov });
+ if (err && err.status && err.code && !res.headersSent && req.path.startsWith('/api/')) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov, extra: err.extra });
+ log.error('[Run] unhandled error:', err);
+ if (res.headersSent) return undefined;
+ if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'run.internal', { ctx: req.ov });
+ return res.status(500).type('text/plain').send('Something went wrong\n');
+ });
+
+ Object.assign(app.locals, { config, db, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });
+ return app;
+}
+
+module.exports = { createApp, VERSION, JSON_LIMIT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+
+/**
+ * OpenVibe.Run configuration. Everything comes from the environment (.env in development,
+ * /etc/openvibe/run.env in production). loadConfig(env) is pure so tests build their own.
+ *
+ * Run is the job service of the network (plan T14, ADR-034): it owns the API, the project scope and the
+ * placement, on its own PostgreSQL (ADR-035). Bot owns the node link and the per-second metering, so Run
+ * holds no Billing token at all — Run never moves money (plan T14 L1).
+ */
+require('dotenv').config();
+
+const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
+const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
+const trim = (u) => String(u || '').replace(/\/+$/, '');
+/** A PEM in an environment variable carries its newlines escaped. */
+const pem = (v) => (v ? String(v).replace(/\\n/g, '\n') : null);
+
+/** A byte size or a plain integer: RUN_LIMITS_MAX_MEM_BYTES=4gb or 4294967296. */
+function bytes(v, d) {
+ const m = /^(\d+)\s*(b|kb|mb|gb)?$/i.exec(String(v || '').trim());
+ if (!m) return d;
+ const n = Number(m[1]) * ({ b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }[(m[2] || 'b').toLowerCase()] || 1);
+ return Number.isFinite(n) && n > 0 ? n : d;
+}
+
+function loadConfig(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4920); // 4920 is Run's; 4000-4910 are taken
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.run' : `http://localhost:${port}`));
+ return {
+ nodeEnv,
+ isProduction,
+ port,
+ host: env.HOST || '127.0.0.1',
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
+
+ // PostgreSQL (ADR-035): DATABASE_URL is the pooled runtime role through PgBouncer (transaction
+ // mode), DATABASE_DIRECT_URL the owner role on a direct connection, for migrations at boot.
+ db: {
+ url: env.DATABASE_URL || '',
+ directUrl: env.DATABASE_DIRECT_URL || '',
+ },
+ // Valkey: shared, never authoritative. Unset: this process only.
+ valkey: {
+ url: env.VALKEY_URL || '',
+ prefix: env.VALKEY_PREFIX || 'ov:run:',
+ },
+ // Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network; Run answers
+ // for the audience openvibe.run only.
+ network: {
+ url: networkUrl,
+ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ publicKey: pem(env.OV_NETWORK_PUBLIC_KEY),
+ },
+ audience: env.RUN_AUDIENCE || 'openvibe.run',
+ // Run's client in the Network (client `run`): the events relay mints its service token with
+ // events.event.publish through it. Unset: run.job.* events wait in the outbox.
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'run',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ },
+ cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
+
+ events: {
+ url: trim(env.EVENTS_URL || ''),
+ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ },
+
+ jobs: {
+ enabled: env.RUN_JOBS !== 'off',
+ intervalMs: int(env.RUN_JOBS_INTERVAL_MS, 5000),
+ },
+
+ // What a project's token may ask for (run.job-create-request@1 $defs.limits): a request over one of
+ // these is refused 422 run.limits.exceeded, never silently lowered (the contract's own ceiling for
+ // ttl_ms is 24 h). The node merges its own, stricter caps on top.
+ limits: {
+ maxWallMs: Math.max(1, int(env.RUN_LIMITS_MAX_WALL_MS, 3600_000)),
+ maxCpuMs: Math.max(1, int(env.RUN_LIMITS_MAX_CPU_MS, 3600_000)),
+ maxMemBytes: bytes(env.RUN_LIMITS_MAX_MEM_BYTES, 4 * 1024 ** 3),
+ maxTtlMs: Math.max(1000, int(env.RUN_LIMITS_MAX_TTL_MS, 86_400_000)),
+ // The contract: args serialize to at most 512 KiB, else 413 (the JSON body cap is above it).
+ maxArgsBytes: bytes(env.RUN_LIMITS_MAX_ARGS_BYTES, 512 * 1024),
+ },
+
+ // The job stream (run.job-stream-event@1). The ticket is a two-minute RS256 compact JWS Run signs
+ // with a key of its own — never the Network signing key, and never a session token (audience
+ // openvibe.run, typ run-stream). Unset in production: stream tickets answer 503 (the API still
+ // serves); in development a key is generated in memory at boot.
+ stream: {
+ ttlS: Math.max(1, Math.min(300, int(env.RUN_STREAM_TTL_S, 120))),
+ keyFile: env.RUN_STREAM_KEY_FILE != null ? String(env.RUN_STREAM_KEY_FILE) : 'data/keys/run-stream.pem',
+ privateKey: pem(env.RUN_STREAM_PRIVATE_KEY),
+ keyId: env.RUN_STREAM_KEY_ID || 'run-stream-1',
+ ringBytes: Math.max(4096, bytes(env.RUN_STREAM_RING_BYTES, 1024 * 1024)), // the contract: replay at most the last 1 MiB
+ maxPerJob: Math.max(1, int(env.RUN_STREAM_MAX_PER_JOB, 64)),
+ heartbeatMs: Math.max(1000, int(env.RUN_STREAM_HEARTBEAT_MS, 25_000)),
+ },
+
+ // The dispatcher bridge to Bot (plan T14 step 6, R1c): Bot holds the node link and the per-second
+ // metering; Run calls it as the service principal `svc:run` for audience openvibe.bot. Until step 6
+ // server/dispatch/index.js is a no-op and every job stays queued.
+ dispatch: {
+ botUrl: trim(env.RUN_BOT_URL || 'http://127.0.0.1:4630'),
+ botAudience: env.RUN_BOT_AUDIENCE || 'openvibe.bot',
+ intervalMs: int(env.RUN_DISPATCH_INTERVAL_MS, 1000),
+ timeoutMs: int(env.RUN_DISPATCH_TIMEOUT_MS, 8000),
+ },
+ };
+}
+
+module.exports = { loadConfig };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * Run's own PostgreSQL database (ADR-035), through openvibe-sdk/db: async, pooled, one dialect.
+ *
+ * The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection
+ * (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in
+ * transaction mode). Run is new: migrations/0001_jobs.sql is written as the final schema, no window.
+ *
+ * run_jobs one row per job (plan T14 R1; run.job-create-request@1)
+ * run_events_outbox the openvibe-sdk transactional outbox (run.job.* events)
+ *
+ * openDb(config) the serving handle: DATABASE_URL; in development without it, an
+ * embedded PGlite database in data/pglite (one process)
+ * migrate(config, {serving}) apply migrations/ with the owner role, then close that connection
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+function openDb(config, { registry, log = console } = {}) {
+ if (!config.db.url && !config.isProduction) {
+ log.warn(`[Run] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
+ fs.mkdirSync(DEV_PGLITE, { recursive: true });
+ return createDb({ pglite: DEV_PGLITE, service: 'run', registry, log });
+ }
+ return createDb({ url: config.db.url, service: 'run', registry, log });
+}
+
+/** Apply pending migrations; several processes starting together are serialised by the SDK's lock. */
+async function migrate(config, { serving = null, log = console } = {}) {
+ if (serving && serving.store === 'pglite') return serving.migrate({ dir: MIGRATIONS, log });
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'run-migrate', max: 1, log });
+ try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+}
+
+module.exports = { openDb, migrate, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+
+/**
+ * The dispatcher bridge Run → Bot — SEAM ONLY: plan T14 step 6 (R1c) builds this, and every method here
+ * is a no-op until then. Run accepts jobs, scopes them to a project and queues them (plan T14 step 5,
+ * R1b); it does not yet hand them to a node, so every job stays `queued` and no node ever sees it.
+ *
+ * Bot owns the node link and the per-second metering (OpenVibe.Bot server/jobs: dispatch(), cancel(),
+ * createJobFrames(); migrations/0004_run_jobs.sql). Step 6 replaces this module with the real bridge:
+ *
+ * place(row) the platform.job@1 body — store.frameOf(row.job), the job record's own platform keys,
+ * validated against platform.job@1, never the requirements/placed_at/cancel_requested Run
+ * keeps beside it — sent to Bot with Run's service token `svc:run` for audience
+ * openvibe.bot holding bot.job.dispatch, which places it on a node advertising
+ * worker:<class>
+ * poll() what Bot answered since the last poll; store.mirror() copies state, placement, timings,
+ * exit, result and the metered wall clock back, store.settle() reaches the end state, and
+ * jobs/events.js emits run.job.started|succeeded|failed|cancelled|expired in the same
+ * transaction; stream.js carries the job's stdout
+ * cancel(row) a job whose cancel_requested marker is set: Bot's job_cancel
+ * sweep() ttl_ms ran out before placement: settle expired with run.job.unplaceable or run.job.ttl
+ *
+ * The interface is fixed here so server/index.js and the API already call it the way the bridge will be
+ * called; only this file changes in step 6. The seam is never enabled by configuration: RUN_BOT_URL and
+ * RUN_BOT_AUDIENCE (server/config.js) are read only by the real bridge.
+ */
+
+/** The runtime class a job needs a node to advertise (platform.resource-offer@1 `worker:<class>`). */
+const workerCapability = (runtimeClass) => `worker:${runtimeClass}`;
+
+/**
+ * createDispatcher({ config, db, store, events, stream, log, now }) → the dispatcher interface.
+ * `enabled` is false until plan T14 step 6: nothing is sent to a node, nothing is mirrored.
+ */
+function createDispatcher({ config = null, db = null, store = null, events = null, stream = null, log = console, now = () => Date.now() } = {}) {
+ const enabled = false;
+ const note = 'the dispatcher bridge to Bot is not built yet (plan T14 step 6, R1c): every job stays queued';
+
+ /** Send a queued job to a node. → { placed, reason }. No-op until step 6. */
+ async function place(row) {
+ return { placed: false, reason: 'dispatcher.noop', job_id: row && row.id, worker: row && workerCapability(row.class) };
+ }
+
+ /** Cancel a placed or running job on its node (after the API marked cancel_requested). → { cancelled, reason }. */
+ async function cancel(row) {
+ return { cancelled: false, reason: 'dispatcher.noop', job_id: row && row.id };
+ }
+
+ /** What Bot answered since the last poll. → [ { job_id, state, … } ]. Empty until step 6. */
+ async function poll() { return []; }
+
+ /** The ttl sweep: jobs whose ttl_ms ran out before placement (run.job.unplaceable / run.job.ttl). → [ { job_id, state } ]. */
+ async function sweep() { return []; }
+
+ function start() { /* step 6 starts the poller here; nothing runs at module load */ }
+ function stop() {}
+
+ return {
+ enabled, note, place, cancel, poll, sweep, start, stop,
+ status: () => ({ enabled, note, bot_url: config ? config.dispatch.botUrl : null, audience: config ? config.dispatch.botAudience : null }),
+ now,
+ // Kept so step 6's bridge has its dependencies in one place without changing the seam's signature.
+ deps: { config, db, store, events, stream, log },
+ };
+}
+
+module.exports = { createDispatcher, workerCapability };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+'use strict';
+
+/**
+ * Run → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * run.job.queued a job was stored queued (POST /api/v1/jobs; never for an idempotent repeat)
+ * run.job.started the node sent job_started
+ * run.job.succeeded job_exit reason exited with code 0
+ * run.job.failed job_exit with another code, or reason limit, stopped or failed
+ * run.job.cancelled POST /api/v1/jobs/:id/cancel, or job_exit reason cancelled
+ * run.job.expired ttl_ms ran out (run.job.unplaceable or run.job.ttl)
+ *
+ * A row is written inside the transaction that makes the change (jobs/events.js: the row is keyed by
+ * `run:<job id>:<state>`, and it validates its envelope against events.event-envelope@1 and its payload
+ * against run.job.<state>@1 itself), so an event exists if and only if its change committed. This module
+ * owns the relay: it publishes with Run's service token (events.event.publish) when EVENTS_URL and
+ * OV_OAUTH_CLIENT_SECRET are set, and otherwise rows wait in run_events_outbox and status() says so.
+ * Payloads carry ids, states and results only — never a credential, never the job's stdout.
+ */
+const { createServiceOutbox } = require('openvibe-sdk/events');
+
+const TABLE = 'run_events_outbox'; // migrations/0001_jobs.sql
+const ACTOR = { type: 'service', id: 'run' };
+const EVENT_TYPES = ['run.job.queued', 'run.job.started', 'run.job.succeeded', 'run.job.failed', 'run.job.cancelled', 'run.job.expired'];
+
+function createRunOutbox({ db, config, fetchImpl, now, log = console }) {
+ return createServiceOutbox({
+ db,
+ source: 'run',
+ table: TABLE,
+ eventsUrl: config.events.url,
+ networkInternalUrl: config.network.internalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ intervalMs: config.events.intervalMs,
+ log,
+ eventTypes: EVENT_TYPES,
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
+ ...(now ? { now } : {}),
+ });
+}
+
+module.exports = { createRunOutbox, TABLE, ACTOR, EVENT_TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+
+/**
+ * OpenVibe.Run — process entry. `node server/index.js`
+ * Listens on PORT (4920) behind nginx (openvibe.run); see deploy/.
+ *
+ * Boot (ADR-035): apply migrations/ with the owner role (DATABASE_DIRECT_URL, one direct connection,
+ * closed afterwards), then serve on the pooled runtime role (DATABASE_URL). Several processes may start
+ * together: the migration run is serialised by an advisory lock. Nothing starts at module load — the key
+ * refresher, the stream key, the events outbox relay and the dispatcher poller all start here, and the
+ * signal handlers stop them in order.
+ *
+ * Background jobs (RUN_JOBS=off disables them): the events outbox relay and the job loop. The job loop
+ * calls the dispatcher's poll() and sweep() — a no-op until the dispatcher bridge to Bot exists (plan T14
+ * step 6, server/dispatch/index.js) — so today every job stays queued and no node ever sees one.
+ */
+const { loadConfig } = require('./config');
+const { openDb, migrate } = require('./db');
+const { createApp } = require('./app');
+const { createValkey } = require('openvibe-sdk/valkey');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { createRegistry } = require('openvibe-shared/metrics');
+
+async function main() {
+ const config = loadConfig();
+ const registry = createRegistry();
+ const db = openDb(config, { registry });
+ const m = await migrate(config, { serving: db });
+ if (m.held.length) console.warn(`[Run] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);
+ const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
+
+ const app = createApp({ config, db, valkey, registry });
+ const { keys, outbox, stream, tickets, dispatch } = app.locals;
+ keys.start();
+ const ticketKey = tickets.start();
+
+ const timers = [];
+ if (config.jobs.enabled) {
+ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Run] job:', e.message)); }, ms); t.unref(); timers.push(t); };
+ // The dispatcher bridge is a no-op until plan T14 step 6: poll() and sweep() answer [] and nothing
+ // changes, but the loop is wired the way the real bridge will run it.
+ every(config.dispatch.intervalMs, async () => {
+ const answers = await dispatch.poll();
+ if (answers.length) console.warn(`[Run] ${answers.length} dispatcher answer(s) ignored: ${dispatch.note}`);
+ });
+ every(config.jobs.intervalMs, () => dispatch.sweep());
+ outbox.start();
+ dispatch.start();
+ }
+
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[Run] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: this process only'})`);
+ console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `→ ${config.dispatch.botUrl}` : 'no-op until plan T14 step 6 (jobs stay queued)'}`);
+ });
+ server.keepAliveTimeout = 65_000;
+
+ // systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the
+ // stop steps in order (nothing new starts), drains the HTTP server — event streams are destroyed, so a
+ // client reconnects — then the close steps and exits. Run's manifest declares no lifecycle.shutdown
+ // deadline, so the kit's 5000 ms default is the right value.
+ const { stop: shutdown } = gracefulStop({
+ name: 'Run',
+ server,
+ drainMs: 4000,
+ deadlineMs: 5000,
+ deadlineExitCode: 0,
+ stop: [
+ () => timers.forEach(clearInterval),
+ () => keys.stop(),
+ () => tickets.stop(),
+ () => dispatch.stop(),
+ () => stream.close(),
+ () => outbox.stop(),
+ ],
+ close: [
+ () => db.close().catch(() => {}),
+ () => valkey && valkey.close().catch(() => {}),
+ ],
+ });
+ return { app, server, shutdown };
+}
+
+if (require.main === module) {
+ main().catch((e) => {
+ console.error(`[Run] could not start: ${e.message}`);
+ process.exit(1);
+ });
+}
+
+module.exports = { main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+
+/**
+ * run.job.* events (contracts/events/payloads/run.job.*@1; migrations/0001_jobs.sql, run_events_outbox).
+ *
+ * Every emitter takes the caller's transaction handle t and the row the change produced, so the event is
+ * written in the same transaction as the state change: an event exists if and only if its change
+ * committed. The outbox row's event_id is `run:<job id>:<state>` — one row per (job, state), so a replay
+ * (a retry, a double cancel) is the row that is already there and never a second event:
+ *
+ * run.job.queued state queued event_id run:<id>:queued
+ * run.job.started state running event_id run:<id>:running
+ * run.job.succeeded state succeeded event_id run:<id>:succeeded
+ * run.job.failed state failed event_id run:<id>:failed
+ * run.job.cancelled state cancelled event_id run:<id>:cancelled
+ * run.job.expired state expired event_id run:<id>:expired
+ *
+ * The payload is the run.job-read-result@1 projection the payload contract describes — job_id,
+ * project_id, requester, state, class, artifact, egress, placement, timings, exit, usage, error — never
+ * args, inputs, the result body or the job's stdout, and never a credential.
+ *
+ * The row's key and the envelope's event_id are two different things on purpose. run_events_outbox is
+ * keyed by Run's own `run:<job id>:<state>` (above), while the published envelope carries an `evt_<ULID>`
+ * document id: events.event-envelope@1 (additionalProperties false) pins event_id to
+ * `^evt_[0-9A-HJKMNP-TV-Z]{26}$`, and OpenVibe.Events validates every published envelope against it, so
+ * the dossier's key cannot itself be the envelope's event_id — a row inserted with it would be published
+ * and then refused. The row is written here, in the caller's transaction (the openvibe-sdk relay is the
+ * only thing that moves rows to the bus); `enqueueOnce` returns whether the row is new, so a replay is a
+ * quiet no-op rather than an error.
+ */
+const contracts = require('openvibe-contracts');
+const { toJob } = require('./store');
+const { ACTOR, TABLE } = require('../events/outbox');
+
+const TRACEPARENT_RE = /^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/;
+
+/**
+ * Event name per state, the state its payload carries, and its priority when the payload contract names
+ * one (queued and started are low, failed is important; the other three say "normal", which
+ * events.event-envelope@1 has no value for — the envelope's own default stands).
+ */
+const EVENT_OF_STATE = {
+ queued: { event: 'run.job.queued', state: 'queued', contract: 'run.job.queued@1', priority: 'low' },
+ // The job's state is `running`; its event — and so its payload contract — is run.job.started.
+ running: { event: 'run.job.started', state: 'running', contract: 'run.job.started@1', priority: 'low' },
+ succeeded: { event: 'run.job.succeeded', state: 'succeeded', contract: 'run.job.succeeded@1', priority: null },
+ failed: { event: 'run.job.failed', state: 'failed', contract: 'run.job.failed@1', priority: 'important' },
+ cancelled: { event: 'run.job.cancelled', state: 'cancelled', contract: 'run.job.cancelled@1', priority: null },
+ expired: { event: 'run.job.expired', state: 'expired', contract: 'run.job.expired@1', priority: null },
+};
+
+/** The payload: run.job-read-result@1 without args, inputs, result or output. */
+function payloadOf(row) {
+ const job = toJob(row);
+ return {
+ job_id: job.id,
+ project_id: job.project_id,
+ requester: job.requester,
+ state: job.state,
+ class: job.class,
+ artifact: job.artifact,
+ egress: job.egress,
+ placement: job.placement,
+ timings: job.timings,
+ exit: job.exit,
+ usage: job.usage,
+ error: job.error,
+ };
+}
+
+/**
+ * createRunEvents({ outbox, now }) → one emitter per state plus emit(t, row, state). Every emitter
+ * validates the payload against its released contract before it reaches the outbox, so a projection bug
+ * is an error here rather than a malformed event on the bus.
+ */
+function createRunEvents({ outbox, now = () => Date.now(), log = console }) {
+ /**
+ * The outbox row for one (job, state), written in the caller's transaction. The row's key is
+ * `run:<job id>:<state>`; a row that is already there is left alone (ON CONFLICT DO NOTHING), which is
+ * what makes a replay one row. → { envelope, enqueued }.
+ */
+ async function enqueueOnce(t, row, spec, payload, traceparent) {
+ const key = `run:${row.id}:${spec.state}`;
+ const prepared = outbox.events.prepare({
+ event_type: spec.event,
+ version: 1,
+ source: 'run',
+ actor: ACTOR,
+ timestamp: new Date(now()).toISOString(),
+ subject: { type: 'job', id: row.id },
+ visibility: 'internal',
+ ...(spec.priority ? { priority: spec.priority } : {}),
+ payload,
+ }, { now: now() });
+ contracts.assertValid('events.event-envelope@1', prepared);
+ const trace = traceparent && TRACEPARENT_RE.test(traceparent) ? traceparent : null;
+ const inserted = await t.maybe(
+ `INSERT INTO ${TABLE} (event_id, envelope, traceparent, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (event_id) DO NOTHING RETURNING id`,
+ [key, JSON.stringify(prepared), trace, now()]);
+ if (inserted && log && log.debug) log.debug(`[Run] ${spec.event} ${row.id}`);
+ return { envelope: prepared, enqueued: Boolean(inserted) };
+ }
+
+ async function emit(t, row, state, { traceparent } = {}) {
+ const spec = EVENT_OF_STATE[state];
+ if (!spec) throw new Error(`run.job.*: ${state} is not a state Run emits (${Object.keys(EVENT_OF_STATE).join(', ')})`);
+ const payload = payloadOf(row);
+ // The payload contract is the event's own (run.job.started@1 for the running state: a state and
+ // its event name are not always the same word, and run.job.running@1 does not exist).
+ contracts.assertValid(spec.contract, payload);
+ return enqueueOnce(t, row, spec, payload, traceparent);
+ }
+
+ return {
+ emit,
+ queued: (t, row, opts) => emit(t, row, 'queued', opts),
+ started: (t, row, opts) => emit(t, row, 'running', opts),
+ succeeded: (t, row, opts) => emit(t, row, 'succeeded', opts),
+ failed: (t, row, opts) => emit(t, row, 'failed', opts),
+ cancelled: (t, row, opts) => emit(t, row, 'cancelled', opts),
+ expired: (t, row, opts) => emit(t, row, 'expired', opts),
+ /** The emitter for a row that just reached its state (used by the dispatcher bridge, step 6). */
+ ofState: (t, row, opts) => emit(t, row, row.state, opts),
+ };
+}
+
+module.exports = { createRunEvents, payloadOf, EVENT_OF_STATE, TRACEPARENT_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,309 @@
@@ -0,0 +1,309 @@
+'use strict';
+
+/**
+ * run_jobs (migrations/0001_jobs.sql): Run's own record of a job — the API, the placement and the project
+ * scope (plan T14 R1). Bot owns the node link and the per-second metering, and Run never holds a Billing
+ * token.
+ *
+ * queued stored, waiting for a node
+ * placed sent to a node as platform.job@1, not yet started
+ * running the node sent job_started
+ * succeeded / failed / cancelled / expired one end state, from job_exit / the API's cancel / the ttl
+ *
+ * The `job` jsonb is the job record: the platform.job@1 body Run sends to the node (id, class, args,
+ * ttl_ms, limits, net, inputs) plus the fields platform.job@1 has no place for and 0001_jobs.sql has no
+ * column for — `requirements` (platform.workload-requirements@1, required by run.job-read-result@1 and by
+ * placement), `placed_at` (timings.placed_at) and `cancel_requested` (a cancel of a placed or running job,
+ * which the dispatcher bridge turns into a job_cancel in plan T14 step 6). server/dispatch/index.js
+ * assembles the frame from the platform.job@1 keys alone and validates it before anything is sent.
+ *
+ * The store is row access only: every state change happens inside the caller's transaction, so the
+ * run.job.* event (server/jobs/events.js) exists if and only if the change committed.
+ */
+const contracts = require('openvibe-contracts');
+const { fail, iso, readCursor, cursorOf, requirementsDefault, newJobId } = require('../util');
+
+// Run's own error code per platform.job-frame@1 job_exit reason (run.job-read-result@1 $defs.error).
+const ERROR_OF_EXIT = {
+ limit: 'run.job.limit',
+ stopped: 'run.job.stopped',
+ failed: 'run.job.worker_failed',
+ ttl: 'run.job.ttl',
+ exited: 'run.job.exit_nonzero',
+ cancelled: null,
+};
+const END_STATES = ['succeeded', 'failed', 'cancelled', 'expired'];
+const isEndState = (state) => END_STATES.includes(state);
+
+// The keys of platform.job@1 — the frame body of the job, and nothing Run keeps beside it.
+const FRAME_KEYS = ['id', 'class', 'artifact', 'args', 'ttl_ms', 'limits', 'net', 'inputs'];
+
+/** The platform.job@1 body a node is sent: the job record's own platform keys (build with this, not a spread). */
+function frameOf(record) {
+ const out = {};
+ for (const k of FRAME_KEYS) if (record && record[k] !== undefined) out[k] = record[k];
+ return out;
+}
+
+const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
+
+/** Canonical JSON (keys sorted): two requests that differ only in key order are the same request. */
+function canonical(v) {
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
+ return JSON.stringify(v === undefined ? null : v);
+}
+
+/** The comparable part of a request / a stored row: an idempotent repeat is judged on exactly this. */
+function recordOf(row) {
+ const job = obj(row.job);
+ return {
+ class: row.class,
+ artifact: job.artifact ?? null,
+ args: job.args ?? {},
+ inputs: job.inputs ?? [],
+ limits: job.limits,
+ ttl_ms: Number(row.ttl_ms),
+ egress: job.net ?? 'none',
+ requirements: job.requirements ?? requirementsDefault(row.class),
+ };
+}
+
+/**
+ * Normalize a validated run.job-create-request@1 body into the row to store: defaults filled (args {},
+ * inputs [], egress none, requirements the contract's default, ttl_ms limits.wall_ms + 600000), the id
+ * minted by Run (job_<ULID>; Run owns run.job.* and passes it to Bot unchanged).
+ */
+function normalize(body, { projectId, requester, idempotencyKey, now }) {
+ const job = {
+ id: newJobId(now),
+ class: body.class,
+ ...(body.artifact ? { artifact: body.artifact } : {}),
+ args: body.args || {},
+ ttl_ms: body.ttl_ms != null ? body.ttl_ms : body.limits.wall_ms + 600_000, // the contract's default
+ limits: body.limits,
+ net: body.egress || 'none',
+ inputs: body.inputs || [],
+ requirements: body.requirements || requirementsDefault(body.class),
+ };
+ // The platform.job@1 body the node will be sent (frameOf drops requirements — platform.job@1 has no
+ // field for it), validated here once so a node can never be handed something off-contract.
+ contracts.assertValid('platform.job@1', frameOf(job));
+ return {
+ id: job.id,
+ project_id: projectId,
+ requester,
+ idempotency_key: idempotencyKey,
+ class: job.class,
+ job,
+ ttl_ms: job.ttl_ms,
+ state: 'queued',
+ created_at: now,
+ updated_at: now,
+ wall_ms: 0,
+ usage_read: 0,
+ };
+}
+
+/** Insert a new job. The same (project_id, idempotency_key) already there returns that row, created:false. */
+async function put(db, row) {
+ const inserted = await db.maybe(
+ `INSERT INTO run_jobs (id, project_id, requester, idempotency_key, class, job, ttl_ms, state, created_at, updated_at, wall_ms, usage_read)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ ON CONFLICT (project_id, idempotency_key) DO NOTHING
+ RETURNING *`,
+ [row.id, row.project_id, row.requester, row.idempotency_key, row.class, JSON.stringify(row.job), row.ttl_ms, row.state, row.created_at, row.updated_at, row.wall_ms, row.usage_read]);
+ if (inserted) return { row: inserted, created: true };
+ const existing = await db.maybe('SELECT * FROM run_jobs WHERE project_id = $1 AND idempotency_key = $2', [row.project_id, row.idempotency_key]);
+ if (!existing) throw new Error('run_jobs: the insert conflicted but no row is visible'); // only a crashed transaction does this
+ return { row: existing, created: false };
+}
+
+/**
+ * Create a job from a validated run.job-create-request@1 body (plan T14 R1: the dispatcher bridge mints
+ * the job id in Run, not in Bot). A repeat of the same idempotency_key with the same body returns the
+ * first job (created:false); with a different body it is 409 run.idempotency.conflict.
+ *
+ * The unique index is permanent: a key is spent for its project for good, where run.job-create-request@1
+ * promises the first-job answer "within 24 h". The 24 h window cannot be implemented on this table (no
+ * migration may add a column or relax the index), and answering a repeat with the first job forever is the
+ * safe direction — it can never run the work twice — so a key older than 24 h answers the same way rather
+ * than being recycled. A caller that reuses a key for different work is refused, not silently served.
+ */
+async function mint(db, body, { projectId, requester, idempotencyKey, now }) {
+ const row = normalize(body, { projectId, requester, idempotencyKey, now });
+ const out = await put(db, row);
+ if (out.created) return out;
+ if (canonical(recordOf(out.row)) !== canonical(recordOf(row))) {
+ fail(409, 'run.idempotency.conflict', `idempotency_key ${idempotencyKey} was first used by ${out.row.id} with a different body`);
+ }
+ return out;
+}
+
+/** Project-scoped get: null when the job does not exist or belongs to another project (→ 404, never 403). */
+const get = (db, id, projectId = null) => db.maybe(`SELECT * FROM run_jobs WHERE id = $1${projectId ? ' AND project_id = $2' : ''}`, projectId ? [id, projectId] : [id]);
+
+/** One page, newest first, keyset cursor (created_at, id) → { rows, nextCursor }. */
+async function page(db, where, values, limit, cursor) {
+ const conds = [...where];
+ const params = [...values];
+ if (cursor) {
+ params.push(cursor.at, cursor.id);
+ conds.push(`(created_at < $${params.length - 1} OR (created_at = $${params.length - 1} AND id < $${params.length}))`);
+ }
+ params.push(limit + 1);
+ const rows = await db.many(`SELECT * FROM run_jobs${conds.length ? ` WHERE ${conds.join(' AND ')}` : ''} ORDER BY created_at DESC, id DESC LIMIT $${params.length}`, params);
+ const more = rows.length > limit;
+ const out = more ? rows.slice(0, limit) : rows;
+ return { rows: out, nextCursor: more && out.length ? cursorOf(out[out.length - 1]) : null };
+}
+
+/** The token project's jobs: GET /api/v1/jobs (run.job.list). */
+function list(db, projectId, { state = null, limit = 50, cursor = null } = {}) {
+ const where = ['project_id = $1'];
+ const values = [projectId];
+ if (state) { values.push(state); where.push(`state = $${values.length}`); }
+ return page(db, where, values, limit, cursor ? readCursor(cursor) : null);
+}
+
+/** Every project's jobs: GET /api/v1/admin/jobs (run.job.admin); project_id narrows it. */
+function adminList(db, { project_id: projectId = null, state = null, limit = 50, cursor = null } = {}) {
+ const where = [];
+ const values = [];
+ if (projectId) { values.push(projectId); where.push(`project_id = $${values.length}`); }
+ if (state) { values.push(state); where.push(`state = $${values.length}`); }
+ return page(db, where, values, limit, cursor ? readCursor(cursor) : null);
+}
+
+/**
+ * Cancel one job (POST /api/v1/jobs/:id/cancel, run.job.cancel). A queued or placed job never starts:
+ * cancelled now. A running one is marked cancel_requested and stays running until its job_exit arrives
+ * (the dispatcher bridge sends job_cancel in plan T14 step 6). An ended job changes nothing.
+ * → { row, cancelled, cancelRequested } — cancelled means the row moved to 'cancelled' now.
+ */
+async function cancel(db, id, projectId, at) {
+ const moved = await db.maybe(
+ `UPDATE run_jobs SET state = 'cancelled', updated_at = $3, finished_ms = $3
+ WHERE id = $1 AND project_id = $2 AND state IN ('queued', 'placed') RETURNING *`,
+ [id, projectId, at]);
+ if (moved) return { row: moved, cancelled: true, cancelRequested: false };
+ const asked = await db.maybe(
+ `UPDATE run_jobs SET job = jsonb_set(job, '{cancel_requested}', 'true'::jsonb), updated_at = $3
+ WHERE id = $1 AND project_id = $2 AND state = 'running' AND job->>'cancel_requested' IS DISTINCT FROM 'true' RETURNING *`,
+ [id, projectId, at]);
+ if (asked) return { row: asked, cancelled: false, cancelRequested: true };
+ return { row: await get(db, id, projectId), cancelled: false, cancelRequested: false };
+}
+
+/**
+ * Copy what Bot answers for a job (plan T14 step 6, R1c): state, placement, timings, exit, result and the
+ * metered wall clock. Run keeps its own job record otherwise untouched; the first placement stamps
+ * placed_at, which run.job-read-result@1 requires from `placed` on. → the row, or null.
+ */
+async function mirror(db, id, bot = {}, at) {
+ const placed = bot.node_id ? at : null;
+ // Bot's answer moves the row only into a state the row can back: run.job-read-result@1 requires a
+ // placement for placed/running/succeeded and started_at for running, so an answer missing them leaves
+ // the state as it is (the bridge sends the placement with the state it describes).
+ return db.maybe(
+ `UPDATE run_jobs SET
+ state = CASE
+ WHEN $2::text IS NULL THEN state
+ WHEN $2::text IN ('placed', 'running', 'succeeded') AND COALESCE($3, node_id) IS NULL THEN state
+ WHEN $2::text = 'running' AND COALESCE($6, started_ms) IS NULL THEN state
+ ELSE $2::text END,
+ node_id = COALESCE($3, node_id),
+ provider = COALESCE($4, provider),
+ region = COALESCE($5, region),
+ started_ms = COALESCE($6, started_ms),
+ finished_ms = COALESCE($7, finished_ms),
+ exit_reason = COALESCE($8, exit_reason),
+ exit_code = COALESCE($9, exit_code),
+ result = COALESCE($10, result),
+ wall_ms = GREATEST(wall_ms, COALESCE($11, 0)),
+ usage_read = GREATEST(usage_read, COALESCE($12, 0)),
+ job = CASE WHEN $13::bigint IS NULL OR job ? 'placed_at' THEN job
+ ELSE jsonb_set(job, '{placed_at}', to_jsonb($13::bigint)) END,
+ updated_at = $14
+ WHERE id = $1 RETURNING *`,
+ [id, bot.state ?? null, bot.node_id ?? null, bot.provider ?? null, bot.region ?? null, bot.started_ms ?? null,
+ bot.finished_at ?? null, bot.exit_reason ?? null, bot.exit_code ?? null,
+ bot.result === undefined || bot.result === null ? null : JSON.stringify(bot.result),
+ bot.wall_ms ?? null, bot.usage_read ?? null, placed, at]);
+}
+
+/**
+ * Reach an end state (the API's cancel uses cancel(), the ttl sweeper and the dispatcher bridge use this).
+ * The first settlement wins; a second is a no-op (changed:false), so the run.job.* event of a state is
+ * enqueued once. `exit` is the platform.job-frame@1 job_exit reason, `code` its exit status; the error
+ * code is derived from the reason unless the caller names one (run.job.unplaceable, run.job.ttl).
+ * → { row, changed }
+ */
+async function settle(db, id, { state, exit = null, code = null, result = null, wallMs = null, errorCode = null, errorDetail = null, at }) {
+ if (!isEndState(state)) fail(422, 'run.invalid_input', `settle: ${state} is not an end state`);
+ // run.job-read-result@1 requires `error` on failed and expired and null everywhere else, so an error
+ // code is only ever written for those two: a caller asking for one on a cancel or a success is ignored
+ // rather than allowed to store a row this service could no longer render.
+ const failed = state === 'failed' || state === 'expired';
+ const derived = state === 'failed' ? ERROR_OF_EXIT[exit] || 'run.job.worker_failed' : state === 'expired' ? ERROR_OF_EXIT[exit] || 'run.job.unplaceable' : null;
+ const row = await db.maybe(
+ `UPDATE run_jobs SET state = $2, exit_reason = COALESCE($3, exit_reason), exit_code = $4, result = $5,
+ wall_ms = GREATEST(wall_ms, COALESCE($6, 0)), finished_ms = $7, updated_at = $7,
+ error_code = CASE WHEN $8::boolean THEN COALESCE($9, $10) ELSE NULL END,
+ error_detail = CASE WHEN $8::boolean THEN $11 ELSE NULL END
+ WHERE id = $1 AND state NOT IN ('succeeded', 'failed', 'cancelled', 'expired')
+ RETURNING *`,
+ [id, state, exit, code, result === null || result === undefined ? null : JSON.stringify(result), wallMs,
+ at, failed, errorCode, derived, errorDetail]);
+ if (row) return { row, changed: true };
+ return { row: await get(db, id), changed: false };
+}
+
+/**
+ * One job as run.job-read-result@1 — exactly the contract's shape: placement null while queued, timings
+ * from the row (and the job record's placed_at), usage.seconds the metered wall clock / 1000, result only
+ * when succeeded, error only when failed or expired.
+ */
+function toJob(row) {
+ const job = obj(row.job);
+ const state = row.state;
+ const wallMs = Number(row.wall_ms || 0);
+ const requester = contracts.ids.parseSubject(String(row.requester));
+ return {
+ id: row.id,
+ project_id: row.project_id,
+ requester,
+ state,
+ class: row.class,
+ artifact: job.artifact ?? null,
+ inputs: job.inputs ?? [],
+ limits: job.limits,
+ ttl_ms: Number(row.ttl_ms),
+ egress: job.net ?? 'none',
+ requirements: job.requirements ?? requirementsDefault(row.class),
+ idempotency_key: row.idempotency_key ?? null,
+ placement: row.node_id ? { node: row.node_id, provider: row.provider ?? null, region: row.region ?? null } : null,
+ timings: {
+ created_at: iso(row.created_at),
+ placed_at: job.placed_at != null ? iso(job.placed_at) : null,
+ started_at: iso(row.started_ms),
+ finished_at: iso(row.finished_ms),
+ },
+ exit: row.exit_reason ? { reason: row.exit_reason, code: row.exit_code == null ? null : Number(row.exit_code) } : null,
+ result: state === 'succeeded' ? (row.result ?? null) : null,
+ usage: { seconds: wallMs / 1000, wall_ms: wallMs, cpu_ms: null, mem_peak_bytes: null },
+ error: row.error_code ? { code: row.error_code, detail: row.error_detail ?? null } : null,
+ };
+}
+
+/** The read result, validated against the contract (a store bug is a 500 here, never a bad body on the wire). */
+function readResult(row) {
+ const job = toJob(row);
+ contracts.assertValid('run.job-read-result@1', job);
+ return job;
+}
+
+module.exports = {
+ // The store surface the API and the dispatcher bridge (plan T14 step 6) use.
+ put, mint, get, list, adminList, cancel, mirror, settle, toJob, readResult, frameOf,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+
+/**
+ * The job stream's fan-out (run.job-stream-event@1; plan T14 R1). One ring buffer per open job plus the
+ * subscribers on it:
+ *
+ * state(jobId, state) the job moved to a state; an end state is followed by one `end` event
+ * output(jobId, chunk) a chunk of the job's stdout (platform.job-frame@1 job_stdout, step 6) — best
+ * effort: a chunk lost on the node's link is never resent
+ * seed(row) a stream opened on a job this process holds no ring for: one `state` event from
+ * the row (and `end` when the job already ended), so a reconnect or a restart
+ * never re-runs anything and still says where the job is
+ *
+ * The sequence is the job's own, from 1, monotonic, and it is the SSE `id`: a reconnect with Last-Event-ID
+ * resumes after it. At most the last RUN_STREAM_RING_BYTES (1 MiB, the contract's replay window) is kept;
+ * an event that fell out of the ring is simply not replayed (output is never resent anyway).
+ *
+ * A ring is memory, so it is reclaimed: `prune()` drops every ring that has no subscriber and either ended
+ * or has been idle for IDLE_MS. Nothing is lost by dropping one — `seed(row)` rebuilds it from the job's own
+ * row, so a stream opened later replays the state the job is in (and its `end`, when it ended). It also means
+ * a seq is this process's (a restart or a second worker starts the job's stream counter again): a client
+ * that resumes with an id from another process's ring gets the current state rather than a gap, and output
+ * is best effort anyway. The dispatcher bridge (plan T14 step 6) is what would make replay durable.
+ *
+ * Nothing here touches the database or the network: the API and the dispatcher bridge are its only callers.
+ */
+const JOB_EVENT_TYPES = ['output', 'state', 'end'];
+/** A ring with no subscriber and no traffic for this long is dropped (a reconnect re-seeds from the row). */
+const IDLE_MS = 10 * 60 * 1000;
+
+function createJobStream({ config, now = () => Date.now(), log = console }) {
+ const ringBytes = config.stream.ringBytes;
+ const maxPerJob = config.stream.maxPerJob;
+ const jobs = new Map(); // jobId → { seq, events: [], bytes, ended, state, touched, subscribers: Set }
+
+ function ringOf(jobId) {
+ let ring = jobs.get(jobId);
+ if (!ring) { ring = { seq: 0, events: [], bytes: 0, ended: false, state: null, touched: now(), subscribers: new Set() }; jobs.set(jobId, ring); }
+ return ring;
+ }
+
+ /** Drop what nothing is watching: an ended ring, or one that has said nothing for IDLE_MS. */
+ function prune() {
+ const t = now();
+ for (const [jobId, ring] of jobs) {
+ if (ring.subscribers.size) continue;
+ if (ring.ended || t - ring.touched > IDLE_MS) jobs.delete(jobId);
+ }
+ }
+
+ /** Append one event to a job's ring and hand it to every subscriber. → the event, or null when the job already ended. */
+ function emit(jobId, event, at = now()) {
+ if (!JOB_EVENT_TYPES.includes(event.type)) throw new Error(`run.job-stream-event@1: ${event.type} is not an event type`);
+ const ring = ringOf(jobId);
+ if (ring.ended) return null; // an end state is final: nothing follows it
+ const out = { ...event, job_id: jobId, seq: ++ring.seq, at: new Date(at).toISOString() };
+ const size = JSON.stringify(out).length + 1;
+ ring.events.push(out);
+ ring.bytes += size;
+ while (ring.bytes > ringBytes && ring.events.length > 1) ring.bytes -= JSON.stringify(ring.events.shift()).length + 1;
+ if (out.type === 'state') ring.state = out.state;
+ if (out.type === 'end') ring.ended = true;
+ ring.touched = now();
+ for (const sub of ring.subscribers) { try { sub(out); } catch (e) { log.warn(`[Run] stream subscriber failed: ${e && e.message}`); } }
+ // The ring stays where it is: a subscriber may be replaying it, and a stream opened right after this
+ // (a ticket minted while the job ended) must find it. prune() reclaims it once nothing watches it.
+ return out;
+ }
+
+ /** The job moved to `state`; an end state also closes the stream with `end`. → the events appended. */
+ function state(jobId, state, at = now()) {
+ const first = emit(jobId, { type: 'state', state }, at);
+ if (!first) return [];
+ if (state === 'succeeded' || state === 'failed' || state === 'cancelled' || state === 'expired') return [first, emit(jobId, { type: 'end', state }, at)].filter(Boolean);
+ return [first];
+ }
+
+ const output = (jobId, chunk, at) => emit(jobId, { type: 'output', chunk: String(chunk).slice(0, 65536) }, at);
+
+ /**
+ * A stream opened on a job this process holds no ring for (a restart, another process created the job,
+ * or the ring was pruned): one `state` event from the row, and the closing `end` when the job already
+ * ended. A ring that has anything to say is left alone, so seeding never rewinds a live stream's seq.
+ */
+ function seed(row) {
+ prune();
+ const ring = ringOf(row.id);
+ if (ring.events.length || ring.seq || ring.subscribers.size) return ring;
+ state(row.id, row.state, Number(row.updated_at || row.created_at || now()));
+ return ringOf(row.id);
+ }
+
+ /**
+ * Replay what is kept from after lastEventId, then follow. → { ended, unsubscribe }. `ended` is true
+ * when the job's stream already closed: the caller writes the replay and ends the response.
+ */
+ function subscribe(jobId, { lastEventId = 0, onEvent }) {
+ // The ring is taken before anything is pruned: the ring we are about to replay (an ended job's, kept
+ // for exactly this) must not be reclaimed under us. The prune below runs with the subscriber in
+ // place, so nothing that is being followed can be dropped.
+ const ring = ringOf(jobId);
+ ring.touched = now();
+ const sub = (e) => { try { onEvent(e); } catch (err) { log.warn(`[Run] stream subscriber failed: ${err && err.message}`); } };
+ for (const e of ring.events) if (e.seq > lastEventId) sub(e);
+ // An ended job: the replay above was the whole answer (the caller closes the response).
+ if (ring.ended) return { ended: true, unsubscribe() {} };
+ ring.subscribers.add(sub);
+ prune();
+ return { ended: false, unsubscribe: () => { ring.subscribers.delete(sub); ring.touched = now(); } };
+ }
+
+ const canAttach = (jobId) => ringOf(jobId).subscribers.size < maxPerJob;
+ const status = () => ({ jobs: jobs.size, streams: [...jobs.values()].reduce((n, r) => n + r.subscribers.size, 0), events: [...jobs.values()].reduce((n, r) => n + r.events.length, 0) });
+
+ /** Shutdown: every ring goes, so a stream in flight ends and its client reconnects (with a fresh ticket). */
+ function close() {
+ for (const [jobId, ring] of jobs) {
+ ring.ended = true;
+ ring.subscribers.clear();
+ jobs.delete(jobId);
+ }
+ }
+
+ return { state, output, seed, subscribe, canAttach, status, close, emit, prune, maxPerJob, ringOf };
+}
+
+module.exports = { createJobStream, JOB_EVENT_TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+
+/**
+ * The job stream ticket (run.job-stream-ticket-result@1; plan T14 R1).
+ *
+ * A browser cannot put a header on an EventSource, so it asks POST /api/v1/jobs/:id/stream/ticket
+ * (capability run.job.stream) for a ticket and opens `${stream_url}?ticket=…` at once, resuming with
+ * Last-Event-ID. The ticket is a two-minute RS256 compact JWS Run signs with a key of its own:
+ *
+ * iss <BASE_URL> aud openvibe.run typ run-stream purpose run-stream
+ * sub <job id> project <project id> exp now + 120 s jti rsk_<24 hex>
+ *
+ * It is never a session anywhere: besides the signature it carries the audience openvibe.run AND the
+ * typ/purpose claims, so a receiver that checks either refuses it, and Run itself refuses it on any route
+ * but GET /api/v1/jobs/:id/stream. A ticket opens one stream (the jti is remembered while the ticket is
+ * valid) and is never logged or stored.
+ *
+ * The key is Run's own, never Network's signing key: RUN_STREAM_PRIVATE_KEY (a PEM in the environment) or
+ * the file RUN_STREAM_KEY_FILE (data/keys/run-stream.pem by default). In production, with neither set,
+ * stream tickets answer 503 and every other route still serves — Run's jobs API never depends on it. In
+ * development (and in tests) a key is generated in memory at boot.
+ *
+ * The used-jti set lives in this process's memory, as OpenVibe.Events' realtime tickets do: it covers the
+ * ticket's own two minutes, a restart forgets it (and a ticket that old has expired), and Run runs one
+ * process per host today. A second worker would need the set shared (Valkey) to keep "one ticket, one
+ * stream" across processes — worth doing when Run is deployed behind more than one.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const JOB_RE = /^job_[0-9A-HJKMNP-TV-Z]{26}$/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TYP = 'run-stream';
+const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
+const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
+const decode = (part) => { try { return JSON.parse(fromB64url(part).toString('utf8')); } catch { return null; } };
+
+function createStreamTickets({ config, log = console, now = () => Date.now() }) {
+ const ttlS = config.stream.ttlS;
+ let privateKey = null;
+ let ephemeral = false;
+ // jti → exp (seconds). A ticket opens one stream; the map is pruned on every verification.
+ const seen = new Map();
+
+ /** Read Run's stream key (never Network's). Production with no key: null, and tickets answer 503. */
+ function load() {
+ if (config.stream.privateKey) {
+ try { privateKey = crypto.createPrivateKey(config.stream.privateKey); return privateKey; }
+ catch (e) { log.error(`[Run] RUN_STREAM_PRIVATE_KEY is not a usable private key: ${e.message}`); return null; }
+ }
+ const file = config.stream.keyFile && path.resolve(process.cwd(), config.stream.keyFile);
+ if (file && fs.existsSync(file)) {
+ try { privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8')); return privateKey; }
+ catch (e) { log.error(`[Run] ${file} is not a usable private key: ${e.message}`); return null; }
+ }
+ if (config.isProduction) return null;
+ privateKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
+ ephemeral = true;
+ log.warn(`[Run] no RUN_STREAM_PRIVATE_KEY or ${config.stream.keyFile}: an ephemeral stream key is in memory (development only; every ticket stops verifying at a restart)`);
+ return privateKey;
+ }
+
+ const get = () => privateKey;
+ const status = () => ({ enabled: Boolean(privateKey), key_id: config.stream.keyId, ttl_s: ttlS, ephemeral });
+
+ /**
+ * Mint a ticket for one job. → { ticket, claims } (never logged).
+ */
+ function mint({ jobId, projectId }) {
+ if (!privateKey) throw new Error('stream tickets are disabled: no stream key (RUN_STREAM_PRIVATE_KEY or RUN_STREAM_KEY_FILE)');
+ if (!JOB_RE.test(String(jobId))) throw new TypeError(`stream ticket: ${jobId} is not a job id`);
+ if (!PROJECT_RE.test(String(projectId))) throw new TypeError(`stream ticket: ${projectId} is not a project id`);
+ const iat = Math.floor(now() / 1000);
+ const claims = {
+ iss: config.baseUrl, sub: jobId, aud: config.audience, typ: TYP, purpose: TYP,
+ project: projectId, job_id: jobId, iat, exp: iat + ttlS, jti: `rsk_${crypto.randomBytes(12).toString('hex')}`,
+ };
+ const header = { alg: 'RS256', typ: 'JWT', kid: config.stream.keyId };
+ const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
+ const signature = b64url(crypto.sign('RSA-SHA256', Buffer.from(input), privateKey));
+ return { ticket: `${input}.${signature}`, claims };
+ }
+
+ function prune(t = Math.floor(now() / 1000)) {
+ for (const [jti, exp] of seen) if (exp < t) seen.delete(jti);
+ }
+
+ /**
+ * Verify a ticket: signature, header, audience, typ, expiry — and, with consume (the default), that its
+ * jti was never used. → { ok: true, claims } or { ok: false, code, reason } (problem codes ticket.*).
+ */
+ function verify(token, { consume = true } = {}) {
+ const bad = (code, reason) => ({ ok: false, code, reason });
+ if (!privateKey) return bad('ticket.unavailable', 'stream tickets are disabled: no stream key is configured');
+ if (typeof token !== 'string' || !token.length || token.length > 4096) return bad('ticket.invalid', 'not a ticket');
+ const parts = token.split('.');
+ if (parts.length !== 3) return bad('ticket.invalid', 'not a ticket');
+ const header = decode(parts[0]);
+ const claims = decode(parts[1]);
+ if (!header || !claims || header.alg !== 'RS256') return bad('ticket.invalid', 'not a ticket');
+ if (config.stream.keyId && header.kid !== config.stream.keyId) return bad('ticket.invalid', 'not a ticket (key)');
+ let good = false;
+ try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), crypto.createPublicKey(privateKey), fromB64url(parts[2])); } catch { good = false; }
+ if (!good) return bad('ticket.invalid', 'the ticket signature does not verify');
+ if (claims.typ !== TYP || claims.purpose !== TYP) return bad('ticket.invalid', 'not a stream ticket');
+ if (claims.aud !== config.audience) return bad('ticket.invalid', `not for ${config.audience}`);
+ if (!JOB_RE.test(String(claims.sub)) || claims.job_id !== claims.sub || !PROJECT_RE.test(String(claims.project))) return bad('ticket.invalid', 'the ticket names no job');
+ const t = Math.floor(now() / 1000);
+ // Run mints and verifies its own tickets, so exp is a hard deadline (no receiver-side skew): a
+ // ticket that is even a second past two minutes is refused. Only a clock that jumped backwards
+ // gets a small allowance on iat.
+ if (typeof claims.exp !== 'number' || claims.exp < t) return bad('ticket.expired', 'the ticket expired; ask for a new one');
+ if (typeof claims.iat === 'number' && claims.iat - 5 > t) return bad('ticket.invalid', 'the ticket is not valid yet');
+ if (typeof claims.jti !== 'string' || !claims.jti) return bad('ticket.invalid', 'the ticket has no jti');
+ prune(t);
+ if (seen.has(claims.jti)) return bad('ticket.used', 'this ticket already opened a stream; ask for a new one');
+ if (consume) seen.set(claims.jti, claims.exp);
+ return { ok: true, claims };
+ }
+
+ /** Load the key at boot (server/index.js); a second call is a no-op. */
+ function start() { if (!privateKey) load(); return status(); }
+ function stop() { seen.clear(); }
+
+ return { start, stop, load, get, status, mint, verify, ttlS };
+}
+
+module.exports = { createStreamTickets, TYP, JOB_RE, PROJECT_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+
+/**
+ * Run's side of OpenVibe.Network: the signing key every token is verified against, and offline
+ * verification of a person's user token.
+ *
+ * Run calls no Network route: it verifies service tokens (audience openvibe.run) with the JWKS key and
+ * accepts a Network user token as a Bearer exactly as Bot does. Only a token that names a project
+ * (an app, agent or service principal with project_id) can act on jobs — every route says so.
+ */
+const crypto = require('crypto');
+const { verifyUserToken } = require('openvibe-sdk/auth');
+
+/** Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins it, else the JWKS at boot. */
+function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
+ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
+ let timer = null;
+ async function load() {
+ const url = `${config.network.internalUrl}/api/.well-known/jwks`;
+ try {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`JWKS ${res.status}`);
+ const body = await res.json();
+ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');
+ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
+ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
+ else throw new Error('JWKS contained no keys');
+ return pem;
+ } catch (e) {
+ log.warn(`[Run] Network key not loaded from ${url}: ${e.message}`);
+ return null;
+ }
+ }
+ function start() {
+ if (config.network.publicKey || timer) return;
+ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
+ retry();
+ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);
+ timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+ return { get: () => pem, load, start, stop };
+}
+
+/**
+ * Offline verification of a Network user token (openvibe-sdk/auth: RS256, issuer, expiry, and a typed
+ * token — a realtime or stream ticket — is never a session). Service principals are not user tokens.
+ * Returns the claims, or null.
+ */
+function createUserAuth(config, keys) {
+ async function verify(token) {
+ if (!token) return null;
+ const publicKey = keys.get();
+ if (!publicKey) return null;
+ try {
+ const claims = await verifyUserToken(token, { publicKey, issuer: config.network.issuer });
+ return claims && typeof claims === 'object' ? claims : null;
+ } catch { return null; }
+ }
+ return { verify };
+}
+
+module.exports = { createKeyProvider, createUserAuth };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * Truthful readiness (GET /api/ready) and the Run gauges (GET /metrics, loopback only).
+ *
+ * db required a real round trip through the pool (db.ready(): the store that answered)
+ * valkey optional a real PING; without it nothing is shared between processes
+ * network_jwks optional the Network signing key has loaded; without it no token can be verified
+ * events optional OpenVibe.Events answers /api/health; without it run.job.* events wait in the outbox
+ * bot optional OpenVibe.Bot answers /api/health; skipped until the dispatcher bridge exists
+ *
+ * Gauges: jobs queued, placed or running, ended, and the outbox backlog. Counts only, never subjects, and
+ * never a job's payload; a scrape whose read failed leaves them out.
+ */
+const { sql } = require('openvibe-sdk/db');
+const { createReadiness, skip } = require('openvibe-shared/ready');
+const { TABLE: OUTBOX } = require('./events/outbox');
+
+const PING_TTL_MS = 15_000;
+
+function probe(url, fetchImpl) {
+ return async () => {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
+ try { await res.body?.cancel(); } catch { /* not needed */ }
+ return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}`, detail: { http_status: res.status } };
+ };
+}
+
+function createRunReadiness({ db, valkey = null, keys, config, outbox, stream = null, dispatch = null, release = null, fetchImpl = globalThis.fetch }) {
+ const events = outbox.enabled
+ ? probe(`${config.events.url}/api/health`, fetchImpl)
+ : () => skip('events relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset): run.job.* events wait in the outbox');
+ const bot = dispatch && dispatch.enabled
+ ? probe(`${config.dispatch.botUrl}/api/health`, fetchImpl)
+ : () => skip('the dispatcher bridge to Bot is not built yet (plan T14 step 6): jobs stay queued');
+ return createReadiness({
+ service: 'run',
+ release,
+ checks: [
+ { name: 'db', required: true, check: () => db.ready() },
+ { name: 'valkey', required: false, check: () => (valkey ? valkey.ready() : skip('VALKEY_URL unset: nothing is shared between processes (one process only)')) },
+ { name: 'network_jwks', required: false, check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified') },
+ { name: 'events', required: false, cacheMs: outbox.enabled ? PING_TTL_MS : 0, timeoutMs: 2500, check: events },
+ { name: 'bot', required: false, cacheMs: dispatch && dispatch.enabled ? PING_TTL_MS : 0, timeoutMs: 2500, check: bot },
+ ],
+ details: async (body) => (body.checks.db.status === 'ok'
+ ? { jobs_queued: await queued(db), events_outbox: await outbox.status(), streams: stream ? stream.status().streams : null }
+ : { jobs_queued: null, events_outbox: null, streams: null }),
+ });
+}
+
+async function queued(db) {
+ return Number(await db.value(`SELECT count(*)::int FROM run_jobs WHERE state = 'queued'`) || 0);
+}
+
+/** Run's gauges on the openvibe-shared/metrics registry; refresh() reads them all in one query. */
+function registerRunGauges(registry, { db, stream = null, now = () => Date.now(), log = console }) {
+ let snap = null;
+ const read = (f) => () => (snap ? f(snap) : undefined);
+ registry.gauge({ name: 'run_jobs_queued', help: 'Jobs stored and waiting for a node', collect: read((s) => s.queued) });
+ registry.gauge({ name: 'run_jobs_active', help: 'Jobs placed or running on a node', collect: read((s) => s.active) });
+ registry.gauge({ name: 'run_jobs_ended', help: 'Jobs in an end state', collect: read((s) => s.ended) });
+ registry.gauge({ name: 'run_outbox_pending', help: 'run.job.* events waiting in the outbox', collect: read((s) => s.outbox) });
+ registry.gauge({ name: 'run_streams', help: 'Open job streams on this process', collect: () => (stream ? stream.status().streams : undefined) });
+ return {
+ async refresh() {
+ try {
+ snap = await Promise.race([
+ db.one(sql`SELECT
+ (SELECT count(*) FROM run_jobs WHERE state = 'queued')::int AS queued,
+ (SELECT count(*) FROM run_jobs WHERE state IN ('placed', 'running'))::int AS active,
+ (SELECT count(*) FROM run_jobs WHERE state IN ('succeeded', 'failed', 'cancelled', 'expired'))::int AS ended,
+ (SELECT count(*) FROM ${sql.ident(OUTBOX)} WHERE sent_at IS NULL AND rejected_at IS NULL)::int AS outbox`),
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000).unref()),
+ ]);
+ } catch (e) {
+ snap = null;
+ log.warn(`[Run] gauges not read: ${e.message}`);
+ }
+ },
+ };
+}
+
+module.exports = { createRunReadiness, registerRunGauges };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+
+/** Small shared pieces: time, the error type, input checks, the keyset cursor. */
+const { ids } = require('openvibe-contracts');
+
+class RunError extends Error {
+ constructor(status, code, detail, extra) {
+ super(detail || code);
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ this.extra = extra;
+ }
+}
+
+function fail(status, code, detail, extra) { throw new RunError(status, code, detail, extra); }
+
+const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
+const isJobId = (v) => typeof v === 'string' && /^job_[0-9A-HJKMNP-TV-Z]{26}$/.test(v);
+
+/** A database refusal the caller caused (a value PostgreSQL cannot store) → the RunError to answer with, else null. */
+function inputError(e) {
+ const code = e && (e.code || (e.cause && e.cause.code));
+ return ['22021', '22P05', '22P02', '22007', '22008'].includes(code)
+ ? new RunError(422, 'run.invalid_input', 'the request carries a value that cannot be stored (such as a NUL character)') : null;
+}
+
+/**
+ * The keyset cursor of run_jobs (created_at, id), newest first: base64url JSON [created_at, id] → the pair,
+ * or 422 run.invalid_input. `cursorOf(row)` makes one; a page's next_cursor is the last row of the page.
+ */
+function readCursor(cursor) {
+ let cur = null;
+ try { cur = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8')); } catch { /* refused below */ }
+ if (!Array.isArray(cur) || !Number.isInteger(cur[0]) || typeof cur[1] !== 'string' || !isJobId(cur[1])) {
+ fail(422, 'run.invalid_input', 'bad cursor');
+ }
+ return { at: cur[0], id: cur[1] };
+}
+const cursorOf = (row) => Buffer.from(JSON.stringify([Number(row.created_at), row.id])).toString('base64url');
+
+/**
+ * The platform.workload-requirements@1 default the contract names for an absent requirements
+ * (run.job-create-request@1: "absent means { kind: run.<class>, mobility: job, latency_class: background,
+ * objective: balanced }"); it is validated against the contract wherever it is filled in.
+ */
+const requirementsDefault = (runtimeClass) => ({ kind: `run.${runtimeClass}`, mobility: 'job', latency_class: 'background', objective: 'balanced' });
+
+module.exports = { RunError, fail, iso, isJobId, inputError, readCursor, cursorOf, requirementsDefault, newJobId: () => ids.newId('job') };
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+/**
+ * The skeleton around the job API (plan T14 step 4, R1a and step 5): liveness, truthful readiness, the
+ * release manifest, the loopback-only /metrics, the JSON body limits and the dispatcher seam.
+ *
+ * node test/app.test.js
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+async function main() {
+ const t = await boot();
+ const project = contracts.ids.newId('project');
+ try {
+ await check('liveness: GET /api/health is ok and says what it can back', async () => {
+ const r = await t.call('GET', '/api/health', { token: null });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.ok, true);
+ assert.strictEqual(r.json.service, 'run');
+ assert.strictEqual(r.json.version, require('../package.json').version);
+ assert.strictEqual(r.json.streams, 0);
+ assert.strictEqual(r.json.events.enabled, false); // EVENTS_URL unset in the test
+ assert.strictEqual(r.json.dispatcher.enabled, false); // plan T14 step 6 is not built
+ assert.match(r.json.dispatcher.note, /T14 step 6/);
+ });
+
+ await check('readiness: GET /api/ready reports the database, and honestly skips what is absent', async () => {
+ const r = await t.call('GET', '/api/ready', { token: null });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.ready, true, r.text);
+ assert.strictEqual(r.json.status, 'ready', r.text);
+ assert.strictEqual(r.json.service, 'run');
+ assert.strictEqual(r.json.checks.db.status, 'ok');
+ assert.strictEqual(r.json.checks.db.required, true);
+ assert.deepStrictEqual(r.json.failed, []);
+ assert.deepStrictEqual(r.json.skipped, ['valkey', 'events', 'bot']); // nothing is claimed that is not read
+ assert.strictEqual(r.json.checks.valkey.status, 'skipped'); // VALKEY_URL unset
+ assert.strictEqual(r.json.checks.events.status, 'skipped'); // the relay is off
+ assert.strictEqual(r.json.checks.bot.status, 'skipped');
+ assert.match(r.json.checks.bot.reason, /T14 step 6/);
+ assert.strictEqual(r.json.jobs_queued, 0);
+ assert.strictEqual(r.json.events_outbox.pending, 0);
+ assert.strictEqual(r.json.checks.network_jwks.status, 'ok'); // the stub Network's key loaded
+ });
+
+ await check('GET /release.json serves the manifest for this release', async () => {
+ const r = await t.call('GET', '/release.json', { token: null });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.service, 'run');
+ assert.strictEqual(r.json.contracts_version, require('openvibe-contracts/package.json').version);
+ assert.strictEqual(r.json.packages['openvibe-sdk'], require('openvibe-sdk/package.json').version);
+ assert.ok(r.json.components.server, 'the release names its server component');
+ });
+
+ await check('GET /metrics is for a direct loopback caller, never a forwarded one', async () => {
+ const direct = await t.call('GET', '/metrics', { token: null });
+ assert.strictEqual(direct.status, 200, direct.text);
+ assert.match(direct.headers.get('content-type') || '', /text\/plain/);
+ assert.match(direct.text, /run_jobs_queued/);
+ for (const header of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'forwarded']) {
+ const forwarded = await t.call('GET', '/metrics', { token: null, headers: { [header]: '203.0.113.7' } });
+ assert.strictEqual(forwarded.status, 404, `${header} must not reach /metrics`);
+ }
+ const proxied = await fetch(`${t.base}/metrics`, { headers: { 'X-Forwarded-For': '203.0.113.7' } });
+ assert.strictEqual(proxied.status, 404);
+ });
+
+ await check('a job that stays queued: the dispatcher seam hands nothing to a node', async () => {
+ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), cap: ['run.job.submit'], project, sub: 'svc:builder' });
+ assert.strictEqual(made.status, 201, made.text);
+ assert.strictEqual(made.json.job.state, 'queued');
+ assert.strictEqual(t.app.locals.dispatch.enabled, false);
+ assert.deepStrictEqual(await t.app.locals.dispatch.poll(), []);
+ assert.deepStrictEqual(await t.app.locals.dispatch.sweep(), []);
+ assert.strictEqual((await t.app.locals.dispatch.place({ id: made.json.job.id, class: 'function' })).placed, false);
+ const read = await t.call('GET', `/api/v1/jobs/${made.json.job.id}`, { cap: ['run.job.read'], project, sub: 'svc:builder' });
+ assert.strictEqual(read.json.state, 'queued');
+ assert.strictEqual(read.json.placement, null);
+ });
+
+ await check('a body that is not JSON is 400 problem+json', async () => {
+ const token = t.network.signService({ cap: ['run.job.submit'], project_id: project, sub: 'svc:builder' });
+ const res = await fetch(`${t.base}/api/v1/jobs`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: '{not json' });
+ const text = await res.text();
+ assert.strictEqual(res.status, 400, text);
+ assert.match(res.headers.get('content-type') || '', /application\/problem\+json/);
+ const body = JSON.parse(text);
+ assert.strictEqual(body.code, 'request.malformed_json');
+ assert.ok(body.request_id && body.trace_id); // the problem carries the request context
+ });
+
+ await check('an unknown API route is a problem+json 404, never a page', async () => {
+ const r = await t.call('GET', '/api/v1/nope', { token: null });
+ assert.strictEqual(r.status, 404, r.text);
+ assert.match(r.headers.get('content-type') || '', /application\/problem\+json/);
+ assert.strictEqual(r.json.code, 'not_found');
+ const internal = await t.call('GET', '/internal/events', { token: null });
+ assert.strictEqual(internal.status, 404);
+ const underMetrics = await t.call('GET', '/metrics/nope', { token: null });
+ assert.strictEqual(underMetrics.status, 404);
+ });
+
+ await check('the skeleton sends the security headers an API should', async () => {
+ const r = await t.call('GET', '/api/health', { token: null });
+ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');
+ assert.ok(r.headers.get('x-frame-options'));
+ assert.strictEqual(r.headers.get('x-powered-by'), null);
+ assert.match(r.headers.get('x-openvibe-request-id') || '', /^req_[0-9a-f]{24}$/);
+ });
+ } finally {
+ await t.close();
+ }
+
+ // A second boot, in production, with no stream key: the API still serves, stream tickets answer 503.
+ const strict = await boot({ env: { NODE_ENV: 'production', RUN_STREAM_PRIVATE_KEY: '', RUN_STREAM_KEY_FILE: '' } });
+ try {
+ await check('production with no stream key: the API serves, stream tickets answer 503', async () => {
+ const project2 = contracts.ids.newId('project');
+ assert.strictEqual(strict.app.locals.tickets.get(), null);
+ const made = await strict.call('POST', '/api/v1/jobs', { body: strict.request(), cap: ['run.job.submit'], project: project2, sub: 'svc:builder' });
+ assert.strictEqual(made.status, 201, made.text);
+ const ticket = await strict.call('POST', `/api/v1/jobs/${made.json.job.id}/stream/ticket`, { cap: ['run.job.stream'], project: project2, sub: 'svc:builder' });
+ assert.strictEqual(ticket.status, 503, ticket.text);
+ assert.strictEqual(ticket.json.code, 'run.stream.disabled');
+ const read = await strict.call('GET', `/api/v1/jobs/${made.json.job.id}`, { cap: ['run.job.read'], project: project2, sub: 'svc:builder' });
+ assert.strictEqual(read.status, 200, read.text);
+ const health = await strict.call('GET', '/api/health', { token: null });
+ assert.strictEqual(health.status, 200);
+ });
+ } finally {
+ await strict.close();
+ }
+ done();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -0,0 +1,232 @@
+'use strict';
+/**
+ * Boots Run against a Network stub on a random port with a migrated database of its own (helpers/db.js:
+ * PGlite, or the containers with RUN_TEST_STORE=pg). Background jobs are off: tests drive the outbox and
+ * the stream explicitly. The events relay is off too (EVENTS_URL unset), so run.job.* events are read
+ * from run_events_outbox where they were enqueued — the transactional path, without a network.
+ *
+ * t.call(method, path, { body, cap, sub, project, token, headers, key }) a service token for audience
+ * openvibe.run with `cap`
+ * t.sse(path, { headers }) an SSE client: .status, .events, .waitFor(pred), .waitForClose()
+ * t.outboxRows(type) what the outbox holds (envelope.payload, newest last)
+ * t.mintTicket(jobId, ...) open a stream as a browser would
+ *
+ * Every token is signed by the stub Network's own key, which the app's key provider loads (the JWKS
+ * route) — the real verification path, never a bypass.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, ids } = require('openvibe-contracts');
+const { testDb } = require('./db');
+
+function listen(server) {
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));
+}
+
+/** OpenVibe.Network's two routes Run uses: the JWKS the key provider loads, and the tokens it verifies. */
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const issuer = 'http://network.test';
+ const server = http.createServer((req, res) => {
+ if (req.url === '/api/.well-known/jwks') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'n1', ...publicKey.export({ format: 'jwk' }) }], public_key: publicPem }));
+ }
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ return res.end('{}');
+ });
+ const url = await listen(server);
+ return {
+ url, issuer, publicPem, privatePem, close: () => new Promise((r) => server.close(r)),
+ /** A Network service token (identity.service-token-claims@1). */
+ signService({ sub = 'svc:live', aud = ['openvibe.run'], cap = ['run.*'], project_id, actor_type = 'service', expSec = 300, env: tokenEnv } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss: issuer, sub, actor_type, aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex'),
+ ...(project_id ? { project_id } : {}), ...(tokenEnv ? { env: tokenEnv } : {}),
+ }, privatePem);
+ },
+ /** A person's SSO token (a user token has no capability and names no project). */
+ signUser({ subject = ids.newId('user'), username = 'ada', role = 'user' } = {}) {
+ return jwt.sign({ sub: String(Date.parse('2026-01-01T00:00:00Z')), subject_id: subject, username, display_name: username, role }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
+ },
+ };
+}
+
+async function boot(opts = {}) {
+ const network = await startNetwork();
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-test-'));
+ const env = {
+ NODE_ENV: 'test',
+ BASE_URL: 'http://run.test',
+ OV_NETWORK_URL: network.url,
+ OV_NETWORK_INTERNAL_URL: network.url,
+ OV_NETWORK_ISSUER: network.issuer,
+ OV_OAUTH_CLIENT_ID: 'run',
+ OV_OAUTH_CLIENT_SECRET: '',
+ RUN_JOBS: 'off',
+ RUN_STREAM_PRIVATE_KEY: crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).replace(/\n/g, '\\n'),
+ ...(opts.env || {}),
+ };
+ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];
+ const { loadConfig } = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const config = loadConfig(env);
+ const clock = { offset: 0 };
+ const logs = [];
+ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+ const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });
+ const app = createApp({ config, db: store.db, valkey: opts.valkey || null, now: () => Date.now() + clock.offset, log, ...(opts.appOpts || {}) });
+ await app.locals.keys.load();
+ app.locals.tickets.start();
+ const server = await new Promise((resolve) => {
+ const s = http.createServer(app);
+ s.listen(0, '127.0.0.1', () => resolve(s));
+ });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const { stream, outbox, tickets, events: runEvents, dispatch } = app.locals;
+
+ let n = 0;
+ async function call(method, p, { body, token, cap = ['run.*'], sub = 'svc:live', project, headers = {}, key } = {}) {
+ const h = { ...headers };
+ if (token !== null) h.Authorization = `Bearer ${token || network.signService({ cap, sub, ...(project ? { project_id: project } : {}) })}`;
+ if (body !== undefined) h['Content-Type'] = 'application/json';
+ // The Idempotency-Key header is sent only when the test names one (like a caller who means it).
+ if (key != null && h['Idempotency-Key'] === undefined) h['Idempotency-Key'] = key;
+ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
+ const text = await res.text();
+ let json = null; try { json = JSON.parse(text); } catch { /* not json */ }
+ return { status: res.status, headers: res.headers, json, text };
+ }
+
+ /** The body of a valid run.job-create-request@1 (PROJECT is not part of it: the token names the project). */
+ function request(over = {}) {
+ return {
+ class: 'function',
+ artifact: { name: 'thumbnail', version: '1.2.0' },
+ args: { width: 320 },
+ limits: { wall_ms: 30_000, cpu_ms: 30_000, mem_bytes: 268_435_456 },
+ ...over,
+ };
+ }
+
+ /** Submit a job through the API (a service token for `project`). */
+ async function submit(over = {}, opts2 = {}) {
+ const r = await call('POST', '/api/v1/jobs', {
+ body: request(over), project: opts2.project || ids.newId('project'), cap: opts2.cap || ['run.job.submit'], sub: opts2.sub || 'svc:builder', key: opts2.key,
+ });
+ if (r.status !== 201) throw new Error(`submit setup: ${r.status} ${r.text}`);
+ return r.json.job;
+ }
+
+ const SOCKET_WAIT_MS = 20_000;
+
+ /**
+ * An SSE client (GET /jobs/:id/stream?ticket=). Non-2xx answers resolve with .status and .json and no
+ * reader; a 200 carries .events and waitFor/waitForClose.
+ */
+ function sse(p, { headers = {}, ticket } = {}) {
+ // No ticket at all (undefined) sends no query parameter: that is the case the API must refuse.
+ const url = ticket === undefined ? `${base}${p}` : `${base}${p}${p.includes('?') ? '&' : '?'}ticket=${encodeURIComponent(ticket)}`;
+ return fetch(url, { headers })
+ .then(async (res) => {
+ const client = { status: res.status, events: [], ended: false, closeCode: null, waiters: [] };
+ if (!res.ok) { client.text = await res.text(); try { client.json = JSON.parse(client.text); } catch { /* not json */ } return client; }
+ client.res = res;
+ const push = (e) => {
+ client.events.push(e);
+ client.waiters = client.waiters.filter((w) => (w.pred(e) ? (w.resolve(e), false) : true));
+ };
+ client.waitFor = (pred, ms = SOCKET_WAIT_MS) => new Promise((ok, fail) => {
+ const hit = client.events.find(pred);
+ if (hit) return ok(hit);
+ if (client.ended) return ok(null);
+ const w = { pred, resolve: ok };
+ client.waiters.push(w);
+ setTimeout(() => { client.waiters = client.waiters.filter((x) => x !== w); fail(new Error(`waitFor timed out; got ${JSON.stringify(client.events)}`)); }, ms).unref();
+ });
+ client.waitForClose = (ms = SOCKET_WAIT_MS) => new Promise((ok) => {
+ if (client.ended) return ok(true);
+ const t = setTimeout(() => ok(false), ms);
+ client.onEnd = () => { clearTimeout(t); ok(true); };
+ });
+ client.close = () => { try { if (client.reader) client.reader.cancel().catch(() => {}); } catch { /* already closed */ } };
+ const reader = res.body.getReader();
+ client.reader = reader;
+ const decoder = new TextDecoder();
+ let buf = '';
+ (async () => {
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += decoder.decode(value, { stream: true });
+ let i;
+ while ((i = buf.indexOf('\n\n')) >= 0) {
+ const block = buf.slice(0, i); buf = buf.slice(i + 2);
+ const ev = { id: null, event: null, data: null };
+ for (const line of block.split('\n')) {
+ if (line.startsWith('id: ')) ev.id = line.slice(4);
+ else if (line.startsWith('event: ')) ev.event = line.slice(7);
+ else if (line.startsWith('data: ')) { try { ev.data = JSON.parse(line.slice(6)); } catch { ev.data = line.slice(6); } }
+ }
+ if (ev.event) push(ev);
+ }
+ }
+ } catch { /* the client closed us */ }
+ client.ended = true;
+ client.waiters.forEach((w) => w.resolve(null));
+ client.waiters = [];
+ if (client.onEnd) client.onEnd();
+ })();
+ return client;
+ });
+ }
+
+ /**
+ * Mint a stream ticket through the API and open the stream with it, as a browser would. The project
+ * the job belongs to is read from the row when the caller does not name it (the token names it in
+ * production; a test usually has the job it just submitted).
+ */
+ async function openStream(jobId, { project, cap = ['run.job.stream'], headers = {} } = {}) {
+ const owner = project || (await store.db.value('SELECT project_id FROM run_jobs WHERE id = $1', [jobId]));
+ const t = await call('POST', `/api/v1/jobs/${jobId}/stream/ticket`, { cap, project: owner, headers });
+ if (t.status !== 200) throw new Error(`ticket setup: ${t.status} ${t.text}`);
+ return sse(`/api/v1/jobs/${jobId}/stream`, { ticket: t.json.ticket, headers });
+ }
+
+ /** What the outbox holds: every row, or the rows of one event type / one job. */
+ const outboxRows = async (type, jobId) => (await store.db.many('SELECT event_id, envelope FROM run_events_outbox ORDER BY id'))
+ .map((r) => ({ ...r.envelope, outbox_key: r.event_id }))
+ .filter((e) => (!type || e.event_type === type) && (!jobId || e.payload.job_id === jobId));
+
+ return {
+ app, base, call, sse, openStream, submit, request, db: app.locals.db, store, config, clock, network, logs, dir, stream, outbox, tickets, runEvents, dispatch,
+ outboxRows, streamState: (jobId) => stream.ringOf(jobId),
+ wait: (ms) => new Promise((r) => setTimeout(r, ms)),
+ close: async () => {
+ stream.close();
+ server.closeAllConnections();
+ await new Promise((r) => server.close(r));
+ await app.locals.outbox.stop();
+ await network.close();
+ await store.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ },
+ };
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); }
+ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done, startNetwork };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035).
+ *
+ * store 'pglite' (the default): real PostgreSQL in-process, migrated.
+ * store 'pg': the production-shaped containers (openvibe-sdk scripts/test-services.sh up), when
+ * OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL are set. The run gets its own roles and schema.
+ *
+ * TIPS-style: RUN_TEST_STORE=pg runs every test file on the containers instead of PGlite.
+ */
+const crypto = require('crypto');
+const { createDb } = require('openvibe-sdk/db');
+const { MIGRATIONS } = require('../../server/db');
+
+const quiet = { log() {}, warn() {}, error: (...a) => console.error(...a) };
+const pgAvailable = () => !!(process.env.OV_TEST_PG_URL && process.env.OV_TEST_PG_DIRECT_URL);
+
+async function testDb({ store = process.env.RUN_TEST_STORE || 'pglite', max = 4 } = {}) {
+ if (store !== 'pg') {
+ const db = createDb({ pglite: true, service: 'run-test', log: quiet });
+ await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
+ return { db, store: 'pglite', close: () => db.close().catch(() => {}) };
+ }
+ if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');
+ const name = `run_t${process.pid}_${crypto.randomBytes(4).toString('hex')}`;
+ const owner = `${name}_owner`;
+ const pw = crypto.randomBytes(16).toString('hex');
+ const su = createDb({ url: process.env.OV_TEST_PG_DIRECT_URL, service: 'run-test-admin', max: 1, log: quiet });
+ const database = await su.value('SELECT current_database()');
+ for (const stmt of [
+ `CREATE ROLE ${owner} LOGIN PASSWORD '${pw}'`,
+ `CREATE ROLE ${name} LOGIN PASSWORD '${pw}'`,
+ `GRANT CONNECT ON DATABASE ${database} TO ${owner}, ${name}`,
+ `CREATE SCHEMA ${name} AUTHORIZATION ${owner}`,
+ `ALTER ROLE ${owner} SET search_path = ${name}`,
+ `ALTER ROLE ${name} SET search_path = ${name}`,
+ `ALTER ROLE ${name} SET statement_timeout = '15s'`,
+ `ALTER ROLE ${name} SET lock_timeout = '5s'`,
+ `GRANT USAGE ON SCHEMA ${name} TO ${name}`,
+ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${name}`,
+ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${name}`,
+ ]) await su.query(stmt);
+ const as = (url, user) => { const u = new URL(url); u.username = user; u.password = pw; return u.toString(); };
+ async function drop() {
+ try {
+ await su.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);
+ for (let i = 0; i < 100; i++) {
+ const left = await su.query('SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);
+ if (!Number(left.rows[0].n)) break;
+ await new Promise((ok) => setTimeout(ok, 50));
+ }
+ const ddl = async (stmt) => {
+ for (let i = 0; ; i++) {
+ try { return await su.query(stmt); } catch (err) { if (err.code !== 'XX000' || i >= 5) throw err; await new Promise((ok) => setTimeout(ok, 100 * (i + 1))); }
+ }
+ };
+ await ddl(`DROP SCHEMA IF EXISTS ${name} CASCADE`);
+ for (const r of [name, owner]) { await ddl(`DROP OWNED BY ${r}`); await ddl(`DROP ROLE ${r}`); }
+ } finally { await su.close(); }
+ }
+ let db;
+ try {
+ const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'run-test-migrate', max: 1, log: quiet });
+ try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 }); } finally { await ownerDb.close(); }
+ db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'run-test', max, log: quiet });
+ } catch (e) { await drop().catch(() => {}); throw e; }
+ return {
+ db, store: 'postgresql', schema: name,
+ open: (o = {}) => createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'run-test', max, log: quiet, ...o }),
+ async close() { await db.close().catch(() => {}); await drop(); },
+ };
+}
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,473 @@
@@ -0,0 +1,473 @@
+'use strict';
+/**
+ * The Run job API (plan T14 step 5, R1b) against the released contracts: one capability per route, the
+ * project and the requester from the token, idempotent submit, project-scoped 404s, keyset paging,
+ * cancel, the run.job.* events in the submitting transaction, the stream ticket and the SSE stream.
+ *
+ * node test/jobs.test.js
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+const PROBLEM = /application\/problem\+json/;
+const decodeClaims = (token) => JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
+/** An outbox row as the published envelope (the helper attaches the row's own key beside it). */
+const envelopeOf = (row) => { const { outbox_key, ...envelope } = row; return envelope; };
+
+async function checks(t) {
+ const projectA = contracts.ids.newId('project');
+ const projectB = contracts.ids.newId('project');
+ const cap = (list, project, sub) => ({ cap: list, project, sub });
+
+ await check('a valid run.job-create-request@1 → 201 created, queued, run.job.queued in the outbox', async () => {
+ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.created, true);
+ assert.ok(contracts.validate('run.job-create-result@1', r.json).valid, JSON.stringify(r.json));
+ const job = r.json.job;
+ assert.match(job.id, /^job_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(job.state, 'queued');
+ assert.strictEqual(job.project_id, projectA);
+ assert.deepStrictEqual(job.requester, { type: 'service', id: 'builder' });
+ assert.strictEqual(job.ttl_ms, 30_000 + 600_000); // limits.wall_ms + 600000
+ assert.strictEqual(job.egress, 'none');
+ assert.deepStrictEqual(job.requirements, { kind: 'run.function', mobility: 'job', latency_class: 'background', objective: 'balanced' });
+ assert.strictEqual(job.placement, null);
+ assert.match(job.idempotency_key, /^auto:[0-9a-z]+$/); // no key given: Run's own
+ const queued = await t.outboxRows('run.job.queued', job.id);
+ assert.strictEqual(queued.length, 1);
+ // The outbox row's key is Run's own (the dossier's run:<job id>:<state>); the published envelope
+ // carries a contract-valid evt_ document id (events.event-envelope@1 pins the pattern).
+ assert.strictEqual(queued[0].outbox_key, `run:${job.id}:queued`);
+ assert.match(queued[0].event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.deepStrictEqual(queued[0].subject, { type: 'job', id: job.id });
+ assert.deepStrictEqual(queued[0].actor, { type: 'service', id: 'run' });
+ assert.strictEqual(queued[0].visibility, 'internal');
+ assert.ok(contracts.validate('run.job.queued@1', queued[0].payload).valid);
+ assert.ok(contracts.validate('events.event-envelope@1', envelopeOf(queued[0])).valid);
+ });
+
+ await check('the same Idempotency-Key answers the first job (200, created false) with one row', async () => {
+ const body = t.request();
+ const first = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'thumb:photo-7' });
+ assert.strictEqual(first.status, 201, first.text);
+ const again = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'thumb:photo-7' });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.strictEqual(again.json.created, false);
+ assert.strictEqual(again.json.job.id, first.json.job.id);
+ const rows = await t.db.many('SELECT * FROM run_jobs WHERE project_id = $1 AND idempotency_key = $2', [projectA, 'thumb:photo-7']);
+ assert.strictEqual(rows.length, 1);
+ assert.strictEqual(rows[0].id, first.json.job.id);
+ assert.strictEqual((await t.outboxRows('run.job.queued', first.json.job.id)).length, 1); // never a second queued event
+ });
+
+ await check('body.idempotency_key works too, and the header disagreeing with it is 422', async () => {
+ const body = { ...t.request(), idempotency_key: 'run:one' };
+ const first = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'run:one' });
+ assert.strictEqual(first.status, 201, first.text);
+ assert.strictEqual(first.json.job.idempotency_key, 'run:one');
+ const again = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'run:one' });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json.job.id, first.json.job.id);
+ const clash = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'run:two' });
+ assert.strictEqual(clash.status, 422, clash.text);
+ assert.strictEqual(clash.json.code, 'run.invalid_input');
+ });
+
+ await check('the same key with a different body is 409 run.idempotency.conflict', async () => {
+ await t.call('POST', '/api/v1/jobs', { body: t.request({ args: { width: 64 } }), ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'conflict:1' });
+ const r = await t.call('POST', '/api/v1/jobs', { body: t.request({ args: { width: 128 } }), ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'conflict:1' });
+ assert.strictEqual(r.status, 409, r.text);
+ assert.strictEqual(r.json.code, 'run.idempotency.conflict');
+ assert.match(r.headers.get('content-type') || '', PROBLEM);
+ });
+
+ await check('the idempotency key is scoped per project: another project may reuse it', async () => {
+ const body = t.request();
+ const mine = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder'), key: 'shared:key' });
+ const theirs = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectB, 'svc:other'), key: 'shared:key' });
+ assert.strictEqual(mine.status, 201, mine.text);
+ assert.strictEqual(theirs.status, 201, theirs.text);
+ assert.notStrictEqual(mine.json.job.id, theirs.json.job.id);
+ assert.strictEqual(theirs.json.job.project_id, projectB);
+ });
+
+ await check('a second project never sees the row: 404, never 403', async () => {
+ const job = t.request();
+ const mine = await t.call('POST', '/api/v1/jobs', { body: job, ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const theirs = await t.call('GET', `/api/v1/jobs/${mine.json.job.id}`, cap(['run.job.read'], projectB, 'svc:other'));
+ assert.strictEqual(theirs.status, 404, theirs.text);
+ assert.strictEqual(theirs.json.code, 'run.job_not_found');
+ const cancelTheirs = await t.call('POST', `/api/v1/jobs/${mine.json.job.id}/cancel`, cap(['run.job.cancel'], projectB, 'svc:other'));
+ assert.strictEqual(cancelTheirs.status, 404);
+ const ticketTheirs = await t.call('POST', `/api/v1/jobs/${mine.json.job.id}/stream/ticket`, cap(['run.job.stream'], projectB, 'svc:other'));
+ assert.strictEqual(ticketTheirs.status, 404);
+ const mineRead = await t.call('GET', `/api/v1/jobs/${mine.json.job.id}`, cap(['run.job.read'], projectA, 'svc:builder'));
+ assert.strictEqual(mineRead.status, 200, mineRead.text);
+ assert.ok(contracts.validate('run.job-read-result@1', mineRead.json).valid);
+ });
+
+ await check('the project and the requester come from the token, never the body', async () => {
+ const body = { ...t.request(), project_id: projectB, requester: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
+ const r = await t.call('POST', '/api/v1/jobs', { body, ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(r.status, 400, r.text); // additionalProperties: false
+ assert.strictEqual(r.json.code, 'run.invalid_request');
+ assert.match(r.headers.get('content-type') || '', PROBLEM);
+ const other = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectB, 'svc:builder') });
+ assert.strictEqual(other.json.job.project_id, projectB);
+ assert.deepStrictEqual(other.json.job.requester, { type: 'service', id: 'builder' });
+ });
+
+ await check('a body that fails run.job-create-request@1 is 400 problem+json with the contract errors', async () => {
+ const r = await t.call('POST', '/api/v1/jobs', { body: { class: 'function' }, ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(r.status, 400, r.text);
+ assert.match(r.headers.get('content-type') || '', PROBLEM);
+ assert.strictEqual(r.json.code, 'run.invalid_request');
+ assert.ok(Array.isArray(r.json.errors) && r.json.errors.length, JSON.stringify(r.json));
+ const bad = await t.call('POST', '/api/v1/jobs', { body: t.request({ egress: 'everywhere' }), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(bad.status, 400);
+ const unbalanced = await t.call('POST', '/api/v1/jobs', { body: t.request({ inputs: [{ name: 'a.txt', media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPS', sha256: 'a'.repeat(64) }, { name: 'a.txt', media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPS', sha256: 'b'.repeat(64) }] }), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(unbalanced.status, 422, unbalanced.text); // duplicate input name
+ assert.strictEqual(unbalanced.json.code, 'run.invalid_input');
+ });
+
+ await check('limits are bounded by the project caps, never silently lowered', async () => {
+ const over = await t.call('POST', '/api/v1/jobs', { body: t.request({ limits: { wall_ms: 10_000_000, cpu_ms: 30_000, mem_bytes: 1 } }), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(over.status, 422, over.text);
+ assert.strictEqual(over.json.code, 'run.limits.exceeded');
+ const mem = await t.call('POST', '/api/v1/jobs', { body: t.request({ limits: { wall_ms: 1000, cpu_ms: 1000, mem_bytes: 8 * 1024 ** 3 } }), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(mem.status, 422);
+ // The default lifetime (limits.wall_ms + 600000) is capped too: a project whose cap is lower refuses
+ // the request rather than storing a job that outlives it.
+ const kept = t.config.limits.maxTtlMs;
+ t.config.limits.maxTtlMs = 600_000;
+ try {
+ const byDefault = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(byDefault.status, 422, byDefault.text);
+ assert.strictEqual(byDefault.json.code, 'run.limits.exceeded');
+ assert.match(byDefault.json.detail, /limits\.wall_ms \+ 600000/);
+ } finally { t.config.limits.maxTtlMs = kept; }
+ const args = await t.call('POST', '/api/v1/jobs', { body: t.request({ args: { blob: 'x'.repeat(520 * 1024) } }), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(args.status, 413, args.text);
+ assert.strictEqual(args.json.code, 'run.args_too_large');
+ const ttl = await t.call('POST', '/api/v1/jobs', { body: t.request({ ttl_ms: 86_400_000 * 2 }), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ assert.strictEqual(ttl.status, 400); // the contract's own ceiling
+ });
+
+ await check('a token without the route capability is 403, a missing token 401, a user token 403', async () => {
+ const wrong = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.list'], projectA, 'svc:builder') });
+ assert.strictEqual(wrong.status, 403, wrong.text);
+ assert.strictEqual(wrong.json.code, 'capability.denied');
+ const listWithRead = await t.call('GET', '/api/v1/jobs', cap(['run.job.read'], projectA, 'svc:builder'));
+ assert.strictEqual(listWithRead.status, 403);
+ const anonymous = await t.call('GET', '/api/v1/jobs', { token: null });
+ assert.strictEqual(anonymous.status, 401, anonymous.text);
+ assert.strictEqual(anonymous.json.code, 'token.required');
+ const user = await t.call('GET', '/api/v1/jobs', { token: t.network.signUser({}) });
+ assert.strictEqual(user.status, 403, user.text);
+ assert.strictEqual(user.json.code, 'capability.denied');
+ const sandbox = await t.call('GET', '/api/v1/jobs', { token: t.network.signService({ cap: ['run.*'], project_id: projectA, actor_type: 'app', sub: `app:${contracts.ids.newId('app')}`, env: 'sandbox' }) });
+ assert.strictEqual(sandbox.status, 401, sandbox.text);
+ assert.strictEqual(sandbox.json.code, 'token.sandbox_refused');
+ });
+
+ await check('a service token with no project_id is refused 403 run.project_required', async () => {
+ const r = await t.call('POST', '/api/v1/jobs', { body: t.request(), cap: ['run.job.submit'], sub: 'svc:run' });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'run.project_required');
+ });
+
+ await check('list is newest first with a keyset cursor, null on the last page', async () => {
+ const project = contracts.ids.newId('project');
+ const made = [];
+ for (let i = 0; i < 3; i++) {
+ const r = await t.call('POST', '/api/v1/jobs', { body: t.request({ args: { i } }), ...cap(['run.job.submit'], project, 'svc:builder') });
+ assert.strictEqual(r.status, 201, r.text);
+ made.push(r.json.job.id);
+ }
+ const page1 = await t.call('GET', '/api/v1/jobs?limit=2', cap(['run.job.list'], project, 'svc:builder'));
+ assert.strictEqual(page1.status, 200, page1.text);
+ assert.ok(contracts.validate('run.job-list-result@1', page1.json).valid, page1.text);
+ assert.strictEqual(page1.json.jobs.length, 2);
+ assert.ok(page1.json.next_cursor);
+ const page2 = await t.call('GET', `/api/v1/jobs?limit=2&cursor=${encodeURIComponent(page1.json.next_cursor)}`, cap(['run.job.list'], project, 'svc:builder'));
+ assert.strictEqual(page2.json.jobs.length, 1);
+ assert.strictEqual(page2.json.next_cursor, null);
+ const seen = [...page1.json.jobs, ...page2.json.jobs].map((j) => j.id);
+ assert.deepStrictEqual(seen.slice().sort(), made.slice().sort());
+ assert.strictEqual(new Set(seen).size, 3);
+ const filtered = await t.call('GET', '/api/v1/jobs?state=queued', cap(['run.job.list'], project, 'svc:builder'));
+ assert.strictEqual(filtered.json.jobs.length, 3);
+ const none = await t.call('GET', '/api/v1/jobs?state=cancelled', cap(['run.job.list'], project, 'svc:builder'));
+ assert.deepStrictEqual(none.json.jobs, []);
+ assert.strictEqual(none.json.next_cursor, null);
+ const bad = await t.call('GET', '/api/v1/jobs?state=gone', cap(['run.job.list'], project, 'svc:builder'));
+ assert.strictEqual(bad.status, 400, bad.text);
+ const badLimit = await t.call('GET', '/api/v1/jobs?limit=500', cap(['run.job.list'], project, 'svc:builder'));
+ assert.strictEqual(badLimit.status, 400);
+ const otherProject = await t.call('GET', '/api/v1/jobs', cap(['run.job.list'], project, 'svc:builder'));
+ assert.strictEqual(otherProject.json.jobs.length, 3); // only this project's
+ const snoop = await t.call('GET', `/api/v1/jobs?project_id=${projectB}`, cap(['run.job.list'], project, 'svc:builder'));
+ assert.strictEqual(snoop.status, 403, snoop.text); // project_id is run.job.admin only
+ assert.strictEqual(snoop.json.code, 'capability.denied');
+ });
+
+ await check('cancel of a queued job → cancelled + run.job.cancelled, and a repeat is one event', async () => {
+ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const id = made.json.job.id;
+ const first = await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));
+ assert.strictEqual(first.status, 200, first.text);
+ assert.ok(contracts.validate('run.job-read-result@1', first.json).valid, first.text);
+ assert.strictEqual(first.json.state, 'cancelled');
+ assert.strictEqual(first.json.finished_at, undefined);
+ assert.ok(first.json.timings.finished_at);
+ assert.strictEqual(first.json.exit, null); // never placed, never ran
+ assert.strictEqual(first.json.placement, null);
+ assert.deepStrictEqual(first.json.usage, { seconds: 0, wall_ms: 0, cpu_ms: null, mem_peak_bytes: null });
+ assert.strictEqual(first.json.error, null);
+ const events = await t.outboxRows('run.job.cancelled', id);
+ assert.strictEqual(events.length, 1);
+ assert.strictEqual(events[0].outbox_key, `run:${id}:cancelled`);
+ assert.ok(contracts.validate('run.job.cancelled@1', events[0].payload).valid);
+ const again = await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json.state, 'cancelled');
+ assert.strictEqual((await t.outboxRows('run.job.cancelled', id)).length, 1); // a replay is one row
+ const unknown = await t.call('POST', `/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));
+ assert.strictEqual(unknown.status, 404);
+ const notAnId = await t.call('POST', '/api/v1/jobs/nope/cancel', cap(['run.job.cancel'], projectA, 'svc:builder'));
+ assert.strictEqual(notAnId.status, 404);
+ });
+
+ await check('the stream ticket is a two-minute run-stream JWS for this one job', async () => {
+ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const id = made.json.job.id;
+ const r = await t.call('POST', `/api/v1/jobs/${id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(contracts.validate('run.job-stream-ticket-result@1', r.json).valid, r.text);
+ assert.strictEqual(r.json.expires_in, 120);
+ assert.strictEqual(r.json.job_id, id);
+ assert.match(r.json.stream_url, new RegExp(`/api/v1/jobs/${id}/stream$`));
+ const claims = decodeClaims(r.json.ticket);
+ assert.strictEqual(claims.exp - claims.iat, 120);
+ assert.strictEqual(claims.aud, 'openvibe.run');
+ assert.strictEqual(claims.typ, 'run-stream');
+ assert.strictEqual(claims.sub, id);
+ assert.strictEqual(claims.project, projectA);
+ assert.match(claims.jti, /^rsk_[0-9a-f]{24}$/);
+ assert.strictEqual(new Date(r.json.expires_at).getTime(), claims.exp * 1000);
+ const other = await t.call('POST', '/api/v1/jobs/job_01JAB2C3D4E5F6G7H8J9K0MNPQ/stream/ticket', cap(['run.job.stream'], projectA, 'svc:builder'));
+ assert.strictEqual(other.status, 404);
+ });
+
+ await check('GET /jobs/:id/stream without a valid, unexpired ticket for that job is refused', async () => {
+ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const id = made.json.job.id;
+ const none = await t.sse(`/api/v1/jobs/${id}/stream`);
+ assert.strictEqual(none.status, 401, JSON.stringify(none));
+ assert.strictEqual(none.json.code, 'ticket.required');
+ const junk = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: 'not.a.ticket' });
+ assert.strictEqual(junk.status, 401);
+ assert.strictEqual(junk.json.code, 'ticket.invalid');
+ // A service token is not a ticket, even a valid one that holds run.job.stream.
+ const token = t.network.signService({ cap: ['run.job.stream'], project_id: projectA, sub: 'svc:builder' });
+ const asToken = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: token });
+ assert.strictEqual(asToken.status, 401, asToken.text);
+ assert.strictEqual(asToken.json.code, 'ticket.invalid');
+ // Another job's ticket is refused for this job.
+ const otherJob = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const otherTicket = await t.call('POST', `/api/v1/jobs/${otherJob.json.job.id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'));
+ const mismatched = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: otherTicket.json.ticket });
+ assert.strictEqual(mismatched.status, 403, mismatched.text);
+ assert.strictEqual(mismatched.json.code, 'run.stream.ticket_mismatch');
+ // An expired ticket is refused (the ticket lives 120 s).
+ const fresh = await t.call('POST', `/api/v1/jobs/${id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'));
+ t.clock.offset = 121_000;
+ try {
+ const expired = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: fresh.json.ticket });
+ assert.strictEqual(expired.status, 401, expired.text);
+ assert.strictEqual(expired.json.code, 'ticket.expired');
+ } finally { t.clock.offset = 0; }
+ // A ticket opens exactly one stream: the second use finds the jti consumed.
+ const used = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: fresh.json.ticket });
+ assert.strictEqual(used.status, 200, used.text);
+ used.close();
+ const replayed = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: fresh.json.ticket });
+ assert.strictEqual(replayed.status, 401, replayed.text);
+ assert.strictEqual(replayed.json.code, 'ticket.used');
+ });
+
+ await check('a streamed output event matches run.job-stream-event@1 with a monotonic seq', async () => {
+ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const id = made.json.job.id;
+ const s = await t.openStream(id, { project: projectA });
+ assert.strictEqual(s.status, 200, s.text);
+ const state = await s.waitFor((e) => e.event === 'state');
+ assert.strictEqual(state.data.state, 'queued');
+ assert.strictEqual(state.data.job_id, id);
+ assert.strictEqual(state.id, String(state.data.seq));
+ assert.ok(contracts.validate('run.job-stream-event@1', state.data).valid, JSON.stringify(state.data));
+ // The dispatcher bridge feeds the job's stdout (plan T14 step 6); the stream just carries it.
+ t.stream.output(id, 'resized 1 of 1\n');
+ t.stream.output(id, 'done\n');
+ const out1 = await s.waitFor((e) => e.event === 'output' && e.data.chunk === 'resized 1 of 1\n');
+ const out2 = await s.waitFor((e) => e.event === 'output' && e.data.chunk === 'done\n');
+ assert.ok(contracts.validate('run.job-stream-event@1', out1.data).valid, JSON.stringify(out1.data));
+ assert.strictEqual(out1.data.seq + 1, out2.data.seq);
+ assert.strictEqual(out1.id, String(out1.data.seq));
+ await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));
+ const end = await s.waitFor((e) => e.event === 'end');
+ assert.strictEqual(end.data.state, 'cancelled');
+ assert.ok(contracts.validate('run.job-stream-event@1', end.data).valid, JSON.stringify(end.data));
+ assert.ok(end.data.seq > out2.data.seq);
+ assert.strictEqual(await s.waitForClose(5000), true);
+ });
+
+ await check('a reconnect replays what is kept and resumes after Last-Event-ID', async () => {
+ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const id = made.json.job.id;
+ const first = await t.openStream(id, { project: projectA });
+ const seen = await first.waitFor((e) => e.event === 'state');
+ t.stream.output(id, 'first chunk\n');
+ const chunk = await first.waitFor((e) => e.event === 'output');
+ first.close();
+ const second = await t.openStream(id, { project: projectA, headers: { 'Last-Event-ID': String(chunk.data.seq) } });
+ assert.strictEqual(second.status, 200, second.text);
+ await t.wait(100);
+ assert.deepStrictEqual(second.events.filter((e) => e.data && e.data.seq <= chunk.data.seq), [], 'nothing already seen is replayed');
+ t.stream.output(id, 'after the reconnect\n');
+ const next = await second.waitFor((e) => e.event === 'output');
+ assert.strictEqual(next.data.chunk, 'after the reconnect\n');
+ assert.ok(next.data.seq > chunk.data.seq);
+ await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));
+ const end = await second.waitFor((e) => e.event === 'end');
+ assert.ok(end.data.seq > next.data.seq);
+ assert.strictEqual(await second.waitForClose(5000), true);
+ });
+
+ await check('a stream opened on an ended job replays what is kept and closes', async () => {
+ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const id = made.json.job.id;
+ await t.call('POST', `/api/v1/jobs/${id}/cancel`, cap(['run.job.cancel'], projectA, 'svc:builder'));
+ // The reconnect a browser makes after a drop, with the id it last saw: everything kept is replayed
+ // and the server ends the response after `end` (a ring that ended and lost its subscriber must not
+ // leave the next caller with an empty, never-closing stream).
+ const opened = await t.openStream(id, { project: projectA });
+ assert.strictEqual(opened.status, 200, opened.text);
+ const end = await opened.waitFor((e) => e.event === 'end');
+ assert.strictEqual(end.data.state, 'cancelled');
+ assert.strictEqual(await opened.waitForClose(5000), true);
+ const resumed = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: (await t.call('POST', `/api/v1/jobs/${id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'))).json.ticket, headers: { 'Last-Event-ID': String(end.data.seq) } });
+ assert.strictEqual(resumed.status, 200, resumed.text);
+ assert.deepStrictEqual(resumed.events.filter((e) => e.data && e.data.seq > end.data.seq), []);
+ assert.strictEqual(await resumed.waitForClose(5000), true);
+ // A resume that missed the end still gets it, with the state it missed.
+ const behind = await t.sse(`/api/v1/jobs/${id}/stream`, { ticket: (await t.call('POST', `/api/v1/jobs/${id}/stream/ticket`, cap(['run.job.stream'], projectA, 'svc:builder'))).json.ticket, headers: { 'Last-Event-ID': '1' } });
+ const late = await behind.waitFor((e) => e.event === 'end');
+ assert.strictEqual(late.data.state, 'cancelled');
+ assert.strictEqual(await behind.waitForClose(5000), true);
+ });
+
+ await check('run.job.admin reads another project\'s job and lists every project', async () => {
+ const mine = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const admin = cap(['run.job.read', 'run.job.admin']);
+ const read = await t.call('GET', `/api/v1/jobs/${mine.json.job.id}`, admin);
+ assert.strictEqual(read.status, 200, read.text);
+ assert.strictEqual(read.json.project_id, projectA);
+ const listed = await t.call('GET', `/api/v1/admin/jobs?project_id=${projectA}`, cap(['run.job.admin']));
+ assert.strictEqual(listed.status, 200, listed.text);
+ assert.ok(contracts.validate('run.job-list-result@1', listed.json).valid);
+ assert.ok(listed.json.jobs.every((j) => j.project_id === projectA));
+ const all = await t.call('GET', '/api/v1/admin/jobs?limit=200', cap(['run.job.admin']));
+ assert.ok(all.json.jobs.length >= listed.json.jobs.length);
+ const notAdmin = await t.call('GET', '/api/v1/admin/jobs', cap(['run.job.list'], projectA, 'svc:builder'));
+ assert.strictEqual(notAdmin.status, 403, notAdmin.text);
+ assert.strictEqual(notAdmin.json.code, 'capability.denied');
+ // run.job.admin is internal: the manifest says it is never a delegated app or mod token, so an app
+ // principal holding the grant is refused here too.
+ const appToken = t.network.signService({ sub: `app:${contracts.ids.newId('app')}`, actor_type: 'app', env: 'production', cap: ['run.job.admin'], project_id: projectA });
+ const viaApp = await t.call('GET', '/api/v1/admin/jobs', { token: appToken });
+ assert.strictEqual(viaApp.status, 403, viaApp.text);
+ assert.strictEqual(viaApp.json.code, 'capability.denied');
+ assert.match(viaApp.json.detail, /internal/);
+ // and an admin cancel reaches another project's job
+ const cancelled = await t.call('POST', `/api/v1/jobs/${mine.json.job.id}/cancel`, cap(['run.job.cancel', 'run.job.admin']));
+ assert.strictEqual(cancelled.status, 200, cancelled.text);
+ assert.strictEqual(cancelled.json.state, 'cancelled');
+ });
+
+ await check('a running job (mirrored from Bot) is settled with its event and its error code', async () => {
+ const made = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const id = made.json.job.id;
+ // What the dispatcher bridge does in plan T14 step 6: mirror Bot's answer, then settle the end state.
+ const store = require('../server/jobs/store');
+ const at = Date.now();
+ const running = await store.mirror(t.db, id, { state: 'running', node_id: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', provider: null, region: 'eu-west', started_ms: at, exit_reason: null, exit_code: null, result: null, wall_ms: 0, usage_read: 0 }, at);
+ assert.strictEqual(running.state, 'running');
+ const read = await t.call('GET', `/api/v1/jobs/${id}`, cap(['run.job.read'], projectA, 'svc:builder'));
+ assert.strictEqual(read.status, 200, read.text);
+ assert.ok(contracts.validate('run.job-read-result@1', read.json).valid, read.text);
+ assert.deepStrictEqual(read.json.placement, { node: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2X', provider: null, region: 'eu-west' });
+ assert.ok(read.json.timings.placed_at && read.json.timings.started_at);
+ // run.job.started carries state `running`: the event's payload contract is run.job.started@1, not a
+ // run.job.running@1 (which does not exist) — the emitter and the row key follow that.
+ const started = await t.db.tx((tx) => t.runEvents.started(tx, running));
+ const startedRow = (await t.outboxRows('run.job.started', id))[0];
+ assert.strictEqual(started.enqueued, true);
+ assert.strictEqual(startedRow.outbox_key, `run:${id}:running`);
+ assert.strictEqual(startedRow.event_type, 'run.job.started');
+ assert.strictEqual(startedRow.payload.state, 'running');
+ assert.ok(contracts.validate('run.job.started@1', startedRow.payload).valid, JSON.stringify(startedRow.payload));
+ const settled = await t.db.tx((tx) => store.settle(tx, id, { state: 'failed', exit: 'limit', code: null, wallMs: 2350, errorDetail: 'mem_bytes exceeded', at: at + 2350 }));
+ assert.strictEqual(settled.changed, true);
+ const after = await t.call('GET', `/api/v1/jobs/${id}`, cap(['run.job.read'], projectA, 'svc:builder'));
+ assert.strictEqual(after.json.state, 'failed');
+ assert.deepStrictEqual(after.json.exit, { reason: 'limit', code: null });
+ assert.deepStrictEqual(after.json.error, { code: 'run.job.limit', detail: 'mem_bytes exceeded' });
+ assert.strictEqual(after.json.usage.wall_ms, 2350);
+ assert.strictEqual(after.json.usage.seconds, 2.35);
+ const again = await t.db.tx((tx) => store.settle(tx, id, { state: 'failed', exit: 'limit', at: at + 3000 }));
+ assert.strictEqual(again.changed, false); // an end state never changes
+ assert.strictEqual(after.json.result, null);
+ // The failed event is the run.job-read-result@1 projection, keyed run:<id>:failed, and a replay of
+ // the same state is the row that is already there.
+ const first = await t.db.tx((tx) => t.runEvents.failed(tx, settled.row));
+ const row = (await t.outboxRows('run.job.failed', id))[0];
+ assert.strictEqual(row.outbox_key, `run:${id}:failed`);
+ assert.strictEqual(row.payload.state, 'failed');
+ assert.deepStrictEqual(row.payload.error, { code: 'run.job.limit', detail: 'mem_bytes exceeded' });
+ assert.ok(contracts.validate('run.job.failed@1', row.payload).valid);
+ assert.strictEqual(first.enqueued, true);
+ assert.strictEqual((await t.db.tx((tx) => t.runEvents.failed(tx, settled.row))).enqueued, false);
+ assert.strictEqual((await t.outboxRows('run.job.failed', id)).length, 1);
+ // An error code on a state whose contract requires `error: null` is never stored: a row this service
+ // could not render must not be reachable.
+ const other = await t.call('POST', '/api/v1/jobs', { body: t.request(), ...cap(['run.job.submit'], projectA, 'svc:builder') });
+ const odd = await t.db.tx((tx) => store.settle(tx, other.json.job.id, { state: 'cancelled', exit: 'cancelled', code: null, errorCode: 'run.job.stopped', errorDetail: 'nope', at: at + 10 }));
+ assert.strictEqual(odd.changed, true);
+ assert.strictEqual(odd.row.error_code, null);
+ assert.strictEqual(odd.row.error_detail, null);
+ const readable = await t.call('GET', `/api/v1/jobs/${other.json.job.id}`, cap(['run.job.read'], projectA, 'svc:builder'));
+ assert.strictEqual(readable.status, 200, readable.text);
+ assert.strictEqual(readable.json.error, null);
+ });
+}
+
+async function main() {
+ const t = await boot();
+ try {
+ await checks(t);
+ await check('no secret and no job output is ever logged', async () => {
+ const secretish = 'Bearer-not-a-token-should-never-be-logged';
+ await t.call('POST', '/api/v1/jobs', { body: t.request(), token: secretish });
+ assert.ok(!t.logs.some((l) => l.includes(secretish)), t.logs.join('\n'));
+ assert.ok(!t.logs.some((l) => /resized 1 of 1/.test(l)), t.logs.join('\n'));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if any of
+ * them fails. They use an in-process stub of OpenVibe.Network, temp databases and random ports; none
+ * needs the network, OpenVibe.Bot or a running service.
+ *
+ * npm test # everything (PGlite)
+ * npm run test:pg # everything on the containers (RUN_TEST_STORE=pg)
+ * npm test -- jobs app # only files whose name contains one of the words
+ */
+'use strict';
+// A file's wall timeout is generous: the test host is shared with other jobs, so a healthy file can take
+// several times its usual time. Files still run one at a time (parallel: 1) to keep the host's load down.
+require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 180000, pad: 28, parallel: 1 });