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)

OP OpenVibers committed 7 Oct 2026 · 602ef12356 · 34 files, +4727 −0

OpenVibe.Run is scaffolded and the R1b job API is implemented, tested and uncommitted (working tree only, HEAD still the empty `dc44223`).

## Files (34, none committed)

| File | What it is |
|---|---|
| `package.json` | Pins `openvibe-contracts` v0.106.0, `openvibe-sdk` v0.34.0, `openvibe-shared` v2.11.0 (codeload tarballs); `start`/`dev`/`test`/`test:pg` scripts, port 4920 |
| `package-lock.json` | Generated with `npm install --package-lock-only --ignore-scripts --no-audit --no-fund` (122 packages, correct tarball pins) |
| `server/config.js` | `loadConfig(env)`: PORT 4920, HOST/BASE_URL/TRUST_PROXY, DATABASE_*/VALKEY_*, OV_NETWORK_*, audience `openvibe.run`, events/oauth, `RUN_LIMITS_*` caps, the stream-ticket key, `RUN_BOT_URL`+`RUN_BOT_AUDIENCE`; **no Billing token** (Bot meters) |
| `server/db.js` | `openDb`/`migrate` on openvibe-sdk/db (pooled runtime role, owner role for migrations, PGlite in dev) |
| `server/network.js` | Network JWKS key provider + offline user-token verification (`openvibe-sdk/auth`) |
| `server/api/auth.js` | `req.principal` (service/user/anonymous), `requireCapability('run.job.…')` via `capabilities.check`, `requireProject` (403 `run.project_required`), requester/project from the token only |
| `server/api/v1.js` | The router: seven routes, one capability each, contract validation in and out, idempotency, keyset paging, cancel, ticket, SSE |
| `server/jobs/store.js` | `put/mint/get/list/adminList/cancel/mirror/settle/toJob/readResult/frameOf` over `run_jobs`; project-scoped get; the `job` record and the frame body |
| `server/jobs/events.js` | The six `run.job.*` emitters, outbox row keyed `run:<job id>:<state>` in the caller's transaction, payload validated per event contract |
| `server/jobs/stream.js` | Per-job ring + subscribers, monotonic `seq` as the SSE id, replay/Last-Event-ID, prune of idle/ended rings |
| `server/jobs/ticket.js` | Two-minute single-use RS256 compact JWS (audience `openvibe.run`, `typ run-stream`, Run's own key), jti consumed once |
| `server/events/outbox.js` | The openvibe-sdk service outbox (`run_events_outbox`, relay → Events, declared event types) |
| `server/dispatch/index.js` | **The T14 step 6 seam**: a no-op dispatcher (`place/cancel/poll/sweep/status`), jobs stay `queued` |
| `server/observability.js` | Truthful `/api/ready` (db required; valkey/JWKS/Events/Bot reported or skipped) + gauges |
| `server/app.js` | App factory: helmet, rate limit (problem+json 429), `express.json({limit:'768kb'})`, health/ready/release.json, loopback-only `/metrics`, problem+json 404/error handling |
| `server/index.js` | `main()`: migrate, listen, start keys/tickets/relay/dispatcher, `gracefulStop` (nothing starts at module load) |
| `server/util.js` | `RunError`/`fail`, time, job-id check, input errors, keyset cursor, requirements default |
| `migrations/0001_jobs.sql` | `run_jobs` + `run_events_outbox`, verbatim from the dossier |
| `test/jobs.test.js` | 21 checks: the job API against the released contracts |
| `test/app.test.js` | 9 checks: health, readiness, release.json, /metrics, the dispatcher seam, prod-no-key |
| `test/helpers/app.js` | Boots Run on a random port against an in-process Network stub (real RS256 JWKS path), SSE client, outbox reader |
| `test/helpers/db.js` | Migrated PGlite (or the containers with `RUN_TEST_STORE=pg`) |
| `test/run.js` | `openvibe-shared/test-runner` launcher, `parallel: 1` |
| `STATUS.json` | Repository/stage/wave/port/domain/contracts/capabilities

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..e6e6717
---- /dev/null
++++ b/.env.example
@@@ -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).
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..1e93da9
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0a08386
---- /dev/null
++++ b/.gitignore
@@@ -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
ddiff --git a/.gitleaks.toml b/.gitleaks.toml
nnew file mode 100644
iindex 0000000..e587b82
---- /dev/null
++++ b/.gitleaks.toml
@@@ -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
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..6c6be5d
---- /dev/null
++++ b/CHANGELOG.md
@@@ -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).
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..4acff73
---- /dev/null
++++ b/README.md
@@@ -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 |
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..71fe9dc
---- /dev/null
++++ b/SECURITY.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..3da10ba
---- /dev/null
++++ b/STATUS.json
@@@ -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"
++}
ddiff --git a/deploy/nginx/openvibe.run.conf b/deploy/nginx/openvibe.run.conf
nnew file mode 100644
iindex 0000000..756ade2
---- /dev/null
++++ b/deploy/nginx/openvibe.run.conf
@@@ -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;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-run.service b/deploy/systemd/openvibe-run.service
nnew file mode 100644
iindex 0000000..bfa3b79
---- /dev/null
++++ b/deploy/systemd/openvibe-run.service
@@@ -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
ddiff --git a/docs/README.md b/docs/README.md
nnew file mode 100644
iindex 0000000..a7ee464
---- /dev/null
++++ b/docs/README.md
@@@ -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.
ddiff --git a/migrations/0001_jobs.sql b/migrations/0001_jobs.sql
nnew file mode 100644
iindex 0000000..2b861fc
---- /dev/null
++++ b/migrations/0001_jobs.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..b525377
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..6d285a9
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/server/api/auth.js b/server/api/auth.js
nnew file mode 100644
iindex 0000000..bb541c5
---- /dev/null
++++ b/server/api/auth.js
@@@ -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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
nnew file mode 100644
iindex 0000000..8c0fe49
---- /dev/null
++++ b/server/api/v1.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..275fc8e
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..2213572
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..32cc54d
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/dispatch/index.js b/server/dispatch/index.js
nnew file mode 100644
iindex 0000000..16ecca0
---- /dev/null
++++ b/server/dispatch/index.js
@@@ -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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..f842e42
---- /dev/null
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..cdb6a4c
---- /dev/null
++++ b/server/index.js
@@@ -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 };
ddiff --git a/server/jobs/events.js b/server/jobs/events.js
nnew file mode 100644
iindex 0000000..506127e
---- /dev/null
++++ b/server/jobs/events.js
@@@ -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 };
ddiff --git a/server/jobs/store.js b/server/jobs/store.js
nnew file mode 100644
iindex 0000000..585c33e
---- /dev/null
++++ b/server/jobs/store.js
@@@ -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,
++};
ddiff --git a/server/jobs/stream.js b/server/jobs/stream.js
nnew file mode 100644
iindex 0000000..35a293e
---- /dev/null
++++ b/server/jobs/stream.js
@@@ -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 };
ddiff --git a/server/jobs/ticket.js b/server/jobs/ticket.js
nnew file mode 100644
iindex 0000000..3e48b8a
---- /dev/null
++++ b/server/jobs/ticket.js
@@@ -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 };
ddiff --git a/server/network.js b/server/network.js
nnew file mode 100644
iindex 0000000..ebd8ee8
---- /dev/null
++++ b/server/network.js
@@@ -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 };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..4ccec76
---- /dev/null
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/util.js b/server/util.js
nnew file mode 100644
iindex 0000000..4471329
---- /dev/null
++++ b/server/util.js
@@@ -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') };
ddiff --git a/test/app.test.js b/test/app.test.js
nnew file mode 100644
iindex 0000000..3d31988
---- /dev/null
++++ b/test/app.test.js
@@@ -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); });
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
nnew file mode 100644
iindex 0000000..22a3434
---- /dev/null
++++ b/test/helpers/app.js
@@@ -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 };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..1d3da6e
---- /dev/null
++++ b/test/helpers/db.js
@@@ -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 };
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
nnew file mode 100644
iindex 0000000..c49c60a
---- /dev/null
++++ b/test/jobs.test.js
@@@ -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); });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..b557184
---- /dev/null
++++ b/test/run.js
@@@ -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 });