openvibers/openvibe.services Commits

Services is the developer platform: the console, docs and tools move here from OpenVibe.Codes (#6)

OP OpenVibers committed 8 Oct 2026 · 928ff3b75e · 96 files, +10910 −575

* Services is the developer platform: the console, docs and tools move here from OpenVibe.Codes

Owner decision 2026-10-08: OpenVibe.Codes becomes the open coding-agent harness and everything its developer portal did moves to openvibe.services. The console over Network's projects API (projects, members, apps, credentials shown once, grants, usage, audit, export, the full archive, delete), app and mod releases with trust tiers and public pages, playgrounds, the generated reference, the OAuth helper, the webhook tester, manifest validation, the policy pages, SSO with PKCE and the crawl artifacts are Codes' code as it ran, renamed to services.* (openvibe-contracts v0.113.0). Production held no portal data, so migration 0002 only creates the console's tables.

The merged index reads Services' own manifests and releases in process (registry/self-authority.js); one key store (the SDK's JWKS client) verifies sign-in, the release API and the index; the outbox publishes services.app.* and services.moderation.action; readiness requires the database and the docs. The community documents and the harness catalog stay on openvibe.codes, linked from here. The home page is the developer platform's: what an integration needs, the services you can build on (from the pinned manifests) and the path from account to integration; its budget is re-baselined for that catalog (30.8 KB measured, 33 allowed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

* Services' vhost serves /.well-known/security.txt like every other site (the host's snippet)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

* Services: migration 0002 says what it is (the console's tables, moved from Codes, column for column)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>

Changes

ddiff --git a/.env.example b/.env.example
iindex f556274..f9f5f75 100644
---- a/.env.example
++++ b/.env.example
@@@ -9,7 +9,8 @@ PORT=4930
HOST=127.0.0.1
NODE_ENV=development
BASE_URL=http://localhost:4930
--TRUST_PROXY=1
++# Cloudflare and nginx sit in front in production.
++TRUST_PROXY=2
# ── Data: PostgreSQL (ADR-035) ──────────────────────────────
# Host's roles/add-service.sh writes both in production. Unset in development: an embedded PGlite
@@@ -23,16 +24,41 @@ OV_NETWORK_URL=https://openvibe.network
# own user token) and reads the signing key from (GET /api/.well-known/jwks).
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 Services answers for: incoming service tokens must carry it.
SERVICES_AUDIENCE=openvibe.services
--# Services' own Network client: it mints one client-credentials token per authority audience
--# (openvibe.network, openvibe.media, …), scoped to that authority's resource.read capability. Unset in
--# development: the fan-out calls the authorities without a token and every authority that requires one
--# is reported partial — the index degrades, it never stops (ADR-046 section 6).
++# Services' own Network client `services`: sign-in (authorization code + PKCE, redirect
++# BASE_URL/auth/callback), the events relay, and one client-credentials token per authority audience
++# (openvibe.network, openvibe.media, …) for the index. Unset in development: nobody can sign in, events wait
++# in the outbox, and every authority that requires a token is reported partial (ADR-046 section 6).
OV_OAUTH_CLIENT_ID=services
OV_OAUTH_CLIENT_SECRET=
++#OV_OAUTH_REDIRECT_URI=https://openvibe.services/auth/callback
++COOKIE_SECURE=false
++# Signs the per-person form token (CSRF). Required in production; unset in development: a random key per process.
++SERVICES_FORM_SECRET=
++# Staff who set trust tiers (besides Network admins holding staff.site.configure): usr_ subjects, comma-separated.
++SERVICES_STAFF_SUBJECTS=
++
++# ── Valkey (ADR-035): per-actor limit counters; optional ────
++VALKEY_URL=
++VALKEY_PREFIX=ov:services:
++#SERVICES_LIMITS_MINUTE=120
++#SERVICES_LIMITS_HOUR=3000
++
++# ── Playgrounds and the project archive ─────────────────────
++# Playgrounds call Events and Media with the APP's own token; the defaults are the public origins in the
++# pinned contracts. The archive reads Media and Events on loopback with Network's read-only export tokens.
++#SERVICES_PLAYGROUND_EVENTS_URL=https://openvibe.events
++#SERVICES_PLAYGROUND_MEDIA_URL=https://openvibe.media
++#SERVICES_PLAYGROUND_MAX_UPLOAD_BYTES=1048576
++#SERVICES_PLAYGROUND_RUNS_PER_HOUR=60
++#SERVICES_EXPORT_MEDIA_URL=http://127.0.0.1:4100
++#SERVICES_EXPORT_EVENTS_URL=http://127.0.0.1:4300
++#SERVICES_REGISTRY_TTL_MS=30000
++
++# ── Search engines ──────────────────────────────────────────
++# IndexNow: with a key, public release changes ping the engines and /<key>.txt is served. Unset: off.
++INDEXNOW_KEY=
# ── The authorities (plan T13 step 6) ───────────────────────
# An authority is a service whose pinned openvibe-contracts manifest lists <id>.resource.read as
@@@ -41,7 +67,6 @@ OV_OAUTH_CLIENT_SECRET=
#SERVICES_NETWORK_URL=http://127.0.0.1:4000
#SERVICES_MEDIA_URL=http://127.0.0.1:4100
#SERVICES_EVENTS_URL=http://127.0.0.1:4300
--#SERVICES_CODES_URL=http://127.0.0.1:4900
#SERVICES_HOST_URL=http://127.0.0.1:4910
# ── The resource index ──────────────────────────────────────
@@@ -57,8 +82,8 @@ SERVICES_INDEX_MAX_LIMIT=1000
SERVICES_NETWORK_TIMEOUT_MS=5000
# ── OpenVibe.Events ─────────────────────────────────────────
--# Services declares no event type yet (plan T13 step 10), so the relay is idle. Set EVENTS_URL and the
--# OAuth secret and it publishes the first registered services.* event the moment EVENT_TYPES has one.
++# The outbox relay publishes services.app.published|deprecated|revoked and services.moderation.action with
++# Services' own service token. Without EVENTS_URL (or the OAuth secret) events wait in services_events_outbox.
EVENTS_URL=
#SERVICES_EVENTS_ENABLED=1
#EVENTS_RELAY_INTERVAL_MS=2000
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bf95da0..ce3d94d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,29 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
## Unreleased
++### Changed
++
++- **The developer portal moved here from OpenVibe.Codes** (owner decision 2026-10-08; Codes became the open coding-agent
++ harness). Services is now the developer platform at `https://openvibe.services`: the console over Network's
++ projects API (projects, members, apps, credentials shown once, grants through the scope editor, usage, audit,
++ project export, the full archive and delete), app and mod releases with trust tiers and their public pages,
++ grant-respecting playgrounds, the generated reference (`/docs`: API explorer, contracts, capabilities, events,
++ SDK, ADRs, limits, billing policy, export format), the OAuth helper, the webhook tester, manifest validation, the
++ policy pages, Network SSO with PKCE, and the crawl artifacts. The code is Codes' as it ran, renamed: capabilities
++ `services.release.read|manage`, events `services.app.*` and `services.moderation.action`, kinds `services.manifest`
++ and `services.release`, schema `services.app-manifest@1` (openvibe-contracts v0.113.0, which retired the `codes.*`
++ forms); environment `SERVICES_*` for the console's settings; cookies `services_at`/`services_rt`. Production data in
++ Codes was nil, so nothing is copied: migration `0002_console.sql` creates the console's tables.
++- Services' own manifests and releases join the merged index in process (`server/registry/self-authority.js`); the
++ index's tokens are verified with the key their header names (the SDK's JWKS client, shared with sign-in).
++- One key store (`server/auth/keys.js`, openvibe-sdk/auth) replaces the index's own JWKS loader; `OV_NETWORK_PUBLIC_KEY`
++ is gone.
++- The code-of-conduct, contributing, contributor-ladder and moderation documents stay with OpenVibe.Codes, where
++ OpenVibe itself is built; `/policy/<slug>` redirects there, and the coding-harness catalog is Codes' too.
++- openvibe-contracts v0.113.0, openvibe-sdk v0.35.1, openvibe-shared v2.13.3.
++
++## Earlier (unreleased kernel)
++
### Added
- The repository kernel (plan T13 step 3): `server/config.js` on port 4930, `server/db.js` (PGlite in
ddiff --git a/README.md b/README.md
iindex f933748..c5cdb52 100644
---- a/README.md
++++ b/README.md
@@@ -1,44 +1,49 @@
# OpenVibe.Services
--The resource control plane of the network (openvibe.services): one merged index over every authority, and — as plan
--T13 lands — the console and the control operations on top of it. Design: OpenVibe.Contracts
--docs/adr/ADR-048-services-control-plane.md; plan track T13.
++> The developer platform of the OpenVibe network: one console for projects and apps over OpenVibe.Network, scoped credentials, capability grants, usage and project export; app and mod releases; API, contract, SDK and event reference generated from what production runs; OAuth and webhook tools; and one resource index over every service.
--## Purpose
--
--Every service that owns resources answers `GET /api/v1/resources` with a `common.resource-list-result@1` page of
--`common.resource-summary@1` (`ADR-048`; the resource-index sweep adds that route to each service in turn). Services is
--the one place that fans out over those authorities, merges their pages, and answers a single index — so a console,
--an operator or a developer sees every resource of every project they may see without any service learning another
--service's schema.
++**Status:** alpha. Serves `https://openvibe.services` (openvibe-ovh, unit `openvibe-services` on 127.0.0.1:4930 behind nginx). The developer portal moved here from OpenVibe.Codes on 2026-10-08 (owner decision: Codes became the open coding-agent harness); its production data then was nil (0 manifests, 0 releases, 0 playground runs), so nothing was copied, and openvibe.codes answers its old portal paths with permanent redirects to the same paths here. Tests run against in-process stand-ins for Network, Events and Media. See [STATUS.json](STATUS.json) for exactly what works and what does not.
++**Domain:** `openvibe.services` · **Port:** 4930 · **Service id:** `services`
++**Plan:** plan T13 (the developer platform and the resource control plane); binding decisions ADR-014 (the portal over Network's projects API) and ADR-048 (the control plane) in OpenVibe.Contracts.
++**License:** AGPL-3.0 (same as every OpenVibe service).
--**Services aggregates authorities; it never replaces one.** It reads each authority's resource index and keeps at
--most a read model it can rebuild; it never opens, writes or migrates another service's database, and every control
--operation (plan T13 step 10) will be a call to the authority that owns the resource. An authority, or Network, being
--down degrades the index into a partial page — it never stops it (ADR-046 §6).
++## Purpose
--This repository is at plan **T13 steps 3 and 6**: the kernel and the fan-out index. The read model and ingestion
--(step 7), the console (step 9), the control operations (step 10), recipes (step 11) and the usage/cost surfaces
--(step 12) are not built yet; this service declares them nowhere.
++A place where a developer outside the network goes from an OpenVibe account to a working, scoped integration using only public docs, the SDK and their own credentials. Services is a **console**: the identity authority (OpenVibe.Network) owns projects, apps, credentials, grants and quotas (ADR-014), and Services shows and forwards what the signed-in person asks for, with that person's own Network token.
--## Routes
++## Owns
--| Method and path | Capability | Request → answer |
--|---------------------------------------------------|---------------------------|------------------|
--| `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=` | `services.resource.read` | `common.no-body@1` → `common.resource-list-result@1`, merged over every authority; an authority a page could not read is named in the body's `partial: [{ service, code }]` |
--| `GET /api/v1/resources/:ovrn` | `services.resource.read` | `common.no-body@1` → `common.resource-summary@1` from the authority that owns the resource (URL-encode the OVRN: it carries a `/`) |
--| `GET /api/v1/authorities` | `services.resource.read` | `common.no-body@1` → `{ authorities: […] }` — the registry as this instance built it. Services-local: no contract names it yet, and it is first-party (a service token, not a person) |
--| `GET /api/health`, `GET /api/ready`, `GET /release.json` | none | liveness, truthful readiness (the database and the Network signing key; a down authority is a partial page, not a red readiness), the release manifest |
--| `GET /metrics` | none (loopback only) | Prometheus text, for a direct loopback caller only |
++- **Release metadata** keyed to Network app ids: validated app and mod manifests, releases (`draft → published → deprecated → revoked`), an append-only release log.
++- **The merged resource index** (ADR-048, `services.resource.read`): every authority's resources, read from each one and merged, never stored as the truth; Services' own manifests (`services.manifest`, `mfs_`) and releases (`services.release`, `rel_`) are read in process. Trust tiers and playground runs are metadata and audit, never resources.
++- **Trust tiers** per ADR-013 (`unreviewed`, `reviewed`, `first-party`) — **metadata only**: a tier changes defaults and discovery, never a grant check. Databases written with the earlier four names are migrated at boot (untrusted→unreviewed, verified/trusted→reviewed, platform-maintained→first-party), idempotently.
++- **Playground run logs** (who ran what, outcome, stage, problem code; never a credential).
++- The events `services.app.published`, `services.app.deprecated`, `services.app.revoked`, and `services.moderation.action` for staff revocations and trust tier changes (the network's moderation audit log), through the openvibe-sdk transactional outbox.
++- The generated reference (rendered from the pinned packages at boot; nothing hand-written that can drift) and the console's pages.
--`services.resource.read` is registered in the pinned openvibe-contracts (v0.110.0: `status: active`, `visibility:
--first-party`). Contracts' manifest and grant rules (`capabilities.check`, wildcards included) decide it, and an id
--contracts does not know is refused, never allowed. `services.resource.control` arrives with step 10.
++## Does not own
--Errors are RFC 9457 `application/problem+json`: `400 resources.bad_query | resources.bad_name |
--resources.unknown_service | resources.project_required`, `401 token.missing`, `403 capability.denied`,
--`404 resources.not_found`, `502 resources.authority_unavailable | resources.authority_error |
--resources.authority_bad_response`, `503 network.unavailable | identity.unavailable`.
++- Projects, members, apps, credentials, grants, allowances, quotas, audit: **OpenVibe.Network** (`/api/v1/projects`). Services never copies them.
++- Secrets: Network returns a client secret once; Services renders it in that one response (`Cache-Control: no-store`) and keeps no copy. Tests scan every table, every log line and every later response for each secret.
++- Token issuance and quota enforcement: Network issues tokens; the service that owns a capability enforces its quota. Services labels quotas "recorded limit — enforced by `<service>`".
++- The registry (Network) and the contracts (OpenVibe.Contracts).
++
++## What is here
++
++| Surface | Path | Notes |
++|---|---|---|
++| Portal | `/projects`, `/projects/:project`, `/projects/:project/apps/:app` | Create projects; add members; create sandbox/production apps (secret shown once); rotate and revoke credentials; request, approve, deny and revoke grants through a scope editor that offers only grantable capabilities (public, or partner when in the allowance) with each one's description, owner and visibility; quotas; **usage** (owner, admins and staff: `/projects/:project/usage?days=7|30|90&env=all|production|sandbox`, Network's `GET /api/v1/projects/:project/usage` rendered server-side: usage by service, capability and day, each recorded quota's headroom as a `<meter>`, the published limits of the services used (their `/limits.json`), errors by code and the sampled failures with their trace ids and job or event ids; the numbers come from the services' hourly `*.usage.recorded` rollups, which Network adds up per day, so the current hour is not counted yet); audit (admin+); **export** a project's metadata as one JSON document (`/projects/:project/export`: Network's project, members, apps with credential ids and hints, grants, quotas and, for admin+, audit, plus Services' releases with manifests and logs, trust tiers and playground runs; never a secret); owners and admins also download the **full archive** (`POST /projects/:project/export/archive`, one zip: that document with the audit log, the release manifests, the project's Media objects with download URLs and its namespaces, and its retained app events, for production and sandbox; read with Network's read-only export tokens; all or nothing when a service fails; [format](https://openvibe.services/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Services deletes drafts, their manifests and playground runs, and revokes published releases so installers are told). Network's errors are shown with its status, code, detail and request id. |
++| OAuth helper | `/oauth`, `/oauth/test-callback` | Explains authorization code + PKCE (S256) for apps, builds a test authorize URL, and a callback that shows `code`/`state`/`error` and **never exchanges** the code. |
++| Webhook tools | `/tools/webhooks` | Verify a delivery as a receiver that requires signature v2 does: `X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">` must match and `t` must be within ±300 s of now (the page shows the age and the window, and tells a stale-but-correct signature from a wrong one); `X-OpenVibe-Timestamp` must equal `t`. v2 only: the v1 header (`X-OpenVibe-Signature`) was retired on 2026-09-28 and has no field. In the browser with Web Crypto, or on the server without JavaScript with openvibe-sdk's `verifyDeliveryV2`; constant-time; secret dropped. Generate a sample delivery signed as Events signs it (`X-OpenVibe-Timestamp` and v2, `signDeliveryHeaders`) for any event type the contracts catalog lists. |
++| Docs | `/docs/*` | Contracts (field tables, fixtures as examples, raw schema), capability catalog (grantable ones highlighted), event types, SDK reference from its `.d.ts` files, ADRs. Every page states the versions it was generated from. The service registry is read live from Network, with health as Network reports it. |
++| Playgrounds | `/projects/:project/apps/:app/playground` | Media upload (sandbox, `media.object.upload`) and Events publish (`events.app.publish`: types `app.<project_key>.*`, source `app-<app ULID>`), run **as the app** with its own token (from its secret typed for that one request, or a pasted token). Refused — with the missing grant named, and nothing requested or called — unless Network lists the grant as approved. Sandbox apps only. |
++| Manifests | `/manifests/validate`, `/projects/:project/apps/:app/releases/new` | Validate `services.app-manifest@1` and `mods.mod-manifest@1` with openvibe-contracts; create, publish, deprecate and revoke releases. |
++| Resource index | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn`, `GET /api/v1/authorities` | The merged index over every authority (ADR-048, `services.resource.read`): see [The merged index](#the-merged-index) below. |
++| Policy | `/policy/*` | Proposal process and the decision record; compatibility and deprecation policy rendered from ADR-002 and ADR-016 as published; licensing read from package metadata; transparency (what Services stores and does not). The community documents — code of conduct, contributing, the contributor ladder and the moderation policy — are OpenVibe.Codes' (`https://openvibe.codes/policy/<slug>`); their old addresses here redirect there. |
++| API | `/api/v1/*` | Public release reads (`services.release.read`: no token needed; a presented token must hold it), manifest validation, docs versions; release management by an app with its own token (`services.release.manage`); Services' own resource index (`services.resource.read`, first-party). Guarded with openvibe-contracts `requireCapability`. RFC 9457 problems. |
++| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (db and docs required; Network, JWKS, OAuth client and events relay reported as optional checks). `/metrics` answers loopback callers only. |
++| Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo for search and AI crawlers. robots.txt keeps every disallow (the portal, staff, sign-in and the API are not crawlable) and names the sitemap; the sitemap lists the fixed public pages with STATUS.json's content date and each public app and published release with its real publish time (never "today"); llms.txt maps the site; llms-full.txt lists every fixed public page the sitemap does, one title and one line of text each. |
++
++Everything is server-rendered and usable without JavaScript. The only scripts of Services' own are optional: in-browser webhook verification and a copy button. The coding-harness catalog that used to sit under `/docs/harnesses` is OpenVibe.Codes' (`https://openvibe.codes/harnesses`).
## Who may read the index
@@@ -91,59 +96,144 @@ manifest (`internalOrigin`); `SERVICES_<ID>_URL` overrides one for the operator.
There is no polling loop: the index reads the authorities on each request (step 6). The rebuildable read model and
`*.resource.*` ingestion are step 7.
--## Owns
++## Depends on
--- The authority registry (derived from the pinned Contracts manifests, not stored) and the merged resource index.
--- Its own database (ADR-035): `migrations/0001_initial.sql` — `services_authorities`, `services_resources`,
-- `services_catalog_entries`, `services_recipes`, `services_recipe_runs`, `services_control_log`,
-- `services_events_outbox`. All of it is the console's own state or a read model it can rebuild; no table is another
-- service's.
++- **OpenVibe.Network** — SSO (OAuth client `services`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Services' own for the events relay; the app's own in playgrounds).
++- **OpenVibe.Events** — the outbox relay publishes `services.app.*` with Services' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`); the project archive pulls the project's app events with a Network export token (`events.app.read`).
++- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace; the project archive lists the project's objects, namespaces and download URLs with a Network export token (`media.object.list`, `media.object.read`).
++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
++- **openvibe-contracts v0.113.0**, **openvibe-sdk v0.35.1**, **openvibe-shared v2.13.3** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
++
++No path in Services sends or accepts a shared loopback key (tested by grep and at runtime).
++
++## Grants and registration the lead must add
++
++Implemented here (the service manifest's `capabilities`): `services.release.read` (public release reads;
++no token needed, a presented token must hold it) and `services.release.manage` (release management by an
++app with its own token), both guarded with openvibe-contracts `requireCapability`; and
++`services.resource.read` (the authority resource index, ADR-048: `GET /api/v1/resources` and
++`GET /api/v1/resources/:ovrn`), first-party — the capability is served here, so a caller presents a
++service token holding it.
++
++Called elsewhere: as the service principal `services`, `events.event.publish` at Events (the outbox relay);
++as the signed-in person, with their own Network token, Network's `/api/v1/projects`; as the app, with
++its own token, `media.object.upload` (Media) and `events.app.publish` (Events) in the playgrounds; and,
++for the project archive, Network's read-only export tokens holding `events.app.read`,
++`media.object.list` and `media.object.read`.
++
++In Network (`server/identity/principals.js` / `server/db/database.js`, as for coupons and host):
++
++- OAuth client `services`, name `OpenVibe.Services`, redirect URI `https://openvibe.services/auth/callback`.
++- Service grant `['services', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
++
++The Services service manifest, `services.release.manage|read`, `services.resource.read` and `services.app-manifest@1` are released in openvibe-contracts v0.113.0 (they replaced the `codes.*` forms, retired that release); the CI contracts check is blocking.
++
++For the playgrounds to succeed end to end (not Services' code; configuration elsewhere): Network `DEV_SANDBOX_AUDIENCES` including `openvibe.media` and `openvibe.events`, a staff-set allowance containing `media.object.upload` and `events.app.publish` for the project, a Media tenant keyed by the project id, and OpenVibe.Events serving `events.app.publish` for app tokens. On 2026-09-23 these were in place on production: a sandbox app uploaded to Media (tenant `prj_…-sandbox`) and published and read app events through public endpoints. That run used curl, not the playgrounds, and it is not yet a committed, repeatable check.
++
++## Configuration
++
++See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET`, `SERVICES_FORM_SECRET`, `BASE_URL`, `DATABASE_URL` (and `DATABASE_DIRECT_URL` for the boot migrations, run as the owner). Optional: `EVENTS_URL` (relay), `INDEXNOW_KEY` (see below), `SERVICES_STAFF_SUBJECTS`, `SERVICES_PLAYGROUND_*`, `SERVICES_REGISTRY_TTL_MS`, `SERVICES_LIMITS_MINUTE` / `SERVICES_LIMITS_HOUR`.
++
++### IndexNow (openvibe-shared/indexnow)
++
++With `INDEXNOW_KEY` set (8–128 hex or alphanumeric characters, what IndexNow's own tools
++generate), the key file is served at `/<key>.txt` as `text/plain` and every public release
++transition tells the engines: publish, deprecate and revoke of a published or deprecated release
++ping `api.indexnow.org` with the release page (while it is published), the app page, `/updates`
++and `/sitemap.xml`; the module batches and debounces these. Nothing pings for a draft, and a
++release that was never public stays silent. Unset: the feature is off — no key file, nothing sent
++(what tests and drills do). The key is not a secret in the credential sense: engines fetch it by
++design. `test/indexnow.test.js`.
++
++### Per-actor limits
++
++`/api/v1`, the console (`/projects`), release actions, the tools' forms and the staff trust form also
++limit who calls them, once the caller is known and before any work (before a form or upload is read,
++before Network is asked): `server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. An
++app token counts as its app (`app:app_…`), a signed-in person as `user:usr_…`, anyone else by address.
++Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
++`services_rate_limited_total{limit,window}`. The per-address limits (pages 300 a minute, console writes 60,
++tools 30, API writes 60, sign-in) and the playground's runs an hour stay. Services hosts no git remotes, so
++there are no clone or push routes to leave out.
++
++| Routes | Per caller, a minute / an hour |
++|---|---|
++| API reads; console pages (each asks Network with the person's token) | `SERVICES_LIMITS_MINUTE` / `SERVICES_LIMITS_HOUR` (120 / 3000) |
++| Project create | 5 / 30 |
++| Members, roles, archive, delete | 20 / 200 |
++| Project export (JSON and the full archive) | 3 / 20 |
++| App create | 10 / 60 |
++| Redirect URIs, grants, app revoke | 30 / 300 |
++| Credential rotate and revoke | 10 / 60 |
++| Playground runs (above the 60 an hour per person) | 10 / 120 |
++| Release create and "validate only" (console and API) | 20 / 200 |
++| Release publish, deprecate, revoke (console and API) | 20 / 200 |
++| Manifest validate (form and API); webhook verify and sample | 30 / 600 each |
++| Staff trust tier | 30 / 300 |
++
++Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, and the
++public pages and docs. `test/actor-limits.test.js`; the other tests boot with the per-actor limits off,
++as they raise the per-address limit.
++
++## Deploy (for the lead)
++
++Production deploys with `sudo ovhost deploy services` on the host (strategy `git-checkout`: fetch,
++fast-forward `/opt/openvibe.services`, install on a lockfile change, restart, wait for `/api/ready`).
++The unit is `openvibe-services.service` on `127.0.0.1:4930`, the env file `/etc/openvibe/services.env`. The database is
++`ov_services` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh services` writes its settings); the
++release migrates it at boot. nginx serves `openvibe.services` from
++[deploy/nginx/openvibe.services.conf](deploy/nginx/openvibe.services.conf).
++Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
++restart; afterwards `sudo ovhost rollback services --to <sha>`. A release from before the console (the bare index,
++before 2026-10-08) never reads migration 0002's tables, so a rollback across it serves the index again and the console
++pages answer 404 until the next deploy.
++
++First install (done once; kept for a rebuild):
++
++1. `git clone` to `/opt/openvibe.services`; `npm ci --omit=dev` with Node 22.
++2. `/etc/openvibe/services.env` from `.env.example` (secrets from the Network client registration).
++3. `deploy/systemd/openvibe-services.service` → `/etc/systemd/system/`; `systemctl enable --now openvibe-services`.
++4. `deploy/nginx/openvibe.services.conf` → `/etc/nginx/sites-available/`, certificate for `openvibe.services` + `www`, reload nginx.
++5. Check `curl -s http://127.0.0.1:4930/api/ready`.
++6. Only when the launch rule below holds: remove `openvibe.services` from `OpenVibe.Sites/sites.json` and switch routing (done on 2026-10-08, when the console moved here).
++
++## Development
--## Does not own
++```bash
++npm install
++fnm exec --using=22.22.1 npm test # every test/*.test.js on temp PGlite databases with mock Network, Events, Media
++fnm exec --using=22.22.1 npm run dev # http://localhost:4930
++npx openvibe-contracts-check --service services --src server
++```
--- Any other service's rows, bytes, grants, events or decisions: every resource belongs to its authority, and every
-- control operation (step 10) will be a `common.resource-control-request@1` to that authority.
--- Identity, projects, apps, keys and nodes: OpenVibe.Network. Services asks Network's developer API with the caller's
-- own token and never caches a membership decision beyond the request.
--- The persons and tokens themselves: Services verifies the tokens it is the audience of (`openvibe.services`) and
-- passes a user token through to Network only for the membership question.
++Tests: the crawl artifacts (robots.txt, sitemap.xml, llms.txt and llms-full.txt — the public pages only, llms-full.txt listing every page the sitemap does, and lastmod from STATUS.json, never the clock); ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; the authority resource index (auth matrix — no token 401, a token without `services.resource.read` 403, with it 200 — every page and read valid against `common.resource-list-result@1`/`common.resource-summary@1`, `?project=` never returning another project's rows, `?kind=`, cursor paging, `GET /:ovrn` found and 404, a bad query 400); no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released services manifest matching the code; IndexNow (off without a key, the key file served with a key, a publish pinging the page and the sitemap, a draft never pinging); and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
--## Depends on
++## Acceptance (must be true before "done")
--- PostgreSQL 18 (ADR-035): `migrations/` applies at boot on the owner role; the service then serves on the pooled
-- runtime role. Unset in development: PGlite in `data/pglite`.
--- OpenVibe.Network: the RS256 signing key (`GET /api/.well-known/jwks`), Services' OAuth client (`services`) for the
-- per-authority tokens, and the developer projects API for membership.
--- The authorities: network (`http://127.0.0.1:4000`), media (4100), events (4300), codes (4900), host (4910) at the
-- current pin. Every one of them is optional to serve, by design.
--- `openvibe-contracts` v0.110.0, `openvibe-sdk` v0.35.0, `openvibe-shared` v2.13.2 (package.json).
++- A new external developer goes from account to a working Media, event and capability integration with only public docs, the SDK and scoped credentials — no loopback key. **Partly shown: one production run on 2026-09-23 covered account, project, sandbox app, auto-approved grants, Media upload and app event publish/read with public endpoints (curl, not the SDK or the playgrounds). It is not committed as a repeatable check, and it did not cover webhook delivery to an external endpoint, publishing a release or revoking credentials.**
++- Credentials can be inspected, scoped, rotated and revoked. **Yes, through Network's API (tested against a stand-in; not yet used on production).**
++- The playground cannot exceed its project's grants. **Yes (tested).**
++- A published app or mod release carries compatibility and trust metadata. **Yes (tested).**
--## Tests
++## Launch rule
--```bash
--npm test # every file on PGlite, one process at a time
--npm run test:pg # the same on PostgreSQL + PgBouncer containers
--node test/index.test.js # one file
--```
++The domain keeps its placeholder page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the following exist (plan §12.12). **The portal left Sites as openvibe.codes on 2026-09-23 and moved to `openvibe.services` on 2026-10-08.** The items: an owning runtime with health/readiness and observability (**done**); canonical identity/auth integration (**done; the OAuth client `services` carries the redirect URI `https://openvibe.services/auth/callback`**); server-rendered public routes useful without JavaScript (**done**); real persistence and end-to-end workflows (**persistence done; the external-developer path ran once on production with curl, not yet through the console**); capability and event registration against OpenVibe.Contracts (**released**); a migration/seed strategy (none needed: no data is imported; raw old developer keys are never imported), a security review and sitemap/robots behaviour (**sitemap and robots done; the security review is the threat notes below, self-authored**); acceptance tests proving the advertised functionality (**against stand-ins**). Roadmap binding: do not present Services as a working developer platform where these paths are mocked.
++
++## Security (threat notes)
--No test needs the network or a running service: `test/helpers/stubs.js` is an in-process OpenVibe.Network (JWKS,
--`/oauth/token` with every mint recorded, project membership) and in-process authorities whose pages, timeouts and
--refusals the tests drive. The index tests cover the merged page walk with a stable cursor, the filters, the
--person-scoped path (including an authority that ignores `?project=`), a hanging and a refusing authority, the OVRN
--route, the malformed-name and unknown-service refusals, and one token mint per authority.
++Reporting a vulnerability: [SECURITY.md](SECURITY.md).
--## Deployment
++- Session tokens are httpOnly (unlike the navbar-readable `ov_token` elsewhere) because this site displays secrets; forms carry an HMAC form token and require a same-site `Origin`.
++- Secrets never reach logs: errors are logged without request bodies; playground failure details are scrubbed of the credential before they are stored or shown.
++- Services never fetches a URL a developer types (the webhook tester produces a `curl` for their own endpoint instead).
++- Grant checks happen twice: Services refuses to forward non-grantable capabilities, and Network decides. Playgrounds verify the app token's signature, subject, project, environment, audience and capability before calling anything.
--`deploy/systemd/openvibe-services.service` (WorkingDirectory `/opt/openvibe.services`, EnvironmentFile
--`/etc/openvibe/services.env`, PORT 4930) and `deploy/nginx/openvibe.services.conf`. Not deployed yet: no DNS, no
--`ovhost` entry, no `ov_services` database and no `/etc/openvibe/services.env`. The first deploy needs Services'
--Network client (`OV_OAUTH_CLIENT_ID=services` with its secret) granted every authority's `<id>.resource.read` scope —
--otherwise every authority refuses and the index is partial (correctly, and visibly).
++---
--## Versions
++Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.112.0
--- openvibe-sdk: v0.35.0
--- openvibe-shared: v2.13.2
++- openvibe-contracts: v0.113.0
++- openvibe-sdk: v0.35.1
++- openvibe-shared: v2.13.3
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex ea06de8..a48f095 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,27 +3,48 @@
"stage": "alpha",
"code": true,
"wave": "W13",
-- "plan": "OpenVibe Updated Plan Based On Codebase (plan T13, ADR-048)",
++ "plan": "OpenVibe Updated Plan Based On Codebase (plan T13, ADR-048; the developer portal of ADR-014 moved here from OpenVibe.Codes on 2026-10-08)",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",
"port": 4930,
"domain": "openvibe.services",
-- "contracts": "openvibe-contracts v0.112.0",
-- "sdk": "openvibe-sdk v0.35.0",
-- "shared": "openvibe-shared v2.13.2",
++ "contracts": "openvibe-contracts v0.113.0",
++ "sdk": "openvibe-sdk v0.35.1",
++ "shared": "openvibe-shared v2.13.3",
"packages": [
-- "openvibe-contracts v0.110.0",
-- "openvibe-sdk v0.34.0",
-- "openvibe-shared v2.12.0"
++ "openvibe-contracts v0.113.0",
++ "openvibe-sdk v0.35.1",
++ "openvibe-shared v2.13.3"
],
"capabilities": [
-- "services.resource.read"
++ "services.resource.read",
++ "services.release.read",
++ "services.release.manage"
],
"capabilitiesRegistered": true,
-- "capabilitiesNote": "services.resource.read is registered in the pinned openvibe-contracts (status active, visibility first-party): contracts' manifest and grant rules decide it and an id contracts does not know is refused, never allowed. The later services.resource.control, services.recipe.run, services.usage.read and services.cost.read arrive with their plan T13 steps.",
-- "events": [],
-- "deployed": false,
-- "deployment": "Not deployed: openvibe.services has no DNS, no ovhost inventory entry, no ov_services database and no /etc/openvibe/services.env yet. The release is deploy/ (nginx vhost deploy/nginx/openvibe.services.conf, unit deploy/systemd/openvibe-services.service, WorkingDirectory /opt/openvibe.services, EnvironmentFile /etc/openvibe/services.env, PORT 4930). The vhost proxies /api/health, /api/ready, /release.json and /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. The first deploy needs Services' Network client (OV_OAUTH_CLIENT_ID=services with its secret) granted every authority's <id>.resource.read scope, or the fan-out reports every authority partial.",
++ "capabilitiesNote": "All three are registered in the pinned openvibe-contracts v0.113.0: services.resource.read (first-party, the merged index), services.release.read (public) and services.release.manage (an app managing its own releases). They replaced codes.resource.read and codes.release.read|manage when the portal moved here. The later services.resource.control, services.recipe.run, services.usage.read and services.cost.read arrive with their plan T13 steps.",
++ "events": [
++ "services.app.published",
++ "services.app.deprecated",
++ "services.app.revoked",
++ "services.moderation.action"
++ ],
++ "deployed": true,
++ "deployment": "openvibe-ovh: unit openvibe-services.service on 127.0.0.1:4930 (git-checkout /opt/openvibe.services, env /etc/openvibe/services.env, database ov_services), nginx deploy/nginx/openvibe.services.conf serving https://openvibe.services (the Sites placeholder left on 2026-10-08).",
"features": [
++ "developer portal over Network's projects, apps, credentials, grants, quotas and audit",
++ "per-project usage page from Network's rollups",
++ "project export (JSON) and full archive (zip)",
++ "secrets shown once, never stored or logged",
++ "scope editor offering only grantable capabilities",
++ "docs generated from the pinned contracts and SDK",
++ "OAuth helper and webhook tester (signature v2)",
++ "grant-respecting Media and Events playgrounds",
++ "app and mod manifest validation and release metadata with services.app.* events",
++ "ADR-013 trust tiers",
++ "policy and governance pages (governance drafts pending owner review)",
++ "per-actor limits (openvibe-sdk/limits)",
++ "Services' own manifests and releases (services.manifest, services.release) in the merged index, read in process, with OVRN reads",
++ "crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt) and the home page's JSON-LD",
"the authority registry (server/authorities/index.js): built at boot from the pinned openvibe-contracts service manifests — every non-placeholder service whose manifest lists an active <id>.resource.read is an authority (at v0.110.0: network, events, host, codes, media), with its manifest internalOrigin (SERVICES_<ID>_URL overrides) and audience openvibe.<id>; Services itself is never an authority",
"one adapter per authority (server/authorities/adapter.js): GET /api/v1/resources and GET /api/v1/resources/:ovrn over that authority's loopback origin with a Network client-credentials token for its audience, scoped to its resource.read capability and cached (one mint per authority, refreshed 60 s before expiry; server/authorities/tokens.js)",
"the merged resource index (server/resources.js, GET /api/v1/resources?project=&kind=&service=&cursor=&limit=): openvibe-sdk/resources' createResourceIndex fans out and merges every authority's pages (per-authority cursor walk, concurrency 4, per-authority timeout), Services adds a deterministic (service, ovrn, id) total order and an opaque keyset cursor, and filters project/kind/service locally so a scoped read never shows a row outside its scope",
@@@ -35,6 +56,11 @@
"migrations/0001_initial.sql: Services' own rows only (services_authorities, services_resources, services_catalog_entries, services_recipes, services_recipe_runs, services_control_log, services_events_outbox) — Services never writes another service's database (ADR-048)"
],
"notYet": [
++ "The playgrounds have never been used on production (0 playground runs); the one production end-to-end run used curl, and it is not a committed, repeatable check",
++ "Usage counts come from the services' hourly *.usage.recorded rollups (Network adds them up per day; the current hour is not counted) and cost lives in Billing, not on the usage page; no trace or error dashboards beyond the sampled failures",
++ "Billing sandbox, Chat bot and Realtime playgrounds; CLI and scaffolding; templates",
++ "Consent screen for third-party production apps (a Network gap)",
++ "Governance material (code of conduct, contribution guide, contributor ladder, moderation policy) is written as drafts pending owner review: not in effect, not indexed",
"plan T13 step 4 done in OpenVibe.Contracts v0.110.0 (services.resource.read is registered, active and first-party), step 5 done in Network, step 7 (read model + ingestion), steps 9-15 (console, control operations, recipes, usage, deploy)"
],
"updated": "2026-10-08"
ddiff --git a/deploy/nginx/openvibe.services.conf b/deploy/nginx/openvibe.services.conf
iindex 0de9846..1b2a83f 100644
---- a/deploy/nginx/openvibe.services.conf
++++ b/deploy/nginx/openvibe.services.conf
@@@ -2,29 +2,28 @@
## openvibe.services — Nginx configuration (REFERENCE)
##
## Deployed to /etc/nginx/sites-available/openvibe.services.conf — keep them in sync and reload nginx
--## after changing it. The Node app (port 4930, systemd unit openvibe-services) serves:
++## after changing it. The Node app (port 4930, systemd unit openvibe-services) renders every page and
++## answers the API (the console and docs moved here from openvibe.codes on 2026-10-08); nginx terminates
++## TLS, redirects www → apex, rate-limits sign-in, forms and API, and compresses text.
##
--## /api/v1/resources… the resource index (services.resource.read); every request needs a
--## Network service token for audience openvibe.services, or a person's
--## user token scoped to one project they own or belong to
--## /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 console (plan T13 step 9) is not served yet: today every other path is a 404 problem+json.
++## /api/v1/resources… the merged resource index (services.resource.read): a Network service token for
++## audience openvibe.services, or a person's token scoped to one project
++## /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.
++## the header is set here, never appended to or passed through from the client.
##
--## No proxy_cache: the index answers no-store (a partial page must never be cached as if it were whole).
--## Never log request bodies or the Authorization header: a service token and a person's token both ride
--## in one, and the index passes the caller's token to no one but Network (project membership).
++## No proxy_cache: the app decides cacheability per response (public only for docs and policy pages
++## rendered for anonymous visitors; private, no-store for everything personal, and no-store for the
++## one page that shows a new client secret, and for the index). Never log request bodies or the
++## Authorization header here: forms carry secrets, and tokens ride in that header.
## ═══════════════════════════════════════════════════════════════
--limit_req_zone $binary_remote_addr zone=ovservices_api:10m rate=20r/s;
++limit_req_zone $binary_remote_addr zone=ovservices_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=ovservices_forms:10m rate=2r/s;
++limit_req_zone $binary_remote_addr zone=ovservices_api:10m rate=5r/s;
--# ── HTTP → HTTPS ────────────────────────────────────────────
server {
listen 80;
listen [::]:80;
@@@ -34,7 +33,6 @@ server {
location / { return 301 https://openvibe.services$request_uri; }
}
--# ── www → apex ──────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
@@@ -48,12 +46,12 @@ server {
return 301 https://openvibe.services$request_uri;
}
--# ── The service ─────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name openvibe.services;
++ include snippets/security-txt.conf; # /.well-known/security.txt (host file; SECURITY.md in every repo)
ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;
@@@ -62,11 +60,10 @@ server {
access_log /var/log/nginx/openvibe.services.access.log;
error_log /var/log/nginx/openvibe.services.error.log;
-- # The API answers query strings and small JSON bodies only (server/app.js, 64 kB).
-- client_max_body_size 1m;
++ # Playground uploads are at most 1 MB (SERVICES_PLAYGROUND_MAX_UPLOAD_BYTES); forms are small.
++ client_max_body_size 2m;
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;
@@@ -74,40 +71,52 @@ server {
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
-- gzip_types application/json application/problem+json text/plain;
++ gzip_types text/css application/javascript text/javascript application/json application/problem+json application/schema+json image/svg+xml text/plain application/xml;
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 X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
-- # ── Health, readiness, the release manifest ─────────────
-- location = /api/health { proxy_pass http://127.0.0.1:4930; }
-- location = /api/ready { proxy_pass http://127.0.0.1:4930; }
-- location = /release.json { proxy_pass http://127.0.0.1:4930; }
++ # ── Network SSO session endpoints (strict) ──────────────
++ # The session probe: the shared navbar asks it on every page view, so it cannot share the sign-in
++ # budget below (10 a minute): a visitor reading quickly was answered 503 and looked signed out
++ # (found by the browser check, OpenVibe.Host scripts/browser-check.js).
++ location = /auth/me {
++ limit_req zone=ovservices_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4930;
++ }
++
++ location /auth/ {
++ limit_req zone=ovservices_auth burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4930;
++ }
++
++ # ── API ─────────────────────────────────────────────────
++ location /api/ {
++ limit_req zone=ovservices_api burst=20 nodelay;
++ proxy_pass http://127.0.0.1:4930;
++ }
++
++ # ── State-changing forms (projects, releases, tools, staff) ──
++ location ~ ^/(projects|releases|tools|manifests|staff) {
++ limit_req zone=ovservices_forms burst=20 nodelay;
++ proxy_pass http://127.0.0.1:4930;
++ }
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4930/metrics) ──
location = /metrics { return 404; }
--
-- # ── Host-local service endpoints ────────────────────────
location /internal/ { return 404; }
-- # ── The resource index ──────────────────────────────────
-- # The fan-out is slow by nature (it reads every authority): give it a generous read timeout while
-- # the app's own per-authority timeout keeps one slow authority from holding the page (it lands in
-- # the page's `partial` array instead).
-- location /api/ {
-- limit_req zone=ovservices_api burst=40 nodelay;
-- limit_req_status 429;
++ # ── Pages, docs, robots.txt, sitemap, static assets ─────
++ location / {
proxy_pass http://127.0.0.1:4930;
-- proxy_read_timeout 30s;
}
-- # ── Nothing else is served yet (the console is plan T13 step 9) ──
-- location / { return 404; }
--
location ~ /\.(?!well-known) {
deny all;
}
ddiff --git a/deploy/systemd/openvibe-services.service b/deploy/systemd/openvibe-services.service
iindex 0cb282d..ec3d04f 100644
---- a/deploy/systemd/openvibe-services.service
++++ b/deploy/systemd/openvibe-services.service
@@@ -1,11 +1,11 @@
[Unit]
--Description=OpenVibe.Services — the resource control plane (authority registry, resource index; plan T13, ADR-048)
++Description=OpenVibe.Services — the developer platform (the console over Network's projects API, generated docs, tools, releases, the resource index; plan T13, ADR-048)
# Network (sign-in, JWKS, OAuth, projects) should be up first, and Services' own PostgreSQL (pooled
--# runtime role plus the direct owner role for migrations) is required to serve. The authorities are
--# NOT required: an authority being down degrades the index into a partial page, it never stops Services
--# (ADR-046 section 6), so nothing here Wants their units.
--After=network.target postgresql.service pgbouncer.service
--Wants=openvibe-network.service postgresql.service pgbouncer.service
++# runtime role plus the direct owner role for migrations) is required to serve. Services degrades honestly
++# without the rest: docs, tools and policy pages serve; project pages show Network's failure; events wait in
++# the outbox; an authority being down makes the index a partial page (ADR-046 section 6), never a stop.
++After=network.target postgresql.service pgbouncer.service valkey-server.service
++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
Type=simple
ddiff --git a/migrations/0002_console.sql b/migrations/0002_console.sql
nnew file mode 100644
iindex 0000000..4612da3
---- /dev/null
++++ b/migrations/0002_console.sql
@@@ -0,0 +1,92 @@
++-- phase: expand
++-- The console's own records (ADR-014), moved here from OpenVibe.Codes on 2026-10-08 with the developer portal: validated
++-- app and mod manifests, releases and their append-only log, trust tiers (metadata, never authority) and playground run
++-- logs. Projects, members, apps, credentials, grants and quotas stay OpenVibe.Network's. The tables are Codes' exactly as
++-- its migration 0001 created them (text COLLATE "C", bigint identities), so its rows copy across unchanged; events go
++-- through Services' own outbox (services_events_outbox, migration 0001).
++
++CREATE TABLE manifests (
++ id text COLLATE "C" PRIMARY KEY, -- mfs_<ULID>
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('app', 'mod')),
++ app_id text COLLATE "C" NOT NULL, -- Network app (app_<ULID>) that publishes it
++ project_id text COLLATE "C" NOT NULL, -- Network project (prj_<ULID>) at the time of creation
++ subject_id text COLLATE "C" NOT NULL, -- the manifest's own id (app_… or mod_…)
++ version text COLLATE "C" NOT NULL,
++ body text COLLATE "C" NOT NULL, -- the manifest JSON exactly as validated
++ contracts_version text COLLATE "C" NOT NULL, -- openvibe-contracts release it was validated with
++ created_by text COLLATE "C" NOT NULL, -- user:usr_… or app:app_…
++ created_at text COLLATE "C" NOT NULL
++);
++CREATE INDEX idx_manifests_app ON manifests(app_id);
++
++CREATE TABLE releases (
++ id text COLLATE "C" PRIMARY KEY, -- rel_<ULID>
++ app_id text COLLATE "C" NOT NULL,
++ project_id text COLLATE "C" NOT NULL,
++ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('app', 'mod')),
++ subject_id text COLLATE "C" NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ version text COLLATE "C" NOT NULL,
++ manifest_id text COLLATE "C" NOT NULL REFERENCES manifests(id),
++ status text COLLATE "C" NOT NULL CHECK (status IN ('draft', 'published', 'deprecated', 'revoked')),
++ compatibility text COLLATE "C" NOT NULL DEFAULT '{}',
++ notes text COLLATE "C" NOT NULL DEFAULT '',
++ created_by text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ published_by text COLLATE "C", published_at TEXT,
++ deprecated_by text COLLATE "C", deprecated_at TEXT, deprecation_reason TEXT, replacement TEXT,
++ revoked_by text COLLATE "C", revoked_at TEXT, revocation_reason TEXT,
++ UNIQUE (app_id, kind, subject_id, version)
++);
++CREATE INDEX idx_releases_app ON releases(app_id, created_at);
++
++CREATE TABLE release_log (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ release_id text COLLATE "C" NOT NULL,
++ action text COLLATE "C" NOT NULL,
++ actor text COLLATE "C" NOT NULL,
++ at text COLLATE "C" NOT NULL,
++ detail text COLLATE "C" NOT NULL DEFAULT '{}'
++);
++CREATE INDEX idx_release_log_release ON release_log(release_id, id);
++CREATE FUNCTION release_log_append_only_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'release_log is append-only'; END $$;
++CREATE TRIGGER release_log_append_only_update BEFORE UPDATE ON release_log FOR EACH ROW EXECUTE FUNCTION release_log_append_only_update();
++CREATE FUNCTION release_log_append_only_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'release_log is append-only'; END $$;
++CREATE TRIGGER release_log_append_only_delete BEFORE DELETE ON release_log FOR EACH ROW EXECUTE FUNCTION release_log_append_only_delete();
++
++CREATE TABLE trust (
++ app_id text COLLATE "C" PRIMARY KEY,
++ tier text COLLATE "C" NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),
++ note text COLLATE "C" NOT NULL DEFAULT '',
++ set_by text COLLATE "C" NOT NULL,
++ set_at text COLLATE "C" NOT NULL
++);
++CREATE TABLE trust_history (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ app_id text COLLATE "C" NOT NULL,
++ from_tier text COLLATE "C" NOT NULL,
++ to_tier text COLLATE "C" NOT NULL,
++ note text COLLATE "C" NOT NULL DEFAULT '',
++ set_by text COLLATE "C" NOT NULL,
++ set_at text COLLATE "C" NOT NULL
++);
++
++CREATE TABLE playground_runs (
++ id text COLLATE "C" PRIMARY KEY, -- run_<ULID>
++ at text COLLATE "C" NOT NULL,
++ actor text COLLATE "C" NOT NULL, -- user:usr_…
++ project_id text COLLATE "C" NOT NULL,
++ app_id text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('events', 'media')),
++ capability text COLLATE "C" NOT NULL,
++ credential text COLLATE "C" NOT NULL CHECK (credential IN ('none', 'client_secret', 'access_token')),
++ outcome text COLLATE "C" NOT NULL CHECK (outcome IN ('ok', 'refused', 'failed')),
++ stage text COLLATE "C" NOT NULL, -- app | grant | token | call | done
++ http_status bigint,
++ code text COLLATE "C",
++ detail text COLLATE "C" NOT NULL DEFAULT '',
++ ref text COLLATE "C" -- event_id or media key on success
++);
++CREATE INDEX idx_runs_app ON playground_runs(app_id, at);
++CREATE INDEX idx_runs_actor ON playground_runs(actor, at);
ddiff --git a/package-lock.json b/package-lock.json
iindex 759edc1..f34a82f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -9,13 +9,16 @@
"version": "0.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
++ "busboy": "^1.6.0",
++ "cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.113.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.3",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -33,6 +36,12 @@
"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",
@@@ -115,6 +124,17 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
++ "node_modules/busboy": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
++ "dependencies": {
++ "streamsearch": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=10.16.0"
++ }
++ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@@ -153,6 +173,15 @@
"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",
@@@ -183,6 +212,25 @@
"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-parser/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/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@@ -198,6 +246,15 @@
"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",
@@@ -563,6 +620,49 @@
"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",
@@@ -627,12 +727,24 @@
"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",
@@@ -778,9 +890,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.112.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
-- "integrity": "sha512-ujIdCjv+oHfwg/+Hw0tyt2erVnornjDiKVPN8snHvybfysR/DBZQ2G32bQj8oAaK90s12ADEpdrXT1eust3eAw==",
++ "version": "0.113.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.113.0",
++ "integrity": "sha512-HdSr3C27r29ciHabb8cFqqZ0658Ki3zbYSz1461xjO4knktweGgJsefJnhGTx7iQBbbJ7XDCAUoTnvouuotNYg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -794,9 +906,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.35.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
-- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",
++ "version": "0.35.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
++ "integrity": "sha512-id+GBVYaTczHL6ZyOZJTE16KSJH/VNaEo3aIk9dxDdZSYHD2zWnSQlSfEfA+ffo6rgzoSm09/AqaR+a4y8QEPw==",
"license": "MIT",
"engines": {
"node": ">=20"
@@@ -823,9 +935,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.13.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",
-- "integrity": "sha512-rvLq6uKd0j84qxZXwGX+gE6+e8SJaP8bBilWu9Eosjgfc70Z4bPJOUJUiLNRte7fU5hW/ea97/JoCLdiwXmPQA==",
++ "version": "2.13.3",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.3",
++ "integrity": "sha512-pKRAMWWh5iJJwgOY3Hr0N+6yRa0j7FASDozeVz9LBxsJ65gMLXXHfdhrWpGuke42tzRt/234ATHk9vQdTmWNNA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
@@@ -1034,6 +1146,27 @@
"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",
@@@ -1213,6 +1346,12 @@
"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",
@@@ -1222,6 +1361,14 @@
"node": ">= 0.8"
}
},
++ "node_modules/streamsearch": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
ddiff --git a/package.json b/package.json
iindex f025728..7df7af6 100644
---- a/package.json
++++ b/package.json
@@@ -20,13 +20,16 @@
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Services",
"dependencies": {
++ "busboy": "^1.6.0",
++ "cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.2",
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.113.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.3",
"pg": "^8.23.0"
},
"devDependencies": {
ddiff --git a/public/css/services.css b/public/css/services.css
nnew file mode 100644
iindex 0000000..ba71541
---- /dev/null
++++ b/public/css/services.css
@@@ -0,0 +1,81 @@
++/* OpenVibe.Services — theme-token driven (openvibe-shared presets). Every page works without JavaScript. */
++:root { color-scheme: dark; }
++* { box-sizing: border-box; }
++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++a { color: var(--accent-light, #60a5fa); }
++a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.skip { position: absolute; left: -9999px; }
++.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
++.account-bar { max-width: 1040px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
++.page { max-width: 1040px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
++h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }
++h1 small { font-size: .95rem; color: var(--text-secondary, #96a7c2); font-weight: 400; }
++h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
++h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }
++.lead { color: var(--text-secondary, #96a7c2); font-size: 1.08rem; }
++.muted { color: var(--text-muted, #7386a3); }
++.small { font-size: .85rem; }
++.versions { color: var(--text-secondary, #96a7c2); font-size: .9rem; }
++.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }
++code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }
++code { overflow-wrap: anywhere; }
++pre { background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; }
++pre code { overflow-wrap: normal; }
++.table-wrap { overflow-x: auto; }
++table { border-collapse: collapse; width: 100%; font-size: .92rem; }
++th, td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); }
++th { color: var(--text-secondary, #96a7c2); font-weight: 600; }
++a.grantable { font-weight: 600; }
++table.caps tr:has(a.grantable) { background: color-mix(in srgb, var(--success, #22c55e) 8%, transparent); }
++.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }
++.facts dt { color: var(--text-secondary, #96a7c2); }
++.facts dd { margin: 0; overflow-wrap: anywhere; }
++ul.plain { list-style: none; padding: 0; margin: 0; }
++ul.cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
++ul.cards li { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 14px; background: var(--bg-card, #131c2e); display: flex; flex-direction: column; gap: 4px; }
++ul.cards span { color: var(--text-secondary, #96a7c2); font-size: .9rem; }
++ol.steps li { margin: .4rem 0; }
++.badge { display: inline-block; font-size: .75rem; padding: 0 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); white-space: nowrap; }
++.badge.ok { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
++.badge.warn { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }
++.badge.bad { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
++/* API explorer: one block per route, the method as a badge */
++.api-op { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 10px 14px; margin: 12px 0; background: var(--bg-card, #131c2e); scroll-margin-top: 72px; }
++.api-op h3 { margin: 0 0 4px; font-size: 1rem; overflow-wrap: anywhere; }
++.api-op .facts { margin: 6px 0; }
++.badge.method-get { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
++.badge.method-post, .badge.method-put, .badge.method-patch { border-color: var(--accent-light, #60a5fa); color: var(--accent-light, #60a5fa); }
++.badge.method-delete { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
++.notice, .problem, .result { border-left: 3px solid var(--info, #38bdf8); padding: 8px 12px; margin: 12px 0; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
++.notice.ok, .result.ok { border-left-color: var(--success, #22c55e); }
++.notice.warn { border-left-color: var(--warning, #f59e0b); }
++.notice.bad, .problem, .result.bad { border-left-color: var(--error, #ef4444); }
++.problem p, .result p, .notice p { margin: .3rem 0; }
++button, .button { font: inherit; display: inline-block; padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-tertiary, #172238); color: var(--text-primary, #e6edf7); cursor: pointer; text-decoration: none; }
++button:hover, .button:hover { border-color: var(--accent, #3b82f6); }
++button.danger { border-color: var(--error, #ef4444); }
++input, select, textarea { font: inherit; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); max-width: 100%; }
++textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
++form.stack { display: flex; flex-direction: column; gap: 10px; max-width: 760px; margin: 10px 0; }
++form.stack label { display: flex; flex-direction: column; gap: 4px; }
++form.stack label:has(input[type=checkbox]), form.stack label:has(input[type=radio]) { flex-direction: row; align-items: center; gap: 8px; }
++form.stack button { align-self: flex-start; }
++form.inline-form { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 2px 0; }
++form.inline-box { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; }
++fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; }
++.secret-box { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--warning, #f59e0b); border-radius: 10px; padding: 12px 14px; max-width: 760px; }
++.secret-box label { display: flex; flex-direction: column; gap: 4px; }
++.secret-box input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; }
++.decl { border-top: 1px solid var(--border, #1f2d47); }
++.decl h2 small { color: var(--text-muted, #7386a3); font-weight: 400; font-size: .85rem; }
++.toc ul { columns: 2; font-size: .9rem; }
++.prose { max-width: 820px; }
++.buttons { display: flex; gap: 8px; flex-wrap: wrap; }
++@media (max-width: 640px) { .facts { grid-template-columns: 1fr; } .facts dt { margin-top: 6px; } .toc ul { columns: 1; } }
++.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
++meter.headroom { width: 8rem; max-width: 100%; vertical-align: middle; }
++
++/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */
++.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
++.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
++.services-hero-code { margin: 0; padding: 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); font-size: 13px; line-height: 1.6; overflow: auto; }
ddiff --git a/public/js/copy.js b/public/js/copy.js
nnew file mode 100644
iindex 0000000..a7a4dfe
---- /dev/null
++++ b/public/js/copy.js
@@@ -0,0 +1,15 @@
++// OpenVibe.Services — optional copy buttons (the value is always selectable without JavaScript).
++(function () {
++ 'use strict';
++ document.querySelectorAll('button.copy[data-copy]').forEach(function (btn) {
++ var target = document.querySelector(btn.getAttribute('data-copy'));
++ if (!target || !navigator.clipboard) return;
++ btn.hidden = false;
++ btn.addEventListener('click', function () {
++ navigator.clipboard.writeText(target.value || target.textContent || '').then(function () {
++ btn.textContent = 'Copied';
++ setTimeout(function () { btn.textContent = 'Copy secret'; }, 2000);
++ }, function () { target.select && target.select(); });
++ });
++ });
++})();
ddiff --git a/public/js/webhook.js b/public/js/webhook.js
nnew file mode 100644
iindex 0000000..55dbeef
---- /dev/null
++++ b/public/js/webhook.js
@@@ -0,0 +1,92 @@
++// OpenVibe.Services — webhook signature checks computed in the browser (Web Crypto), so the secret
++// never leaves this page. Without JavaScript (or Web Crypto) the form posts to Services instead,
++// which computes the same HMAC with openvibe-sdk and forgets the secret.
++//
++// v2: X-OpenVibe-Signature-V2 = t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
++// A receiver requiring v2 accepts only a matching v2 within ±300 s of its clock. (The v1 header,
++// an HMAC of the body alone, was retired on 2026-09-28.)
++(function () {
++ 'use strict';
++ var WINDOW_SEC = 300;
++ var form = document.getElementById('verify-form');
++ var out = document.getElementById('verify-result');
++ if (!form || !out || !window.crypto || !window.crypto.subtle || !window.TextEncoder) return;
++ var enc = new TextEncoder();
++
++ function hex(buf) {
++ return Array.prototype.map.call(new Uint8Array(buf), function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
++ }
++ // Compare every character regardless of where the first difference is.
++ function equal(a, b) {
++ if (a.length !== b.length) return false;
++ var diff = 0;
++ for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
++ return diff === 0;
++ }
++ // t and v2 values from the header, parsed as openvibe-sdk does; null when malformed.
++ function parseV2(header) {
++ var t = null, values = [], parts = header.split(',');
++ for (var i = 0; i < parts.length; i++) {
++ var at = parts[i].indexOf('=');
++ if (at < 0) return null;
++ var k = parts[i].slice(0, at).trim(), v = parts[i].slice(at + 1).trim();
++ if (k === 't') {
++ if (t !== null || !/^\d{1,12}$/.test(v)) return null;
++ t = Number(v);
++ } else if (k === 'v2') values.push(v);
++ }
++ return t === null || !values.length ? null : { t: t, values: values };
++ }
++ function show(ok, lines) {
++ out.textContent = '';
++ var box = document.createElement('div');
++ box.className = 'result ' + (ok ? 'ok' : 'bad');
++ box.setAttribute('role', 'status');
++ lines.forEach(function (l, i) {
++ var p = document.createElement('p');
++ if (i === 0) { var s = document.createElement('strong'); s.textContent = l; p.appendChild(s); } else { p.textContent = l; }
++ box.appendChild(p);
++ });
++ out.appendChild(box);
++ }
++
++ form.addEventListener('submit', function (e) {
++ e.preventDefault();
++ var body = form.elements.body.value.replace(/\r\n/g, '\n');
++ var given2 = form.elements.signature_v2.value.trim();
++ var stated = form.elements.timestamp.value.trim();
++ var secret = form.elements.secret.value;
++ if (!body || !secret) { show(false, ['Enter the raw body and the secret.']); return; }
++ var parsed = given2 ? parseV2(given2) : null;
++ var nowSec = Date.now() / 1000;
++ crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
++ .then(function (key) {
++ return parsed ? crypto.subtle.sign('HMAC', key, enc.encode(parsed.t + '.' + body)) : null;
++ })
++ .then(function (sig2) {
++ var lines = [];
++ var accepted = false, v2Line;
++ if (!given2) {
++ v2Line = 'v2: not given. Events sends X-OpenVibe-Signature-V2 on every delivery; a receiver that requires v2 refuses a delivery without it.';
++ } else if (!parsed) {
++ v2Line = 'v2: malformed. It must look like t=<unix seconds>,v2=<64 lowercase hex characters>.';
++ } else {
++ var hex2 = hex(sig2);
++ var sigOk = parsed.values.some(function (v) { return equal(v, hex2); });
++ var age = Math.floor(nowSec) - parsed.t;
++ var inside = Math.abs(nowSec - parsed.t) <= WINDOW_SEC;
++ var agree = !stated || stated === String(parsed.t);
++ accepted = sigOk && inside && agree;
++ v2Line = 'v2: signature ' + (sigOk ? 'matches' : 'does NOT match') + '; t=' + parsed.t + ' is ' + (age >= 0 ? age + ' s ago' : (-age) + ' s in the future') +
++ ', ' + (inside ? 'inside' : 'OUTSIDE') + ' the ±' + WINDOW_SEC + ' s window by your clock' +
++ (stated ? '; X-OpenVibe-Timestamp ' + (agree ? 'equals t=' : 'DIFFERS from t= (' + stated + ')') : '') + '.';
++ lines.push('Expected v2: t=' + parsed.t + ',v2=' + hex2);
++ }
++ lines.unshift(v2Line);
++ lines.unshift(accepted ? 'Accepted: v2 verifies and is inside the window.' : 'Refused: a receiver that requires v2 rejects this delivery.');
++ lines.push('Computed in your browser; nothing was sent.');
++ show(accepted, lines);
++ })
++ .catch(function () { form.submit(); });
++ });
++})();
ddiff --git a/server/app.js b/server/app.js
iindex b51a702..164bfb0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -1,103 +1,233 @@
'use strict';
/**
-- * OpenVibe.Services — the resource control plane (ADR-048, plan T13). Express app factory;
-- * server/index.js listens, tests build their own instance. Everything is injectable and nothing starts
-- * at module load (the key refresher and the outbox relay start in server/index.js main()).
++ * OpenVibe.Services — the developer platform (ADR-048, plan T13; the developer portal moved here from OpenVibe.Codes
++ * on 2026-10-08). Express app factory; server/index.js listens and starts the background work, tests build their
++ * own instance with a temp database, an injectable clock and mock neighbours. Nothing starts at module load.
*
-- * GET /api/health, /api/ready, /release.json liveness, truthful readiness, the release manifest
-- * GET /metrics direct loopback callers only (never proxied)
-- * /api/v1/* the resource index (api/v1.js: one capability per route)
++ * / landing, policy, public release pages, staff trust (http/pages.js)
++ * /docs generated reference (http/docs.js)
++ * /oauth, /tools/webhooks, /manifests/validate developer tools (http/tools.js)
++ * /projects the signed-in console over Network's projects API (http/portal.js)
++ * /releases/:id/* release actions (http/portal.js)
++ * /api/v1/resources, /api/v1/authorities the merged resource index over every authority (api/v1.js)
++ * /api/v1 the release API and the docs' JSON (http/api.js)
++ * /auth/* Network SSO with PKCE (auth/sso.js)
++ * /api/health, /api/ready, /release.json, /metrics (loopback only)
*
-- * createApp({ config, db, keys, userAuth, membership, authorities, tokens, index, outbox, registry,
-- * fetchImpl, now, log })
++ * createApp({ config, store, now, fetchImpl, log, keys, membership, authorities, tokens, index, registry,
++ * limitsNow, actorLimits, indexnow, valkey }) → { app, ctx }
*/
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 contracts = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
++const { createRegistry, instrument } = require('openvibe-shared/metrics');
++
const { loadConfig } = require('./config');
--const { openDb } = require('./db');
--const { createKeyProvider, createUserAuth, createMembership } = require('./network');
++const { openDb, migrate, createStore } = require('./db');
++const { createKeyStore } = require('./auth/keys');
++const { createSso } = require('./auth/sso');
++const { createApiAuth } = require('./auth/api');
++const { createUserAuth, createMembership } = require('./network');
++const { createNetworkClient } = require('./clients/network');
++const { createServicesOutbox } = require('./events/outbox');
const { createAuthorities } = require('./authorities');
const { createAuthorityTokens } = require('./authorities/tokens');
const { createResourceIndexService } = require('./resources');
--const { createServicesOutbox } = require('./events/outbox');
--const { createApiAuth } = require('./auth');
++const { createSelfAuthority } = require('./registry/self-authority');
const { v1Router } = require('./api/v1');
++const { generate } = require('./docs/generate');
++const { createTrust } = require('./domain/trust');
++const { createReleases } = require('./domain/releases');
++const { createPlayground } = require('./domain/playground');
++const { createArchiver } = require('./domain/project-archive');
++const { createLimitsReader } = require('./domain/limits');
++const { createDocsRoutes } = require('./http/docs');
++const { createToolRoutes } = require('./http/tools');
++const { createPageRoutes } = require('./http/pages');
++const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
++const { createApi } = require('./http/api');
const { createServicesReadiness, registerServicesGauges } = require('./observability');
++const { createActorLimits } = require('./http/actor-limits');
++const { createIndexNow } = require('openvibe-shared/indexnow');
++const { assetVersion, send } = require('./render/layout');
++const { html } = require('./render/html');
const { ServiceError } = require('./util');
--const { createRegistry, instrument } = require('openvibe-shared/metrics');
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--// The API takes query strings and (later) small JSON bodies; nothing here uploads.
--const JSON_LIMIT = '64kb';
++// The resource index takes query strings; nothing there uploads.
++const INDEX_JSON_LIMIT = '64kb';
--function createApp(opts = {}) {
++async function createApp(opts = {}) {
const config = opts.config || loadConfig();
-- const db = opts.db || openDb(config);
-- 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 membership = opts.membership || createMembership(config, { fetchImpl });
++ const fetchImpl = opts.fetchImpl;
++ const registry = opts.registry || createRegistry();
++ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, server/index.js) hands in a store.
++ let store = opts.store;
++ if (!store) {
++ const db = openDb(config, { registry, log });
++ await migrate(config, { serving: db, log });
++ store = createStore(db, { now: opts.now });
++ }
++
++ const docs = generate({ now: store.now });
++ const keys = opts.keys || createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
++ const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
++ const network = createNetworkClient({ config, fetchImpl, log });
++ const outbox = createServicesOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
++ const trust = createTrust({ store, outbox });
++ // IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY. Unset → off, nothing
++ // mounted, nothing sent; tests and drills never set it.
++ const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({
++ host: config.baseUrl, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log,
++ });
++ const releases = createReleases({ store, outbox, trust, config, indexnow });
++ const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
++ const archiver = createArchiver({ config, fetchImpl, log });
++ // Each enforcing service's /limits.json, for /docs/limits and the project usage page.
++ const limits = createLimitsReader();
++
++ // The merged resource index: every authority the pinned contracts name, read over HTTP with a token minted
++ // for its audience, plus Services' own manifests and releases read in process.
const authorities = opts.authorities || createAuthorities(config, { log });
-- const tokens = opts.tokens || createAuthorityTokens(config, { authorities, fetchImpl });
-- const index = opts.index || createResourceIndexService({ config, authorities, tokens, fetchImpl, log });
-- const outbox = opts.outbox || createServicesOutbox({ db, config, fetchImpl, now, log });
++ const tokens = opts.tokens || createAuthorityTokens(config, { authorities, fetchImpl: fetchImpl || globalThis.fetch });
++ const index = opts.index || createResourceIndexService({ config, authorities, tokens, self: createSelfAuthority({ store }), fetchImpl: fetchImpl || globalThis.fetch, log });
++ const userAuth = createUserAuth(config, keys);
++ const membership = opts.membership || createMembership(config, { fetchImpl: fetchImpl || globalThis.fetch });
const apiAuth = createApiAuth({ config, keys, userAuth, membership });
-- const release = require('openvibe-shared/release').createRelease({ service: 'services', root: path.join(__dirname, '..') });
++
++ const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, indexnow, authorities, tokens, index, apiAuth, log };
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
-- // Services answers JSON only for now (the console, plan T13 step 9, will widen this): helmet's
-- // defaults carry what matters and there is no page yet whose CSP would need widening.
-- app.use(helmet({ contentSecurityPolicy: false }));
--
-- const registry = opts.registry || createRegistry();
++ const release = require('openvibe-shared/release').createRelease({ service: 'services', root: path.join(__dirname, '..') });
++ require('./render/layout').setRelease(release.release);
registerServicesGauges(registry, { authorities });
const metrics = instrument(app, { service: 'services', release: release.release, registry });
-- app.use(http.middleware());
++ app.locals.metrics = metrics.registry;
++ app.locals.ctx = ctx;
++ // Per-actor limits (http/actor-limits.js) at /api/v1, the console, release actions and the tools'
++ // forms, counted once the caller is known; the per-address limits below stay.
++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
++ ctx.valkey = valkey;
++ ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.actorLimits !== false, valkey });
-- // 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.use(contracts.http.middleware());
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ // The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
++ // Cloudflare Web Analytics: Cloudflare injects its beacon at the edge and the privacy text says it may
++ // measure performance; script-src loads the beacon, connect-src is where it reports.
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ // openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
++ frameSrc: ["'self'", 'https://openvibe.network'],
++ frameAncestors: ["'none'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ },
++ },
++ frameguard: { action: 'deny' },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'same-site' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // ── Machine endpoints ───────────────────────────────────
++ // Liveness: the process answers. What it can and cannot do is /api/ready's job.
app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({
-- ok: true, service: 'services', version: VERSION, authorities: authorities.size(), events,
++ status: 'ok', service: 'services', version: VERSION, authorities: authorities.size(), events,
}), next));
-- const readiness = createServicesReadiness({ db, keys, authorities, release: release.release });
-- app.get('/api/ready', readiness.handler);
++ // GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
++ const readiness = createServicesReadiness({ store, network, outbox, docs, config, authorities, release: release.release, valkey: ctx.valkey });
++ app.get('/api/ready', readiness.handler);
-- app.use('/api/v1',
++ // ── The merged resource index (ADR-048: services.resource.read) ──
++ // Its own auth (a service token, or a person's token scoped to one project) and JSON-only answers; mounted
++ // before the session middleware and the release API, so nothing of the browser side applies to it.
++ const kernel = express.Router();
++ kernel.use(
// A per-address cap: the fan-out reaches every authority, so a caller loop here is every
// authority's load too; a refusal is an RFC 9457 problem like every other answer, never text.
rateLimit({
windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false,
-- handler: (req, res) => http.sendProblem(res, 429, 'services.rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
++ handler: (req, res) => contracts.http.sendProblem(res, 429, 'services.rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}),
-- express.json({ limit: JSON_LIMIT }),
++ express.json({ limit: INDEX_JSON_LIMIT }),
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
apiAuth.middleware,
v1Router({ config, apiAuth, index, authorities }));
++ app.use('/api/v1', (req, res, next) => (/^\/(resources|authorities)(\/|$)/.test(req.path) ? kernel(req, res, next) : next()));
++
++ // ── Who is asking (verified offline; refreshed when expired) ──
++ app.use(sso.middleware());
++
++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', sso.routes());
++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'services', service: 'services', host: 'openvibe.services', name: 'OpenVibe.Services', profile: 'ugc' })); }
++
++ // GET /<key>.txt — the IndexNow key file (mounted only when a key is configured; it serves itself).
++ if (indexnow.enabled) app.use(indexnow.keyFile);
++
++ // ── Static assets (content-hashed ?v= → immutable) ──────
++ // This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
++ app.use('/shared', require('openvibe-shared/serve').handler());
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, redirect: false,
++ setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ const v = res.req && res.req.query && res.req.query.v;
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
++ },
++ }));
++
++ // ── API ─────────────────────────────────────────────────
++ app.use('/api/v1', createApi(ctx));
++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++
++ // ── Pages ───────────────────────────────────────────────
++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.SERVICES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it
++ app.use('/docs', createDocsRoutes(ctx));
++ app.use('/projects', rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false, skip: (req) => req.method === 'GET' }), createPortalRoutes(ctx));
++ app.use('/releases', createReleaseActionRoutes(ctx));
++ app.use(createToolRoutes(ctx));
++ app.use(createPageRoutes(ctx));
++ app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try the <a href="/docs">docs</a> or <a href="/projects">your projects</a>.</p>` }));
-- // No page is served yet (the console is plan T13 step 9): every unknown route, page or API, is the
-- // same problem+json.
-- app.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { ctx: req.ov }));
// 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 });
-- if (err instanceof ServiceError && !res.headersSent) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov, extra: err.extra });
-- log.error('[Services] unhandled error:', err);
-- if (res.headersSent) return undefined;
-- return http.sendProblem(res, 500, 'services.internal', { ctx: req.ov });
++ app.use((err, req, res, _next) => {
++ if (res.headersSent) return;
++ if (req.path.startsWith('/api/')) {
++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
++ if (err instanceof ServiceError) return contracts.http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov, extra: err.extra });
++ }
++ // Never log request bodies: forms here carry secrets.
++ log.error('[Services]', err && err.message ? err.message.slice(0, 300) : err);
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return res.status(413).type('text/plain').send('That form was too large.');
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
});
-- Object.assign(app.locals, { config, db, keys, userAuth, membership, authorities, tokens, index, outbox, apiAuth, metrics, registry });
-- return app;
++ return { app, ctx };
}
--module.exports = { createApp, VERSION, JSON_LIMIT };
++module.exports = { createApp, VERSION, INDEX_JSON_LIMIT };
ddiff --git a/server/auth.js b/server/auth.js
ddeleted file mode 100644
iindex f63af42..0000000
---- a/server/auth.js
++++ /dev/null
@@@ -1,151 +0,0 @@
--'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.services; the index routes
-- * check services.resource.read (server/api/v1.js).
-- * { kind: 'user', subject: 'usr_…', username, token } a person's Network user token (SSO). A person
-- * reads ONLY projects they own or belong to, verified with
-- * their own token against Network (server/network.js
-- * projectMember), and only one project at a time.
-- * { kind: 'anonymous' }
-- *
-- * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
-- *
-- * Capabilities: services.resource.read is registered in the pinned openvibe-contracts (status active,
-- * visibility first-party), so contracts' manifest and grant rules decide it (wildcards included) — and
-- * an id contracts does not know is refused, never allowed. Services enforces exactly the id the manifest
-- * names. services.resource.control arrives with plan T13 step 10.
-- */
--const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
--const { ServiceError } = require('./util');
--
--const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
--const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
--const ANON = Object.freeze({ kind: 'anonymous' });
--
--/**
-- * The capabilities Services enforces. services.resource.read — active and first-party in the pinned
-- * openvibe-contracts — is the whole read side of the resource index (list, one, the authority list).
-- * services.resource.control arrives with plan T13 step 10.
-- */
--const CAPS = Object.freeze({ resourceRead: 'services.resource.read' });
--
--/** One capability against a token's claims: contracts' manifest and grant rules decide; an unknown id is refused. */
--function capabilityDecision(claims, id) {
-- return capabilities.check(claims, id);
--}
--
--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; }
--}
--
--/** Verify a service token (audience openvibe.services). 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 };
--}
--
--/** A Network user token's claims → the user principal (null when it names no canonical subject). */
--function userPrincipal(claims, token) {
-- if (!claims) return null;
-- const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
-- if (!subject) return null;
-- return {
-- kind: 'user', subject, token, username: claims.username || null,
-- name: claims.display_name || claims.username || null, role: claims.role || 'user', cap: [], claims,
-- };
--}
--
--/** The project a principal names, or null. */
--const projectOf = (principal) => {
-- const id = principal && principal.claims && principal.claims.project_id;
-- return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;
--};
--
--function createApiAuth({ config, keys, userAuth, membership }) {
-- 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, token);
-- 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();
-- }
--
-- /**
-- * The principal gate every index route runs first: anonymous 401, a service token without
-- * services.resource.read 403. A user token passes here — what a person may see is the project
-- * scope's question (authorizeProject), never this one's.
-- */
-- function requireRead(req) {
-- const p = req.principal;
-- if (!p || p.kind === 'anonymous') throw new ServiceError(401, 'token.missing', `${CAPS.resourceRead} needs a Network service or user token`);
-- if (p.kind === 'service') {
-- const d = capabilityDecision(p.claims, CAPS.resourceRead);
-- if (!d.allowed) throw new ServiceError(403, d.code || 'capability.denied', d.reason || `${CAPS.resourceRead} not granted`);
-- // First-party only (plan T13 step 4 registers services.resource.read first-party): an app,
-- // mod or agent token is refused even if it somehow holds the grant. A person reads with their
-- // own user token, never through a developer app's credential.
-- if (!String(p.sub || '').startsWith('svc:')) {
-- throw new ServiceError(403, 'capability.denied', `${CAPS.resourceRead} is first-party: a svc: service token or a person's own user token, never an app, mod or agent token`);
-- }
-- }
-- }
--
-- /** The first-party gate: a service token with services.resource.read (the authority registry). */
-- function requireServiceRead(req) {
-- requireRead(req);
-- if (req.principal.kind !== 'service') throw new ServiceError(403, 'capability.denied', `${CAPS.resourceRead} is first-party: this route needs a service token`);
-- }
--
-- /**
-- * The project a read is scoped to (null = every project a first-party caller may see):
-- *
-- * service any project; ?project= only narrows what a first-party caller sees
-- * user only a project they own or belong to, named explicitly — Network answers it with the
-- * caller's own token, and a project they are not a member of is 404 (existence is not
-- * disclosed), never a 403 that would confirm it exists
-- *
-- * No person is ever accepted unscoped: a user token reads one project at a time.
-- */
-- async function authorizeProject(req, project) {
-- const p = req.principal;
-- if (p.kind === 'service') return { project };
-- if (!project) throw new ServiceError(400, 'resources.project_required', 'a user token reads one project at a time: pass ?project=prj_…');
-- if (!PROJECT_RE.test(String(project))) throw new ServiceError(400, 'resources.bad_query', 'project must be a prj_ id');
-- const member = await membership.projectMember(p.token, project);
-- if (member === 'unavailable') throw new ServiceError(503, 'network.unavailable', 'OpenVibe.Network could not answer whether this account may read that project');
-- if (member !== 'yes') throw new ServiceError(404, 'resources.project_not_found', `no project ${project} for this account`);
-- return { project };
-- }
--
-- return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, authorizeProject, projectOf };
--}
--
--module.exports = { createApiAuth, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROJECT_RE, PRINCIPAL_SUB };
ddiff --git a/server/auth/api.js b/server/auth/api.js
nnew file mode 100644
iindex 0000000..c2b16cd
---- /dev/null
++++ b/server/auth/api.js
@@@ -0,0 +1,158 @@
++'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.services; the index routes
++ * check services.resource.read (server/api/v1.js).
++ * { kind: 'user', subject: 'usr_…', username, token } a person's Network user token (SSO). A person
++ * reads ONLY projects they own or belong to, verified with
++ * their own token against Network (server/network.js
++ * projectMember), and only one project at a time.
++ * { kind: 'anonymous' }
++ *
++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
++ *
++ * Capabilities: services.resource.read is registered in the pinned openvibe-contracts (status active,
++ * visibility first-party), so contracts' manifest and grant rules decide it (wildcards included) — and
++ * an id contracts does not know is refused, never allowed. Services enforces exactly the id the manifest
++ * names. services.resource.control arrives with plan T13 step 10.
++ */
++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
++const { ServiceError } = require('../util');
++
++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ANON = Object.freeze({ kind: 'anonymous' });
++
++/**
++ * The capabilities Services enforces. services.resource.read — active and first-party in the pinned
++ * openvibe-contracts — is the whole read side of the resource index (list, one, the authority list).
++ * services.resource.control arrives with plan T13 step 10.
++ */
++const CAPS = Object.freeze({ resourceRead: 'services.resource.read' });
++
++/** One capability against a token's claims: contracts' manifest and grant rules decide; an unknown id is refused. */
++function capabilityDecision(claims, id) {
++ return capabilities.check(claims, id);
++}
++
++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; }
++}
++
++/** Verify a service token (audience openvibe.services). 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 };
++}
++
++/** A Network user token's claims → the user principal (null when it names no canonical subject). */
++function userPrincipal(claims, token) {
++ if (!claims) return null;
++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
++ if (!subject) return null;
++ return {
++ kind: 'user', subject, token, username: claims.username || null,
++ name: claims.display_name || claims.username || null, role: claims.role || 'user', cap: [], claims,
++ };
++}
++
++/** The project a principal names, or null. */
++const projectOf = (principal) => {
++ const id = principal && principal.claims && principal.claims.project_id;
++ return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;
++};
++
++/** The PEM a token verifies against: the key store's per-kid lookup when it has one, else its current key. */
++async function keyFor(keys, token) {
++ if (typeof keys.pemForToken === 'function') return (await keys.pemForToken(token)) || keys.get();
++ return keys.get();
++}
++
++function createApiAuth({ config, keys, userAuth, membership }) {
++ async function resolve(req) {
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer ')) return { principal: ANON };
++ const token = header.slice(7).trim();
++ // The key the token's header names (a rotation publishes two keys), from the key store's JWKS client.
++ const publicKey = await keyFor(keys, token);
++ 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, token);
++ 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();
++ }
++
++ /**
++ * The principal gate every index route runs first: anonymous 401, a service token without
++ * services.resource.read 403. A user token passes here — what a person may see is the project
++ * scope's question (authorizeProject), never this one's.
++ */
++ function requireRead(req) {
++ const p = req.principal;
++ if (!p || p.kind === 'anonymous') throw new ServiceError(401, 'token.missing', `${CAPS.resourceRead} needs a Network service or user token`);
++ if (p.kind === 'service') {
++ const d = capabilityDecision(p.claims, CAPS.resourceRead);
++ if (!d.allowed) throw new ServiceError(403, d.code || 'capability.denied', d.reason || `${CAPS.resourceRead} not granted`);
++ // First-party only (plan T13 step 4 registers services.resource.read first-party): an app,
++ // mod or agent token is refused even if it somehow holds the grant. A person reads with their
++ // own user token, never through a developer app's credential.
++ if (!String(p.sub || '').startsWith('svc:')) {
++ throw new ServiceError(403, 'capability.denied', `${CAPS.resourceRead} is first-party: a svc: service token or a person's own user token, never an app, mod or agent token`);
++ }
++ }
++ }
++
++ /** The first-party gate: a service token with services.resource.read (the authority registry). */
++ function requireServiceRead(req) {
++ requireRead(req);
++ if (req.principal.kind !== 'service') throw new ServiceError(403, 'capability.denied', `${CAPS.resourceRead} is first-party: this route needs a service token`);
++ }
++
++ /**
++ * The project a read is scoped to (null = every project a first-party caller may see):
++ *
++ * service any project; ?project= only narrows what a first-party caller sees
++ * user only a project they own or belong to, named explicitly — Network answers it with the
++ * caller's own token, and a project they are not a member of is 404 (existence is not
++ * disclosed), never a 403 that would confirm it exists
++ *
++ * No person is ever accepted unscoped: a user token reads one project at a time.
++ */
++ async function authorizeProject(req, project) {
++ const p = req.principal;
++ if (p.kind === 'service') return { project };
++ if (!project) throw new ServiceError(400, 'resources.project_required', 'a user token reads one project at a time: pass ?project=prj_…');
++ if (!PROJECT_RE.test(String(project))) throw new ServiceError(400, 'resources.bad_query', 'project must be a prj_ id');
++ const member = await membership.projectMember(p.token, project);
++ if (member === 'unavailable') throw new ServiceError(503, 'network.unavailable', 'OpenVibe.Network could not answer whether this account may read that project');
++ if (member !== 'yes') throw new ServiceError(404, 'resources.project_not_found', `no project ${project} for this account`);
++ return { project };
++ }
++
++ return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, authorizeProject, projectOf };
++}
++
++module.exports = { createApiAuth, keyFor, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROJECT_RE, PRINCIPAL_SUB };
ddiff --git a/server/auth/forms.js b/server/auth/forms.js
nnew file mode 100644
iindex 0000000..0e17e57
---- /dev/null
++++ b/server/auth/forms.js
@@@ -0,0 +1,31 @@
++'use strict';
++
++/**
++ * Form tokens (CSRF) for every state-changing form. The session cookies are SameSite=Lax and
++ * httpOnly; the token is the second lock: an HMAC of the signed-in subject under SERVICES_FORM_SECRET
++ * (a random per-process key when unset, so forms opened before a restart must be reloaded).
++ * A POST whose Origin header names another site is refused before the token is even looked at.
++ */
++const crypto = require('crypto');
++
++const fallback = crypto.randomBytes(32).toString('hex');
++
++function csrfToken(config, viewer) {
++ if (!viewer || !viewer.subject) return '';
++ return crypto.createHmac('sha256', config.formSecret || fallback).update(`services-form:${viewer.subject}`).digest('base64url').slice(0, 32);
++}
++
++function checkCsrf(config, viewer, token) {
++ const expected = csrfToken(config, viewer);
++ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;
++ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
++}
++
++/** True when the request's Origin (if any) is this site. */
++function sameOrigin(config, req) {
++ const origin = req.headers.origin;
++ if (!origin || origin === 'null') return !origin;
++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
++}
++
++module.exports = { csrfToken, checkCsrf, sameOrigin };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..436c90b
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,114 @@
++'use strict';
++
++/**
++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured
++ * internal URL and kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline
++ * against it: the person's session token (sign-in) and, in playgrounds, the app token a developer
++ * brings.
++ *
++ * The SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
++ * rotation honoured at once, unknown-kid floods throttled, the refresh timer unref'd. This module
++ * is a thin shim so the rest of Services (the contracts-based requireCapability guard, the readiness
++ * check, the SSO middleware) can keep talking to `keys.get()` / `keys.ensure()` / `keys.loaded()`,
++ * while verification goes through the SDK's verifyUserToken / verifyAppToken with `jwks: <url>`.
++ *
++ * Nothing the SDK says about a failed key fetch leaves this module: its message names the internal
++ * JWKS URL and the fetch error, so token.no_key answers 'signing key not loaded yet' (and an error
++ * without a code 'token does not verify'). Reasons about the token itself (audience, expiry,
++ * signature) are kept: the playground's developers need them. Every rejection is logged.
++ */
++const sdk = require('openvibe-sdk/auth');
++
++/** Public reasons. The SDK's own text (the JWKS URL, the fetch error) never goes over the wire. */
++const KEY_UNAVAILABLE = 'signing key not loaded yet';
++const DOES_NOT_VERIFY = 'token does not verify';
++
++/**
++ * What a caller may be told. Only token.no_key carries the SDK's fetch text (the internal JWKS URL and the connect
++ * error); every other code's message is about the token itself ('not for openvibe.media', 'expired'), which a
++ * developer in the playground needs, so it is kept.
++ */
++function publicReason(err) {
++ const code = err && err.code;
++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
++}
++
++/** Resolve the JWKS URL for the configured Network (internal first, then public). */
++function jwksUrl(config) {
++ return (config.network.internalUrl || config.network.url) + '/api/.well-known/jwks';
++}
++
++/**
++ * `keys` is what app.js hands the rest of the service: a facade over the SDK's jwksClient. The
++ * contracts-based requireCapability wants a synchronous PEM, so we mirror the cached first key as
++ * PEM — refreshed every time the SDK gives us keys (ensure(), verifyUser(), verifyApp()).
++ */
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = jwksUrl(config);
++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
++ let mirror = null; // { pem, count } — the first cached key as PEM
++
++ async function refreshMirror() {
++ try {
++ const ks = await client.keys();
++ const first = ks && ks[0];
++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
++ } catch (err) { log.warn(`[Services] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
++ }
++ /** First fetch (or wait out the backoff window). Refreshes the mirror. */
++ async function ensure() { await refreshMirror(); }
++ /** Cached public key as PEM — for openvibe-contracts' verifyServiceToken (synchronous). */
++ function get() { return mirror ? mirror.pem : null; }
++ function loaded() { return Boolean(mirror); }
++
++ /** The SDK's `now` is a NUMBER of milliseconds (it does Math.floor(now / 1000)); a function disables every expiry check. */
++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
++
++ // Verify a user (session) token via the SDK. Returns { ok, claims } | { ok: false, reason, code, expired }.
++ // `reason` is one of the two fixed public strings; the SDK's text is logged, never returned.
++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[Services] user token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
++ }
++ }
++ // Verify an app token. Same shape and same fixed reasons. The playground reads v.reason for its detail.
++ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[Services] app token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code };
++ }
++ }
++
++ /**
++ * The PEM for the key a token's header names (a rotation publishes two keys: the first one is not always it), for
++ * openvibe-contracts' synchronous guard. The SDK refetches for an unknown kid (at most every 30 s). Null when no
++ * key is loaded; a token that names no kid gets the first key.
++ */
++ async function pemForToken(token) {
++ let kid = null;
++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
++ try {
++ const ks = await client.keysForKid(kid);
++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
++ } catch { return null; }
++ }
++
++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
++}
++
++module.exports = { createKeyStore, jwksUrl };
\\ No newline at end of file
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..8a9a723
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,264 @@
++'use strict';
++const { staff: staffMap } = require('openvibe-contracts');
++
++/**
++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client
++ * `services` (the same shape as OpenVibe.Billing's console).
++ *
++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
++ * (?silent=1 adds prompt=none; ?next= a same-site path)
++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
++ * GET /auth/me → { user } for the shared navbar (it cannot read the httpOnly cookie); { user: null } for a guest
++ *
++ * Services calls the Network's /api/v1/projects API with the person's access token, so it keeps that
++ * token — in an httpOnly cookie (services_at) no script can read, unlike the navbar-readable ov_token
++ * other sites use: this portal shows client secrets once, and a script-readable token would let an
++ * injected script mint more. The refresh token (services_rt) is httpOnly too. Neither is ever stored
++ * server-side, rendered or logged.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++
++const ACCESS_COOKIE = 'services_at';
++const REFRESH_COOKIE = 'services_rt';
++const HINT_COOKIE = 'ov_sso_hint';
++const FLOW_COOKIE = 'services_oauth';
++
++function pkcePair() {
++ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ return { verifier, challenge };
++}
++
++/** Same-site relative paths only (never protocol-relative, never another origin). */
++function sanitizeNext(next) {
++ // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
++ // leave the site. A next with any control character or backslash goes home.
++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
++ return next;
++}
++
++function authorizeUrl(config, { state, challenge, silent = false }) {
++ const q = new URLSearchParams({
++ response_type: 'code',
++ client_id: config.oauth.clientId,
++ redirect_uri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ state,
++ code_challenge: challenge,
++ code_challenge_method: 'S256',
++ });
++ if (silent) q.set('prompt', 'none');
++ return `${config.network.url}/oauth/authorize?${q.toString()}`;
++}
++
++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
++
++ function setSession(res, data) {
++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, cookieBase());
++ res.clearCookie(REFRESH_COOKIE, cookieBase());
++ }
++
++ /** POST the Network token endpoint (internal URL). Throws { status, error } on refusal. */
++ async function tokenGrant(fields) {
++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
++ let res;
++ try {
++ res = await fetchImpl(`${config.network.internalUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body, signal: AbortSignal.timeout(10_000),
++ });
++ } catch (err) {
++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || !data.access_token) {
++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
++ { status: res.status, error: (data && data.error) || 'invalid_grant' });
++ }
++ return data;
++ }
++
++ /**
++ * A Services session is a Network SESSION token: audience openvibe.network and a person. The Network
++ * signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and
++ * internal tokens with the same key and issuer; none of them may become a session here.
++ */
++ async function verifySession(token) {
++ const v = await keys.verifyUser(token, { issuer: config.network.issuer, audience: config.oauth.sessionAudience, now: now() });
++ const c = v.claims;
++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
++ return v;
++ }
++
++ function viewerFromClaims(claims, token) {
++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
++ const role = typeof claims.role === 'string' ? claims.role : 'user';
++ return {
++ kind: 'user',
++ subject,
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
++ role,
++ // Staff = the contracts staff map's staff.site.configure (ADR-022), or a subject in SERVICES_STAFF_SUBJECTS.
++ staff: staffMap.can(claims, 'staff.site.configure') || Boolean(subject && config.staffSubjects.includes(subject)),
++ // The person's Network access token: used for server-side Network calls, never rendered.
++ token,
++ };
++ }
++
++ /** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */
++ async function refresh(req, res) {
++ if (!req.servicesRefresh) req.servicesRefresh = await doRefresh(req, res);
++ return req.servicesRefresh;
++ }
++
++ async function doRefresh(req, res) {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (!rt) return null;
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
++ setSession(res, data);
++ const v = await verifySession(data.access_token);
++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
++ } catch (err) {
++ if (err.status && err.status < 500) clearSession(res);
++ return null;
++ }
++ }
++
++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
++ function middleware() {
++ return async (req, res, next) => {
++ req.viewer = { kind: 'anonymous' };
++ const token = req.cookies && req.cookies[ACCESS_COOKIE];
++ if (!token) return next();
++ await keys.ensure();
++ const v = await verifySession(token);
++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
++ if (v.expired) {
++ const fresh = await refresh(req, res);
++ if (fresh) { req.viewer = fresh; return next(); }
++ }
++ if (keys.loaded()) clearSession(res);
++ next();
++ };
++ }
++
++ /**
++ * Run fn(token) as the viewer; when the Network answers 401 (expired or revoked session), refresh
++ * once and run it again. Other failures are returned to the caller unchanged.
++ */
++ async function asViewer(req, res, fn) {
++ try {
++ return await fn(req.viewer.token);
++ } catch (err) {
++ if (!err || err.status !== 401) throw err;
++ const fresh = await refresh(req, res);
++ if (!fresh) throw err;
++ req.viewer = fresh;
++ return fn(fresh.token);
++ }
++ }
++
++ function routes() {
++ const r = express.Router();
++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
++
++ r.get('/login', (req, res) => {
++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
++ const state = crypto.randomBytes(16).toString('hex');
++ const { verifier, challenge } = pkcePair();
++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next), silent }), flowOpts());
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ res.redirect(authorizeUrl(config, { state, challenge, silent }));
++ });
++
++ r.get('/callback', async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ let flow = null;
++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
++ const next = flow ? sanitizeNext(flow.next) : '/';
++ const { code, state, error } = req.query;
++ if (error) {
++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
++ }
++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
++ }
++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
++ setSession(res, data);
++ return res.redirect(next);
++ } catch (err) {
++ log.warn('[Services] sign-in exchange failed:', err.status || '', err.error || '');
++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
++ }
++ });
++
++ // Sign-out ends the session, so another site must not be able to trigger it (a cross-site
++ // <img src=/auth/logout> or link). A same-origin navigation or form, or the address bar
++ // (Sec-Fetch-Site none), signs out directly; anything else gets a confirm button (POST).
++ const sameOriginRequest = (req) => {
++ const site = String(req.get('sec-fetch-site') || '');
++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
++ const origin = req.get('origin');
++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
++ return req.method === 'GET'; // no fetch metadata and no Origin: an old browser navigating
++ };
++ r.get('/logout', (req, res, next) => {
++ if (sameOriginRequest(req)) return next();
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';
++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
++ + `<p>Sign out of OpenVibe.Services?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
++ });
++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
++ r.all('/logout', async (req, res) => {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (rt) {
++ fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
++ signal: AbortSignal.timeout(3000),
++ }).catch(() => {});
++ }
++ clearSession(res);
++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ res.redirect(303, sanitizeNext(req.query.next));
++ });
++
++ r.get('/me', (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ // No session at all (a guest: no access or refresh cookie) is signed out, not an error: the shared
++ // navbar asks this on every page view, and a 401 logged a console error on each (browser check,
++ // OpenVibe.Host). A session cookie that is present but invalid or expired still answers 401.
++ const c = req.cookies || {};
++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
++ const v = req.viewer;
++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });
++ });
++ return r;
++ }
++
++ return { middleware, routes, asViewer, tokenGrant, pkcePair };
++}
++
++module.exports = { createSso, pkcePair, sanitizeNext, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/authorities/index.js b/server/authorities/index.js
iindex a3dd4f2..bc577d8 100644
---- a/server/authorities/index.js
++++ b/server/authorities/index.js
@@@ -5,7 +5,7 @@
* and where each one listens.
*
* An authority is a service whose released openvibe-contracts manifest lists `<id>.resource.read` as
-- * `active`. At v0.110.0 that is network, media, events, host and codes; the moment a service's manifest
++ * `active`. At v0.113.0 that is network, media, events and host (and Services itself, in process); the moment a service's manifest
* (and its capability manifest) turn that on, it is in this registry on the next boot — nothing here is
* hand-maintained, and nothing here is another service's data. Services itself is never an authority:
* it reads authorities, it does not index itself. A `retired` or `placeholder` service is not called.
ddiff --git a/server/clients/network.js b/server/clients/network.js
nnew file mode 100644
iindex 0000000..59fbc58
---- /dev/null
++++ b/server/clients/network.js
@@@ -0,0 +1,126 @@
++'use strict';
++
++/**
++ * OpenVibe.Network through openvibe-sdk.
++ *
++ * projects /api/v1/projects… with the SIGNED-IN PERSON's access token (Bearer, server-side).
++ * Network owns projects, members, apps, credentials, grants and quotas (ADR-014); Services
++ * renders what it answers and forwards what the person asks. It never stores any of it.
++ * registry /api/v1/registry/* and /.well-known/openvibe (public, cached briefly).
++ * POST /:project/export-tokens mints the project export's read-only tokens (never kept).
++ * appToken /oauth/token client_credentials for an app — only in playgrounds, with the secret the
++ * developer typed for that one request (never stored, never logged, never echoed).
++ *
++ * Mutations are never retried (the projects API does not dedupe), and a failure is returned as the
++ * Network's own problem (status, code, detail, request id) so pages can show it honestly.
++ */
++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
++const { createRegistryClient } = require('openvibe-sdk/registry');
++
++const enc = encodeURIComponent;
++
++/** A failure as pages show it. Never contains a request body. */
++function problemOf(err) {
++ if (isOpenVibeError(err)) {
++ const unreachable = !err.status || err.code.startsWith('sdk.');
++ return {
++ status: unreachable ? 502 : err.status,
++ code: unreachable ? `network.unreachable` : err.code,
++ detail: unreachable ? `OpenVibe.Network did not answer (${err.code.replace(/^sdk\./, '')}).` : (err.detail || err.title || ''),
++ requestId: err.requestId || null,
++ unreachable,
++ };
++ }
++ return { status: 500, code: 'services.internal', detail: 'unexpected error', requestId: null, unreachable: false };
++}
++
++function createNetworkClient({ config, fetchImpl, log = console }) {
++ const base = config.network.internalUrl;
++ const client = createClient({
++ network: base, autoDiscover: false, baseUrls: { network: base }, retries: 1, timeoutMs: 8000, deadlineMs: 15000,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ onWarning: (m) => log.warn('[Services] sdk:', m),
++ });
++
++ const call = (token, method, path, json) => client.json({
++ baseUrl: base, path: `/api/v1/projects${path}`, method, token,
++ ...(json !== undefined ? { json } : {}),
++ ...(method === 'GET' ? {} : { idempotencyKey: false, retries: 0 }),
++ });
++
++ const P = (p) => `/${enc(p)}`;
++ const A = (p, a) => `/${enc(p)}/apps/${enc(a)}`;
++ const projects = {
++ catalog: (t) => call(t, 'GET', '/catalog'),
++ list: (t, { all } = {}) => call(t, 'GET', all ? '?all=1' : ''),
++ create: (t, body) => call(t, 'POST', '', body),
++ get: (t, p) => call(t, 'GET', P(p)),
++ rename: (t, p, body) => call(t, 'PATCH', P(p), body),
++ archive: (t, p) => call(t, 'POST', `${P(p)}/archive`, {}),
++ members: (t, p) => call(t, 'GET', `${P(p)}/members`),
++ addMember: (t, p, body) => call(t, 'POST', `${P(p)}/members`, body),
++ updateMember: (t, p, s, body) => call(t, 'PATCH', `${P(p)}/members/${enc(s)}`, body),
++ removeMember: (t, p, s) => call(t, 'DELETE', `${P(p)}/members/${enc(s)}`),
++ apps: (t, p) => call(t, 'GET', `${P(p)}/apps`),
++ createApp: (t, p, body) => call(t, 'POST', `${P(p)}/apps`, body),
++ app: (t, p, a) => call(t, 'GET', A(p, a)),
++ updateApp: (t, p, a, body) => call(t, 'PATCH', A(p, a), body),
++ revokeApp: (t, p, a) => call(t, 'DELETE', A(p, a)),
++ credentials: (t, p, a) => call(t, 'GET', `${A(p, a)}/credentials`),
++ rotate: (t, p, a, body) => call(t, 'POST', `${A(p, a)}/credentials/rotate`, body),
++ revokeCredential: (t, p, a, c) => call(t, 'POST', `${A(p, a)}/credentials/${enc(c)}/revoke`, {}),
++ grants: (t, p, a) => call(t, 'GET', `${A(p, a)}/grants`),
++ requestGrant: (t, p, a, capability) => call(t, 'POST', `${A(p, a)}/grants`, { capability }),
++ decideGrant: (t, p, a, cap, decision) => (decision === 'revoked'
++ ? call(t, 'DELETE', `${A(p, a)}/grants/${enc(cap)}`)
++ : call(t, 'POST', `${A(p, a)}/grants/${enc(cap)}/${decision === 'approved' ? 'approve' : 'deny'}`, {})),
++ quotas: (t, p) => call(t, 'GET', `${P(p)}/quotas`),
++ // Usage per day, quotas with their use and recent errors (network.project-usage-result@1; owner/admin).
++ usage: (t, p, { days, env } = {}) => call(t, 'GET', `${P(p)}/usage?${new URLSearchParams({ days: String(days || 30), env: env || 'all' })}`),
++ audit: (t, p, { before, limit } = {}) => call(t, 'GET', `${P(p)}/audit${before || limit ? `?${new URLSearchParams({ ...(before ? { before: String(before) } : {}), ...(limit ? { limit: String(limit) } : {}) })}` : ''}`),
++ // A 5-minute read-only token for the project export (owner/admin; Network checks and audits).
++ exportToken: (t, p, body) => call(t, 'POST', `${P(p)}/export-tokens`, body),
++ };
++
++ // Registry: public, cached for registryTtlMs so a page view never waits on Network twice.
++ const registryClient = createRegistryClient(client, { baseUrl: base });
++ const cache = new Map();
++ async function cached(key, fn) {
++ const hit = cache.get(key);
++ if (hit && Date.now() - hit.at < config.registryTtlMs) return hit.value;
++ const value = await fn();
++ cache.set(key, { at: Date.now(), value });
++ return value;
++ }
++ const registry = {
++ services: async () => await cached('services', () => registryClient.services()),
++ descriptor: async () => await cached('descriptor', () => client.json({ baseUrl: base, path: '/.well-known/openvibe', auth: false })),
++ };
++
++ /**
++ * Client-credentials token for an APP (playgrounds). The secret is used for this one request
++ * and dropped; failures come back as the Network's OAuth error, never with the secret.
++ */
++ async function appToken({ appId, clientSecret, audience, scope }) {
++ const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: appId, client_secret: clientSecret, audience, scope });
++ const doFetch = fetchImpl || globalThis.fetch;
++ let res;
++ try {
++ res = await doFetch(`${base}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body, signal: AbortSignal.timeout(8000),
++ });
++ } catch (err) {
++ return { ok: false, status: 502, code: 'network.unreachable', detail: `OpenVibe.Network did not answer (${err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable'}).` };
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || typeof data.access_token !== 'string') {
++ return { ok: false, status: res.status, code: (data && data.error) || `http.${res.status}`, detail: (data && data.error_description) || '' };
++ }
++ return { ok: true, token: data.access_token, scope: data.scope || '' };
++ }
++
++ return { projects, registry, appToken, problemOf };
++}
++
++module.exports = { createNetworkClient, problemOf };
ddiff --git a/server/config.js b/server/config.js
iindex 07ed28f..df88aa1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -2,32 +2,40 @@
/**
* OpenVibe.Services configuration. Everything comes from the environment (.env in development,
-- * /etc/openvibe/services.env in production). loadConfig(env) is pure so tests build their own.
++ * /etc/openvibe/services.env in production). loadConfig(env) is pure so tests build their own. Only environment
++ * variable NAMES appear in code and docs; secrets are never logged.
*
-- * Services is the resource control plane (ADR-048, plan T13): it reads every authority's resource index
-- * and merges it. It owns no other service's data and stores no credential: the authority tokens are
-- * Network client-credentials tokens minted per authority audience at call time (server/authorities/tokens.js)
-- * from OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET, and the person-facing path passes the caller's own
-- * Network user token through to Network (server/auth.js).
++ * Services is the developer platform (ADR-048, plan T13; the developer portal moved here from OpenVibe.Codes on
++ * 2026-10-08): the console and docs people use in a browser, the release API apps call, and the resource index that
++ * reads every authority and merges it. It owns no other service's data and stores no credential: the authority tokens
++ * are Network client-credentials tokens minted per audience at call time (server/authorities/tokens.js) from
++ * OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET, a person's own Network token is passed through to Network
++ * (server/auth/api.js, server/clients/network.js), and playgrounds use the APP's token only.
*/
require('dotenv').config();
++const contracts = require('openvibe-contracts');
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);
++const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
++const originOf = (id, fallback) => {
++ const m = contracts.services.get(id);
++ return (m && m.publicOrigin) || fallback;
++};
/**
* SERVICES_<ID>_URL overrides one authority's loopback origin (the id uppercased, '-' -> '_'), e.g.
* SERVICES_NETWORK_URL=http://127.0.0.1:4010. The origin otherwise comes from the service manifest
-- * (internalOrigin) of the pinned openvibe-contracts release.
++ * (internalOrigin) of the pinned openvibe-contracts release. The console's own *_URL settings
++ * (SERVICES_PLAYGROUND_*_URL, SERVICES_EXPORT_*_URL) name no authority and are not read here.
*/
++const OWN_URL_SETTINGS = /^(PLAYGROUND|EXPORT)_/;
function authorityOrigins(env) {
const out = {};
for (const [k, v] of Object.entries(env)) {
const m = /^SERVICES_([A-Z0-9]+(?:_[A-Z0-9]+)*)_URL$/.exec(k);
-- if (m && v) out[m[1].toLowerCase().replace(/_/g, '-')] = trim(v);
++ if (m && v && !OWN_URL_SETTINGS.test(m[1])) out[m[1].toLowerCase().replace(/_/g, '-')] = trim(v);
}
return out;
}
@@@ -36,41 +44,85 @@ function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 4930); // 4930 is Services'; 4000-4910 and 4920 are taken
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.services' : `http://localhost:${port}`));
const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
return {
++ service: 'services',
nodeEnv,
isProduction,
port,
host: env.HOST || '127.0.0.1',
-- baseUrl: trim(env.BASE_URL || (isProduction ? 'https://openvibe.services' : `http://localhost:${port}`)),
-- trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ // Per-actor limits (server/http/actor-limits.js, roadmap WS-R task 4): the reads one caller (an
++ // app, a person, else an address) may make per minute and per hour. Writes set tighter numbers there.
++ limits: {
++ minute: Math.max(1, int(env.SERVICES_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.SERVICES_LIMITS_HOUR, 3000)),
++ },
// 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.
++ // mode), DATABASE_DIRECT_URL the owner role on a direct connection, for migrations at boot. In
++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used
++ // (SERVICES_PGLITE_DIR overrides the directory).
db: {
url: env.DATABASE_URL || '',
directUrl: env.DATABASE_DIRECT_URL || '',
++ pgliteDir: env.SERVICES_PGLITE_DIR || '',
},
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:services:' },
-- // Identity: the Network signing key verifies every incoming token; NETWORK_URL is the origin the
-- // person-facing path asks about project membership (GET /api/v1/projects/:project, with the
-- // caller's own token — the developer projects API).
++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), the signing key (its JWKS), the
++ // developer projects API (/api/v1/projects,
++ // called with the signed-in person's own token), the registry, and client-credentials tokens
++ // (Services' own and, in playgrounds, the app's).
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),
},
++ // The audience a token for Services' API carries (the index and the release API).
audience: env.SERVICES_AUDIENCE || 'openvibe.services',
--
-- // Services' own Network client (`services`): it mints the per-authority client-credentials
-- // tokens. Unset in development: the fan-out reaches the authorities without a token and an
-- // authority that refuses is reported partial, never fatal (ADR-046 section 6).
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'services',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile',
++ // Network session tokens (what /oauth/token hands Services) carry this audience; FedCM
++ // assertions, app and service tokens signed with the same key do not.
++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++ // Signs the per-person form token (CSRF). Unset: a random per-process key.
++ formSecret: env.SERVICES_FORM_SECRET || '',
++
++ // Staff (trust tiers): staff.site.configure (Network admins), plus these subjects (usr_…).
++ staffSubjects: list(env.SERVICES_STAFF_SUBJECTS),
++
++ // Where playgrounds call, with the APP's own token (never Services' credentials). Defaults are
++ // the public origins published in openvibe-contracts' service manifests.
++ playground: {
++ eventsUrl: trim(env.SERVICES_PLAYGROUND_EVENTS_URL || originOf('events', 'https://openvibe.events')),
++ mediaUrl: trim(env.SERVICES_PLAYGROUND_MEDIA_URL || originOf('media', 'https://openvibe.media')),
++ maxUploadBytes: int(env.SERVICES_PLAYGROUND_MAX_UPLOAD_BYTES, 1024 * 1024),
++ runsPerHour: int(env.SERVICES_PLAYGROUND_RUNS_PER_HOUR, 60),
},
++ // The full project archive (server/domain/project-archive.js): Media and Events are read
++ // server-side (loopback by default, as for /docs/limits) with Network's read-only export
++ // tokens. The limits bound one archive: objects and events per environment, and how long
++ // Media's signed download URLs in it stay valid (Media allows at most an hour).
++ export: {
++ mediaUrl: trim(env.SERVICES_EXPORT_MEDIA_URL || env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
++ eventsUrl: trim(env.SERVICES_EXPORT_EVENTS_URL || env.OV_EVENTS_INTERNAL_URL || 'http://127.0.0.1:4300'),
++ maxObjects: Math.max(1, int(env.SERVICES_EXPORT_MAX_OBJECTS, 5000)),
++ maxEvents: Math.max(1, int(env.SERVICES_EXPORT_MAX_EVENTS, 10000)),
++ urlTtlS: Math.min(3600, Math.max(60, int(env.SERVICES_EXPORT_URL_TTL_S, 3600))),
++ },
++
++ // Registry answers are cached this long (Network's own health poll runs every 60 s).
++ registryTtlMs: int(env.SERVICES_REGISTRY_TTL_MS, 30 * 1000),
++
// One authority's loopback origin override (see authorityOrigins); the manifest's internalOrigin
// is the default.
authorityOrigins: authorityOrigins(env),
@@@ -87,13 +139,18 @@ function loadConfig(env = process.env) {
networkTimeoutMs: Math.max(1, int(env.SERVICES_NETWORK_TIMEOUT_MS, 5000)),
},
-- // The outbox relay. Services declares no event type yet (plan T13 step 10), so the relay is
-- // idle; EVENT_URL unset (or the OAuth client secret unset) keeps rows in the table.
++ // OpenVibe.Events: the outbox relay publishes services.app.* and services.moderation.action when
++ // EVENTS_URL and the client secret are set; otherwise rows wait in services_events_outbox.
events: {
enabled: bool(env.SERVICES_EVENTS_ENABLED, true),
url: trim(env.EVENTS_URL || ''),
-- intervalMs: Math.max(250, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
++ intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
},
++
++ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment a
++ // public release appears, changes or goes away (the key file is served at /<key>.txt). Unset:
++ // off, no key file, nothing sent. Tests and drills never set it.
++ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
};
}
ddiff --git a/server/db.js b/server/db.js
iindex 4ce680a..676a493 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -5,26 +5,37 @@
*
* 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). Services is new: migrations/0001_initial.sql is written as the final schema, no
-- * window. Nothing here is another service's data (ADR-048): the authority registry, the rebuildable
-- * resource read model, recipes and the control log are Services' own rows.
++ * transaction mode). Nothing here is another service's data (ADR-048):
++ * 0001 the authority registry, the rebuildable resource read model, recipes and the control log
++ * 0002 the console's own records (ADR-014, moved from OpenVibe.Codes): release metadata keyed to Network app
++ * ids, trust tiers (metadata, never authority), validated manifests and playground run logs. Projects,
++ * members, apps, credentials, grants and quotas belong to OpenVibe.Network and are never copied here.
++ *
++ * No table has a column for a secret, token or credential, and nothing written here ever contains one
++ * (test/secrets.test.js scans every console table).
*
* 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
++ * createStore(db, {now}) the console's handle: the db, an injectable clock, ids, transactions
*/
const fs = require('fs');
const path = require('path');
const { createDb } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++
++const TABLES = ['manifests', 'releases', 'release_log', 'trust', 'trust_history', 'playground_runs'];
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(`[Services] 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: 'services', registry, log });
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh services)');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[Services] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ return createDb({ pglite: dir, service: 'services', registry, log });
}
return createDb({ url: config.db.url, service: 'services', registry, log });
}
@@@ -37,4 +48,19 @@ async function migrate(config, { serving = null, log = console } = {}) {
try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
}
--module.exports = { openDb, migrate, MIGRATIONS };
++/**
++ * The console's handle on a migrated database. opts.now — injectable clock (epoch ms), so tests and replays are
++ * deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).
++ */
++function createStore(db, { now = () => Date.now() } = {}) {
++ return {
++ db,
++ now,
++ iso: () => new Date(now()).toISOString(),
++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
++ tx: async (fn) => await db.tx(() => fn()),
++ close: () => db.close(),
++ };
++}
++
++module.exports = { openDb, migrate, createStore, MIGRATIONS, TABLES };
ddiff --git a/server/docs/dts.js b/server/docs/dts.js
nnew file mode 100644
iindex 0000000..06f0efd
---- /dev/null
++++ b/server/docs/dts.js
@@@ -0,0 +1,83 @@
++'use strict';
++
++/**
++ * A small reader for the SDK's hand-maintained .d.ts files: every top-level `export …` statement
++ * with the /** doc comment right above it. Not a TypeScript parser — it only has to split the
++ * statements the SDK writes (interfaces, types, declared functions, consts and classes,
++ * re-exports), keeping each statement's text verbatim so the reference cannot drift from the file.
++ */
++
++const KINDS = [
++ [/^export\s+(?:declare\s+)?interface\s+([A-Za-z0-9_$]+)/, 'interface'],
++ [/^export\s+(?:declare\s+)?type\s+([A-Za-z0-9_$]+)/, 'type'],
++ [/^export\s+declare\s+function\s+([A-Za-z0-9_$]+)/, 'function'],
++ [/^export\s+declare\s+const\s+([A-Za-z0-9_$]+)/, 'const'],
++ [/^export\s+declare\s+class\s+([A-Za-z0-9_$]+)/, 'class'],
++ [/^export\s+\*\s+as\s+([A-Za-z0-9_$]+)/, 're-export'],
++ [/^export\s+\*\s+from\s+['"]([^'"]+)['"]/, 're-export'],
++ [/^export\s+type\s+\{([^}]*)\}\s+from/, 're-export'],
++ [/^export\s+\{([^}]*)\}/, 're-export'],
++];
++
++function cleanDoc(raw) {
++ return raw.replace(/^\/\*\*|\*\/$/g, '').split('\n').map((l) => l.replace(/^\s*\*\s?/, '')).join('\n').trim();
++}
++
++/** Split `source` into [{ kind, name, doc, text }]. */
++function parseDts(source) {
++ const out = [];
++ const src = String(source).replace(/\r\n/g, '\n');
++ let i = 0;
++ let pendingDoc = '';
++ while (i < src.length) {
++ // Skip whitespace.
++ const ws = src.slice(i).match(/^\s+/);
++ if (ws) { i += ws[0].length; continue; }
++ if (src.startsWith('/**', i)) {
++ const end = src.indexOf('*/', i + 3);
++ if (end < 0) break;
++ pendingDoc = cleanDoc(src.slice(i, end + 2));
++ i = end + 2;
++ continue;
++ }
++ if (src.startsWith('//', i)) { const nl = src.indexOf('\n', i); i = nl < 0 ? src.length : nl + 1; continue; }
++ if (src.startsWith('/*', i)) { const end = src.indexOf('*/', i + 2); i = end < 0 ? src.length : end + 2; pendingDoc = ''; continue; }
++
++ // One statement: to the `;` or the closing `}` at depth 0 (interfaces and classes end at `}`).
++ let depth = 0;
++ let j = i;
++ let sawBrace = false;
++ let inStr = null;
++ for (; j < src.length; j++) {
++ const ch = src[j];
++ if (inStr) { if (ch === inStr && src[j - 1] !== '\\') inStr = null; continue; }
++ // Comments inside a declaration (doc comments on interface members) may hold quotes or
++ // brackets ("the app's …"); skip them so they never open a string or change the depth.
++ if (ch === '/' && src[j + 1] === '*') { const end = src.indexOf('*/', j + 2); j = end < 0 ? src.length : end + 1; continue; }
++ if (ch === '/' && src[j + 1] === '/') { const nl = src.indexOf('\n', j); j = nl < 0 ? src.length : nl; continue; }
++ if (ch === '\'' || ch === '"' || ch === '`') { inStr = ch; continue; }
++ if (ch === '{' || ch === '(' || ch === '<' || ch === '[') { if (ch === '{') sawBrace = true; depth++; continue; }
++ if (ch === '}' || ch === ')' || ch === ']' || (ch === '>' && src[j - 1] !== '=')) {
++ depth = Math.max(0, depth - 1);
++ if (ch === '}' && depth === 0 && sawBrace && /^export\s+(?:declare\s+)?(?:interface|class)\b/.test(src.slice(i, i + 40))) { j++; break; }
++ continue;
++ }
++ if (ch === ';' && depth === 0) { j++; break; }
++ }
++ const text = src.slice(i, j).trim();
++ i = j;
++ if (!text.startsWith('export')) { pendingDoc = ''; continue; }
++ let kind = 'export';
++ let name = '';
++ for (const [re, k] of KINDS) {
++ const m = text.match(re);
++ if (m) { kind = k; name = m[1].trim(); break; }
++ }
++ if (kind === 'import') { pendingDoc = ''; continue; }
++ out.push({ kind, name: name || text.slice(0, 60), doc: pendingDoc, text });
++ pendingDoc = '';
++ }
++ return out;
++}
++
++module.exports = { parseDts };
ddiff --git a/server/docs/generate.js b/server/docs/generate.js
nnew file mode 100644
iindex 0000000..44c00b7
---- /dev/null
++++ b/server/docs/generate.js
@@@ -0,0 +1,189 @@
++'use strict';
++
++/**
++ * Generated documentation (roadmap §30.5: contract → generated docs → published package version).
++ *
++ * Built ONCE at boot from the packages this release pins — openvibe-contracts (catalog, JSON
++ * schemas, fixtures, capability and service manifests, ADRs) and openvibe-sdk (its .d.ts files) —
++ * so every page says exactly which versions it was generated from and nothing is hand-written that
++ * could drift. Upgrading a pin and restarting regenerates everything.
++ */
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const contractsRegistry = require('openvibe-contracts/lib/registry');
++const { parseDts } = require('./dts');
++
++const CONTRACTS_ROOT = path.dirname(require.resolve('openvibe-contracts/package.json'));
++const SDK_ROOT = path.dirname(require.resolve('openvibe-sdk/package.json'));
++const contractsPkg = require('openvibe-contracts/package.json');
++const sdkPkg = require('openvibe-sdk/package.json');
++const ownPkg = require('../../package.json');
++
++/** The git tag this release pins a package to (from package.json's tarball URL), or null. */
++function pinnedTag(dep) {
++ const m = String((ownPkg.dependencies || {})[dep] || '').match(/refs\/tags\/(v[0-9A-Za-z.-]+)$/);
++ return m ? m[1] : null;
++}
++
++/** Visibilities an app may ever be granted (Network's grantability rule, ADR-014). */
++const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);
++
++function readJsonDir(dir) {
++ try {
++ return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort()
++ .map((f) => ({ name: f.replace(/\.json$/, ''), body: JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) }));
++ } catch { return []; }
++}
++
++/** Absolute $id for a $ref relative to the schema that holds it. */
++function resolveRef(ref, baseId) {
++ try { return new URL(ref, baseId).toString(); } catch { return ref; }
++}
++
++function typeOf(s) {
++ if (!s || typeof s !== 'object') return 'any';
++ if (s.$ref) return 'ref';
++ if (s.const !== undefined) return 'const';
++ if (s.enum) return 'enum';
++ if (Array.isArray(s.type)) return s.type.join(' | ');
++ if (s.type) return s.type;
++ if (s.oneOf || s.anyOf) return 'one of';
++ return 'any';
++}
++
++function constraintsOf(s) {
++ const out = [];
++ if (!s || typeof s !== 'object') return out;
++ if (s.enum) out.push(`one of ${s.enum.map((v) => JSON.stringify(v)).join(', ')}`);
++ if (s.const !== undefined) out.push(`= ${JSON.stringify(s.const)}`);
++ if (s.pattern) out.push(`pattern ${s.pattern}`);
++ if (s.format) out.push(`format ${s.format}`);
++ for (const k of ['minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems']) if (s[k] !== undefined) out.push(`${k} ${s[k]}`);
++ if (s.uniqueItems) out.push('unique items');
++ if (s.default !== undefined) out.push(`default ${JSON.stringify(s.default)}`);
++ if (s.additionalProperties === false && s.type === 'object') out.push('no other fields');
++ return out;
++}
++
++/** Rows { path, type, required, description, constraints, ref } for a schema, nested with dotted paths. */
++function fieldRows(schema, baseId, prefix = '', depth = 0) {
++ const rows = [];
++ if (!schema || typeof schema !== 'object' || depth > 6) return rows;
++ const props = schema.properties || {};
++ const required = new Set(schema.required || []);
++ for (const [name, s] of Object.entries(props)) {
++ const p = prefix ? `${prefix}.${name}` : name;
++ const item = s && s.type === 'array' && s.items ? s.items : null;
++ rows.push({
++ path: p,
++ type: item ? `array of ${typeOf(item)}` : typeOf(s),
++ required: required.has(name),
++ description: (s && s.description) || '',
++ constraints: [...constraintsOf(s), ...(item ? constraintsOf(item).map((c) => `items: ${c}`) : [])],
++ ref: s && s.$ref ? resolveRef(s.$ref, baseId) : (item && item.$ref ? resolveRef(item.$ref, baseId) : null),
++ });
++ if (s && s.type === 'object' && s.properties) rows.push(...fieldRows(s, baseId, p, depth + 1));
++ if (item && item.type === 'object' && item.properties) rows.push(...fieldRows(item, baseId, `${p}[]`, depth + 1));
++ }
++ return rows;
++}
++
++function adrIndex() {
++ const dir = path.join(CONTRACTS_ROOT, 'docs', 'adr');
++ let files = [];
++ try { files = fs.readdirSync(dir).filter((f) => /^ADR-\d+.*\.md$/.test(f)).sort(); } catch { return []; }
++ return files.map((f) => {
++ const markdown = fs.readFileSync(path.join(dir, f), 'utf8');
++ const title = (markdown.match(/^#\s+(.+)$/m) || [null, f])[1].trim();
++ const status = (markdown.match(/^\*\*Status:\*\*\s*(.+)$/m) || [null, ''])[1].trim();
++ const id = (f.match(/^(ADR-\d+)/) || [null, f])[1];
++ return { id, file: f, title, status, markdown };
++ });
++}
++
++function sdkModules() {
++ const exp = sdkPkg.exports || {};
++ const out = [];
++ for (const [subpath, target] of Object.entries(exp)) {
++ if (!target || typeof target !== 'object' || !target.types) continue;
++ const file = path.join(SDK_ROOT, target.types);
++ let source = '';
++ try { source = fs.readFileSync(file, 'utf8'); } catch { continue; }
++ const name = subpath === '.' ? 'openvibe-sdk' : `openvibe-sdk/${subpath.replace(/^\.\//, '')}`;
++ out.push({
++ slug: subpath === '.' ? 'index' : subpath.replace(/^\.\//, '').replace(/\//g, '-'),
++ name,
++ typesFile: target.types.replace(/^\.\//, ''),
++ browser: target.browser === null ? 'server only' : (target.browser ? 'browser build available' : 'both'),
++ declarations: parseDts(source),
++ });
++ }
++ // contracts.d.ts is re-exported by core; document it too.
++ const extra = path.join(SDK_ROOT, 'types', 'contracts.d.ts');
++ if (fs.existsSync(extra)) out.push({ slug: 'contracts-types', name: 'openvibe-sdk (contract types)', typesFile: 'types/contracts.d.ts', browser: 'types only', declarations: parseDts(fs.readFileSync(extra, 'utf8')) });
++ return out;
++}
++
++function generate({ now = () => Date.now() } = {}) {
++ const deprecations = new Map((contractsRegistry.deprecations || []).map((d) => [d.id, d]));
++ const byDollarId = new Map();
++ const contractList = contracts.catalog.map((entry) => {
++ const schema = contracts.schema(entry.id);
++ byDollarId.set(schema.$id, entry.id);
++ const fixtures = {
++ valid: readJsonDir(path.join(CONTRACTS_ROOT, 'fixtures', entry.id, 'valid')),
++ invalid: readJsonDir(path.join(CONTRACTS_ROOT, 'fixtures', entry.id, 'invalid')),
++ };
++ return {
++ ...entry, $id: schema.$id, title: schema.title || entry.id, description: schema.description || '', schema,
++ fields: fieldRows(schema, schema.$id), fixtures, deprecation: deprecations.get(entry.id) || null,
++ };
++ });
++ for (const c of contractList) for (const f of c.fields) f.refId = f.ref ? (byDollarId.get(f.ref) || null) : null;
++
++ const capabilities = contracts.capabilities.manifests.map((c) => ({
++ ...c, grantable: GRANTABLE_VISIBILITIES.has(c.visibility) && c.status === 'active',
++ }));
++
++ const events = new Map();
++ for (const m of contracts.services.manifests) {
++ for (const t of m.eventsProduced || []) {
++ if (!events.has(t)) events.set(t, { type: t, producers: [], consumers: [] });
++ events.get(t).producers.push(m.id);
++ }
++ }
++ for (const m of contracts.services.manifests) {
++ for (const t of m.eventsConsumed || []) {
++ for (const [type, e] of events) {
++ const pattern = String(t);
++ const hit = pattern === type || (pattern.endsWith('*') && type.startsWith(pattern.slice(0, -1)));
++ if (hit && !e.consumers.includes(m.id)) e.consumers.push(m.id);
++ }
++ }
++ }
++
++ return {
++ generatedAt: new Date(now()).toISOString(),
++ contractsVersion: contractsPkg.version,
++ // The tag may differ from the version the package declares (the contracts tag v0.27.0 ships
++ // package.json 0.28.0); pages show both rather than pick one.
++ contractsTag: pinnedTag('openvibe-contracts') || `v${contractsPkg.version}`,
++ sdkTag: pinnedTag('openvibe-sdk') || `v${sdkPkg.version}`,
++ contractsLicense: contractsPkg.license,
++ sdkVersion: sdkPkg.version,
++ sdkLicense: sdkPkg.license,
++ sdkContractsRange: (() => { try { return require('openvibe-sdk/core').CONTRACTS_RANGE; } catch { return null; } })(),
++ contracts: contractList,
++ contract: (id) => contractList.find((c) => c.id === id) || null,
++ capabilities,
++ capability: (id) => capabilities.find((c) => c.id === id) || null,
++ services: contracts.services.manifests,
++ events: [...events.values()].sort((a, b) => a.type.localeCompare(b.type)),
++ eventTypes: new Set(events.keys()),
++ sdk: sdkModules(),
++ adrs: adrIndex(),
++ };
++}
++
++module.exports = { generate, fieldRows, pinnedTag, GRANTABLE_VISIBILITIES, CONTRACTS_ROOT, SDK_ROOT };
ddiff --git a/server/domain/limits.js b/server/domain/limits.js
nnew file mode 100644
iindex 0000000..67ea169
---- /dev/null
++++ b/server/domain/limits.js
@@@ -0,0 +1,57 @@
++'use strict';
++
++/**
++ * The limits a developer project meets, as each enforcing service reports them in its /limits.json
++ * (read from its running configuration, WS-N task 7). Shared by /docs/limits and a project's usage
++ * page (WS-N task 4). A service that does not answer gives a problem, never remembered numbers; a
++ * good answer is kept for ttlMs so a page view never waits on a service twice in a row.
++ */
++
++/** The services that publish /limits.json, with where Services reads them and where people can. */
++function limitSources(env = process.env) {
++ return [
++ // public: null until openvibe.host serves Host itself (WS-N task 10, Stage B step 2); read internally meanwhile.
++ { service: 'host', name: 'OpenVibe Host', internal: env.OV_HOST_INTERNAL_URL || 'http://127.0.0.1:4910', public: null },
++ { service: 'events', name: 'OpenVibe Events', internal: env.OV_EVENTS_INTERNAL_URL || 'http://127.0.0.1:4300', public: 'https://openvibe.events/limits.json' },
++ { service: 'media', name: 'OpenVibe Media', internal: env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100', public: 'https://openvibe.media/limits.json' },
++ ];
++}
++
++function createLimitsReader({ sources = limitSources(), ttlMs = 300_000, fetchImpl = (...a) => globalThis.fetch(...a) } = {}) {
++ const cache = new Map();
++ /** → { body } | { problem } for one source. */
++ async function read(src) {
++ const hit = cache.get(src.service);
++ if (hit && Date.now() - hit.at < ttlMs) return { body: hit.body };
++ try {
++ const out = await fetchImpl(`${src.internal.replace(/\/$/, '')}/limits.json`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
++ if (!out.ok) throw new Error(`${src.name} answered ${out.status}`);
++ const body = await out.json();
++ if (!body || !Array.isArray(body.limits)) throw new Error(`${src.name} answered without a limits list`);
++ cache.set(src.service, { at: Date.now(), body });
++ return { body };
++ } catch (err) {
++ return { problem: { code: 'services.limits_unavailable', detail: err.message } };
++ }
++ }
++ const bySource = (service) => sources.find((s) => s.service === service) || null;
++ return { sources, read, bySource };
++}
++
++const MB = 1024 * 1024;
++const bytes = (n) => (n >= 1024 * MB ? `${+(n / 1024 / MB).toFixed(2)} GB` : n >= MB ? `${+(n / MB).toFixed(2)} MB` : n >= 1024 ? `${+(n / 1024).toFixed(1)} KB` : `${n} bytes`);
++
++/** A limit's value as people read it (null: no limit, 0: none allowed). */
++function amount(v, unit) {
++ if (v === null || v === undefined) return 'no limit';
++ if (v === 0) return 'none';
++ const n = Number(v).toLocaleString('en-US');
++ if (unit === 'bytes') return bytes(Number(v));
++ if (unit === 'per_minute') return `${n} a minute`;
++ if (unit === 'per_day') return `${n} in 24 hours`;
++ if (unit === 'hours') return `${n} hours`;
++ if (unit === 'days') return `${n} days`;
++ return n;
++}
++
++module.exports = { limitSources, createLimitsReader, amount, bytes };
ddiff --git a/server/domain/manifests.js b/server/domain/manifests.js
nnew file mode 100644
iindex 0000000..dc96f3d
---- /dev/null
++++ b/server/domain/manifests.js
@@@ -0,0 +1,134 @@
++'use strict';
++
++/**
++ * Manifest validation with openvibe-contracts.
++ *
++ * mod mods.mod-manifest@1
++ * app services.app-manifest@1
++ *
++ * Schema errors come first; then checks the schema cannot express, against the pinned catalog:
++ * every requested capability must exist and be grantable to apps (active + public/partner), ranges
++ * must parse, consumed events should be produced by someone. With an app context (a release), the
++ * manifest must also describe THAT app: same id, project and environment.
++ */
++const contracts = require('openvibe-contracts');
++const { satisfiesRange } = require('openvibe-sdk/core');
++const { GRANTABLE_VISIBILITIES } = require('../docs/generate');
++
++const contractsVersion = require('openvibe-contracts/package.json').version;
++const sdkVersion = require('openvibe-sdk/package.json').version;
++const SCHEMAS = { app: 'services.app-manifest@1', mod: 'mods.mod-manifest@1' };
++
++function rangeError(range) {
++ try { satisfiesRange('0.0.0', range); return null; } catch (err) { return err.message; }
++}
++
++/** Starter manifests for the editor (valid shapes; the ids are the developer's own). */
++function template(kind, { appId, projectId, environment, name, subject } = {}) {
++ if (kind === 'mod') {
++ return {
++ id: 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ name: name || 'My mod',
++ version: '0.1.0',
++ description: '',
++ publisher: appId ? { type: 'app', id: appId } : { type: 'user', id: subject || 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++ target: 'games.browser',
++ runtime: 'games-content@1',
++ permissions: { capabilities: [] },
++ resources: { cpuMs: 1, memoryMb: 0, storageMb: 0 },
++ compatibility: { runtime: '>=1.0.0 <2.0.0', contracts: `^${contractsVersion}` },
++ };
++ }
++ return {
++ id: appId || 'app_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ name: name || 'My app',
++ version: '0.1.0',
++ publisher: appId ? { type: 'app', id: appId } : { type: 'user', id: subject || 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++ project_id: projectId || 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ environment: environment || 'sandbox',
++ capabilities: [],
++ compatibility: { contracts: `^${contractsVersion}`, sdk: `^${sdkVersion}` },
++ };
++}
++
++/**
++ * validate(kind, manifest, { app, viewerSubject, eventTypes })
++ * app: the Network app view (id, project_id, environment, grants) when validating for a release
++ * → { valid, errors: [{ path, message }], warnings: [{ path, message }], schema: { id, version } }
++ */
++function validate(kind, manifest, { app = null, viewerSubject = null, eventTypes = null } = {}) {
++ const errors = [];
++ const warnings = [];
++ if (kind !== 'app' && kind !== 'mod') return { valid: false, errors: [{ path: '/', message: 'kind is app or mod' }], warnings, schema: null };
++ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
++ return { valid: false, errors: [{ path: '/', message: 'a manifest is a JSON object' }], warnings, schema: null };
++ }
++ const r = contracts.validate(SCHEMAS[kind], manifest);
++ errors.push(...r.errors);
++ const entry = contracts.resolve(SCHEMAS[kind]);
++ const schema = { id: entry.id, version: entry.version };
++
++ // Requested capabilities: must exist in the pinned catalog and be grantable to apps.
++ const requested = kind === 'mod'
++ ? (manifest.permissions && Array.isArray(manifest.permissions.capabilities) ? manifest.permissions.capabilities : [])
++ : (Array.isArray(manifest.capabilities) ? manifest.capabilities : []);
++ const base = kind === 'mod' ? '/permissions/capabilities' : '/capabilities';
++ requested.forEach((id, i) => {
++ if (typeof id !== 'string') return;
++ const cap = contracts.capabilities.get(id);
++ if (!cap) { errors.push({ path: `${base}/${i}`, message: `${id} is not in openvibe-contracts ${contractsVersion}` }); return; }
++ if (!GRANTABLE_VISIBILITIES.has(cap.visibility)) { errors.push({ path: `${base}/${i}`, message: `${id} is ${cap.visibility}: it is never granted to apps or mods` }); return; }
++ if (cap.status !== 'active') warnings.push({ path: `${base}/${i}`, message: `${id} is ${cap.status}: it cannot be granted until it is active` });
++ if (cap.visibility === 'partner') warnings.push({ path: `${base}/${i}`, message: `${id} is partner-only: staff must add it to the project's allowance by hand` });
++ if (app && Array.isArray(app.grants) && !app.grants.includes(id)) warnings.push({ path: `${base}/${i}`, message: `${id} is not granted to this app yet (request it on the app page; Network decides)` });
++ });
++
++ // Ranges.
++ const comp = manifest.compatibility && typeof manifest.compatibility === 'object' ? manifest.compatibility : {};
++ for (const key of ['contracts', 'sdk', 'runtime']) {
++ if (typeof comp[key] !== 'string') continue;
++ const bad = rangeError(comp[key]);
++ if (bad) { errors.push({ path: `/compatibility/${key}`, message: `not a semver range: ${bad}` }); continue; }
++ if (key === 'contracts' && !satisfiesRange(contractsVersion, comp[key])) warnings.push({ path: '/compatibility/contracts', message: `the network's pinned openvibe-contracts ${contractsVersion} is outside ${comp[key]}` });
++ if (key === 'sdk' && !satisfiesRange(sdkVersion, comp[key])) warnings.push({ path: '/compatibility/sdk', message: `the current openvibe-sdk ${sdkVersion} is outside ${comp[key]}` });
++ }
++
++ // Events the app or mod wants delivered.
++ const consumes = kind === 'mod'
++ ? (manifest.permissions && Array.isArray(manifest.permissions.events) ? manifest.permissions.events : [])
++ : (manifest.events && Array.isArray(manifest.events.consumes) ? manifest.events.consumes : []);
++ if (eventTypes) {
++ consumes.forEach((t, i) => {
++ if (typeof t !== 'string') return;
++ const family = t.endsWith('.*') ? t.slice(0, -1) : null;
++ const known = family ? [...eventTypes].some((x) => x.startsWith(family)) : eventTypes.has(t);
++ if (!known) warnings.push({ path: kind === 'mod' ? `/permissions/events/${i}` : `/events/consumes/${i}`, message: `no service in openvibe-contracts ${contractsVersion} declares it produces ${t}` });
++ });
++ }
++
++ // For a release: the manifest must describe this very app.
++ if (app) {
++ if (kind === 'app') {
++ if (manifest.id !== app.id) errors.push({ path: '/id', message: `must be this app's id ${app.id}` });
++ if (manifest.project_id !== app.project_id) errors.push({ path: '/project_id', message: `must be this app's project ${app.project_id}` });
++ if (manifest.environment !== undefined && manifest.environment !== app.environment) errors.push({ path: '/environment', message: `this app is ${app.environment}` });
++ }
++ const pub = manifest.publisher;
++ if (pub && typeof pub === 'object') {
++ const ok = (pub.type === 'app' && pub.id === app.id) || (pub.type === 'user' && viewerSubject && pub.id === viewerSubject);
++ if (!ok) errors.push({ path: '/publisher', message: `must be this app (app ${app.id})${viewerSubject ? ` or you (user ${viewerSubject})` : ''}` });
++ }
++ }
++
++ return { valid: errors.length === 0, errors, warnings, schema };
++}
++
++/** Parse a manifest from form text. → { manifest } | { error } */
++function parse(text) {
++ const s = String(text || '').trim();
++ if (!s) return { error: 'paste a manifest (JSON)' };
++ if (s.length > 64 * 1024) return { error: 'a manifest is at most 64 KB' };
++ try { return { manifest: JSON.parse(s) }; } catch (err) { return { error: `not valid JSON: ${err.message.slice(0, 200)}` }; }
++}
++
++module.exports = { validate, parse, template, SCHEMAS };
ddiff --git a/server/domain/playground.js b/server/domain/playground.js
nnew file mode 100644
iindex 0000000..6e90717
---- /dev/null
++++ b/server/domain/playground.js
@@@ -0,0 +1,217 @@
++'use strict';
++
++/**
++ * Playgrounds that cannot exceed the project's grants (ADR-014 acceptance).
++ *
++ * events publish one test event needs events.app.publish (audience openvibe.events;
++ * type app.<project_key>.…,
++ * source app-<app ulid>)
++ * media upload one small file (sandbox) needs media.object.upload (audience openvibe.media,
++ * namespace = project id)
++ *
++ * The order is fixed and every step can refuse:
++ * 1. app the app (as Network reports it to the signed-in member) is active and is a SANDBOX app
++ * 2. grant Network lists the capability among the app's APPROVED grants; if not, nothing else
++ * happens — no token is requested, no service is called — and the page says which
++ * grant is missing and whether it can ever be granted (the pinned catalog's visibility)
++ * 3. input the request is valid (an event envelope validates as events.event-envelope@1)
++ * 4. token the APP's own token: minted by Network from the client secret the developer typed for
++ * this request, or pasted; then verified offline — signed by Network, for this app, this
++ * project, env sandbox, this audience, and carrying the capability
++ * 5. call the service is called through openvibe-sdk with that token, and its answer is shown
++ *
++ * Services' own credentials are never used, no shared loopback key exists anywhere, and the secret or token
++ * is never stored, logged or rendered: run logs keep only ids, outcome, status and problem code,
++ * with any echo of the credential scrubbed from the detail.
++ */
++const contracts = require('openvibe-contracts');
++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
++const { createEventsClient } = require('openvibe-sdk/events');
++const { createMediaClient } = require('openvibe-sdk/media');
++const { GRANTABLE_VISIBILITIES } = require('../docs/generate');
++
++const contractsVersion = require('openvibe-contracts/package.json').version;
++
++const KINDS = {
++ // `needs`: the capability the APP must hold (owned by Events / Media; Services guards nothing with it).
++ events: { needs: 'events.app.publish', audience: 'openvibe.events', label: 'Events: publish a test event' },
++ media: { needs: 'media.object.upload', audience: 'openvibe.media', label: 'Media: upload a file to the sandbox' },
++};
++
++function scrub(text, secrets) {
++ let s = String(text || '').slice(0, 500);
++ for (const x of secrets) if (x && x.length >= 8) s = s.split(x).join('[redacted]');
++ return s.replace(/ovsec_[A-Za-z0-9_-]+/g, '[redacted]').replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[redacted token]');
++}
++
++/**
++ * What events.app.publish allows an app (openvibe-contracts): types app.<project_key>.<name>[…]
++ * where project_key is 'p' + the project's ULID in lowercase, and source 'app-' + the app's ULID
++ * in lowercase.
++ */
++const projectKey = (projectId) => `p${String(projectId).replace(/^prj_/, '').toLowerCase()}`;
++const appSource = (appId) => `app-${String(appId).replace(/^app_/, '').toLowerCase()}`;
++
++/** Why a capability is not granted, from the pinned catalog. */
++function grantAdvice(capability) {
++ const cap = contracts.capabilities.get(capability);
++ if (!cap) return { grantable: false, text: `${capability} is not in openvibe-contracts ${contractsVersion}.` };
++ if (!GRANTABLE_VISIBILITIES.has(cap.visibility)) {
++ return { grantable: false, text: `${capability} is ${cap.visibility} in openvibe-contracts ${contractsVersion}: Network never grants it to apps, so this playground cannot run until ${cap.owner} publishes a public capability for it.` };
++ }
++ if (cap.status !== 'active') return { grantable: false, text: `${capability} is ${cap.status}; it can be granted once it is active.` };
++ return { grantable: true, text: `Request ${capability} on the app page. An owner or admin approves it, and only inside the project's allowance (staff set the allowance).` };
++}
++
++function createPlayground({ store, config, network, keys, fetchImpl, log = console }) {
++ const { db } = store;
++
++ async function record(run) {
++ const id = store.newId('run');
++ await db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(id, store.iso(), run.actor, run.project_id, run.app_id, run.kind, run.capability, run.credential, run.outcome, run.stage,
++ run.http_status || null, run.code || null, run.detail || '', run.ref || null);
++ return id;
++ }
++
++ const runsFor = async (appId, limit = 20) => await db.prepare('SELECT id, at, actor, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE app_id = ? ORDER BY at DESC, id DESC LIMIT ?').all(String(appId), limit);
++ const runsForProject = async (projectId) => await db.prepare('SELECT id, at, actor, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE project_id = ? ORDER BY at, id').all(String(projectId));
++ const deleteRunsForProject = async (projectId) => (await db.prepare('DELETE FROM playground_runs WHERE project_id = ?').run(String(projectId))).changes;
++ const recentCount = async (actor) => (await db.prepare('SELECT COUNT(*) AS n FROM playground_runs WHERE actor = ? AND at > ?').get(actor, new Date(store.now() - 3600_000).toISOString())).n;
++
++ /** Steps 1–2 only: may this app run this playground at all? */
++ function precheck(app, kind) {
++ const k = KINDS[kind];
++ if (!k) return { ok: false, stage: 'input', code: 'playground.unknown', detail: 'no such playground' };
++ if (app.revoked_at) return { ok: false, stage: 'app', code: 'playground.app_revoked', detail: 'this app is revoked in Network' };
++ if (app.environment !== 'sandbox') return { ok: false, stage: 'app', code: 'playground.production_app', detail: 'playgrounds run only sandbox apps; create a sandbox app in this project to try things out' };
++ const grants = Array.isArray(app.grants) ? app.grants : [];
++ if (!grants.includes(k.needs)) {
++ const advice = grantAdvice(k.needs);
++ return { ok: false, stage: 'grant', code: 'playground.grant_missing', detail: `this app does not hold ${k.needs} (audience ${k.audience}). ${advice.text}`, missing: k.needs, grantable: advice.grantable };
++ }
++ return { ok: true };
++ }
++
++ /** Build the events envelope from form input. → { envelope } | { error } */
++ function eventInput(input) {
++ let payload = {};
++ const raw = String(input.payload || '').trim();
++ if (raw) {
++ if (raw.length > 16 * 1024) return { error: 'payload is at most 16 KB' };
++ try { payload = JSON.parse(raw); } catch (err) { return { error: `payload is not JSON: ${err.message.slice(0, 120)}` }; }
++ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { error: 'payload must be a JSON object' };
++ }
++ const eventType = String(input.event_type || '').trim();
++ const prefix = `app.${projectKey(input.projectId)}.`;
++ if (!eventType.startsWith(prefix) || eventType.length === prefix.length) return { error: `this app may publish only event types starting with ${prefix} (events.app.publish)` };
++ const envelope = {
++ event_id: contracts.ids.newId('event', store.now()),
++ event_type: eventType,
++ version: 1,
++ source: appSource(input.appId),
++ actor: { type: 'app', id: input.appId },
++ timestamp: new Date(store.now()).toISOString(),
++ visibility: 'internal',
++ subject: { type: String(input.subject_type || 'test').trim().slice(0, 40) || 'test', id: String(input.subject_id || 'test-1').trim().slice(0, 120) || 'test-1' },
++ payload,
++ };
++ const v = contracts.validate('events.event-envelope@1', envelope);
++ if (!v.valid) return { error: `the event does not validate as events.event-envelope@1: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}` };
++ return { envelope };
++ }
++
++ async function appToken(app, k, credential) {
++ let token;
++ if (credential.type === 'client_secret') {
++ const got = await network.appToken({ appId: app.id, clientSecret: credential.value, audience: k.audience, scope: k.needs });
++ if (!got.ok) return { ok: false, http_status: got.status, code: got.code, detail: `Network refused the token request: ${got.detail || got.code}` };
++ token = got.token;
++ } else {
++ token = credential.value;
++ }
++ await keys.ensure();
++ const v = await keys.verifyApp(token, { issuer: config.network.issuer, audience: k.audience, acceptSandbox: true, now: store.now() });
++ if (!v.ok) return { ok: false, code: 'playground.token_invalid', detail: `the token is not usable: ${v.reason}` };
++ const c = v.claims;
++ const problems = [];
++ if (c.sub !== `app:${app.id}`) problems.push(`it belongs to ${String(c.sub).slice(0, 60)}, not app:${app.id}`);
++ if (c.project_id !== undefined && c.project_id !== app.project_id) problems.push('it is for another project');
++ if (c.env !== 'sandbox') problems.push(`its env is ${c.env === undefined ? 'unset' : c.env}, not sandbox`);
++ if (!Array.isArray(c.cap) || !c.cap.includes(k.needs)) problems.push(`it does not carry ${k.needs}`);
++ if (problems.length) return { ok: false, code: 'playground.token_refused', detail: `the token was refused: ${problems.join('; ')}` };
++ return { ok: true, token };
++ }
++
++ function sdkClient(token) {
++ return createClient({
++ autoDiscover: false, retries: 0, timeoutMs: 10_000, deadlineMs: 15_000, token,
++ baseUrls: { events: config.playground.eventsUrl, media: config.playground.mediaUrl },
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ });
++ }
++
++ /**
++ * run({ actor, app, kind, credential: { type: 'client_secret' | 'access_token', value }, input, file })
++ * → { outcome: 'ok' | 'refused' | 'failed', stage, code, detail, http_status, result, runId }
++ */
++ async function run({ actor, app, kind, credential, input = {}, file = null }) {
++ const k = KINDS[kind];
++ const secrets = [credential && credential.value].filter(Boolean);
++ const base = { actor, project_id: app.project_id, app_id: app.id, kind, capability: k ? k.needs : String(kind), credential: 'none' };
++ const finish = async (out) => {
++ const safe = { ...out, detail: scrub(out.detail, secrets) };
++ const runId = await record({ ...base, ...safe });
++ return { ...safe, runId };
++ };
++
++ if (await recentCount(actor) >= config.playground.runsPerHour) {
++ return { outcome: 'refused', stage: 'rate', code: 'playground.rate_limited', detail: `at most ${config.playground.runsPerHour} playground runs an hour` };
++ }
++ const pre = precheck(app, kind);
++ if (!pre.ok) return { ...await finish({ outcome: 'refused', stage: pre.stage, code: pre.code, detail: pre.detail }), missing: pre.missing, grantable: pre.grantable };
++
++ let envelope = null;
++ if (kind === 'events') {
++ const e = eventInput({ ...input, appId: app.id, projectId: app.project_id });
++ if (e.error) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });
++ envelope = e.envelope;
++ } else {
++ if (!file || !file.buffer || !file.buffer.length) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: 'choose a file' });
++ if (file.buffer.length > config.playground.maxUploadBytes) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: `playground uploads are at most ${config.playground.maxUploadBytes} bytes` });
++ }
++
++ if (!credential || !credential.value || !['client_secret', 'access_token'].includes(credential.type)) {
++ return await finish({ outcome: 'refused', stage: 'token', code: 'playground.no_credential', detail: 'enter the app\'s client secret (used once, never stored) or an access token you minted for it' });
++ }
++ base.credential = credential.type;
++ const tok = await appToken(app, k, credential);
++ if (!tok.ok) return await finish({ outcome: 'refused', stage: 'token', code: tok.code, detail: tok.detail, http_status: tok.http_status });
++
++ const client = sdkClient(tok.token);
++ try {
++ if (kind === 'events') {
++ const events = createEventsClient(client, { source: envelope.source, baseUrl: config.playground.eventsUrl });
++ const out = await events.publish(envelope);
++ return { ...await finish({ outcome: 'ok', stage: 'done', http_status: 200, detail: out.duplicate ? 'accepted (duplicate event_id)' : `accepted as seq ${out.seq}`, ref: out.event_id }), result: { event_id: out.event_id, seq: out.seq, duplicate: out.duplicate } };
++ }
++ const media = createMediaClient(client, { app: app.project_id, baseUrl: config.playground.mediaUrl, publicOrigin: config.playground.mediaUrl });
++ const out = await media.files.upload(file.buffer, { filename: file.filename, contentType: file.mimeType });
++ return { ...await finish({ outcome: 'ok', stage: 'done', http_status: 201, detail: `stored ${out.size} bytes`, ref: out.key }), result: { key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256 } };
++ } catch (err) {
++ if (isOpenVibeError(err)) {
++ const service = kind === 'events' ? 'OpenVibe.Events' : 'OpenVibe.Media';
++ const unreachable = !err.status;
++ return await finish({ outcome: 'failed', stage: 'call', http_status: err.status || null, code: err.code,
++ detail: unreachable ? `${service} did not answer (${err.code})` : `${service} answered ${err.status}: ${err.detail || err.title || err.code}` });
++ }
++ log.error('[Services] playground error:', scrub(err && err.message, secrets));
++ return await finish({ outcome: 'failed', stage: 'call', code: 'playground.error', detail: 'unexpected error calling the service' });
++ }
++ }
++
++ return { run, precheck, runsFor, runsForProject, deleteRunsForProject, grantAdvice, KINDS, projectKey, appSource };
++}
++
++module.exports = { createPlayground, KINDS, grantAdvice, scrub, projectKey, appSource };
ddiff --git a/server/domain/project-archive.js b/server/domain/project-archive.js
nnew file mode 100644
iindex 0000000..eadc9c9
---- /dev/null
++++ b/server/domain/project-archive.js
@@@ -0,0 +1,289 @@
++'use strict';
++
++/**
++ * The full project archive (roadmap WS-N task 9): one zip with everything the project holds on the
++ * platform, for its owner or an admin. Built when asked, sent once, never stored.
++ *
++ * manifest.json what each part holds: counts, limits reached, file sizes and hashes
++ * README.txt the same in words, and how to fetch the objects before the URLs expire
++ * project.json the metadata document (project-export.js): Network's project, members,
++ * apps, credentials by id and hint, grants, quotas and audit; Services'
++ * releases, trust tiers and playground runs. This is the configuration.
++ * modules/<release_id>.json each release's manifest (services.app-manifest@1 or mods.mod-manifest@1)
++ * exactly as validated: the project's app and mod modules
++ * media/<env>/namespaces.json Media's namespaces for the project: policy, quotas, usage
++ * media/<env>/objects.jsonl every object Media holds for the project (soft-deleted ones too),
++ * with `download`: its public URL, or a signed URL valid urlTtlS
++ * events/<env>.jsonl the project's app events (app.<project_key>.*) Events still keeps,
++ * one { seq, event } per line
++ *
++ * Objects are listed with download URLs rather than copied into the zip: a project may hold a GiB
++ * or more, and Services stays out of the byte path. Media signs private and sandbox URLs for at most
++ * an hour; public production objects keep their permanent URL.
++ *
++ * Authorization: for each audience (openvibe.media, openvibe.events) and environment, Network mints
++ * a 5-minute read-only export token for the signed-in person (`mintToken`, with their own Network
++ * token; Network allows only the owner and admins). A long export asks again a minute before one
++ * expires. No app secret is involved, and no token is written to the archive or the logs.
++ *
++ * Failure: a service that fails (an error, a refusal, no answer) fails the whole export with an
++ * ArchiveError naming the service, the part and the environment; a partial archive is never sent as
++ * if it were complete. A size limit is not a failure: the part says `complete: false`, how many it
++ * holds and where it stopped, and manifest.json's top-level `complete` is false.
++ */
++const crypto = require('crypto');
++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
++const { zip } = require('./zip');
++
++const FORMAT = 'openvibe.services.project-archive';
++const FORMAT_VERSION = 1;
++const ENVS = ['production', 'sandbox'];
++const OBJECT_PAGE = 200; // Media's largest list page
++const EVENT_PAGE = 1000; // Events' largest pull page
++const MAX_EVENT_PAGES = 10000; // a guard; each pull scans up to 5,000 rows of the project's topic
++const REMINT_BEFORE_MS = 60 * 1000;
++const DOWNLOAD_CONCURRENCY = 8;
++const SERVICES = { media: 'OpenVibe.Media', events: 'OpenVibe.Events' };
++
++const NOT_INCLUDED = Object.freeze([
++ { what: 'Tools job results', why: 'OpenVibe.Tools keeps them in its own Media tenant (tools.app.<project_id>), readable only by Tools; fetch each result from the Tools job API with the app\'s own token.' },
++ { what: 'Events webhook subscriptions', why: 'they belong to each app (listing them needs events.app.subscribe) and carry signing secrets; list them with the app\'s own token.' },
++ { what: 'Network user modules', why: 'they are per person, not per project; each member exports their own from OpenVibe.Network (GET /api/modules).' },
++ { what: 'Client secrets and tokens', why: 'never exported: Network lists credentials by id and last four characters only.' },
++]);
++
++/** A service that failed during the export. `problem` is what the portal shows. */
++class ArchiveError extends Error {
++ constructor({ service, part, env, status, code, detail, requestId }) {
++ const name = SERVICES[service] || service;
++ const answered = status ? `answered ${status}${code ? ` (${code})` : ''}` : `did not answer${code ? ` (${code})` : ''}`;
++ super(`${name} ${answered} for ${part} (${env})`);
++ this.problem = {
++ status: 502, code: 'services.export_failed', requestId: requestId || null,
++ detail: `${name} ${answered} while exporting ${part} (${env})${detail ? `: ${detail}` : ''}. Nothing was exported: an archive is never sent with a part missing.`,
++ };
++ }
++}
++
++const projectKey = (projectId) => `p${String(projectId).replace(/^prj_/, '').toLowerCase()}`;
++const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
++const jsonl = (rows) => rows.map((r) => `${JSON.stringify(r)}\n`).join('');
++const json = (v) => `${JSON.stringify(v, null, 2)}\n`;
++
++/** Run `fn` over `items`, `n` at a time; the first failure rejects (the rest are not started). */
++async function pool(items, n, fn) {
++ let next = 0;
++ let failed = null;
++ const worker = async () => {
++ while (!failed && next < items.length) {
++ const item = items[next++];
++ try { await fn(item); } catch (err) { failed = failed || err; }
++ }
++ };
++ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
++ if (failed) throw failed;
++}
++
++function createArchiver({ config, fetchImpl, log = console }) {
++ const opts = config.export;
++ const client = createClient({
++ network: config.network.internalUrl, autoDiscover: false, retries: 1, timeoutMs: 15000, deadlineMs: 30000,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ onWarning: (m) => log.warn('[Services] sdk:', m),
++ });
++
++ /** A token source for one audience and environment: minted on first use and again before it expires. */
++ function tokenSource(mintToken, audience, env) {
++ let current = null;
++ return async () => {
++ if (!current || Date.parse(current.expires_at) - Date.now() < REMINT_BEFORE_MS) current = await mintToken(audience, env);
++ return current.access_token;
++ };
++ }
++
++ /**
++ * GET one JSON document from Media or Events; any failure there becomes an ArchiveError. The
++ * token comes first and outside: Network refusing it is Network's answer, not the service's.
++ */
++ async function get(service, part, env, token, path, query) {
++ const baseUrl = service === 'media' ? opts.mediaUrl : opts.eventsUrl;
++ const bearer = await token();
++ try {
++ return (await client.json({ baseUrl, path, query, token: bearer })) || {};
++ } catch (err) {
++ if (err instanceof ArchiveError || !isOpenVibeError(err)) throw err;
++ const unreachable = !err.status || String(err.code || '').startsWith('sdk.');
++ throw new ArchiveError({
++ service, part, env, status: unreachable ? null : err.status, code: err.code,
++ detail: unreachable ? '' : String(err.detail || err.title || '').slice(0, 300), requestId: err.requestId,
++ });
++ }
++ }
++
++ async function mediaPart(project, env, token) {
++ const base = `/api/v2/${encodeURIComponent(project.id)}`;
++ const ns = await get('media', 'namespaces', env, token, `${base}/namespaces`);
++ const objects = [];
++ let cursor = null;
++ for (;;) {
++ const page = await get('media', 'objects', env, token, `${base}/objects`, { limit: OBJECT_PAGE, include_deleted: 1, ...(cursor ? { cursor } : {}) });
++ objects.push(...(page.objects || []));
++ cursor = page.next_cursor || null;
++ if (!cursor || objects.length >= opts.maxObjects) break;
++ }
++ const complete = !cursor && objects.length <= opts.maxObjects;
++ objects.length = Math.min(objects.length, opts.maxObjects);
++
++ const counts = { public: 0, signed: 0, none: 0 };
++ const toSign = [];
++ for (const o of objects) {
++ if (o.lifecycle_status === 'ready' && o.public_url) { o.download = { url: o.public_url, expires_at: null, public: true }; counts.public++; }
++ else if (o.lifecycle_status === 'ready') toSign.push(o);
++ else { o.download = null; counts.none++; }
++ }
++ await pool(toSign, DOWNLOAD_CONCURRENCY, async (o) => {
++ const d = await get('media', 'download URLs', env, token, `${base}/objects/${encodeURIComponent(o.id)}/download`, { format: 'json', ttl: opts.urlTtlS });
++ o.download = { url: d.url, expires_at: d.expires_at || null, public: Boolean(d.public) };
++ if (d.public) counts.public++; else counts.signed++;
++ });
++ const expiries = objects.map((o) => o.download && o.download.expires_at).filter(Boolean).sort();
++ return {
++ namespaces: ns.namespaces || [],
++ objects,
++ summary: {
++ count: objects.length, complete, limit: opts.maxObjects,
++ ...(complete ? {} : { next_cursor: objects.length ? objects[objects.length - 1].id : null, note: `stopped at ${opts.maxObjects} objects (SERVICES_EXPORT_MAX_OBJECTS); list the rest from Media with ?cursor=next_cursor` }),
++ bytes: objects.reduce((n, o) => n + (Number(o.size_bytes) || 0), 0),
++ downloads: counts, urls_expire_at: expiries[0] || null,
++ },
++ };
++ }
++
++ async function eventsPart(project, env, token) {
++ const topic = `app.${projectKey(project.id)}.*`;
++ const events = [];
++ let after = 0;
++ let end = null;
++ let gap = null;
++ let pages = 0;
++ let stopped = false;
++ for (;;) {
++ const page = await get('events', 'events', env, token, '/api/v1/events', { topic, after_seq: after, limit: EVENT_PAGE });
++ if (end === null) end = Number(page.latest_seq) || 0;
++ if (page.gap && !gap) gap = page.gap;
++ events.push(...(page.events || []));
++ const next = Number(page.next_after_seq);
++ if (events.length > opts.maxEvents) { stopped = true; break; }
++ if (!(next > after) || next >= end) break;
++ if (++pages >= MAX_EVENT_PAGES) { stopped = true; break; }
++ after = next;
++ }
++ events.length = Math.min(events.length, opts.maxEvents);
++ return {
++ events,
++ summary: {
++ count: events.length, complete: !stopped, limit: opts.maxEvents, topic,
++ first_seq: events.length ? events[0].seq : null, last_seq: events.length ? events[events.length - 1].seq : null,
++ ...(stopped ? { next_after_seq: events.length ? events[events.length - 1].seq : after, note: `stopped at ${opts.maxEvents} events (SERVICES_EXPORT_MAX_EVENTS); pull the rest from Events with after_seq=next_after_seq` } : {}),
++ retention: `Events keeps app events for a limited time (production 30 days, sandbox 7 by default); older ones are gone${gap ? ` (the oldest kept is seq ${gap.to_seq + 1})` : ''}.`,
++ },
++ };
++ }
++
++ /**
++ * project: Network's project view; metadata: the project.json document (buildExport);
++ * mintToken(audience, env) → { access_token, expires_at } (throws Network's problem as is).
++ * Returns { buffer, filename, summary }.
++ */
++ async function build({ project, metadata, mintToken, meta }) {
++ const media = {};
++ const events = {};
++ for (const env of ENVS) {
++ media[env] = await mediaPart(project, env, tokenSource(mintToken, 'openvibe.media', env));
++ events[env] = await eventsPart(project, env, tokenSource(mintToken, 'openvibe.events', env));
++ }
++
++ const releases = (metadata.services && metadata.services.releases) || [];
++ const modules = releases.filter((r) => r.manifest && r.manifest.body).map((r) => ({
++ path: `modules/${r.id}.json`, body: r.manifest.body,
++ entry: { path: `modules/${r.id}.json`, release_id: r.id, app_id: r.app_id, kind: r.kind, name: r.name, version: r.version, status: r.status, environment: r.environment, schema: r.kind === 'mod' ? 'mods.mod-manifest@1' : 'services.app-manifest@1' },
++ }));
++
++ const files = [
++ { name: 'project.json', data: json(metadata) },
++ ...modules.map((m) => ({ name: m.path, data: json(m.body) })),
++ ];
++ for (const env of ENVS) {
++ files.push({ name: `media/${env}/namespaces.json`, data: json({ project_id: project.id, env, namespaces: media[env].namespaces }) });
++ files.push({ name: `media/${env}/objects.jsonl`, data: jsonl(media[env].objects) });
++ files.push({ name: `events/${env}.jsonl`, data: jsonl(events[env].events) });
++ }
++ for (const f of files) f.data = Buffer.from(f.data, 'utf8');
++
++ const complete = ENVS.every((env) => media[env].summary.complete && events[env].summary.complete) && !(metadata.audit && metadata.audit.complete === false);
++ const manifest = {
++ format: FORMAT, format_version: FORMAT_VERSION,
++ exported_at: meta.now, exported_by: meta.subject,
++ project: { id: project.id, name: project.name, archived_at: project.archived_at || null },
++ complete,
++ parts: {
++ metadata: { path: 'project.json', format: metadata.format, format_version: metadata.format_version, holds: 'project, members, apps, credentials (id and last four only), grants, quotas, audit; releases, trust tiers, playground runs' },
++ modules: { path: 'modules/', count: modules.length, files: modules.map((m) => m.entry) },
++ media: Object.fromEntries(ENVS.map((env) => [env, {
++ namespaces: { path: `media/${env}/namespaces.json`, count: media[env].namespaces.length },
++ objects: { path: `media/${env}/objects.jsonl`, ...media[env].summary },
++ }])),
++ events: Object.fromEntries(ENVS.map((env) => [env, { path: `events/${env}.jsonl`, ...events[env].summary }])),
++ },
++ not_included: NOT_INCLUDED,
++ files: files.map((f) => ({ path: f.name, bytes: f.data.length, sha256: sha256(f.data) })),
++ };
++ const entries = [{ name: 'manifest.json', data: json(manifest) }, { name: 'README.txt', data: readme(manifest) }, ...files];
++ const buffer = zip(entries, { date: new Date(meta.now) });
++ return {
++ buffer,
++ filename: `openvibe-project-${project.id}-${meta.now.slice(0, 10)}.zip`,
++ summary: {
++ complete,
++ objects: ENVS.reduce((n, env) => n + media[env].objects.length, 0),
++ events: ENVS.reduce((n, env) => n + events[env].events.length, 0),
++ modules: modules.length, bytes: buffer.length,
++ },
++ };
++ }
++
++ return { build };
++}
++
++function readme(m) {
++ const env = (e) => {
++ const o = m.parts.media[e].objects;
++ const ev = m.parts.events[e];
++ return ` ${e}:\n`
++ + ` media/${e}/objects.jsonl ${o.count} objects${o.complete ? '' : ` (NOT COMPLETE: ${o.note})`}; ${o.downloads.public} public, ${o.downloads.signed} signed, ${o.downloads.none} without bytes to fetch (not ready or deleted)\n`
++ + ` media/${e}/namespaces.json ${m.parts.media[e].namespaces.count} namespaces (policy, quotas, usage)\n`
++ + ` events/${e}.jsonl ${ev.count} events${ev.complete ? '' : ` (NOT COMPLETE: ${ev.note})`}\n`;
++ };
++ const expiry = ['production', 'sandbox'].map((e) => m.parts.media[e].objects.urls_expire_at).filter(Boolean).sort()[0];
++ return `OpenVibe project archive: ${m.project.name} (${m.project.id})
++Exported ${m.exported_at} by ${m.exported_by}. Format ${m.format} v${m.format_version}.
++${m.complete ? 'Complete: every part below holds everything the services returned.' : 'NOT COMPLETE: a size limit was reached; manifest.json says which part and where it stopped.'}
++
++project.json the project as OpenVibe.Network holds it (members, apps, credentials by id and
++ last four characters, grants, quotas, audit) and OpenVibe.Services' releases, trust
++ tiers and playground runs. This is the project's configuration.
++modules/ ${m.parts.modules.count} release manifest(s), exactly as validated (app and mod modules).
++${env('production')}${env('sandbox')}
++Objects are listed, not copied: each line of objects.jsonl has "download": a public URL, or a
++signed URL that ${expiry ? `expires at ${expiry}` : 'expires within the hour'}. Fetch them before then, for example:
++
++ node -e "const fs=require('fs'),p=require('path');(async()=>{for(const l of fs.readFileSync(process.argv[1],'utf8').split('\\n').filter(Boolean)){const o=JSON.parse(l);if(!o.download)continue;const r=await fetch(o.download.url);if(!r.ok){console.error(o.id,r.status);continue;}fs.mkdirSync('objects',{recursive:true});fs.writeFileSync(p.join('objects',o.id),Buffer.from(await r.arrayBuffer()));}})()" media/production/objects.jsonl
++
++Download the archive again for fresh URLs. Not included:
++${m.not_included.map((x) => ` - ${x.what}: ${x.why}`).join('\n')}
++
++manifest.json lists every file with its size and SHA-256.
++`;
++}
++
++module.exports = { createArchiver, ArchiveError, FORMAT, FORMAT_VERSION, NOT_INCLUDED, projectKey };
ddiff --git a/server/domain/project-export.js b/server/domain/project-export.js
nnew file mode 100644
iindex 0000000..28ad040
---- /dev/null
++++ b/server/domain/project-export.js
@@@ -0,0 +1,67 @@
++'use strict';
++
++/**
++ * A project's metadata as one JSON document, for the developer to keep or move elsewhere.
++ *
++ * Network owns the project, members, apps, credentials, grants, quotas and audit (ADR-014), so
++ * those parts are read from its /api/v1/projects API with the person's own token, exactly as it
++ * answers: Services adds nothing and copies nothing into its own database. Services adds only what is
++ * Services': releases (with manifests and logs), trust tiers and playground run logs.
++ *
++ * There is no secret in it: Network lists credentials by id and last four characters only, and no
++ * Services table holds a secret (test/secrets.test.js). The audit log needs the admin role in Network;
++ * for anyone else it is left out and the document says so. Any Network failure fails the whole
++ * export: a partial export presented as complete would be wrong.
++ */
++const contractsVersion = require('openvibe-contracts/package.json').version;
++
++const FORMAT = 'openvibe.services.project-export';
++const FORMAT_VERSION = 1;
++const AUDIT_PAGE = 200;
++const AUDIT_MAX_PAGES = 50;
++
++async function buildExport({ network, token, project, includeAudit, releases, playground, trust, meta }) {
++ const P = network.projects;
++ const [members, apps, quotas] = await Promise.all([P.members(token, project.id), P.apps(token, project.id), P.quotas(token, project.id)]);
++ const appList = await Promise.all((apps.apps || []).map(async (a) => {
++ const [credentials, grants] = await Promise.all([P.credentials(token, project.id, a.id), P.grants(token, project.id, a.id)]);
++ return { ...a, credentials: credentials.credentials || [], grants: grants.grants || [] };
++ }));
++
++ let audit = null;
++ if (includeAudit) {
++ audit = { entries: [], complete: true };
++ let before;
++ for (let page = 0; ; page++) {
++ if (page === AUDIT_MAX_PAGES) { audit.complete = false; break; }
++ const got = await P.audit(token, project.id, { before, limit: AUDIT_PAGE });
++ audit.entries.push(...(got.entries || []));
++ if (!got.next_before) break;
++ before = got.next_before;
++ }
++ }
++
++ const appIds = appList.map((a) => a.id);
++ return {
++ format: FORMAT,
++ format_version: FORMAT_VERSION,
++ exported_at: meta.now,
++ exported_by: meta.subject,
++ sources: {
++ network: { url: meta.networkUrl, api: '/api/v1/projects', owns: ['project', 'members', 'apps', 'credentials', 'grants', 'quotas', 'audit'] },
++ services: { url: meta.baseUrl, contracts: contractsVersion, owns: ['releases', 'trust', 'playground_runs'] },
++ },
++ project,
++ members: members.members || [],
++ apps: appList,
++ quotas: quotas.quotas || [],
++ audit: audit || { entries: null, note: 'the audit log needs the admin role in this project' },
++ services: {
++ releases: await releases.listForProject(project.id),
++ trust: (await Promise.all(appIds.map(async (id) => ({ app_id: id, ...await trust.get(id), history: await trust.history(id) })))),
++ playground_runs: await playground.runsForProject(project.id),
++ },
++ };
++}
++
++module.exports = { buildExport, FORMAT, FORMAT_VERSION };
ddiff --git a/server/domain/releases.js b/server/domain/releases.js
nnew file mode 100644
iindex 0000000..a924c51
---- /dev/null
++++ b/server/domain/releases.js
@@@ -0,0 +1,250 @@
++'use strict';
++
++/**
++ * Release metadata — Services-owned records keyed to Network app ids.
++ *
++ * draft → published → deprecated → revoked (revoked is terminal; a draft can be revoked too)
++ *
++ * Who may act is decided from the Network's answer about the actor (their project role and the
++ * app's environment), mirroring Network's own rule: developer+ for sandbox apps, admin+ for
++ * production apps; Services staff may revoke any release. An app acting with its own token (API,
++ * services.release.manage) may manage only its own releases.
++ *
++ * A release carries its validated manifest, its compatibility ranges and — when shown — the app's
++ * current trust tier, which is metadata only: it never grants or bypasses anything.
++ */
++const { validate: validateManifest } = require('./manifests');
++
++const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
++const contractsVersion = require('openvibe-contracts/package.json').version;
++
++class ReleaseError extends Error {
++ constructor(status, code, detail, extra = {}) { super(detail || code); this.status = status; this.code = code; this.detail = detail; Object.assign(this, extra); }
++}
++const fail = (status, code, detail, extra) => { throw new ReleaseError(status, code, detail, extra); };
++
++/** Role needed to manage releases of an app in this environment (Network's rule for apps). */
++const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');
++
++function createReleases({ store, outbox, trust, config, indexnow }) {
++ const { db } = store;
++ const site = String((config && config.baseUrl) || '').replace(/\/+$/, '');
++ const abs = (p) => `${site}${p}`;
++
++ /**
++ * IndexNow (openvibe-shared/indexnow): tell the engines when a public, indexable page appears,
++ * changes or goes away. Called after the row moved, so beforeStatus/afterStatus are real. Only
++ * public releases (published, deprecated) count; a draft's own change is never public, and a
++ * revoked release that was never public stays silent. The sitemap and /updates change with them.
++ */
++ function pingChange(beforeStatus, afterStatus, rel) {
++ if (!indexnow || !indexnow.enabled) return;
++ const wasPublic = beforeStatus === 'published' || beforeStatus === 'deprecated';
++ const isPublic = afterStatus === 'published' || afterStatus === 'deprecated';
++ if (!wasPublic && !isPublic) return;
++ const paths = [abs(`/apps/${rel.app_id}`), abs('/updates'), abs('/sitemap.xml')];
++ // The release page itself is indexable only while published; ping it when it became or
++ // stopped being published, so an engine picks it up or drops it.
++ if (beforeStatus === 'published' || afterStatus === 'published') paths.unshift(abs(`/releases/${rel.id}`));
++ indexnow.pingSoon(paths);
++ }
++
++ async function view(r) {
++ if (!r) return null;
++ return {
++ id: r.id, app_id: r.app_id, project_id: r.project_id, environment: r.environment, kind: r.kind,
++ subject_id: r.subject_id, name: r.name, version: r.version, status: r.status, manifest_id: r.manifest_id,
++ compatibility: JSON.parse(r.compatibility || '{}'), notes: r.notes,
++ created_at: r.created_at, created_by: r.created_by,
++ published_at: r.published_at || null, deprecated_at: r.deprecated_at || null, deprecation_reason: r.deprecation_reason || null,
++ replacement: r.replacement || null, revoked_at: r.revoked_at || null, revocation_reason: r.revocation_reason || null,
++ trust: await trust.get(r.app_id),
++ };
++ }
++
++ const get = async (id) => await view(await db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id)));
++ const manifestOf = async (id) => {
++ const m = await db.prepare('SELECT * FROM manifests WHERE id = ?').get(String(id));
++ return m ? { id: m.id, kind: m.kind, body: JSON.parse(m.body), contracts_version: m.contracts_version, created_at: m.created_at } : null;
++ };
++ const listForApp = async (appId, { includeDrafts = false } = {}) => (await Promise.all((await db.prepare(
++ `SELECT * FROM releases WHERE app_id = ? ${includeDrafts ? '' : "AND status != 'draft'"} ORDER BY created_at DESC, id DESC`,
++ ).all(String(appId))).map(view)));
++ const recentPublic = async (limit = 20) => (await Promise.all((await db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit)).map(view)));
++ // Light rows for the sitemap (http/discovery.js): no trust lookup per row, no N+1. Only the two
++ // non-draft statuses the public pages render; drafts, revoked and private releases never appear.
++ const publicIndex = async (limit = 5000) => await db.prepare("SELECT id, app_id, status, published_at FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit);
++ const log = async (releaseId) => (await db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId))
++ .map((l) => ({ ...l, detail: JSON.parse(l.detail || '{}') }));
++
++ async function writeLog(releaseId, action, actor, detail = {}) {
++ await db.prepare('INSERT INTO release_log (release_id, action, actor, at, detail) VALUES (?, ?, ?, ?, ?)')
++ .run(releaseId, action, actor, store.iso(), JSON.stringify(detail));
++ }
++
++ /**
++ * actor: { label: 'user:usr_…' | 'app:app_…', subject, kind: 'user' | 'app', role?, staff? }
++ * role is the person's project role as Network reported it; apps act on themselves only.
++ */
++ function assertCanManage(actor, app, { revoke = false } = {}) {
++ if (actor.kind === 'app') {
++ if (actor.subject !== app.id) fail(403, 'release.forbidden', 'an app manages only its own releases');
++ return;
++ }
++ if (revoke && actor.staff) return;
++ const need = manageRole(app.environment);
++ if (!actor.role || RANK[actor.role] < RANK[need]) fail(403, 'release.forbidden', `managing ${app.environment} releases needs the ${need} role in the project (Network reports ${actor.role || 'no role'})`);
++ }
++
++ async function event(type, rel, actor, extra = {}) {
++ const [atype, aid] = actor.label.split(':');
++ return await outbox.emit({
++ event_type: type,
++ actor: { type: atype === 'app' ? 'app' : 'user', id: aid },
++ subject: { type: 'app', id: rel.app_id },
++ visibility: 'public',
++ payload: {
++ project_id: rel.project_id, release_id: rel.id, kind: rel.kind, subject_id: rel.subject_id, version: rel.version,
++ environment: rel.environment, trust_tier: (await trust.get(rel.app_id)).tier, ...extra,
++ },
++ }, { traceparent: actor.traceparent });
++ }
++
++ /**
++ * Create a draft from a manifest. app: { id, project_id, environment, grants, revoked_at } as
++ * Network reported it (or, for an app token, as its claims say).
++ */
++ async function createDraft({ actor, app, kind, manifest, notes = '', eventTypes }) {
++ if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');
++ assertCanManage(actor, app);
++ const result = validateManifest(kind, manifest, { app, viewerSubject: actor.kind === 'user' ? actor.subject : null, eventTypes });
++ if (!result.valid) fail(422, 'manifest.invalid', 'the manifest does not validate', { validation: result });
++ const subjectId = manifest.id;
++ const exists = await db.prepare('SELECT id FROM releases WHERE app_id = ? AND kind = ? AND subject_id = ? AND version = ?').get(app.id, kind, subjectId, manifest.version);
++ if (exists) fail(409, 'release.exists', `version ${manifest.version} of ${subjectId} already has a release (${exists.id}); versions are immutable`);
++ const cleanNotes = String(notes || '').slice(0, 2000);
++ const manifestId = store.newId('mfs');
++ const releaseId = store.newId('rel');
++ const t = store.iso();
++ await db.tx(async () => {
++ await db.prepare(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(manifestId, kind, app.id, app.project_id, subjectId, manifest.version, JSON.stringify(manifest), contractsVersion, actor.label, t);
++ await db.prepare(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)`)
++ .run(releaseId, app.id, app.project_id, app.environment, kind, subjectId, String(manifest.name).slice(0, 80), manifest.version, manifestId,
++ JSON.stringify(manifest.compatibility || {}), cleanNotes, actor.label, t);
++ await writeLog(releaseId, 'created', actor.label, { version: manifest.version, kind, warnings: result.warnings.length });
++ });
++ return { release: await get(releaseId), validation: result };
++ }
++
++ async function load(id) {
++ const r = await db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id));
++ if (!r) fail(404, 'release.not_found', 'no such release');
++ return r;
++ }
++
++ async function publish({ actor, releaseId, app }) {
++ const r = await load(releaseId);
++ if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
++ if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');
++ assertCanManage(actor, { ...app, environment: r.environment });
++ if (r.status !== 'draft') fail(409, 'release.not_draft', `release is ${r.status}`);
++ let env;
++ await db.tx(async () => {
++ await db.prepare("UPDATE releases SET status = 'published', published_by = ?, published_at = ? WHERE id = ? AND status = 'draft'").run(actor.label, store.iso(), r.id);
++ await writeLog(r.id, 'published', actor.label);
++ env = await event('services.app.published', r, actor, { compatibility: JSON.parse(r.compatibility || '{}') });
++ });
++ pingChange(r.status, 'published', r);
++ return { release: await get(r.id), event_id: env.event_id };
++ }
++
++ async function deprecate({ actor, releaseId, app, reason, replacement }) {
++ const r = await load(releaseId);
++ if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
++ assertCanManage(actor, { ...app, environment: r.environment });
++ if (r.status !== 'published') fail(409, 'release.not_published', `release is ${r.status}`);
++ const why = String(reason || '').trim().slice(0, 500);
++ if (!why) fail(422, 'release.invalid', 'say why the release is deprecated');
++ let repl = null;
++ if (replacement) {
++ const rr = await db.prepare('SELECT id, app_id, status FROM releases WHERE id = ?').get(String(replacement));
++ if (!rr || rr.app_id !== r.app_id || rr.status !== 'published') fail(422, 'release.invalid', 'the replacement must be a published release of the same app');
++ repl = rr.id;
++ }
++ let env;
++ await db.tx(async () => {
++ await db.prepare("UPDATE releases SET status = 'deprecated', deprecated_by = ?, deprecated_at = ?, deprecation_reason = ?, replacement = ? WHERE id = ?")
++ .run(actor.label, store.iso(), why, repl, r.id);
++ await writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });
++ env = await event('services.app.deprecated', r, actor, { reason: why, replacement: repl });
++ });
++ pingChange(r.status, 'deprecated', r);
++ return { release: await get(r.id), event_id: env.event_id };
++ }
++
++ async function revoke({ actor, releaseId, app, reason }) {
++ const r = await load(releaseId);
++ if (app && app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
++ assertCanManage(actor, { ...(app || { id: r.app_id }), environment: r.environment }, { revoke: true });
++ if (r.status === 'revoked') fail(409, 'release.revoked', 'release is already revoked');
++ const why = String(reason || '').trim().slice(0, 500);
++ if (!why) fail(422, 'release.invalid', 'say why the release is revoked');
++ const wasPublic = r.status === 'published' || r.status === 'deprecated';
++ // Staff revoking a release their own project role would not let them manage is moderation
++ // (ADR-022): it goes to Network's audit log too. A member revoking their own release is not.
++ const byStaff = actor.kind === 'user' && actor.staff && !(actor.role && RANK[actor.role] >= RANK[manageRole(r.environment)]);
++ let env = null;
++ await db.tx(async () => {
++ await db.prepare("UPDATE releases SET status = 'revoked', revoked_by = ?, revoked_at = ?, revocation_reason = ? WHERE id = ?").run(actor.label, store.iso(), why, r.id);
++ await writeLog(r.id, 'revoked', actor.label, { reason: why, was: r.status });
++ // A draft was never public: nobody downstream knows it, so there is nothing to announce.
++ if (wasPublic) env = await event('services.app.revoked', r, actor, { reason: why });
++ if (byStaff) {
++ const [ckind, cid] = String(r.created_by).split(':');
++ await outbox.moderationAction({
++ action: 'release.revoked', target: { type: 'release', id: r.id, owner_subject: ckind === 'user' ? cid : null },
++ actorSubject: actor.subject, reason: why, details: { app_id: r.app_id, project_id: r.project_id, version: r.version, was: r.status },
++ }, { traceparent: actor.traceparent });
++ }
++ });
++ if (wasPublic) pingChange(r.status, 'revoked', r);
++ return { release: await get(r.id), event_id: env ? env.event_id : null };
++ }
++
++ /** Every release of a project (drafts included), each with its manifest and log: for the project export. */
++ const listForProject = async (projectId) => (await Promise.all((await db.prepare('SELECT * FROM releases WHERE project_id = ? ORDER BY created_at, id').all(String(projectId)))
++ .map(async (r) => ({ ...await view(r), manifest: await manifestOf(r.manifest_id), log: await log(r.id) }))));
++
++ /**
++ * Services' part of deleting a project (Network archives the project itself). Drafts were never
++ * public, so they and their manifests are deleted (the append-only log records it). Published
++ * and deprecated releases are revoked, not erased: people who installed them need the
++ * revocation (compatibility policy), so each emits services.app.revoked as any revocation does.
++ * The caller has checked that the actor owns the project (or is staff).
++ */
++ async function retireProject({ actor, projectId, reason = actor.staff && actor.role !== 'owner' ? 'project deleted by OpenVibe staff' : 'project deleted by its owner' }) {
++ const rows = await db.prepare("SELECT * FROM releases WHERE project_id = ? AND status != 'revoked' ORDER BY created_at, id").all(String(projectId));
++ const out = { revoked: [], deletedDrafts: [] };
++ for (const r of rows.filter((x) => x.status !== 'draft')) {
++ await revoke({ actor, releaseId: r.id, app: null, reason });
++ out.revoked.push(r.id);
++ }
++ await db.tx(async () => {
++ for (const r of rows.filter((x) => x.status === 'draft')) {
++ await db.prepare("DELETE FROM releases WHERE id = ? AND status = 'draft'").run(r.id);
++ const shared = (await db.prepare('SELECT COUNT(*) AS n FROM releases WHERE manifest_id = ?').get(r.manifest_id)).n;
++ if (!shared) await db.prepare('DELETE FROM manifests WHERE id = ?').run(r.manifest_id);
++ await writeLog(r.id, 'deleted', actor.label, { reason, was: 'draft' });
++ out.deletedDrafts.push(r.id);
++ }
++ });
++ return out;
++ }
++
++ return { get, manifestOf, listForApp, listForProject, recentPublic, publicIndex, log, createDraft, publish, deprecate, revoke, retireProject, manageRole };
++}
++
++module.exports = { createReleases, ReleaseError, manageRole, RANK };
ddiff --git a/server/domain/trust.js b/server/domain/trust.js
nnew file mode 100644
iindex 0000000..6ac8d0d
---- /dev/null
++++ b/server/domain/trust.js
@@@ -0,0 +1,54 @@
++'use strict';
++
++/**
++ * Trust tiers (ADR-013, binding): unreviewed, reviewed, first-party.
++ *
++ * METADATA ONLY. "A tier changes defaults and discovery, but never the grant check." Nothing in
++ * Services (or anywhere) reads a tier to allow an action; the tier is shown next to releases and
++ * carried in services.app.* event payloads. Every app starts unreviewed; Services staff change it, with
++ * a note, and every change is kept and reported to Network's moderation audit log
++ * (services.moderation.action app.trust_changed, ADR-022). Older names are migrated at boot (server/db.js).
++ */
++const TIERS = ['unreviewed', 'reviewed', 'first-party'];
++const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createTrust({ store, outbox = null }) {
++ const { db } = store;
++
++ async function get(appId) {
++ const r = await db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));
++ return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'unreviewed', note: '', set_by: null, set_at: null };
++ }
++
++ async function set({ appId, tier, note, actor }) {
++ if (!actor || !actor.staff) throw Object.assign(new Error('only Services staff set trust tiers'), { status: 403, code: 'trust.staff_only' });
++ if (!APP_ID_RE.test(String(appId))) throw Object.assign(new Error('not an app id'), { status: 422, code: 'trust.invalid' });
++ if (!TIERS.includes(tier)) throw Object.assign(new Error(`tier is one of ${TIERS.join(', ')}`), { status: 422, code: 'trust.invalid' });
++ const clean = String(note || '').trim().slice(0, 500);
++ if (!clean) throw Object.assign(new Error('say why (the note is public)'), { status: 422, code: 'trust.invalid' });
++ const before = (await get(appId)).tier;
++ const t = store.iso();
++ await db.tx(async () => {
++ await db.prepare(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT(app_id) DO UPDATE SET tier = excluded.tier, note = excluded.note, set_by = excluded.set_by, set_at = excluded.set_at`)
++ .run(appId, tier, clean, actor.label, t);
++ await db.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(appId, before, tier, clean, actor.label, t);
++ if (outbox) {
++ const [kind, id] = String(actor.label).split(':');
++ await outbox.moderationAction({
++ action: 'app.trust_changed', target: { type: 'app', id: appId }, actorSubject: kind === 'user' ? id : null,
++ reason: clean, details: { from: before, to: tier },
++ }, { traceparent: actor.traceparent });
++ }
++ });
++ return await get(appId);
++ }
++
++ const history = async (appId) => await db.prepare('SELECT from_tier, to_tier, note, set_by, set_at FROM trust_history WHERE app_id = ? ORDER BY id DESC').all(String(appId));
++ const listSet = async () => await db.prepare('SELECT * FROM trust ORDER BY set_at DESC LIMIT 200').all();
++
++ return { get, set, history, listSet, TIERS };
++}
++
++module.exports = { createTrust, TIERS };
ddiff --git a/server/domain/webhooks.js b/server/domain/webhooks.js
nnew file mode 100644
iindex 0000000..84f3f90
---- /dev/null
++++ b/server/domain/webhooks.js
@@@ -0,0 +1,151 @@
++'use strict';
++
++/**
++ * Webhook tester and signed-event inspector.
++ *
++ * OpenVibe.Events delivers `{ "event": <events.event-envelope@1>, "seq": <n> }` signed under the
++ * subscription secret:
++ * X-OpenVibe-Timestamp: <unix seconds this attempt was sent>
++ * X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
++ * Every consumer requires v2 and refuses it more than 300 s from its own clock (replay window).
++ * The v1 header (X-OpenVibe-Signature, an HMAC of the body alone) was retired on 2026-09-28.
++ * Verification here uses openvibe-sdk's own signDeliveryV2/verifyDeliveryV2 (constant-time
++ * compare), so the tester agrees with what a receiver built on the SDK does. The secret a
++ * developer types is used for one computation and dropped: never stored, logged or rendered back.
++ */
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const { signDeliveryV2, verifyDeliveryV2, signDeliveryHeaders } = require('openvibe-sdk/events');
++
++const MAX_BODY = 256 * 1024;
++/** The replay window every Events consumer applies to v2 (openvibe-sdk's default). */
++const V2_WINDOW_SEC = 300;
++/** Wide enough to check the v2 HMAC alone, so a stale-but-correct signature is told apart from a wrong one. */
++const ANY_TIME = Number.MAX_SAFE_INTEGER;
++
++/** t and the v2 values from `t=<ts>,v2=<hex>[,v2=<hex>]`, parsed as the SDK does; null when malformed. */
++function parseV2Header(header) {
++ let t = null;
++ const values = [];
++ for (const part of header.split(',')) {
++ const i = part.indexOf('=');
++ if (i < 0) return null;
++ const k = part.slice(0, i).trim();
++ const v = part.slice(i + 1).trim();
++ if (k === 't') {
++ if (t !== null || !/^\d{1,12}$/.test(v)) return null;
++ t = Number(v);
++ } else if (k === 'v2') values.push(v);
++ }
++ return t === null || !values.length ? null : { t, values };
++}
++
++function checkV2(body, given, stated, secret, now) {
++ const out = { present: !!given, valid: false, signatureValid: false, reason: null, expected: null, given, timestamp: null, statedTimestamp: stated || null, ageSec: null, windowSec: V2_WINDOW_SEC, withinWindow: null, timestampsAgree: null };
++ if (!given) {
++ out.reason = 'no X-OpenVibe-Signature-V2 value given. Events sends it on every delivery, and a receiver that requires v2 refuses a delivery without it';
++ return out;
++ }
++ const parsed = parseV2Header(given);
++ if (!parsed) {
++ out.reason = 'the header value must look like t=<unix seconds>,v2=<64 lowercase hex characters>';
++ return out;
++ }
++ out.timestamp = parsed.t;
++ out.expected = signDeliveryV2(body, secret, parsed.t);
++ out.ageSec = Math.floor(now / 1000) - parsed.t;
++ out.withinWindow = Math.abs(now / 1000 - parsed.t) <= V2_WINDOW_SEC;
++ out.timestampsAgree = stated ? stated === String(parsed.t) : null;
++ // The HMAC alone (any time, X-OpenVibe-Timestamp left out), then the check a receiver makes.
++ out.signatureValid = verifyDeliveryV2(body, { 'x-openvibe-signature-v2': given }, secret, { toleranceSec: ANY_TIME, now });
++ const headers = { 'x-openvibe-signature-v2': given };
++ if (stated) headers['x-openvibe-timestamp'] = stated;
++ out.valid = verifyDeliveryV2(body, headers, secret, { toleranceSec: V2_WINDOW_SEC, now });
++ if (!out.valid) {
++ if (!out.signatureValid) {
++ out.reason = parsed.values.every((v) => v.length !== 64)
++ ? 'wrong length: v2= followed by 64 lowercase hex characters'
++ : 'the v2 signature does not match: it is the HMAC of "<t>.<raw body>" (the timestamp, a dot, then the body byte for byte)';
++ } else if (out.timestampsAgree === false) {
++ out.reason = `X-OpenVibe-Timestamp (${stated}) is not the t= in X-OpenVibe-Signature-V2 (${parsed.t})`;
++ } else {
++ out.reason = `the signature matches, but t is ${Math.abs(out.ageSec)} s ${out.ageSec >= 0 ? 'in the past' : 'in the future'}, outside the ±${V2_WINDOW_SEC} s window: a receiver refuses it now. Events signs every attempt afresh, so only a replayed or long-held delivery is this old (or a clock is wrong: keep NTP on)`;
++ }
++ }
++ return out;
++}
++
++/**
++ * → { accepted, v2, bodyBytes, parsed } or { accepted: false, reason } for unusable input.
++ * accepted is what a receiver requiring v2 decides (parseDelivery(..., { requireV2: true })):
++ * v2 must verify and be within the window.
++ * The check carries `expected`, the signature the secret produces for this body (showing it
++ * does not reveal the secret, and it is what a developer needs to see why a comparison failed).
++ */
++function inspect({ rawBody, signatureV2, timestamp, secret, now = Date.now() }) {
++ const body = typeof rawBody === 'string' ? rawBody : '';
++ const str = (v) => (typeof v === 'string' ? v.trim() : '');
++ if (!body) return { accepted: false, reason: 'paste the raw request body exactly as received (byte for byte)' };
++ if (Buffer.byteLength(body) > MAX_BODY) return { accepted: false, reason: 'body is larger than 256 KB' };
++ if (!secret) return { accepted: false, reason: 'enter the subscription secret' };
++ const v2 = checkV2(body, str(signatureV2), str(timestamp), secret, now);
++ let parsed = null;
++ try {
++ const obj = JSON.parse(body);
++ if (obj && typeof obj === 'object' && obj.event) {
++ const v = contracts.validate('events.event-envelope@1', obj.event);
++ parsed = { event_type: obj.event.event_type || null, event_id: obj.event.event_id || null, seq: obj.seq ?? null, envelopeValid: v.valid, envelopeErrors: v.errors };
++ } else {
++ parsed = { note: 'JSON, but not an Events delivery ({ event, seq })' };
++ }
++ } catch { parsed = { note: 'not JSON' }; }
++ return { accepted: v2.valid, v2, bodyBytes: Buffer.byteLength(body), parsed };
++}
++
++/**
++ * A sample delivery for an event type from the contracts catalog, signed with the given secret
++ * (v2 only, as Events sends it: openvibe-sdk's signDeliveryHeaders).
++ * The envelope is valid events.event-envelope@1. Contracts define no per-event payload schemas yet,
++ * so the payload is empty and the page says so rather than inventing fields.
++ */
++function sample({ eventType, secret, producer, now = Date.now() }) {
++ const source = producer || String(eventType).split('.')[0];
++ const envelope = {
++ event_id: contracts.ids.newId('event', now),
++ event_type: eventType,
++ version: 1,
++ source,
++ actor: { type: 'service', id: source },
++ timestamp: new Date(now).toISOString(),
++ visibility: 'internal',
++ subject: { type: 'example', id: 'example-1' },
++ payload: {},
++ trace_id: crypto.randomBytes(16).toString('hex'),
++ };
++ const check = contracts.validate('events.event-envelope@1', envelope);
++ const seq = 1;
++ const body = JSON.stringify({ event: envelope, seq });
++ const subscriptionId = `sub_${contracts.ids.ulid(now)}`;
++ const headers = {
++ 'Content-Type': 'application/json',
++ 'User-Agent': 'OpenVibe.Events/0.1',
++ 'X-OpenVibe-Event-Id': envelope.event_id,
++ 'X-OpenVibe-Event-Type': eventType,
++ 'X-OpenVibe-Seq': String(seq),
++ 'X-OpenVibe-Subscription-Id': subscriptionId,
++ 'X-OpenVibe-Delivery-Attempt': '1',
++ 'X-OpenVibe-Hops': '0',
++ ...signDeliveryHeaders(body, secret, { now }),
++ traceparent: `00-${envelope.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
++ };
++ return { body, headers, envelopeValid: check.valid, envelopeErrors: check.errors };
++}
++
++/** A curl command that replays the sample against the developer's OWN endpoint (Services never sends it). */
++function curlFor(headers, body, endpoint = 'https://your-app.example/webhooks/openvibe') {
++ const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
++ const hs = Object.entries(headers).map(([k, v]) => ` -H ${q(`${k}: ${v}`)}`).join(' \\\n');
++ return `curl -X POST ${q(endpoint)} \\\n${hs} \\\n --data-binary ${q(body)}`;
++}
++
++module.exports = { inspect, sample, curlFor, parseV2Header, MAX_BODY, V2_WINDOW_SEC };
ddiff --git a/server/domain/zip.js b/server/domain/zip.js
nnew file mode 100644
iindex 0000000..e01f038
---- /dev/null
++++ b/server/domain/zip.js
@@@ -0,0 +1,95 @@
++'use strict';
++
++/**
++ * A minimal zip writer (PKZIP 2.0: deflate, UTF-8 names, no zip64) for the project archive. Every
++ * archive Services builds is bounded well below zip's 4 GiB and 65,535-entry limits; past them it
++ * throws rather than write a file other tools would misread.
++ */
++const zlib = require('zlib');
++
++const MAX_BYTES = 0xffffffff;
++const MAX_ENTRIES = 0xffff;
++
++let table = null;
++function crc32(buf) {
++ if (typeof zlib.crc32 === 'function') return zlib.crc32(buf) >>> 0;
++ if (!table) {
++ table = new Uint32Array(256);
++ for (let n = 0; n < 256; n++) {
++ let c = n;
++ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
++ table[n] = c >>> 0;
++ }
++ }
++ let c = 0xffffffff;
++ for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
++ return (c ^ 0xffffffff) >>> 0;
++}
++
++/** MS-DOS time and date (UTC; zip has no time zone). */
++function dosTime(d) {
++ return {
++ time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | Math.floor(d.getUTCSeconds() / 2),
++ date: ((Math.max(1980, d.getUTCFullYear()) - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(),
++ };
++}
++
++/**
++ * entries: [{ name, data: Buffer | string }] in the order they are written. Returns one Buffer.
++ * Small or incompressible entries are stored; the rest are deflated.
++ */
++function zip(entries, { date = new Date() } = {}) {
++ if (entries.length > MAX_ENTRIES) throw new Error(`zip: ${entries.length} entries is past the zip limit`);
++ const { time, date: day } = dosTime(date);
++ const locals = [];
++ const central = [];
++ let offset = 0;
++ for (const e of entries) {
++ const name = Buffer.from(String(e.name), 'utf8');
++ const data = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data), 'utf8');
++ const deflated = data.length > 64 ? zlib.deflateRawSync(data, { level: 6 }) : null;
++ const method = deflated && deflated.length < data.length ? 8 : 0;
++ const body = method === 8 ? deflated : data;
++ const crc = crc32(data);
++ if (data.length > MAX_BYTES || offset + 30 + name.length + body.length > MAX_BYTES) throw new Error('zip: past the 4 GiB limit');
++ const head = Buffer.alloc(30);
++ head.writeUInt32LE(0x04034b50, 0);
++ head.writeUInt16LE(20, 4);
++ head.writeUInt16LE(0x0800, 6); // names are UTF-8
++ head.writeUInt16LE(method, 8);
++ head.writeUInt16LE(time, 10);
++ head.writeUInt16LE(day, 12);
++ head.writeUInt32LE(crc, 14);
++ head.writeUInt32LE(body.length, 18);
++ head.writeUInt32LE(data.length, 22);
++ head.writeUInt16LE(name.length, 26);
++ head.writeUInt16LE(0, 28);
++ locals.push(head, name, body);
++ const dir = Buffer.alloc(46);
++ dir.writeUInt32LE(0x02014b50, 0);
++ dir.writeUInt16LE(20, 4);
++ dir.writeUInt16LE(20, 6);
++ dir.writeUInt16LE(0x0800, 8);
++ dir.writeUInt16LE(method, 10);
++ dir.writeUInt16LE(time, 12);
++ dir.writeUInt16LE(day, 14);
++ dir.writeUInt32LE(crc, 16);
++ dir.writeUInt32LE(body.length, 20);
++ dir.writeUInt32LE(data.length, 24);
++ dir.writeUInt16LE(name.length, 28);
++ dir.writeUInt32LE(offset, 42);
++ central.push(dir, name);
++ offset += head.length + name.length + body.length;
++ }
++ const dirBytes = central.reduce((n, b) => n + b.length, 0);
++ if (offset + dirBytes + 22 > MAX_BYTES) throw new Error('zip: past the 4 GiB limit');
++ const end = Buffer.alloc(22);
++ end.writeUInt32LE(0x06054b50, 0);
++ end.writeUInt16LE(entries.length, 8);
++ end.writeUInt16LE(entries.length, 10);
++ end.writeUInt32LE(dirBytes, 12);
++ end.writeUInt32LE(offset, 16);
++ return Buffer.concat([...locals, ...central, end]);
++}
++
++module.exports = { zip, crc32 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 49aeded..0a6205d 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -3,18 +3,24 @@
/**
* Services → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
*
-- * Services declares no event type yet: ADR-048 puts its control operations and their audit in plan T13
-- * step 10, and no `services.*` event is registered in the pinned openvibe-contracts. The outbox is built
-- * anyway — the table (services_events_outbox), the relay and Services' own OAuth client — so the first
-- * registered type is one string in EVENT_TYPES; until then emit() refuses every envelope and the relay
-- * has nothing to send. A row would be written inside the transaction that makes the change, and the
-- * relay publishes with Services' service token (events.event.publish, audience openvibe.events).
++ * services.app.published a release became public subject { type: 'app', id: app_… }
++ * services.app.deprecated a public release was deprecated (payload: release id, version, kind,
++ * services.app.revoked a public release was revoked environment, trust tier, reason…)
++ * services.moderation.action Services staff acted on someone else's app: revoked a release they
++ * could not manage as a member, or set a trust tier (ADR-022, for
++ * Network's moderation audit log; subject { type: 'moderation_action' })
++ *
++ * emit() runs inside the PostgreSQL transaction that makes the change, so an event exists if and only
++ * if its change committed. The relay publishes with Services' OWN service token (events.event.publish,
++ * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise rows
++ * wait in services_events_outbox and /api/ready reports the relay as off. Payloads never carry a secret.
++ * The control operations of plan T13 step 10 add their audit types here.
*/
const { createServiceOutbox } = require('openvibe-sdk/events');
const TABLE = 'services_events_outbox'; // migrations/0001_initial.sql
const ACTOR = { type: 'service', id: 'services' };
--const EVENT_TYPES = [];
++const EVENT_TYPES = ['services.app.published', 'services.app.deprecated', 'services.app.revoked', 'services.moderation.action'];
function createServicesOutbox({ db, config, fetchImpl, now, log = console }) {
return createServiceOutbox({
@@@ -28,6 +34,7 @@ function createServicesOutbox({ db, config, fetchImpl, now, log = console }) {
intervalMs: config.events.intervalMs,
log,
eventTypes: EVENT_TYPES,
++ autoDiscover: false,
...(fetchImpl ? { fetch: fetchImpl } : {}),
...(now ? { now } : {}),
});
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..e0cc33b
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -0,0 +1,98 @@
++'use strict';
++
++/**
++ * Per-actor rate limits at Services' capability boundaries (roadmap WS-R task 4; openvibe-sdk/limits):
++ * /api/v1, the signed-in portal (/projects), release actions, the developer tools' forms and the
++ * staff trust form.
++ *
++ * The per-address limits in app.js and the routers (pages 300 a minute, portal writes 60, the tools and
++ * API writes 30 and 60, sign-in) and the playground's runs an hour per person stay. These count
++ * requests by who makes them:
++ *
++ * an app its principal, app:app_… (the token requireCapability verified on /api/v1)
++ * a person user:usr_… (the signed-in session sso.middleware verified)
++ * anyone else ip:<address>
++ *
++ * Services hosts no git remotes: there are no clone or push routes to leave out. Past a limit the route
++ * answers 429 problem+json `rate_limited` with Retry-After before it does any work (before the form or
++ * upload is read, before Network is asked); the refusal is logged once and counted in
++ * services_rate_limited_total{limit,window}. Reads take SERVICES_LIMITS_MINUTE / SERVICES_LIMITS_HOUR (120 and
++ * 3000); every write has its own number below. Counters live in this process: a restart forgets them.
++ *
++ * Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the public pages and
++ * docs (the per-address limit bounds them).
++ */
++const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');
++
++function actor(req) {
++ const p = req.principal;
++ if (p && !p.legacy && typeof p.sub === 'string' && p.sub) return p.sub;
++ const v = req.viewer;
++ if (v && v.kind === 'user' && v.subject) return `user:${v.subject}`;
++ return `ip:${req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'}`;
++}
++
++/** The writes and the expensive reads, each with its numbers per caller (a minute, an hour). */
++const BUDGETS = {
++ // A project is a Network record with its own members and environments: people start a few.
++ 'services.project.create': { minute: 5, hour: 30 },
++ // Members, roles, archive and delete: an owner changes a few at a time.
++ 'services.project.manage': { minute: 20, hour: 200 },
++ // The full archive reads every object and event a project holds (one at a time per project).
++ 'services.project.export': { minute: 3, hour: 20 },
++ // An app is a Network client with credentials: a developer registers a few.
++ 'services.app.create': { minute: 10, hour: 60 },
++ // Redirect URIs, grants and revoking an app: a developer saves a form now and then.
++ 'services.app.manage': { minute: 30, hour: 300 },
++ // Rotating or revoking a credential mints or kills a secret: a few at a time.
++ 'services.app.credential': { minute: 10, hour: 60 },
++ // A playground run calls Events or Media with the app's own credential: above the runs an hour
++ // per person (SERVICES_PLAYGROUND_RUNS_PER_HOUR, 60), which keeps deciding; refusals count.
++ 'services.playground.run': { minute: 10, hour: 120 },
++ // A release draft (a validated manifest; the editor's "validate only" counts too, so a developer
++ // fixing a manifest has room): 20 a minute, 200 an hour.
++ 'services.release.create': { minute: 20, hour: 200 },
++ // Publishing, deprecating and revoking a release announce it to the network.
++ 'services.release.manage': { minute: 20, hour: 200 },
++ // Validating a manifest or checking a webhook signature: a developer's pace, not a crawler's.
++ 'services.manifest.validate': { minute: 30, hour: 600 },
++ 'services.webhook.tool': { minute: 30, hour: 600 },
++ // Staff setting an app's trust tier.
++ 'services.trust.set': { minute: 30, hour: 300 },
++};
++
++/**
++ * limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on GET/HEAD) and
++ * limits.budget(name) (one of BUDGETS). enabled=false (tests only, as they raise the per-address
++ * limit) counts nobody.
++ */
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
++ const refused = registry
++ ? registry.counter({ name: 'services_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ const limiter = createActorLimiter({
++ limits: { minute: config.limits.minute, hour: config.limits.hour },
++ actor: enabled ? actor : () => null,
++ now,
++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
++ onLimited(e) {
++ // The actor is an app principal, a subject id or an address, never a token.
++ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++ limiter.reads = (name) => {
++ const limit = limiter(name);
++ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());
++ };
++ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));
++ limiter.budget = (name) => {
++ const m = budgets.get(name);
++ if (!m) throw new Error(`limits: no budget named ${name}`);
++ return m;
++ };
++ return limiter;
++}
++
++module.exports = { createActorLimits, actor, BUDGETS };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..13e3dc7
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,180 @@
++'use strict';
++
++/**
++ * JSON API (/api/v1). Errors are RFC 9457 problems (openvibe-contracts http.sendProblem).
++ *
++ * GET /docs/versions the pinned versions the docs were generated from
++ * POST /manifests/validate { kind: app|mod, manifest } → { valid, errors, warnings }
++ *
++ * services.release.read (public; no token needed — a token that IS presented must hold it):
++ * GET /apps/:app/releases public releases of an app (never drafts) + trust tier
++ * GET /apps/:app/trust the app's trust tier (metadata only, ADR-013)
++ * GET /releases/:id one public release with its manifest
++ *
++ * services.release.manage — an app managing its OWN releases with its own token:
++ * POST /apps/:app/releases { kind, manifest, notes, publish } → draft (or published)
++ * POST /releases/:id/publish | /deprecate { reason, replacement } | /revoke { reason }
++ *
++ * Guards are openvibe-contracts requireCapability() (audience openvibe.services, issuer, expiry, claim
++ * shape, the capability; sandbox app tokens accepted, release records carry the environment), then
++ * sub = app:<the app in the path>. No route accepts a shared loopback key of any kind.
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const rateLimit = require('express-rate-limit');
++const { http, serviceAuth } = require('openvibe-contracts');
++const manifests = require('../domain/manifests');
++const { ReleaseError } = require('../domain/releases');
++
++const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MANAGE = 'services.release.manage';
++const READ = 'services.release.read';
++
++/**
++ * The service-token capability guard this service's first-party routes use: the key the token names is
++ * looked up first (the SDK's JWKS client), then the synchronous openvibe-contracts requireCapability runs
++ * against it. Exported so the resource index (server/registry/resource-index.js, mounted by server/app.js)
++ * guards services.resource.read with the same check — never a second auth path.
++ *
++ * const access = createCapabilityAccess({ config, keys });
++ * app.use('/api/v1/resources', resourceIndex.router({ guard: access.checked('services.resource.read') }));
++ */
++function createCapabilityAccess({ config, keys }) {
++ const guardOpts = { issuer: config.network.issuer, audience: 'openvibe.services', acceptSandbox: true };
++ const bearerToken = (req) => String(req.headers.authorization || '').replace(/^Bearer\s+/, '');
++ const loadKey = (req, res, next) => { keys.pemForToken(bearerToken(req)).then((pem) => { req.ovNetworkKey = pem; next(); }, () => next()); };
++ const guardFor = (cap) => (req, res, next) => serviceAuth.requireCapability(cap, { ...guardOpts, getPublicKey: () => req.ovNetworkKey || null })(req, res, next);
++ const bearer = (req) => String(req.headers.authorization || '').startsWith('Bearer ');
++ /**
++ * The chain for a route a token is REQUIRED on: no token is 401 auth.required; a token that does not
++ * hold the capability is 403 capability.denied and an invalid one 401 token.* (requireCapability decides
++ * those two).
++ */
++ const checked = (cap, detail = 'send Authorization: Bearer <service token for audience openvibe.services>') => {
++ const guard = guardFor(cap); // built once, at mount: an unknown capability fails the boot, not a request
++ return [
++ loadKey,
++ (req, res, next) => (bearer(req) ? guard(req, res, next) : http.sendProblem(res, 401, 'auth.required', { detail, ctx: req.ov })),
++ ];
++ };
++ return { loadKey, guardFor, bearer, checked };
++}
++
++function createApi(ctx) {
++ const { config, docs, releases, trust, keys, actorLimits } = ctx;
++ const r = asyncRouter();
++ // Per-actor limits (http/actor-limits.js): reads take the defaults once the token (if any) is checked;
++ // writes name their budget after the guard and before the body is read.
++ const reads = actorLimits.reads('services.read');
++ const B = (name) => actorLimits.budget(name);
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ const json = express.json({ limit: '96kb' });
++ const limiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false });
++ const problem = (req, res, status, code, detail, extra) => http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
++
++ r.get('/docs/versions', reads, (req, res) => {
++ res.json({ contracts: docs.contractsVersion, contracts_tag: docs.contractsTag, sdk: docs.sdkVersion, sdk_tag: docs.sdkTag, generated_at: docs.generatedAt, contracts_count: docs.contracts.length, capabilities_count: docs.capabilities.length });
++ });
++
++ r.post('/manifests/validate', limiter, B('services.manifest.validate'), json, (req, res) => {
++ const b = req.body || {};
++ const kind = b.kind === 'mod' ? 'mod' : (b.kind === 'app' ? 'app' : null);
++ if (!kind) return problem(req, res, 422, 'manifest.kind', 'kind is app or mod');
++ const v = manifests.validate(kind, b.manifest, { eventTypes: docs.eventTypes });
++ res.status(v.valid ? 200 : 422).json({ ...v, contracts_version: docs.contractsVersion });
++ });
++
++ // Guards: the key the token names is looked up first (the SDK's JWKS client), then the synchronous contracts guard
++ // runs against it (createCapabilityAccess — the same check server/app.js guards the resource index with).
++ const { loadKey, guardFor, bearer, checked } = createCapabilityAccess({ config, keys });
++ const readGuard = guardFor('services.release.read');
++ // Public reads: anonymous callers pass; a presented token must be valid and hold services.release.read.
++ const readAccess = [loadKey, (req, res, next) => (bearer(req) ? readGuard(req, res, next) : next())];
++ // Managing releases needs a token: an app's own, holding services.release.manage.
++ const manageAccess = checked('services.release.manage', 'send Authorization: Bearer <app token for audience openvibe.services>');
++
++ r.get('/apps/:app/releases', ...readAccess, reads, async (req, res) => {
++ if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
++ res.set('Cache-Control', 'public, max-age=60');
++ res.json({ app_id: req.params.app, trust: await trust.get(req.params.app), releases: await releases.listForApp(req.params.app) });
++ });
++ r.get('/apps/:app/trust', ...readAccess, reads, async (req, res) => {
++ if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
++ res.set('Cache-Control', 'public, max-age=60');
++ res.json({ app_id: req.params.app, ...await trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });
++ });
++ r.get('/releases/:id', ...readAccess, reads, async (req, res) => {
++ const rel = REL_RE.test(req.params.id) ? await releases.get(req.params.id) : null;
++ if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');
++ res.set('Cache-Control', 'public, max-age=60');
++ const m = await releases.manifestOf(rel.manifest_id);
++ res.json({ release: rel, manifest: m ? m.body : null });
++ });
++
++ // ── App token: an app manages its own releases ──────────
++ /** After manageGuard: the verified token's app, project and environment. */
++ function appPrincipal(req, res, appId) {
++ const c = claimsOf(req);
++ if (!c || typeof c.sub !== 'string' || !c.sub.startsWith('app:')) { problem(req, res, 403, 'capability.denied', 'only app tokens manage releases here; people use the portal'); return null; }
++ const self = c.sub.slice(4);
++ if (appId && self !== appId) { problem(req, res, 403, 'release.forbidden', 'an app manages only its own releases'); return null; }
++ return {
++ actor: { kind: 'app', label: c.sub, subject: self, traceparent: req.ov.traceparent },
++ app: { id: self, project_id: typeof c.project_id === 'string' ? c.project_id : (Array.isArray(c.ns) ? c.ns[0] : null), environment: c.env === 'production' ? 'production' : 'sandbox', revoked_at: null },
++ };
++ }
++ // requireCapability already verified this token (signature, issuer, audience, expiry, claims);
++ // read the claims it does not copy onto req.principal (project_id, env).
++ function claimsOf(req) {
++ if (!req.principal || !req.principal.sub) return null;
++ try { return JSON.parse(Buffer.from(String(req.headers.authorization).slice(7).trim().split('.')[1], 'base64url').toString('utf8')); } catch { return null; }
++ }
++ const fail = (req, res, err) => {
++ if (err instanceof ReleaseError) return problem(req, res, err.status, err.code, err.detail, err.validation ? { validation: err.validation } : undefined);
++ ctx.log.error('[Services] api error:', err && err.message ? err.message.slice(0, 200) : err);
++ return problem(req, res, 500, 'internal.error', 'Internal error');
++ };
++
++ r.post('/apps/:app/releases', limiter, ...manageAccess, B('services.release.create'), json, async (req, res) => {
++ if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
++ const p = appPrincipal(req, res, req.params.app);
++ if (!p) return;
++ if (!p.app.project_id) return problem(req, res, 403, 'token.invalid_claims', 'the token names no project');
++ const b = req.body || {};
++ try {
++ const kind = b.kind === 'mod' ? 'mod' : 'app';
++ const out = await releases.createDraft({ actor: p.actor, app: p.app, kind, manifest: b.manifest, notes: b.notes, eventTypes: docs.eventTypes });
++ let release = out.release;
++ if (b.publish === true) release = (await releases.publish({ actor: p.actor, releaseId: release.id, app: p.app })).release;
++ res.status(201).json({ release, warnings: out.validation.warnings });
++ } catch (err) { fail(req, res, err); }
++ });
++
++ for (const action of ['publish', 'deprecate', 'revoke']) {
++ r.post(`/releases/:id/${action}`, limiter, ...manageAccess, B('services.release.manage'), json, async (req, res) => {
++ const rel = REL_RE.test(req.params.id) ? await releases.get(req.params.id) : null;
++ if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');
++ const p = appPrincipal(req, res, rel.app_id);
++ if (!p) return;
++ const b = req.body || {};
++ try {
++ const out = action === 'publish' ? await releases.publish({ actor: p.actor, releaseId: rel.id, app: p.app })
++ : action === 'deprecate' ? await releases.deprecate({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason, replacement: b.replacement })
++ : await releases.revoke({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason });
++ res.json(out);
++ } catch (err) { fail(req, res, err); }
++ });
++ }
++
++ // eslint-disable-next-line no-unused-vars
++ r.use((err, req, res, _next) => {
++ if (err && err.type === 'entity.parse.failed') return problem(req, res, 400, 'request.malformed_json', 'body is not valid JSON');
++ if (err && err.type === 'entity.too.large') return problem(req, res, 413, 'request.too_large', 'body too large');
++ ctx.log.error('[Services] api error:', err && err.message ? err.message.slice(0, 200) : err);
++ return problem(req, res, 500, 'internal.error', 'Internal error');
++ });
++ return r;
++}
++
++module.exports = { createApi, createCapabilityAccess, MANAGE, READ };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..873a67e
---- /dev/null
++++ b/server/http/discovery.js
@@@ -0,0 +1,281 @@
++'use strict';
++
++/**
++ * Crawl and machine-readability artifacts (plan T11): GET /robots.txt, GET /sitemap.xml,
++ * GET /llms.txt and GET /llms-full.txt, plus the home page's JSON-LD. Every one is built with
++ * openvibe-shared/seo — the same toolkit the other OpenVibe sites use — so they say the same things
++ * here as everywhere.
++ *
++ * Public pages only, and never the viewer: the portal, staff console, sign-in and the API are absent
++ * from all of them and Disallowed in robots.txt. lastmod is a real timestamp from the site's own
++ * data — the committed content-revision date in STATUS.json (and each published release's publish
++ * time for the pages a release changes) — never "today" from the clock, which would tell crawlers
++ * the whole site changed on every fetch.
++ */
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
++const { asyncRouter } = require('./router');
++
++/**
++ * The community documents (code of conduct, contributing, the contributor ladder, moderation) are OpenVibe.Codes':
++ * they govern the repositories and the apps and releases published here alike, and Codes renders them from its
++ * repository. The policy index links to them there; nothing here restates them.
++ */
++const COMMUNITY = [
++ { slug: 'code-of-conduct', title: 'Code of conduct', blurb: 'how we treat each other, and how to report a problem' },
++ { slug: 'contributing', title: 'Contributing', blurb: 'how to report a bug, propose a change and open a pull request' },
++ { slug: 'contributor-ladder', title: 'Contributor ladder', blurb: 'participant, contributor, reviewer, maintainer, owner' },
++ { slug: 'moderation', title: 'Moderation', blurb: 'what is moderated (apps and releases here included), the steps, and appeals' },
++];
++const CODES_ORIGIN = 'https://openvibe.codes';
++
++const SITE_NAME = 'OpenVibe.Services';
++const DESCRIPTION = 'The open developer platform of OpenVibe: one console for projects, apps, scoped credentials, capability grants, usage and releases across every OpenVibe service, with API, contract, SDK and event reference generated from the exact versions production runs.';
++// Every rule robots.txt had before this module existed: none of them may quietly disappear.
++const DISALLOW = ['/projects', '/auth/', '/oauth/test-callback', '/staff', '/api/'];
++const RELEASE_LIMIT = 5000;
++
++// The one line /llms-full.txt says about each fixed public page: a title and what the page serves.
++// These are the pages publicPages() lists (and so the sitemap), so the two cannot name different sets.
++const PAGE_TEXT = {
++ '/': ['OpenVibe.Services home', 'The developer platform: one console for projects, credentials, grants, usage and releases over every OpenVibe service, with generated reference docs.'],
++ '/updates': ['What shipped on OpenVibe.Services', 'This site\'s update log, from the network changelog feed.'],
++ '/docs': ['Platform reference', 'Reference generated at boot from the pinned openvibe-contracts and openvibe-sdk.'],
++ '/docs/api': ['API explorer', 'Every service\'s routes from the contracts\' OpenAPI 3.1 documents.'],
++ '/docs/updates': ['The update system', 'The feed, the data-ov-shipped markup and the shared helpers every OpenVibe site shows.'],
++ '/docs/contracts': ['Contracts', 'The JSON Schemas with their fields, examples and versions.'],
++ '/docs/capabilities': ['Capabilities', 'Every capability, which of them can be granted to apps, and its owner, visibility and status.'],
++ '/docs/events': ['Event types', 'Every event type a service manifest declares it produces or consumes.'],
++ '/docs/services': ['Service registry', 'The Network\'s registry as it answers, with health (never invented).'],
++ '/docs/billing': ['Billing policy', 'The billing policy as OpenVibe.Billing\'s /policy.json states it.'],
++ '/docs/limits': ['Limits and tiers', 'What a project may do in sandbox and production, from the services that enforce it.'],
++ '/docs/tools': ['Tools API', 'The OpenVibe.Tools registry, callable from code.'],
++ '/docs/export': ['Project export', 'What a project export holds: the metadata JSON and the full archive.'],
++ '/docs/sdk': ['SDK reference', 'openvibe-sdk modules, from their type definitions.'],
++ '/oauth': ['OAuth callback helper', 'Authorization code + PKCE, with a callback that never exchanges the code.'],
++ '/tools/webhooks': ['Webhook signature tester', 'Decides as a receiver requiring signature v2 does.'],
++ '/manifests/validate': ['Manifest validation', 'App and mod manifests, validated against the pinned contracts.'],
++ '/policy': ['Policy', 'The portal\'s policy pages: decisions, compatibility, licensing, transparency and community.'],
++ '/policy/rfc': ['Proposals and decisions', 'How a contract, capability or event changes, and the ADR record.'],
++ '/policy/compatibility': ['Compatibility and deprecation', 'ADR-002 and ADR-016 as published, with the current deprecations.'],
++ '/policy/licensing': ['Licensing', 'What each package Services runs is licensed under.'],
++ '/policy/transparency': ['Transparency', 'What Services stores, what it does not, and what works today.'],
++};
++
++/** "2026-09-28" or "2026-09-28T12:00:00Z" as YYYY-MM-DD; null when the value is unusable. */
++function dayOf(ts) {
++ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
++ return m ? m[1] : null;
++}
++
++/** The committed status file's content date: the site's own revision date, never the clock. */
++function siteUpdated() {
++ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
++}
++
++/**
++ * The home page's JSON-LD: the site as a WebSite, the developer platform as the site's primary type
++ * (the shared kit's WebApplication) and the page that carries them. The same nodes for every
++ * crawler; nothing about the reader.
++ */
++function homeJsonLd(config) {
++ const site = String(config.baseUrl).replace(/\/+$/, '');
++ return [
++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'open source developer platform, developer console, api platform, event bus api, ai gateway, oauth, webhooks, sdk, openvibe' }),
++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
++ ];
++}
++
++/**
++ * The site's fixed public pages, with their crawl hints. Docs and policy pages are generated from
++ * the pinned contracts and SDK, so their lastmod is the site's content-revision date.
++ */
++function publicPages({ docs }) {
++ const pages = [
++ { path: '/', changefreq: 'daily', priority: 1.0 },
++ { path: '/updates', changefreq: 'daily', priority: 0.5 },
++ { path: '/docs', changefreq: 'weekly', priority: 0.9 },
++ { path: '/docs/api', changefreq: 'weekly', priority: 0.8 },
++ { path: '/docs/updates', changefreq: 'daily', priority: 0.6 },
++ { path: '/docs/contracts', changefreq: 'weekly', priority: 0.7 },
++ { path: '/docs/capabilities', changefreq: 'weekly', priority: 0.7 },
++ { path: '/docs/events', changefreq: 'weekly', priority: 0.6 },
++ { path: '/docs/services', changefreq: 'weekly', priority: 0.6 },
++ { path: '/docs/billing', changefreq: 'monthly', priority: 0.5 },
++ { path: '/docs/limits', changefreq: 'monthly', priority: 0.5 },
++ { path: '/docs/tools', changefreq: 'monthly', priority: 0.5 },
++ { path: '/docs/export', changefreq: 'monthly', priority: 0.5 },
++ { path: '/docs/sdk', changefreq: 'weekly', priority: 0.7 },
++ { path: '/oauth', changefreq: 'monthly', priority: 0.5 },
++ { path: '/tools/webhooks', changefreq: 'monthly', priority: 0.5 },
++ { path: '/manifests/validate', changefreq: 'monthly', priority: 0.5 },
++ { path: '/policy', changefreq: 'monthly', priority: 0.5 },
++ { path: '/policy/rfc', changefreq: 'monthly', priority: 0.4 },
++ { path: '/policy/compatibility', changefreq: 'monthly', priority: 0.4 },
++ { path: '/policy/licensing', changefreq: 'monthly', priority: 0.4 },
++ { path: '/policy/transparency', changefreq: 'monthly', priority: 0.4 },
++ ];
++ for (const c of docs.contracts) pages.push({ path: `/docs/contracts/${c.id}`, changefreq: 'monthly', priority: 0.4 });
++ for (const c of docs.capabilities) pages.push({ path: `/docs/capabilities/${c.id}`, changefreq: 'monthly', priority: 0.4 });
++ for (const x of contracts.openapi.index()) pages.push({ path: `/docs/api/${x.service}`, changefreq: 'monthly', priority: 0.4 });
++ for (const m of docs.sdk) pages.push({ path: `/docs/sdk/${m.slug}`, changefreq: 'monthly', priority: 0.3 });
++ for (const a of docs.adrs) pages.push({ path: `/docs/adr/${a.id}`, changefreq: 'yearly', priority: 0.3 });
++ return pages;
++}
++
++/**
++ * The title and one-line text /llms-full.txt gives a public page: the fixed pages from PAGE_TEXT and
++ * the generated docs pages from the same data the page
++ * renders. Nothing here is fetched or invented; an unknown path (there is none today) falls back to
++ * the path as its title.
++ */
++function describePage(pathname, { docs, apiIndex = [] }) {
++ if (PAGE_TEXT[pathname]) return { title: PAGE_TEXT[pathname][0], text: PAGE_TEXT[pathname][1] };
++ let m;
++ if ((m = /^\/docs\/contracts\/([^/]+)$/.exec(pathname))) {
++ const c = docs.contracts.find((x) => x.id === m[1]);
++ if (c) return { title: c.title, text: seo.clip(c.description, 200) };
++ }
++ if ((m = /^\/docs\/capabilities\/([^/]+)$/.exec(pathname))) {
++ const c = docs.capabilities.find((x) => x.id === m[1]);
++ if (c) return { title: c.id, text: seo.clip(c.description, 200) };
++ }
++ if ((m = /^\/docs\/api\/([^/]+)$/.exec(pathname))) {
++ const s = apiIndex.find((x) => x.service === m[1]);
++ if (s) return { title: `${s.name} API`, text: `${s.operations} routes performing ${s.capabilities} capabilities.` };
++ }
++ if ((m = /^\/docs\/sdk\/([^/]+)$/.exec(pathname))) {
++ const mod = docs.sdk.find((x) => x.slug === m[1]);
++ if (mod) return { title: mod.name, text: `From ${mod.typesFile} (${mod.browser}).` };
++ }
++ if ((m = /^\/docs\/adr\/([^/]+)$/.exec(pathname))) {
++ const a = docs.adrs.find((x) => x.id === m[1]);
++ if (a) return { title: a.title, text: `Status: ${a.status}. Published in openvibe-contracts.` };
++ }
++ return { title: pathname, text: '' };
++}
++
++function createDiscoveryRoutes(ctx) {
++ const { config, docs, releases } = ctx;
++ const r = asyncRouter();
++ const site = String(config.baseUrl).replace(/\/+$/, '');
++ const abs = (p) => `${site}${p}`;
++
++ // The public, indexable releases: what the sitemap lists and what dates the pages a release
++ // changes. Never drafts, revoked or private ones (only 'published' pages are index: true).
++ async function releaseIndex() {
++ try { return await releases.publicIndex(RELEASE_LIMIT); } catch { return []; }
++ }
++
++ async function sitemapEntries() {
++ const content = siteUpdated();
++ const rows = await releaseIndex();
++ const entries = publicPages({ docs }).map((p) => ({ ...p, lastmod: content }));
++ const newest = rows.map((x) => dayOf(x.published_at)).filter(Boolean).sort().pop() || null;
++ // The home and shipped pages change when a release publishes, so they carry the newer of
++ // that real publish time and the site's content date.
++ for (const e of entries) if ((e.path === '/' || e.path === '/updates') && newest && (!e.lastmod || newest > e.lastmod)) e.lastmod = newest;
++ const byApp = new Map();
++ for (const x of rows) {
++ const d = dayOf(x.published_at);
++ if (d && (!byApp.has(x.app_id) || d > byApp.get(x.app_id))) byApp.set(x.app_id, d);
++ }
++ for (const [appId, lastmod] of byApp) entries.push({ path: `/apps/${appId}`, changefreq: 'weekly', priority: 0.4, lastmod });
++ for (const x of rows.filter((x) => x.status === 'published')) entries.push({ path: `/releases/${x.id}`, changefreq: 'monthly', priority: 0.3, lastmod: dayOf(x.published_at) });
++ return entries;
++ }
++
++ r.get('/robots.txt', (_req, res) => {
++ // The same rules as before, plus the search and AI crawlers the shared kit names by name and
++ // the sitemap. Every previous Disallow is kept (DISALLOW).
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(
++ '# openvibe.services: the public pages are for search and AI crawlers; the portal, sign-in, staff and the API are not.\n'
++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
++ });
++
++ r.get('/llms.txt', (_req, res) => {
++ const firstContract = docs.contracts[0] ? docs.contracts[0].id : null;
++ const firstService = (contracts.openapi.index()[0] || {}).service || null;
++ const machine = [
++ { title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages below, each with a real lastmod' },
++ { title: 'robots.txt', url: abs('/robots.txt'), note: 'search and AI crawlers are welcome on the public pages' },
++ { title: 'Full text for language models', url: abs('/llms-full.txt'), note: 'every fixed public page, one line each' },
++ { title: 'Release metadata (JSON)', url: abs('/release.json'), note: 'this service\'s current release, per ADR-016' },
++ ];
++ if (firstContract) machine.push({ title: 'Contract JSON Schema', url: abs(`/docs/contracts/${firstContract}.json`), note: 'append .json to any contract page for its schema' });
++ if (firstService) machine.push({ title: 'Service OpenAPI', url: abs(`/docs/api/${firstService}.json`), note: 'append .json to any API explorer page for its OpenAPI document' });
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
++ name: SITE_NAME,
++ summary: 'OpenVibe.Services: the open developer platform of OpenVibe — projects, apps, scoped credentials and capability grants over OpenVibe.Network\'s API, usage and project export, app and mod releases, one resource index over every service, reference documentation generated from the pinned contracts and SDK, and OAuth, webhook and manifest tools.',
++ details: 'Every page is server-rendered and readable without JavaScript, and every one is public: no account is needed to read the docs or use the OAuth, webhook and manifest tools. Projects, apps, credentials and grants belong to OpenVibe.Network; the pages that manage them need a signed-in OpenVibe account and are deliberately not listed here or in the sitemap. The reference under /docs is generated at boot from the exact openvibe-contracts and openvibe-sdk versions the platform runs, so it cannot drift from them.',
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'The developer platform', url: abs('/'), note: 'what the platform is for and how an account becomes an integration' },
++ { title: 'Reference documentation', url: abs('/docs'), note: 'generated from the pinned contracts and SDK' },
++ { title: 'What shipped on OpenVibe.Services', url: abs('/updates') },
++ { title: 'Transparency', url: abs('/policy/transparency'), note: 'what the portal stores, what it does not, and what works today' },
++ ] },
++ { title: 'Reference', links: [
++ { title: 'API explorer', url: abs('/docs/api'), note: 'each service\'s OpenAPI document, per page as .json' },
++ { title: 'Contracts', url: abs('/docs/contracts'), note: 'schemas, field tables and fixtures, per page as .json' },
++ { title: 'Capabilities', url: abs('/docs/capabilities') },
++ { title: 'Events', url: abs('/docs/events') },
++ { title: 'Services', url: abs('/docs/services') },
++ { title: 'Tools', url: abs('/docs/tools') },
++ { title: 'Coding harnesses', url: 'https://openvibe.codes/harnesses', note: 'the coding agents OpenVibe.Codes routes a task to (another site)' },
++ { title: 'SDK', url: abs('/docs/sdk') },
++ ] },
++ { title: 'Developer tools', links: [
++ { title: 'OAuth callback helper', url: abs('/oauth'), note: 'authorization code + PKCE, with a callback that never exchanges the code' },
++ { title: 'Webhook signature tester', url: abs('/tools/webhooks'), note: 'decides as a receiver requiring signature v2 does' },
++ { title: 'Manifest validation', url: abs('/manifests/validate'), note: 'app and mod manifests, validated against the pinned contracts' },
++ ] },
++ { title: 'Policy', links: [
++ { title: 'Policy home', url: abs('/policy') },
++ { title: 'Proposals and decisions', url: abs('/policy/rfc') },
++ { title: 'Compatibility and deprecation', url: abs('/policy/compatibility') },
++ { title: 'Licensing', url: abs('/policy/licensing') },
++ ...COMMUNITY.map((g) => ({ title: g.title, url: `${CODES_ORIGIN}/policy/${g.slug}`, note: 'on OpenVibe.Codes' })),
++ ] },
++ { title: 'Machine-readable', links: machine },
++ ],
++ }));
++ });
++
++ // /llms-full.txt: the same public pages the sitemap lists, one title and one line of text each,
++ // so a language model sees what this origin serves without fetching every page. Never a private
++ // page (the portal, sign-in, staff and the API are absent) and maxBytes caps the file.
++ r.get('/llms-full.txt', (_req, res) => {
++ const pages = publicPages({ docs });
++ const apiIndex = contracts.openapi.index();
++ const isDocs = (p) => p === '/docs' || p.startsWith('/docs/');
++ const tools = ['/oauth', '/tools/webhooks', '/manifests/validate'];
++ const group = (pick) => pages.filter((p) => pick(p.path)).map((p) => ({ url: p.path, ...describePage(p.path, { docs, apiIndex }) }));
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsFull({
++ site: SITE_NAME,
++ summary: 'Every fixed public page of OpenVibe.Services, one line each: the home and update log, the reference generated from the pinned contracts and SDK, the developer tools, and the policy pages.',
++ base: site,
++ maxBytes: 512 * 1024,
++ sections: [
++ { title: 'Home', pages: group((p) => p === '/') },
++ { title: 'Reference', pages: group(isDocs) },
++ { title: 'Playgrounds', pages: group((p) => tools.includes(p)) },
++ { title: 'Site', pages: group((p) => p !== '/' && !isDocs(p) && !tools.includes(p)) },
++ ],
++ }));
++ });
++
++ r.get('/sitemap.xml', async (_req, res) => {
++ const entries = await sitemapEntries();
++ const urls = entries.map((e) => ({ loc: abs(e.path), ...(e.lastmod ? { lastmod: e.lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
++ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
++ });
++
++ return r;
++}
++
++module.exports = { COMMUNITY, CODES_ORIGIN, createDiscoveryRoutes, homeJsonLd, publicPages, dayOf };
ddiff --git a/server/http/docs.js b/server/http/docs.js
nnew file mode 100644
iindex 0000000..a80047d
---- /dev/null
++++ b/server/http/docs.js
@@@ -0,0 +1,529 @@
++'use strict';
++
++/**
++ * Generated documentation pages. Every page states the package versions it was generated from and
++ * renders ONLY what the pinned packages (or, for the service registry, the Network) say.
++ *
++ * /docs index + versions
++ * /docs/contracts[/:id] catalog; one page per contract: field table, fixtures, raw schema
++ * /docs/contracts/:id.json the schema itself
++ * /docs/capabilities[/:id] capability catalog; grantable (public/partner, active) highlighted
++ * /docs/api[/:service] API explorer: every service's routes from openvibe-contracts' OpenAPI
++ * /docs/api/:service.json that OpenAPI 3.1 document (CORS *, for Swagger-style tools)
++ * /docs/updates the update system ("shipped" and /updates on every site): feed, markup,
++ * helpers, from the installed openvibe-shared and openvibe-sdk, with a live sample
++ * /docs/events event types from the service manifests
++ * /docs/services the Network's registry with health as reported (never invented)
++ * /docs/billing the billing policy as OpenVibe.Billing's /policy.json states it (never restated)
++ * /docs/limits limits and tiers: each service's /limits.json as it answers (never restated)
++ * /docs/export the project export: the metadata JSON and the full archive's layout
++ * /docs/sdk[/:module] SDK reference from its .d.ts files
++ * /docs/adr/:id ADRs as published in openvibe-contracts
++ */
++const { asyncRouter } = require('./router');
++const cache = require('openvibe-shared/cache-policy');
++const { html, raw, table, code, badge, time, problemBox } = require('../render/html');
++const { send } = require('../render/layout');
++const { markdown } = require('../render/markdown');
++const { createLimitsReader, amount } = require('../domain/limits');
++
++function createDocsRoutes(ctx) {
++ const { docs, config, network } = ctx;
++ const r = asyncRouter();
++ const openapi = require('openvibe-contracts').openapi;
++ const apiIndex = openapi.index();
++ const apiDocs = new Map(apiIndex.map((s) => [s.service, openapi.document(s.service)]));
++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
++
++ const tagNote = (tag, version) => (tag !== `v${version}` ? html` (tag ${tag})` : '');
++ const versions = () => html`<p class="versions">Generated at ${time(docs.generatedAt)} from
++<a href="https://github.com/OpenVibers/OpenVibe.Contracts/tree/${docs.contractsTag}"><code>openvibe-contracts v${docs.contractsVersion}</code></a>${tagNote(docs.contractsTag, docs.contractsVersion)} and
++<a href="https://github.com/OpenVibers/OpenVibe.SDK/tree/${docs.sdkTag}"><code>openvibe-sdk v${docs.sdkVersion}</code></a>${tagNote(docs.sdkTag, docs.sdkVersion)}.</p>`;
++ const page = (req, res, o, status = 200) => send(res, status, { index: true, cache: PUBLIC_CACHE, viewer: req.viewer, config, path: req.originalUrl, ...o });
++
++ r.get('/', (req, res) => {
++ const grantable = docs.capabilities.filter((c) => c.grantable).length;
++ page(req, res, {
++ title: 'Docs',
++ description: `OpenVibe platform reference generated from openvibe-contracts v${docs.contractsVersion} and openvibe-sdk v${docs.sdkVersion}.`,
++ crumbs: [{ label: 'Docs' }],
++ body: html`<h1>Platform reference</h1>${versions()}
++<p>Nothing on these pages is written by hand: each one is rendered from the published packages above when Services starts, so it always matches what the platform runs against. If something is missing here, it is not part of the public surface yet (<a href="/policy/compatibility">why</a>).</p>
++<ul class="cards">
++<li><a href="/docs/contracts"><strong>Contracts</strong></a><span>${docs.contracts.length} JSON Schemas with fields, examples and versions</span></li>
++<li><a href="/docs/api"><strong>API explorer</strong></a><span>${apiIndex.reduce((n, s) => n + s.operations, 0)} routes across ${apiIndex.length} services, with their capabilities and schemas (OpenAPI 3.1)</span></li>
++<li><a href="/docs/updates"><strong>Update system</strong></a><span>the "shipped" pill, recent list and /updates log every OpenVibe site shows, and how to add them to yours</span></li>
++<li><a href="/docs/capabilities"><strong>Capabilities</strong></a><span>${docs.capabilities.length} capabilities; ${grantable} can be granted to apps</span></li>
++<li><a href="/docs/events"><strong>Events</strong></a><span>${docs.events.length} event types services declare they produce</span></li>
++<li><a href="/docs/services"><strong>Services</strong></a><span>the registry as OpenVibe.Network reports it, with health</span></li>
++<li><a href="/docs/tools"><strong>Tools API</strong></a><span>every OpenVibe tool you can call from code, from the live registry</span></li>
++<li><a href="https://openvibe.codes/harnesses"><strong>Coding harnesses</strong></a><span>the coding agents OpenVibe.Codes routes a task to, with their capabilities, prices and limits</span></li>
++<li><a href="/docs/billing"><strong>Billing policy</strong></a><span>prices, the creator split, fees, holds and cashouts, live from OpenVibe.Billing</span></li>
++<li><a href="/docs/limits"><strong>Limits and tiers</strong></a><span>what a project may do in sandbox and production, live from the services that enforce it</span></li>
++<li><a href="/docs/export"><strong>Project export</strong></a><span>take a project with you: its configuration, modules, objects and events as one download</span></li>
++<li><a href="/docs/sdk"><strong>SDK</strong></a><span>${docs.sdk.length} modules of openvibe-sdk from their type definitions</span></li>
++<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
++</ul>
++<h2>Install</h2>
++<pre><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/${docs.sdkTag}
++npm install https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/${docs.contractsTag}</code></pre>
++<p>The SDK is ${docs.sdkLicense}; the contracts are ${docs.contractsLicense}. The SDK release is tested against contracts <code>${docs.sdkContractsRange || 'n/a'}</code>.</p>`,
++ });
++ });
++
++ // ── Contracts ───────────────────────────────────────────
++ r.get('/contracts', (req, res) => {
++ page(req, res, {
++ title: 'Contracts',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Contracts' }],
++ body: html`<h1>Contracts</h1>${versions()}
++<p>A contract id is permanent. Minor versions only add optional fields; a breaking change is a new major with a deprecation window (<a href="/policy/compatibility">policy</a>).</p>
++${table(['Contract', 'Version', 'Owner', 'Visibility', 'Status', 'Compatibility'], docs.contracts.map((c) => [
++ html`<a href="/docs/contracts/${c.id}">${c.id}</a>${c.deprecation ? html` ${badge('deprecated', 'warn')}` : ''}`,
++ c.version, c.owner, c.visibility, c.status, c.compatibility,
++ ]))}`,
++ });
++ });
++
++ r.get('/contracts/:id.json', (req, res, next) => {
++ const c = docs.contract(req.params.id);
++ if (!c) return next();
++ res.set('Cache-Control', PUBLIC_CACHE).type('application/schema+json').send(JSON.stringify(c.schema, null, 2));
++ });
++
++ r.get('/contracts/:id', (req, res, next) => {
++ const c = docs.contract(req.params.id);
++ if (!c) return next();
++ const major = c.version.split('.')[0];
++ const fixture = (f, ok) => html`<details${ok ? raw(' open') : ''}><summary>${ok ? 'Valid' : 'Rejected'}: ${f.name}</summary><pre><code>${JSON.stringify(f.body, null, 2)}</code></pre></details>`;
++ page(req, res, {
++ title: `${c.id}@${major}`,
++ description: c.description.slice(0, 200),
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Contracts', href: '/docs/contracts' }, { label: c.id }],
++ body: html`<h1>${c.title} <small><code>${c.id}@${major}</code></small></h1>${versions()}
++<dl class="facts"><dt>Version</dt><dd>${c.version}</dd><dt>Owner</dt><dd>${c.owner}</dd><dt>Visibility</dt><dd>${c.visibility}</dd><dt>Status</dt><dd>${c.status}</dd>
++<dt>Compatibility</dt><dd>${c.compatibility}</dd>${c.adr ? html`<dt>Decision</dt><dd><a href="/docs/adr/${c.adr}">${c.adr}</a></dd>` : ''}
++<dt>Schema</dt><dd><a href="/docs/contracts/${c.id}.json"><code>${c.$id}</code></a></dd></dl>
++${c.deprecation ? html`<div class="notice warn">Deprecated since ${c.deprecation.since}; use ${c.deprecation.replacement}. Removed after ${c.deprecation.removeAfter}. ${c.deprecation.reason || ''}</div>` : ''}
++<p>${c.description}</p>
++<h2>Fields</h2>
++${table(['Field', 'Type', 'Required', 'Description', 'Constraints'], c.fields.map((f) => [
++ code(f.path),
++ f.refId ? html`<a href="/docs/contracts/${f.refId}">${f.refId}</a>` : (f.ref ? code(f.ref) : f.type),
++ f.required ? 'yes' : '',
++ f.description,
++ f.constraints.length ? html`<ul class="plain">${f.constraints.map((x) => html`<li><code>${x}</code></li>`)}</ul>` : '',
++ ]), { empty: 'This schema has no named fields.' })}
++<h2>Examples</h2>
++<p class="muted">From the contract's own test fixtures: valid ones validate, rejected ones must fail.</p>
++${c.fixtures.valid.map((f) => fixture(f, true))}${c.fixtures.invalid.map((f) => fixture(f, false))}
++${!c.fixtures.valid.length && !c.fixtures.invalid.length ? html`<p class="muted">No fixtures ship with this contract.</p>` : ''}
++<h2>Validate</h2>
++<pre><code>const contracts = require('openvibe-contracts');
++contracts.validate('${c.id}@${major}', value); // { valid, errors: [{ path, message }] }</code></pre>`,
++ });
++ });
++
++ // ── Capabilities ────────────────────────────────────────
++ r.get('/capabilities', (req, res) => {
++ const owners = [...new Set(docs.capabilities.map((c) => c.owner))].sort();
++ const filter = typeof req.query.owner === 'string' && owners.includes(req.query.owner) ? req.query.owner : null;
++ const onlyGrantable = req.query.grantable === '1';
++ const list = docs.capabilities.filter((c) => (!filter || c.owner === filter) && (!onlyGrantable || c.grantable));
++ page(req, res, {
++ title: 'Capabilities',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Capabilities' }],
++ body: html`<h1>Capabilities</h1>${versions()}
++<p>A capability is an action a principal may invoke, checked at the owning service against the token's grants. <strong>Highlighted rows can be granted to apps</strong>: only active capabilities whose visibility is <code>public</code> (or <code>partner</code>, when staff put one in a project's allowance). <code>first-party</code> and <code>internal</code> capabilities are never granted to apps.</p>
++<form method="get" action="/docs/capabilities" class="inline-form"><label>Owner <select name="owner"><option value="">all</option>${owners.map((o) => html`<option value="${o}"${o === filter ? raw(' selected') : ''}>${o}</option>`)}</select></label>
++<label><input type="checkbox" name="grantable" value="1"${onlyGrantable ? raw(' checked') : ''}> grantable to apps only</label> <button type="submit">Filter</button></form>
++${table(['Capability', 'Owner', 'Visibility', 'Status', 'Description', 'Quota class'], list.map((c) => [
++ html`<a href="/docs/capabilities/${c.id}" class="${c.grantable ? 'grantable' : ''}">${c.id}</a>${c.grantable ? html` ${badge('grantable', 'ok')}` : ''}`,
++ c.owner, c.visibility, c.status, c.description || '', c.quotaClass,
++ ]), { cls: 'caps' })}`,
++ });
++ });
++
++ r.get('/capabilities/:id', (req, res, next) => {
++ const c = docs.capability(req.params.id);
++ if (!c) return next();
++ page(req, res, {
++ title: c.id,
++ description: (c.description || '').slice(0, 200),
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Capabilities', href: '/docs/capabilities' }, { label: c.id }],
++ body: html`<h1><code>${c.id}</code></h1>${versions()}
++${c.grantable ? html`<div class="notice ok">Grantable to apps: request it for an app on its project page. Network approves it only inside the project's allowance.</div>`
++ : html`<div class="notice">Not grantable to apps (${c.status}, ${c.visibility}).</div>`}
++<p>${c.description || ''}</p>
++<dl class="facts"><dt>Owner</dt><dd>${c.owner} (token audience <code>openvibe.${c.owner}</code>)</dd><dt>Version</dt><dd>${c.version}</dd>
++<dt>Status</dt><dd>${c.status}</dd><dt>Visibility</dt><dd>${c.visibility}</dd>
++<dt>Resource constraints</dt><dd>${c.resourceConstraints.join(', ') || 'none'}</dd><dt>Quota class</dt><dd>${c.quotaClass}</dd>
++<dt>Permissions</dt><dd>${c.permissions.join(', ') || '—'}</dd>
++${c.inputSchema ? html`<dt>Input</dt><dd>${docs.contract(c.inputSchema.split('@')[0]) ? html`<a href="/docs/contracts/${c.inputSchema.split('@')[0]}">${c.inputSchema}</a>` : c.inputSchema}</dd>` : ''}
++${c.outputSchema ? html`<dt>Output</dt><dd>${c.outputSchema}</dd>` : ''}
++<dt>Events</dt><dd>${c.events.length ? c.events.join(', ') : '—'}</dd>
++<dt>Implemented by</dt><dd>${(c.implementedBy || []).length ? html`<ul class="plain">${c.implementedBy.map((x) => html`<li><code>${x}</code></li>`)}</ul>` : '—'}</dd></dl>
++${apiDocs.has(c.owner) ? html`<p><a href="/docs/api/${c.owner}#cap-${c.id}">These routes in the API explorer</a></p>` : ''}`,
++ });
++ });
++
++ // ── API explorer (WS-C task 6): openvibe-contracts' OpenAPI 3.1 per service ──
++ const METHOD_ORDER = ['get', 'head', 'post', 'put', 'patch', 'delete'];
++ const schemaLink = (s) => {
++ if (!s || typeof s !== 'object') return '—';
++ if (s.$ref) {
++ const key = String(s.$ref).replace('#/components/schemas/', '');
++ const id = key.replace(/\.v\d+$/, '');
++ return docs.contract(id) ? html`<a href="/docs/contracts/${id}">${id}@${key.split('.v').pop()}</a>` : code(key);
++ }
++ if (s.anyOf) return html`one of ${s.anyOf.map((x, i) => html`${i ? ', ' : ''}${schemaLink(x)}`)}`;
++ if (s.contentEncoding === 'binary') return 'bytes';
++ return code(s.type || 'schema');
++ };
++ const bodyOf = (content) => (content ? Object.entries(content).map(([type, m]) => html`<div><code>${type}</code> ${schemaLink(m.schema)}</div>`) : '—');
++
++ r.get('/api', (req, res) => {
++ page(req, res, {
++ title: 'API explorer',
++ description: `Every OpenVibe service route the contracts describe, with its capabilities and schemas: ${apiIndex.length} OpenAPI 3.1 documents.`,
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'API explorer' }],
++ body: html`<h1>API explorer</h1>${versions()}
++<p>One OpenAPI 3.1 document per service, generated from the capabilities in openvibe-contracts: every route a capability names, the capabilities it performs (a token for the service must carry one), its request and response schemas and its problem+json errors. A service may answer more routes than these; what is here is the contract.</p>
++${table(['Service', 'Routes', 'Capabilities', 'Origin', 'OpenAPI'], apiIndex.map((s) => [
++ html`<a href="/docs/api/${s.service}">${s.name}</a>`, String(s.operations), String(s.capabilities),
++ s.origin ? code(s.origin) : '—', html`<a href="/docs/api/${s.service}.json">${s.service}.json</a>`,
++ ]))}
++<p class="muted">Load a document into any OpenAPI tool from <code>https://openvibe.services/docs/api/&lt;service&gt;.json</code>, or read it from the package: <code>require('openvibe-contracts').openapi.document('tools')</code>.</p>`,
++ });
++ });
++
++ r.get('/api/:service.json', (req, res, next) => {
++ const doc = apiDocs.get(req.params.service);
++ if (!doc) return next();
++ res.set({ 'Cache-Control': PUBLIC_CACHE, 'Access-Control-Allow-Origin': '*' }).type('application/vnd.oai.openapi+json;version=3.1').send(JSON.stringify(doc, null, 2));
++ });
++
++ r.get('/api/:service', (req, res, next) => {
++ const doc = apiDocs.get(req.params.service);
++ if (!doc) return next();
++ const s = apiIndex.find((x) => x.service === req.params.service);
++ const onlyPublic = req.query.public === '1';
++ const ops = [];
++ for (const [p, methods] of Object.entries(doc.paths)) {
++ for (const m of Object.keys(methods).sort((a, b) => METHOD_ORDER.indexOf(a) - METHOD_ORDER.indexOf(b))) {
++ const op = methods[m];
++ if (onlyPublic && !op['x-openvibe-visibility'].some((v) => v === 'public' || v === 'partner')) continue;
++ ops.push({ path: p, method: m, op });
++ }
++ }
++ const firstCap = new Set();
++ const opBlock = ({ path: p, method, op }) => {
++ const anchors = op['x-openvibe-capabilities'].filter((id) => !firstCap.has(id));
++ anchors.forEach((id) => firstCap.add(id));
++ const params = op.parameters || [];
++ return html`<section class="api-op" id="${op.operationId}">${anchors.map((id) => html`<span id="cap-${id}"></span>`)}
++<h3><span class="badge method-${method}">${method.toUpperCase()}</span> <code>${p}</code></h3>
++<p>${op.summary}</p>
++<dl class="facts"><dt>Capabilities</dt><dd>${op['x-openvibe-capabilities'].map((id, i) => html`${i ? ', ' : ''}<a href="/docs/capabilities/${id}">${id}</a>`)}${op.security.some((x) => !Object.keys(x).length) ? html` ${badge('open', 'ok')}` : ''}</dd>
++<dt>Visibility</dt><dd>${op['x-openvibe-visibility'].join(', ')}</dd>
++${params.length ? html`<dt>Parameters</dt><dd><ul class="plain">${params.map((x) => html`<li><code>${x.name}</code> (${x.in}${x.required ? ', required' : ''})${x.description ? html` — ${x.description}` : ''}</li>`)}</ul></dd>` : ''}
++${op.requestBody ? html`<dt>Request body</dt><dd>${bodyOf(op.requestBody.content)}</dd>` : ''}
++${op['x-openvibe-input'] && !op.requestBody ? html`<dt>Input</dt><dd>${schemaLink(op['x-openvibe-input'])}</dd>` : ''}
++<dt>Response</dt><dd>${bodyOf(op.responses['2XX'].content)}</dd>
++<dt>Errors</dt><dd><a href="/docs/contracts/errors.problem">errors.problem@1</a> (application/problem+json)</dd></dl>
++<details><summary>Description</summary>${markdown(op.description)}</details></section>`;
++ };
++ page(req, res, {
++ title: `${s.name} API`,
++ description: `${s.operations} routes of ${s.name} with their capabilities and schemas (OpenAPI 3.1 from openvibe-contracts v${docs.contractsVersion}).`,
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'API explorer', href: '/docs/api' }, { label: s.name }],
++ body: html`<h1>${s.name} API</h1>${versions()}
++<p>${doc.servers ? html`Server <code>${doc.servers[0].url}</code>. ` : ''}${s.operations} routes performing ${s.capabilities} capabilities. <a href="/docs/api/${s.service}.json">OpenAPI 3.1 document</a>.</p>
++<form method="get" action="/docs/api/${s.service}" class="inline-form"><label><input type="checkbox" name="public" value="1"${onlyPublic ? raw(' checked') : ''}> only routes apps can be granted (public, partner)</label> <button type="submit">Filter</button></form>
++${ops.length ? ops.map(opBlock) : html`<p class="muted">No route here matches.</p>`}
++${(doc['x-openvibe-other-bindings'] || []).length ? html`<h2>Other bindings</h2><ul class="plain">${doc['x-openvibe-other-bindings'].map((b) => html`<li><a href="/docs/capabilities/${b.capability}">${b.capability}</a>: <code>${b.binding}</code></li>`)}</ul>` : ''}`,
++ });
++ });
++
++ // ── The update system (roadmap WS-A task 4) ─────────────
++ const sharedVersion = (() => { try { return require('openvibe-shared/package.json').version; } catch { return null; } })();
++ const frameHelpers = (() => { try { return Object.keys(require('openvibe-shared/frame')).filter((k) => ['shipped', 'updatesBody', 'shippedScript'].includes(k)); } catch { return []; } })();
++ const sdkFrame = (() => { try { return Object.keys(require('openvibe-sdk/frame')); } catch { return []; } })();
++ r.get('/updates', async (req, res) => {
++ const feedUrl = `${config.network.url}/api/v1/changelog?service=services&limit=3`;
++ let sample = null, failed = null;
++ try {
++ const f = await fetch(feedUrl, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
++ if (!f.ok) throw new Error(`answered ${f.status}`);
++ sample = await f.json();
++ } catch (err) { failed = err.message; }
++ send(res, 200, {
++ index: true, cache: cache.htmlHeaders({ maxAge: 60 }), viewer: req.viewer, config, path: req.originalUrl,
++ title: 'The update system',
++ description: 'How every OpenVibe site shows what shipped: the network changelog feed, the data-ov-shipped markup and the openvibe-shared / openvibe-sdk helpers.',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Update system' }],
++ body: html`<h1>The update system</h1>
++<p class="versions">Helpers as installed here: <code>openvibe-shared v${sharedVersion || '?'}</code> (frame: ${frameHelpers.map((k, i) => html`${i ? ', ' : ''}<code>${k}()</code>`)}) and <code>openvibe-sdk v${docs.sdkVersion}</code> (frame: ${sdkFrame.map((k, i) => html`${i ? ', ' : ''}<code>${k}</code>`)}).</p>
++<p>Every OpenVibe site shows what shipped on it. It has three pieces: a "🚀 shipped X ago" line in the footer, a "Recently shipped" list on the home page, and a <code>/updates</code> page with the whole log. They all read one feed, so a commit that reaches production shows up everywhere without anyone writing release notes twice.</p>
++<h2>Where entries come from</h2>
++<p>OpenVibe.Blog watches every running service's release: the <code>release</code> in its <code>/release.json</code>, as OpenVibe.Network's registry reports it. When a release changes, GitHub's compare of the old and new commit gives the commits that shipped. Each one becomes an entry, with the commit message as its text, and batches of them are published as "Patch notes" posts. A service appears once it serves <code>/release.json</code> and its registry entry names its repository.</p>
++<h2>The feed</h2>
++<pre><code>GET https://openvibe.network/api/v1/changelog?service=&lt;id&gt;&amp;limit=&lt;1-100&gt;&amp;before=&lt;cursor&gt;
++→ { service, entries: [{ service, sha, short, subject, author, committed_at, deployed_at, major, url, post_id }],
++ latest_post: { id, title, url, published_at, entries } | null, posts, next }</code></pre>
++<p>It is public, CORS <code>*</code> and cached for 60 s (stale while the blog is down). Leave <code>service</code> out for the whole network.</p>
++${sample ? html`<details open><summary>Live sample: the last ${String((sample.entries || []).length)} entries for this site</summary><ul>${(sample.entries || []).map((e) => html`<li><a href="${e.url}"><code>${e.short}</code></a> ${e.subject} ${e.deployed_at ? html`<span class="muted small">(${time(e.deployed_at)})</span>` : ''}</li>`)}</ul></details>`
++ : html`<p class="muted">The feed could not be read just now (${failed}); the shape above is what it answers.</p>`}
++<h2>On a server-rendered site</h2>
++<pre><code>const frame = require('openvibe-shared/frame');
++frame.shipped({ service: 'myservice', title: 'Recently shipped on MySite' }) // home: pill + recent list
++frame.updatesBody({ service: 'myservice', siteName: 'MySite' }) // the body of your /updates page
++frame.shippedScript() // the &lt;script&gt; that fills them</code></pre>
++<p>The footer from <code>openvibe-shared/footer</code> already carries the "shipped X ago" line and an Updates link.</p>
++<h2>The markup, if you render it yourself</h2>
++<pre><code>&lt;a data-ov-shipped="latest" data-service="myservice" href="/updates" hidden&gt;&lt;/a&gt;
++&lt;div data-ov-shipped="list" data-service="myservice" data-limit="5" data-more="/updates" data-title="Recently shipped" hidden&gt;&lt;/div&gt;
++&lt;div data-ov-shipped="log" data-service="myservice" data-limit="50"&gt;&lt;/div&gt;</code></pre>
++<p><code>shipped.js</code> (served by every site from its pinned openvibe-shared) mounts every <code>[data-ov-shipped]</code> element: <code>latest</code> is the pill, <code>list</code> the recent changes, and <code>log</code> the full log with days, "Load more" and the Patch notes posts. Elements stay hidden until there is something to show.</p>
++<h2>In a browser app outside the network</h2>
++<pre><code>import { mountFrame } from 'openvibe-sdk/frame';
++await mountFrame({ service: 'myapp' }); // navbar and footer, with the shipped line
++await mountFrame({ service: 'myapp', shipped: false }); // without it</code></pre>
++<p>Live's <a href="https://openvibe.live/updates">/updates</a>, <a href="/updates">this site's</a> and <a href="https://openvibe.network/updates">the whole network's</a> are the same component.</p>`,
++ });
++ });
++
++ // ── Events ──────────────────────────────────────────────
++ // Event types that have a payload contract (events/payloads/<type>.v<major>.json in openvibe-contracts).
++ const payloadIds = new Set(require('openvibe-contracts').catalog.filter((c) => String(c.schema || '').startsWith('events/payloads/')).map((c) => c.id));
++ r.get('/events', (req, res) => {
++ page(req, res, {
++ title: 'Event types',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Events' }],
++ body: html`<h1>Event types</h1>${versions()}
++<p>Every event type a service manifest declares it produces, and who declares they consume it. Events travel as <a href="/docs/contracts/events.event-envelope">events.event-envelope@1</a>; deliveries are signed (<a href="/tools/webhooks">webhook tester</a>). Each event's payload has its own contract where one is published (named after the event type, version = the envelope's <code>version</code>).</p>
++${table(['Event type', 'Payload', 'Produced by', 'Consumed by'], docs.events.map((e) => [code(e.type), payloadIds.has(e.type) ? html`<a href="/docs/contracts/${e.type}">schema</a>` : html`<span class="muted">planned</span>`, e.producers.join(', '), e.consumers.join(', ') || '—']))}`,
++ });
++ });
++
++ // ── Services (live registry) ────────────────────────────
++ r.get('/services', async (req, res) => {
++ let services = null;
++ let problem = null;
++ try { services = await network.registry.services(); } catch (err) { problem = network.problemOf(err); }
++ const rows = (services || []).map((s) => {
++ const rt = s.runtime || {};
++ return [
++ html`<strong>${s.name || s.id}</strong><br><code>${s.id}</code>`,
++ s.status,
++ (s.domains || []).join(', ') || '—',
++ html`${badge(rt.status || 'unknown', rt.status === 'up' ? 'ok' : (rt.status === 'down' ? 'bad' : ''))}${rt.reason ? html`<br><span class="small muted">${rt.reason}</span>` : ''}`,
++ rt.checked_at ? time(rt.checked_at) : '—',
++ (s.capabilities || []).length,
++ ];
++ });
++ send(res, problem ? 502 : 200, {
++ index: true, cache: cache.htmlHeaders({ maxAge: 30 }), viewer: req.viewer, config, path: req.originalUrl,
++ title: 'Services',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Services' }],
++ body: html`<h1>Service registry</h1>
++<p>As OpenVibe.Network's registry reports it (<code>GET /api/v1/registry/services</code>), fetched at most every ${Math.round(config.registryTtlMs / 1000)} s. Health is Network's own check with the time it ran; a stale or missing check reads <em>unknown</em>, never up.</p>
++${problem ? html`${problemBox(problem, { title: 'The registry could not be read' })}
++<p>The service manifests published in <code>openvibe-contracts v${docs.contractsVersion}</code> are below, without health (nothing here says whether they run).</p>
++${table(['Service', 'Status (manifest)', 'Domains'], docs.services.map((s) => [html`<strong>${s.name}</strong><br><code>${s.id}</code>`, s.status, (s.domains || []).join(', ') || '—']))}`
++ : table(['Service', 'Maturity', 'Domains', 'Health (as reported)', 'Checked', 'Capabilities'], rows)}`,
++ });
++ });
++
++ // ── Tools (live registry from OpenVibe.Tools, ADR-027) ──────────
++ const TOOLS_URL = (process.env.OV_TOOLS_INTERNAL_URL || 'http://127.0.0.1:4001').replace(/\/$/, '');
++ let toolsCache = { at: 0, list: null };
++ r.get('/tools', async (req, res) => {
++ let list = toolsCache.list; let problem = null;
++ if (!list || Date.now() - toolsCache.at > 300_000) {
++ try {
++ const out = await fetch(`${TOOLS_URL}/api/v1/tools`, { headers: { Host: 'openvibe.tools', Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
++ if (!out.ok) throw new Error(`Tools answered ${out.status}`);
++ list = (await out.json()).tools || [];
++ toolsCache = { at: Date.now(), list };
++ } catch (err) { problem = { code: 'services.tools_unavailable', detail: err.message }; }
++ }
++ const api = (list || []).filter((t) => t.api && t.status !== 'unavailable');
++ const families = [...new Set(api.map((t) => t.family))].sort();
++ send(res, problem && !list ? 502 : 200, {
++ index: true, cache: cache.htmlHeaders({ maxAge: 60 }), viewer: req.viewer, config, path: req.originalUrl,
++ title: 'Tools API',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Tools' }],
++ body: html`<h1>Tools API</h1>
++<p>${api.length} tools on <a href="https://openvibe.tools">OpenVibe.Tools</a> can be called from code: <code>POST https://openvibe.tools/api/v1/tools/{id}/run</code> (capability <code>tools.tool.run</code>; anonymous calls run on the lowest tier), long work as jobs at <code>/api/v1/jobs/{id}</code>. OpenAPI: <a href="https://openvibe.tools/api/v1/openapi.json">openapi.json</a> · guide: <a href="https://openvibe.tools/developers">openvibe.tools/developers</a> · SDK: <code>openvibe-sdk/tools</code>.</p>
++${problem && !list ? html`${problemBox(problem, { title: 'The Tools registry could not be read' })}` : ''}
++${families.map((f) => html`<h2>${f}</h2>${table(['Tool', 'Runs as', 'Access', 'Inputs'], api.filter((t) => t.family === f).map((t) => [
++ html`<a href="${t.docs || `https://openvibe.tools/tool/${t.id}`}"><strong>${t.name}</strong></a><br><code>${t.id}</code>`,
++ t.execution === 'job' ? 'job' : 'direct',
++ t.auth && t.auth.anonymous ? 'anonymous or token' : (t.auth && t.auth.capability === 'tools.net.probe' ? 'partner token (tools.net.probe)' : 'session or token'),
++ t.files ? `files (${t.files.min}-${t.files.max})` : 'JSON',
++ ]))}`)}`,
++ });
++ });
++
++ // ── Billing policy (live from OpenVibe.Billing, WS-K task 10) ──
++ // The numbers are Billing's /policy.json (read from the rates the ledger charges); nothing is
++ // restated here. Billing not answering shows a problem, never remembered or invented numbers.
++ const BILLING_URL = (process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600').replace(/\/$/, '');
++ let billingCache = { at: 0, policy: null };
++ r.get('/billing', async (req, res) => {
++ let policy = billingCache.policy; let problem = null;
++ if (!policy || Date.now() - billingCache.at > 300_000) {
++ try {
++ const out = await fetch(`${BILLING_URL}/policy.json`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
++ if (!out.ok) throw new Error(`Billing answered ${out.status}`);
++ policy = await out.json();
++ billingCache = { at: Date.now(), policy };
++ } catch (err) { problem = { code: 'services.billing_unavailable', detail: err.message }; }
++ }
++ const usd = (c) => `$${(c / 100).toFixed(2)}`;
++ const n = (x) => Number(x).toLocaleString('en-US');
++ const p = policy;
++ send(res, problem && !p ? 502 : 200, {
++ index: true, cache: PUBLIC_CACHE, viewer: req.viewer, config, path: req.originalUrl,
++ title: 'Billing policy',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Billing' }],
++ body: html`<h1>Billing policy</h1>
++<p>How money moves on OpenVibe, as <a href="https://billing.openvibe.network/policy">billing.openvibe.network/policy</a> states it for everyone. The numbers below are Billing's <a href="https://billing.openvibe.network/policy.json"><code>/policy.json</code></a>, read from the rates the ledger charges, fetched at most every 5 minutes. Apps that take payments or show balances should link to that page rather than restate it.</p>
++${problem && !p ? problemBox(problem, { title: 'The billing policy could not be read' }) : html`
++<p>Ledger: <strong>${p.authority === 'billing' ? 'OpenVibe Billing' : 'openvibe.live'}</strong> (<code>authority: ${p.authority}</code>). Wording last changed ${p.wording_date}.</p>
++<h2>Currencies</h2>
++${table(['Currency', 'Bought with money', 'Withdrawable'], [
++ ['Vibes', 'yes', `only Vibes received; ${usd(p.currencies.vibes.cash_value_per_100_cents)} per 100`],
++ ['OpenCoins', 'no', 'never'],
++ ['Channel points', 'no', 'never'],
++])}
++<h2>Buying Vibes</h2>
++${table(['Vibes in one purchase', 'Price per 100', 'Creator value per 100', 'OpenVibe keeps'], p.purchase.tiers.map((t) => [
++ `${n(t.from)}${t.to == null ? ' or more' : `–${n(t.to)}`}`, usd(t.price_per_100_cents), usd(t.creator_value_per_100_cents), `${t.openvibe_keeps_pct}%`,
++]))}
++<p>One purchase: ${n(p.purchase.min_vibes)} to ${n(p.purchase.max_vibes)} Vibes. Tips reach the creator in full (${p.tips.creator_receives_pct}%).</p>
++<h2>Subscriptions and cashouts</h2>
++${table(['Rule', 'Value'], [
++ ['Subscription', `${usd(p.subscription.price_cents)} for ${n(p.subscription.period_days)} days`],
++ ['Creator share (paid through OpenVibe’s PowerChat)', `${p.subscription.creator_share_pct}% (${usd(p.subscription.creator_share_cents)})`],
++ ['Site routing fee, on top', `${p.subscription.site_route_fee_pct}% (${usd(p.subscription.site_route_fee_cents)})`],
++ ['Minimum cashout', `${n(p.cashout.min_vibes)} Vibes (${usd(p.cashout.min_cents)})`],
++ ['Cashout hold, then review', `${n(p.cashout.hold_days)} days`],
++])}`}`,
++ });
++ });
++
++ // ── Limits and tiers (live from the enforcing services, WS-N task 7) ──
++ // Each service that publishes a /limits.json (read from its running config) is shown as it
++ // answered; a service not answering shows a problem, never remembered numbers. Public
++ // capabilities whose owner publishes none are listed with their quota class and said so.
++ const limitsReader = ctx.limits || createLimitsReader();
++ const LIMIT_SOURCES = limitsReader.sources;
++ const limitsOf = limitsReader.read;
++ r.get('/limits', async (req, res) => {
++ const answers = await Promise.all(LIMIT_SOURCES.map(async (src) => ({ src, ...(await limitsOf(src)) })));
++ const covered = new Set(LIMIT_SOURCES.map((s) => s.service));
++ const rest = docs.capabilities.filter((c) => c.grantable && !covered.has(c.owner));
++ const owners = [...new Set(rest.map((c) => c.owner))].sort();
++ page(req, res, {
++ title: 'Limits and tiers',
++ description: 'What an OpenVibe project may do in sandbox and production, as the services that enforce it answer.',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Limits' }],
++ body: html`<h1>Limits and tiers</h1>
++<p>OpenVibe runs every project within limits it can keep paying for. Usage is metered per project and per environment, and each limit below is what the service that enforces it reports from its running configuration. Services reads it every few minutes and never keeps its own copy.</p>
++<h2>How limits work</h2>
++<ul>
++<li><strong>Two environments.</strong> A project's <code>sandbox</code> is for building and testing: smaller limits, shorter retention, kept apart from production data. <code>production</code> is what people use.</li>
++<li><strong>Enforced at the capability.</strong> The service that owns a capability checks the token's grant and the project's limit in one place, before it does the work. Nothing else can grant more.</li>
++<li><strong>Past a limit</strong> the answer is a problem (<code>application/problem+json</code>) with a stable code: <code>429</code> for a rate or a count, <code>413</code> for a size. Rate limits say when to retry (<code>Retry-After</code> or <code>retry_after</code>).</li>
++<li><strong>Trust tiers</strong> (unreviewed, reviewed, first-party; <a href="/docs/adr/ADR-013">ADR-013</a>) change defaults and discovery, never the grant check. Every tier meets the same limits today.</li>
++<li><strong>Raising a limit</strong> is a per-project override staff set at the owning service; the numbers here are the defaults every project starts with.</li>
++<li><strong>What a project used</strong> is on its usage page (<code>/projects/&lt;project&gt;/usage</code>, for the owner and admins): per day, service and capability, with the headroom of its recorded quotas and its recent failures. The services report it as hourly rollups, so it trails by an hour. Counts are what services report each hour. Cost is on your Billing page.</li>
++</ul>
++${answers.map(({ src, body, problem }) => html`<h2 id="${src.service}">${src.name}</h2>
++${problem ? problemBox(problem, { title: `${src.name}'s limits could not be read` }) : html`<p class="muted small">${src.public ? html`From <a href="${src.public}"><code>${src.public.replace('https://', '')}</code></a>` : html`From ${src.name}'s <code>/limits.json</code>`}${body.scope ? html`: ${body.scope}` : ''}.</p>
++${table(['Limit', 'Capability', 'Sandbox', 'Production', 'Past it'], body.limits.map((l) => [
++ l.label, l.capability ? html`<a href="/docs/capabilities/${l.capability}"><code>${l.capability}</code></a>` : '—',
++ amount(l.sandbox, l.unit), amount(l.production, l.unit), l.exceeded ? code(l.exceeded) : '—',
++]))}`}`)}
++<h2>Other capabilities</h2>
++<p>These can be granted to apps, but their owners do not publish their limits yet. Each shows its quota class from <code>openvibe-contracts v${docs.contractsVersion}</code>.</p>
++${table(['Owner', 'Capabilities (quota class)'], owners.map((o) => [code(o), html`${rest.filter((c) => c.owner === o).map((c, i) => html`${i ? ', ' : ''}<a href="/docs/capabilities/${c.id}">${c.id}</a> (${c.quotaClass})`)}`]))}`,
++ });
++ });
++
++ // ── Project export (WS-N task 9; server/domain/project-archive.js) ──
++ r.get('/export', (req, res) => {
++ const x = config.export;
++ page(req, res, {
++ title: 'Project export',
++ description: 'What an OpenVibe project export holds: the metadata JSON and the full archive (configuration, modules, objects, events).',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Project export' }],
++ body: html`<h1>Project export</h1>
++<p>A project's data is yours to take elsewhere. On the project's page in the portal there are two downloads. Neither contains a secret: credentials appear by id and last four characters only, and no token is ever written into either.</p>
++<h2>Metadata (JSON)</h2>
++<p>Any member can download it. It is one JSON document (<code>openvibe.services.project-export</code> v1) with what OpenVibe.Network holds for the project (the project, members, apps, credentials, grants and quotas, plus the audit log for admins) and what Services holds (releases with their manifests and logs, trust tiers, playground runs). If Network fails part-way, you get an error, never half a document.</p>
++<h2>Full archive (zip)</h2>
++<p>The owner and admins can download it. Services asks OpenVibe.Network for a read-only <em>export token</em> for each service and environment. Network checks your role, records the request in the project's audit log, and the token lasts five minutes. Services then reads the project's data with it. Services keeps nothing of the archive.</p>
++${table(['File', 'What it holds'], [
++ [code('manifest.json'), 'format (openvibe.services.project-archive v1), counts per part, whether each part is complete and where it stopped, every file with its size and SHA-256, and what is not included'],
++ [code('README.txt'), 'the same in words, with a one-line script that fetches the objects'],
++ [code('project.json'), 'the metadata document above, with the audit log. This is the project\'s configuration: environments, allowance, apps and redirect URIs, grants, quotas'],
++ [code('modules/<release_id>.json'), 'each release\'s manifest (services.app-manifest@1 or mods.mod-manifest@1) exactly as validated'],
++ [code('media/<env>/namespaces.json'), 'OpenVibe.Media\'s namespaces for the project: policy, quotas, usage'],
++ [code('media/<env>/objects.jsonl'), html`every object, soft-deleted ones included, one per line with Media's metadata and <code>download</code>: a public URL, or a signed one valid for ${Math.round(x.urlTtlS / 60)} minutes`],
++ [code('events/<env>.jsonl'), html`the project's app events (<code>app.&lt;project_key&gt;.*</code>) that OpenVibe.Events still keeps, one <code>{ seq, event }</code> per line`],
++ ])}
++<p><code>&lt;env&gt;</code> is <code>production</code> and <code>sandbox</code>, both always present (empty when the project has nothing there).</p>
++<ul>
++<li><strong>Objects are listed, not copied.</strong> A project can hold gigabytes; the zip holds each object's URL, and the bytes come straight from Media. Fetch them before the signed URLs expire, or download the archive again for fresh ones.</li>
++<li><strong>Limits.</strong> At most ${x.maxObjects} objects and ${x.maxEvents} events per environment. A part that reaches its limit says <code>complete: false</code> and the cursor to continue from, and so does the archive as a whole.</li>
++<li><strong>All or nothing.</strong> If Network, Media or Events fails or refuses, the page names the service, part and environment, and nothing is downloaded.</li>
++<li><strong>Retention.</strong> Events keeps app events for a limited time (see <a href="/docs/limits#events">Limits</a>); older ones are gone before any export.</li>
++<li><strong>Not included:</strong> Tools job results (readable only by Tools), Events webhook subscriptions (each app's own, with signing secrets), and Network user modules (per person, not per project: each member exports their own from Network).</li>
++</ul>`,
++ });
++ });
++
++ // ── SDK ─────────────────────────────────────────────────
++ r.get('/sdk', (req, res) => {
++ page(req, res, {
++ title: 'SDK reference',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'SDK' }],
++ body: html`<h1>openvibe-sdk v${docs.sdkVersion}</h1>${versions()}
++<p><strong>If a capability is not in the SDK, it is not public.</strong> Apps call services through the SDK; a route with no SDK wrapper is internal and can change without notice.</p>
++${table(['Module', 'Where', 'Declarations', 'Types file'], docs.sdk.map((m) => [
++ html`<a href="/docs/sdk/${m.slug}"><code>${m.name}</code></a>`, m.browser, m.declarations.length, code(m.typesFile),
++ ]))}`,
++ });
++ });
++
++ r.get('/sdk/:module', (req, res, next) => {
++ const m = docs.sdk.find((x) => x.slug === req.params.module);
++ if (!m) return next();
++ page(req, res, {
++ title: m.name,
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'SDK', href: '/docs/sdk' }, { label: m.name }],
++ body: html`<h1><code>${m.name}</code></h1>${versions()}
++<p class="muted">From <code>${m.typesFile}</code> (${m.browser}). Declarations are shown verbatim.</p>
++<nav class="toc"><ul class="plain">${m.declarations.map((d, i) => html`<li><a href="#d${i}">${d.kind} ${d.name}</a></li>`)}</ul></nav>
++${m.declarations.map((d, i) => html`<section id="d${i}" class="decl"><h2><small>${d.kind}</small> ${d.name}</h2>${d.doc ? html`<p>${d.doc}</p>` : ''}<pre><code>${d.text}</code></pre></section>`)}`,
++ });
++ });
++
++ r.get('/adr/:id', (req, res, next) => {
++ const a = docs.adrs.find((x) => x.id === req.params.id);
++ if (!a) return next();
++ page(req, res, {
++ title: a.title,
++ crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Decisions', href: '/policy/rfc' }, { label: a.id }],
++ body: html`<p class="versions">Published in <code>openvibe-contracts v${docs.contractsVersion}</code> (<code>docs/adr/${a.file}</code>), rendered as is.</p>
++<article class="prose">${raw(markdown(a.markdown, { headingOffset: 0 }))}</article>`,
++ });
++ });
++
++ return r;
++}
++
++module.exports = { createDocsRoutes };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..0470baf
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,313 @@
++'use strict';
++
++/**
++ * Public pages: the landing page, policy pages, public release and trust pages, the staff trust
++ * console. Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, the home page's JSON-LD) live in
++ * http/discovery.js and are mounted here.
++ *
++ * Policy pages are derived, not paraphrased: the compatibility and deprecation policy renders
++ * ADR-002 and ADR-016 exactly as openvibe-contracts publishes them; the decision index is the ADR
++ * directory; licensing reads the license fields of the packages Services runs. The community documents
++ * (code of conduct, contributing, contributor ladder, moderation) are OpenVibe.Codes' and linked there.
++ */
++const fs = require('fs');
++const contracts = require('openvibe-contracts');
++const ovServe = require('openvibe-shared/serve');
++const frame = require('openvibe-shared/frame');
++const showcase = require('openvibe-shared/showcase');
++const cache = require('openvibe-shared/cache-policy');
++const path = require('path');
++const express = require('express');
++const { asyncRouter } = require('./router');
++const { createDiscoveryRoutes, homeJsonLd, COMMUNITY, CODES_ORIGIN } = require('./discovery');
++const { html, raw, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
++const { send } = require('../render/layout');
++const { markdown } = require('../render/markdown');
++const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
++const { TABLES } = require('../db');
++const { EVENT_TYPES } = require('../events/outbox');
++
++const ROOT = path.join(__dirname, '..', '..');
++const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
++
++
++/** A limit as its service states it: a number with its unit, or unlimited/none. */
++function limitAmount(v, unit) {
++ if (v === null || v === undefined) return 'unlimited';
++ if (v === 0) return 'none';
++ return unit ? `${Number(v).toLocaleString('en-US')} ${unit}` : Number(v).toLocaleString('en-US');
++}
++
++function createPageRoutes(ctx) {
++ const { config, docs, releases, trust, network, sso } = ctx;
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '32kb' });
++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++
++ r.get('/', async (req, res) => {
++ const recent = await releases.recentPublic(10);
++ // The limits a developer's project really gets, from the service that enforces them (never restated here).
++ const eventsSource = ctx.limits && ctx.limits.bySource ? ctx.limits.bySource('events') : null;
++ const eventsLimits = eventsSource ? await ctx.limits.read(eventsSource) : null;
++ const limitRows = eventsLimits && eventsLimits.body ? eventsLimits.body.limits.slice(0, 6).map((l) => ({ label: l.label, values: [limitAmount(l.sandbox, l.unit), limitAmount(l.production, l.unit)] })) : [];
++ const snippet = `// openvibe-sdk v${docs.sdkVersion} (install: /docs/sdk)
++const { createClient } = require('openvibe-sdk');
++const { createServiceTokenClient } =
++ require('openvibe-sdk/auth');
++
++const ov = createClient({
++ tokenProvider: createServiceTokenClient({
++ clientId, clientSecret,
++ }),
++});
++const services = await ov.registry.services();`;
++ // The services a project can call: every live service whose manifest lists a capability an app can be
++ // granted, linked to its generated API reference. Read from the pinned contracts, never typed here.
++ const apiDocs = new Set(contracts.openapi.index().map((x) => x.service));
++ const programmable = contracts.services.manifests
++ .filter((m) => m && m.site && m.exposure && m.exposure.state === 'live' && m.exposure.publicSite === 'service')
++ .filter((m) => (m.capabilities || []).some((c) => { const cap = contracts.capabilities.get(c); return cap && cap.status === 'active' && cap.visibility === 'public'; }))
++ .sort((a, b) => (a.site.position ?? 99) - (b.site.position ?? 99));
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ jsonLd: homeJsonLd(config),
++ styles: [showcase.STYLESHEET],
++ body: html`${raw(showcase.hero({
++ eyebrow: 'OpenVibe.Services · the developer platform · alpha',
++ title: 'Build with the whole', accent: 'network',
++ lede: 'One console for everything programmable on OpenVibe: projects, scoped credentials, capability grants, usage and releases, over every service. The reference is generated from the exact contract and SDK versions production runs.',
++ actions: [{ label: 'Create a project', href: '/projects', primary: true }, { label: 'Read the docs', href: '/docs' }],
++ note: 'Open source. Network owns projects, apps, credentials, grants and quotas; this console shows its API with your own sign-in and never stores a secret.',
++ aside: { html: `<pre class="services-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
++ }))}
++<div class="notice">Status: alpha. What works and what does not is listed on the <a href="/policy/transparency">transparency page</a>.</div>
++${raw(showcase.features({
++ title: 'Everything an integration needs', lede: 'Each piece is a public API with a reference generated from what production runs.',
++ items: [
++ { icon: 'ov:account', title: 'Scoped credentials', text: 'A confidential app gets a client secret, shown once; try calls in a playground with exactly the app\'s grants.', href: '/projects' },
++ { icon: 'ov:check', title: 'Capability grants', text: 'Ask for exactly what your app does; grants are approved inside the project\'s allowance.', href: '/docs/capabilities?grantable=1' },
++ { icon: 'ov:bell', title: 'Events and webhooks', text: 'Subscribe to the network and verify every delivery\'s signature.', href: '/tools/webhooks' },
++ { icon: 'ov:docs', title: 'Reference and SDK', text: `APIs, contracts, capabilities and events from openvibe-contracts v${docs.contractsVersion}; openvibe-sdk v${docs.sdkVersion} for browser and server.`, href: '/docs' },
++ { icon: 'ov:search', title: 'One resource index', text: 'Every resource of a project across every service, behind one API, with each service keeping its own data.', href: '/docs/api/services' },
++ { icon: 'ov:download', title: 'Take it with you', text: 'Export a project\'s configuration, objects and events as one archive, any time.', href: '/docs/export' },
++ ],
++ }))}
++${programmable.length ? raw(showcase.features({
++ id: 'services', title: 'Services you can build on', lede: 'Each one runs in production today; its API reference is generated from the capabilities it declares.',
++ // Text only: an inline icon per service would double the page's weight (the home page's budget).
++ items: programmable.map((m) => ({
++ title: m.name, text: m.site.tagline || '',
++ href: apiDocs.has(m.id) ? `/docs/api/${m.id}` : m.publicOrigin,
++ })),
++ })) : ''}
++${raw(showcase.steps({
++ title: 'From account to integration',
++ items: [
++ { title: 'Create a project', text: 'Signed in with your OpenVibe account. New projects are sandbox-only; staff enable production.', href: '/projects' },
++ { title: 'Add a sandbox app', text: 'A confidential app gets a client secret, shown once.' },
++ { title: 'Pick capabilities', text: 'Request them for the app; they are approved only inside the project\'s allowance.', href: '/docs/capabilities?grantable=1' },
++ { title: 'Get a token', text: 'createServiceTokenClient() for client credentials, or sign people in with authorization code + PKCE.', href: '/oauth' },
++ { title: 'Receive events', text: 'Subscribe, then verify each delivery\'s signature.', href: '/tools/webhooks' },
++ { title: 'Publish a release', text: 'Validated release metadata; rotate or revoke credentials any time.', href: '/manifests/validate' },
++ ],
++ }))}
++${raw(showcase.limits({
++ title: 'What a project gets', lede: 'Pricing is the limits: these are the numbers OpenVibe.Events enforces for every project today.',
++ columns: ['Sandbox', 'Production'], rows: limitRows, source: eventsSource && eventsSource.public,
++ }))}
++<section class="sc-sec" aria-labelledby="h-ref"><h2 id="h-ref">Reference</h2>
++<p class="sc-lede">Generated from <code>openvibe-contracts v${docs.contractsVersion}</code> and <code>openvibe-sdk v${docs.sdkVersion}</code>: <a href="/docs/api">API explorer</a> · <a href="/docs/contracts">contracts</a> · <a href="/docs/capabilities">capabilities</a> · <a href="/docs/events">events</a> · <a href="/docs/services">services</a> · <a href="/docs/tools">tools</a> · <a href="/docs/sdk">SDK</a> · <a href="/docs/limits">all limits</a>.</p></section>
++<section class="sc-sec" aria-labelledby="h-rel"><h2 id="h-rel">Recent releases</h2>
++${table(['App', 'Kind', 'Version', 'Status', 'Trust', 'Published'], recent.map((x) => [
++ html`<a href="/apps/${x.app_id}">${x.name}</a>`, x.kind, html`<a href="/releases/${x.id}">${x.version}</a>`, x.status, x.trust.tier, time(x.published_at),
++ ]), { empty: 'No releases have been published yet.' })}</section>
++${raw(showcase.cta({ title: 'Start building', text: 'Sign in with your OpenVibe account; your first project takes a minute. Coding with an agent? OpenVibe.Codes routes the task to Claude Code, Codex, OpenCode or your own model.', actions: [{ label: 'Create a project', href: '/projects', primary: true }, { label: 'Code with any agent', href: 'https://openvibe.codes' }] }))}`,
++ });
++ });
++
++ // ── Policy ──────────────────────────────────────────────
++ const adr = (id) => docs.adrs.find((a) => a.id === id);
++ // What shipped on OpenVibe.Services: the shared update log every OpenVibe site has.
++ r.get('/updates', (req, res) => page(req, res, { index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Services', body: raw(frame.updatesBody({ service: 'services', siteName: 'OpenVibe.Services' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) }));
++ r.get('/policy', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Policy', crumbs: [{ label: 'Policy' }],
++ body: html`<h1>Policy</h1><ul class="cards">
++<li><a href="/policy/rfc"><strong>Proposals and decisions</strong></a><span>how a contract, capability or event changes; the decision record</span></li>
++<li><a href="/policy/compatibility"><strong>Compatibility and deprecation</strong></a><span>ADR-002 and ADR-016, as published</span></li>
++<li><a href="/policy/licensing"><strong>Licensing</strong></a><span>what each package is licensed under</span></li>
++<li><a href="/policy/transparency"><strong>Transparency</strong></a><span>what Services stores, what it does not, and what works today</span></li></ul>
++<h2>Community</h2><p class="muted">These live on <a href="${CODES_ORIGIN}">OpenVibe.Codes</a>, where OpenVibe itself is built.</p><ul class="cards">
++${COMMUNITY.map((g) => html`<li><a href="${CODES_ORIGIN}/policy/${g.slug}"><strong>${g.title}</strong></a><span>${g.blurb}</span></li>`)}</ul>`,
++ }));
++
++ // The community documents moved with Codes' contributor side: their old addresses here answer with Codes' page.
++ for (const g of COMMUNITY) r.get(`/policy/${g.slug}`, (req, res) => res.redirect(301, `${CODES_ORIGIN}/policy/${g.slug}`));
++
++ r.get('/policy/rfc', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Proposals and decisions', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Proposals' }],
++ body: html`<h1>Proposals and decisions</h1>
++<p>Changes to the platform's public surface — a contract, a capability, an event type, or a major product policy — are proposed in the open and recorded as an architecture decision record (ADR) in <a href="https://github.com/OpenVibers/OpenVibe.Contracts">OpenVibe.Contracts</a>.</p>
++<ol class="steps">
++<li><strong>Propose.</strong> Open an issue or pull request on OpenVibe.Contracts describing the change, who owns it, and who consumes it. A service that introduces capabilities ships proposals in its own repository (<code>docs/capabilities-proposal/</code>, <code>docs/service-manifest-proposal.json</code>) — this portal's are in <a href="https://github.com/OpenVibers/OpenVibe.Services/tree/main/docs">its repository</a>.</li>
++<li><strong>Decide.</strong> An accepted proposal becomes an ADR with context, decision, alternatives, migration consequences, rollback and acceptance tests.</li>
++<li><strong>Release.</strong> The contract lands in a tagged openvibe-contracts release; CI in every consuming service checks it (<code>openvibe-contracts-check</code>). The SDK wraps it in the same or the next release, and these docs regenerate from the new pins.</li>
++</ol>
++<h2>Decision record</h2>
++<p class="versions">The ADRs in <code>openvibe-contracts v${docs.contractsVersion}</code>:</p>
++${table(['Decision', 'Status'], docs.adrs.map((a) => [html`<a href="/docs/adr/${a.id}">${a.title}</a>`, a.status]))}`,
++ }));
++
++ r.get('/policy/compatibility', (req, res) => {
++ const a2 = adr('ADR-002');
++ const a16 = adr('ADR-016');
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Compatibility and deprecation', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Compatibility' }],
++ body: html`<h1>Compatibility and deprecation</h1>
++<p class="versions">This page renders two decisions exactly as published in <code>openvibe-contracts v${docs.contractsVersion}</code>, so it cannot say anything they do not.</p>
++<p>Current deprecations in that release: ${docs.contracts.filter((c) => c.deprecation).length ? html`<ul>${docs.contracts.filter((c) => c.deprecation).map((c) => html`<li><a href="/docs/contracts/${c.id}">${c.id}</a> → ${c.deprecation.replacement}, removed after ${c.deprecation.removeAfter}</li>`)}</ul>` : 'none.'}
++Deprecated capabilities: ${docs.capabilities.filter((c) => c.status === 'deprecated').map((c) => c.id).join(', ') || 'none'}.</p>
++${a2 ? html`<article class="prose">${raw(markdown(a2.markdown))}</article>` : notice('ADR-002 is not in this contracts release.', 'warn')}
++${a16 ? html`<article class="prose">${raw(markdown(a16.markdown))}</article>` : notice('ADR-016 is not in this contracts release.', 'warn')}`,
++ });
++ });
++
++ r.get('/policy/licensing', (req, res) => {
++ const pkgs = ['openvibe-contracts', 'openvibe-sdk', 'openvibe-shared'].map((n) => {
++ const p = readJson(path.join(path.dirname(require.resolve(`${n}/package.json`)), 'package.json')) || {};
++ return [code(n), p.version || '—', p.license || 'not declared'];
++ });
++ const own = readJson(path.join(ROOT, 'package.json')) || {};
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Licensing', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Licensing' }],
++ body: html`<h1>Licensing</h1>
++<p>Read from the packages this portal runs, not typed in:</p>
++${table(['Package', 'Version', 'License'], [[code(own.name || 'openvibe-services'), own.version || '—', own.license || 'not declared'], ...pkgs])}
++<p>The SDK is the library apps outside the network embed; the services and their contracts are open source under the licenses above. Each repository's LICENSE file is authoritative. This page is not legal advice.</p>
++<p>Source: <a href="https://github.com/OpenVibers">github.com/OpenVibers</a>.</p>`,
++ });
++ });
++
++ r.get('/policy/transparency', (req, res) => {
++ const status = readJson(path.join(ROOT, 'STATUS.json')) || {};
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Transparency', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Transparency' }],
++ body: html`<h1>Transparency</h1>
++<h2>What Services stores</h2>
++<p>Its own PostgreSQL database has these tables and nothing else: ${TABLES.map((t, i) => html`${i ? ', ' : ''}<code>${t}</code>`)}, plus the event outbox. That is release metadata keyed to Network app ids, trust tiers (metadata), validated manifests, and a log of playground runs (who ran what, the outcome and the problem code).</p>
++<h2>What Services does not store</h2>
++<ul><li>Projects, members, apps, credentials, grants and quotas: OpenVibe.Network owns them; Services shows what Network answers for the signed-in person.</li>
++<li>Client secrets: Network returns a new secret once, Services shows it in that response and keeps no copy. Network itself stores only a hash.</li>
++<li>Access tokens: your Network session sits in httpOnly cookies in your browser; tokens a playground uses exist only for that request.</li>
++<li>Webhook secrets typed into the tester: used for one computation, then dropped.</li></ul>
++<h2>What Services does not do</h2>
++<ul><li>It does not enforce quotas: each service that owns a capability does (quotas are shown as recorded limits).</li>
++<li>It does not issue tokens or decide grants: Network does.</li>
++<li>Trust tiers never grant anything: authority comes only from grants.</li></ul>
++<h2>Events it publishes</h2>
++<p>${EVENT_TYPES.map((t, i) => html`${i ? ', ' : ''}<code>${t}</code>`)} when a release is published, deprecated or revoked (through OpenVibe.Events once its relay is configured).</p>
++<h2>Status</h2>
++<p>Stage <strong>${status.stage || 'unknown'}</strong>. ${status.summary || ''}</p>
++${Array.isArray(status.works) ? html`<h3>Works</h3><ul>${status.works.map((w) => html`<li>${w}</li>`)}</ul>` : ''}
++${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((w) => html`<li>${w}</li>`)}</ul>` : ''}`,
++ });
++ });
++
++ // ── Public release pages ────────────────────────────────
++ const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++ r.get('/apps/:app', async (req, res, next) => {
++ if (!APP_ID_RE.test(req.params.app)) return next();
++ const list = await releases.listForApp(req.params.app);
++ const t = await trust.get(req.params.app);
++ const hist = await trust.history(req.params.app);
++ page(req, res, {
++ index: list.length > 0, cache: PUBLIC_CACHE, title: list[0] ? list[0].name : req.params.app,
++ crumbs: [{ label: 'Apps' }, { label: req.params.app }],
++ body: html`<h1>${list[0] ? list[0].name : 'App'} <small><code>${req.params.app}</code></small></h1>
++<p>Trust tier: ${badge(t.tier)} ${t.note ? html`<span class="muted">— ${t.note}</span>` : ''}</p>
++<p class="muted small">Trust tiers (<a href="/docs/adr/ADR-013">ADR-013</a>: unreviewed, reviewed, first-party) are metadata. They change defaults and discovery, never a grant check: what an app may do comes only from its grants in OpenVibe.Network.</p>
++<h2>Releases</h2>
++${table(['Version', 'Kind', 'Environment', 'Status', 'Published', 'Compatibility'], list.map((x) => [
++ html`<a href="/releases/${x.id}">${x.version}</a>`, x.kind, x.environment, statusBadge(x.status), time(x.published_at),
++ Object.entries(x.compatibility).map(([k, v]) => `${k} ${v}`).join('; '),
++ ]), { empty: 'No public releases.' })}
++${hist.length ? html`<h2>Trust history</h2>${table(['When', 'Change', 'Note'], hist.map((h) => [time(h.set_at), `${h.from_tier} → ${h.to_tier}`, h.note]))}` : ''}`,
++ });
++ });
++
++ r.get('/releases/:id', async (req, res, next) => {
++ const rel = await releases.get(req.params.id);
++ if (!rel) return next();
++ if (rel.status === 'draft') {
++ // Drafts are visible to members of the app's project only — as Network reports it.
++ let member = false;
++ if (req.viewer.kind === 'user') {
++ try { await sso.asViewer(req, res, (t) => network.projects.app(t, rel.project_id, rel.app_id)); member = true; } catch { member = false; }
++ }
++ if (!member) return next();
++ }
++ const m = await releases.manifestOf(rel.manifest_id);
++ const signedIn = req.viewer.kind === 'user';
++ page(req, res, {
++ index: rel.status === 'published', cache: rel.status === 'draft' ? null : PUBLIC_CACHE, title: `${rel.name} ${rel.version}`,
++ crumbs: [{ label: 'Apps' }, { label: rel.app_id, href: `/apps/${rel.app_id}` }, { label: rel.version }],
++ body: html`<h1>${rel.name} <small>${rel.version}</small> ${statusBadge(rel.status)}</h1>
++${req.query.done ? notice(`Release ${req.query.done}.`, 'ok') : ''}
++<dl class="facts"><dt>Release</dt><dd>${code(rel.id)}</dd><dt>App</dt><dd><a href="/apps/${rel.app_id}">${rel.app_id}</a> (${rel.environment})</dd>
++<dt>Kind</dt><dd>${rel.kind} ${code(rel.subject_id)}</dd><dt>Trust tier</dt><dd>${rel.trust.tier} <span class="muted small">(metadata only)</span></dd>
++<dt>Compatibility</dt><dd>${Object.entries(rel.compatibility).map(([k, v]) => html`<code>${k} ${v}</code> `)}</dd>
++<dt>Created</dt><dd>${time(rel.created_at)}</dd>${rel.published_at ? html`<dt>Published</dt><dd>${time(rel.published_at)}</dd>` : ''}
++${rel.deprecated_at ? html`<dt>Deprecated</dt><dd>${time(rel.deprecated_at)}: ${rel.deprecation_reason}${rel.replacement ? html` — use <a href="/releases/${rel.replacement}">${rel.replacement}</a>` : ''}</dd>` : ''}
++${rel.revoked_at ? html`<dt>Revoked</dt><dd>${time(rel.revoked_at)}: ${rel.revocation_reason}</dd>` : ''}</dl>
++${rel.notes ? html`<h2>Notes</h2><p>${rel.notes}</p>` : ''}
++<h2>Manifest</h2><p class="muted small">Validated with openvibe-contracts ${m ? m.contracts_version : '?'}.</p><pre><code>${m ? JSON.stringify(m.body, null, 2) : ''}</code></pre>
++${signedIn ? releaseActions(req, rel) : ''}
++<h2>History</h2>${table(['When', 'Action', 'By'], (await releases.log(rel.id)).map((l) => [time(l.at), l.action, l.actor]))}`,
++ });
++ });
++
++ function releaseActions(req, rel) {
++ const csrf = csrfToken(config, req.viewer);
++ const f = (action, label, extra = '') => html`<form method="post" action="/releases/${rel.id}/${action}" class="stack inline-box">${csrfField(csrf)}${raw(extra)}<button type="submit">${label}</button></form>`;
++ const out = [];
++ if (rel.status === 'draft') out.push(f('publish', 'Publish this release'));
++ if (rel.status === 'published') out.push(f('deprecate', 'Deprecate', '<label>Why (public) <input name="reason" required maxlength="500"></label><label>Replacement release id (optional) <input name="replacement" pattern="rel_[0-9A-HJKMNP-TV-Z]{26}"></label>'));
++ if (rel.status !== 'revoked') out.push(f('revoke', 'Revoke', '<label>Why (public) <input name="reason" required maxlength="500"></label><label><input type="checkbox" name="confirm" value="1" required> Revoking cannot be undone</label>'));
++ return out.length ? html`<h2>Manage</h2><p class="muted small">Network decides whether you may: developer+ for sandbox apps, admin+ for production apps.</p>${out}` : '';
++ }
++
++ // ── Staff: trust tiers (metadata) ───────────────────────
++ r.get('/staff', async (req, res) => {
++ if (!req.viewer.staff) return page(req, res, { title: 'Staff', body: html`<h1>Staff only</h1><p>Trust tiers are set by Services staff.</p>` }, req.viewer.kind === 'user' ? 403 : 401);
++ page(req, res, {
++ title: 'Staff: trust tiers', crumbs: [{ label: 'Staff' }],
++ body: html`<h1>Trust tiers</h1>${req.query.done ? notice('Saved.', 'ok') : ''}
++<p>Tiers follow <a href="/docs/adr/ADR-013">ADR-013</a>: <code>unreviewed</code> (the default), <code>reviewed</code>, <code>first-party</code>. Metadata only: a tier never grants, allows or bypasses anything. Grants and allowances are set in Network.</p>
++<form method="post" action="/staff/trust" class="stack">${csrfField(csrfToken(config, req.viewer))}
++<label>App id <input name="app_id" required pattern="app_[0-9A-HJKMNP-TV-Z]{26}"></label>
++<label>Tier <select name="tier">${trust.TIERS.map((t) => html`<option>${t}</option>`)}</select></label>
++<label>Note (public) <input name="note" required maxlength="500"></label><button type="submit">Set tier</button></form>
++<h2>Set tiers</h2>${table(['App', 'Tier', 'Note', 'By', 'When'], (await trust.listSet()).map((t) => [html`<a href="/apps/${t.app_id}">${t.app_id}</a>`, t.tier, t.note, t.set_by, time(t.set_at)]))}`,
++ });
++ });
++ // Per-actor limit (http/actor-limits.js) before the form is read.
++ r.post('/staff/trust', ctx.actorLimits.budget('services.trust.set'), form, async (req, res) => {
++ if (!req.viewer.staff || !sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
++ try {
++ await trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });
++ res.redirect(303, '/staff?done=1');
++ } catch (err) {
++ page(req, res, { title: 'Staff', body: problemBox({ status: err.status || 422, code: err.code || 'trust.invalid', detail: err.message }) }, err.status || 422);
++ }
++ });
++
++ // ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt and the home page's JSON-LD (http/discovery.js,
++ // built with openvibe-shared/seo) ─────────────────────
++ r.use(createDiscoveryRoutes(ctx));
++
++ return r;
++}
++
++const statusBadge = (s) => badge(s, s === 'published' ? 'ok' : (s === 'revoked' ? 'bad' : (s === 'deprecated' ? 'warn' : '')));
++
++module.exports = { createPageRoutes, statusBadge };
ddiff --git a/server/http/portal.js b/server/http/portal.js
nnew file mode 100644
iindex 0000000..1b5ba2f
---- /dev/null
++++ b/server/http/portal.js
@@@ -0,0 +1,767 @@
++'use strict';
++
++/**
++ * The signed-in portal: projects, members, apps, credentials, grants, quotas, usage and audit — all over
++ * OpenVibe.Network's /api/v1/projects API with the person's own access token (ADR-014: Network owns
++ * every one of these; Services stores none of them). Plus the Services-owned parts that hang off an app:
++ * releases (manifest editor, publish/deprecate/revoke) and playgrounds.
++ *
++ * Honesty rules:
++ * - a Network failure is shown as Network answered it (HTTP status, problem code, detail, request
++ * id), with the same status on the page; an unreachable Network is a 502, never an empty list
++ * - a client secret appears only in the response to the create/rotate that produced it, with
++ * Cache-Control: no-store, and is never stored, logged or redirected through a URL
++ * - quotas are "recorded limit — enforced by <service>": Network records, the owner enforces
++ * - usage (owner and admins, staff) is what Network added up from the services' hourly rollups,
++ * shown with its lag; Services computes nothing of its own
++ * - the scope editor offers only capabilities apps can be granted (public, or partner when in the
++ * allowance) and refuses to forward anything else
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const cache = require('openvibe-shared/cache-policy');
++const Busboy = require('busboy');
++const { html, raw, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
++const { send } = require('../render/layout');
++const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
++const manifestsLib = require('../domain/manifests');
++const { RANK, ReleaseError } = require('../domain/releases');
++const { validationResult } = require('./tools');
++const { buildExport } = require('../domain/project-export');
++const { ArchiveError } = require('../domain/project-archive');
++const { statusBadge } = require('./pages');
++const { usageBody, usageQuery } = require('../render/usage');
++
++const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USR_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CAP_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/;
++const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
++
++function createPortalRoutes(ctx) {
++ const { config, docs, network, sso, releases, trust, playground, archiver, limits, actorLimits, log } = ctx;
++ const r = asyncRouter();
++ const B = (name) => actorLimits.budget(name);
++ const form = express.urlencoded({ extended: false, limit: '96kb' });
++
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const csrf = (req) => csrfToken(config, req.viewer);
++
++ function problemPage(req, res, problem, { title = 'Request failed', back = null } = {}) {
++ page(req, res, {
++ title,
++ body: html`<h1>${title}</h1>${problemBox(problem, { title: problem.code && problem.code.startsWith('network.') ? 'OpenVibe.Network could not be reached' : `OpenVibe.Network answered ${problem.status}` })}
++${back ? html`<p><a href="${back}">Back</a></p>` : ''}`,
++ }, problem.status >= 400 && problem.status < 600 ? problem.status : 502);
++ }
++
++ /** Run a Network call as the viewer. → { ok, data } | { ok: false, problem } */
++ async function net(req, res, fn) {
++ try {
++ return { ok: true, data: await sso.asViewer(req, res, fn) };
++ } catch (err) {
++ const p = network.problemOf(err);
++ log.warn(`[Services] network ${p.status} ${p.code}${p.requestId ? ` (${p.requestId})` : ''}`);
++ return { ok: false, problem: p };
++ }
++ }
++
++ // Everything here needs a signed-in person with a canonical subject.
++ r.use((req, res, next) => {
++ if (req.viewer.kind !== 'user') {
++ return page(req, res, {
++ title: 'Sign in',
++ body: html`<h1>Sign in to manage projects</h1><p>Projects and apps belong to your OpenVibe account and live in OpenVibe.Network.</p>
++<p><a class="button" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
++ }, 401);
++ }
++ if (!req.viewer.subject) return page(req, res, { title: 'Account', body: html`<h1>Your account has no subject id yet</h1><p>Sign out and in again; Network assigns one on sign-in.</p>` }, 409);
++ next();
++ });
++ // Per-actor limits (http/actor-limits.js), for the signed-in person: every page asks Network with their
++ // token, so reads take the defaults; each write names its budget before its form is read.
++ r.use(actorLimits.reads('services.portal.read'));
++ // Every state change: same-origin and a valid form token.
++ const guard = (req, res, next) => {
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) {
++ return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1><p>Go back, reload the page and try again.</p>` }, 403);
++ }
++ next();
++ };
++ const idParams = (req, res, next) => {
++ const { project, app } = req.params;
++ if ((project && !PRJ_RE.test(project)) || (app && !APP_RE.test(app))) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
++ next();
++ };
++
++ // ── Projects ────────────────────────────────────────────
++ async function projectsPage(req, res, { problem = null, status = 200 } = {}) {
++ const got = await net(req, res, (t) => network.projects.list(t, { all: req.viewer.staff && req.query.all === '1' }));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Your projects' });
++ const list = got.data.projects || [];
++ page(req, res, {
++ title: 'Your projects', crumbs: [{ label: 'Projects' }],
++ body: html`<h1>Your projects</h1>${problem ? problemBox(problem, { title: `OpenVibe.Network answered ${problem.status}` }) : ''}
++${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p) => [
++ html`<a href="/projects/${p.id}">${p.name}</a><br><code class="small">${p.id}</code>${p.archived_at ? html` ${badge('archived', 'bad')}` : ''}`,
++ p.role || (req.viewer.staff ? 'staff' : '—'), (p.environments || []).join(', '), p.counts ? p.counts.apps : '—', time(p.created_at),
++ ]), { empty: 'You are not a member of any project yet.' })}
++<h2>Create a project</h2>
++<form method="post" action="/projects" class="inline-form">${csrfField(csrf(req))}
++<label>Name <input name="name" required maxlength="80"></label><button type="submit">Create</button></form>
++<p class="muted small">New projects are sandbox-only. OpenVibe staff enable production and set which capabilities a project's apps may hold (its allowance).</p>`,
++ }, status);
++ }
++ r.get('/', async (req, res) => await projectsPage(req, res));
++ r.post('/', B('services.project.create'), form, guard, async (req, res) => {
++ const got = await net(req, res, (t) => network.projects.create(t, { name: String(req.body.name || '') }));
++ if (!got.ok) return await projectsPage(req, res, { problem: got.problem, status: got.problem.status });
++ res.redirect(303, `/projects/${got.data.id}`);
++ });
++
++ /** Project + its role; renders the problem and returns null when Network refuses. */
++ async function loadProject(req, res) {
++ const got = await net(req, res, (t) => network.projects.get(t, req.params.project));
++ if (!got.ok) { problemPage(req, res, got.problem, { title: 'Project', back: '/projects' }); return null; }
++ return got.data;
++ }
++
++ r.get('/:project', idParams, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const [members, apps, quotas] = await Promise.all([
++ net(req, res, (t) => network.projects.members(t, project.id)),
++ net(req, res, (t) => network.projects.apps(t, project.id)),
++ net(req, res, (t) => network.projects.quotas(t, project.id)),
++ ]);
++ const role = project.role;
++ const archived = Boolean(project.archived_at);
++ const c = csrf(req);
++ const envs = project.environments || ['sandbox'];
++ const assignable = role === 'owner' ? ['admin', 'developer', 'viewer'] : (role === 'admin' ? ['developer', 'viewer'] : []);
++ page(req, res, {
++ title: project.name, crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name }],
++ body: html`<h1>${project.name}</h1>${req.query.done ? notice(String(req.query.done).slice(0, 80), 'ok') : ''}
++${archived ? notice('This project is archived: every app in it is revoked.', 'bad') : ''}
++<dl class="facts"><dt>Project</dt><dd>${code(project.id)}</dd><dt>Your role</dt><dd>${role || (req.viewer.staff ? 'staff (not a member)' : '—')}</dd>
++<dt>Environments</dt><dd>${envs.join(', ')}${project.environment_policy === 'sandbox' ? html` <span class="muted small">(production apps need staff to enable production for this project)</span>` : ''}</dd>
++<dt>Allowance</dt><dd>${(project.allowance || []).length ? html`<ul class="plain">${project.allowance.map((id) => html`<li><a href="/docs/capabilities/${id}"><code>${id}</code></a></li>`)}</ul>` : html`<span class="muted">empty: staff decide which capabilities this project's apps may hold</span>`}</dd>
++<dt>Created</dt><dd>${time(project.created_at)}</dd></dl>
++${atLeast(role, 'admin') || req.viewer.staff ? html`<p><a href="/projects/${project.id}/usage">Usage, quotas and errors</a> · <a href="/projects/${project.id}/audit">Audit log</a></p>` : ''}
++
++<h2>Apps</h2>
++${apps.ok ? table(['App', 'Environment', 'Type', 'Grants', 'Created'], (apps.data.apps || []).map((a) => [
++ html`<a href="/projects/${project.id}/apps/${a.id}">${a.name}</a><br><code class="small">${a.id}</code>${a.revoked_at ? html` ${badge('revoked', 'bad')}` : ''}`,
++ a.environment, a.client_type, (a.grants || []).length, time(a.created_at),
++ ]), { empty: 'No apps yet.' }) : problemBox(apps.problem, { title: 'Apps could not be loaded' })}
++${!archived && atLeast(role, 'developer') ? html`<h3>Add an app</h3>
++<form method="post" action="/projects/${project.id}/apps" class="stack">${csrfField(c)}
++<label>Name <input name="name" required maxlength="80"></label>
++<label>Environment <select name="environment">${envs.map((e) => html`<option value="${e}"${e === 'production' && !atLeast(role, 'admin') ? raw(' disabled') : ''}>${e}${e === 'production' && !atLeast(role, 'admin') ? ' (admin+)' : ''}</option>`)}</select></label>
++<label>Type <select name="type"><option value="confidential">confidential — has a client secret (server-side apps)</option><option value="public">public — no secret, authorization code + PKCE only</option></select></label>
++<label>Redirect URIs (one per line; https, or http://localhost for sandbox apps) <textarea name="redirect_uris" rows="3" spellcheck="false" placeholder="${config.baseUrl}/oauth/test-callback"></textarea></label>
++<button type="submit">Create app</button></form>` : ''}
++
++<h2>Quotas</h2>
++${quotas.ok ? html`${table(['Capability', 'Limit', 'Window', 'Unit', 'Enforcement'], (quotas.data.quotas || []).map((q) => [
++ html`<a href="/docs/capabilities/${q.capability}"><code>${q.capability}</code></a>`, String(q.limit), q.window, q.unit,
++ html`recorded limit — enforced by <code>${q.enforced_by || 'the owning service'}</code>`,
++ ]), { empty: 'No quotas recorded for this project.' })}
++<p class="muted small">${quotas.data.note || ''} Services shows these limits; it does not enforce them.${atLeast(role, 'admin') || req.viewer.staff ? html` <a href="/projects/${project.id}/usage#quotas">What each window has used</a>.` : ' The owner and admins see what each window has used.'}</p>` : problemBox(quotas.problem, { title: 'Quotas could not be loaded' })}
++
++<h2>Members</h2>
++${members.ok ? table(['Member', 'Role', 'Added', ''], (members.data.members || []).map((m) => [
++ html`${m.display_name || m.username || ''} ${m.username ? html`<span class="muted">@${m.username}</span>` : ''}<br><code class="small">${m.subject.id}</code>`,
++ m.role, time(m.added_at),
++ memberActions(project, role, m, c, req.viewer.subject, assignable),
++ ])) : problemBox(members.problem, { title: 'Members could not be loaded' })}
++${!archived && assignable.length ? html`<h3>Add a member</h3>
++<form method="post" action="/projects/${project.id}/members" class="inline-form">${csrfField(c)}
++<label>Username <input name="username" required maxlength="64"></label>
++<label>Role <select name="role">${assignable.map((x) => html`<option>${x}</option>`)}</select></label><button type="submit">Add</button></form>` : ''}
++
++<h2>Export</h2>
++<p>Download this project's metadata as JSON: the project, members, apps with their credentials (ids and last four characters, never a secret) and grants, quotas${atLeast(role, 'admin') || req.viewer.staff ? ', the audit log' : ''} as OpenVibe.Network holds them, and Services' releases, manifests, trust tiers and playground runs.${atLeast(role, 'admin') || req.viewer.staff ? '' : ' The audit log needs the admin role and is left out.'}</p>
++<p><a class="button" href="/projects/${project.id}/export" download>Download export (JSON)</a></p>
++${atLeast(role, 'admin') ? html`<h3>Full archive</h3>
++<p>Everything the project holds, as one zip: the metadata above with the audit log, its release manifests (its app and mod modules), its Media objects and namespaces, and the app events OpenVibe.Events still keeps, in both environments. Objects are listed with their download URLs (signed ones stay valid for ${Math.round(config.export.urlTtlS / 60)} minutes), not copied into the zip. At most ${config.export.maxObjects} objects and ${config.export.maxEvents} events per environment: the archive says when a part stops short, and if a service fails nothing is downloaded. <a href="/docs/export">What is in it</a>.</p>
++<form method="post" action="/projects/${project.id}/export/archive" class="stack">${csrfField(c)}<button type="submit">Download full archive (zip)</button></form>` : html`<p class="muted small">The full archive (objects, events and modules too) is for the project's owner and admins.</p>`}
++${role === 'owner' && !archived ? html`<h2>Archive</h2><form method="post" action="/projects/${project.id}/archive" class="stack">${csrfField(c)}
++<label><input type="checkbox" name="confirm" value="1" required> Archiving cannot be undone and revokes every app in the project</label><button type="submit" class="danger">Archive project</button></form>` : ''}
++${role === 'owner' || req.viewer.staff ? html`<h2>Delete</h2>
++<p>Deleting archives the project in OpenVibe.Network (every app and credential is revoked; this cannot be undone) and removes Services' data for it:</p>
++<ul><li>draft releases and their manifests are deleted;</li>
++<li>published and deprecated releases are <strong>revoked</strong>, not erased, so people who installed them are told (the <a href="/policy/compatibility">compatibility policy</a>);</li>
++<li>playground run logs are deleted.</li></ul>
++<p class="muted small">OpenVibe.Network keeps the archived project record and its append-only audit log; it offers archiving, not erasure. Download the export first if you want a copy.</p>
++<form method="post" action="/projects/${project.id}/delete" class="stack">${csrfField(c)}
++<label>Type the project name, <strong>${project.name}</strong>, to confirm <input name="confirm_name" required autocomplete="off" spellcheck="false"></label>
++<button type="submit" class="danger">Delete project</button></form>` : ''}`,
++ });
++ });
++
++ function memberActions(project, role, m, c, me, assignable) {
++ if (project.archived_at || m.role === 'owner') return '';
++ const self = m.subject.id === me;
++ const canChange = assignable.includes(m.role);
++ return html`${canChange ? html`<form method="post" action="/projects/${project.id}/members/${m.subject.id}/role" class="inline-form">${csrfField(c)}
++<select name="role" aria-label="Role">${assignable.map((x) => html`<option${x === m.role ? raw(' selected') : ''}>${x}</option>`)}</select><button type="submit">Change</button></form>` : ''}
++${canChange || self ? html`<form method="post" action="/projects/${project.id}/members/${m.subject.id}/remove" class="inline-form">${csrfField(c)}<button type="submit">${self ? 'Leave' : 'Remove'}</button></form>` : ''}`;
++ }
++
++ const back = (req) => `/projects/${req.params.project}`;
++ const after = (req, res, got, target, done) => {
++ if (!got.ok) return problemPage(req, res, got.problem, { back: target });
++ res.redirect(303, `${target}?done=${encodeURIComponent(done)}`);
++ };
++
++ r.post('/:project/members', idParams, B('services.project.manage'), form, guard, async (req, res) => {
++ const username = String(req.body.username || '').trim().replace(/^@/, '');
++ const got = await net(req, res, (t) => network.projects.addMember(t, req.params.project, { username, role: String(req.body.role || 'viewer') }));
++ after(req, res, got, back(req), `Added ${username}`);
++ });
++ r.post('/:project/members/:subject/role', idParams, B('services.project.manage'), form, guard, async (req, res) => {
++ if (!USR_RE.test(req.params.subject)) return problemPage(req, res, { status: 422, code: 'services.invalid', detail: 'not a member subject' }, { back: back(req) });
++ const got = await net(req, res, (t) => network.projects.updateMember(t, req.params.project, req.params.subject, { role: String(req.body.role || '') }));
++ after(req, res, got, back(req), 'Role changed');
++ });
++ r.post('/:project/members/:subject/remove', idParams, B('services.project.manage'), form, guard, async (req, res) => {
++ if (!USR_RE.test(req.params.subject)) return problemPage(req, res, { status: 422, code: 'services.invalid', detail: 'not a member subject' }, { back: back(req) });
++ const self = req.params.subject === req.viewer.subject;
++ const got = await net(req, res, (t) => network.projects.removeMember(t, req.params.project, req.params.subject));
++ if (got.ok && self) return res.redirect(303, '/projects');
++ after(req, res, got, back(req), 'Member removed');
++ });
++ r.post('/:project/archive', idParams, B('services.project.manage'), form, guard, async (req, res) => {
++ if (req.body.confirm !== '1') return problemPage(req, res, { status: 422, code: 'services.confirm', detail: 'tick the box to confirm' }, { back: back(req) });
++ const got = await net(req, res, (t) => network.projects.archive(t, req.params.project));
++ after(req, res, got, back(req), 'Project archived');
++ });
++
++ r.get('/:project/export', idParams, B('services.project.export'), async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const got = await net(req, res, async (t) => await buildExport({
++ network, token: t, project, includeAudit: atLeast(project.role, 'admin') || req.viewer.staff, releases, playground, trust,
++ meta: { now: new Date().toISOString(), subject: req.viewer.subject, networkUrl: config.network.url, baseUrl: config.baseUrl },
++ }));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Export', back: back(req) });
++ res.set('Cache-Control', 'no-store');
++ res.set('Content-Disposition', `attachment; filename="openvibe-project-${project.id}.json"`);
++ res.type('application/json').send(`${JSON.stringify(got.data, null, 2)}\n`);
++ });
++
++ // The full archive (owner/admin; Network checks the role again when it mints the export tokens).
++ // One at a time per project: it reads every object and event the project holds.
++ const exporting = new Set();
++ r.post('/:project/export/archive', idParams, B('services.project.export'), form, guard, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ if (!atLeast(project.role, 'admin')) return problemPage(req, res, { status: 403, code: 'project.forbidden', detail: 'only the project owner or an admin can download the full archive' }, { title: 'Export', back: back(req) });
++ if (exporting.has(project.id)) return problemPage(req, res, { status: 429, code: 'services.export_running', detail: 'an archive of this project is being built; try again when it is done' }, { title: 'Export', back: back(req) });
++ exporting.add(project.id);
++ try {
++ const meta = { now: new Date().toISOString(), subject: req.viewer.subject, networkUrl: config.network.url, baseUrl: config.baseUrl };
++ const metadata = await net(req, res, async (t) => await buildExport({ network, token: t, project, includeAudit: true, releases, playground, trust, meta }));
++ if (!metadata.ok) return problemPage(req, res, metadata.problem, { title: 'Export', back: back(req) });
++ const mintToken = async (audience, env) => {
++ const got = await net(req, res, (t) => network.projects.exportToken(t, project.id, { audience, env }));
++ if (!got.ok) throw Object.assign(new Error('export token refused'), { networkProblem: got.problem });
++ return got.data;
++ };
++ const out = await archiver.build({ project, metadata: metadata.data, mintToken, meta });
++ log.info(`[Services] project ${project.id} archive exported by user:${req.viewer.subject}: ${out.summary.objects} objects, ${out.summary.events} events, ${out.summary.modules} modules, ${out.summary.bytes} bytes${out.summary.complete ? '' : ' (a limit was reached)'}`);
++ res.set('Cache-Control', 'no-store');
++ res.set('Content-Disposition', `attachment; filename="${out.filename}"`);
++ res.type('application/zip').send(out.buffer);
++ } catch (err) {
++ if (err.networkProblem) return problemPage(req, res, err.networkProblem, { title: 'Export', back: back(req) });
++ if (!(err instanceof ArchiveError)) throw err;
++ log.warn(`[Services] project ${project.id} archive failed: ${err.message}`);
++ page(req, res, {
++ title: 'Export failed',
++ body: html`<h1>Export failed</h1>${problemBox(err.problem, { title: 'The archive could not be built' })}
++<p>Nothing was downloaded. Try again in a moment; the metadata export (JSON) does not depend on these services.</p><p><a href="${back(req)}">Back</a></p>`,
++ }, err.problem.status);
++ } finally {
++ exporting.delete(project.id);
++ }
++ });
++
++ r.post('/:project/delete', idParams, B('services.project.manage'), form, guard, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ if (project.role !== 'owner' && !req.viewer.staff) return problemPage(req, res, { status: 403, code: 'project.forbidden', detail: 'only the project owner can delete it' }, { back: back(req) });
++ if (String(req.body.confirm_name || '').trim() !== project.name) return problemPage(req, res, { status: 422, code: 'services.confirm', detail: 'type the project name exactly to confirm' }, { back: back(req) });
++ // Network first: if it refuses, nothing in Services changes.
++ if (!project.archived_at) {
++ const got = await net(req, res, (t) => network.projects.archive(t, project.id));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Delete project', back: back(req) });
++ }
++ const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.role, staff: req.viewer.staff, traceparent: req.ov.traceparent };
++ const retired = await releases.retireProject({ actor, projectId: project.id });
++ const runs = await playground.deleteRunsForProject(project.id);
++ log.info(`[Services] project ${project.id} deleted by ${actor.label}: archived in Network, ${retired.revoked.length} releases revoked, ${retired.deletedDrafts.length} drafts and ${runs} playground runs deleted`);
++ res.redirect(303, `${back(req)}?done=${encodeURIComponent(`Deleted: ${retired.revoked.length} revoked, ${retired.deletedDrafts.length} drafts and ${runs} runs removed`)}`);
++ });
++
++ r.get('/:project/audit', idParams, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const before = /^\d+$/.test(String(req.query.before || '')) ? req.query.before : undefined;
++ const got = await net(req, res, (t) => network.projects.audit(t, project.id, { before }));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Audit log', back: back(req) });
++ page(req, res, {
++ title: `Audit · ${project.name}`, crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Audit' }],
++ body: html`<h1>Audit log</h1><p class="muted">Append-only, kept by OpenVibe.Network. Credential ids and last four characters only; never a secret.</p>
++${table(['When', 'Actor', 'Action', 'Target', 'Detail', 'Event'], (got.data.entries || []).map((e) => [
++ time(e.at), code(e.actor), e.action, e.target ? code(e.target) : '', html`<code class="small">${JSON.stringify(e.detail || {})}</code>`, e.event_type || '',
++ ]))}
++${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=${got.data.next_before}">Older</a></p>` : ''}`,
++ });
++ });
++
++ // ── Usage (WS-N task 4): Network's per-day numbers from the services' rollups ──
++ r.get('/:project/usage', idParams, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const crumbs = [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Usage' }];
++ if (!atLeast(project.role, 'admin') && !req.viewer.staff) {
++ return page(req, res, { title: `Usage · ${project.name}`, crumbs, body: html`<h1>Usage</h1><p>A project's usage and errors are for its owner and admins.</p><p><a href="${back(req)}">Back to the project</a></p>` }, 403);
++ }
++ const query = usageQuery(req.query);
++ const got = await net(req, res, (t) => network.projects.usage(t, project.id, query));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Usage', back: back(req) });
++ // The published limits of the services this project used (cached by the reader).
++ const used = new Set((got.data.totals || []).map((t) => t.service));
++ const answers = limits ? await Promise.all(limits.sources.filter((src) => used.has(src.service)).map(async (src) => ({ src, ...(await limits.read(src)) }))) : [];
++ page(req, res, { title: `Usage · ${project.name}`, crumbs, body: usageBody({ project, usage: got.data, limits: answers, query }) });
++ });
++
++ // ── Apps ────────────────────────────────────────────────
++ const redirectList = (text) => String(text || '').split(/\s+/).map((s) => s.trim()).filter(Boolean).slice(0, 11);
++
++ /** The one page a client secret ever appears on. */
++ function secretPage(req, res, { project, app, credential, rotated = false, previous = [] }) {
++ res.set('Cache-Control', 'no-store');
++ res.set('Pragma', 'no-cache');
++ page(req, res, {
++ title: 'Copy your client secret', scripts: ['js/copy.js'],
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project, href: `/projects/${project}` }, { label: app.name || app.id }],
++ body: html`<h1>${rotated ? 'New client secret' : `${app.name} is ready`}</h1>
++<div class="secret-box">
++<label>client_id <input readonly value="${app.id}" id="client-id" autocomplete="off" spellcheck="false"></label>
++<label>client_secret <input readonly value="${credential.client_secret}" id="client-secret" class="secret" autocomplete="off" spellcheck="false"></label>
++<button type="button" class="copy" data-copy="#client-secret" hidden>Copy secret</button>
++</div>
++<div class="notice warn"><strong>You won't see this secret again.</strong> Copy it into your server's secret store now. Services does not keep it and OpenVibe.Network stores only a hash (credential <code>${credential.id}</code>, ending <code>…${credential.hint}</code>). If you lose it, rotate the credential. Never put it in a browser or a repository.</div>
++${rotated && previous.length ? html`<p>The previous secret${previous.length > 1 ? 's stay' : ' stays'} valid until ${previous.map((p) => html`${time(p.expires_at)} `)} so you can deploy the new one first.</p>` : ''}
++<h2>Use it</h2>
++<pre><code>const sdk = require('openvibe-sdk');
++const client = sdk.createClient({
++ tokenProvider: sdk.auth.createServiceTokenClient({ clientId: '${app.id}', clientSecret: process.env.OV_CLIENT_SECRET }),
++});</code></pre>
++<p><a class="button" href="/projects/${project}/apps/${app.id}">I have stored it — continue</a></p>`,
++ });
++ }
++
++ r.post('/:project/apps', idParams, B('services.app.create'), form, guard, async (req, res) => {
++ const body = {
++ name: String(req.body.name || ''), environment: String(req.body.environment || 'sandbox'),
++ type: String(req.body.type || 'confidential'), redirect_uris: redirectList(req.body.redirect_uris),
++ };
++ const got = await net(req, res, (t) => network.projects.createApp(t, req.params.project, body));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Create app', back: back(req) });
++ const app = got.data;
++ if (app.credential && app.credential.client_secret) return secretPage(req, res, { project: req.params.project, app, credential: app.credential });
++ res.redirect(303, `/projects/${req.params.project}/apps/${app.id}?done=${encodeURIComponent('App created (public: no secret)')}`);
++ });
++
++ /** Capabilities the scope editor may offer: grantable to apps, per the pinned catalog AND Network. */
++ function offered(catalog, project) {
++ const allowance = new Set(project.allowance || []);
++ return ((catalog && catalog.capabilities) || []).filter((c) => {
++ if (!c || typeof c.id !== 'string' || !CAP_RE.test(c.id)) return false;
++ const pinned = docs.capability(c.id);
++ if (pinned && !pinned.grantable) return false; // never offer what the contracts say is not grantable
++ const vis = pinned ? pinned.visibility : c.visibility;
++ if (vis === 'public') return true;
++ if (vis === 'partner') return allowance.has(c.id); // partner: only when staff put it in this allowance
++ return false;
++ }).map((c) => {
++ const pinned = docs.capability(c.id);
++ return {
++ id: c.id, owner: c.owner || (pinned && pinned.owner), audience: c.audience || `openvibe.${c.owner}`,
++ visibility: pinned ? pinned.visibility : c.visibility, description: c.description || (pinned && pinned.description) || '',
++ inAllowance: allowance.has(c.id),
++ };
++ });
++ }
++
++ async function loadApp(req, res) {
++ const [project, app] = await Promise.all([
++ net(req, res, (t) => network.projects.get(t, req.params.project)),
++ net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app)),
++ ]);
++ if (!project.ok) { problemPage(req, res, project.problem, { title: 'Project', back: '/projects' }); return null; }
++ if (!app.ok) { problemPage(req, res, app.problem, { title: 'App', back: back(req) }); return null; }
++ return { project: project.data, app: app.data };
++ }
++
++ r.get('/:project/apps/:app', idParams, async (req, res) => {
++ const loaded = await loadApp(req, res);
++ if (!loaded) return;
++ const { project, app } = loaded;
++ const [creds, grants, catalog] = await Promise.all([
++ net(req, res, (t) => network.projects.credentials(t, project.id, app.id)),
++ net(req, res, (t) => network.projects.grants(t, project.id, app.id)),
++ net(req, res, (t) => network.projects.catalog(t)),
++ ]);
++ const role = project.role;
++ const manage = atLeast(role, app.environment === 'production' ? 'admin' : 'developer') && !app.revoked_at && !project.archived_at;
++ const decide = atLeast(role, 'admin') && !app.revoked_at;
++ const c = csrf(req);
++ const base = `/projects/${project.id}/apps/${app.id}`;
++ const grantRows = grants.ok ? (grants.data.grants || []) : [];
++ const byCap = new Map(grantRows.map((g) => [g.capability, g]));
++ const offer = catalog.ok ? offered(catalog.data, project) : [];
++ const t = await trust.get(app.id);
++ const rels = await releases.listForApp(app.id, { includeDrafts: true });
++ page(req, res, {
++ title: app.name, scripts: ['js/copy.js'],
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name }],
++ body: html`<h1>${app.name} ${app.revoked_at ? badge('revoked', 'bad') : badge(app.environment, app.environment === 'production' ? 'warn' : '')}</h1>
++${req.query.done ? notice(String(req.query.done).slice(0, 120), 'ok') : ''}
++<dl class="facts"><dt>client_id</dt><dd>${code(app.client_id)}</dd><dt>Subject</dt><dd>${code(`app:${app.id}`)}</dd>
++<dt>Type</dt><dd>${app.client_type}</dd><dt>Environment</dt><dd>${app.environment}</dd><dt>Trust tier</dt><dd>${t.tier} <span class="muted small">(metadata only; never grants anything)</span></dd>
++<dt>Created</dt><dd>${time(app.created_at)}</dd>${app.revoked_at ? html`<dt>Revoked</dt><dd>${time(app.revoked_at)}</dd>` : ''}</dl>
++
++<h2>Redirect URIs</h2>
++${manage ? html`<form method="post" action="${base}/redirects" class="stack">${csrfField(c)}
++<textarea name="redirect_uris" rows="3" spellcheck="false">${(app.redirect_uris || []).join('\n')}</textarea>
++<p class="muted small">Exact match. https only (http://localhost, 127.0.0.1 and [::1] for sandbox apps). At most 10. To try sign-in before your callback exists, add <code>${config.baseUrl}/oauth/test-callback</code> (<a href="/oauth?client_id=${app.id}">helper</a>).</p>
++<button type="submit">Save redirect URIs</button></form>` : table(['URI'], (app.redirect_uris || []).map((u) => [code(u)]), { empty: 'None.' })}
++
++<h2>Credentials</h2>
++${app.client_type === 'public' ? html`<p>Public apps have no secret: they use authorization code + PKCE only.</p>` : (creds.ok ? html`
++${table(['Credential', 'Ends with', 'State', 'Created', 'Expires', 'Last used', ''], (creds.data.credentials || []).map((cr) => [
++ code(cr.id), code(`…${cr.hint}`), badge(cr.state, cr.state === 'active' ? 'ok' : (cr.state === 'revoked' || cr.state === 'expired' ? 'bad' : 'warn')),
++ time(cr.created_at), time(cr.expires_at), time(cr.last_used_at),
++ manage && (cr.state === 'active' || cr.state === 'expiring') ? html`<form method="post" action="${base}/credentials/${cr.id}/revoke" class="inline-form">${csrfField(c)}<button type="submit" class="danger">Revoke now</button></form>` : '',
++ ]), { empty: 'No credentials.' })}
++<p class="muted small">Only the last four characters are ever shown: the secret itself was displayed once when it was made. A revoked secret stops working at once; tokens already issued with it end within 5 minutes.</p>
++${manage ? html`<form method="post" action="${base}/credentials/rotate" class="inline-form">${csrfField(c)}
++<label>Keep the previous secret valid for <select name="overlap_seconds"><option value="0">0 (stop at once)</option><option value="3600">1 hour</option><option value="86400" selected>24 hours</option><option value="604800">7 days</option></select></label>
++<button type="submit">Rotate secret</button></form>` : ''}` : problemBox(creds.problem, { title: 'Credentials could not be loaded' }))}
++
++<h2>Grants</h2>
++${grants.ok ? table(['Capability', 'Audience', 'Status', 'Requested', 'Decided', ''], grantRows.map((g) => [
++ html`<a href="/docs/capabilities/${g.capability}"><code>${g.capability}</code></a>`, code(g.audience), badge(g.status, g.status === 'approved' ? 'ok' : (g.status === 'requested' ? 'warn' : 'bad')),
++ time(g.requested_at), time(g.decided_at),
++ decide ? grantActions(base, g, c, project) : '',
++ ]), { empty: 'No grants yet: this app can call nothing.' }) : problemBox(grants.problem, { title: 'Grants could not be loaded' })}
++
++<h3 id="request">Request a capability</h3>
++${catalog.ok ? html`<p class="muted small">Only capabilities apps can be granted are offered: active and <code>public</code>, or <code>partner</code> when staff put one in this project's allowance. A request outside the allowance waits until staff add it; owners and admins get requests inside it approved at once.</p>
++${table(['Capability', 'Owner', 'Visibility', 'Description', 'Allowance', ''], offer.map((o) => {
++ const g = byCap.get(o.id);
++ return [
++ html`<a href="/docs/capabilities/${o.id}"><code>${o.id}</code></a>`, html`${o.owner}<br><code class="small">${o.audience}</code>`, o.visibility, o.description,
++ o.inAllowance ? badge('in allowance', 'ok') : badge('outside allowance'),
++ g && (g.status === 'approved' || g.status === 'requested') ? g.status
++ : (atLeast(role, 'developer') && !app.revoked_at ? html`<form method="post" action="${base}/grants" class="inline-form">${csrfField(c)}<input type="hidden" name="capability" value="${o.id}"><button type="submit">Request</button></form>` : ''),
++ ];
++ }), { cls: 'scope-editor', empty: 'Network offers no grantable capabilities.' })}` : problemBox(catalog.problem, { title: 'The capability catalog could not be loaded' })}
++
++<h2>Playground</h2>
++<p><a href="${base}/playground">Try calls with this app's own credentials</a> — it can do only what this app is granted.</p>
++
++<h2>Releases</h2>
++${table(['Version', 'Kind', 'Status', 'Created', 'Published'], rels.map((x) => [html`<a href="/releases/${x.id}">${x.version}</a>`, x.kind, statusBadge(x.status), time(x.created_at), time(x.published_at)]), { empty: 'No releases yet.' })}
++${manage ? html`<p><a class="button" href="${base}/releases/new">New release</a> <a href="/apps/${app.id}">Public page</a></p>` : html`<p><a href="/apps/${app.id}">Public page</a></p>`}
++
++${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revoke" class="stack">${csrfField(c)}
++<label><input type="checkbox" name="confirm" value="1" required> Revoking stops every credential and pending code at once and cannot be undone</label>
++<button type="submit" class="danger">Revoke app</button></form>` : ''}`,
++ });
++ });
++
++ function grantActions(base, g, c, project) {
++ const f = (action, label, cls = '') => html`<form method="post" action="${base}/grants/${g.capability}/${action}" class="inline-form">${csrfField(c)}<button type="submit" class="${cls}">${label}</button></form>`;
++ if (g.status === 'requested') {
++ const inside = (project.allowance || []).includes(g.capability);
++ return html`${inside ? f('approve', 'Approve') : html`<span class="muted small">outside the allowance</span> `}${f('deny', 'Deny')}`;
++ }
++ if (g.status === 'approved') return f('revoke', 'Revoke', 'danger');
++ return '';
++ }
++
++ const appBase = (req) => `/projects/${req.params.project}/apps/${req.params.app}`;
++ r.post('/:project/apps/:app/redirects', idParams, B('services.app.manage'), form, guard, async (req, res) => {
++ const got = await net(req, res, (t) => network.projects.updateApp(t, req.params.project, req.params.app, { redirect_uris: redirectList(req.body.redirect_uris) }));
++ after(req, res, got, appBase(req), 'Redirect URIs saved');
++ });
++ r.post('/:project/apps/:app/revoke', idParams, B('services.app.manage'), form, guard, async (req, res) => {
++ if (req.body.confirm !== '1') return problemPage(req, res, { status: 422, code: 'services.confirm', detail: 'tick the box to confirm' }, { back: appBase(req) });
++ const got = await net(req, res, (t) => network.projects.revokeApp(t, req.params.project, req.params.app));
++ after(req, res, got, appBase(req), 'App revoked');
++ });
++ r.post('/:project/apps/:app/credentials/rotate', idParams, B('services.app.credential'), form, guard, async (req, res) => {
++ const n = Number(req.body.overlap_seconds);
++ const body = Number.isInteger(n) && n >= 0 && n <= 604800 ? { overlap_seconds: n } : {};
++ const got = await net(req, res, (t) => network.projects.rotate(t, req.params.project, req.params.app, body));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Rotate secret', back: appBase(req) });
++ const appInfo = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
++ secretPage(req, res, {
++ project: req.params.project, app: appInfo.ok ? appInfo.data : { id: req.params.app, name: req.params.app },
++ credential: got.data.credential, rotated: true, previous: got.data.previous || [],
++ });
++ });
++ r.post('/:project/apps/:app/credentials/:credential/revoke', idParams, B('services.app.credential'), form, guard, async (req, res) => {
++ if (!CRD_RE.test(req.params.credential)) return problemPage(req, res, { status: 404, code: 'credential.not_found', detail: 'no such credential' }, { back: appBase(req) });
++ const got = await net(req, res, (t) => network.projects.revokeCredential(t, req.params.project, req.params.app, req.params.credential));
++ after(req, res, got, appBase(req), 'Credential revoked');
++ });
++
++ r.post('/:project/apps/:app/grants', idParams, B('services.app.manage'), form, guard, async (req, res) => {
++ const capability = String(req.body.capability || '');
++ // Re-derive what the scope editor offers and refuse anything else before Network sees it.
++ const [project, catalog] = await Promise.all([
++ net(req, res, (t) => network.projects.get(t, req.params.project)),
++ net(req, res, (t) => network.projects.catalog(t)),
++ ]);
++ if (!project.ok) return problemPage(req, res, project.problem, { back: appBase(req) });
++ if (!catalog.ok) return problemPage(req, res, catalog.problem, { back: appBase(req) });
++ if (!offered(catalog.data, project.data).some((o) => o.id === capability)) {
++ return problemPage(req, res, { status: 422, code: 'services.not_grantable', detail: `${capability.slice(0, 80) || 'that'} is not a capability apps can be granted (only public ones, or partner ones in this project's allowance)` }, { title: 'Request a capability', back: appBase(req) });
++ }
++ const got = await net(req, res, (t) => network.projects.requestGrant(t, req.params.project, req.params.app, capability));
++ after(req, res, got, appBase(req), got.ok ? `${capability}: ${got.data.status}` : '');
++ });
++ for (const [action, decision] of [['approve', 'approved'], ['deny', 'denied'], ['revoke', 'revoked']]) {
++ r.post(`/:project/apps/:app/grants/:capability/${action}`, idParams, B('services.app.manage'), form, guard, async (req, res) => {
++ if (!CAP_RE.test(req.params.capability)) return problemPage(req, res, { status: 404, code: 'grant.not_found', detail: 'no such grant' }, { back: appBase(req) });
++ const got = await net(req, res, (t) => network.projects.decideGrant(t, req.params.project, req.params.app, req.params.capability, decision));
++ after(req, res, got, appBase(req), `${req.params.capability}: ${decision}`);
++ });
++ }
++
++ // ── Playgrounds ─────────────────────────────────────────
++ async function playgroundPage(req, res, { result = null, kind = null, values = {}, status = 200 } = {}) {
++ const loaded = await loadApp(req, res);
++ if (!loaded) return;
++ const { project, app } = loaded;
++ const base = `/projects/${project.id}/apps/${app.id}`;
++ const c = csrf(req);
++ const events = playground.precheck(app, 'events');
++ const media = playground.precheck(app, 'media');
++ const credFields = html`<fieldset><legend>The app's credential (used for this request only, never stored)</legend>
++<label><input type="radio" name="credential_type" value="client_secret" checked> client secret — Services asks Network for a 5-minute token with it</label>
++<label><input type="radio" name="credential_type" value="access_token"> access token you minted for this app</label>
++<input type="password" name="credential" value="" autocomplete="off" required aria-label="Credential"></fieldset>`;
++ const pre = (p) => (p.ok ? '' : html`<div class="notice ${p.stage === 'grant' ? 'warn' : 'bad'}"><strong>Cannot run:</strong> ${p.detail}${p.missing && p.grantable ? html` <a href="${base}#request">Request it</a>.` : ''}</div>`);
++ const runs = await playground.runsFor(app.id);
++ page(req, res, {
++ title: `Playground · ${app.name}`,
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name, href: base }, { label: 'Playground' }],
++ body: html`<h1>Playground</h1>
++<p>Calls here run <strong>as the app</strong>, with its own token, against the real services: the playground can do exactly what this app is granted and nothing more. Services' own credentials are never used. Sandbox apps only.</p>
++${result ? runResult(result) : ''}
++<h2>${playground.KINDS.events.label}</h2>
++<p class="muted small">Needs <code>events.app.publish</code> (audience <code>openvibe.events</code>). This app may publish types starting with <code>app.${playground.projectKey(project.id)}.</code> with source <code>${playground.appSource(app.id)}</code>; sandbox events reach sandbox subscriptions only. Endpoint: <code>${config.playground.eventsUrl}</code>.</p>
++${pre(events)}${events.ok ? html`<form method="post" action="${base}/playground/events" class="stack">${csrfField(c)}
++<label>Event type <input name="event_type" required value="${values.event_type || `app.${playground.projectKey(project.id)}.test.ping`}"></label>
++<label>Subject type <input name="subject_type" value="${values.subject_type || 'test'}"></label>
++<label>Subject id <input name="subject_id" value="${values.subject_id || 'test-1'}"></label>
++<label>Payload (JSON object) <textarea name="payload" rows="4" spellcheck="false">${values.payload || '{}'}</textarea></label>
++${credFields}<button type="submit">Publish test event</button></form>` : ''}
++<h2>${playground.KINDS.media.label}</h2>
++<p class="muted small">Needs <code>media.object.upload</code> (audience <code>openvibe.media</code>) for the project's namespace <code>${project.id}</code>. Endpoint: <code>${config.playground.mediaUrl}</code>. At most ${config.playground.maxUploadBytes} bytes.</p>
++${pre(media)}${media.ok ? html`<form method="post" action="${base}/playground/media" enctype="multipart/form-data" class="stack">${csrfField(c)}
++<label>File <input type="file" name="file" required></label>${credFields}<button type="submit">Upload</button></form>` : ''}
++<h2>Recent runs</h2>
++${table(['When', 'Playground', 'Credential', 'Outcome', 'Stage', 'HTTP', 'Code', 'Detail', 'Result'], runs.map((x) => [
++ time(x.at), x.kind, x.credential, badge(x.outcome, x.outcome === 'ok' ? 'ok' : (x.outcome === 'failed' ? 'bad' : 'warn')), x.stage, x.http_status || '', x.code ? code(x.code) : '', x.detail, x.ref ? code(x.ref) : '',
++ ]), { empty: 'No runs yet.' })}`,
++ }, status);
++ }
++
++ function runResult(r) {
++ return html`<div class="result ${r.outcome === 'ok' ? 'ok' : 'bad'}" role="status"><p><strong>${r.outcome === 'ok' ? 'Done.' : (r.outcome === 'refused' ? 'Refused.' : 'Failed.')}</strong> ${r.detail}</p>
++<p class="muted small">stage ${r.stage}${r.http_status ? ` · HTTP ${r.http_status}` : ''}${r.code ? html` · <code>${r.code}</code>` : ''}${r.runId ? html` · run <code>${r.runId}</code>` : ''}</p>
++${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>` : ''}</div>`;
++ }
++
++ const statusFor = (out) => (out.outcome === 'ok' ? 200 : (out.stage === 'grant' || out.stage === 'app' ? 403 : (out.stage === 'input' ? 422 : (out.stage === 'rate' ? 429 : (out.stage === 'token' ? (out.http_status && out.http_status >= 400 ? out.http_status : 401) : 502)))));
++
++ r.get('/:project/apps/:app/playground', idParams, async (req, res) => await playgroundPage(req, res));
++
++ r.post('/:project/apps/:app/playground/events', idParams, B('services.playground.run'), form, guard, async (req, res) => {
++ const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
++ if (!got.ok) return problemPage(req, res, got.problem, { back: appBase(req) });
++ const b = req.body || {};
++ const out = await playground.run({
++ actor: `user:${req.viewer.subject}`, app: got.data, kind: 'events',
++ credential: { type: b.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: typeof b.credential === 'string' ? b.credential.trim() : '' },
++ input: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },
++ });
++ // The credential is NOT passed back: the form comes back with it empty.
++ return await playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });
++ });
++
++ r.post('/:project/apps/:app/playground/media', idParams, B('services.playground.run'), async (req, res) => {
++ if (!sameOrigin(config, req)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
++ let parsed;
++ try { parsed = await readMultipart(req, config.playground.maxUploadBytes); } catch (err) {
++ return page(req, res, { title: 'Upload', body: problemBox({ status: 413, code: 'playground.too_large', detail: err.message }) }, 413);
++ }
++ if (!checkCsrf(config, req.viewer, parsed.fields.csrf)) return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1><p>Reload the page and try again.</p>` }, 403);
++ const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
++ if (!got.ok) return problemPage(req, res, got.problem, { back: appBase(req) });
++ const out = await playground.run({
++ actor: `user:${req.viewer.subject}`, app: got.data, kind: 'media',
++ credential: { type: parsed.fields.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: String(parsed.fields.credential || '').trim() },
++ file: parsed.file,
++ });
++ return await playgroundPage(req, res, { result: out, status: statusFor(out) });
++ });
++
++ // ── Releases (Services-owned) ──────────────────────────────
++ async function editorPage(req, res, { status = 200, kind, text, notes = '', validation = null, parseError = null, problem = null } = {}) {
++ const loaded = await loadApp(req, res);
++ if (!loaded) return;
++ const { project, app } = loaded;
++ const k = kind === 'mod' ? 'mod' : 'app';
++ const base = `/projects/${project.id}/apps/${app.id}`;
++ const starter = JSON.stringify(manifestsLib.template(k, { appId: app.id, projectId: project.id, environment: app.environment, name: app.name, subject: req.viewer.subject }), null, 2);
++ page(req, res, {
++ title: `New release · ${app.name}`,
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name, href: base }, { label: 'New release' }],
++ body: html`<h1>New release</h1>
++<p>A release is Services' record of a version of this app (or of a mod it publishes): a validated manifest, compatibility ranges and a status. Versions are immutable. Publishing announces <code>services.app.published</code>; a manifest's capabilities are only a request — grants in Network stay the authority.</p>
++<p class="muted small">Start from: <a href="${base}/releases/new?kind=app">app manifest</a> · <a href="${base}/releases/new?kind=mod">mod manifest</a> (<code>mods.mod-manifest@1</code>)</p>
++${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? notice(parseError, 'bad') : ''}${validation ? validationResult(validation) : ''}
++<form method="post" action="${base}/releases" class="stack">${csrfField(csrf(req))}
++<input type="hidden" name="kind" value="${k}">
++<label>${k === 'mod' ? 'Mod' : 'App'} manifest (JSON) <textarea name="manifest" rows="22" spellcheck="false">${text != null ? text : starter}</textarea></label>
++<label>Release notes (public) <textarea name="notes" rows="3" maxlength="2000">${notes}</textarea></label>
++<div class="buttons"><button type="submit" name="intent" value="validate">Validate only</button> <button type="submit" name="intent" value="create">Create draft</button></div></form>`,
++ }, status);
++ }
++
++ r.get('/:project/apps/:app/releases/new', idParams, async (req, res) => await editorPage(req, res, { kind: req.query.kind }));
++ r.post('/:project/apps/:app/releases', idParams, B('services.release.create'), form, guard, async (req, res) => {
++ const b = req.body || {};
++ const kind = b.kind === 'mod' ? 'mod' : 'app';
++ const p = manifestsLib.parse(b.manifest);
++ if (p.error) return await editorPage(req, res, { status: 422, kind, text: b.manifest || '', notes: b.notes, parseError: p.error });
++ const [project, app] = await Promise.all([
++ net(req, res, (t) => network.projects.get(t, req.params.project)),
++ net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app)),
++ ]);
++ if (!project.ok || !app.ok) return problemPage(req, res, (project.ok ? app : project).problem, { back: appBase(req) });
++ if (b.intent !== 'create') {
++ const v = manifestsLib.validate(kind, p.manifest, { app: app.data, viewerSubject: req.viewer.subject, eventTypes: docs.eventTypes });
++ return await editorPage(req, res, { status: v.valid ? 200 : 422, kind, text: b.manifest, notes: b.notes, validation: v });
++ }
++ try {
++ const out = await releases.createDraft({
++ actor: { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.data.role, staff: req.viewer.staff, traceparent: req.ov.traceparent },
++ app: app.data, kind, manifest: p.manifest, notes: b.notes, eventTypes: docs.eventTypes,
++ });
++ res.redirect(303, `/releases/${out.release.id}`);
++ } catch (err) {
++ if (!(err instanceof ReleaseError)) return unexpected(req, res, err);
++ return await editorPage(req, res, { status: err.status, kind, text: b.manifest, notes: b.notes, validation: err.validation || null, problem: err.validation ? null : { status: err.status, code: err.code, detail: err.detail } });
++ }
++ });
++
++ return r;
++}
++
++/**
++ * Release actions (/releases/:id/publish|deprecate|revoke): the actor's role comes from Network,
++ * asked with the actor's own token for the release's project and app.
++ */
++function createReleaseActionRoutes(ctx) {
++ const { config, network, sso, releases, log, actorLimits } = ctx;
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '16kb' });
++ // Per-actor limit (http/actor-limits.js) before the form is read; the API's release actions share it.
++ const manage = actorLimits.budget('services.release.manage');
++ const page = (req, res, o, status) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++
++ for (const action of ['publish', 'deprecate', 'revoke']) {
++ r.post(`/:id/${action}`, manage, form, async (req, res) => {
++ if (req.viewer.kind !== 'user' || !req.viewer.subject) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>` }, 401);
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1>` }, 403);
++ if (!REL_RE.test(req.params.id)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
++ const rel = await releases.get(req.params.id);
++ if (!rel) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
++ let project = null;
++ let app = null;
++ try {
++ [project, app] = await Promise.all([
++ sso.asViewer(req, res, (t) => network.projects.get(t, rel.project_id)),
++ sso.asViewer(req, res, (t) => network.projects.app(t, rel.project_id, rel.app_id)),
++ ]);
++ } catch (err) {
++ const p = network.problemOf(err);
++ // Services staff may revoke a release of a project they cannot see in Network.
++ if (!(action === 'revoke' && req.viewer.staff && p.status === 404)) {
++ log.warn(`[Services] network ${p.status} ${p.code}`);
++ return page(req, res, { title: 'Release', body: problemBox(p, { title: `OpenVibe.Network answered ${p.status}` }) }, p.status);
++ }
++ }
++ const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project ? project.role : null, staff: req.viewer.staff, traceparent: req.ov.traceparent };
++ try {
++ if (action === 'publish') await releases.publish({ actor, releaseId: rel.id, app });
++ else if (action === 'deprecate') await releases.deprecate({ actor, releaseId: rel.id, app, reason: req.body.reason, replacement: req.body.replacement || null });
++ else {
++ if (req.body.confirm !== '1') throw new ReleaseError(422, 'services.confirm', 'tick the box to confirm');
++ await releases.revoke({ actor, releaseId: rel.id, app, reason: req.body.reason });
++ }
++ res.redirect(303, `/releases/${rel.id}?done=${action === 'publish' ? 'published' : (action === 'deprecate' ? 'deprecated' : 'revoked')}`);
++ } catch (err) {
++ if (!(err instanceof ReleaseError)) return unexpected(req, res, err);
++ page(req, res, { title: 'Release', body: html`${problemBox({ status: err.status, code: err.code, detail: err.detail }, { title: 'Not done' })}<p><a href="/releases/${rel.id}">Back</a></p>` }, err.status);
++ }
++ });
++ }
++ return r;
++}
++
++/** An unexpected error in an async handler: logged without the request body, a plain 500 page. */
++function unexpected(req, res, err) {
++ const log = req.app.locals.ctx.log;
++ log.error('[Services]', err && err.message ? err.message.slice(0, 300) : err);
++ if (res.headersSent) return;
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++}
++
++/** multipart/form-data → { fields, file: { buffer, filename, mimeType } | null }; rejects past the limit. */
++function readMultipart(req, maxBytes) {
++ return new Promise((resolve, reject) => {
++ let bb;
++ try { bb = Busboy({ headers: req.headers, limits: { files: 1, fileSize: maxBytes, fields: 10, fieldSize: 16 * 1024 } }); } catch (err) { reject(new Error('expected multipart/form-data')); return; }
++ const fields = {};
++ let file = null;
++ let tooBig = false;
++ bb.on('field', (name, value) => { fields[name] = value; });
++ bb.on('file', (name, stream, info) => {
++ const chunks = [];
++ stream.on('data', (d) => chunks.push(d));
++ stream.on('limit', () => { tooBig = true; });
++ stream.on('end', () => { if (name === 'file') file = { buffer: Buffer.concat(chunks), filename: String(info.filename || 'file').slice(0, 200), mimeType: info.mimeType || 'application/octet-stream' }; });
++ });
++ bb.on('close', () => (tooBig ? reject(new Error(`playground uploads are at most ${maxBytes} bytes`)) : resolve({ fields, file })));
++ bb.on('error', () => reject(new Error('the upload could not be read')));
++ req.pipe(bb);
++ });
++}
++
++module.exports = { createPortalRoutes, createReleaseActionRoutes };
ddiff --git a/server/http/router.js b/server/http/router.js
nnew file mode 100644
iindex 0000000..088ba89
---- /dev/null
++++ b/server/http/router.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++/**
++ * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the
++ * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
++ */
++const express = require('express');
++
++function asyncRouter() {
++ const r = express.Router();
++ for (const method of ['get', 'post']) {
++ const orig = r[method].bind(r);
++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
++ try {
++ const out = h(req, res, next);
++ if (out && typeof out.catch === 'function') out.catch(next);
++ } catch (err) { next(err); }
++ })));
++ }
++ return r;
++}
++
++module.exports = { asyncRouter };
ddiff --git a/server/http/tools.js b/server/http/tools.js
nnew file mode 100644
iindex 0000000..0c52276
---- /dev/null
++++ b/server/http/tools.js
@@@ -0,0 +1,189 @@
++'use strict';
++
++/**
++ * Developer tools that need no project: the OAuth callback helper, the webhook tester and signed-
++ * event inspector, and the manifest validator. All usable without JavaScript; the webhook page adds
++ * an optional in-browser computation (public/js/webhook.js) so a secret need not leave the browser.
++ *
++ * Secrets typed here are used for one computation and dropped: never stored, logged or rendered
++ * back (forms come back with the secret field empty).
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const crypto = require('crypto');
++const rateLimit = require('express-rate-limit');
++const { html, raw, code, table, notice, badge, time } = require('../render/html');
++const { send } = require('../render/layout');
++const webhooks = require('../domain/webhooks');
++const manifests = require('../domain/manifests');
++const { pkcePair } = require('../auth/sso');
++
++function createToolRoutes(ctx) {
++ const { config, docs, actorLimits } = ctx;
++ // Per-actor limits (http/actor-limits.js) after the per-address limiter, before the form is read.
++ const webhookLimit = actorLimits.budget('services.webhook.tool');
++ const validateLimit = actorLimits.budget('services.manifest.validate');
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '300kb' });
++ const limiter = rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: true, legacyHeaders: false });
++ const eventTypes = docs.events.map((e) => e.type);
++
++ // ── OAuth callback helper ───────────────────────────────
++ r.get('/oauth', (req, res) => {
++ const clientId = typeof req.query.client_id === 'string' ? req.query.client_id.trim().slice(0, 40) : '';
++ const redirectUri = typeof req.query.redirect_uri === 'string' && req.query.redirect_uri ? req.query.redirect_uri.trim().slice(0, 500) : `${config.baseUrl}/oauth/test-callback`;
++ const scope = typeof req.query.scope === 'string' ? req.query.scope.trim().slice(0, 500) : '';
++ let built = null;
++ if (clientId) {
++ const { verifier, challenge } = pkcePair();
++ const state = crypto.randomBytes(12).toString('hex');
++ const q = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' });
++ if (scope) q.set('scope', scope);
++ built = { url: `${config.network.url}/oauth/authorize?${q}`, verifier, challenge, state };
++ }
++ send(res, 200, {
++ viewer: req.viewer, config, path: req.originalUrl, title: 'OAuth callback helper', index: !clientId,
++ crumbs: [{ label: 'OAuth' }],
++ body: html`<h1>Sign people in to your app (authorization code + PKCE)</h1>
++<ol class="steps">
++<li><strong>Register a redirect URI</strong> on your app (project → app → redirect URIs). It must match exactly. https is required; <code>http://localhost</code>, <code>127.0.0.1</code> and <code>[::1]</code> are allowed for sandbox apps only. To try the flow before your own callback exists, register <code>${config.baseUrl}/oauth/test-callback</code>: it shows what Network sent and never uses it.</li>
++<li><strong>Make a PKCE pair.</strong> A random <code>code_verifier</code> (43–128 characters) and <code>code_challenge = BASE64URL(SHA-256(code_verifier))</code>. Network requires <code>code_challenge_method=S256</code> for every app, public or confidential. The SDK does it: <code>createPkcePair()</code>, <code>startAuthorization()</code> in <a href="/docs/sdk/auth-browser"><code>openvibe-sdk/auth</code></a>.</li>
++<li><strong>Send the browser to</strong> <code>${config.network.url}/oauth/authorize</code> with <code>response_type=code</code>, <code>client_id</code> (your app id), <code>redirect_uri</code>, <code>state</code>, the challenge, and optionally <code>scope</code> (capability ids, which limit what the code can yield). <code>prompt=none</code> is refused for apps: a person always chooses to continue. A sandbox app can be authorized only by members of its project.</li>
++<li><strong>Network redirects back</strong> with <code>code</code> and your <code>state</code> (or <code>error</code>). Check the state. Services are single use and expire after 5 minutes; a failed PKCE check burns the code.</li>
++<li><strong>Exchange on your server</strong>: <code>POST ${config.network.url}/oauth/token</code> with <code>grant_type=authorization_code</code>, <code>client_id</code>, <code>code</code>, <code>redirect_uri</code>, <code>code_verifier</code>, <code>audience</code> (the service you will call, e.g. <code>openvibe.media</code>) and, for a confidential app, <code>client_secret</code>. The token lasts 5 minutes and carries <code>on_behalf_of</code> (the person) and <code>cap</code> (your approved grants for that audience). Apps get no refresh token.</li>
++</ol>
++<h2>Build a test authorization URL</h2>
++<form method="get" action="/oauth" class="stack">
++<label>Your app's client id <input name="client_id" value="${clientId}" placeholder="app_01…" pattern="app_[0-9A-HJKMNP-TV-Z]{26}" required></label>
++<label>Redirect URI <input name="redirect_uri" value="${redirectUri}" size="60"></label>
++<label>Scope (optional, capability ids separated by spaces) <input name="scope" value="${scope}" size="60"></label>
++<button type="submit">Build URL</button></form>
++${built ? html`<div class="result"><p>Open this URL to start the flow (it goes to OpenVibe.Network; Services is not involved in the exchange):</p>
++<p><a href="${built.url}" rel="noreferrer">${built.url}</a></p>
++<dl class="facts"><dt>state</dt><dd>${code(built.state)}</dd><dt>code_challenge</dt><dd>${code(built.challenge)}</dd>
++<dt>code_verifier</dt><dd>${code(built.verifier)} <span class="muted small">made just now for this test and not kept anywhere; you need it for the exchange</span></dd></dl></div>` : ''}`,
++ });
++ });
++
++ r.get('/oauth/test-callback', (req, res) => {
++ const q = (k) => (typeof req.query[k] === 'string' ? req.query[k].slice(0, 2000) : null);
++ const got = { code: q('code'), state: q('state'), error: q('error'), error_description: q('error_description') };
++ res.set('Cache-Control', 'no-store');
++ res.set('Referrer-Policy', 'no-referrer');
++ send(res, got.error ? 400 : 200, {
++ viewer: req.viewer, config, path: '/oauth/test-callback', title: 'OAuth test callback', noReferrer: true,
++ crumbs: [{ label: 'OAuth', href: '/oauth' }, { label: 'Test callback' }],
++ body: html`<h1>What Network sent back</h1>
++<p>Services shows this and does nothing else with it: it <strong>never exchanges the code</strong> and keeps no copy. Exchange it on your own server within 5 minutes, once.</p>
++${table(['Parameter', 'Value'], Object.entries(got).filter(([, v]) => v !== null).map(([k, v]) => [code(k), code(v)]), { empty: 'No code, state or error in this request. Network redirects here with ?code=…&state=… after a person continues.' })}
++${got.error ? notice(html`Network returned <code>${got.error}</code>${got.error_description ? html`: ${got.error_description}` : ''}. <code>access_denied</code> means the person declined; <code>interaction_required</code> means <code>prompt=none</code> was sent (apps must not).`, 'warn') : ''}
++${got.code ? html`<h2>Exchange it (on your server)</h2>
++<p>Send <code>client_secret</code> only for a confidential app, and only from a server — never from a browser.</p>
++<pre><code>curl -X POST ${config.network.url}/oauth/token \\
++ -d grant_type=authorization_code \\
++ -d client_id="$CLIENT_ID" \\
++ -d client_secret="$CLIENT_SECRET" \\
++ -d code=${got.code} \\
++ -d redirect_uri=${config.baseUrl}/oauth/test-callback \\
++ -d code_verifier="$CODE_VERIFIER" \\
++ -d audience=openvibe.media</code></pre>` : ''}`,
++ });
++ });
++
++ // ── Webhook tester and signed-event inspector ───────────
++ const webhookPage = (req, res, { status = 200, verify = null, sample = null, values = {} } = {}) => send(res, status, {
++ viewer: req.viewer, config, path: '/tools/webhooks', title: 'Webhook tester', index: true, scripts: ['js/webhook.js'],
++ crumbs: [{ label: 'Webhooks' }],
++ body: html`<h1>Webhook tester and signed-event inspector</h1>
++<p>OpenVibe.Events delivers each event as <code>POST</code> with the body <code>{"event": &lt;envelope&gt;, "seq": &lt;n&gt;}</code> and these headers, signed with your subscription secret:</p>
++<ul>
++<li><code>X-OpenVibe-Timestamp: &lt;unix seconds&gt;</code>, the time this attempt was sent. Every retry gets a new one.</li>
++<li><code>X-OpenVibe-Signature-V2: t=&lt;that timestamp&gt;,v2=&lt;hex HMAC-SHA256 of "&lt;t&gt;.&lt;raw body&gt;"&gt;</code> (v2).</li>
++</ul>
++<p>Verify against the <strong>raw bytes</strong>, before parsing, with a constant-time comparison. v2 covers the time as well as the body, so a captured delivery cannot be replayed later: refuse it when <code>t</code> is more than 300 seconds from your clock, in either direction, and reject a delivery whose v2 header is missing, does not verify or is stale. The v1 header (<code>X-OpenVibe-Signature</code>, an HMAC of the body alone, which verifies forever) was retired on 2026-09-28: Events no longer sends it. <code>parseDelivery(raw, headers, secret, { requireV2: true })</code> in <a href="/docs/sdk/events"><code>openvibe-sdk/events</code></a> does all of this; <code>verifyDeliveryV2()</code> checks v2 and the window. The tester below checks v2 the same way, says whether the timestamp is inside the window now, and decides as a receiver that requires v2 does.</p>
++<h2>Verify a delivery</h2>
++<form method="post" action="/tools/webhooks/verify" class="stack" id="verify-form">
++<label>Raw body <textarea name="body" rows="8" required spellcheck="false">${values.body || ''}</textarea></label>
++<label>X-OpenVibe-Signature-V2 <input name="signature_v2" value="${values.signature_v2 || ''}" size="80" spellcheck="false" autocomplete="off" placeholder="t=1790000000,v2=…"></label>
++<label>X-OpenVibe-Timestamp <span class="muted small">(optional; when given it must equal t=)</span> <input name="timestamp" value="${values.timestamp || ''}" size="14" spellcheck="false" autocomplete="off" inputmode="numeric"></label>
++<label>Subscription secret <input type="password" name="secret" value="" autocomplete="off" required></label>
++<p class="muted small">Without JavaScript the form is sent to Services, which computes the HMAC and forgets the secret. With JavaScript it is computed in your browser and nothing is sent.</p>
++<button type="submit">Verify</button></form>
++<div id="verify-result">${verify ? verifyResult(verify) : ''}</div>
++<h2>Generate a sample delivery</h2>
++<form method="post" action="/tools/webhooks/sample" class="stack">
++<label>Event type <select name="event_type">${eventTypes.map((t) => html`<option${t === values.event_type ? raw(' selected') : ''}>${t}</option>`)}</select></label>
++<label>Secret to sign with <input type="password" name="secret" value="" autocomplete="off" required></label>
++<button type="submit">Generate</button></form>
++${sample ? html`<div class="result"><p>A delivery exactly as Events would send it (event types from <code>openvibe-contracts v${docs.contractsVersion}</code>). The envelope ${sample.envelopeValid ? 'validates' : 'does NOT validate'} as events.event-envelope@1. The payload is empty: contracts define no payload schema for this event type yet, and Services does not invent one.</p>
++${table(['Header', 'Value'], Object.entries(sample.headers).map(([k, v]) => [code(k), code(v)]))}
++<p>Body (byte for byte):</p><pre><code>${sample.body}</code></pre>
++<p>Replay it against your own endpoint (Services never sends requests to your URLs). The v2 timestamp is the time you pressed Generate, so a receiver refuses the replay once it is more than ${webhooks.V2_WINDOW_SEC} seconds old: generate a fresh one, or use it to check that your endpoint does refuse a stale delivery.</p><pre><code>${webhooks.curlFor(sample.headers, sample.body)}</code></pre></div>` : ''}`,
++ });
++
++ function verifyResult(v) {
++ if (v.reason || !v.v2) return html`<div class="result bad" role="status"><p><strong>Cannot check.</strong> ${v.reason || ''}</p></div>`;
++ const { v2 } = v;
++ const age = v2.ageSec == null ? '' : v2.ageSec >= 0 ? `${v2.ageSec} s ago` : `${-v2.ageSec} s in the future`;
++ return html`<div class="result ${v.accepted ? 'ok' : 'bad'}" role="status"><p><strong>${v.accepted ? 'Accepted: v2 verifies and is inside the window.' : 'Refused: a receiver that requires v2 rejects this delivery.'}</strong>${v.accepted ? '' : html` ${v2.reason || ''}`}</p>
++<h3>v2 (X-OpenVibe-Signature-V2) ${badge(v2.valid ? 'valid' : v2.signatureValid ? 'stale' : 'NOT valid', v2.valid ? 'ok' : v2.signatureValid ? 'warn' : 'bad')}</h3>
++<dl class="facts">${v2.timestamp != null ? html`<dt>Timestamp</dt><dd>${code(String(v2.timestamp))} ${v2.timestamp < 8.64e9 ? time(new Date(v2.timestamp * 1000).toISOString()) : ''}, ${age} by this server's clock</dd>
++<dt>Window</dt><dd>${v2.withinWindow ? html`${badge('inside', 'ok')} within ±${v2.windowSec} s` : html`${badge('outside', 'bad')} more than ${v2.windowSec} s from now`}</dd>
++<dt>Signature</dt><dd>${v2.signatureValid ? 'matches this body and secret' : 'does not match this body and secret'}</dd>` : ''}
++${v2.timestampsAgree != null ? html`<dt>X-OpenVibe-Timestamp</dt><dd>${code(v2.statedTimestamp)} ${v2.timestampsAgree ? 'equals t=' : html`${badge('differs from t=', 'bad')}`}</dd>` : ''}
++<dt>Expected</dt><dd>${code(v2.expected || '(needs a t= value)')}</dd><dt>Given</dt><dd>${code(v2.given || '(none)')}</dd></dl>
++<h3>Body</h3>
++<dl class="facts"><dt>Size</dt><dd>${v.bodyBytes} bytes</dd>
++${v.parsed && v.parsed.event_type ? html`<dt>Event</dt><dd>${code(v.parsed.event_type)} ${code(v.parsed.event_id || '')} seq ${v.parsed.seq}; envelope ${v.parsed.envelopeValid ? 'validates' : html`does not validate: ${v.parsed.envelopeErrors.map((e) => `${e.path} ${e.message}`).join('; ')}`}</dd>` : ''}
++${v.parsed && v.parsed.note ? html`<dt>Shape</dt><dd>${v.parsed.note}</dd>` : ''}</dl></div>`;
++ }
++
++ r.get('/tools/webhooks', (req, res) => webhookPage(req, res));
++ r.post('/tools/webhooks/verify', limiter, webhookLimit, form, (req, res) => {
++ const b = req.body || {};
++ const v = webhooks.inspect({ rawBody: typeof b.body === 'string' ? b.body.replace(/\r\n/g, '\n') : '', signatureV2: b.signature_v2, timestamp: b.timestamp, secret: typeof b.secret === 'string' ? b.secret : '' });
++ webhookPage(req, res, { status: v.accepted ? 200 : 422, verify: v, values: { body: b.body, signature_v2: b.signature_v2, timestamp: b.timestamp } });
++ });
++ r.post('/tools/webhooks/sample', limiter, webhookLimit, form, (req, res) => {
++ const b = req.body || {};
++ const type = eventTypes.includes(b.event_type) ? b.event_type : null;
++ if (!type || !b.secret) return webhookPage(req, res, { status: 422, values: { event_type: b.event_type } });
++ const e = docs.events.find((x) => x.type === type);
++ webhookPage(req, res, { sample: webhooks.sample({ eventType: type, secret: String(b.secret), producer: e && e.producers[0] }), values: { event_type: type } });
++ });
++
++ // ── Manifest validator ──────────────────────────────────
++ const validatorPage = (req, res, { status = 200, kind = 'app', text = null, result = null, parseError = null } = {}) => send(res, status, {
++ viewer: req.viewer, config, path: '/manifests/validate', title: 'Manifest validator', index: true,
++ crumbs: [{ label: 'Manifests' }],
++ body: html`<h1>Manifest validator</h1>
++<p>Checks an app manifest or a mod manifest with <code>openvibe-contracts v${docs.contractsVersion}</code>: the JSON Schema first, then that every requested capability exists and can be granted to apps, that version ranges parse, and that consumed events are produced by someone. Nothing is stored. To publish a release, open your app's page.</p>
++<ul class="plain small muted"><li>app: <a href="/docs/contracts/services.app-manifest"><code>services.app-manifest@1</code></a></li><li>mod: <a href="/docs/contracts/mods.mod-manifest"><code>mods.mod-manifest@1</code></a></li></ul>
++<form method="post" action="/manifests/validate" class="stack">
++<label>Kind <select name="kind"><option value="app"${kind === 'app' ? raw(' selected') : ''}>app</option><option value="mod"${kind === 'mod' ? raw(' selected') : ''}>mod</option></select></label>
++<label>Manifest (JSON) <textarea name="manifest" rows="18" spellcheck="false">${text != null ? text : JSON.stringify(manifests.template(kind), null, 2)}</textarea></label>
++<button type="submit">Validate</button></form>
++${parseError ? notice(parseError, 'bad') : ''}
++${result ? validationResult(result) : ''}`,
++ });
++
++ r.get('/manifests/validate', (req, res) => validatorPage(req, res, { kind: req.query.kind === 'mod' ? 'mod' : 'app' }));
++ r.post('/manifests/validate', limiter, validateLimit, form, (req, res) => {
++ const b = req.body || {};
++ const kind = b.kind === 'mod' ? 'mod' : 'app';
++ const p = manifests.parse(b.manifest);
++ if (p.error) return validatorPage(req, res, { status: 422, kind, text: b.manifest || '', parseError: p.error });
++ const result = manifests.validate(kind, p.manifest, { eventTypes: docs.eventTypes });
++ validatorPage(req, res, { status: result.valid ? 200 : 422, kind, text: b.manifest, result });
++ });
++
++ return r;
++}
++
++function validationResult(v) {
++ return html`<div class="result ${v.valid ? 'ok' : 'bad'}" role="status"><p><strong>${v.valid ? 'Valid.' : `${v.errors.length} error${v.errors.length === 1 ? '' : 's'}.`}</strong>${v.schema ? html` Checked with <code>${v.schema.id}</code> ${v.schema.version}.` : ''}</p>
++${v.errors.length ? html`<h3>Errors</h3><ul>${v.errors.map((e) => html`<li><code>${e.path}</code> ${e.message}</li>`)}</ul>` : ''}
++${v.warnings.length ? html`<h3>Warnings</h3><ul>${v.warnings.map((e) => html`<li><code>${e.path}</code> ${e.message}</li>`)}</ul>` : ''}</div>`;
++}
++
++module.exports = { createToolRoutes, validationResult };
ddiff --git a/server/index.js b/server/index.js
iindex eab3e57..5bae711 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -9,55 +9,54 @@
* together: the migration run is serialised by an advisory lock. Nothing starts at module load — the
* Network key refresher and the outbox relay start here, and the signal handlers stop them in order.
*
-- * The authority registry is built here from the pinned openvibe-contracts service manifests
++ * The authority registry is built from the pinned openvibe-contracts service manifests
* (server/authorities/index.js) and logged, so the boot line says what this instance will fan out over.
* There is no polling loop: the index reads the authorities on each request (plan T13 step 6), and the
* rebuildable read model is step 7.
*/
const { loadConfig } = require('./config');
--const { openDb, migrate } = require('./db');
++const { openDb, migrate, createStore } = require('./db');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { createRegistry } = require('openvibe-shared/metrics');
++/**
++ * The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the HTTP drain runs, then the key refresher
++ * and the outbox relay stop and the store closes; past the deadline the process exits 0. Exported so a test can
++ * inject `exit` and `signals: false`.
++ */
++function createLifecycle({ server, ctx, exit, signals }) {
++ return gracefulStop({
++ name: 'Services', server, drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 0, exit, signals,
++ stop: [() => ctx.keys.client.stop(), () => ctx.outbox.stop()],
++ close: [() => ctx.store.close().catch(() => {})],
++ });
++}
++
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(`[Services] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);
++ if (m && m.held && m.held.length) console.warn(`[Services] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);
-- const app = createApp({ config, db, registry });
-- const { keys, outbox, authorities, tokens } = app.locals;
-- keys.start();
-- outbox.start();
++ const { app, ctx } = await createApp({ config, store: createStore(db), registry });
++ const { authorities, tokens, outbox, keys, docs } = ctx;
const server = app.listen(config.port, config.host, () => {
console.log(`[Services] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store})`);
-- console.log(`[Services] authorities ${authorities.size()}: ${authorities.ids().join(', ') || 'none'}; authority tokens ${tokens.enabled ? `client ${config.oauth.clientId}` : 'off (OV_OAUTH_CLIENT_SECRET unset: authorities are reported partial)'}; outbox ${outbox.enabled ? `→ ${config.events.url}` : 'idle (no event type declared)'}`);
++ console.log(`[Services] docs from openvibe-contracts v${docs.contractsVersion} and openvibe-sdk v${docs.sdkVersion}; authorities ${authorities.size()}: ${authorities.ids().join(', ') || 'none'} (+ services in process); authority tokens ${tokens.enabled ? `client ${config.oauth.clientId}` : 'off (OV_OAUTH_CLIENT_SECRET unset: authorities are reported partial)'}; events relay ${outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in services_events_outbox)'}`);
});
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
server.requestTimeout = 40_000;
++ outbox.start();
++ // Keep the JWKS cache fresh in the background (openvibe-sdk/auth): one client per URL, the last
++ // good keys through outages, exponential backoff, unknown-kid floods throttled, an unref'd timer.
++ keys.client.start();
-- // 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, then the close steps and
-- // exits. Services' manifest declares no lifecycle.shutdown deadline, so the kit's default fits.
-- const { stop: shutdown } = gracefulStop({
-- name: 'Services',
-- server,
-- drainMs: 4000,
-- deadlineMs: 5000,
-- deadlineExitCode: 0,
-- stop: [
-- () => keys.stop(),
-- () => outbox.stop(),
-- ],
-- close: [
-- () => db.close().catch(() => {}),
-- ],
-- });
-- return { app, server, shutdown };
++ const { stop: shutdown } = createLifecycle({ server, ctx });
++ return { app, server, ctx, shutdown };
}
if (require.main === module) {
@@@ -67,4 +66,4 @@ if (require.main === module) {
});
}
--module.exports = { main };
++module.exports = { main, createLifecycle };
ddiff --git a/server/network.js b/server/network.js
iindex d4f6a94..8a47339 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -1,13 +1,10 @@
'use strict';
/**
-- * Services' side of OpenVibe.Network: the signing key every incoming token is verified against, and the
-- * project-membership question the person-facing index path asks.
++ * Services' side of OpenVibe.Network for the resource index: offline verification of a person's token and the
++ * project-membership question the person-facing index path asks. The signing key comes from the key store
++ * (server/auth/keys.js: the SDK's JWKS client), the same one sign-in and the release API use.
*
-- * createKeyProvider(config) Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins
-- * it, else the JWKS (GET {internalUrl}/api/.well-known/jwks) refreshed at boot
-- * and every six hours; a JWKS outage keeps the last key and never stops the
-- * process.
* createUserAuth(config, keys) offline verification of a person's Network user token (openvibe-sdk/auth:
* RS256, issuer, expiry; a realtime/stream ticket is never a session).
* projectMember(config, token, project) does the person this token belongs to own or belong to
@@@ -16,40 +13,8 @@
* Services never holds a project's membership itself (ADR-048: Network owns
* projects; Services only shows and calls it).
*/
--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(`[Services] 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). Returns the claims, or null.
@@@ -57,7 +22,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
function createUserAuth(config, keys) {
async function verify(token) {
if (!token) return null;
-- const publicKey = keys.get();
++ const publicKey = typeof keys.pemForToken === 'function' ? (await keys.pemForToken(token)) || keys.get() : keys.get();
if (!publicKey) return null;
try {
const claims = await verifyUserToken(token, { publicKey, issuer: config.network.issuer });
@@@ -96,4 +61,4 @@ function createMembership(config, { fetchImpl = globalThis.fetch } = {}) {
return { projectMember };
}
--module.exports = { createKeyProvider, createUserAuth, createMembership };
++module.exports = { createUserAuth, createMembership };
ddiff --git a/server/observability.js b/server/observability.js
iindex 7fdc059..84287b9 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -1,29 +1,84 @@
'use strict';
--
/**
-- * Truthful readiness (GET /api/ready) and the Services gauges (GET /metrics, loopback only).
++ * Truthful readiness (GET /api/ready, openvibe-shared/ready) and the Services gauges (GET /metrics, loopback only).
*
-- * db required a real round trip through the pool (db.ready()) — migrations/ runs at boot
-- * network_jwks required the Network signing key has loaded; without it no token can be verified and
-- * every /api/v1 route answers 503 (server/auth.js), so the service cannot
-- * serve its one capability
++ * db required a real query on Services' PostgreSQL store: every Services table answers
++ * docs required the reference was generated at boot from the pinned packages
++ * jwks optional openvibe-sdk/auth's JWKS cache has the Network signing key; without
++ * it nobody can sign in and app tokens cannot be verified (docs and
++ * tools still serve). The SDK keeps one client per URL and reports
++ * readiness, staleness, failures and the next try for us.
++ * network optional the Network answers (its /.well-known/openvibe descriptor); without
++ * it the portal shows Network's failure on every project page
++ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in and the events relay need it)
++ * events_relay optional the outbox relay is configured and has no rejected rows
*
* The authorities are deliberately NOT readiness checks: an authority, or Network, being down degrades
* the index and never stops it (ADR-046 section 6). A body that could not be read is a partial page
* (common.resource-list-result@1's `partial` array), not a red readiness — /api/v1/authorities lists the
-- * registry as it stands.
++ * registry as it stands, and the details below count it.
*
* Gauges: the authorities the registry reads. Counts only, never a token, never a resource.
*/
++const { jwksStatus } = require('openvibe-sdk/auth');
const { createReadiness } = require('openvibe-shared/ready');
++const { TABLES } = require('./db');
--function createServicesReadiness({ db, keys, authorities, release = null }) {
++function createServicesReadiness({ store, network, outbox, docs, config, authorities, valkey = null, release = null }) {
++ const { db } = store;
return createReadiness({
service: 'services',
release,
checks: [
-- { name: 'db', required: true, check: () => db.ready() },
-- { name: 'network_jwks', required: true, check: () => (keys.get() ? true : 'Network signing key not loaded yet: no token can be verified') },
++ {
++ name: 'db', required: true,
++ // A real round trip that names the store (postgresql / pglite), and the tables present.
++ check: async () => {
++ const r = await db.ready();
++ if (!r.ok) return r.error;
++ const names = new Set((await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name));
++ const missing = TABLES.filter((t) => !names.has(t));
++ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };
++ },
++ },
++ {
++ name: 'docs', required: true,
++ check: () => (docs && docs.contracts.length && docs.capabilities.length
++ ? { ok: true, detail: { contracts: docs.contractsVersion, sdk: docs.sdkVersion } }
++ : 'reference docs were not generated'),
++ },
++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
++ {
++ name: 'network_jwks', required: false,
++ // Public: counts and times only. Never the internal JWKS URL and never the SDK's
++ // lastError (it names both) — the SDK logs the real error server-side.
++ check: () => {
++ const statuses = jwksStatus();
++ if (!statuses.length) return 'no JWKS clients (misconfigured)';
++ const first = statuses[0];
++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and token checks are unavailable';
++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
++ },
++ },
++ {
++ name: 'network', required: false, cacheMs: 30_000,
++ check: async () => {
++ try { await network.registry.descriptor(); return true; } catch (err) { return `OpenVibe.Network did not answer: ${network.problemOf(err).code}`; }
++ },
++ },
++ {
++ name: 'oauth_client', required: false,
++ check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete'),
++ },
++ {
++ name: 'events_relay', required: false,
++ check: async () => {
++ const s = await outbox.status();
++ if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
++ if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
++ return { ok: true, detail: { pending: s.pending } };
++ },
++ },
],
details: async () => ({ authorities: { count: authorities.size(), ids: authorities.ids() } }),
});
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..036455d
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -0,0 +1,173 @@
++'use strict';
++/**
++ * Services' own resources as an authority (ADR-048 section 3): the pages server/registry/self-authority.js
++ * hands the merged index in process, in the shape every other authority answers over HTTP.
++ *
++ * pageOf(db, { project, kind, cursor, limit }) → { status, body: common.resource-list-result@1 }
++ * oneOf(db, ovrn) → { status, body: common.resource-summary@1 }
++ *
++ * It pages the resources OpenVibe.Services owns — its validated app and mod manifests (services.manifest, mfs_)
++ * and its releases (services.release, rel_) — as common.resource-summary@1. Trust tiers are per-app metadata and
++ * playground runs are audit records, neither a resource; Services hosts no repositories, so no services.repo
++ * kind (ADR-048's catalog).
++ *
++ * Tenancy: `?project=prj_…` is the caller's tenancy boundary. With it, only that project's resources
++ * answer; a resource of another project is never returned. Without it the first-party caller (the
++ * capability is first-party) sees every Services-owned resource, which is what an authority-wide fan-out
++ * needs.
++ *
++ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one
++ * formatter, so it is present exactly when the resource is a nameable resource. Both kinds here are
++ * nameable (ADR-048: services.manifest is mfs_, services.release is rel_): a manifest is named
++ * ovrn:services:<prj_…>:manifest/mfs_…, a release ovrn:services:<prj_…>:release/rel_…. That is also what
++ * oneOf can read: only a resource whose computed ovrn equals the one asked for answers.
++ *
++ * Field mapping (`only fields the schema allows`; common.resource-summary@1):
++ * services.manifest — name: the name in the stored manifest body (both manifest schemas require `name`;
++ * the row is written only after it validated), state: 'valid' (a manifest row is immutable — it is
++ * written once, after validation, and has no lifecycle of its own). owner: created_by when it is a
++ * user:usr_… subject; an app:app_… actor has none.
++ * services.release — name: releases.name, state: releases.status (draft|published|deprecated|revoked).
++ * owner: created_by likewise.
++ */
++const contracts = require('openvibe-contracts');
++
++const SERVICE = 'services';
++const RESOURCE_READ = 'services.resource.read';
++const MANIFEST_KIND = 'services.manifest';
++const RELEASE_KIND = 'services.release';
++const KINDS = [MANIFEST_KIND, RELEASE_KIND];
++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 1000;
++
++/** The stored subject (user:usr_… / app:app_…) as an identity.subject-ref, or null. Only a person owns. */
++function userOwner(subject) {
++ const ref = contracts.ids.parseSubject(subject);
++ return ref && ref.type === 'user' ? ref : null;
++}
++
++/** A manifest body's own `name` (both schemas require it), or null when the row holds none. */
++function manifestName(body) {
++ try { const name = JSON.parse(body).name; return typeof name === 'string' && name ? name : null; } catch { return null; }
++}
++
++/** common.resource-summary@1 for a manifests row. A manifest's project is its tenancy boundary. */
++function manifestSummary(m) {
++ const owner = userOwner(m.created_by);
++ const name = manifestName(m.body);
++ return {
++ id: m.id, kind: MANIFEST_KIND, service: SERVICE, project_id: m.project_id,
++ ...(owner ? { owner } : {}),
++ ...(name ? { name } : {}),
++ state: 'valid', created_at: m.created_at,
++ };
++}
++
++/** common.resource-summary@1 for a releases row; its state is the release's own status. */
++function releaseSummary(r) {
++ const owner = userOwner(r.created_by);
++ return {
++ id: r.id, kind: RELEASE_KIND, service: SERVICE, project_id: r.project_id,
++ ...(owner ? { owner } : {}),
++ name: r.name, state: r.status, created_at: r.created_at,
++ };
++}
++
++/** The summary's ovrn: both kinds are nameable, so contracts.resources.nameOf always composes one. */
++function ovrnOf(summary) {
++ return contracts.resources.nameOf(summary);
++}
++
++/** The summary with its ovrn attached when it has one. */
++function named(summary) {
++ const ovrn = ovrnOf(summary);
++ return ovrn ? { ...summary, ovrn } : summary;
++}
++
++/** A stable (kind, id) ordering, so a cursor can be a position in it. */
++const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
++
++/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */
++// Scope: services.resource.read is first-party (resourceConstraints none), so its holder sees every project and
++// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
++// principal's grants here instead of trusting the query.
++async function collect(db, { project = null, kind = null } = {}) {
++ const out = [];
++ if (!kind || kind === MANIFEST_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT id, project_id, body, created_by, created_at FROM manifests WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT id, project_id, body, created_by, created_at FROM manifests ORDER BY id').all();
++ for (const m of rows) out.push(named(manifestSummary(m)));
++ }
++ if (!kind || kind === RELEASE_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT id, project_id, name, status, created_by, created_at FROM releases WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT id, project_id, name, status, created_by, created_at FROM releases ORDER BY id').all();
++ for (const r of rows) out.push(named(releaseSummary(r)));
++ }
++ return out.sort(order);
++}
++
++/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */
++const encodeCursor = (s) => Buffer.from(JSON.stringify([s.kind, s.id])).toString('base64url');
++function decodeCursor(raw) {
++ let v;
++ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
++ return Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;
++}
++const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);
++
++/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */
++function filtersOf(query) {
++ const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
++ if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
++ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
++ let limit = DEFAULT_LIMIT;
++ if (typeof query.limit === 'string' && query.limit !== '') {
++ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
++ limit = Number(query.limit);
++ }
++ let cursor = null;
++ if (typeof query.cursor === 'string' && query.cursor !== '') {
++ cursor = decodeCursor(query.cursor);
++ if (!cursor) return { error: 'cursor is not one this index issued' };
++ }
++ return { project, kind, limit, cursor };
++}
++
++/**
++ * One common.resource-list-result@1 page of Services' own resources: the filtered, sorted summaries from the
++ * cursor, then `limit` of them. → { status, body } (400 resources.bad_query for a query it cannot honour).
++ */
++async function pageOf(db, query = {}) {
++ const f = filtersOf(query);
++ if (f.error) return { status: 400, body: { code: 'resources.bad_query', detail: f.error } };
++ const all = await collect(db, f);
++ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;
++ const resources = rest.slice(0, f.limit);
++ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;
++ return { status: 200, body: { resources, next_cursor } };
++}
++
++/** The summary whose computed ovrn is exactly `name`. → { status, body } (404 resources.not_found otherwise). */
++async function oneOf(db, name) {
++ const parsed = contracts.resources.parse(String(name));
++ let summary = null;
++ if (parsed && parsed.service === SERVICE) {
++ if (parsed.type === 'manifest') {
++ const row = await db.prepare('SELECT id, project_id, body, created_by, created_at FROM manifests WHERE id = ?').get(parsed.id);
++ if (row) summary = named(manifestSummary(row));
++ } else if (parsed.type === 'release') {
++ const row = await db.prepare('SELECT id, project_id, name, status, created_by, created_at FROM releases WHERE id = ?').get(parsed.id);
++ if (row) summary = named(releaseSummary(row));
++ }
++ }
++ if (!summary || summary.ovrn !== name) return { status: 404, body: { code: 'resources.not_found', detail: `no resource named ${name}` } };
++ return { status: 200, body: summary };
++}
++
++module.exports = {
++ pageOf, oneOf, SERVICE, RESOURCE_READ, KINDS, MANIFEST_KIND, RELEASE_KIND, DEFAULT_LIMIT, MAX_LIMIT,
++ manifestSummary, releaseSummary, ovrnOf, collect, encodeCursor,
++};
ddiff --git a/server/registry/self-authority.js b/server/registry/self-authority.js
nnew file mode 100644
iindex 0000000..665bae5
---- /dev/null
++++ b/server/registry/self-authority.js
@@@ -0,0 +1,40 @@
++'use strict';
++/**
++ * Services as the authority of its own resources (manifests and releases: ADR-048's services.manifest and
++ * services.release), read by the merged index IN PROCESS. The fan-out's fetch for this origin never leaves the
++ * process and the one-resource read calls the same function; reading itself over HTTP would mint a token for its
++ * own audience and go out through nginx and back for nothing.
++ *
++ * It has the shape server/authorities/adapter.js gives every other authority: { authority, origin, fetchAs, get }.
++ */
++const own = require('./resource-index');
++
++const ORIGIN = 'http://services.in-process';
++
++function createSelfAuthority({ store }) {
++ const authority = Object.freeze({
++ id: own.SERVICE,
++ name: 'OpenVibe.Services',
++ internalOrigin: ORIGIN,
++ publicOrigin: 'https://openvibe.services',
++ audience: 'openvibe.services',
++ capability: own.RESOURCE_READ,
++ manifestVersion: null,
++ contractsRange: null,
++ status: 'in-process',
++ });
++ const json = ({ status, body }) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
++
++ /** The index's fetch for this origin: GET /api/v1/resources[?…] and GET /api/v1/resources/:ovrn. */
++ async function fetchAs(url) {
++ const u = new URL(url);
++ if (u.pathname === '/api/v1/resources') return json(await own.pageOf(store.db, Object.fromEntries(u.searchParams)));
++ const m = /^\/api\/v1\/resources\/([^/]+)$/.exec(u.pathname);
++ if (m) return json(await own.oneOf(store.db, decodeURIComponent(m[1])));
++ return json({ status: 404, body: { code: 'route.not_found' } });
++ }
++
++ return { authority, origin: ORIGIN, fetchAs, get: (ovrn) => own.oneOf(store.db, ovrn) };
++}
++
++module.exports = { createSelfAuthority, ORIGIN };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..c0f69d9
---- /dev/null
++++ b/server/render/html.js
@@@ -0,0 +1,45 @@
++'use strict';
++
++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */
++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
++const raw = (s) => new Raw(s);
++function html(strings, ...values) {
++ let out = strings[0];
++ values.forEach((v, i) => {
++ out += render(v) + strings[i + 1];
++ });
++ return raw(out);
++}
++function render(v) {
++ if (v == null || v === false) return '';
++ if (v instanceof Raw) return v.s;
++ if (Array.isArray(v)) return v.map(render).join('');
++ return esc(v);
++}
++
++const code = (s) => html`<code>${s}</code>`;
++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
++
++/** A <table> from headers and rows of cells (cells are already html or plain values). */
++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
++ if (!rows.length) return html`<p class="muted">${empty}</p>`;
++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
++}
++
++/** A problem as returned by Network, Events or Media — shown as it came, never softened. */
++function problemBox(p, { title = 'The request failed' } = {}) {
++ if (!p) return '';
++ return html`<div class="problem" role="alert"><strong>${title}</strong>
++<p>${p.detail || 'No detail was given.'}</p>
++<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;
++}
++
++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
++const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
++
++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..2f3eb27
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,130 @@
++'use strict';
++
++/**
++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript:
++ * - <head>: title, description, canonical, robots (portal pages are noindex), Open Graph/Twitter,
++ * JSON-LD (openvibe-shared/seo), plus Services' extras: the shared app icon, stylesheets, the boost marker
++ * - the OpenVibe Frame: theme-loader, web-runtime, navbar and footer scripts (the shell boots the
++ * navbar; the footer is initialised below), a <noscript> navigation bar and the server-rendered footer
++ * - Services' own script only where a page offers an optional in-browser convenience (webhook
++ * signature computed locally, copy buttons); every form works without it
++ */
++const crypto = require('crypto');
++const ovServe = require('openvibe-shared/serve');
++const fs = require('fs');
++const path = require('path');
++const appIcon = require('openvibe-shared/app-icon');
++const frame = require('openvibe-shared/frame');
++const shell = require('openvibe-shared/shell');
++const cache = require('openvibe-shared/cache-policy');
++const { html, raw, esc } = require('./html');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe.Services';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const NAV = [
++ { label: 'Projects', href: '/projects' },
++ { label: 'Docs', href: '/docs' },
++ { label: 'OAuth', href: '/oauth' },
++ { label: 'Webhooks', href: '/tools/webhooks' },
++ { label: 'Manifests', href: '/manifests/validate' },
++ { label: 'Policy', href: '/policy' },
++];
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in place only
++// between pages of the same release, and does a normal load across a deploy.
++let RELEASE = 'dev';
++function setRelease(id) { if (id) RELEASE = String(id); }
++
++/**
++ * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
++ */
++function renderPage(o) {
++ const viewer = o.viewer || { kind: 'anonymous' };
++ const signedIn = viewer.kind === 'user';
++ const pathNow = o.path || '/';
++ const loginNext = encodeURIComponent(pathNow);
++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
++ const nav = {
++ service: 'services',
++ apiBase: NETWORK_URL,
++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)
++ logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
++ notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
++ };
++ // This site's own account links live in the shared navbar's account menu (the page's account
++ // bar below is only for visitors without JavaScript).
++ if (signedIn) nav.menu = { before: [...(viewer.staff ? [{ label: 'Staff', href: '/staff', icon: 'fa-shield' }] : []), { label: 'Your projects', href: '/projects', icon: 'fa-folder' }] };
++ const footer = { service: 'services', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
++ const account = signedIn
++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong>${viewer.staff ? html` · <a href="/staff">Staff</a>` : ''} · <a href="/projects">Your projects</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
++ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a> to manage projects and apps`;
++ const crumbs = (o.crumbs || []).length
++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
++ : '';
++ const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
++ return shell.page({
++ name: SITE_NAME,
++ lang: 'en',
++ title: o.title || `${SITE_NAME}: the OpenVibe developer platform`,
++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
++ siteName: SITE_NAME,
++ description: o.description || 'Build with the whole OpenVibe network: projects, apps and scoped credentials, usage and releases, generated API, contract and SDK docs, webhook and OAuth tools.',
++ canonical,
++ robots: o.index ? 'index, follow' : 'noindex, nofollow',
++ jsonLd: (o.jsonLd || []).filter(Boolean),
++ home: '/',
++ navLinks: NAV,
++ navbar: nav,
++ footer,
++ head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
++${appIcon.headTags({ site: 'services' })}
++<link rel="stylesheet" href="${asset('css/services.css')}">
++${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
++${render(scripts)}
++<meta name="ov-boost" content="services@${esc(RELEASE)}">
++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
++ body: `<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
++<main id="main" class="page">
++${render(crumbs)}
++${render(o.body)}
++${o.path === '/' ? frame.shipped({ service: 'services', title: `Recently shipped on ${SITE_NAME}` }) : ''}
++</main>
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
++});
++</script>`,
++ });
++}
++
++function render(v) { return require('./html').render(v); }
++
++/** Send a page; portal pages are private and never cached. */
++function send(res, status, o) {
++ // A page rendered for a signed-in person names them in the account bar: never shared caches.
++ const personal = o.viewer && o.viewer.kind === 'user';
++ if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
++ res.set('Vary', 'Cookie');
++ res.status(status).type('html').send(renderPage(o));
++}
++
++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
nnew file mode 100644
iindex 0000000..076977b
---- /dev/null
++++ b/server/render/markdown.js
@@@ -0,0 +1,62 @@
++'use strict';
++
++/**
++ * A deliberately small Markdown renderer for the ADRs shipped inside openvibe-contracts (the policy
++ * pages render them verbatim, so the policy cannot drift from the decision record). Everything is
++ * escaped first; only headings, paragraphs, lists, fenced code, bold, inline code and links are
++ * recognised. Links are kept only when http(s) or relative.
++ */
++const { esc } = require('./html');
++
++function inline(s) {
++ let out = esc(s);
++ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
++ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
++ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, href) => {
++ const h = href.replace(/&amp;/g, '&');
++ if (/^https?:\/\//.test(h) || /^[#./a-zA-Z0-9]/.test(h) && !/^[a-z]+:/i.test(h)) return `<a href="${esc(h)}">${text}</a>`;
++ return text;
++ });
++ return out;
++}
++
++function markdown(src, { headingOffset = 1 } = {}) {
++ const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
++ const out = [];
++ let para = [];
++ let list = null; // { type: 'ul' | 'ol', items: [] }
++ let fence = null;
++ const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
++ const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };
++ for (const line of lines) {
++ if (fence) {
++ if (/^```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
++ continue;
++ }
++ if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }
++ const h = line.match(/^(#{1,6})\s+(.*)$/);
++ if (h) {
++ flushPara(); flushList();
++ const level = Math.min(6, h[1].length + headingOffset);
++ out.push(`<h${level}>${inline(h[2])}</h${level}>`);
++ continue;
++ }
++ const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);
++ if (li) {
++ flushPara();
++ const type = li[1] ? 'ol' : 'ul';
++ if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }
++ list.items.push(li[2]);
++ continue;
++ }
++ if (!line.trim()) { flushPara(); flushList(); continue; }
++ if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }
++ flushList();
++ para.push(line.trim());
++ }
++ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
++ flushPara(); flushList();
++ return out.join('\n');
++}
++
++module.exports = { markdown };
ddiff --git a/server/render/usage.js b/server/render/usage.js
nnew file mode 100644
iindex 0000000..b2c91e7
---- /dev/null
++++ b/server/render/usage.js
@@@ -0,0 +1,109 @@
++'use strict';
++
++/**
++ * A project's usage page (roadmap WS-N task 4): what OpenVibe.Network answers for
++ * GET /api/v1/projects/:project/usage (network.project-usage-result@1), next to the limits each
++ * enforcing service publishes. Server-rendered and complete without JavaScript: the filters are a GET
++ * form, headroom is a <meter>, every number is text.
++ */
++const { html, raw, table, code, time, problemBox } = require('./html');
++const { amount } = require('../domain/limits');
++
++const DAYS = [7, 30, 90];
++const ENVS = ['all', 'production', 'sandbox'];
++const SERVICE_NAMES = { tools: 'OpenVibe Tools', events: 'OpenVibe Events', media: 'OpenVibe Media', host: 'OpenVibe Host', ai: 'OpenVibe AI' };
++
++const num = (v) => html`<span class="num">${Number(v).toLocaleString('en-US')}</span>`;
++const cap = (id) => html`<a href="/docs/capabilities/${id}"><code>${id}</code></a>`;
++const rate = (errors, quantity) => (quantity > 0 ? `${+((errors / quantity) * 100).toFixed(1)}%` : '—');
++const per = (window) => (window === 'total' ? 'in total' : `a ${window}`);
++
++/** ?days=&env= as the page offers them (anything else falls back to 30 days, every environment). */
++function usageQuery(query = {}) {
++ const days = DAYS.includes(Number(query.days)) ? Number(query.days) : 30;
++ const env = ENVS.includes(String(query.env)) ? String(query.env) : 'all';
++ return { days, env };
++}
++
++function filters(projectId, { days, env }) {
++ return html`<form method="get" action="/projects/${projectId}/usage" class="inline-form" aria-label="Usage range">
++<label>Last <select name="days">${DAYS.map((d) => html`<option value="${d}"${d === days ? raw(' selected') : ''}>${d} days</option>`)}</select></label>
++<label>Environment <select name="env">${ENVS.map((e) => html`<option value="${e}"${e === env ? raw(' selected') : ''}>${e === 'all' ? 'both' : e}</option>`)}</select></label>
++<button type="submit">Show</button></form>`;
++}
++
++function quotaRows(quotas) {
++ return quotas.map((q) => {
++ const used = q.used == null
++ ? html`— <span class="muted small">${q.note || ''}</span>`
++ : html`${num(q.used)} ${q.unit}${q.window_start ? html` <span class="muted small">since ${time(q.window_start)}</span>` : ''}${q.note ? html` <span class="muted small">(${q.note})</span>` : ''}`;
++ const headroom = q.used == null ? '—' : html`<meter class="headroom" min="0" max="${Math.max(q.limit, 1)}" value="${Math.min(q.used, Math.max(q.limit, 1))}" low="${Math.floor(q.limit * 0.8)}" high="${Math.floor(q.limit * 0.95)}" optimum="0">${rate(q.used, q.limit)}</meter> ${num(q.remaining)} left${q.limit > 0 ? html` <span class="muted small">(${rate(q.used, q.limit)} used)</span>` : ''}`;
++ return [cap(q.capability), html`${num(q.limit)} ${q.unit} ${per(q.window)}`, used, headroom,
++ html`recorded limit — enforced by <code>${q.enforced_by || 'the owning service'}</code>`];
++ });
++}
++
++/** The limits of the services this project used, for the capabilities it used. */
++function serviceLimits(u, limitAnswers, env) {
++ const used = new Set(u.totals.map((t) => t.capability));
++ const services = [...new Set(u.totals.map((t) => t.service))].sort();
++ if (!services.length) return '';
++ const cols = env === 'all' ? ['sandbox', 'production'] : [env];
++ return html`<h2 id="limits">Service limits</h2>
++<p class="muted small">The defaults each service enforces for every project, as it reports them now (<a href="/docs/limits">all limits</a>). A rate limit is enforced as it happens; these hourly numbers cannot show a single busy minute.</p>
++${services.map((svc) => {
++ const a = limitAnswers.find((x) => x.src.service === svc);
++ const name = SERVICE_NAMES[svc] || svc;
++ if (!a) return html`<h3>${name}</h3><p class="muted small">${name} does not publish its limits yet.</p>`;
++ if (a.problem) return html`<h3>${name}</h3>${problemBox(a.problem, { title: `${name}'s limits could not be read` })}`;
++ const rows = a.body.limits.filter((l) => l.capability && used.has(l.capability));
++ return html`<h3>${name}</h3>${table(['Limit', 'Capability', ...cols.map((c) => c[0].toUpperCase() + c.slice(1)), 'Past it'],
++ rows.map((l) => [l.label, cap(l.capability), ...cols.map((c) => amount(l[c], l.unit)), l.exceeded ? code(l.exceeded) : '—']),
++ { empty: `${name} publishes no limit for the capabilities used here.` })}`;
++ })}`;
++}
++
++/**
++ * @param {object} o
++ * @param {object} o.project Network's project view
++ * @param {object} o.usage network.project-usage-result@1
++ * @param {Array} o.limits [{ src, body } | { src, problem }] from the limits reader
++ * @param {{ days: number, env: string }} o.query
++ */
++function usageBody({ project, usage: u, limits: limitAnswers, query }) {
++ const byService = [...u.totals].sort((a, b) => a.service.localeCompare(b.service) || a.capability.localeCompare(b.capability));
++ return html`<h1>Usage <small>${project.name}</small></h1>
++<p class="muted">${u.freshness}${u.last_recorded_at ? html` Last rollup received ${time(u.last_recorded_at)}.` : ''} Showing ${u.range.from} to ${u.range.to} (UTC).</p>
++<p class="muted small">Counts are what services report each hour. Cost is on your Billing page.</p>
++${filters(project.id, query)}
++
++<h2 id="summary">Summary</h2>
++${byService.length ? html`<ul class="cards">${byService.map((t) => html`<li><strong>${num(t.quantity)} ${t.unit}</strong>
++<span>${cap(t.capability)} · ${t.env}</span>
++<span>${t.errors ? html`${num(t.errors)} errors (${rate(t.errors, t.quantity)})` : 'no errors'}</span></li>`)}</ul>`
++ : html`<p class="muted">No usage in these days. Usage is counted for apps of this project, per environment, by the service that does the work.</p>`}
++
++<h2 id="quotas">Quotas</h2>
++${table(['Capability', 'Limit', 'Used this window', 'Headroom', 'Enforcement'], quotaRows(u.quotas),
++ { empty: 'No quotas are recorded for this project (staff set them). The service limits below apply to every project.' })}
++
++${serviceLimits(u, limitAnswers, query.env)}
++
++<h2 id="daily">By day</h2>
++${table(['Day (UTC)', 'Service', 'Capability', 'Detail', 'Environment', 'Used', 'Errors'], u.daily.map((d) => [
++ d.day, d.service, cap(d.capability), d.dimension ? code(d.dimension) : '—', d.env, html`${num(d.quantity)} ${d.unit}`, d.errors ? num(d.errors) : '0',
++ ]), { empty: 'No usage in these days.' })}
++
++<h2 id="errors">Errors</h2>
++<p>${num(u.errors.total)} ${u.errors.total === 1 ? 'error' : 'errors'} in these days.</p>
++${table(['Code', 'Service', 'Capability', 'Count'], u.errors.by_code.map((e) => [code(e.code), e.service, cap(e.capability), num(e.count)]), { empty: 'No errors.' })}
++<h3 id="recent">Recent failures</h3>
++<p class="muted small">The last failures the services sampled, newest first. A trace id is the <code>traceparent</code> trace of the request that failed (for a job, the request that submitted it; for a webhook, the event delivered): look it up in your own logs. A job or event id can be read with your app's token.</p>
++${table(['When', 'Environment', 'Capability', 'Code', 'Status', 'Trace id', 'Job or event'], u.errors.recent.map((e) => [
++ time(e.at), e.env, cap(e.capability), code(e.code), e.status == null ? '—' : String(e.status), e.trace_id ? code(e.trace_id) : '—', e.ref ? code(e.ref) : '—',
++ ]), { empty: 'No failures sampled in these days.' })}
++
++<p class="muted small">Counts come from the services that own each capability (OpenVibe Tools and OpenVibe Events today) as hourly rollups through OpenVibe Events; OpenVibe.Network adds them up per day. They say how much and what failed, never who did it.</p>`;
++}
++
++module.exports = { usageBody, usageQuery, DAYS, ENVS };
ddiff --git a/server/resources.js b/server/resources.js
iindex e2a47ad..5343129 100644
---- a/server/resources.js
++++ b/server/resources.js
@@@ -51,8 +51,13 @@ function decodeCursor(raw) {
return Array.isArray(v) && v.length === 2 && v[0] === CURSOR_V && typeof v[1] === 'string' ? v[1] : null;
}
--function createResourceIndexService({ config, authorities, tokens, fetchImpl = globalThis.fetch, log = console }) {
++/**
++ * opts.self: Services' own resources as an authority read in process (server/registry/self-authority.js), next to
++ * the authorities the registry reads over HTTP.
++ */
++function createResourceIndexService({ config, authorities, tokens, self = null, fetchImpl = globalThis.fetch, log = console }) {
const adapters = authorities.list().map((a) => createAuthorityAdapter(a, { tokens, fetchImpl, timeoutMs: config.index.timeoutMs }));
++ if (self) adapters.push(self);
const adapterById = new Map(adapters.map((a) => [a.authority.id, a]));
const idByOrigin = new Map(adapters.map((a) => [a.origin, a.authority.id]));
const indexes = new Map();
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..887ce66
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * Per-actor rate limits (server/http/actor-limits.js, roadmap WS-R task 4): past its limit one caller
++ * gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while
++ * another caller still passes; the window reopens on the clock. A person counts as themselves, an app
++ * token as its app, anyone else by address. Writes have their own, tighter numbers: a refused one
++ * never reaches Network. Health, ready, release.json and metrics are never limited; refusals are
++ * logged (no token) and counted.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { actor } = require('../server/http/actor-limits');
++
++(async () => {
++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
++ const t = await boot({ actorLimits: true, limitsNow: () => clock, env: { SERVICES_LIMITS_MINUTE: '3', SERVICES_LIMITS_HOUR: '100' } });
++ const rosa = t.network.addUser('rosa');
++ const sam = t.network.addUser('sam');
++ const projectId = await t.project(rosa, 'Limits');
++ const app = await t.app(rosa, projectId, { name: 'Counted' });
++ const other = await t.app(rosa, projectId, { name: 'Other' });
++
++ await check('a portal read: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/projects', { as: rosa })).status, 200);
++ const asked = t.network.requests.length;
++ const r = await t.get('/projects', { as: rosa });
++ assert.strictEqual(r.status, 429, r.text.slice(0, 300));
++ assert.strictEqual(r.headers.get('retry-after'), '45');
++ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')), r.headers.get('content-type'));
++ const body = r.json();
++ assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(body.detail.includes('services.portal.read'), body.detail);
++ assert.strictEqual(t.network.requests.length, asked, 'Network was not asked');
++ assert.strictEqual((await t.get('/projects', { as: sam })).status, 200, 'another person still passes');
++ });
++
++ await check('an API read: an app token counts as its app; signed-out callers by address', async () => {
++ const token = t.network.mintApp(app.id, 'openvibe.services', ['services.release.read']);
++ const path = `/api/v1/apps/${app.id}/trust`;
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get(path, { headers: { authorization: `Bearer ${token}` } })).status, 200);
++ const r = await t.get(path, { headers: { authorization: `Bearer ${token}` } });
++ assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
++ const theirs = t.network.mintApp(other.id, 'openvibe.services', ['services.release.read']);
++ assert.strictEqual((await t.get(path, { headers: { authorization: `Bearer ${theirs}` } })).status, 200, 'another app still passes');
++ const from = async (ip) => await t.get(path, { headers: { 'X-Forwarded-For': ip } });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await from('203.0.113.7')).status, 200);
++ assert.strictEqual((await from('203.0.113.7')).status, 429);
++ assert.strictEqual((await from('203.0.113.8')).status, 200, 'another address still passes');
++ });
++
++ await check('the next minute opens the window again', async () => {
++ clock += 45 * 1000;
++ assert.strictEqual((await t.get('/projects', { as: rosa })).status, 200);
++ });
++
++ await check('a write has its own number: 5 new projects a minute, the 6th refused before Network hears of it', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ for (let i = 0; i < 4; i++) await t.project(sam, `Sam ${i}`);
++ await t.project(sam, 'Sam 4');
++ const asked = t.network.requests.length;
++ const r = await t.get('/projects', { as: sam, form: { name: 'Sam 5' } });
++ assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ assert.ok(r.json().detail.includes('services.project.create'), r.json().detail);
++ assert.strictEqual(t.network.requests.length, asked, 'Network was not asked');
++ const mine = await t.get('/projects', { as: rosa, form: { name: 'Rosa 2' } });
++ assert.strictEqual(mine.status, 303, 'another person still creates');
++ });
++
++ await check('health, ready, release.json and metrics are never limited', async () => {
++ for (let i = 0; i < 6; i++) {
++ assert.strictEqual((await t.get('/api/health')).status, 200);
++ assert.notStrictEqual((await t.get('/api/ready')).status, 429);
++ assert.strictEqual((await t.get('/release.json')).status, 200);
++ assert.strictEqual((await t.get('/metrics')).status, 200);
++ }
++ });
++
++ await check('refusals are counted in services_rate_limited_total and logged without a token', async () => {
++ const m = (await t.get('/metrics')).text;
++ const counted = m.split('\n').filter((l) => l.includes('services_rate_limited_total')).join('\n');
++ assert.ok(/services_rate_limited_total\{limit="services.portal.read",window="minute"\} 1/.test(m), counted);
++ assert.ok(/services_rate_limited_total\{limit="services.read",window="minute"\} 2/.test(m), counted);
++ assert.ok(/services_rate_limited_total\{limit="services.project.create",window="minute"\} 1/.test(m), counted);
++ const logs = t.logs();
++ assert.ok(logs.includes(`[Limits] services.portal.read: user:${rosa.subject} refused`), 'one log line per refusal');
++ assert.ok(logs.includes(`[Limits] services.read: app:${app.id} refused`));
++ assert.ok(!/\[Limits\][^\n]*eyJ/.test(logs), 'a token in the log');
++ });
++
++ await check('who is counted', () => {
++ assert.strictEqual(actor({ principal: { sub: 'app:app_1' }, viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'app:app_1');
++ assert.strictEqual(actor({ viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');
++ assert.strictEqual(actor({ principal: { legacy: true }, viewer: { kind: 'anonymous' }, ip: '203.0.113.1' }), 'ip:203.0.113.1');
++ assert.strictEqual(actor({ viewer: { kind: 'anonymous' }, ip: '198.51.100.4' }), 'ip:198.51.100.4');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..071e0f2
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++/**
++ * The one cache rule Services serves assets with (openvibe-shared/cache-policy, plan T11): a URL
++ * whose ?v= is the asset's current hash is immutable for a year, because those bytes can never
++ * change under the same URL; a wrong or missing ?v= may change at any moment, so it gets the
++ * short public window with a day of stale-while-revalidate. The values are pinned here against
++ * the module, not re-stated: the site never picks its own numbers.
++ */
++const assert = require('assert');
++const cache = require('openvibe-shared/cache-policy');
++const { assetVersion } = require('../server/render/layout');
++const { boot, check, done } = require('./helpers/boot');
++
++const ASSET = 'css/services.css';
++const SHORT = 'public, max-age=300, stale-while-revalidate=86400';
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the current ?v=<assetVersion> is immutable for a year', async () => {
++ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ });
++
++ await check('a wrong-but-hex ?v= is never pinned', async () => {
++ const r = await t.get(`/${ASSET}?v=deadbeefdeadbeef`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), SHORT);
++ });
++
++ await check('no ?v= gets the short public window', async () => {
++ const r = await t.get(`/${ASSET}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), SHORT);
++ });
++
++ await check('those values are the module\'s, not Services\' own', () => {
++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: true }), 'public, max-age=31536000, immutable');
++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: false }), SHORT);
++ assert.strictEqual(cache.htmlHeaders({ private: true }), 'private, no-store');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/auth-jwks.test.js b/test/auth-jwks.test.js
nnew file mode 100644
iindex 0000000..11285f9
---- /dev/null
++++ b/test/auth-jwks.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++/**
++ * The Network signing key is fetched and verified through openvibe-sdk/auth (plan T0/T1). The
++ * SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
++ * rotation honoured at once, unknown-kid floods throttled. Services is a thin shim around it: the
++ * boot reads the key and verifies a session/app token against the URL,
++ * a JWKS outage with cached keys still verifies,
++ * readiness (network_jwks) shows the cache state.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++function jwksServer({ doc = null, fail = null } = {}) {
++ const seen = [];
++ const srv = http.createServer((req, res) => {
++ seen.push(req.url);
++ if (fail) { res.writeHead(503); return res.end('down'); }
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(doc));
++ });
++ return new Promise((resolve) => {
++ srv.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${srv.address().port}`,
++ close: () => new Promise((r) => srv.close(r)),
++ seen,
++ setDoc: (d) => { doc = d; },
++ setFail: (f) => { fail = f; },
++ }));
++ });
++}
++
++function signJwt(claims, privateKey, { kid = null } = {}) {
++ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };
++ const input = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++
++function pem(k) { return k.export({ type: 'spki', format: 'pem' }); }
++function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid }; }
++
++(async () => {
++ await check('a token signed by a key from a stub JWKS verifies', async () => {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
++ const { createKeyStore, jwksUrl } = require('../server/auth/keys');
++ const config = { network: { internalUrl: jwks.url, url: jwks.url, issuer: 'https://openvibe.network' } };
++ const keys = createKeyStore({ config, log: console });
++ const url = jwksUrl(config);
++ await keys.ensure();
++ const now = Math.floor(Date.now() / 1000);
++ const token = signJwt({ iss: config.network.issuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
++ const v = await keys.verifyUser(token, { issuer: config.network.issuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, true, `verify should succeed: ${v.reason || ''}`);
++ assert.strictEqual(v.claims.username, 'demo');
++ assert.ok(keys.get(), 'the cached PEM is exposed to the contracts guard');
++ assert.deepStrictEqual(jwks.seen, ['/api/.well-known/jwks']);
++ await jwks.close();
++ });
++
++ // A rotation publishes two keys: the /api/v1 guard must verify against the one the token names, not the first.
++ await check('service tokens get the key their kid names (a rotation keeps two keys)', async () => {
++ const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(a.publicKey, 'old'), jwk(b.publicKey, 'new')] } });
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { network: { internalUrl: jwks.url, url: jwks.url, issuer: 'https://openvibe.network' } };
++ const keys = createKeyStore({ config, log: { warn() {}, info() {} } });
++ const now = Math.floor(Date.now() / 1000);
++ const claims = { iss: config.network.issuer, sub: 'app:x', aud: ['openvibe.services'], iat: now, exp: now + 300 };
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, b.privateKey, { kid: 'new' })), pem(b.publicKey), 'the new key for a token signed with it');
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey, { kid: 'old' })), pem(a.publicKey));
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey)), pem(a.publicKey), 'no kid: the first key');
++ assert.strictEqual(await keys.pemForToken('garbage'), pem(a.publicKey), 'an undecodable header falls back to the first key (the guard refuses the token)');
++ await jwks.close();
++ });
++
++ await check('with keys cached, a JWKS outage still verifies (the last good keys)', async () => {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { network: { internalUrl: jwks.url, url: jwks.url, issuer: 'https://openvibe.network' } };
++ const keys = createKeyStore({ config, log: console });
++ await keys.ensure();
++ const before = jwks.seen.length;
++ jwks.setFail(true); // Network goes away
++ const now = Math.floor(Date.now() / 1000);
++ const token = signJwt({ iss: config.network.issuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
++ const v = await keys.verifyUser(token, { issuer: config.network.issuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, true, `outage should not break verification: ${v.reason || ''}`);
++ assert.strictEqual(jwks.seen.length, before, 'no new fetch during the outage');
++ await jwks.close();
++ });
++
++ await check('readiness (network_jwks) shows the SDK JWKS state', async () => {
++ const t = await boot();
++ const r = await t.get('/api/ready');
++ const b = r.json();
++ assert.strictEqual(b.checks.network_jwks.status, 'ok', `readiness JWKS not ok: ${JSON.stringify(b.checks.network_jwks)}`);
++ assert.ok(b.checks.network_jwks.detail && b.checks.network_jwks.detail.keys >= 1, `detail.keys should be >= 1: ${JSON.stringify(b.checks.network_jwks.detail)}`);
++ assert.strictEqual(b.checks.network_jwks.detail.failures, 0);
++ await t.close();
++ });
++
++ await check('an unreachable JWKS never names the URL or the fetch error (503, /api/ready)', async () => {
++ // Port 9 (discard) refuses instantly: the SDK's message is "JWKS http://127.0.0.1:9/…: connect ECONNREFUSED".
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { network: { internalUrl: 'http://127.0.0.1:9', url: 'http://127.0.0.1:9', issuer: 'https://openvibe.network' } };
++ const keys = createKeyStore({ config, log: { warn() {}, log() {}, info() {}, error() {} } });
++ await keys.ensure();
++ const now = Math.floor(Date.now() / 1000);
++ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const token = signJwt({ iss: config.network.issuer, sub: 'usr_demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey);
++ const v = await keys.verifyUser(token, { issuer: config.network.issuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, false);
++ assert.strictEqual(v.code, 'token.no_key', `the failure is token.no_key: ${v.code}`);
++ assert.strictEqual(v.reason, 'signing key not loaded yet', 'a fixed public reason, not the SDK text');
++ assert.ok(!/127\.0\.0\.1|jwks|ECONNREFUSED|fetch failed/i.test(v.reason), `reason leaks: ${v.reason}`);
++
++ // The same unreachable JWKS behind a booted Services: neither /api/v1 nor /api/ready names it.
++ const t = await boot({ env: { OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9', OV_NETWORK_URL: 'http://127.0.0.1:9' } });
++ const ready = await t.get('/api/ready');
++ const readyText = ready.text;
++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(readyText),
++ `/api/ready leaks the JWKS URL or the fetch error: ${readyText.slice(0, 400)}`);
++ const api = await t.get('/api/v1/releases/rel_missing', { headers: { authorization: `Bearer ${token}` } });
++ assert.strictEqual(api.status, 401, `an unverifiable token is 401: ${api.status} ${api.text.slice(0, 200)}`);
++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(api.text),
++ `the API answer leaks the JWKS URL or the fetch error: ${api.text.slice(0, 300)}`);
++ await t.close();
++ });
++
++ await check('an expired session token starts no session (the SDK takes the clock as a number)', async () => {
++ const t = await boot();
++ const u = t.network.addUser('expired-demo');
++ // Signed by the Network, but exp is an hour in the past: the SDK's Math.floor(now / 1000) must see it.
++ const expired = t.network.userToken(u, { ttl: -3600 });
++ const me = await t.get('/auth/me', { cookie: `services_at=${expired}` });
++ assert.strictEqual(me.status, 401, `an expired cookie is not a session: ${me.status} ${me.text.slice(0, 200)}`);
++ const page = await t.get('/', { cookie: `services_at=${expired}` });
++ assert.ok(page.status < 400, `a page still renders: ${page.status}`);
++ assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');
++ await t.close();
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
\\ No newline at end of file
ddiff --git a/test/auth-ops.test.js b/test/auth-ops.test.js
nnew file mode 100644
iindex 0000000..70ff0d2
---- /dev/null
++++ b/test/auth-ops.test.js
@@@ -0,0 +1,158 @@
++'use strict';
++/**
++ * Sign-in with PKCE S256, the outbox relay with Services' own service token, truthful readiness,
++ * /release.json, loopback-only /metrics, pages useful without JavaScript, and form protection.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('kim');
++
++ await check('login redirects to Network with state and an S256 code challenge', async () => {
++ const r = await t.get('/auth/login?next=/projects');
++ assert.strictEqual(r.status, 302);
++ const loc = new URL(r.headers.get('location'));
++ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);
++ assert.strictEqual(loc.searchParams.get('client_id'), 'services');
++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
++ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
++ assert.match(r.headers.get('set-cookie'), /services_oauth=.*Path=\/auth.*HttpOnly/i);
++ });
++
++ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
++ const login = await t.get('/auth/login?next=/projects');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
++ assert.strictEqual(r.status, 302, r.text);
++ assert.strictEqual(r.headers.get('location'), '/projects');
++ const set = r.headers.get('set-cookie');
++ assert.match(set, /services_at=[^;]+;.*HttpOnly/i);
++ assert.match(set, /services_rt=[^;]+;.*HttpOnly/i);
++ const at = set.match(/services_at=([^;]+)/)[1];
++ const me = await t.get('/auth/me', { cookie: `services_at=${at}` });
++ assert.strictEqual(me.json().user.username, 'kim');
++ assert.ok(!me.text.includes(at), '/auth/me never returns the token');
++ });
++
++ await check('a wrong state or a verifier that does not match is refused', async () => {
++ const login = await t.get('/auth/login');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
++ assert.strictEqual(bad.status, 400);
++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
++ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
++ assert.strictEqual(mismatch.status, 400, 'Network refuses the PKCE check; Services reports it');
++ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
++ assert.strictEqual(noCookie.status, 400);
++ });
++
++ await check('next= only accepts same-site paths', async () => {
++ const r = await t.get('/auth/login?next=//evil.example/x');
++ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/services_oauth=([^;]+)/)[1]);
++ assert.strictEqual(JSON.parse(flow).next, '/');
++ });
++
++ await check('forms need the form token and a same-site Origin', async () => {
++ const noToken = await t.get('/projects', { as: user, form: { name: 'x', csrf: 'nope' } });
++ assert.strictEqual(noToken.status, 403);
++ const crossSite = await t.get('/projects', { as: user, form: { name: 'x' }, headers: { origin: 'https://evil.example' } });
++ assert.strictEqual(crossSite.status, 403);
++ const ok = await t.get('/projects', { as: user, form: { name: 'x' }, headers: { origin: 'https://openvibe.services' } });
++ assert.strictEqual(ok.status, 303);
++ });
++
++ await check('readiness is truthful: required db + docs; Network, relay and client are optional', async () => {
++ const r = await t.get('/api/ready');
++ const b = r.json();
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(b.checks.db.status, 'ok');
++ assert.strictEqual(b.checks.docs.status, 'ok');
++ assert.strictEqual(b.checks.network_jwks.status, 'ok');
++ assert.strictEqual(b.checks.network.status, 'ok');
++ assert.strictEqual(b.checks.events_relay.status, 'fail', 'EVENTS_URL is unset in this boot, and it says so');
++ assert.ok(b.degraded.includes('events_relay'));
++ });
++
++ await check('/release.json and /metrics (loopback only)', async () => {
++ const rel = await t.get('/release.json');
++ assert.strictEqual(rel.status, 200);
++ assert.strictEqual(rel.json().service, 'services');
++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);
++ assert.strictEqual(rel.json().metrics_url, '/release-metrics');
++ const m = await t.get('/metrics');
++ assert.strictEqual(m.status, 200);
++ assert.match(m.text, /http_requests_total|release_info/);
++ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
++ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
++ });
++
++ await check('public pages are complete without JavaScript (forms, noscript nav, SSR footer)', async () => {
++ for (const p of ['/', '/docs', '/oauth', '/tools/webhooks', '/manifests/validate', '/policy/transparency']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.match(r.text, /<noscript><nav/);
++ assert.match(r.text, /<footer id="ov-footer"/);
++ assert.match(r.text, /<main id="main"/);
++ }
++ const hook = await t.get('/tools/webhooks');
++ assert.match(hook.text, /<form method="post" action="\/tools\/webhooks\/verify"/);
++ });
++
++ await check('the OAuth test callback shows what came back and never exchanges it', async () => {
++ const before = t.network.tokenRequests.length;
++ const r = await t.get('/oauth/test-callback?code=abc123&state=s1');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<code>abc123<\/code>/);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ assert.strictEqual(r.headers.get('referrer-policy'), 'no-referrer');
++ assert.match(r.text, /noindex/);
++ assert.strictEqual(t.network.tokenRequests.length, before, 'no token request');
++ const err = await t.get('/oauth/test-callback?error=access_denied&state=s1');
++ assert.strictEqual(err.status, 400);
++ assert.match(err.text, /access_denied/);
++ const xss = await t.get('/oauth/test-callback?code=%3Cscript%3Ealert(1)%3C%2Fscript%3E');
++ assert.ok(!xss.text.includes('<script>alert(1)</script>'));
++ });
++
++ await check('the OAuth helper builds a PKCE authorize URL for the developer\'s app', async () => {
++ const r = await t.get('/oauth?client_id=app_01JABCDEFGHJKMNPQRSTVWXYZ0&scope=media.object.upload');
++ const url = new URL(r.text.match(/<a href="([^"]+oauth\/authorize[^"]+)"/)[1].replace(/&amp;/g, '&'));
++ assert.strictEqual(url.searchParams.get('code_challenge_method'), 'S256');
++ const verifier = r.text.match(/code_verifier<\/dt><dd><code>([A-Za-z0-9_-]{43})<\/code>/)[1];
++ assert.strictEqual(crypto.createHash('sha256').update(verifier).digest('base64url'), url.searchParams.get('code_challenge'));
++ assert.strictEqual(url.searchParams.get('redirect_uri'), 'https://openvibe.services/oauth/test-callback');
++ });
++
++ await t.close();
++
++ // ── Outbox relay: Services' own token, events.event.publish, to Events ──
++ const t2 = await boot({ relay: true });
++ await check('with EVENTS_URL set, the relay publishes services.app.* with Services\' own service token', async () => {
++ const owner = t2.network.addUser('rel');
++ const p = await t2.project(owner);
++ const a = await t2.app(owner, p);
++ const manifest = JSON.stringify(require('../server/domain/manifests').template('app', { appId: a.id, projectId: p }));
++ const c = await t2.get(`/projects/${p}/apps/${a.id}/releases`, { as: owner, form: { kind: 'app', manifest, intent: 'create' } });
++ const id = c.headers.get('location').split('/').pop();
++ await t2.get(`/releases/${id}/publish`, { as: owner, form: {} });
++ for (let i = 0; i < 60 && !t2.events.published.length; i++) await new Promise((r) => setTimeout(r, 50));
++ assert.strictEqual(t2.events.published.length, 1);
++ assert.strictEqual(t2.events.published[0].sub, 'svc:services');
++ assert.strictEqual(t2.events.published[0].event.event_type, 'services.app.published');
++ const tok = t2.network.tokenRequests.find((x) => x.client_id === 'services' && x.grant_type === 'client_credentials');
++ assert.strictEqual(tok.audience, 'openvibe.events');
++ assert.strictEqual(tok.scope, 'events.event.publish');
++ const ready = (await t2.get('/api/ready')).json();
++ assert.strictEqual(ready.checks.events_relay.status, 'ok');
++ });
++ await t2.close();
++ done();
++})();
ddiff --git a/test/authorities.test.js b/test/authorities.test.js
iindex cced726..36086a3 100644
---- a/test/authorities.test.js
++++ b/test/authorities.test.js
@@@ -1,10 +1,11 @@
'use strict';
/**
-- * server/authorities/index.js against the REAL pinned openvibe-contracts (v0.110.0): the registry is
++ * server/authorities/index.js against the REAL pinned openvibe-contracts (v0.113.0): the registry is
* derived from the released service manifests — every non-placeholder service whose manifest lists an
-- * active <id>.resource.read — and never hand-maintained. The five authorities of the pin, their manifest
-- * loopback origins and their audiences; Services itself (whose manifest now lists
-- * services.resource.read), placeholders and services without the capability are not authorities.
++ * active <id>.resource.read — and never hand-maintained. The four authorities of the pin, their manifest
++ * loopback origins and their audiences; Services itself (read in process: server/registry/self-authority.js),
++ * Codes (retired codes.resource.read at 0.113.0, when its portal moved here), placeholders and services without
++ * the capability are not authorities.
*
* node test/authorities.test.js
*/
@@@ -17,22 +18,21 @@ const { check, done } = require('./helpers/app');
const quiet = { warn() {}, log() {}, error() {} };
async function main() {
-- await check('the pin lists six active <id>.resource.read capabilities, five of them authorities', () => {
-- const ids = contracts.capabilities.manifests.filter((c) => /^([a-z][a-z0-9-]{0,31})\.resource\.read$/.test(c.id)).map((c) => c.id).sort();
-- assert.deepStrictEqual(ids, ['codes.resource.read', 'events.resource.read', 'host.resource.read', 'media.resource.read', 'network.resource.read', 'services.resource.read']);
-- for (const id of ids) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
++ await check('the pin lists five active <id>.resource.read capabilities, four of them authorities', () => {
++ const ids = contracts.capabilities.manifests.filter((c) => /^([a-z][a-z0-9-]{0,31})\.resource\.read$/.test(c.id) && c.status === 'active').map((c) => c.id).sort();
++ assert.deepStrictEqual(ids, ['events.resource.read', 'host.resource.read', 'media.resource.read', 'network.resource.read', 'services.resource.read']);
++ assert.strictEqual(contracts.capabilities.get('codes.resource.read').status, 'retired', 'Codes owns no resources since 0.113.0');
// services.resource.read is Services' own id; Services is never its own authority (the next check).
assert.ok(!createAuthorities(loadConfig({}), { log: quiet }).ids().includes('services'));
});
await check('the registry is built from those manifests, with the manifest origins and audiences', () => {
const registry = createAuthorities(loadConfig({}), { log: quiet });
-- assert.deepStrictEqual(registry.ids(), ['codes', 'events', 'host', 'media', 'network']);
++ assert.deepStrictEqual(registry.ids(), ['events', 'host', 'media', 'network']);
const expected = {
network: 'http://127.0.0.1:4000',
media: 'http://127.0.0.1:4100',
events: 'http://127.0.0.1:4300',
-- codes: 'http://127.0.0.1:4900',
host: 'http://127.0.0.1:4910',
};
for (const [id, origin] of Object.entries(expected)) {
@@@ -46,16 +46,16 @@ async function main() {
assert.strictEqual(a.publicOrigin, contracts.services.get(id).publicOrigin);
}
assert.strictEqual(registry.get('nope'), null);
-- assert.strictEqual(registry.size(), 5);
++ assert.strictEqual(registry.size(), 4);
});
await check('Services, placeholders and services without the capability are not authorities', () => {
const registry = createAuthorities(loadConfig({}), { log: quiet });
-- for (const id of ['services', 'run', 'actor', 'watch', 'actor-console', 'sites']) {
++ for (const id of ['services', 'codes', 'run', 'actor', 'watch', 'actor-console', 'sites']) {
if (!contracts.services.get(id)) continue;
assert.strictEqual(registry.get(id), null, `${id} must not be an authority`);
}
-- // Services' own manifest is a placeholder today; even a live one would never index itself.
++ // Services' own resources are read in process (server/registry/self-authority.js), never over HTTP.
assert.ok(!registry.ids().includes('services'));
});
@@@ -63,7 +63,7 @@ async function main() {
const registry = createAuthorities(loadConfig({ SERVICES_MEDIA_URL: 'http://127.0.0.1:4999', SERVICES_RUN_URL: 'http://127.0.0.1:4888' }), { log: quiet });
assert.strictEqual(registry.get('media').internalOrigin, 'http://127.0.0.1:4999');
assert.strictEqual(registry.get('run'), null, 'an override for a service without the capability changes nothing');
-- assert.deepStrictEqual(registry.ids(), ['codes', 'events', 'host', 'media', 'network']);
++ assert.deepStrictEqual(registry.ids(), ['events', 'host', 'media', 'network']);
});
await check('every registry entry satisfies the rule the registry claims', () => {
ddiff --git a/test/config.test.js b/test/config.test.js
iindex e3be539..9cae677 100644
---- a/test/config.test.js
++++ b/test/config.test.js
@@@ -28,6 +28,8 @@ async function main() {
assert.strictEqual(c.events.enabled, true);
assert.strictEqual(c.events.url, '');
assert.strictEqual(c.db.url, '');
++ assert.strictEqual(c.oauth.redirectUri, 'http://localhost:4930/auth/callback');
++ assert.strictEqual(c.service, 'services');
});
await check('the environment overrides every default', () => {
@@@ -35,7 +37,8 @@ async function main() {
NODE_ENV: 'production', PORT: '5000', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.services/',
DATABASE_URL: 'postgres://runtime', DATABASE_DIRECT_URL: 'postgres://owner',
OV_NETWORK_URL: 'https://network.example/', OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:4001/',
-- OV_NETWORK_ISSUER: 'https://issuer.example', OV_NETWORK_PUBLIC_KEY: '-----BEGIN\\nKEY-----',
++ OV_NETWORK_ISSUER: 'https://issuer.example', SERVICES_FORM_SECRET: 'form', SERVICES_STAFF_SUBJECTS: 'usr_A, usr_B',
++ SERVICES_PLAYGROUND_EVENTS_URL: 'http://127.0.0.1:4301', SERVICES_EXPORT_MEDIA_URL: 'http://127.0.0.1:4101',
OV_OAUTH_CLIENT_ID: 'svc', OV_OAUTH_CLIENT_SECRET: 'secret',
SERVICES_INDEX_PAGE_SIZE: '2', SERVICES_INDEX_CONCURRENCY: '1', SERVICES_INDEX_TIMEOUT_MS: '250',
SERVICES_INDEX_DEFAULT_LIMIT: '10', SERVICES_INDEX_MAX_LIMIT: '20', SERVICES_NETWORK_TIMEOUT_MS: '750',
@@@ -49,7 +52,12 @@ async function main() {
assert.strictEqual(c.network.url, 'https://network.example');
assert.strictEqual(c.network.internalUrl, 'http://127.0.0.1:4001');
assert.strictEqual(c.network.issuer, 'https://issuer.example');
-- assert.strictEqual(c.network.publicKey, '-----BEGIN\nKEY-----'); // \n escaped in the env
++ assert.strictEqual(c.formSecret, 'form');
++ assert.deepStrictEqual(c.staffSubjects, ['usr_A', 'usr_B']);
++ assert.strictEqual(c.playground.eventsUrl, 'http://127.0.0.1:4301');
++ assert.strictEqual(c.export.mediaUrl, 'http://127.0.0.1:4101');
++ // The console's own *_URL settings name no authority.
++ assert.ok(!('playground-events' in c.authorityOrigins) && !('export-media' in c.authorityOrigins), JSON.stringify(c.authorityOrigins));
assert.strictEqual(c.oauth.clientSecret, 'secret');
assert.deepStrictEqual(c.index, { pageSize: 2, concurrency: 1, timeoutMs: 250, defaultLimit: 10, maxLimit: 20, networkTimeoutMs: 750 });
assert.deepStrictEqual(c.authorityOrigins, { media: 'http://127.0.0.1:4999', network: 'http://127.0.0.1:4010' });
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
nnew file mode 100644
iindex 0000000..cbf50a5
---- /dev/null
++++ b/test/contracts.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++/**
++ * The released contracts (the pinned openvibe-contracts tag) describe what the code does: the services
++ * service manifest's events are exactly what the outbox can produce, its capabilities are the ones
++ * the API and the resource index guard with requireCapability, and the release API answers with the
++ * contracts' problems.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { EVENT_TYPES } = require('../server/events/outbox');
++const { MANAGE, READ } = require('../server/http/api');
++const { RESOURCE_READ } = require('../server/registry/resource-index');
++const { check, done } = require('./helpers/boot');
++
++(async () => {
++ const manifest = contracts.services.get('services');
++
++ await check('the services service manifest is released as alpha, with this code\'s events', async () => {
++ assert.ok(manifest);
++ assert.strictEqual(manifest.status, 'alpha');
++ assert.deepStrictEqual(manifest.domains, ['openvibe.services']);
++ assert.deepStrictEqual([...manifest.eventsProduced].sort(), [...EVENT_TYPES].sort());
++ for (const e of manifest.eventsProduced) assert.strictEqual(e.split('.').length, 3, `${e} has three segments`);
++ const range = manifest.contractRanges['openvibe-contracts'];
++ assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
++ });
++
++ await check('its capabilities exist, are owned by services, and are the ones the API guards', async () => {
++ // services.resource.read (ADR-048, the resource index) is released too. It stays 'planned' until the
++ // contracts follow-up flips it after this ships, so only the two release capabilities are active.
++ assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ, RESOURCE_READ].sort());
++ for (const id of [MANAGE, READ]) {
++ const c = contracts.capabilities.get(id);
++ assert.ok(c, id);
++ assert.strictEqual(c.owner, 'services');
++ assert.strictEqual(c.status, 'active');
++ }
++ const resource = contracts.capabilities.get(RESOURCE_READ);
++ assert.ok(resource, RESOURCE_READ);
++ assert.strictEqual(resource.owner, 'services');
++ const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
++ // The guards are serviceAuth.requireCapability built per request with the key the token names; api.js's
++ // createCapabilityAccess is the one factory the release routes and the resource index both guard with.
++ assert.match(api, /serviceAuth\.requireCapability\(cap,/);
++ assert.match(api, /checked\('services\.release\.manage'/);
++ assert.match(api, /guardFor\('services\.release\.read'\)/);
++ const index = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
++ assert.match(index, /RESOURCE_READ = 'services\.resource\.read'/, 'the resource index guards services.resource.read');
++ });
++
++ await check('no proposal left behind: everything proposed is released', async () => {
++ const docs = path.join(__dirname, '..', 'docs');
++ assert.ok(!fs.existsSync(path.join(docs, 'capabilities-proposal')));
++ assert.ok(!fs.existsSync(path.join(docs, 'service-manifest-proposal.json')));
++ assert.ok(!fs.existsSync(path.join(docs, 'contracts-proposal')));
++ assert.ok(contracts.resolve('services.app-manifest@1'));
++ });
++
++ done();
++})();
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
nnew file mode 100644
iindex 0000000..8f8c0e3
---- /dev/null
++++ b/test/discovery.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * The public crawl artifacts (plan T11), fetched from the booted app: /robots.txt, /sitemap.xml,
++ * /llms.txt, /llms-full.txt and the home page's JSON-LD. Each is served with the right status and
++ * content type, carries at least one real entry, lists public pages only, and takes lastmod from the
++ * site's own data (STATUS.json) — never from the clock.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;
++const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('robots.txt: 200 text/plain, every previous Disallow kept, sitemap named', async () => {
++ const r = await t.get('/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
++ for (const d of [...PRIVATE, '/api/']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);
++ assert.ok(r.text.includes('Sitemap: https://openvibe.services/sitemap.xml'), 'sitemap not named');
++ assert.ok(/^User-agent: \*$/m.test(r.text), 'no User-agent: * group');
++ });
++
++ await check('sitemap.xml: 200 application/xml, real entries, lastmod from the data', async () => {
++ const r = await t.get('/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^application\/xml/);
++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
++ assert.ok(r.text.includes('<loc>https://openvibe.services/docs</loc>'), 'no /docs entry');
++ assert.ok(!r.text.includes('/docs/harnesses'), 'the harness catalog is OpenVibe.Codes\' page, not in this sitemap');
++ assert.ok(r.text.includes('<loc>https://openvibe.services/policy/transparency</loc>'), 'no policy entry');
++ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
++ assert.ok(lastmods.length > 0, 'no lastmod anywhere');
++ assert.ok(lastmods.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)), `bad lastmod: ${lastmods.slice(0, 3)}`);
++ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED}), got ${[...new Set(lastmods)].join(', ')}`);
++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.services${p}`), `private path in sitemap: ${p}`);
++ });
++
++ await check('llms.txt: 200 text/plain, real links, nothing private', async () => {
++ const r = await t.get('/llms.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
++ assert.ok(r.text.startsWith('# OpenVibe.Services'), 'no title');
++ assert.ok(r.text.includes('(https://openvibe.services/docs)'), 'no reference link');
++ assert.ok(r.text.includes('(https://openvibe.codes/harnesses)'), 'llms.txt points at the harness catalog on openvibe.codes');
++ assert.ok(r.text.includes('https://openvibe.services/sitemap.xml'), 'sitemap not listed');
++ assert.ok(r.text.includes('(https://openvibe.services/llms-full.txt)'), 'llms-full.txt not listed');
++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.services${p}`), `private path in llms.txt: ${p}`);
++ assert.ok(!r.text.includes('https://openvibe.services/api/'), 'api path in llms.txt');
++ });
++
++ await check('llms-full.txt: 200 text/plain, the site title, and every page the sitemap lists', async () => {
++ const r = await t.get('/llms-full.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
++ assert.ok(r.text.startsWith('# OpenVibe.Services'), 'no title');
++ const sitemap = await t.get('/sitemap.xml');
++ const locs = [...sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
++ assert.ok(locs.length >= 20, `sitemap too small: ${locs.length}`);
++ for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);
++ assert.ok(r.text.includes('URL: https://openvibe.services/docs\n'), 'no /docs entry');
++ assert.ok(r.text.includes('URL: https://openvibe.services/tools/webhooks\n'), 'no playground entry');
++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.services${p}`), `private path in llms-full.txt: ${p}`);
++ assert.ok(!r.text.includes('https://openvibe.services/api/'), 'api path in llms-full.txt');
++ });
++
++ await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const types = blocks.map((b) => b['@type']);
++ assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);
++ assert.ok(types.includes('WebApplication'), `no primary-type node: ${types}`);
++ const site = blocks.find((b) => b['@type'] === 'WebSite');
++ assert.strictEqual(site.url, 'https://openvibe.services');
++ assert.strictEqual(site.name, 'OpenVibe.Services');
++ const page = blocks.find((b) => b['@type'] === 'WebPage');
++ assert.strictEqual(page.url, 'https://openvibe.services/');
++ });
++ } finally { await t.close(); }
++ done();
++})();
ddiff --git a/test/docs-billing.test.js b/test/docs-billing.test.js
nnew file mode 100644
iindex 0000000..9211d91
---- /dev/null
++++ b/test/docs-billing.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * /docs/billing (WS-K task 10) renders OpenVibe.Billing's /policy.json and nothing of its own: the
++ * numbers on the page are the ones Billing answered, and Billing not answering is a 502 with a
++ * problem box, never numbers from anywhere else.
++ */
++const assert = require('assert');
++const http = require('http');
++
++const POLICY = {
++ authority: 'live', wording_date: '2026-09-26',
++ currencies: { vibes: { bought_with_money: true, cash_value_per_100_cents: 100, withdrawable: 'only Vibes you received' }, opencoins: { bought_with_money: false, withdrawable: false }, channel_points: { bought_with_money: false, withdrawable: false } },
++ purchase: { min_vibes: 100, max_vibes: 10000000, tiers: [{ from: 100, to: 499, price_per_100_cents: 150, creator_value_per_100_cents: 100, openvibe_keeps_pct: 33.3 }, { from: 500, to: null, price_per_100_cents: 137, creator_value_per_100_cents: 100, openvibe_keeps_pct: 27 }] },
++ tips: { creator_receives_pct: 100 },
++ subscription: { price_cents: 499, period_days: 31, creator_share_pct: 70, creator_share_cents: 349, site_route_fee_pct: 10, site_route_fee_cents: 50 },
++ cashout: { min_vibes: 500, min_cents: 500, hold_days: 14 },
++};
++
++(async () => {
++ let up = true;
++ const billing = http.createServer((req, res) => {
++ if (!up) { res.statusCode = 503; return res.end('{}'); }
++ if (req.url !== '/policy.json') { res.statusCode = 404; return res.end(); }
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify(POLICY));
++ });
++ await new Promise((r) => billing.listen(0, '127.0.0.1', r));
++ process.env.OV_BILLING_INTERNAL_URL = `http://127.0.0.1:${billing.address().port}`;
++ const { boot, check, done } = require('./helpers/boot');
++ const t = await boot();
++
++ await check('the page shows what Billing answered', async () => {
++ const r = await t.get('/docs/billing');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ for (const s of ['$1.37', '27%', '500 or more', '$4.99 for 31 days', '70% ($3.49)', '10% ($0.50)', '500 Vibes ($5.00)', '14 days', 'openvibe.live', 'billing.openvibe.network/policy']) {
++ assert.ok(r.text.includes(s), `the page says ${s}`);
++ }
++ assert.ok((await t.get('/docs')).text.includes('href="/docs/billing"'), 'the docs index links it');
++ });
++
++ await check('Billing down on a cold cache: a problem, no numbers', async () => {
++ up = false;
++ // A fresh process has no cache; drop this one's by booting a second app.
++ for (const k of Object.keys(require.cache)) if (k.includes('/server/http/docs.js')) delete require.cache[k];
++ const t2 = await boot();
++ const r = await t2.get('/docs/billing');
++ assert.strictEqual(r.status, 502);
++ assert.ok(r.text.includes('The billing policy could not be read'));
++ assert.ok(!r.text.includes('$4.99'), 'no numbers without Billing');
++ await t2.close();
++ });
++
++ await t.close();
++ billing.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/docs-limits.test.js b/test/docs-limits.test.js
nnew file mode 100644
iindex 0000000..c7d5932
---- /dev/null
++++ b/test/docs-limits.test.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * /docs/limits (WS-N task 7) renders each enforcing service's /limits.json and nothing of its own:
++ * the numbers are the ones the service answered (0 is "none", null is "no limit"), a service not
++ * answering shows a problem and no numbers, and grantable capabilities whose owner publishes no
++ * limits are listed as such.
++ */
++const assert = require('assert');
++const http = require('http');
++
++const HOST = {
++ service: 'host', scope: 'per project and environment; projects per owner',
++ limits: [
++ { id: 'custom_domains', label: 'Custom domains', capability: 'host.site.manage', unit: 'count', production: 5, sandbox: 0, exceeded: '429 quota.custom_domains' },
++ { id: 'storage_bytes', label: 'Stored bytes (every deploy kept)', capability: 'host.site.manage', unit: 'bytes', production: 1073741824, sandbox: 104857600, exceeded: '413 quota.storage' },
++ { id: 'deploys_per_day', label: 'Deploys in 24 hours', capability: 'host.site.manage', unit: 'per_day', production: 50, sandbox: 20, exceeded: '429 quota.deploys_per_day' },
++ ],
++};
++const EVENTS = {
++ service: 'events', scope: 'per project and environment (ADR-014)',
++ limits: [
++ { id: 'publish_per_minute', label: 'Events published per minute', capability: 'events.app.publish', unit: 'per_minute', production: 120, sandbox: 30, exceeded: '429 events.quota_exceeded' },
++ { id: 'subscriptions', label: 'Webhook subscriptions', capability: 'events.app.subscribe', unit: 'count', production: null, sandbox: 5, exceeded: '429 events.quota_exceeded' },
++ { id: 'retention_days', label: 'Days an event is kept', capability: 'events.app.read', unit: 'days', production: 30, sandbox: 7, exceeded: 'pruned' },
++ ],
++};
++
++const MEDIA = {
++ service: 'media', scope: 'per project tenant and environment',
++ limits: [
++ { id: 'storage_bytes', label: 'Stored bytes (every namespace of the tenant)', capability: 'media.object.upload', unit: 'bytes', production: 1073741824, sandbox: 104857600, exceeded: '413 media.quota.exceeded' },
++ { id: 'unfinished_upload_hours', label: 'Hours an unfinished upload holds its quota', capability: 'media.object.upload', unit: 'hours', production: 72, sandbox: 72, exceeded: 'failed, bytes freed' },
++ ],
++};
++
++function serve(body) {
++ const s = http.createServer((req, res) => {
++ if (!s.up) { res.statusCode = 503; return res.end('{}'); }
++ if (req.url !== '/limits.json') { res.statusCode = 404; return res.end(); }
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify(body));
++ });
++ s.up = true;
++ return new Promise((r) => s.listen(0, '127.0.0.1', () => r(s)));
++}
++
++(async () => {
++ const host = await serve(HOST);
++ const events = await serve(EVENTS);
++ process.env.OV_HOST_INTERNAL_URL = `http://127.0.0.1:${host.address().port}`;
++ process.env.OV_EVENTS_INTERNAL_URL = `http://127.0.0.1:${events.address().port}`;
++ const media = await serve(MEDIA);
++ process.env.OV_MEDIA_INTERNAL_URL = `http://127.0.0.1:${media.address().port}`;
++ const { boot, check, done } = require('./helpers/boot');
++ const t = await boot();
++
++ await check('the page shows what each service answered', async () => {
++ const r = await t.get('/docs/limits');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ for (const s of ['1 GB', '100 MB', '50 in 24 hours', '120 a minute', '30 a minute', '7 days', 'quota.custom_domains', 'events.quota_exceeded',
++ 'openvibe.events/limits.json', 'openvibe.media/limits.json', '72 hours', 'media.quota.exceeded', 'per project and environment (ADR-014)']) {
++ assert.ok(r.text.includes(s), `the page says ${s}`);
++ }
++ assert.match(r.text, /Custom domains<\/td><td[^>]*>[^<]*<a[^>]*><code>host\.site\.manage<\/code><\/a><\/td><td[^>]*>none<\/td>/, 'sandbox 0 reads "none"');
++ assert.match(r.text, /Webhook subscriptions.*?<td[^>]*>5<\/td><td[^>]*>no limit<\/td>/s, 'production null reads "no limit"');
++ assert.ok(r.text.includes('Other capabilities') && r.text.includes('/docs/capabilities/tools.tool.run'), 'owners without /limits.json are listed');
++ assert.ok(!/Other capabilities[\s\S]*media\.object\.upload \(/.test(r.text), 'Media publishes its own now');
++ assert.ok(!/Other capabilities[\s\S]*events\.app\.publish \(/.test(r.text), 'a covered owner is not listed again');
++ assert.ok((await t.get('/docs')).text.includes('href="/docs/limits"'), 'the docs index links it');
++ assert.ok((await t.get('/sitemap.xml')).text.includes('/docs/limits'), 'and the sitemap');
++ });
++
++ await check('a service down on a cold cache: its problem, no numbers; the others still show', async () => {
++ events.up = false;
++ for (const k of Object.keys(require.cache)) if (k.includes('/server/http/docs.js')) delete require.cache[k];
++ const t2 = await boot();
++ const r = await t2.get('/docs/limits');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('OpenVibe Events&#39;s limits could not be read') || r.text.includes("OpenVibe Events's limits could not be read"), 'the problem is shown');
++ assert.ok(!r.text.includes('120 a minute'), 'no Events numbers without Events');
++ assert.ok(r.text.includes('50 in 24 hours'), 'Host still shows');
++ await t2.close();
++ });
++
++ await t.close();
++ host.close();
++ events.close();
++ media.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/docs.test.js b/test/docs.test.js
nnew file mode 100644
iindex 0000000..855dc85
---- /dev/null
++++ b/test/docs.test.js
@@@ -0,0 +1,155 @@
++'use strict';
++/**
++ * Generated docs match the pinned contracts and SDK versions: the tag pinned in package.json, the
++ * installed package and what every page states are the same version, and every contract,
++ * capability, event type and SDK module in those packages has its page — nothing more, nothing less.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const pkg = require('../package.json');
++const contractsVersion = require('openvibe-contracts/package.json').version;
++const sdkPkg = require('openvibe-sdk/package.json');
++
++(async () => {
++ const t = await boot();
++
++ await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {
++ const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];
++ assert.strictEqual(tag('openvibe-contracts'), `v${contractsVersion}`);
++ assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
++ assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
++ assert.strictEqual(`v${sdkPkg.version}`, tag('openvibe-sdk'));
++ // The contracts tag v0.112.0 carries the released services manifest, services.app-manifest@1 and services.resource.read.
++ assert.ok(contracts.services.get('services') && contracts.services.get('services').status === 'alpha');
++ assert.ok(contracts.resolve('services.app-manifest@1'));
++ });
++
++ await check('every docs page states the versions it was generated from', async () => {
++ for (const p of ['/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);
++ assert.ok(r.text.includes(`OpenVibe.Contracts/tree/v${contractsVersion}`), `${p} links the pinned tag`);
++ assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
++ }
++ const v = (await t.get('/api/v1/docs/versions')).json();
++ assert.strictEqual(v.contracts, contractsVersion);
++ assert.strictEqual(v.contracts_tag, `v${contractsVersion}`);
++ assert.strictEqual(v.sdk, sdkPkg.version);
++ });
++
++ await check('one page per contract in the catalog, with its version, fields and fixtures', async () => {
++ const index = (await t.get('/docs/contracts')).text;
++ for (const c of contracts.catalog) {
++ assert.ok(index.includes(`/docs/contracts/${c.id}"`), `${c.id} listed`);
++ const r = await t.get(`/docs/contracts/${c.id}`);
++ assert.strictEqual(r.status, 200, c.id);
++ assert.ok(r.text.includes(`<dd>${c.version}</dd>`), `${c.id} version`);
++ const schema = contracts.schema(c.id);
++ for (const field of Object.keys(schema.properties || {})) assert.ok(r.text.includes(`<code>${field}</code>`), `${c.id} field ${field}`);
++ const fixtureDir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', c.id, 'valid');
++ for (const f of fs.existsSync(fixtureDir) ? fs.readdirSync(fixtureDir) : []) assert.ok(r.text.includes(f.replace(/\.json$/, '')), `${c.id} fixture ${f}`);
++ const raw = await t.get(`/docs/contracts/${c.id}.json`);
++ assert.deepStrictEqual(JSON.parse(raw.text), schema);
++ }
++ assert.strictEqual((await t.get('/docs/contracts/no.such-contract')).status, 404);
++ });
++
++ await check('the capability catalog lists every capability manifest exactly once', async () => {
++ const r = await t.get('/docs/capabilities');
++ const listed = [...r.text.matchAll(/<a href="\/docs\/capabilities\/([^"]+)"/g)].map((m) => m[1]);
++ assert.deepStrictEqual([...listed].sort(), contracts.capabilities.manifests.map((c) => c.id).sort());
++ });
++
++ await check('event types are exactly the service manifests\' eventsProduced', async () => {
++ const r = await t.get('/docs/events');
++ const expected = new Set(contracts.services.manifests.flatMap((m) => m.eventsProduced || []));
++ for (const e of expected) assert.ok(r.text.includes(`<code>${e}</code>`), e);
++ assert.strictEqual([...r.text.matchAll(/<tr><td><code>/g)].length, expected.size);
++ });
++
++ await check('the SDK reference has one page per exported types entry, declarations verbatim', async () => {
++ const typed = Object.entries(sdkPkg.exports).filter(([, v]) => v && typeof v === 'object' && v.types);
++ const r = await t.get('/docs/sdk');
++ for (const [sub] of typed) {
++ const slug = sub === '.' ? 'index' : sub.replace(/^\.\//, '').replace(/\//g, '-');
++ assert.ok(r.text.includes(`/docs/sdk/${slug}"`), `module ${sub}`);
++ }
++ const media = await t.get('/docs/sdk/media');
++ assert.ok(media.text.includes('export declare function createMediaClient'));
++ const events = await t.get('/docs/sdk/events');
++ assert.ok(events.text.includes('export declare function verifyDelivery'));
++ });
++
++ await check('policy pages render the published ADRs, not a paraphrase', async () => {
++ const r = await t.get('/policy/compatibility');
++ const adr = fs.readFileSync(path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'docs', 'adr', 'ADR-016-active-client-updates.md'), 'utf8');
++ assert.ok(r.text.includes('Mixed-version window'));
++ assert.ok(adr.includes('Mixed-version window'));
++ assert.ok(r.text.includes('ADR-002: Contract repository and compatibility policy'));
++ const rfc = await t.get('/policy/rfc');
++ const adrs = fs.readdirSync(path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'docs', 'adr')).filter((f) => /^ADR-\d+/.test(f));
++ for (const f of adrs) assert.ok(rfc.text.includes(`/docs/adr/${f.match(/^(ADR-\d+)/)[1]}"`), f);
++ const lic = await t.get('/policy/licensing');
++ assert.ok(lic.text.includes(sdkPkg.license) && lic.text.includes(require('openvibe-contracts/package.json').license));
++ });
++
++ await check('public docs are crawlable and cacheable; the sitemap lists them', async () => {
++ const r = await t.get('/docs/capabilities');
++ assert.match(r.text, /<meta name="robots" content="index, follow">/);
++ assert.match(r.headers.get('cache-control'), /public/);
++ const sm = await t.get('/sitemap.xml');
++ assert.ok(sm.text.includes('/docs/contracts/mods.mod-manifest</loc>'));
++ assert.ok(sm.text.includes('/docs/api</loc>') && sm.text.includes('/docs/api/tools</loc>'), 'the API explorer is in the sitemap');
++ });
++
++ await check('the API explorer lists every service document from the pinned contracts (WS-C task 6)', async () => {
++ const index = contracts.openapi.index();
++ assert.ok(index.length >= 20);
++ const list = await t.get('/docs/api');
++ assert.strictEqual(list.status, 200);
++ for (const s of index) assert.ok(list.text.includes(`href="/docs/api/${s.service}"`), `${s.service} is listed`);
++ assert.ok((await t.get('/docs')).text.includes('href="/docs/api"'), 'the docs index links it');
++ const raw = await t.get('/docs/api/tools.json');
++ assert.strictEqual(raw.status, 200);
++ assert.match(raw.headers.get('content-type'), /application\/vnd\.oai\.openapi\+json/);
++ assert.strictEqual(raw.headers.get('access-control-allow-origin'), '*');
++ assert.deepStrictEqual(raw.json(), contracts.openapi.document('tools'), 'the document as the package ships it');
++ const page = await t.get('/docs/api/tools');
++ assert.strictEqual(page.status, 200);
++ const doc = contracts.openapi.document('tools');
++ const ops = Object.values(doc.paths).reduce((n, m) => n + Object.keys(m).length, 0);
++ assert.strictEqual((page.text.match(/class="api-op"/g) || []).length, ops, 'one block per route');
++ assert.ok(page.text.includes('id="cap-tools.job.create"') && page.text.includes('href="/docs/capabilities/tools.job.create"'), 'capabilities anchored and linked');
++ assert.ok(page.text.includes('href="/docs/contracts/tools.job"'), 'schemas link to their contract pages');
++ assert.ok(page.text.includes('<code>/api/v1/jobs/{id}</code>'));
++ const pub = await t.get('/docs/api/tools?public=1');
++ assert.ok((pub.text.match(/class="api-op"/g) || []).length <= ops);
++ assert.strictEqual((await t.get('/docs/api/nope')).status, 404);
++ assert.strictEqual((await t.get('/docs/api/nope.json')).status, 404);
++ const cap = await t.get('/docs/capabilities/tools.job.create');
++ assert.ok(cap.text.includes('href="/docs/api/tools#cap-tools.job.create"'), 'a capability links its routes');
++ });
++
++ await check('the update system page documents the feed, the markup and the helpers (WS-A task 4)', async () => {
++ const r = await t.get('/docs/updates');
++ assert.strictEqual(r.status, 200);
++ for (const s of ['/api/v1/changelog', 'data-ov-shipped="latest"', 'frame.shipped(', 'updatesBody(', 'mountFrame', 'openvibe-shared v' + require('openvibe-shared/package.json').version])
++ assert.ok(r.text.includes(s.replace(/"/g, '&quot;')) || r.text.includes(s), `names ${s}`);
++ assert.ok((await t.get('/docs')).text.includes('href="/docs/updates"'), 'linked from the docs index');
++ assert.ok((await t.get('/sitemap.xml')).text.includes('/docs/updates</loc>'));
++ });
++
++ await check('pages rendered for a signed-in person are never publicly cacheable', async () => {
++ const u = t.network.addUser('pat');
++ const r = await t.get('/docs', { as: u });
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex e335c91..74b7dd3 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -54,8 +54,11 @@ async function boot(opts = {}) {
for (const a of opts.authorities || []) stubs.push(await startAuthority(a));
const authorities = opts.registry || registryOf(stubs);
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, authorities, log, ...(opts.appOpts || {}) });
-- await app.locals.keys.load();
++ const { createStore } = require('../../server/db');
++ const built = await createApp({ config, store: createStore(store.db), authorities, log, actorLimits: false, ...(opts.appOpts || {}) });
++ const app = built.app;
++ Object.assign(app.locals, built.ctx);
++ await built.ctx.keys.ensure();
const server = await new Promise((resolve) => {
const s = http.createServer(app);
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..4badeb2
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,129 @@
++'use strict';
++// The docs tests fetch one page per contract (hundreds); the per-address limit would answer 429 part-way.
++process.env.SERVICES_RATE_LIMIT_PER_MIN = process.env.SERVICES_RATE_LIMIT_PER_MIN || '100000';
++// For the same reason the per-actor limits (server/http/actor-limits.js) count nobody unless a test
++// asks for them: boot({ actorLimits: true, limitsNow }) (test/actor-limits.test.js).
++/**
++ * Boots Services on a temp database with mocks of Network, Events and Media, captures every log line
++ * (the app's logger AND console), and returns a small HTTP client. Every test file gets its own.
++ *
++ * const t = await boot();
++ * t.get(path, { as: user, form, json, multipart: { fields, file }, headers, method })
++ * t.signIn(user) → cookie header value (services_at = a Network user token, as after /auth/callback)
++ * t.csrf(user), t.logs(), t.dbDump(), t.project(user, name), t.app(user, projectId, body)
++ */
++const http = require('http');
++const { startNetwork, startEvents, startMedia } = require('./mocks');
++
++const captured = [];
++for (const m of ['log', 'info', 'warn', 'error']) {
++ const orig = console[m].bind(console);
++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
++}
++
++async function boot(opts = {}) {
++ const network = await startNetwork(opts.network || {});
++ const events = await startEvents(network);
++ const media = await startMedia(network);
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.services', TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'services', OV_OAUTH_CLIENT_SECRET: 'services-secret', COOKIE_SECURE: 'false',
++ SERVICES_FORM_SECRET: 'test-form-secret',
++ SERVICES_PLAYGROUND_EVENTS_URL: events.url, SERVICES_PLAYGROUND_MEDIA_URL: media.url,
++ SERVICES_EXPORT_EVENTS_URL: events.url, SERVICES_EXPORT_MEDIA_URL: media.url,
++ SERVICES_REGISTRY_TTL_MS: '0',
++ ...(opts.relay ? { EVENTS_URL: events.url, EVENTS_RELAY_INTERVAL_MS: '50' } : {}),
++ ...(opts.env || {}),
++ };
++ const configLib = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
++
++ const config = configLib.loadConfig(env);
++ const { createStore } = require('../../server/db');
++ // One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.
++ const testdb = await require('./db').testDb();
++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow });
++ await built.ctx.keys.ensure();
++ if (opts.relay) built.ctx.outbox.start();
++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const signIn = (user) => `services_at=${network.userToken(user)}`;
++ const csrf = (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.SERVICES_FORM_SECRET }, user);
++
++ async function get(p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
++ if (o.cookie) headers.cookie = o.cookie;
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) {
++ const f = { ...o.form };
++ if (o.as && f.csrf === undefined) f.csrf = csrf(o.as);
++ body = new URLSearchParams(f).toString();
++ headers['content-type'] = 'application/x-www-form-urlencoded';
++ }
++ if (o.multipart) {
++ const fd = new FormData();
++ const fields = { ...(o.multipart.fields || {}) };
++ if (o.as && fields.csrf === undefined) fields.csrf = csrf(o.as);
++ for (const [k, v] of Object.entries(fields)) fd.append(k, v);
++ if (o.multipart.file) fd.append('file', new Blob([o.multipart.file.content], { type: o.multipart.file.type || 'text/plain' }), o.multipart.file.name || 'f.txt');
++ body = fd;
++ }
++ const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
++ const buf = Buffer.from(await res.arrayBuffer());
++ const text = buf.toString('utf8');
++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
++ }
++
++ /** Every value in every table of Services' database, as one string. */
++ async function dbDump() {
++ const db = built.ctx.store.db;
++ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name);
++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.prepare(`SELECT * FROM "${tname}"`).all())))).join('\n');
++ }
++
++ async function project(user, name = 'Test project') {
++ const r = await get('/projects', { as: user, form: { name } });
++ if (r.status !== 303) throw new Error(`project: ${r.status} ${r.text.slice(0, 300)}`);
++ return r.headers.get('location').split('/').pop();
++ }
++ /** Create an app through the portal; returns { id, secret, page }. */
++ async function app(user, projectId, body = {}) {
++ const r = await get(`/projects/${projectId}/apps`, { as: user, form: { name: 'Test app', environment: 'sandbox', type: 'confidential', redirect_uris: '', ...body } });
++ if (r.status === 303) return { id: r.headers.get('location').split('/').pop().split('?')[0], secret: null, page: r };
++ if (r.status !== 200) throw new Error(`app: ${r.status} ${r.text.slice(0, 300)}`);
++ const secret = (r.text.match(/ovsec_[A-Za-z0-9_-]{43}/) || [null])[0];
++ const id = (r.text.match(/app_[0-9A-HJKMNP-TV-Z]{26}/) || [null])[0];
++ return { id, secret, page: r };
++ }
++ /** Approve a grant the way Network would after staff put it in the allowance. */
++ async function grant(owner, projectId, appId, capability) {
++ network.setAllowance(projectId, [...new Set([...network.state.projects.get(projectId).allowance, capability])]);
++ const r = await get(`/projects/${projectId}/apps/${appId}/grants`, { as: owner, form: { capability } });
++ if (r.status !== 303) throw new Error(`grant: ${r.status} ${r.text.slice(0, 300)}`);
++ }
++
++ const t = {
++ base, network, events, media, config, ctx: built.ctx, get, signIn, csrf, dbDump, project, app, grant,
++ logs: () => captured.join('\n'),
++ async close() {
++ await new Promise((r) => server.close(r));
++ await built.ctx.outbox.stop();
++ await testdb.close();
++ await network.close(); await events.close(); await media.close();
++ },
++ };
++ return t;
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
++}
++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
iindex 7e27fa1..1d6a689 100644
---- a/test/helpers/db.js
++++ b/test/helpers/db.js
@@@ -1,74 +1,11 @@
'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: SERVICES_TEST_STORE=pg runs every test file on the containers instead of PGlite.
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * SERVICES_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
*/
--const crypto = require('crypto');
--const { createDb } = require('openvibe-sdk/db');
++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
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.SERVICES_TEST_STORE || 'pglite', max = 4 } = {}) {
-- if (store !== 'pg') {
-- const db = createDb({ pglite: true, service: 'services-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 = `services_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: 'services-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: 'services-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: 'services-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: 'services-test', max, log: quiet, ...o }),
-- async close() { await db.close().catch(() => {}); await drop(); },
-- };
--}
++const testDb = ({ store = process.env.SERVICES_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'services', max });
module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..1b918f4
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,497 @@
++'use strict';
++/**
++ * In-process stand-ins for Services' neighbours, with a real RS256 key pair.
++ *
++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `services`, refresh_token,
++ * client_credentials for `services` and for developer apps with Network's app rules:
++ * hashed secrets, sandbox audiences, cap = approved grants ∩ allowance for the audience);
++ * /oauth/revoke; /.well-known/openvibe; /api/v1/registry/services; and /api/v1/projects…
++ * with the same shapes, roles and problem services as OpenVibe.Network's developer API
++ * (simplified: no audit paging, no rate limits). setDown(true) makes it refuse
++ * connections-equivalent (503) and drop() closes the socket.
++ * GET /api/v1/projects/:project/usage (owner/admin or staff; days 1-90, env all|sandbox|production)
++ * answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
++ * POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
++ * sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
++ * Events POST /api/v1/events: verifies the app token (openvibe-contracts), needs the capability;
++ * GET /api/v1/events (pull, events.app.read): the token's project and env only, paged
++ * like Events (next_after_seq, latest_seq); addAppEvent() seeds it
++ * Media POST /api/v1/:app/files: verifies the token, capability and namespace;
++ * GET /api/v2/:project/(objects|objects/:id/download|namespaces) for app-shaped tokens
++ * of that project, the token's env picking the tenant; addObject() seeds it
++ * Both fail(pathPattern, status) answers matching requests with a problem (failure tests)
++ * Every request is recorded in `.requests` so tests can assert what was (not) called.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const Busboy = require('busboy');
++const { serviceAuth, ids, capabilities } = require('openvibe-contracts');
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const sockets = new Set();
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ server.listen(0, '127.0.0.1', () => resolve({
++ server, url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
++ }));
++ });
++}
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function signJwt(claims, privateKey) {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
++
++async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.media'] } = {}) {
++ 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 st = {
++ users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
++ grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], services: new Map(), refresh: new Map(),
++ catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
++ };
++ let issuer = null;
++ let nextUserId = 1;
++
++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
++
++ function addUser(username, { role = 'user' } = {}) {
++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
++ st.users.set(u.id, u); st.byUsername.set(username, u);
++ return u;
++ }
++ function userToken(u, { ttl = 3600, iat = null } = {}) {
++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
++ }
++ function userFrom(req) {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) return null;
++ const parts = h.slice(7).split('.');
++ if (parts.length !== 3) return null;
++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
++ if (!ok) return null;
++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ if (c.exp * 1000 < Date.now()) return 'expired';
++ return st.users.get(c.sub) || null;
++ }
++
++ // ── Projects model ──
++ const roleOf = (p, u) => (st.members.get(p.id) || new Map()).get(u.subject) || null;
++ const projectView = (p, role) => ({
++ id: p.id, name: p.name, owner: { type: 'user', id: p.owner }, role: role || null, environment_policy: p.environment_policy,
++ environments: p.environment_policy === 'sandbox' ? ['sandbox'] : ['sandbox', 'production'], allowance: [...p.allowance],
++ created_at: p.created_at, archived_at: p.archived_at, counts: { members: (st.members.get(p.id) || new Map()).size, apps: [...st.apps.values()].filter((a) => a.project_id === p.id && !a.revoked_at).length },
++ });
++ const appView = (a) => ({
++ id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment, client_id: a.id,
++ client_type: a.client_type, redirect_uris: a.redirect_uris, created_at: a.created_at, revoked_at: a.revoked_at,
++ grants: [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved').map((g) => g.capability).sort(),
++ });
++ function newCredential(app) {
++ const secret = `ovsec_${crypto.randomBytes(32).toString('base64url')}`;
++ const c = { id: ids.newId('project').replace(/^prj_/, 'crd_'), app_id: app.id, hash: sha256(secret), hint: secret.slice(-4), created_at: new Date().toISOString(), expires_at: null, revoked_at: null };
++ st.creds.set(c.id, c);
++ return { c, secret };
++ }
++ const credState = (c) => (c.revoked_at ? 'revoked' : (c.expires_at ? (Date.parse(c.expires_at) < Date.now() ? 'expired' : 'expiring') : 'active'));
++ const grantable = (id) => { const c = capabilities.get(id); return c && c.status === 'active' && ['public', 'partner'].includes(c.visibility); };
++ const defaultCatalog = () => capabilities.manifests.filter((c) => grantable(c.id)).map((c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '', resourceConstraints: c.resourceConstraints, quotaClass: c.quotaClass }));
++
++ async function projectsApi(req, raw, json, u, parts, q) {
++ let body = {};
++ if (raw.length) { try { body = JSON.parse(raw.toString('utf8')); } catch { return problem(json, 400, 'request.malformed_json', 'body is not valid JSON'); } }
++ const m = req.method;
++ if (parts.length === 1 && parts[0] === 'catalog' && m === 'GET') return json(200, { capabilities: st.catalog || defaultCatalog(), rule: 'only active public capabilities' });
++ if (!parts.length && m === 'GET') {
++ const list = [...st.projects.values()].filter((p) => roleOf(p, u) || (q.get('all') === '1' && u.role === 'admin')).map((p) => projectView(p, roleOf(p, u)));
++ return json(200, { projects: list });
++ }
++ if (!parts.length && m === 'POST') {
++ const name = String(body.name || '').trim();
++ if (!name || name.length > 80) return problem(json, 422, 'project.invalid', 'name must be 1-80 characters');
++ const p = { id: ids.newId('project'), name, owner: u.subject, environment_policy: 'sandbox', allowance: [], created_at: new Date().toISOString(), archived_at: null };
++ st.projects.set(p.id, p); st.members.set(p.id, new Map([[u.subject, 'owner']]));
++ return json(201, projectView(p, 'owner'));
++ }
++ const p = st.projects.get(parts[0]);
++ const role = p ? roleOf(p, u) : null;
++ const staff = u.role === 'admin';
++ if (!p || (!role && !staff)) return problem(json, 404, 'project.not_found', 'no such project');
++ const need = (r) => { if (!role || RANK[role] < RANK[r]) { problem(json, 403, 'project.forbidden', `requires ${r} role`); return false; } return true; };
++ const rest = parts.slice(1);
++ if (!rest.length && m === 'GET') return json(200, projectView(p, role));
++ if (rest[0] === 'archive' && m === 'POST') { if (role !== 'owner' && !staff) return problem(json, 403, 'project.forbidden', 'owner only'); p.archived_at = new Date().toISOString(); for (const a of st.apps.values()) if (a.project_id === p.id) a.revoked_at = a.revoked_at || p.archived_at; return json(200, projectView(p, role)); }
++ if (rest[0] === 'members') {
++ const mem = st.members.get(p.id);
++ if (rest.length === 1 && m === 'GET') return json(200, { members: [...mem.entries()].map(([s, r]) => { const x = [...st.users.values()].find((y) => y.subject === s); return { subject: { type: 'user', id: s }, username: x && x.username, display_name: x && x.display_name, role: r, added_at: p.created_at }; }) });
++ if (rest.length === 1 && m === 'POST') {
++ if (!need('admin')) return;
++ const target = st.byUsername.get(String(body.username || ''));
++ if (!target) return problem(json, 404, 'member.user_not_found', 'no such account');
++ if (!['admin', 'developer', 'viewer'].includes(body.role)) return problem(json, 422, 'member.invalid', 'role is one of admin, developer, viewer');
++ if (body.role === 'admin' && role !== 'owner') return problem(json, 403, 'member.forbidden', `a ${role} cannot assign admin`);
++ if (mem.has(target.subject)) return problem(json, 409, 'member.exists', 'already a member');
++ mem.set(target.subject, body.role);
++ return json(201, { subject: { type: 'user', id: target.subject }, role: body.role });
++ }
++ const s = rest[1];
++ if (!mem.has(s)) return problem(json, 404, 'member.not_found', 'not a member');
++ if (m === 'PATCH') { if (!need('admin')) return; mem.set(s, body.role); return json(200, { role: body.role }); }
++ if (m === 'DELETE') { if (s !== u.subject && !need('admin')) return; if (mem.get(s) === 'owner') return problem(json, 403, 'member.forbidden', 'the owner cannot be removed'); mem.delete(s); res204(json); return; }
++ }
++ if (rest[0] === 'quotas' && m === 'GET') return json(200, { quotas: [...st.quotas.values()].filter((x) => x.project_id === p.id).map((x) => ({ capability: x.capability, limit: x.limit, window: x.window, unit: x.unit, enforced_by: `openvibe.${capabilities.get(x.capability).owner}`, updated_at: x.updated_at })), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' });
++ if (rest[0] === 'usage' && m === 'GET') {
++ if ((!role || RANK[role] < RANK.admin) && !staff) return problem(json, 403, 'project.forbidden', 'requires admin role');
++ const days = q.get('days') || '30';
++ const env = q.get('env') || 'all';
++ if (!/^\d{1,3}$/.test(days) || Number(days) < 1 || Number(days) > 90) return problem(json, 422, 'usage.invalid', 'days is a whole number from 1 to 90');
++ if (!['all', 'sandbox', 'production'].includes(env)) return problem(json, 422, 'usage.invalid', 'env is all, sandbox or production');
++ const today = new Date().toISOString().slice(0, 10);
++ const range = { days: Number(days), from: new Date(Date.now() - (Number(days) - 1) * 86400000).toISOString().slice(0, 10), to: today };
++ const u = st.usage.get(p.id);
++ if (u) return json(200, { ...u, project_id: p.id, env, range });
++ return json(200, { project_id: p.id, env, range, generated_at: new Date().toISOString(), last_recorded_at: null, freshness: 'No usage has been recorded for this project yet.', totals: [], daily: [], quotas: [], errors: { total: 0, by_code: [], recent: [] } });
++ }
++ if (rest[0] === 'audit' && m === 'GET') { if (!role || RANK[role] < RANK.admin) { if (!staff) return problem(json, 403, 'project.forbidden', 'requires admin role'); } return json(200, { entries: st.audit.filter((e) => e.project_id === p.id).slice().reverse(), next_before: null }); }
++ if (rest[0] === 'apps') {
++ if (rest.length === 1 && m === 'GET') return json(200, { apps: [...st.apps.values()].filter((a) => a.project_id === p.id).map(appView) });
++ if (rest.length === 1 && m === 'POST') {
++ const env = String(body.environment || 'sandbox');
++ if (!['sandbox', 'production'].includes(env)) return problem(json, 422, 'app.invalid', 'environment is sandbox or production');
++ if (!need(env === 'production' ? 'admin' : 'developer')) return;
++ if (env === 'production' && p.environment_policy === 'sandbox') return problem(json, 403, 'app.environment_not_allowed', 'this project may only have sandbox apps (staff enable production)');
++ const type = String(body.type || 'confidential');
++ const redirects = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
++ for (const x of redirects) { try { const url = new URL(x); if (url.protocol !== 'https:' && url.hostname !== 'localhost') return problem(json, 422, 'app.invalid', 'redirect URIs must be https (http only for loopback)'); } catch { return problem(json, 422, 'app.invalid', `bad redirect URI ${x}`); } }
++ if (type === 'public' && !redirects.length) return problem(json, 422, 'app.invalid', 'a public app needs at least one redirect URI');
++ const a = { id: ids.newId('app'), project_id: p.id, name: String(body.name || '').trim() || 'app', environment: env, client_type: type, redirect_uris: redirects, created_at: new Date().toISOString(), revoked_at: null };
++ st.apps.set(a.id, a);
++ const out = appView(a);
++ if (type === 'confidential') { const { c, secret } = newCredential(a); out.credential = { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }; }
++ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'app.created', target: `app:${a.id}`, detail: { name: a.name }, event_type: 'network.app.created' });
++ return json(201, out);
++ }
++ const a = st.apps.get(rest[1]);
++ if (!a || a.project_id !== p.id) return problem(json, 404, 'app.not_found', 'no such app');
++ const manage = a.environment === 'production' ? 'admin' : 'developer';
++ if (rest.length === 2 && m === 'GET') return json(200, appView(a));
++ if (rest.length === 2 && m === 'PATCH') { if (!need(manage)) return; if (Array.isArray(body.redirect_uris)) a.redirect_uris = body.redirect_uris; return json(200, appView(a)); }
++ if (rest.length === 2 && m === 'DELETE') { if (!staff && !need(manage)) return; a.revoked_at = new Date().toISOString(); return json(200, appView(a)); }
++ if (rest[2] === 'credentials') {
++ if (rest.length === 3 && m === 'GET') return json(200, { credentials: [...st.creds.values()].filter((c) => c.app_id === a.id).map((c) => ({ id: c.id, hint: c.hint, state: credState(c), created_at: c.created_at, expires_at: c.expires_at, revoked_at: c.revoked_at, last_used_at: null })) });
++ if (rest[3] === 'rotate' && m === 'POST') {
++ if (!need(manage)) return;
++ if (a.revoked_at) return problem(json, 409, 'app.revoked', 'app is revoked');
++ if (a.client_type !== 'confidential') return problem(json, 409, 'credential.public_client', 'public apps have no client secret');
++ const overlap = body.overlap_seconds === undefined ? 86400 : body.overlap_seconds;
++ const until = new Date(Date.now() + overlap * 1000).toISOString();
++ const prev = [];
++ for (const c of st.creds.values()) if (c.app_id === a.id && !c.revoked_at) { c.expires_at = until; prev.push({ id: c.id, expires_at: until }); }
++ const { c, secret } = newCredential(a);
++ return json(201, { credential: { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }, previous: prev });
++ }
++ if (rest[4] === 'revoke' && m === 'POST') { if (!need(manage)) return; const c = st.creds.get(rest[3]); if (!c || c.app_id !== a.id) return problem(json, 404, 'credential.not_found', 'no such credential'); c.revoked_at = new Date().toISOString(); return json(200, { id: c.id, state: 'revoked' }); }
++ }
++ if (rest[2] === 'grants') {
++ const key = (cap) => `${a.id} ${cap}`;
++ const gv = (g) => ({ app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null });
++ if (rest.length === 3 && m === 'GET') return json(200, { grants: [...st.grants.values()].filter((g) => g.app_id === a.id).map(gv) });
++ if (rest.length === 3 && m === 'POST') {
++ if (!need('developer')) return;
++ const cap = String(body.capability || '');
++ const c = capabilities.get(cap);
++ if (!c) return problem(json, 422, 'grant.unknown_capability', `no capability ${cap} in the catalog`);
++ if (!grantable(cap)) return problem(json, 403, 'grant.not_grantable', `${cap} is ${c.visibility}; only public capabilities are granted to apps`);
++ const auto = RANK[role] >= RANK.admin && p.allowance.includes(cap);
++ const g = { app_id: a.id, capability: cap, audience: `openvibe.${c.owner}`, status: auto ? 'approved' : 'requested', requested_by: `user:${u.subject}`, requested_at: new Date().toISOString(), decided_at: auto ? new Date().toISOString() : null };
++ st.grants.set(key(cap), g);
++ return json(201, gv(g));
++ }
++ const g = st.grants.get(key(rest[3]));
++ if (!g) return problem(json, 404, 'grant.not_found', 'no such grant');
++ if (m === 'DELETE') { if (!staff && !need('admin')) return; g.status = 'revoked'; return json(200, gv(g)); }
++ if (rest[4] === 'approve') { if (!need('admin')) return; if (!p.allowance.includes(g.capability)) return problem(json, 403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance (staff set it)`); g.status = 'approved'; g.decided_at = new Date().toISOString(); return json(200, gv(g)); }
++ if (rest[4] === 'deny') { if (!need('admin')) return; g.status = 'denied'; return json(200, gv(g)); }
++ }
++ }
++ if (rest[0] === 'export-tokens' && rest.length === 1 && m === 'POST') {
++ if (!role || RANK[role] < RANK.admin) return problem(json, 403, 'project.forbidden', 'requires admin role');
++ const cap = { 'openvibe.media': ['media.object.list', 'media.object.read'], 'openvibe.events': ['events.app.read'] }[body.audience];
++ if (!cap) return problem(json, 422, 'export.invalid_audience', 'audience is one of openvibe.media, openvibe.events');
++ if (!['sandbox', 'production'].includes(body.env)) return problem(json, 422, 'export.invalid_env', 'env is one of sandbox, production');
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: issuer, sub: `app:app_${p.id.replace(/^prj_/, '')}`, actor_type: 'app', aud: [body.audience], cap, ns: [p.id, `app.${p.id}.*`],
++ project_id: p.id, env: body.env, on_behalf_of: u.subject, purpose: 'export', iat: now, exp: now + st.exportTtl, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
++ };
++ const token = serviceAuth.signServiceToken(claims, privatePem);
++ st.exportTokens.push({ project_id: p.id, audience: body.audience, env: body.env, subject: u.subject, jti: claims.jti, token });
++ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'project.export_token_issued', target: claims.sub, detail: { audience: body.audience, env: body.env, cap, jti: claims.jti } });
++ return json(201, { access_token: token, token_type: 'Bearer', expires_in: st.exportTtl, expires_at: new Date(claims.exp * 1000).toISOString(), scope: cap.join(' '), audience: body.audience, env: body.env, purpose: 'export', subject: claims.sub, project_id: p.id, jti: claims.jti });
++ }
++ return problem(json, 404, 'route.not_found', 'no such route');
++ }
++ const res204 = (json) => json(204, {});
++
++ function mintApp(a, audience, cap) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);
++ }
++
++ const srv = await listen(async (req, raw, json, res) => {
++ const url = new URL(req.url, 'http://x');
++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers });
++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
++ if (url.pathname === '/api/v1/registry/services') {
++ return json(200, { services: [{ id: 'network', name: 'OpenVibe.Network', status: 'stable', domains: ['openvibe.network'], capabilities: [], runtime: { status: 'up', basis: 'ready', checked_at: new Date().toISOString() } }, { id: 'services', name: 'OpenVibe.Services', status: 'placeholder', domains: [], capabilities: [], runtime: { status: 'not-running', reason: 'placeholder', checked_at: new Date().toISOString() } }] });
++ }
++ if (url.pathname === '/oauth/revoke') return json(200, {});
++ if (url.pathname === '/oauth/token' && req.method === 'POST') {
++ const ct = String(req.headers['content-type'] || '');
++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
++ if (body.client_id === 'services') {
++ if (body.client_secret !== 'services-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
++ if (body.grant_type === 'authorization_code') {
++ const c = st.services.get(body.code);
++ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
++ st.services.delete(body.code);
++ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
++ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
++ st.refresh.set(rt, c.user);
++ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });
++ }
++ if (body.grant_type === 'refresh_token') {
++ const user = st.refresh.get(body.refresh_token);
++ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });
++ st.refresh.delete(body.refresh_token);
++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
++ st.refresh.set(rt, user);
++ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
++ }
++ if (body.grant_type === 'client_credentials') {
++ const now = Math.floor(Date.now() / 1000);
++ return json(200, { access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:services', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem), token_type: 'Bearer', expires_in: 300 });
++ }
++ }
++ const a = st.apps.get(body.client_id);
++ if (!a || a.revoked_at) return json(401, { error: 'invalid_client', error_description: 'unknown or revoked app' });
++ const cred = [...st.creds.values()].find((c) => c.app_id === a.id && c.hash === sha256(body.client_secret) && credState(c) !== 'revoked' && credState(c) !== 'expired');
++ if (!cred) return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
++ if (a.environment === 'sandbox' && !sandboxAudiences.includes(body.audience)) return json(400, { error: 'invalid_target', error_description: `${body.audience} does not accept sandbox tokens` });
++ const p = st.projects.get(a.project_id);
++ const held = [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved' && g.audience === body.audience && p.allowance.includes(g.capability)).map((g) => g.capability);
++ const wanted = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : held;
++ const missing = wanted.filter((w) => !held.includes(w));
++ if (missing.length) return json(400, { error: 'invalid_scope', error_description: `not granted: ${missing.join(' ')}` });
++ if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
++ return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
++ }
++ if (url.pathname.startsWith('/api/v1/projects')) {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ if (req.headers['x-internal-key']) return problem(json, 400, 'test.internal_key_seen', 'X-Internal-Key must never be sent');
++ const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
++ return await projectsApi(req, raw, json, u, parts, url.searchParams);
++ }
++ return json(404, { error: 'not found' });
++ });
++ issuer = srv.url;
++
++ return {
++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
++ addUser, userToken,
++ /** An authorization code the mock will exchange for this user if the verifier matches. */
++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.services.set(code, { user, challenge }); return code; },
++ setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
++ setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },
++ setUsage(projectId, body) { st.usage.set(projectId, body); },
++ setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
++ setCatalog(list) { st.catalog = list; },
++ setDown(v) { st.down = v; },
++ mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
++ close: srv.close,
++ };
++}
++
++/** Problem answers and injected failures shared by the Events and Media stand-ins. */
++function failures() {
++ const list = [];
++ return {
++ fail(pattern, status = 503, code = 'service.unavailable') { list.push({ re: pattern instanceof RegExp ? pattern : new RegExp(pattern), status, code }); },
++ clear() { list.length = 0; },
++ match: (url) => list.find((f) => f.re.test(url)) || null,
++ };
++}
++const problemJson = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail }, { 'Content-Type': 'application/problem+json' });
++
++/**
++ * Events: POST /api/v1/events with an app or service token carrying events.event.publish; GET
++ * /api/v1/events pulls an app token's own project and environment (events.app.read, topic
++ * app.<project_key>.* only), with Events' paging: next_after_seq moves to latest_seq at the end.
++ */
++async function startEvents(network) {
++ const published = [];
++ const requests = [];
++ const stored = [];
++ const f = failures();
++ let seq = 0;
++ const verify = (req) => serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events', acceptSandbox: true });
++ const srv = await listen((req, raw, json) => {
++ const url = new URL(req.url, 'http://x');
++ requests.push({ method: req.method, path: req.url, auth: String(req.headers.authorization || '') });
++ const failing = f.match(req.url);
++ if (failing) return problemJson(json, failing.status, failing.code, 'injected failure');
++ if (url.pathname === '/api/v1/events' && req.method === 'GET') {
++ const v = verify(req);
++ if (!v.ok) return problemJson(json, 401, v.code, v.reason);
++ if (!String(v.claims.sub).startsWith('app:')) return problemJson(json, 403, 'capability.denied', 'service reads are not modelled here');
++ if (!v.claims.cap.includes('events.app.read')) return problemJson(json, 403, 'capability.denied', 'events.app.read not granted');
++ const key = `p${v.claims.project_id.replace(/^prj_/, '').toLowerCase()}`;
++ if (url.searchParams.get('topic') !== `app.${key}.*`) return problemJson(json, 403, 'events.topic_not_allowed', `app.* patterns must name your project: app.${key}.*`);
++ const after = Number(url.searchParams.get('after_seq') || 0);
++ const limit = Math.min(1000, Number(url.searchParams.get('limit') || 100));
++ const rows = stored.filter((r) => r.seq > after && r.project_id === v.claims.project_id && r.env === v.claims.env).slice(0, limit);
++ return json(200, { events: rows.map((r) => ({ seq: r.seq, event: r.event })), next_after_seq: rows.length === limit ? rows[rows.length - 1].seq : seq, latest_seq: seq });
++ }
++ if (req.url === '/api/v1/events' && req.method === 'POST') {
++ const v = verify(req);
++ if (!v.ok) return json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason });
++ const isApp = String(v.claims.sub).startsWith('app:');
++ const needed = isApp ? 'events.app.publish' : 'events.event.publish';
++ if (!v.claims.cap.includes(needed)) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'capability.denied', detail: `${needed} not granted` });
++ const body = JSON.parse(raw.toString('utf8'));
++ const events = body.events || [body];
++ if (isApp) {
++ // events.app.publish: app.<project_key>.* types, source app-<lowercase app ULID>.
++ const key = `p${v.claims.project_id.replace(/^prj_/, '').toLowerCase()}`;
++ const src = `app-${v.claims.sub.replace(/^app:app_/, '').toLowerCase()}`;
++ for (const e of events) {
++ if (!e.event_type.startsWith(`app.${key}.`)) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'events.type_not_allowed', detail: `apps publish app.${key}.* only` });
++ if (e.source !== src) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'events.source_mismatch', detail: `source must be ${src}` });
++ }
++ }
++ const results = events.map((e) => { published.push({ event: e, sub: v.claims.sub }); return { event_id: e.event_id, seq: ++seq, duplicate: false }; });
++ return json(201, body.events ? { results } : results[0]);
++ }
++ return json(404, { error: 'not found' });
++ });
++ /** An app event of `projectId` in `env`, as an app of that project would have published it. */
++ function addAppEvent(projectId, env, name = 'order.created', payload = {}) {
++ const key = `p${projectId.replace(/^prj_/, '').toLowerCase()}`;
++ const event = { event_id: ids.newId('event'), event_type: `app.${key}.${name}`, version: 1, source: `app-${ids.ulid().toLowerCase()}`, actor: { type: 'app', id: ids.newId('app') }, timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'thing', id: String(seq + 1) }, payload };
++ stored.push({ seq: ++seq, project_id: projectId, env, event });
++ return stored[stored.length - 1];
++ }
++ return { url: srv.url, published, requests, stored, addAppEvent, fail: f.fail, clear: f.clear, close: srv.close };
++}
++
++/**
++ * Media: POST /api/v1/:app/files with a token carrying media.object.upload for that namespace; and
++ * for a developer project's app-shaped tokens, GET /api/v2/:project/objects (list verb: .list or
++ * .read), /objects/:id/download?format=json (media.object.read) and /namespaces, the token's env
++ * picking the tenant. Sandbox objects are never public; private ones get signed URLs (ttl ≤ 1 h).
++ */
++async function startMedia(network) {
++ const uploads = [];
++ const requests = [];
++ const objects = [];
++ const f = failures();
++ const verify = (req) => serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.media', acceptSandbox: true });
++ let base = null;
++ function v2(req, url, json) {
++ const m = url.pathname.match(/^\/api\/v2\/(prj_[0-9A-HJKMNP-TV-Z]{26})\/(namespaces|objects)(?:\/(med_[0-9A-HJKMNP-TV-Z]{26})\/download)?$/);
++ if (!m || req.method !== 'GET') return json(404, { error: 'not found' });
++ const [, project, what, id] = m;
++ const v = verify(req);
++ if (!v.ok) return json(401, { error: 'Authentication required' });
++ if (v.claims.actor_type !== 'app') return problemJson(json, 403, 'capability.denied', 'app tokens only here');
++ if (v.claims.project_id !== project) return problemJson(json, 403, 'capability.namespace_denied', `this app token belongs to ${v.claims.project_id}, not ${project}`);
++ const verbOk = id ? v.claims.cap.includes('media.object.read') : v.claims.cap.some((c) => c === 'media.object.list' || c === 'media.object.read');
++ if (!verbOk) return problemJson(json, 403, 'capability.denied', `${id ? 'media.object.read' : 'media.object.list'} not granted`);
++ const env = v.claims.env === 'sandbox' ? 'sandbox' : 'production';
++ const mine = objects.filter((o) => o._project === project && o._env === env);
++ const view = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !k.startsWith('_')));
++ const root = env === 'sandbox' ? `app.${project}.sandbox` : `app.${project}`;
++ if (what === 'namespaces') {
++ return json(200, { namespaces: [{ namespace: root, owner: `project:${project}`, parent: null, policy: {}, quota: { bytes: env === 'sandbox' ? 100 * 1024 * 1024 : 1024 * 1024 * 1024, objects: null }, usage: { bytes: mine.reduce((n, o) => n + o.size_bytes, 0), objects: mine.length } }] });
++ }
++ if (!id) {
++ const limit = Math.min(Math.max(Number(url.searchParams.get('limit') || 50), 1), 200);
++ const cursor = url.searchParams.get('cursor');
++ const all = ['1', 'true'].includes(url.searchParams.get('include_deleted') || '');
++ const rows = mine.filter((o) => (all || o.lifecycle_status !== 'deleted') && (!cursor || o.id < cursor)).sort((a, b) => (a.id < b.id ? 1 : -1)).slice(0, limit + 1);
++ const page = rows.slice(0, limit);
++ return json(200, { objects: page.map(view), next_cursor: rows.length > limit ? page[page.length - 1].id : null, limit });
++ }
++ const o = mine.find((x) => x.id === id);
++ if (!o) return problemJson(json, 404, 'media.object.not_found', 'No such object in this namespace');
++ if (o.lifecycle_status === 'deleted') return problemJson(json, 410, 'media.object.deleted', 'Object was deleted');
++ if (o.lifecycle_status !== 'ready') return problemJson(json, 409, 'media.object.not_ready', `Object is ${o.lifecycle_status}`);
++ if (o.public_url) return json(200, { url: o.public_url, expires_at: null, public: true });
++ const ttl = Math.min(3600, Math.max(30, Number(url.searchParams.get('ttl')) || 300));
++ const exp = Math.floor(Date.now() / 1000) + ttl;
++ return json(200, { url: `${base}/o/${o.id}?exp=${exp}&sig=${crypto.createHash('sha256').update(`${o.id}.${exp}`).digest('hex').slice(0, 32)}`, expires_at: new Date(exp * 1000).toISOString(), public: false });
++ }
++ const srv = await listen((req, raw, json) => new Promise((resolve) => {
++ requests.push({ method: req.method, path: req.url, auth: String(req.headers.authorization || '') });
++ const failing = f.match(req.url);
++ if (failing) { problemJson(json, failing.status, failing.code, 'injected failure'); return resolve(); }
++ const url = new URL(req.url, 'http://x');
++ if (url.pathname.startsWith('/api/v2/')) { v2(req, url, json); return resolve(); }
++ const m = req.url.match(/^\/api\/v1\/([^/]+)\/files$/);
++ if (!m || req.method !== 'POST') { json(404, { error: 'not found' }); return resolve(); }
++ const v = verify(req);
++ if (!v.ok) { json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason }); return resolve(); }
++ const c = capabilities.check(v.claims, 'media.object.upload', { namespace: decodeURIComponent(m[1]) });
++ if (!c.allowed) { json(403, { type: 'x', title: 'Forbidden', status: 403, code: c.code, detail: c.reason }); return resolve(); }
++ const bb = Busboy({ headers: req.headers });
++ let file = null;
++ bb.on('file', (_n, stream, info) => { const chunks = []; stream.on('data', (d) => chunks.push(d)); stream.on('end', () => { file = { buf: Buffer.concat(chunks), info }; }); });
++ bb.on('close', () => {
++ const key = crypto.createHash('sha256').update(file.buf).digest('hex').slice(0, 16);
++ uploads.push({ app: m[1], key, size: file.buf.length });
++ json(201, { key, app_id: m[1], user_id: null, original_name: file.info.filename, size: file.buf.length, mime: file.info.mimeType, sha256: crypto.createHash('sha256').update(file.buf).digest('hex'), url: `/f/${key}`, created_at: new Date().toISOString() });
++ resolve();
++ });
++ bb.end(raw);
++ }));
++ base = srv.url;
++ /** An object in the project's `env` tenant, shaped as Media's object view (locations left out). */
++ function addObject(projectId, env, { visibility = 'private', status = 'ready', size = 5, metadata = {} } = {}) {
++ const id = `med_${ids.ulid()}`;
++ const tenant = env === 'sandbox' ? `${projectId}-sandbox` : projectId;
++ const o = {
++ id, media_ref: { media_id: id }, legacy_ref: null, app_id: tenant, namespace: env === 'sandbox' ? `app.${projectId}.sandbox` : `app.${projectId}`, kind: 'file',
++ owner: { subject: null, app: null, user_id: null }, visibility, lifecycle_status: status, mime_type: 'text/plain', size_bytes: size,
++ content_hash: crypto.createHash('sha256').update(id).digest('hex'), metadata, held: false, readiness: { metadata: true, bytes_verified: status === 'ready', playable: false },
++ public_url: visibility !== 'private' && status === 'ready' && env === 'production' ? `${srv.url}/o/${id}` : null,
++ created_at: new Date().toISOString(), updated_at: new Date().toISOString(), deleted_at: status === 'deleted' ? new Date().toISOString() : null,
++ ...(env === 'sandbox' ? { sandbox: true } : {}), _project: projectId, _env: env,
++ };
++ objects.push(o);
++ return o;
++ }
++ return { url: srv.url, uploads, requests, objects, addObject, fail: f.fail, clear: f.clear, close: srv.close };
++}
++
++module.exports = { startNetwork, startEvents, startMedia, signJwt };
ddiff --git a/test/helpers/unzip.js b/test/helpers/unzip.js
nnew file mode 100644
iindex 0000000..57a1e2d
---- /dev/null
++++ b/test/helpers/unzip.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * Reads a zip the way a general-purpose tool does: from the end-of-central-directory record, through
++ * the central directory, to each local header; inflates and checks every entry's CRC-32 and sizes.
++ * Returns a Map of name → Buffer, in central-directory order. Throws on anything malformed.
++ */
++const zlib = require('zlib');
++const { crc32 } = require('../../server/domain/zip');
++
++function unzip(buf) {
++ let eocd = -1;
++ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
++ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
++ }
++ if (eocd < 0) throw new Error('unzip: no end-of-central-directory record');
++ const count = buf.readUInt16LE(eocd + 10);
++ const dirSize = buf.readUInt32LE(eocd + 12);
++ let p = buf.readUInt32LE(eocd + 16);
++ if (p + dirSize !== eocd) throw new Error('unzip: central directory does not end at the end record');
++ const out = new Map();
++ for (let n = 0; n < count; n++) {
++ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error(`unzip: bad central header ${n}`);
++ const flags = buf.readUInt16LE(p + 8);
++ const method = buf.readUInt16LE(p + 10);
++ const crc = buf.readUInt32LE(p + 16);
++ const csize = buf.readUInt32LE(p + 20);
++ const usize = buf.readUInt32LE(p + 24);
++ const nameLen = buf.readUInt16LE(p + 28);
++ const extraLen = buf.readUInt16LE(p + 30);
++ const commentLen = buf.readUInt16LE(p + 32);
++ const local = buf.readUInt32LE(p + 42);
++ const name = buf.slice(p + 46, p + 46 + nameLen).toString(flags & 0x0800 ? 'utf8' : 'latin1');
++ p += 46 + nameLen + extraLen + commentLen;
++ if (buf.readUInt32LE(local) !== 0x04034b50) throw new Error(`unzip: bad local header for ${name}`);
++ if (buf.slice(local + 30, local + 30 + buf.readUInt16LE(local + 26)).toString('utf8') !== name) throw new Error(`unzip: local name differs for ${name}`);
++ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
++ const raw = buf.slice(start, start + csize);
++ const data = method === 8 ? zlib.inflateRawSync(raw) : method === 0 ? raw : null;
++ if (!data) throw new Error(`unzip: method ${method} for ${name}`);
++ if (data.length !== usize) throw new Error(`unzip: size of ${name}`);
++ if (crc32(data) !== crc) throw new Error(`unzip: CRC of ${name}`);
++ if (out.has(name)) throw new Error(`unzip: ${name} twice`);
++ out.set(name, data);
++ }
++ return out;
++}
++
++module.exports = { unzip };
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
nnew file mode 100644
iindex 0000000..31694c2
---- /dev/null
++++ b/test/indexnow.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++/**
++ * IndexNow (openvibe-shared/indexnow): INDEXNOW_KEY unset → the feature is off (no key route, nothing
++ * sent). With a key, the key file is served at /<key>.txt as text/plain and publishing a release pings
++ * the engines with the release page, the app page, /updates and the sitemap. A draft never pings.
++ */
++const assert = require('assert');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++const KEY = 'k'.repeat(32);
++const SITEMAP = 'https://openvibe.services/sitemap.xml';
++const UPDATES = 'https://openvibe.services/updates';
++
++(async () => {
++ const off = await boot();
++ await check('without a key IndexNow is off: no key route and nothing sent', async () => {
++ assert.strictEqual(off.ctx.indexnow.enabled, false);
++ const res = await off.get(`/${KEY}.txt`);
++ assert.strictEqual(res.status, 404, res.text);
++ });
++ await off.close();
++
++ const on = await boot({ env: { INDEXNOW_KEY: KEY } });
++ await check('with a key the key file answers text/plain with the key', async () => {
++ assert.strictEqual(on.ctx.indexnow.enabled, true);
++ const res = await on.get(`/${KEY}.txt`);
++ assert.strictEqual(res.status, 200, res.text);
++ assert.match(res.headers.get('content-type'), /text\/plain/);
++ assert.strictEqual(res.text, KEY);
++ });
++ await on.close();
++
++ // A spy in place of the module's HTTP send: records every pingSoon batch.
++ const pings = [];
++ const spy = {
++ enabled: true,
++ keyFile: (_req, _res, next) => next(),
++ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },
++ ping: async () => ({ sent: 0, status: 0 }),
++ flush: async () => ({ sent: 0, status: 0 }),
++ };
++ const t = await boot({ indexnow: spy });
++ const owner = t.network.addUser('rosa');
++ const projectId = await t.project(owner, 'IndexNow');
++ const app = await t.app(owner, projectId, { name: 'Pinger' });
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Pinger' }), version });
++ let release;
++
++ await check('a draft never pings', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'create' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 400));
++ release = r.headers.get('location').split('/').pop();
++ assert.deepStrictEqual(pings, []);
++ });
++
++ await check('a publish pings the release page, the app page, /updates and the sitemap', async () => {
++ const r = await t.get(`/releases/${release}/publish`, { as: owner, form: {} });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.ok(pings.includes(`https://openvibe.services/releases/${release}`), JSON.stringify(pings));
++ assert.ok(pings.includes(`https://openvibe.services/apps/${app.id}`), JSON.stringify(pings));
++ assert.ok(pings.includes(UPDATES), JSON.stringify(pings));
++ assert.ok(pings.includes(SITEMAP), JSON.stringify(pings));
++ });
++
++ await check('a revoke pings the page, the app page and the sitemap', async () => {
++ pings.length = 0;
++ const r = await t.get(`/releases/${release}/revoke`, { as: owner, form: { reason: 'Withdrawn', confirm: '1' } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.ok(pings.includes(`https://openvibe.services/releases/${release}`), JSON.stringify(pings));
++ assert.ok(pings.includes(SITEMAP), JSON.stringify(pings));
++ });
++ await t.close();
++
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/layout.test.js b/test/layout.test.js
nnew file mode 100644
iindex 0000000..39ccb65
---- /dev/null
++++ b/test/layout.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * Every page carries the openvibe-shared boost marker and self-starting script (plan T11), so a
++ * same-site click swaps <main> in place without a reload. The marker names the layout's release,
++ * which the app sets from openvibe-shared/release (a swap only happens between pages of the same
++ * release; across a deploy it is a normal load), the boost tag names #main in data-main, and the
++ * shared navbar's sign-in follows the current page through the {path} template.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const MARKER = /<meta name="ov-boost" content="(services@[0-9A-Za-z._+-]{1,64})">/;
++const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer><\/script>/;
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('kim');
++ const release = (await t.get('/release.json')).json().release;
++
++ await check('every rendered page carries the ov-boost marker (the app\'s release) and the data-main boost script', async () => {
++ for (const p of ['/', '/docs', '/oauth', '/manifests/validate']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ const m = r.text.match(MARKER);
++ assert.ok(m, `${p}: no ov-boost marker`);
++ assert.strictEqual(m[1], `services@${release}`, `${p}: the marker is not the app's release`);
++ assert.match(r.text, BOOST, `${p}: no boost script with data-main`);
++ assert.match(r.text, /<main id="main"/, `${p}: the page's main is not #main`);
++ }
++ const signedIn = await t.get('/projects', { as: user });
++ assert.strictEqual(signedIn.status, 200);
++ assert.match(signedIn.text, MARKER, '/projects: no ov-boost marker');
++ assert.match(signedIn.text, BOOST, '/projects: no boost script');
++ });
++
++ await check('the home page head comes from openvibe-shared/shell with the same title, canonical, robots and JSON-LD', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ const head = r.text.slice(0, r.text.indexOf('</head>'));
++ assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
++ assert.ok(head.includes('<title>OpenVibe.Services: the OpenVibe developer platform</title>'), 'home title');
++ assert.ok(head.includes('<link rel="canonical" href="https://openvibe.services/">'), 'canonical');
++ assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
++ const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ for (const tag of ['<meta property="og:title"', 'data-ov-icon="services"', '/css/services.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
++ assert.ok(head.includes(tag), `head has ${tag}`);
++ }
++ assert.ok(r.text.includes('id="ov-footer"'), 'the server-rendered footer');
++ assert.ok(r.text.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), 'the footer is initialised');
++ });
++
++ await check('the navbar config uses the {path} login template, not the path baked in', async () => {
++ const r = await t.get('/docs');
++ assert.match(r.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/);
++ assert.ok(!/"loginUrl":"\/auth\/login\?next=%2F/.test(r.text), 'loginUrl still has the current path baked in');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/manifests.test.js b/test/manifests.test.js
nnew file mode 100644
iindex 0000000..e3d12a1
---- /dev/null
++++ b/test/manifests.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++/**
++ * Manifest validation with openvibe-contracts: mods.mod-manifest@1 (released) and the app manifest
++ * proposal compiled into the contracts validator; semantic checks against the pinned catalog.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++const FIX = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', 'mods.mod-manifest');
++const read = (p) => JSON.parse(fs.readFileSync(p, 'utf8'));
++const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const PRJ = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';
++
++(async () => {
++ await check('the contracts\' own mod fixtures: valid ones pass, invalid ones fail', async () => {
++ for (const f of fs.readdirSync(path.join(FIX, 'valid'))) assert.strictEqual(manifests.validate('mod', read(path.join(FIX, 'valid', f))).valid, true, f);
++ for (const f of fs.readdirSync(path.join(FIX, 'invalid'))) assert.strictEqual(manifests.validate('mod', read(path.join(FIX, 'invalid', f))).valid, false, f);
++ });
++
++ await check('schema errors come from openvibe-contracts with JSON pointers', async () => {
++ const m = read(path.join(FIX, 'valid', fs.readdirSync(path.join(FIX, 'valid'))[0]));
++ const v = manifests.validate('mod', { ...m, version: 'one' });
++ assert.strictEqual(v.valid, false);
++ assert.ok(v.errors.some((e) => e.path === '/version'));
++ assert.strictEqual(v.schema.id, 'mods.mod-manifest');
++ });
++
++ await check('the starter templates validate', async () => {
++ assert.deepStrictEqual(manifests.validate('app', manifests.template('app')).errors, []);
++ assert.deepStrictEqual(manifests.validate('mod', manifests.template('mod')).errors, []);
++ });
++
++ await check('app manifests: unknown and non-grantable capabilities are errors', async () => {
++ const internal = contracts.capabilities.manifests.find((c) => c.visibility === 'internal').id;
++ const m = { ...manifests.template('app'), capabilities: ['media.object.upload', 'nope.not.real', internal] };
++ const v = manifests.validate('app', m);
++ assert.strictEqual(v.valid, false);
++ assert.ok(v.errors.some((e) => e.path === '/capabilities/1' && /not in openvibe-contracts/.test(e.message)));
++ assert.ok(v.errors.some((e) => e.path === '/capabilities/2' && /never granted/.test(e.message)));
++ assert.ok(!v.errors.some((e) => e.path === '/capabilities/0'));
++ });
++
++ await check('ranges must parse; out-of-range pins are warnings', async () => {
++ const bad = manifests.validate('app', { ...manifests.template('app'), compatibility: { contracts: 'lots' } });
++ assert.ok(bad.errors.some((e) => e.path === '/compatibility/contracts'));
++ const old = manifests.validate('app', { ...manifests.template('app'), compatibility: { contracts: '^0.5.0' } });
++ assert.strictEqual(old.valid, true);
++ assert.ok(old.warnings.some((w) => /outside \^0\.5\.0/.test(w.message)));
++ });
++
++ await check('unknown consumed events are warnings, not errors', async () => {
++ const types = new Set(contracts.services.manifests.flatMap((m) => m.eventsProduced || []));
++ const v = manifests.validate('app', { ...manifests.template('app'), events: { consumes: ['network.app.created', 'made.up.thing', 'network.*'] } }, { eventTypes: types });
++ assert.strictEqual(v.valid, true);
++ assert.strictEqual(v.warnings.filter((w) => /made\.up\.thing/.test(w.message)).length, 1);
++ assert.ok(!v.warnings.some((w) => /network\.\*/.test(w.message)));
++ });
++
++ await check('for a release, the manifest must describe that app (id, project, environment, publisher)', async () => {
++ const app = { id: APP, project_id: PRJ, environment: 'sandbox', grants: [] };
++ const ok = manifests.validate('app', manifests.template('app', { appId: APP, projectId: PRJ }), { app });
++ assert.strictEqual(ok.valid, true, JSON.stringify(ok.errors));
++ const other = manifests.validate('app', { ...manifests.template('app', { appId: APP, projectId: PRJ }), id: 'app_01JZZZZZZZZZZZZZZZZZZZZZZZ', environment: 'production', publisher: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' } }, { app, viewerSubject: 'usr_01JBBBBBBBBBBBBBBBBBBBBBBB' });
++ assert.ok(other.errors.some((e) => e.path === '/id'));
++ assert.ok(other.errors.some((e) => e.path === '/environment'));
++ assert.ok(other.errors.some((e) => e.path === '/publisher'));
++ const ungranted = manifests.validate('app', { ...manifests.template('app', { appId: APP, projectId: PRJ }), capabilities: ['media.object.upload'] }, { app });
++ assert.ok(ungranted.warnings.some((w) => /not granted to this app yet/.test(w.message)));
++ });
++
++ await check('app manifests validate as the released services.app-manifest@1, fixtures included', async () => {
++ assert.strictEqual(manifests.SCHEMAS.app, 'services.app-manifest@1');
++ const dir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', 'services.app-manifest');
++ for (const f of fs.readdirSync(path.join(dir, 'valid'))) assert.deepStrictEqual(manifests.validate('app', read(path.join(dir, 'valid', f))).errors.filter((e) => !/not in openvibe-contracts|never granted/.test(e.message)), [], f);
++ for (const f of fs.readdirSync(path.join(dir, 'invalid'))) assert.strictEqual(manifests.validate('app', read(path.join(dir, 'invalid', f))).valid, false, f);
++ const v = manifests.validate('app', { ...manifests.template('app'), publisher: { type: 'nobody', id: 'x' } });
++ assert.ok(v.errors.some((e) => e.path.startsWith('/publisher')), 'the $ref to identity.subject-ref resolves');
++ assert.strictEqual(v.schema.id, 'services.app-manifest');
++ });
++
++ const t = await boot();
++ await check('the validator page and API work without signing in and store nothing', async () => {
++ const r = await t.get('/manifests/validate', { form: { kind: 'mod', manifest: JSON.stringify(manifests.template('mod')) } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Valid\./);
++ const bad = await t.get('/manifests/validate', { form: { kind: 'app', manifest: '{not json' } });
++ assert.strictEqual(bad.status, 422);
++ assert.match(bad.text, /not valid JSON/);
++ const api = await t.get('/api/v1/manifests/validate', { json: { kind: 'app', manifest: { ...manifests.template('app'), capabilities: ['nope.not.real'] } } });
++ assert.strictEqual(api.status, 422);
++ assert.strictEqual(api.json().valid, false);
++ assert.strictEqual(api.json().contracts_version, require('openvibe-contracts/package.json').version);
++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM manifests').get()).n, 0);
++ });
++ await t.close();
++ done();
++})();
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
nnew file mode 100644
iindex 0000000..9066100
---- /dev/null
++++ b/test/moderation-events.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * services.moderation.action (ADR-022): Services staff acting on someone else's app goes to Network's
++ * moderation audit log, in the transaction that makes the change.
++ * - staff revoking a release of a project they cannot manage: exactly one event (release.revoked)
++ * - a member revoking their own release: none (it is not moderation)
++ * - staff setting a trust tier: exactly one event (app.trust_changed, from and to); refused: none
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('mona');
++ const staff = t.network.addUser('root', { role: 'admin' });
++ const projectId = await t.project(owner, 'Moderated');
++ const app = await t.app(owner, projectId, { name: 'Moderated' });
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Moderated' }), version });
++ const moderation = async () => (await t.ctx.store.db.prepare('SELECT envelope FROM services_events_outbox ORDER BY id').all())
++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => e.event_type === 'services.moderation.action');
++ const published = async (version) => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest(version), intent: 'create' } });
++ const id = r.headers.get('location').split('/').pop();
++ assert.strictEqual((await t.get(`/releases/${id}/publish`, { as: owner, form: {} })).status, 303);
++ return id;
++ };
++ const valid = (e) => {
++ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
++ const r = contracts.validate('services.moderation.action@1', e.payload);
++ assert.strictEqual(r.valid, true, JSON.stringify(r.errors));
++ assert.strictEqual(e.source, 'services');
++ assert.strictEqual(e.visibility, 'internal');
++ assert.deepStrictEqual(e.actor, { type: 'user', id: staff.subject });
++ };
++
++ await check('the owner revoking their own release is not moderation: no event', async () => {
++ const id = await published('1.0.0');
++ const r = await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'Mine to pull', confirm: '1' } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual((await moderation()).length, 0);
++ });
++
++ await check('staff revoking someone else\'s release: exactly one valid services.moderation.action', async () => {
++ const id = await published('1.1.0');
++ const r = await t.get(`/releases/${id}/revoke`, { as: staff, form: { reason: 'Malware in the bundle', confirm: '1' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const ev = await moderation();
++ assert.strictEqual(ev.length, 1);
++ valid(ev[0]);
++ assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `release:${id}` });
++ assert.strictEqual(ev[0].payload.action, 'release.revoked');
++ assert.deepStrictEqual(ev[0].payload.target, { type: 'release', id, owner_subject: owner.subject });
++ assert.strictEqual(ev[0].payload.actor_subject, staff.subject);
++ assert.strictEqual(ev[0].payload.reason, 'Malware in the bundle');
++ assert.strictEqual(ev[0].payload.details.was, 'published');
++ });
++
++ await check('staff setting a trust tier: exactly one event; a refused change: none', async () => {
++ assert.strictEqual((await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'reviewed', note: 'me' } })).status, 403);
++ assert.strictEqual((await moderation()).length, 1);
++ const r = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'reviewed', note: 'Read the source' } });
++ assert.strictEqual(r.status, 303);
++ const ev = await moderation();
++ assert.strictEqual(ev.length, 2);
++ const e = ev[1];
++ valid(e);
++ assert.strictEqual(e.payload.action, 'app.trust_changed');
++ assert.deepStrictEqual(e.payload.target, { type: 'app', id: app.id, owner_subject: null });
++ assert.strictEqual(e.payload.reason, 'Read the source');
++ assert.deepStrictEqual(e.payload.details, { from: 'unreviewed', to: 'reviewed' });
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/network-errors.test.js b/test/network-errors.test.js
nnew file mode 100644
iindex 0000000..5ba04f6
---- /dev/null
++++ b/test/network-errors.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++/**
++ * Network API errors surface honestly: the page carries Network's HTTP status, problem code, detail
++ * and request id; an unreachable Network is a 502 that says so, never an empty list; an expired
++ * session is refreshed once and the call retried.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('grace');
++ const viewer = t.network.addUser('vera');
++ const stranger = t.network.addUser('sam');
++ const projectId = await t.project(owner, 'Errors');
++ await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vera', role: 'viewer' } });
++
++ await check('a project the person cannot see: 404 with project.not_found', async () => {
++ const r = await t.get(`/projects/${projectId}`, { as: stranger });
++ assert.strictEqual(r.status, 404);
++ assert.match(r.text, /project\.not_found/);
++ assert.match(r.text, /no such project/);
++ assert.match(r.text, /request <code>req_/);
++ });
++
++ await check('a role Network refuses: 403 with project.forbidden and its detail', async () => {
++ const r = await t.get(`/projects/${projectId}/apps`, { as: viewer, form: { name: 'x', environment: 'sandbox', type: 'confidential' } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /project\.forbidden/);
++ assert.match(r.text, /requires developer role/);
++ });
++
++ await check('a validation failure: 422 with Network\'s own detail', async () => {
++ const r = await t.get(`/projects/${projectId}/apps`, { as: owner, form: { name: 'pub', environment: 'sandbox', type: 'public', redirect_uris: '' } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /app\.invalid/);
++ assert.match(r.text, /public app needs at least one redirect URI/);
++ });
++
++ await check('production on a sandbox-only project: 403 app.environment_not_allowed', async () => {
++ const r = await t.get(`/projects/${projectId}/apps`, { as: owner, form: { name: 'prod', environment: 'production', type: 'confidential' } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /app\.environment_not_allowed/);
++ });
++
++ await check('a conflict: 409 member.exists', async () => {
++ const r = await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vera', role: 'viewer' } });
++ assert.strictEqual(r.status, 409);
++ assert.match(r.text, /member\.exists/);
++ });
++
++ await check('approving outside the allowance: 403 grant.beyond_allowance', async () => {
++ const app = await t.app(owner, projectId);
++ const req = await t.get(`/projects/${projectId}/apps/${app.id}/grants`, { as: owner, form: { capability: 'media.object.read' } });
++ assert.strictEqual(req.status, 303);
++ const r = await t.get(`/projects/${projectId}/apps/${app.id}/grants/media.object.read/approve`, { as: owner, form: {} });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /grant\.beyond_allowance/);
++ });
++
++ await check('Network answering 503: the page is 503 with Network\'s code, not an empty list', async () => {
++ t.network.setDown(true);
++ const r = await t.get('/projects', { as: owner });
++ t.network.setDown(false);
++ assert.strictEqual(r.status, 503);
++ assert.match(r.text, /network\.down/);
++ assert.ok(!/not a member of any project/.test(r.text));
++ });
++
++ await check('Network unreachable: 502 "did not answer", and the registry page says so too', async () => {
++ // A separate instance whose Network mock is shut down.
++ const t2 = await boot();
++ const u = t2.network.addUser('ivy');
++ await t2.network.close();
++ const r = await t2.get('/projects', { as: u });
++ assert.strictEqual(r.status, 502);
++ assert.match(r.text, /OpenVibe\.Network could not be reached/);
++ assert.match(r.text, /did not answer/);
++ const s = await t2.get('/docs/services');
++ assert.strictEqual(s.status, 502);
++ assert.match(s.text, /The registry could not be read/);
++ assert.match(s.text, /without health/);
++ const ready = await t2.get('/api/ready');
++ assert.strictEqual(ready.status, 200);
++ const body = ready.json();
++ assert.ok(body.degraded.includes('network'), 'readiness reports the Network as degraded');
++ await t2.events.close(); await t2.media.close();
++ });
++
++ await check('an expired access token is refreshed once and the call retried', async () => {
++ const expired = t.network.userToken(owner, { iat: Math.floor(Date.now() / 1000) - 7200, ttl: 3600 });
++ // Hand the mock a refresh token for this user.
++ t.network.state.refresh.set('rt_test', owner);
++ const r = await t.get('/projects', { cookie: `services_at=${expired}; services_rt=rt_test` });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(r.text, /Errors/);
++ const set = r.headers.get('set-cookie') || '';
++ assert.match(set, /services_at=/);
++ assert.match(set, /HttpOnly/i);
++ });
++
++ await check('an expired token with no refresh token reads as signed out (401), not as an error', async () => {
++ const expired = t.network.userToken(owner, { iat: Math.floor(Date.now() / 1000) - 7200, ttl: 3600 });
++ const r = await t.get('/projects', { cookie: `services_at=${expired}` });
++ assert.strictEqual(r.status, 401);
++ assert.match(r.text, /Sign in/);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/nginx-auth-limit.test.js b/test/nginx-auth-limit.test.js
nnew file mode 100644
iindex 0000000..5be464f
---- /dev/null
++++ b/test/nginx-auth-limit.test.js
@@@ -0,0 +1,20 @@
++'use strict';
++// deploy/nginx/openvibe.services.conf: the shared navbar asks /auth/me on every page view, so the session probe has its own location on
++// the API zone; the sign-in routes keep the strict zone (10 a minute). Both answer 429 when limited, not
++// nginx's default 503 (the browser check, OpenVibe.Host scripts/browser-check.js, found /auth/me answering
++// 503 after about ten quick page loads, which the navbar reads as signed out).
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/openvibe.services.conf'.split('/')), 'utf8');
++const block = (loc) => { const m = conf.match(new RegExp(`\\n location ${loc} \\{\\n([\\s\\S]*?)\\n \\}`)); return m && m[1]; };
++const probe = block('= /auth/me'), auth = block('/auth/');
++assert.ok(probe && auth, 'both locations');
++assert.ok(conf.indexOf('location = /auth/me') < conf.indexOf('location /auth/ {'));
++assert.match(probe, /limit_req zone=ovservices_api burst=30 nodelay;/);
++assert.match(auth, /limit_req zone=ovservices_auth burst=10 nodelay;/);
++for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);
++const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];
++assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');
++console.log('nginx auth limit: /auth/me on the API zone, 429 when limited');
ddiff --git a/test/no-internal-key.test.js b/test/no-internal-key.test.js
nnew file mode 100644
iindex 0000000..3c071c0
---- /dev/null
++++ b/test/no-internal-key.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * No X-Internal-Key anywhere (ADR-014: "No developer-facing path ever accepts X-Internal-Key").
++ * Code, client scripts, deploy files, the environment template and the manifest proposals must not
++ * mention it; the running portal never sends it to Network (the Network mock refuses any request
++ * that carries one), and a request that presents one gets no special treatment.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++const ROOT = path.join(__dirname, '..');
++const PATTERN = /x-internal-key|internal[_-]?key|INTERNAL_API_KEY/i;
++
++function files(dir) {
++ const out = [];
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) out.push(...files(p)); else out.push(p);
++ }
++ return out;
++}
++
++(async () => {
++ await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {
++ const targets = [
++ ...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),
++ ...(fs.existsSync(path.join(ROOT, 'docs')) ? files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')) : []),
++ path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),
++ ].filter((f) => fs.existsSync(f));
++ assert.ok(targets.length > 20);
++ const hits = targets.filter((f) => PATTERN.test(fs.readFileSync(f, 'utf8')));
++ assert.deepStrictEqual(hits.map((f) => path.relative(ROOT, f)), []);
++ });
++
++ const t = await boot();
++ await check('a whole portal session never sends an internal key to Network', async () => {
++ const u = t.network.addUser('nia');
++ const p = await t.project(u);
++ const a = await t.app(u, p);
++ await t.grant(u, p, a.id, 'media.object.upload');
++ await t.get(`/projects/${p}`, { as: u });
++ await t.get(`/projects/${p}/apps/${a.id}`, { as: u });
++ await t.get(`/projects/${p}/apps/${a.id}/playground/media`, { as: u, multipart: { fields: { credential_type: 'client_secret', credential: a.secret }, file: { name: 'x', content: 'x' } } });
++ const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);
++ assert.strictEqual(sent.length, 0);
++ assert.ok(t.network.requests.length > 10);
++ });
++
++ await check('presenting X-Internal-Key gets nothing: the API still wants an app token', async () => {
++ const r = await t.get('/api/v1/apps/app_01JABCDEFGHJKMNPQRSTVWXYZ0/releases', { method: 'POST', headers: { 'x-internal-key': 'anything', 'content-type': 'application/json' }, body: '{}' });
++ assert.strictEqual(r.status, 401);
++ const page = await t.get('/projects', { headers: { 'x-internal-key': 'anything' } });
++ assert.strictEqual(page.status, 401);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/open-redirect.test.js b/test/open-redirect.test.js
nnew file mode 100644
iindex 0000000..083e35b
---- /dev/null
++++ b/test/open-redirect.test.js
@@@ -0,0 +1,20 @@
++'use strict';
++// Open redirect regression (WS-R task 5): the sign-in "next" never leaves the site. Browsers drop tab and
++// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;
++// until 2026-09-26 it got through here.
++const assert = require('assert');
++const { sanitizeNext } = require('../server/auth/sso');
++
++const config = { baseUrl: 'https://site.openvibe.test', network: { url: 'https://openvibe.network' } };
++const home = (v) => {
++ const out = String(sanitizeNext(v, config));
++ const host = new URL(out.replace(/[\t\n\r]/g, ''), 'https://site.openvibe.test').host;
++ return host === 'site.openvibe.test' || host === 'openvibe.network';
++};
++for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/\n/evil.com', '\t//evil.com',
++ ' //evil.com', '/\\/evil.com', 'https:/\\evil.com', '///evil.com', 'javascript:alert(1)', '/%09/evil.com']) {
++ assert.ok(home(v), `${JSON.stringify(v)} stays on the site (got ${JSON.stringify(sanitizeNext(v, config))})`);
++ assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));
++}
++assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');
++console.log('open redirect: all checks passed');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
nnew file mode 100644
iindex 0000000..e1b80b9
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++// Size budgets for openvibe.services' home page (roadmap WS-T task 1, openvibe-shared/perf-budget): the
++// server as it runs (a fresh database), measured without a browser. Budgets sit a little above the
++// 2026-09-29 measurement; raising one is a decision to state in the commit.
++// node test/perf-budget.test.js
++const assert = require('assert');
++const fs = require('fs');
++const net = require('net');
++const os = require('os');
++const path = require('path');
++const { spawn } = require('child_process');
++const { measure, check, format } = require('openvibe-shared/perf-budget');
++
++const BUDGETS = {
++ htmlRawKB: 33, // measured 30.8 (fresh database, 2026-10-08: the developer platform's home — six ring icons plus the text-only catalog of services you can build on)
++ htmlBrotliKB: 8, // 7.4
++ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
++ jsRawKB: 245, // 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
++ jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
++ cssFiles: 2, // 1
++ cssRawKB: 19, // 17.3 (services.css + the cached /shared/showcase.css)
++ cssBrotliKB: 5, // 4.1
++ externalFiles: 2, // 0
++};
++
++const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
++
++(async () => {
++ // A database of its own (not the shared dev PGlite in data/pglite, and not whatever the caller's
++ // DATABASE_URL names), so the measurement is the server on a fresh database and the run leaves nothing.
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-budget-'));
++ const pgliteDir = path.join(dir, 'pglite');
++ const port = await freePort();
++ const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
++ cwd: path.join(__dirname, '..'),
++ env: {
++ ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',
++ DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '', SERVICES_PGLITE_DIR: pgliteDir,
++ },
++ stdio: ['ignore', 'ignore', 'pipe'],
++ });
++ let stderr = '';
++ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
++ const base = `http://127.0.0.1:${port}`;
++ try {
++ let up = false;
++ for (let i = 0; i < 150 && !up; i++) {
++ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
++ if (!up) await new Promise((r) => setTimeout(r, 100));
++ }
++ assert.ok(up, `the server did not start:\n${stderr}`);
++ assert.ok(fs.existsSync(pgliteDir), 'the server did not use the isolated database (SERVICES_PGLITE_DIR)');
++ const m = await measure({ base });
++ const over = check(m, BUDGETS);
++ assert.deepStrictEqual(over, [], format(m, over));
++ console.log(format(m));
++ console.log('perf budget: all checks passed');
++ } finally {
++ child.kill();
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/playground.test.js b/test/playground.test.js
nnew file mode 100644
iindex 0000000..e4704c8
---- /dev/null
++++ b/test/playground.test.js
@@@ -0,0 +1,176 @@
++'use strict';
++/**
++ * Playgrounds cannot exceed the project's grants.
++ * - without the grant: refused at the grant step, the missing grant is named, and NOTHING else
++ * happens (no token request to Network, no call to Events or Media)
++ * - production apps and revoked apps are refused
++ * - with the grant: the app's own token (from its secret, or pasted) is used through the SDK
++ * - a pasted token for another app, another audience or without the capability is refused
++ * - the Network refusing the token request surfaces as Network said it
++ * - the Events playground uses events.app.publish: app.<project_key>.* types, source app-<ulid>
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('mia');
++ const projectId = await t.project(owner, 'Play');
++ const app = await t.app(owner, projectId);
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const key = `p${projectId.replace(/^prj_/, '').toLowerCase()}`;
++ const media = async (fields, file = { name: 'a.txt', content: 'hello' }) => await t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });
++ const outside = () => ({
++ token: t.network.tokenRequests.filter((x) => x.client_id === app.id).length,
++ media: t.media.requests.length,
++ events: t.events.requests.length,
++ });
++
++ await check('without media.object.upload: refused at the grant step, nothing requested or called', async () => {
++ const before = outside();
++ const r = await media({ credential_type: 'client_secret', credential: app.secret });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /does not hold media\.object\.upload/);
++ assert.match(r.text, /Request media\.object\.upload on the app page/);
++ assert.deepStrictEqual(outside(), before);
++ const page = await t.get(`${base}/playground`, { as: owner });
++ assert.match(page.text, /Cannot run:/);
++ assert.ok(!page.text.includes(`action="${base}/playground/media"`), 'no upload form without the grant');
++ });
++
++ await check('without events.app.publish: refused, the grant named, nothing requested or called', async () => {
++ const before = outside();
++ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: `app.${key}.test.ping`, credential_type: 'client_secret', credential: app.secret } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /does not hold events\.app\.publish/);
++ assert.match(r.text, /Request events\.app\.publish on the app page/);
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await check('a requested (not approved) grant still refuses', async () => {
++ await t.get(`${base}/grants`, { as: owner, form: { capability: 'media.object.upload' } }); // outside the allowance → requested
++ const before = outside();
++ const r = await media({ credential_type: 'client_secret', credential: app.secret });
++ assert.strictEqual(r.status, 403);
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await check('with the grant and the client secret: uploads through the SDK with the app token', async () => {
++ await t.grant(owner, projectId, app.id, 'media.object.upload');
++ const r = await media({ credential_type: 'client_secret', credential: app.secret }, { name: 'hello.txt', content: 'hello playground' });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 400));
++ assert.match(r.text, /Done\./);
++ assert.strictEqual(t.media.uploads.length, 1);
++ assert.strictEqual(t.media.uploads[0].app, projectId, 'the namespace is the project id');
++ const tok = t.network.tokenRequests.filter((x) => x.client_id === app.id).pop();
++ assert.strictEqual(tok.audience, 'openvibe.media');
++ assert.strictEqual(tok.scope, 'media.object.upload');
++ });
++
++ await check('a pasted token for another app is refused before any call', async () => {
++ const other = await t.app(owner, projectId, { name: 'Other' });
++ await t.grant(owner, projectId, other.id, 'media.object.upload');
++ const foreign = t.network.mintApp(other.id, 'openvibe.media', ['media.object.upload']);
++ const before = outside();
++ const r = await media({ credential_type: 'access_token', credential: foreign });
++ assert.strictEqual(r.status, 401);
++ assert.match(r.text, /not app:/);
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await check('a pasted token without the capability, or for another audience, is refused', async () => {
++ const noCap = t.network.mintApp(app.id, 'openvibe.media', ['media.object.read']);
++ const r1 = await media({ credential_type: 'access_token', credential: noCap });
++ assert.strictEqual(r1.status, 401);
++ assert.match(r1.text, /does not carry media\.object\.upload/);
++ const wrongAud = t.network.mintApp(app.id, 'openvibe.events', ['media.object.upload']);
++ const r2 = await media({ credential_type: 'access_token', credential: wrongAud });
++ assert.strictEqual(r2.status, 401);
++ assert.match(r2.text, /not for openvibe\.media/);
++ const prod = t.network.mintApp(app.id, 'openvibe.media', ['media.object.upload'], { env: 'production' });
++ const r3 = await media({ credential_type: 'access_token', credential: prod });
++ assert.strictEqual(r3.status, 401);
++ assert.match(r3.text, /not sandbox/);
++ });
++
++ await check('Network refusing the token request is shown as Network said it', async () => {
++ const r = await media({ credential_type: 'client_secret', credential: 'ovsec_wrong' });
++ assert.strictEqual(r.status, 401);
++ assert.match(r.text, /invalid_client/);
++ assert.match(r.text, /Network refused the token request/);
++ });
++
++ await check('a sandbox audience Network does not allow: invalid_target surfaces', async () => {
++ const t2 = await boot({ network: { sandboxAudiences: [] } });
++ const o = t2.network.addUser('ned');
++ const p = await t2.project(o);
++ const a = await t2.app(o, p);
++ await t2.grant(o, p, a.id, 'media.object.upload');
++ const r = await t2.get(`/projects/${p}/apps/${a.id}/playground/media`, { as: o, multipart: { fields: { credential_type: 'client_secret', credential: a.secret }, file: { name: 'x', content: 'x' } } });
++ assert.strictEqual(r.status, 400);
++ assert.match(r.text, /invalid_target/);
++ assert.match(r.text, /does not accept sandbox tokens/);
++ assert.strictEqual(t2.media.requests.length, 0);
++ await t2.close();
++ });
++
++ await check('production apps are refused', async () => {
++ t.network.setEnvironmentPolicy(projectId, 'sandbox+production');
++ const prod = await t.app(owner, projectId, { name: 'Prod', environment: 'production' });
++ await t.grant(owner, projectId, prod.id, 'media.object.upload');
++ const r = await t.get(`/projects/${projectId}/apps/${prod.id}/playground/media`, { as: owner, multipart: { fields: { credential_type: 'client_secret', credential: prod.secret }, file: { name: 'x', content: 'x' } } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /playgrounds run only sandbox apps/);
++ });
++
++ await check('revoking the grant in Network stops the playground at once', async () => {
++ await t.get(`${base}/grants/media.object.upload/revoke`, { as: owner, form: {} });
++ const before = outside();
++ const r = await media({ credential_type: 'client_secret', credential: app.secret });
++ assert.strictEqual(r.status, 403);
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await check('the run log records outcomes and services, never the credential', async () => {
++ const rows = await t.ctx.store.db.prepare('SELECT * FROM playground_runs WHERE app_id = ? ORDER BY at').all(app.id);
++ assert.ok(rows.length >= 6);
++ assert.ok(rows.some((x) => x.outcome === 'ok' && x.ref));
++ assert.ok(rows.some((x) => x.stage === 'grant' && x.code === 'playground.grant_missing'));
++ const dump = JSON.stringify(rows);
++ assert.ok(!dump.includes(app.secret));
++ assert.ok(!/eyJ[A-Za-z0-9_-]{10,}\./.test(dump), 'no JWT in the run log');
++ });
++
++ await check('a non-member cannot open the playground of another project\'s app', async () => {
++ const stranger = t.network.addUser('zed');
++ const r = await t.get(`${base}/playground`, { as: stranger });
++ assert.strictEqual(r.status, 404);
++ assert.match(r.text, /project\.not_found/);
++ });
++
++ await check('with events.app.publish: publishes as the app, with its source and project-scoped type', async () => {
++ await t.grant(owner, projectId, app.id, 'events.app.publish');
++ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: `app.${key}.test.ping`, payload: '{"n":1}', credential_type: 'client_secret', credential: app.secret } });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 600));
++ assert.match(r.text, /Done\./);
++ assert.strictEqual(t.events.published.length, 1);
++ const e = t.events.published[0];
++ assert.strictEqual(e.sub, `app:${app.id}`);
++ assert.strictEqual(e.event.source, `app-${app.id.replace(/^app_/, '').toLowerCase()}`);
++ assert.deepStrictEqual(e.event.actor, { type: 'app', id: app.id });
++ const tok = t.network.tokenRequests.filter((x) => x.client_id === app.id).pop();
++ assert.strictEqual(tok.audience, 'openvibe.events');
++ assert.strictEqual(tok.scope, 'events.app.publish');
++ });
++
++ await check('an event type outside the project\'s app.<project_key>.* space is refused locally', async () => {
++ const before = outside();
++ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: 'network.app.created', credential_type: 'client_secret', credential: app.secret } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, new RegExp(`starting with app\\.${key}\\.`));
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/project-archive.test.js b/test/project-archive.test.js
nnew file mode 100644
iindex 0000000..f5ddb7e
---- /dev/null
++++ b/test/project-archive.test.js
@@@ -0,0 +1,280 @@
++'use strict';
++/**
++ * The full project archive (roadmap WS-N task 9; server/domain/project-archive.js):
++ * - owner and admins only (developers, viewers, non-members, forged forms get nothing, and no
++ * export token is minted for them); Network mints one read-only export token per service and
++ * environment with the person's own token, and Media and Events are read only with those
++ * - one zip: manifest.json, README.txt, project.json (the metadata document with the audit log),
++ * modules/<release>.json, media/<env>/(namespaces.json|objects.jsonl), events/<env>.jsonl, for
++ * both environments; every object (deleted and uploading ones too) with a public or signed URL
++ * or none; every retained app event of the project, paged; nothing of another project
++ * - never a secret or a token in the archive, Services' database or its logs
++ * - a failing service fails the whole export (never a partial zip); a limit makes the part and
++ * the archive say complete: false and where they stopped
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const manifests = require('../server/domain/manifests');
++const { FORMAT } = require('../server/domain/project-archive');
++const { unzip } = require('./helpers/unzip');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('ona');
++ const admin = t.network.addUser('ada');
++ const dev = t.network.addUser('dev');
++ const viewer = t.network.addUser('vee');
++ const stranger = t.network.addUser('sam');
++ const staff = t.network.addUser('staffer', { role: 'admin' });
++ const projectId = await t.project(owner, 'Archived things');
++ t.network.setEnvironmentPolicy(projectId, 'sandbox+production');
++ for (const [u, role] of [['ada', 'admin'], ['dev', 'developer'], ['vee', 'viewer']]) {
++ assert.strictEqual((await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: u, role } })).status, 303);
++ }
++ const app = await t.app(owner, projectId, { name: 'Shop' });
++ assert.ok(app.secret);
++ const manifest = JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Shop' }), version: '1.2.3' });
++ let r = await t.get(`/projects/${projectId}/apps/${app.id}/releases`, { as: owner, form: { kind: 'app', manifest, intent: 'create' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const releaseId = r.headers.get('location').split('/').pop();
++ assert.strictEqual((await t.get(`/releases/${releaseId}/publish`, { as: owner, form: {} })).status, 303);
++
++ // What the services hold: this project's objects and events in both environments, and another project's.
++ const otherProject = await t.project(stranger, 'Not mine');
++ const O = {
++ pub: [0, 1, 2].map(() => t.media.addObject(projectId, 'production', { visibility: 'public' })),
++ priv: [0, 1].map(() => t.media.addObject(projectId, 'production', { visibility: 'private', size: 2048 })),
++ uploading: t.media.addObject(projectId, 'production', { status: 'uploading' }),
++ deleted: t.media.addObject(projectId, 'production', { status: 'deleted' }),
++ sandbox: [0, 1, 2].map(() => t.media.addObject(projectId, 'sandbox', { visibility: 'public' })),
++ other: t.media.addObject(otherProject, 'production', { visibility: 'public' }),
++ };
++ const E = { production: [], sandbox: [], other: [] };
++ for (let i = 0; i < 1203; i++) {
++ E.production.push(t.events.addAppEvent(projectId, 'production', 'order.created', { n: i }));
++ if (i % 400 === 0) E.sandbox.push(t.events.addAppEvent(projectId, 'sandbox', 'order.tested', { n: i }));
++ if (i % 300 === 0) E.other.push(t.events.addAppEvent(otherProject, 'production', 'order.created', { n: i }));
++ }
++
++ const archivePath = `/projects/${projectId}/export/archive`;
++ const exportAs = async (who, form = {}) => await t.get(archivePath, { as: who, form });
++ const minted = () => t.network.state.exportTokens;
++ const lines = (buf) => buf.toString('utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
++ let files = null;
++
++ await check('the owner downloads one zip with every part, for both environments', async () => {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 500));
++ assert.strictEqual(r.headers.get('content-type'), 'application/zip');
++ assert.match(r.headers.get('content-disposition'), new RegExp(`^attachment; filename="openvibe-project-${projectId}-\\d{4}-\\d{2}-\\d{2}\\.zip"$`));
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ files = unzip(r.buffer);
++ assert.deepStrictEqual([...files.keys()], [
++ 'manifest.json', 'README.txt', 'project.json', `modules/${releaseId}.json`,
++ 'media/production/namespaces.json', 'media/production/objects.jsonl', 'events/production.jsonl',
++ 'media/sandbox/namespaces.json', 'media/sandbox/objects.jsonl', 'events/sandbox.jsonl',
++ ]);
++ const m = JSON.parse(files.get('manifest.json'));
++ assert.strictEqual(m.format, FORMAT);
++ assert.strictEqual(m.format_version, 1);
++ assert.strictEqual(m.exported_by, owner.subject);
++ assert.deepStrictEqual(m.project, { id: projectId, name: 'Archived things', archived_at: null });
++ assert.strictEqual(m.complete, true);
++ assert.deepStrictEqual([m.parts.media.production.objects.count, m.parts.media.sandbox.objects.count, m.parts.events.production.count, m.parts.events.sandbox.count], [7, 3, 1203, E.sandbox.length]);
++ assert.deepStrictEqual(m.parts.media.production.objects.downloads, { public: 3, signed: 2, none: 2 });
++ assert.deepStrictEqual(m.parts.media.sandbox.objects.downloads, { public: 0, signed: 3, none: 0 });
++ assert.deepStrictEqual(m.parts.modules.files.map((x) => [x.release_id, x.kind, x.version, x.status, x.schema]), [[releaseId, 'app', '1.2.3', 'published', 'services.app-manifest@1']]);
++ assert.ok(m.not_included.some((x) => /Tools job results/.test(x.what)) && m.not_included.some((x) => /user modules/.test(x.what)));
++ for (const f of m.files) {
++ assert.strictEqual(files.get(f.path).length, f.bytes, f.path);
++ assert.strictEqual(crypto.createHash('sha256').update(files.get(f.path)).digest('hex'), f.sha256, f.path);
++ }
++ assert.match(files.get('README.txt').toString(), /Complete: every part/);
++ });
++
++ await check('project.json is the metadata document with the audit log; modules are the manifests as validated', async () => {
++ const x = JSON.parse(files.get('project.json'));
++ assert.strictEqual(x.format, 'openvibe.services.project-export');
++ assert.strictEqual(x.project.id, projectId);
++ assert.deepStrictEqual(x.members.map((mm) => mm.username).sort(), ['ada', 'dev', 'ona', 'vee']);
++ assert.ok(Array.isArray(x.audit.entries) && x.audit.entries.some((e) => e.action === 'app.created'), 'the audit log (admin+)');
++ assert.deepStrictEqual(x.services.releases.map((rel) => rel.id), [releaseId]);
++ assert.deepStrictEqual(JSON.parse(files.get(`modules/${releaseId}.json`)), JSON.parse(manifest));
++ });
++
++ await check('objects: all of this project\'s, per environment, each with a public URL, a signed one or none', async () => {
++ const prod = lines(files.get('media/production/objects.jsonl'));
++ const sbx = lines(files.get('media/sandbox/objects.jsonl'));
++ const ids = (list) => list.map((o) => o.id).sort();
++ assert.deepStrictEqual(ids(prod), [...O.pub, ...O.priv, O.uploading, O.deleted].map((o) => o.id).sort(), 'deleted and uploading objects are listed too');
++ assert.deepStrictEqual(ids(sbx), O.sandbox.map((o) => o.id).sort());
++ assert.ok(![...prod, ...sbx].some((o) => o.id === O.other.id), 'nothing of another project');
++ const byId = new Map(prod.map((o) => [o.id, o]));
++ for (const o of O.pub) assert.deepStrictEqual(byId.get(o.id).download, { url: o.public_url, expires_at: null, public: true });
++ for (const o of [...O.priv.map((x) => byId.get(x.id)), ...sbx]) {
++ assert.strictEqual(o.download.public, false);
++ assert.match(o.download.url, new RegExp(`/o/${o.id}\\?exp=\\d+&sig=`));
++ const left = Date.parse(o.download.expires_at) - Date.now();
++ assert.ok(left > 55 * 60 * 1000 && left <= 3600 * 1000, 'signed for an hour');
++ }
++ assert.strictEqual(byId.get(O.uploading.id).download, null);
++ assert.strictEqual(byId.get(O.deleted.id).download, null);
++ assert.strictEqual(byId.get(O.priv[0].id).size_bytes, 2048, 'Media\'s metadata as it answered');
++ const ns = JSON.parse(files.get('media/sandbox/namespaces.json'));
++ assert.deepStrictEqual(ns.namespaces.map((n) => n.namespace), [`app.${projectId}.sandbox`]);
++ });
++
++ await check('events: every retained app event of the project, per environment, paged in order', async () => {
++ const prod = lines(files.get('events/production.jsonl'));
++ const sbx = lines(files.get('events/sandbox.jsonl'));
++ assert.deepStrictEqual(prod.map((e) => e.seq), E.production.map((e) => e.seq), 'all 1,203, across two pages');
++ assert.deepStrictEqual(sbx.map((e) => e.event.event_id), E.sandbox.map((e) => e.event.event_id));
++ assert.deepStrictEqual(prod[5].event, E.production[5].event, 'the envelope as Events keeps it');
++ const pulls = t.events.requests.filter((q) => q.method === 'GET' && q.path.startsWith('/api/v1/events'));
++ assert.ok(pulls.length >= 3, 'production took more than one page');
++ });
++
++ await check('Network minted the tokens for the owner, one per service and environment; Media and Events saw only those', async () => {
++ const mine = minted().filter((x) => x.subject === owner.subject);
++ assert.deepStrictEqual(mine.map((x) => `${x.audience} ${x.env}`).sort(), ['openvibe.events production', 'openvibe.events sandbox', 'openvibe.media production', 'openvibe.media sandbox']);
++ const tokens = new Set(minted().map((x) => `Bearer ${x.token}`));
++ const reads = [...t.media.requests, ...t.events.requests].filter((q) => q.method === 'GET');
++ assert.ok(reads.length > 10);
++ assert.ok(reads.every((q) => tokens.has(q.auth)), 'every read carried an export token, never the person\'s own');
++ const mintCalls = t.network.requests.filter((q) => q.path.endsWith('/export-tokens'));
++ assert.strictEqual(mintCalls.length, 4);
++ const claimsOf = (h) => JSON.parse(Buffer.from(String(h).split('.')[1], 'base64url').toString('utf8'));
++ assert.ok(mintCalls.every((q) => claimsOf(q.headers.authorization).sub === owner.id && !claimsOf(q.headers.authorization).actor_type), 'asked with the person\'s own Network token');
++ });
++
++ await check('no secret and no token in the archive, in Services\' database or in its logs', async () => {
++ const everything = [...files.values()].map((b) => b.toString('utf8')).join('\n');
++ assert.ok(!everything.includes(app.secret), 'the client secret');
++ assert.ok(!/ovsec_/.test(everything));
++ assert.ok(!everything.includes(t.network.userToken(owner).split('.')[2]), 'the person\'s token');
++ for (const x of minted()) {
++ const sig = x.token.split('.')[2];
++ assert.ok(!everything.includes(sig), 'an export token in the archive');
++ assert.ok(!(await t.dbDump()).includes(sig), 'an export token in Services\' database');
++ assert.ok(!t.logs().includes(sig), 'an export token in the logs');
++ }
++ assert.ok(!/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(everything), 'no JWT at all');
++ assert.match(t.logs(), new RegExp(`project ${projectId} archive exported by user:${owner.subject}: 10 objects, ${1203 + E.sandbox.length} events, 1 modules`));
++ });
++
++ await check('an admin may download it; the project page offers it to owner and admins only', async () => {
++ r = await exportAs(admin);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(minted().some((x) => x.subject === admin.subject));
++ assert.match((await t.get(`/projects/${projectId}`, { as: admin })).text, /Download full archive \(zip\)/);
++ const devPage = (await t.get(`/projects/${projectId}`, { as: dev })).text;
++ assert.ok(!devPage.includes(`action="${archivePath}"`));
++ assert.match(devPage, /for the project's owner and admins/);
++ assert.match(devPage, /Download export \(JSON\)/, 'the metadata export stays for every member');
++ });
++
++ await check('developers, viewers, staff outside the project, strangers, forged forms and anonymous visitors get nothing, and nothing is minted', async () => {
++ const before = minted().length;
++ const readsBefore = t.media.requests.length + t.events.requests.length;
++ for (const who of [dev, viewer, staff]) {
++ r = await exportAs(who);
++ assert.strictEqual(r.status, 403, who.username);
++ assert.ok(!r.headers.get('content-disposition'));
++ }
++ r = await exportAs(stranger);
++ assert.strictEqual(r.status, 404);
++ assert.match(r.text, /project\.not_found/);
++ r = await exportAs(owner, { csrf: 'forged' });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual((await t.get(archivePath, { method: 'POST', form: {} })).status, 401);
++ assert.strictEqual((await t.get(archivePath, { as: owner })).status, 404, 'no GET: it is a form');
++ assert.strictEqual(minted().length, before);
++ assert.strictEqual(t.media.requests.length + t.events.requests.length, readsBefore);
++ });
++
++ const failsWhole = async (label, arrange, pattern) => {
++ arrange();
++ try {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 502, `${label}: ${r.status} ${r.text.slice(0, 200)}`);
++ assert.ok(!r.headers.get('content-disposition'), `${label}: no download`);
++ assert.match(r.headers.get('content-type'), /text\/html/);
++ assert.match(r.text, pattern, label);
++ assert.match(r.text, /Nothing was exported/);
++ } finally { t.media.clear(); t.events.clear(); }
++ };
++
++ await check('a failing service fails the whole export: nothing is downloaded, the page names the service, part and environment', async () => {
++ await failsWhole('Media listing', () => t.media.fail(/\/objects\?/, 503), /OpenVibe\.Media answered 503[^]*objects \(production\)/);
++ await failsWhole('one signed URL', () => t.media.fail(new RegExp(`/objects/${O.sandbox[1].id}/download`), 500, 'media.internal'), /OpenVibe\.Media answered 500 \(media\.internal\)[^]*download URLs \(sandbox\)/);
++ await failsWhole('Events refusing', () => t.events.fail(/\/api\/v1\/events\?/, 403, 'capability.denied'), /OpenVibe\.Events answered 403 \(capability\.denied\)[^]*events \(production\)/);
++ await failsWhole('Events unreachable', () => { t.ctx.config.export.eventsUrl = 'http://127.0.0.1:9'; }, /OpenVibe\.Events did not answer[^]*events \(production\)/);
++ t.ctx.config.export.eventsUrl = t.events.url;
++ });
++
++ await check('when Network refuses the export token, its answer is shown and no service is called', async () => {
++ const orig = t.ctx.network.projects.exportToken;
++ t.ctx.network.projects.exportToken = async () => { throw Object.assign(new (require('openvibe-sdk/core').OpenVibeError)({ status: 403, code: 'project.forbidden', message: 'requires admin role' })); };
++ const readsBefore = t.media.requests.length;
++ try {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 403, r.text.slice(0, 300));
++ assert.match(r.text, /project\.forbidden/);
++ assert.ok(!r.headers.get('content-disposition'));
++ assert.strictEqual(t.media.requests.length, readsBefore);
++ } finally { t.ctx.network.projects.exportToken = orig; }
++ });
++
++ await check('limits: a part that reaches one says complete: false and where it stopped; so does the archive', async () => {
++ const x = t.ctx.config.export;
++ const saved = { ...x };
++ x.maxObjects = 4;
++ x.maxEvents = 1000;
++ try {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ const z = unzip(r.buffer);
++ const m = JSON.parse(z.get('manifest.json'));
++ assert.strictEqual(m.complete, false);
++ const o = m.parts.media.production.objects;
++ assert.deepStrictEqual([o.count, o.complete, o.limit], [4, false, 4]);
++ const listed = lines(z.get('media/production/objects.jsonl'));
++ assert.strictEqual(listed.length, 4);
++ assert.strictEqual(o.next_cursor, listed[3].id, 'continue from the last one listed');
++ assert.strictEqual(m.parts.media.sandbox.objects.complete, true, 'the sandbox has 3: under the limit');
++ const ev = m.parts.events.production;
++ assert.deepStrictEqual([ev.count, ev.complete, ev.next_after_seq], [1000, false, E.production[999].seq]);
++ assert.strictEqual(lines(z.get('events/production.jsonl')).length, 1000);
++ assert.strictEqual(m.parts.events.sandbox.complete, true);
++ assert.match(z.get('README.txt').toString(), /NOT COMPLETE/);
++ } finally { Object.assign(x, saved); }
++ });
++
++ await check('a long export asks Network again before a token expires', async () => {
++ t.network.state.exportTtl = 30; // under the one-minute margin: every read gets a fresh token
++ const before = minted().length;
++ try {
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(minted().length - before > 10, `re-minted (${minted().length - before})`);
++ } finally { t.network.state.exportTtl = 300; }
++ });
++
++ await check('an archived project can still be exported by its owner', async () => {
++ assert.strictEqual((await t.get(`/projects/${projectId}/archive`, { as: owner, form: { confirm: '1' } })).status, 303);
++ r = await exportAs(owner);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(JSON.parse(unzip(r.buffer).get('manifest.json')).project.archived_at);
++ });
++
++ await check('the docs describe the archive', async () => {
++ r = await t.get('/docs/export');
++ assert.strictEqual(r.status, 200);
++ for (const s of ['manifest.json', 'objects.jsonl', 'events/&lt;env&gt;.jsonl', 'modules/&lt;release_id&gt;.json', 'All or nothing', 'Not included']) assert.ok(r.text.includes(s), s);
++ assert.match((await t.get('/docs')).text, /href="\/docs\/export"/);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/project-export-delete.test.js b/test/project-export-delete.test.js
nnew file mode 100644
iindex 0000000..33f9f96
---- /dev/null
++++ b/test/project-export-delete.test.js
@@@ -0,0 +1,207 @@
++'use strict';
++/**
++ * Project export and delete.
++ * - export: one JSON document, read from Network's /api/v1/projects with the person's token
++ * (project, members, apps with credentials and grants, quotas, audit for admin+) plus Services'
++ * releases, trust and playground runs; never a secret; any member may export; a Network
++ * failure fails the export instead of returning a partial one
++ * - delete: owner (or staff) only, confirmed by the project name, CSRF-guarded; archives in
++ * Network first and changes nothing in Services when Network refuses; then deletes drafts (and
++ * their manifests) and playground runs, and revokes public releases with services.app.revoked
++ */
++const assert = require('assert');
++const manifests = require('../server/domain/manifests');
++const { buildExport, FORMAT } = require('../server/domain/project-export');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const db = t.ctx.store.db;
++ const owner = t.network.addUser('ona');
++ const dev = t.network.addUser('dev');
++ const viewer = t.network.addUser('vee');
++ const stranger = t.network.addUser('sam');
++ const projectId = await t.project(owner, 'Exportable');
++ await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'dev', role: 'developer' } });
++ await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vee', role: 'viewer' } });
++ const app = await t.app(owner, projectId, { name: 'Exporter' });
++ assert.ok(app.secret, 'a confidential app gets a secret');
++ await t.grant(owner, projectId, app.id, 'media.object.upload');
++ const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Exporter' }), version });
++ const draft = async (version) => {
++ const r = await t.get(`/projects/${projectId}/apps/${app.id}/releases`, { as: owner, form: { kind: 'app', manifest: manifest(version), intent: 'create' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ return r.headers.get('location').split('/').pop();
++ };
++ const published = await draft('1.0.0');
++ assert.strictEqual((await t.get(`/releases/${published}/publish`, { as: owner, form: {} })).status, 303);
++ const drafted = await draft('1.1.0');
++ const draftManifest = (await db.prepare('SELECT manifest_id FROM releases WHERE id = ?').get(drafted)).manifest_id;
++ await db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, detail)
++ VALUES ('run_01JTEST00000000000000000000', ?, ?, ?, ?, 'media', 'media.object.upload', 'client_secret', 'ok', 'done', 'stored 5 bytes')`)
++ .run(new Date().toISOString(), `user:${owner.subject}`, projectId, app.id);
++ const count = async (sql, ...a) => (await db.prepare(sql).get(...a)).n;
++ const exportPath = `/projects/${projectId}/export`;
++
++ await check('the owner exports everything Network and Services hold for the project, as a JSON download', async () => {
++ const r = await t.get(exportPath, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(r.headers.get('content-type'), /^application\/json/);
++ assert.strictEqual(r.headers.get('content-disposition'), `attachment; filename="openvibe-project-${projectId}.json"`);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const x = r.json();
++ assert.strictEqual(x.format, FORMAT);
++ assert.strictEqual(x.format_version, 1);
++ assert.strictEqual(x.exported_by, owner.subject);
++ assert.strictEqual(x.project.id, projectId);
++ assert.deepStrictEqual(x.members.map((m) => m.username).sort(), ['dev', 'ona', 'vee']);
++ assert.strictEqual(x.apps.length, 1);
++ assert.strictEqual(x.apps[0].id, app.id);
++ assert.strictEqual(x.apps[0].credentials.length, 1);
++ assert.ok(x.apps[0].credentials[0].hint, 'credentials by id and hint');
++ assert.deepStrictEqual(x.apps[0].grants.map((g) => g.capability), ['media.object.upload']);
++ assert.ok(Array.isArray(x.quotas));
++ assert.ok(Array.isArray(x.audit.entries) && x.audit.entries.length >= 1, 'the owner gets the audit log');
++ assert.strictEqual(x.audit.complete, true);
++ assert.deepStrictEqual(x.services.releases.map((rel) => [rel.id, rel.status]), [[published, 'published'], [drafted, 'draft']]);
++ assert.strictEqual(x.services.releases[0].manifest.body.version, '1.0.0');
++ assert.deepStrictEqual(x.services.releases[0].log.map((l) => l.action), ['created', 'published']);
++ assert.strictEqual(x.services.trust[0].app_id, app.id);
++ assert.strictEqual(x.services.trust[0].tier, 'unreviewed');
++ assert.strictEqual(x.services.playground_runs.length, 1);
++ assert.strictEqual(x.sources.network.api, '/api/v1/projects');
++ });
++
++ await check('the export never contains a secret or a token', async () => {
++ const r = await t.get(exportPath, { as: owner });
++ assert.ok(!r.text.includes(app.secret), 'the client secret');
++ assert.ok(!/ovsec_/.test(r.text));
++ assert.ok(!r.text.includes(t.network.userToken(owner).split('.')[2]));
++ });
++
++ await check('a viewer may export; the audit log (admin+ in Network) is left out and the document says so', async () => {
++ const r = await t.get(exportPath, { as: viewer });
++ assert.strictEqual(r.status, 200);
++ const x = r.json();
++ assert.strictEqual(x.audit.entries, null);
++ assert.match(x.audit.note, /admin role/);
++ assert.strictEqual(x.apps.length, 1);
++ const pageText = (await t.get(`/projects/${projectId}`, { as: viewer })).text;
++ assert.match(pageText, /Download export \(JSON\)/);
++ assert.ok(!pageText.includes(`action="/projects/${projectId}/delete"`), 'no delete form for a viewer');
++ });
++
++ await check('a non-member gets Network\'s answer, not a document; anonymous must sign in', async () => {
++ const r = await t.get(exportPath, { as: stranger });
++ assert.strictEqual(r.status, 404);
++ assert.match(r.text, /project\.not_found/);
++ assert.ok(!r.headers.get('content-disposition'));
++ assert.strictEqual((await t.get(exportPath)).status, 401);
++ });
++
++ await check('when Network fails part-way the export fails; it is never partial', async () => {
++ const orig = t.ctx.network.projects.quotas;
++ t.ctx.network.projects.quotas = async () => { throw Object.assign(new Error('x'), { status: 503 }); };
++ try {
++ const r = await t.get(exportPath, { as: owner });
++ assert.notStrictEqual(r.status, 200);
++ assert.ok(!r.headers.get('content-disposition'));
++ } finally { t.ctx.network.projects.quotas = orig; }
++ });
++
++ const unchanged = async () => {
++ assert.strictEqual(t.network.state.projects.get(projectId).archived_at, null, 'not archived in Network');
++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM releases WHERE project_id = ?', projectId), 2);
++ assert.strictEqual(await count("SELECT COUNT(*) AS n FROM releases WHERE status = 'published' AND id = ?", published), 1);
++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 1);
++ };
++ const del = `/projects/${projectId}/delete`;
++
++ await check('delete: a developer is refused and nothing changes', async () => {
++ const r = await t.get(del, { as: dev, form: { confirm_name: 'Exportable' } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /only the project owner/);
++ await unchanged();
++ });
++
++ await check('delete: the wrong name, or no form token, changes nothing and calls no Network write', async () => {
++ const before = t.network.requests.filter((q) => q.method === 'POST').length;
++ const wrong = await t.get(del, { as: owner, form: { confirm_name: 'exportable' } });
++ assert.strictEqual(wrong.status, 422);
++ assert.match(wrong.text, /type the project name exactly/);
++ const noCsrf = await t.get(del, { as: owner, form: { confirm_name: 'Exportable', csrf: 'forged' } });
++ assert.strictEqual(noCsrf.status, 403);
++ assert.strictEqual(t.network.requests.filter((q) => q.method === 'POST').length, before);
++ await unchanged();
++ });
++
++ await check('delete: when Network refuses to archive, Services keeps everything', async () => {
++ const orig = t.ctx.network.projects.archive;
++ t.ctx.network.projects.archive = async () => { throw Object.assign(new Error('x'), { status: 503 }); };
++ try {
++ const r = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });
++ assert.notStrictEqual(r.status, 303);
++ } finally { t.ctx.network.projects.archive = orig; }
++ await unchanged();
++ });
++
++ await check('delete by the owner: archived in Network, drafts and runs removed, public releases revoked with an event', async () => {
++ const page = await t.get(`/projects/${projectId}`, { as: owner });
++ assert.match(page.text, /Type the project name/);
++ const r = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ assert.match(decodeURIComponent(r.headers.get('location')), /Deleted: 1 revoked, 1 drafts and 1 runs removed/);
++ const p = t.network.state.projects.get(projectId);
++ assert.ok(p.archived_at, 'archived in Network');
++ assert.ok(t.network.state.apps.get(app.id).revoked_at, 'Network revoked the app');
++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM releases WHERE id = ?', drafted), 0, 'draft deleted');
++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM manifests WHERE id = ?', draftManifest), 0, 'its manifest deleted');
++ assert.strictEqual((await db.prepare('SELECT status, revocation_reason FROM releases WHERE id = ?').get(published)).status, 'revoked');
++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 0);
++ const ev = (await db.prepare('SELECT envelope FROM services_events_outbox ORDER BY id').all()).map((x) => (typeof x.envelope === 'string' ? JSON.parse(x.envelope) : x.envelope));
++ const revoked = ev.filter((e) => e.event_type === 'services.app.revoked');
++ assert.strictEqual(revoked.length, 1);
++ assert.strictEqual(revoked[0].payload.release_id, published);
++ assert.deepStrictEqual((await db.prepare('SELECT action FROM release_log WHERE release_id = ? ORDER BY id').all(drafted)).map((l) => l.action), ['created', 'deleted'], 'the append-only log records the deletion');
++ // The public release page now says revoked.
++ assert.match((await t.get(`/releases/${published}`)).text, /revoked/i);
++ });
++
++ await check('after delete: the export still works (archived) and deleting again is harmless', async () => {
++ const x = (await t.get(exportPath, { as: owner })).json();
++ assert.ok(x.project.archived_at);
++ assert.deepStrictEqual(x.services.releases.map((rel) => [rel.id, rel.status]), [[published, 'revoked']]);
++ assert.deepStrictEqual(x.services.playground_runs, []);
++ const again = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });
++ assert.strictEqual(again.status, 303);
++ assert.match(decodeURIComponent(again.headers.get('location')), /Deleted: 0 revoked, 0 drafts and 0 runs removed/);
++ });
++
++ await check('buildExport pages through Network\'s audit log and marks a very long one incomplete', async () => {
++ const fake = (pages) => {
++ let calls = 0;
++ const P = {
++ members: async () => ({ members: [] }), apps: async () => ({ apps: [] }), quotas: async () => ({ quotas: [] }),
++ audit: async (_t, _p, { before, limit }) => {
++ calls++;
++ assert.strictEqual(limit, 200);
++ const n = before ? Number(before) : pages;
++ return { entries: [{ id: n }], next_before: n > 1 ? n - 1 : null };
++ },
++ };
++ return { network: { projects: P }, calls: () => calls };
++ };
++ const deps = { releases: { listForProject: () => [] }, playground: { runsForProject: () => [] }, trust: { get: () => ({}), history: () => [] }, meta: {} };
++ const three = fake(3);
++ const a = await buildExport({ ...deps, network: three.network, token: 't', project: { id: 'prj_x' }, includeAudit: true });
++ assert.deepStrictEqual(a.audit.entries.map((e) => e.id), [3, 2, 1]);
++ assert.strictEqual(a.audit.complete, true);
++ const many = fake(1000);
++ const b = await buildExport({ ...deps, network: many.network, token: 't', project: { id: 'prj_x' }, includeAudit: true });
++ assert.strictEqual(many.calls(), 50);
++ assert.strictEqual(b.audit.complete, false);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/project-usage.test.js b/test/project-usage.test.js
nnew file mode 100644
iindex 0000000..d8a6023
---- /dev/null
++++ b/test/project-usage.test.js
@@@ -0,0 +1,172 @@
++'use strict';
++/**
++ * A project's usage page (WS-N task 4): /projects/:project/usage renders what Network's
++ * GET /api/v1/projects/:project/usage answers (usage by service, capability and day, quotas with
++ * their headroom, errors by code and the sampled failures with trace ids) next to the limits of the
++ * services the project used, server-side and complete without JavaScript. Only the owner, admins and
++ * staff see it (and Network is not asked for anyone else); the range filters are a GET form whose
++ * values reach Network as offered; a Network failure is shown as it answered. Never "free".
++ */
++const assert = require('assert');
++const http = require('http');
++const { usageQuery } = require('../server/render/usage');
++
++const EVENTS_LIMITS = {
++ service: 'events', scope: 'per project and environment (ADR-014)',
++ limits: [
++ { id: 'publish_per_minute', label: 'Events published per minute', capability: 'events.app.publish', unit: 'per_minute', production: 120, sandbox: 30, exceeded: '429 events.quota_exceeded' },
++ { id: 'subscriptions', label: 'Webhook subscriptions', capability: 'events.app.subscribe', unit: 'count', production: 20, sandbox: 5, exceeded: '429 events.quota_exceeded' },
++ ],
++};
++const TRACE = '4bf92f3577b34da6a3ce929d0e0e4736';
++const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
++
++function usageFor(projectId) {
++ const today = new Date().toISOString().slice(0, 10);
++ return {
++ project_id: projectId, env: 'all', range: { days: 30, from: today, to: today }, generated_at: new Date().toISOString(),
++ last_recorded_at: new Date().toISOString(),
++ freshness: 'Usage arrives as hourly rollups a few minutes after each hour closes (UTC); the current hour is not counted yet.',
++ totals: [
++ { service: 'tools', capability: 'tools.job.create', unit: 'jobs', env: 'production', quantity: 4200, errors: 3 },
++ { service: 'events', capability: 'events.app.publish', unit: 'events', env: 'sandbox', quantity: 1800, errors: 61 },
++ ],
++ daily: [
++ { day: today, service: 'tools', capability: 'tools.job.create', dimension: 'img.process', unit: 'jobs', env: 'production', quantity: 4200, errors: 3 },
++ { day: today, service: 'events', capability: 'events.app.publish', dimension: null, unit: 'events', env: 'sandbox', quantity: 1800, errors: 61 },
++ ],
++ quotas: [
++ { capability: 'tools.job.create', limit: 5000, window: 'day', unit: 'jobs', enforced_by: 'openvibe.tools', used: 4200, remaining: 800, window_start: `${today}T00:00:00.000Z`, note: null },
++ { capability: 'events.app.publish', limit: 120, window: 'minute', unit: 'events', enforced_by: 'openvibe.events', used: null, remaining: null, window_start: null, note: 'a minute window is enforced by the service as it happens; hourly rollups cannot show it' },
++ ],
++ errors: {
++ total: 64,
++ by_code: [
++ { service: 'events', capability: 'events.app.publish', code: 'events.quota_exceeded', count: 60 },
++ { service: 'tools', capability: 'tools.job.create', code: 'tools.job.timeout', count: 3 },
++ ],
++ recent: [
++ { at: new Date().toISOString(), env: 'production', service: 'tools', capability: 'tools.job.create', code: 'tools.job.timeout', status: 504, trace_id: TRACE, ref: JOB },
++ { at: new Date().toISOString(), env: 'sandbox', service: 'events', capability: 'events.app.publish', code: 'events.quota_exceeded', status: 429, trace_id: null, ref: null },
++ ],
++ },
++ };
++}
++
++(async () => {
++ const limitsServer = http.createServer((req, res) => {
++ if (req.url !== '/limits.json') { res.statusCode = 404; return res.end(); }
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify(EVENTS_LIMITS));
++ });
++ await new Promise((r) => limitsServer.listen(0, '127.0.0.1', r));
++ process.env.OV_EVENTS_INTERNAL_URL = `http://127.0.0.1:${limitsServer.address().port}`;
++ const { boot, check, done } = require('./helpers/boot');
++ const t = await boot();
++ const owner = t.network.addUser('ona');
++ const admin = t.network.addUser('ada');
++ const dev = t.network.addUser('dev');
++ const viewer = t.network.addUser('vee');
++ const stranger = t.network.addUser('sam');
++ const staff = t.network.addUser('staff', { role: 'admin' });
++ const projectId = await t.project(owner, 'Metered');
++ for (const [who, role] of [['ada', 'admin'], ['dev', 'developer'], ['vee', 'viewer']]) {
++ assert.strictEqual((await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: who, role } })).status, 303);
++ }
++ const page = `/projects/${projectId}/usage`;
++ const usageAsked = (from) => t.network.requests.slice(from).filter((x) => x.path === `/api/v1/projects/${projectId}/usage`);
++
++ await check('before any usage: an honest empty page', async () => {
++ const r = await t.get(page, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(r.text, /No usage has been recorded for this project yet/);
++ assert.match(r.text, /No usage in these days/);
++ assert.match(r.text, /No quotas are recorded for this project/);
++ });
++
++ t.network.setUsage(projectId, usageFor(projectId));
++
++ await check('the owner sees usage by service, capability and day, quota headroom, errors and trace ids, server-rendered', async () => {
++ const r = await t.get(page, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ const body = r.text;
++ assert.match(body, /<h1>Usage <small>Metered<\/small><\/h1>/);
++ assert.match(body, /hourly rollups/);
++ // Summary and by day.
++ assert.match(body, /4,200<\/span> jobs/);
++ assert.match(body, /<code>tools\.job\.create<\/code>/);
++ assert.match(body, /<code>img\.process<\/code>/);
++ assert.match(body, /1,800<\/span> events/);
++ assert.match(body, /61<\/span> errors \(3\.4%\)/);
++ // Quotas: a meter for the day window, the reason for the minute one.
++ assert.match(body, /<meter class="headroom" min="0" max="5000" value="4200" low="4000" high="4750" optimum="0">84%<\/meter>/);
++ assert.match(body, /800<\/span> left/);
++ assert.match(body, /a minute window is enforced by the service as it happens/);
++ assert.match(body, /recorded limit — enforced by <code>openvibe\.tools<\/code>/);
++ // Service limits of the services used: Events publishes them, Tools does not yet.
++ assert.match(body, /Events published per minute/);
++ assert.match(body, /30 a minute/);
++ assert.match(body, /OpenVibe Tools does not publish its limits yet/);
++ assert.ok(body.includes('Counts are what services report each hour. Cost is on your Billing page.'), 'the counts and cost note');
++ // Errors.
++ assert.match(body, /<code>events\.quota_exceeded<\/code>/);
++ assert.match(body, /64<\/span> errors in these days/);
++ assert.ok(body.includes(`<code>${TRACE}</code>`), 'the sampled trace id');
++ assert.ok(body.includes(`<code>${JOB}</code>`), 'the failed job');
++ // Works without JavaScript: the range is a GET form; nothing here needs a script of Services'.
++ assert.match(body, new RegExp(`<form method="get" action="/projects/${projectId}/usage"`));
++ assert.ok(!/src="\/js\//.test(body), 'no Services script on the page');
++ assert.ok(!/\bfree\b|\$0/i.test(body), 'never "free" or "$0"');
++ });
++
++ await check('the range filters reach Network as offered; anything else falls back to 30 days, both environments', async () => {
++ const before = t.network.requests.length;
++ let r = await t.get(`${page}?days=7&env=sandbox`, { as: owner });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<option value="7" selected>/);
++ assert.match(r.text, /<option value="sandbox" selected>/);
++ assert.deepStrictEqual(usageAsked(before).map((x) => x.query), ['?days=7&env=sandbox']);
++ const again = t.network.requests.length;
++ r = await t.get(`${page}?days=365&env=staging`, { as: owner });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<option value="30" selected>/);
++ assert.deepStrictEqual(usageAsked(again).map((x) => x.query), ['?days=30&env=all']);
++ assert.deepStrictEqual(usageQuery({ days: '90', env: 'production' }), { days: 90, env: 'production' });
++ assert.deepStrictEqual(usageQuery({ days: '1; drop', env: '<x>' }), { days: 30, env: 'all' });
++ });
++
++ await check('admins and staff see it; developers and viewers get 403 without Network being asked; strangers 404', async () => {
++ assert.strictEqual((await t.get(page, { as: admin })).status, 200);
++ assert.strictEqual((await t.get(page, { as: staff })).status, 200);
++ const before = t.network.requests.length;
++ for (const who of [dev, viewer]) {
++ const r = await t.get(page, { as: who });
++ assert.strictEqual(r.status, 403, who.username);
++ assert.match(r.text, /for its owner and admins/);
++ }
++ assert.strictEqual(usageAsked(before).length, 0, 'Network was not asked for their usage');
++ assert.strictEqual((await t.get(page, { as: stranger })).status, 404);
++ const anon = await t.get(page);
++ assert.strictEqual(anon.status, 401);
++ });
++
++ await check('the project page links to the usage page for its owner, and no longer says usage is not collected', async () => {
++ const r = await t.get(`/projects/${projectId}`, { as: owner });
++ assert.ok(r.text.includes(`href="/projects/${projectId}/usage"`));
++ assert.ok(!/no usage numbers are collected/.test(r.text));
++ const d = await t.get(`/projects/${projectId}`, { as: dev });
++ assert.ok(!d.text.includes(`href="/projects/${projectId}/usage"`), 'no link for a developer');
++ });
++
++ await check('a Network failure is shown as it answered, never as an empty page', async () => {
++ t.network.setDown(true);
++ const r = await t.get(page, { as: owner });
++ t.network.setDown(false);
++ assert.ok(r.status >= 500, String(r.status));
++ assert.ok(!/No usage in these days/.test(r.text));
++ });
++
++ await t.close();
++ limitsServer.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/releases.test.js b/test/releases.test.js
nnew file mode 100644
iindex 0000000..e23bda3
---- /dev/null
++++ b/test/releases.test.js
@@@ -0,0 +1,198 @@
++'use strict';
++/**
++ * Release metadata (Services-owned, keyed to Network app ids) and its events.
++ * - draft → published → deprecated → revoked, with Network's role rules (developer+ sandbox)
++ * - each public transition writes services.app.published|deprecated|revoked to the SDK outbox in the
++ * same transaction, as a valid events.event-envelope@1
++ * - trust tiers are staff-set metadata and change no permission
++ * - an app manages its own releases with an app token carrying services.release.manage
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('rosa');
++ const viewer = t.network.addUser('vic');
++ const staff = t.network.addUser('root', { role: 'admin' });
++ const projectId = await t.project(owner, 'Releases');
++ await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vic', role: 'viewer' } });
++ const app = await t.app(owner, projectId, { name: 'Releaser' });
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const manifest = (version, extra = {}) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Releaser' }), version, ...extra });
++ const outbox = async () => (await t.ctx.store.db.prepare('SELECT envelope FROM services_events_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
++ let rel1;
++ let rel2;
++
++ await check('the editor validates without creating anything', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'validate' } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Valid\./);
++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get()).n, 0);
++ });
++
++ await check('an invalid manifest creates nothing and shows the errors', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0', { id: 'app_01JZZZZZZZZZZZZZZZZZZZZZZZ' }), intent: 'create' } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /must be this app&#39;s id/);
++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get()).n, 0);
++ });
++
++ await check('a draft is created, visible to members only, and emits nothing', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'create', notes: 'First.' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 400));
++ rel1 = r.headers.get('location').split('/').pop();
++ assert.match(rel1, /^rel_/);
++ assert.strictEqual((await t.get(`/releases/${rel1}`)).status, 404, 'anonymous cannot see a draft');
++ assert.strictEqual((await t.get(`/releases/${rel1}`, { as: owner })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/releases/${rel1}`)).status, 404);
++ assert.strictEqual((await outbox()).length, 0);
++ });
++
++ await check('versions are immutable: the same version again is 409', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'create' } });
++ assert.strictEqual(r.status, 409);
++ assert.match(r.text, /release\.exists/);
++ });
++
++ await check('a viewer cannot publish (Network reports the role; Services enforces its own records)', async () => {
++ const r = await t.get(`/releases/${rel1}/publish`, { as: viewer, form: {} });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /needs the developer role/);
++ });
++
++ await check('publishing emits services.app.published (valid envelope, 3 segments, public)', async () => {
++ const r = await t.get(`/releases/${rel1}/publish`, { as: owner, form: {} });
++ assert.strictEqual(r.status, 303);
++ const ev = await outbox();
++ assert.strictEqual(ev.length, 1);
++ const e = ev[0];
++ assert.strictEqual(e.event_type, 'services.app.published');
++ assert.strictEqual(e.event_type.split('.').length, 3);
++ assert.strictEqual(e.source, 'services');
++ assert.deepStrictEqual(e.subject, { type: 'app', id: app.id });
++ assert.deepStrictEqual(e.actor, { type: 'user', id: owner.subject });
++ assert.strictEqual(e.payload.release_id, rel1);
++ assert.strictEqual(e.payload.project_id, projectId);
++ assert.strictEqual(e.payload.trust_tier, 'unreviewed');
++ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true);
++ const pub = await t.get(`/api/v1/apps/${app.id}/releases`);
++ assert.strictEqual(pub.json().releases[0].status, 'published');
++ const pageAnon = await t.get(`/releases/${rel1}`);
++ assert.strictEqual(pageAnon.status, 200);
++ });
++
++ await check('deprecating needs a reason and a published replacement of the same app', async () => {
++ const r2 = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.1.0'), intent: 'create' } });
++ rel2 = r2.headers.get('location').split('/').pop();
++ const noReason = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: '' } });
++ assert.strictEqual(noReason.status, 422);
++ const draftRepl = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: 'old', replacement: rel2 } });
++ assert.strictEqual(draftRepl.status, 422, 'a draft cannot be the replacement');
++ await t.get(`/releases/${rel2}/publish`, { as: owner, form: {} });
++ const ok = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: 'Use 1.1.0', replacement: rel2 } });
++ assert.strictEqual(ok.status, 303);
++ const e = (await outbox()).pop();
++ assert.strictEqual(e.event_type, 'services.app.deprecated');
++ assert.strictEqual(e.payload.replacement, rel2);
++ });
++
++ await check('revoking needs confirmation and a reason; emits services.app.revoked; is terminal', async () => {
++ const unconfirmed = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'bad' } });
++ assert.strictEqual(unconfirmed.status, 422);
++ const ok = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'Security issue', confirm: '1' } });
++ assert.strictEqual(ok.status, 303);
++ assert.strictEqual((await outbox()).pop().event_type, 'services.app.revoked');
++ const again = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'x', confirm: '1' } });
++ assert.strictEqual(again.status, 409);
++ });
++
++ await check('revoking a draft announces nothing (it was never public)', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('2.0.0-rc.1'), intent: 'create' } });
++ const id = r.headers.get('location').split('/').pop();
++ const n = (await outbox()).length;
++ await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'abandoned', confirm: '1' } });
++ assert.strictEqual((await outbox()).length, n);
++ });
++
++ await check('only the three services.app.* types are ever produced', async () => {
++ const types = new Set((await outbox()).map((e) => e.event_type));
++ for (const x of types) assert.ok(['services.app.published', 'services.app.deprecated', 'services.app.revoked'].includes(x), x);
++ });
++
++ await check('trust tiers: staff only, noted, recorded, and they change no permission', async () => {
++ const denied = await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'reviewed', note: 'me' } });
++ assert.strictEqual(denied.status, 403);
++ const old = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'verified', note: 'pre-ADR-013 name' } });
++ assert.strictEqual(old.status, 422, 'only ADR-013 tiers are accepted');
++ const page = await t.get('/staff', { as: staff });
++ assert.deepStrictEqual([...page.text.matchAll(/<option>([^<]+)<\/option>/g)].map((m) => m[1]), ['unreviewed', 'reviewed', 'first-party']);
++ const ok = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'reviewed', note: 'Reviewed the source' } });
++ assert.strictEqual(ok.status, 303);
++ const trust = (await t.get(`/api/v1/apps/${app.id}/trust`)).json();
++ assert.strictEqual(trust.tier, 'reviewed');
++ assert.match(trust.note_on_authority, /metadata only/);
++ // A reviewed app is still refused by the playground without the grant.
++ const r = await t.get(`${base}/playground/media`, { as: owner, multipart: { fields: { credential_type: 'client_secret', credential: app.secret }, file: { name: 'x', content: 'x' } } });
++ assert.strictEqual(r.status, 403);
++ // And a viewer still cannot publish.
++ const r3 = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('3.0.0'), intent: 'create' } });
++ const id = r3.headers.get('location').split('/').pop();
++ assert.strictEqual((await t.get(`/releases/${id}/publish`, { as: viewer, form: {} })).status, 403);
++ });
++
++ await check('mod releases validate as mods.mod-manifest@1', async () => {
++ const mod = { ...manifests.template('mod', { appId: app.id }), id: contracts.ids.newId('mod'), version: '0.1.0' };
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'mod', manifest: JSON.stringify(mod), intent: 'create' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const rel = await t.ctx.releases.get(r.headers.get('location').split('/').pop());
++ assert.strictEqual(rel.kind, 'mod');
++ assert.strictEqual(rel.subject_id, mod.id);
++ });
++
++ // ── App token API ──
++ await check('an app token with services.release.manage creates and publishes its own release', async () => {
++ const token = t.network.mintApp(app.id, 'openvibe.services', ['services.release.manage']);
++ const r = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${token}` }, json: { kind: 'app', manifest: JSON.parse(manifest('4.0.0', { publisher: { type: 'app', id: app.id } })), publish: true } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().release.status, 'published');
++ const e = (await outbox()).pop();
++ assert.deepStrictEqual(e.actor, { type: 'app', id: app.id });
++ });
++
++ await check('the API refuses: no token, wrong audience, no capability, another app', async () => {
++ const body = { kind: 'app', manifest: JSON.parse(manifest('5.0.0')) };
++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { json: body })).status, 401);
++ const wrongAud = t.network.mintApp(app.id, 'openvibe.media', ['services.release.manage']);
++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${wrongAud}` }, json: body })).status, 401);
++ const noCap = t.network.mintApp(app.id, 'openvibe.services', ['media.object.upload']);
++ const r3 = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${noCap}` }, json: body });
++ assert.strictEqual(r3.status, 403);
++ assert.strictEqual(r3.json().code, 'capability.denied');
++ const other = await t.app(owner, projectId, { name: 'Other' });
++ const otherTok = t.network.mintApp(other.id, 'openvibe.services', ['services.release.manage']);
++ const r4 = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${otherTok}` }, json: body });
++ assert.strictEqual(r4.json().code, 'release.forbidden');
++ assert.strictEqual(r4.status, 403);
++ assert.strictEqual(r4.headers.get('content-type'), 'application/problem+json');
++ const userTok = t.network.userToken(owner);
++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${userTok}` }, json: body })).status, 401);
++ });
++
++ await check('public reads need no token; a presented token must hold services.release.read', async () => {
++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`)).status, 200);
++ const withRead = t.network.mintApp(app.id, 'openvibe.services', ['services.release.read']);
++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${withRead}` } })).status, 200);
++ const without = t.network.mintApp(app.id, 'openvibe.services', ['services.release.manage']);
++ const r = await t.get(`/api/v1/apps/${app.id}/trust`, { headers: { authorization: `Bearer ${without}` } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'capability.denied');
++ const forged = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: 'Bearer not.a.jwt' } });
++ assert.strictEqual(forged.status, 401);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..7233302
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -0,0 +1,150 @@
++'use strict';
++// Services' own resources as an authority (ADR-048 section 3): server/registry/resource-index.js pages
++// common.resource-summary@1 for the resources Services owns — its validated manifests (services.manifest, mfs_) and
++// its releases (services.release, rel_) — and reads one by its computed ovrn. project/kind/cursor/limit are honoured;
++// project is the tenancy boundary, so a resource of another project is never returned. Both kinds are nameable, so
++// every summary carries an ovrn and the project it was created in. The merged index (GET /api/v1/resources, guarded
++// by services.resource.read) reads them in process (server/registry/self-authority.js), and every page and read is
++// validated against the released common.resource-list-result@1 / common.resource-summary@1.
++const assert = require('assert');
++const crypto = require('crypto');
++const { validate, ids, serviceAuth } = require('openvibe-contracts');
++const resourceIndex = require('../server/registry/resource-index');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const db = t.ctx.store.db;
++ const at = '2026-10-01T00:00:00Z';
++
++ // ── Fixtures: two projects, their manifests (user- and app-created) and releases in three states ──
++ const prjA = ids.newId('project'); const prjB = ids.newId('project');
++ const usrA = ids.newId('user');
++ const appA = ids.newId('app'); const appB = ids.newId('app');
++ const subjA = ids.newId('app'); const subjB = ids.newId('app');
++ const mfsA1 = ids.newId('manifest'); const mfsA2 = ids.newId('manifest'); const mfsB = ids.newId('manifest');
++ const relA1 = ids.newId('release'); const relA2 = ids.newId('release'); const relB = ids.newId('release');
++ const usrB = ids.newId('user');
++
++ const insertManifest = db.prepare('INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
++ const insertRelease = db.prepare("INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at) VALUES (?, ?, ?, 'sandbox', ?, ?, ?, ?, ?, ?, '{}', '', ?, ?)");
++ const body = (subject, name, version) => JSON.stringify({ id: subject, name, version });
++ await insertManifest.run(mfsA1, 'app', appA, prjA, subjA, '1.0.0', body(subjA, 'Notes app', '1.0.0'), '0.107.0', `user:${usrA}`, at);
++ await insertManifest.run(mfsA2, 'app', appA, prjA, subjA, '1.1.0', body(subjA, 'Notes app', '1.1.0'), '0.107.0', `app:${appA}`, at);
++ await insertManifest.run(mfsB, 'app', appB, prjB, subjB, '1.0.0', body(subjB, 'Other app', '1.0.0'), '0.107.0', `user:${usrB}`, at);
++ await insertRelease.run(relA1, appA, prjA, 'app', subjA, 'Notes app', '1.0.0', mfsA1, 'published', `user:${usrA}`, at);
++ await insertRelease.run(relA2, appA, prjA, 'app', subjA, 'Notes app', '1.1.0', mfsA2, 'draft', `app:${appA}`, at);
++ await insertRelease.run(relB, appB, prjB, 'app', subjB, 'Other app', '1.0.0', mfsB, 'revoked', `user:${usrB}`, at);
++
++ const expected = [
++ { id: mfsA1, kind: resourceIndex.MANIFEST_KIND }, { id: mfsA2, kind: resourceIndex.MANIFEST_KIND }, { id: mfsB, kind: resourceIndex.MANIFEST_KIND },
++ { id: relA1, kind: resourceIndex.RELEASE_KIND }, { id: relA2, kind: resourceIndex.RELEASE_KIND }, { id: relB, kind: resourceIndex.RELEASE_KIND },
++ ].sort((x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
++ const ovrnOfManifest = (project, id) => `ovrn:services:${project}:manifest/${id}`;
++ const ovrnOfRelease = (project, id) => `ovrn:services:${project}:release/${id}`;
++
++ // A first-party service token signed by the Network mock, holding the capabilities asked for.
++ const token = (cap, { aud = 'openvibe.services', sub = 'svc:services' } = {}) => {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: [aud], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, t.network.privatePem);
++ };
++ const get = (path, headers) => t.get(path, headers ? { headers } : {});
++ const auth = { authorization: `Bearer ${token([resourceIndex.RESOURCE_READ])}` };
++
++ const page = async (q = {}) => {
++ const r = await resourceIndex.pageOf(db, q);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.ok(validate('common.resource-list-result@1', r.body).valid, JSON.stringify(validate('common.resource-list-result@1', r.body).errors));
++ return r.body;
++ };
++ const idsOf = async (q) => (await page(q)).resources.map((r) => r.id);
++
++ await check('a page is common.resource-list-result@1: only its fields, every summary valid and sorted', async () => {
++ const y = await page();
++ assert.deepStrictEqual(Object.keys(y).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');
++ y.resources.forEach((s) => assert.ok(validate('common.resource-summary@1', s).valid, `${s.id}: ${JSON.stringify(validate('common.resource-summary@1', s).errors)}`));
++ assert.strictEqual(y.next_cursor, null, 'one page holds the whole index');
++ assert.deepStrictEqual(y.resources.map((r) => [r.kind, r.id]), expected.map((r) => [r.kind, r.id]), 'both kinds, sorted by (kind, id)');
++ assert.deepStrictEqual([...new Set(y.resources.map((r) => r.service))], ['services']);
++ assert.deepStrictEqual([...new Set(y.resources.map((r) => r.project_id))].sort(), [prjA, prjB].sort(), 'every summary carries the project it was created in');
++ });
++
++ await check('every summary carries its computed ovrn, name, state and owner', async () => {
++ const byId = new Map((await page()).resources.map((r) => [r.id, r]));
++ assert.strictEqual(byId.get(mfsA1).ovrn, ovrnOfManifest(prjA, mfsA1));
++ assert.strictEqual(byId.get(mfsB).ovrn, ovrnOfManifest(prjB, mfsB));
++ assert.strictEqual(byId.get(relA1).ovrn, ovrnOfRelease(prjA, relA1));
++ assert.strictEqual(byId.get(relB).ovrn, ovrnOfRelease(prjB, relB));
++ assert.strictEqual(byId.get(mfsA1).name, 'Notes app', 'a manifest is named by its stored body');
++ assert.strictEqual(byId.get(relA1).name, 'Notes app', "a release is named by the release row's own name");
++ assert.deepStrictEqual(byId.get(mfsA1).owner, { type: 'user', id: usrA }, 'a user-created resource names its owner');
++ assert.ok(!('owner' in byId.get(mfsA2)), 'an app-created resource has no person owner');
++ assert.strictEqual(byId.get(mfsA1).state, 'valid', 'a stored manifest is a validated, immutable row');
++ assert.strictEqual(byId.get(relA1).state, 'published', 'a release state is its status');
++ assert.strictEqual(byId.get(relA2).state, 'draft');
++ assert.strictEqual(byId.get(relB).state, 'revoked');
++ });
++
++ await check('project is the tenancy boundary: another project\'s rows are never returned', async () => {
++ assert.deepStrictEqual((await idsOf({ project: prjA })).sort(), [mfsA1, mfsA2, relA1, relA2].sort());
++ assert.deepStrictEqual((await idsOf({ project: prjB })).sort(), [mfsB, relB].sort());
++ assert.deepStrictEqual(await idsOf({ project: ids.newId('project') }), [], 'an unknown project has no resources');
++ });
++
++ await check('kind narrows to one kind; an unknown kind is an empty page, not an error', async () => {
++ assert.deepStrictEqual((await idsOf({ kind: 'services.manifest' })).sort(), [mfsA1, mfsA2, mfsB].sort());
++ assert.deepStrictEqual((await idsOf({ kind: 'services.release' })).sort(), [relA1, relA2, relB].sort());
++ assert.deepStrictEqual((await idsOf({ project: prjA, kind: 'services.release' })).sort(), [relA1, relA2].sort(), 'kind narrows within the project');
++ assert.deepStrictEqual(await idsOf({ kind: 'services.unknown' }), []);
++ });
++
++ await check('an opaque cursor pages the whole set: no duplicates, none skipped, order preserved', async () => {
++ const seen = [];
++ let cursor = null;
++ for (let pages = 0; ; pages++) {
++ const y = await page({ limit: '2', ...(cursor ? { cursor } : {}) });
++ seen.push(...y.resources.map((r) => r.id));
++ if (y.next_cursor === null) break;
++ cursor = y.next_cursor;
++ if (pages > 50) assert.fail('the cursor chain never ended');
++ }
++ assert.deepStrictEqual(seen, expected.map((r) => r.id));
++ });
++
++ await check('one resource by its computed name; an unknown or non-matching OVRN is 404 resources.not_found', async () => {
++ const listed = new Map((await page()).resources.map((r) => [r.id, r]));
++ for (const [name, id] of [[ovrnOfManifest(prjA, mfsA1), mfsA1], [ovrnOfRelease(prjA, relA1), relA1]]) {
++ const r = await resourceIndex.oneOf(db, name);
++ assert.strictEqual(r.status, 200, name);
++ assert.deepStrictEqual(r.body, listed.get(id), 'the same summary the list answers');
++ }
++ for (const name of [ovrnOfManifest(prjA, ids.newId('manifest')), ovrnOfRelease(prjA, relB), `ovrn:services:${prjA}:object/${mfsA1}`, ovrnOfRelease(prjA, mfsA1), 'nope']) {
++ const r = await resourceIndex.oneOf(db, name);
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'resources.not_found'], name);
++ }
++ });
++
++ await check('a query that cannot be honoured is 400 resources.bad_query', async () => {
++ for (const q of [{ project: 'nope' }, { limit: '0' }, { limit: 'abc' }, { limit: '99999' }, { cursor: '***' }]) {
++ const r = await resourceIndex.pageOf(db, q);
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'resources.bad_query'], JSON.stringify(q));
++ }
++ });
++
++ // ── The merged index serves them, in process, behind services.resource.read ──
++ await check('GET /api/v1/resources?service=services: Services\' own resources, guarded, never partial', async () => {
++ assert.strictEqual((await get('/api/v1/resources?service=services')).status, 401, 'no token');
++ assert.strictEqual((await get('/api/v1/resources?service=services', { authorization: `Bearer ${token(['services.release.read'])}` })).status, 403, 'a token without the capability');
++ const y = await get('/api/v1/resources?service=services', auth);
++ assert.strictEqual(y.status, 200, y.text.slice(0, 300));
++ assert.deepStrictEqual(y.json().resources.map((r) => r.id).sort(), expected.map((r) => r.id).sort());
++ assert.ok(!(y.json().partial || []).length, 'the in-process authority is never partial');
++ const one = await get(`/api/v1/resources/${encodeURIComponent(ovrnOfRelease(prjA, relA1))}`, auth);
++ assert.strictEqual(one.status, 200, one.text.slice(0, 300));
++ assert.strictEqual(one.json().id, relA1);
++ assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOfRelease(prjA, ids.newId('release')))}`, auth)).status, 404);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/scaffold.test.js b/test/scaffold.test.js
iindex 0dd6c27..b2cd36b 100644
---- a/test/scaffold.test.js
++++ b/test/scaffold.test.js
@@@ -15,25 +15,28 @@ async function main() {
await check('liveness: GET /api/health says only 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.status, 'ok');
assert.strictEqual(r.json.service, 'services');
assert.strictEqual(r.json.version, require('../package.json').version);
assert.strictEqual(r.json.authorities, 0); // no authority in this boot
assert.strictEqual(r.json.events.enabled, false); // EVENTS_URL unset in the test
-- assert.strictEqual(r.json.events.pending, 0); // no event type declared yet
++ assert.strictEqual(r.json.events.pending, 0); // nothing has happened in this boot
});
-- await check('readiness: GET /api/ready reports the database and the Network key, and nothing else', async () => {
++ await check('readiness: GET /api/ready requires the database and the docs; the rest degrades, never fails', 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, 'services');
-- assert.strictEqual(r.json.checks.db.status, 'ok');
-- assert.strictEqual(r.json.checks.db.required, true);
++ for (const name of ['db', 'docs']) {
++ assert.strictEqual(r.json.checks[name].status, 'ok', name);
++ assert.strictEqual(r.json.checks[name].required, true, name);
++ }
assert.strictEqual(r.json.checks.network_jwks.status, 'ok'); // the stub Network's key is loaded
++ assert.strictEqual(r.json.checks.network_jwks.required, false); // docs and tools serve without it
assert.deepStrictEqual(r.json.failed, []);
-- assert.deepStrictEqual(r.json.skipped, [], 'readiness claims nothing it did not read');
++ // The relay is off in this boot (no EVENTS_URL): reported as degraded, never as ready.
++ assert.ok(r.json.degraded.includes('events_relay'), r.text);
assert.deepStrictEqual(r.json.authorities, { count: 0, ids: [] });
});
@@@ -60,14 +63,19 @@ async function main() {
assert.strictEqual(under.status, 404);
});
-- await check('an unknown route is a problem+json 404, page or API', async () => {
-- for (const p of ['/api/v1/nope', '/console', '/', '/nope']) {
++ await check('an unknown API route is a problem+json 404; an unknown page is an HTML 404', async () => {
++ for (const p of ['/api/v1/nope', '/api/nope']) {
const r = await t.call('GET', p, { token: null });
assert.strictEqual(r.status, 404, `${p}: ${r.text}`);
assert.match(r.headers.get('content-type') || '', /application\/problem\+json/, p);
assert.strictEqual(r.json.code, 'route.not_found', p);
assert.ok(r.json.request_id, p);
}
++ for (const p of ['/console', '/nope']) {
++ const r = await t.call('GET', p, { token: null });
++ assert.strictEqual(r.status, 404, `${p}: ${r.text.slice(0, 200)}`);
++ assert.match(r.headers.get('content-type') || '', /text\/html/, p);
++ }
});
await check('the index without a token is 401 token.missing', async () => {
ddiff --git a/test/scope-editor.test.js b/test/scope-editor.test.js
nnew file mode 100644
iindex 0000000..db48b9f
---- /dev/null
++++ b/test/scope-editor.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++/**
++ * The scope editor offers only capabilities apps can be granted: active + public, or partner when
++ * staff put it in this project's allowance. Even if Network's catalog listed something the pinned
++ * contracts call first-party or internal, Services would not offer it and would refuse to forward a
++ * forged request for it (Network is never asked).
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('lin');
++ const projectId = await t.project(owner, 'Scopes');
++ const app = await t.app(owner, projectId);
++ const base = `/projects/${projectId}/apps/${app.id}`;
++
++ const pub = contracts.capabilities.manifests.find((c) => c.visibility === 'public' && c.status === 'active');
++ const internal = contracts.capabilities.manifests.find((c) => c.visibility === 'internal');
++ const firstParty = contracts.capabilities.manifests.find((c) => c.visibility === 'first-party');
++ const planned = contracts.capabilities.manifests.find((c) => c.visibility === 'public' && c.status !== 'active');
++ // Partner capabilities do not exist in v0.26.0's manifests; the catalog Network serves can still
++ // carry one (Network accepts the proposed visibility), so the mock lists two that the pinned
++ // contracts do not know.
++ const entry = (c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '' });
++ t.network.setCatalog([
++ entry(pub),
++ entry(internal), // a buggy Network must not make Services offer this
++ entry(firstParty),
++ ...(planned ? [entry(planned)] : []),
++ { id: 'partnerx.thing.do', owner: 'partnerx', audience: 'openvibe.partnerx', visibility: 'partner', description: 'Partner thing A' },
++ { id: 'partnerx.other.do', owner: 'partnerx', audience: 'openvibe.partnerx', visibility: 'partner', description: 'Partner thing B' },
++ ]);
++ t.network.setAllowance(projectId, ['partnerx.thing.do']);
++
++ const offeredOn = (html) => {
++ const table = (html.split('class="scope-editor"')[1] || '').split('</table>')[0];
++ return [...table.matchAll(/<a href="\/docs\/capabilities\/([^"]+)"/g)].map((m) => m[1]);
++ };
++
++ await check('offers public capabilities with description, owner and visibility', async () => {
++ const r = await t.get(base, { as: owner });
++ assert.strictEqual(r.status, 200);
++ const offered = offeredOn(r.text);
++ assert.ok(offered.includes(pub.id));
++ const row = r.text.split(`/docs/capabilities/${pub.id}"`)[1].split('</tr>')[0];
++ assert.ok(row.includes(pub.owner) && row.includes('public'));
++ if (pub.description) assert.ok(row.includes(pub.description.slice(0, 20).replace(/"/g, '&quot;').replace(/'/g, '&#39;')));
++ });
++
++ await check('never offers internal, first-party or non-active capabilities', async () => {
++ const offered = offeredOn((await t.get(base, { as: owner })).text);
++ assert.ok(!offered.includes(internal.id), `${internal.id} offered`);
++ assert.ok(!offered.includes(firstParty.id), `${firstParty.id} offered`);
++ if (planned) assert.ok(!offered.includes(planned.id), `${planned.id} offered`);
++ });
++
++ await check('offers a partner capability only when it is in this project\'s allowance', async () => {
++ const offered = offeredOn((await t.get(base, { as: owner })).text);
++ assert.ok(offered.includes('partnerx.thing.do'));
++ assert.ok(!offered.includes('partnerx.other.do'));
++ });
++
++ await check('a forged request for an internal capability is refused before Network sees it', async () => {
++ const before = t.network.requests.filter((x) => x.method === 'POST' && x.path.endsWith('/grants')).length;
++ const r = await t.get(`${base}/grants`, { as: owner, form: { capability: internal.id } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /services\.not_grantable/);
++ const after = t.network.requests.filter((x) => x.method === 'POST' && x.path.endsWith('/grants')).length;
++ assert.strictEqual(after, before, 'no grant request reached Network');
++ });
++
++ await check('a forged request for a partner capability outside the allowance is refused too', async () => {
++ const r = await t.get(`${base}/grants`, { as: owner, form: { capability: 'partnerx.other.do' } });
++ assert.strictEqual(r.status, 422);
++ });
++
++ await check('an offered public capability is forwarded to Network and its answer shown', async () => {
++ const r = await t.get(`${base}/grants`, { as: owner, form: { capability: pub.id } });
++ assert.strictEqual(r.status, 303);
++ assert.match(decodeURIComponent(r.headers.get('location')), new RegExp(`${pub.id.replace(/\./g, '\\.')}: requested`));
++ const page = await t.get(base, { as: owner });
++ assert.match(page.text, /outside the allowance/, 'a requested grant outside the allowance cannot be approved from Services');
++ });
++
++ await check('the capability docs highlight exactly the grantable ones', async () => {
++ const r = await t.get('/docs/capabilities?grantable=1');
++ const listed = [...r.text.matchAll(/<a href="\/docs\/capabilities\/([^"]+)" class="grantable">/g)].map((m) => m[1]).sort();
++ const expected = contracts.capabilities.manifests.filter((c) => c.status === 'active' && ['public', 'partner'].includes(c.visibility)).map((c) => c.id).sort();
++ assert.deepStrictEqual(listed, expected);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/secrets.test.js b/test/secrets.test.js
nnew file mode 100644
iindex 0000000..da49c96
---- /dev/null
++++ b/test/secrets.test.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * The portal never persists or re-displays a secret.
++ *
++ * Every client secret Network hands out (app creation, rotation), every secret or token typed into a
++ * playground, and every webhook secret typed into the tester is searched for in: every table of
++ * Services' database, every log line (app logger and console), and every later page and API response.
++ * The only response allowed to contain a client secret is the one to the create/rotate that made it,
++ * and that response is Cache-Control: no-store.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('ada');
++ const secrets = [];
++ const seen = []; // every response body after the one-time display
++ const record = (r) => { seen.push(r.text); return r; };
++
++ const projectId = await t.project(owner, 'Secrets');
++ let appId;
++
++ await check('creating a confidential app shows the secret once, with no-store, and a clear warning', async () => {
++ const made = await t.app(owner, projectId, { name: 'Server app' });
++ assert.ok(made.secret, 'secret on the creation page');
++ assert.ok(made.id);
++ appId = made.id;
++ secrets.push(made.secret);
++ assert.strictEqual(made.page.headers.get('cache-control'), 'no-store');
++ assert.match(made.page.text, /You won't see this secret again/);
++ assert.match(made.page.text, /Services does not keep it/);
++ assert.ok(!made.page.headers.get('location'), 'no redirect through a URL');
++ });
++
++ await check('the app page afterwards shows only the last four characters', async () => {
++ const r = record(await t.get(`/projects/${projectId}/apps/${appId}`, { as: owner }));
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(!r.text.includes(secrets[0]));
++ assert.ok(r.text.includes(`…${secrets[0].slice(-4)}`));
++ });
++
++ await check('rotating shows the new secret once; neither secret appears again', async () => {
++ const r = await t.get(`/projects/${projectId}/apps/${appId}/credentials/rotate`, { as: owner, form: { overlap_seconds: '3600' } });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const s = (r.text.match(/ovsec_[A-Za-z0-9_-]{43}/) || [])[0];
++ assert.ok(s && s !== secrets[0]);
++ assert.ok(!r.text.includes(secrets[0]), 'the old secret is not shown with the new one');
++ secrets.push(s);
++ const again = record(await t.get(`/projects/${projectId}/apps/${appId}`, { as: owner }));
++ for (const x of secrets) assert.ok(!again.text.includes(x));
++ });
++
++ await check('a playground run with the client secret neither stores nor echoes it', async () => {
++ await t.grant(owner, projectId, appId, 'media.object.upload');
++ const r = record(await t.get(`/projects/${projectId}/apps/${appId}/playground/media`, {
++ as: owner, multipart: { fields: { credential_type: 'client_secret', credential: secrets[1] }, file: { name: 'hello.txt', content: 'hello world' } },
++ }));
++ assert.strictEqual(r.status, 200, r.text.slice(0, 500));
++ assert.match(r.text, /Done\./);
++ assert.ok(!r.text.includes(secrets[1]), 'the form comes back empty');
++ });
++
++ await check('a playground run with a pasted access token neither stores nor echoes it', async () => {
++ const token = t.network.mintApp(appId, 'openvibe.media', ['media.object.upload']);
++ secrets.push(token);
++ const r = record(await t.get(`/projects/${projectId}/apps/${appId}/playground/media`, {
++ as: owner, multipart: { fields: { credential_type: 'access_token', credential: token }, file: { name: 'b.txt', content: 'bytes' } },
++ }));
++ assert.strictEqual(r.status, 200, r.text.slice(0, 500));
++ assert.ok(!r.text.includes(token));
++ });
++
++ await check('a refused playground run (wrong secret) does not echo it either', async () => {
++ const wrong = 'ovsec_' + 'x'.repeat(43);
++ secrets.push(wrong);
++ const r = record(await t.get(`/projects/${projectId}/apps/${appId}/playground/media`, {
++ as: owner, multipart: { fields: { credential_type: 'client_secret', credential: wrong }, file: { name: 'c.txt', content: 'c' } },
++ }));
++ assert.strictEqual(r.status, 401, r.text.slice(0, 500));
++ assert.match(r.text, /invalid_client/);
++ assert.ok(!r.text.includes(wrong));
++ });
++
++ await check('webhook tester secrets are not echoed', async () => {
++ const hook = 'whsec_test_' + 'y'.repeat(40);
++ secrets.push(hook);
++ const r1 = record(await t.get('/tools/webhooks/verify', { form: { body: '{"event":{},"seq":1}', signature_v2: 't=1,v2=00', secret: hook } }));
++ assert.strictEqual(r1.status, 422);
++ const r2 = record(await t.get('/tools/webhooks/sample', { form: { event_type: 'network.app.created', secret: hook } }));
++ assert.strictEqual(r2.status, 200);
++ assert.ok(!r1.text.includes(hook) && !r2.text.includes(hook));
++ });
++
++ await check('the release flow, audit and project pages carry no secret', async () => {
++ for (const p of [`/projects/${projectId}`, `/projects/${projectId}/audit`, `/projects/${projectId}/apps/${appId}/playground`, '/projects', `/apps/${appId}`, `/api/v1/apps/${appId}/releases`]) {
++ record(await t.get(p, { as: owner }));
++ }
++ });
++
++ await check('no secret or token anywhere: database, logs, later responses', async () => {
++ const dump = await t.dbDump();
++ const logs = t.logs();
++ assert.ok(secrets.length >= 5);
++ for (const s of secrets) {
++ assert.ok(!dump.includes(s), `secret ${s.slice(0, 10)}… found in the database`);
++ assert.ok(!logs.includes(s), `secret ${s.slice(0, 10)}… found in the logs`);
++ for (const body of seen) assert.ok(!body.includes(s), `secret ${s.slice(0, 10)}… re-displayed`);
++ }
++ assert.ok(!/ovsec_[A-Za-z0-9_-]{20,}/.test(dump), 'no ovsec_ value of any kind in the database');
++ // The user's own Network access token is never rendered either.
++ const userToken = t.network.userToken(owner);
++ assert.ok(!seen.some((b) => b.includes(userToken.split('.')[2])));
++ });
++
++ await check('session cookies are httpOnly (no script can read the Network token)', async () => {
++ const code = t.network.issueCode(owner, 'x');
++ void code;
++ const login = await t.get('/auth/login?next=/projects');
++ const flow = login.headers.get('set-cookie');
++ assert.match(flow, /services_oauth=.*HttpOnly/i);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/security-crawl.js b/test/security-crawl.js
nnew file mode 100644
iindex 0000000..0ee0107
---- /dev/null
++++ b/test/security-crawl.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
++ * Wiki app (walking Express's router stack, so a route added later is crawled without anyone
++ * listing it), fills route parameters with seeded values, and GETs each path as several people,
++ * reporting any response whose body or headers carry a value that person must never see.
++ * Not a test itself (no .test.js); used with test/helpers/boot.js.
++ */
++
++/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
++function mountPath(layer) {
++ if (!layer.regexp || layer.regexp.fast_slash) return '';
++ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');
++ let i = 0;
++ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);
++ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');
++ return /[\\^$()|[\]*+?]/.test(src) ? null : src;
++}
++
++/** Every route of the booted Services app (captured from createApp): [{ path, methods }]. */
++function listRoutes(app) {
++ if (!app || !app._router) throw new Error('no Express app');
++ const out = [];
++ const walk = (stack, prefix) => {
++ for (const layer of stack) {
++ if (layer.route) {
++ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);
++ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });
++ } else if (layer.handle && Array.isArray(layer.handle.stack)) {
++ const mp = mountPath(layer);
++ if (mp !== null) walk(layer.handle.stack, prefix + mp);
++ } else {
++ const mp = mountPath(layer);
++ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });
++ }
++ }
++ };
++ walk(app._router.stack, '');
++ const seen = new Set();
++ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });
++}
++
++/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */
++function expand(template, values) {
++ const names = [];
++ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });
++ if (!names.length) return [t];
++ const lists = names.map((n) => values(n));
++ const width = Math.max(...lists.map((l) => l.length));
++ const paths = new Set();
++ for (let i = 0; i < width; i++) {
++ let j = 0;
++ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));
++ }
++ return [...paths];
++}
++
++/** Every GET path: each GET route expanded, also with `query` appended, plus `extra`. */
++function getPaths(app, values, { query = '', extra = [] } = {}) {
++ const paths = new Set();
++ for (const r of listRoutes(app)) {
++ if (!r.methods.includes('get') && !r.methods.includes('_all')) continue;
++ for (const p of expand(r.path, values)) {
++ paths.add(p);
++ if (query && !p.includes('?')) paths.add(`${p}?${query}`);
++ }
++ }
++ for (const p of extra) paths.add(p);
++ return [...paths];
++}
++
++/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */
++function leaks(res, needles) {
++ const found = [];
++ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');
++ for (const [label, value] of Object.entries(needles)) {
++ if (!value) continue;
++ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });
++ if (headerText.includes(value)) found.push({ label, where: 'headers' });
++ }
++ return found;
++}
++
++/** A fresh client address per request (TRUST_PROXY is on in tests): the per-address rate limits are not what is being read. */
++let seq = 0;
++function nextAddress() { seq++; return `10.${(seq >> 16) & 255}.${(seq >> 8) & 255}.${seq & 255}`; }
++
++/**
++ * GET every path as every person ({ who: user | token | null }) through t.get (a user object signs
++ * in with the session cookie, a string is a bearer token). `needlesFor(who)` names what that person
++ * must never see. Resolves { found, answered, statuses }.
++ */
++async function crawl(t, paths, people, needlesFor, { concurrency = 6 } = {}) {
++ const found = [];
++ const statuses = {};
++ let answered = 0;
++ for (const [who, as] of Object.entries(people)) {
++ const needles = needlesFor(who);
++ const queue = [...paths];
++ await Promise.all(Array.from({ length: concurrency }, async () => {
++ while (queue.length) {
++ const p = queue.shift();
++ let r;
++ try { r = await t.get(p, { ...(as ? { as } : {}), headers: { 'x-forwarded-for': nextAddress() } }); } catch (e) { r = { status: 0, text: '', headers: {} }; }
++ if (r.status) answered++;
++ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
++ statuses[cls] = (statuses[cls] || 0) + 1;
++ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);
++ }
++ }));
++ }
++ return { found, answered, statuses };
++}
++
++module.exports = { listRoutes, expand, getPaths, leaks, crawl, nextAddress };
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
nnew file mode 100644
iindex 0000000..3c3a2d9
---- /dev/null
++++ b/test/security-idor.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++/**
++ * One developer cannot act on another's projects, apps, credentials or releases by swapping ids
++ * (roadmap WS-R task 5, the IDOR class). Project data lives at OpenVibe.Network (Services asks with the
++ * person's own token, so Network's rules apply); releases, trust tiers and playground runs are
++ * Services' own. Ann and Bob each have a project with an app; Ann has a draft release. Bob tries every
++ * page and form that takes an id with Ann's instead (her project's pages, apps, members, credentials,
++ * grants, redirects, playground, archive, export, delete), and the nested swap: his own project in the
++ * path with Ann's app or credential id. Then Ann's releases: the draft's page and API, and publish,
++ * deprecate, revoke. Every refusal leaves Network's projects, apps and credentials and every table of
++ * Services as they were, and shows nothing of Ann's (her app's name, its secret, the draft's notes);
++ * controls show the routes work for their owner.
++ *
++ * node test/security-idor.test.js
++ */
++const assert = require('assert');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const ann = t.network.addUser('ann');
++ const bob = t.network.addUser('bob');
++ const annProject = await t.project(ann, 'Ann Secret Project');
++ const annApp = await t.app(ann, annProject, { name: 'Ann Hidden App' });
++ const bobProject = await t.project(bob, 'Bob Project');
++ const bobApp = await t.app(bob, bobProject, { name: 'Bob App' });
++ const annCred = [...t.network.state.creds.values()].find((c) => c.app_id === annApp.id);
++ const manifest = JSON.stringify({ ...manifests.template('app', { appId: annApp.id, projectId: annProject, environment: 'sandbox', name: 'Ann Hidden App' }), version: '1.0.0' });
++ const made = await t.get(`/projects/${annProject}/apps/${annApp.id}/releases`, { as: ann, form: { kind: 'app', manifest, intent: 'create', notes: 'ann-draft-notes' } });
++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
++ const annRelease = made.headers.get('location').split('/').pop();
++
++ const networkState = () => JSON.stringify([[...t.network.state.projects.entries()], [...t.network.state.members.entries()], [...t.network.state.apps.entries()], [...t.network.state.creds.entries()]]);
++ const snapshot = async () => networkState() + await t.dbDump();
++ const HERS = ['Ann Hidden App', 'ann-draft-notes', ...(annApp.secret ? [annApp.secret] : [])];
++ const refused = (r, what) => {
++ assert.ok([401, 403, 404].includes(r.status) || (r.status === 303 && /\/login|\/auth/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
++ for (const h of HERS) assert.ok(!r.text.includes(h), `${what}: the answer carries "${h}"`);
++ };
++
++ await check('pages: Bob sees nothing of Ann\'s project, apps or releases', async () => {
++ for (const p of ['', '/audit', '/usage', '/export', `/apps/${annApp.id}`, `/apps/${annApp.id}/playground`, `/apps/${annApp.id}/releases/new`]) {
++ refused(await t.get(`/projects/${annProject}${p}`, { as: bob }), `GET project${p}`);
++ }
++ refused(await t.get(`/projects/${bobProject}/apps/${annApp.id}`, { as: bob }), 'Ann\'s app under Bob\'s project');
++ refused(await t.get(`/releases/${annRelease}`, { as: bob }), 'Ann\'s draft release page');
++ refused(await t.get(`/api/v1/releases/${annRelease}`), 'Ann\'s draft release API (anonymous)');
++ const list = await t.get(`/api/v1/apps/${annApp.id}/releases`);
++ assert.ok(!list.text.includes(annRelease) && !list.text.includes('ann-draft-notes'), 'the public release list holds no draft');
++ });
++
++ await check('forms: Bob cannot change Ann\'s project, apps, members, credentials, grants or releases', async () => {
++ const before = await snapshot();
++ const P = `/projects/${annProject}`;
++ const A = `${P}/apps/${annApp.id}`;
++ for (const [p, form] of [
++ [`${P}/apps`, { name: 'Bob in Ann', environment: 'sandbox', type: 'confidential', redirect_uris: '' }],
++ [`${P}/members`, { username: 'bob', role: 'owner' }],
++ [`${P}/members/${ann.subject}/remove`, {}], [`${P}/members/${ann.subject}/role`, { role: 'viewer' }],
++ [`${A}/credentials/rotate`, {}], [`${A}/credentials/${annCred ? annCred.id : 'cred_x'}/revoke`, {}],
++ [`${A}/grants`, { capability: 'media.object.upload' }], [`${A}/redirects`, { redirect_uris: 'https://bob.example/cb' }],
++ [`${A}/revoke`, { confirm: '1' }], [`${A}/releases`, { kind: 'app', manifest, intent: 'create' }],
++ [`${A}/playground/events`, { event_type: 'x' }], [`${A}/playground/media`, {}],
++ [`${P}/archive`, { confirm: '1' }], [`${P}/delete`, { confirm: '1', name: annProject }], [`${P}/export/archive`, {}],
++ // His own project in the path, her ids after it.
++ [`/projects/${bobProject}/apps/${annApp.id}/credentials/rotate`, {}],
++ [`/projects/${bobProject}/apps/${annApp.id}/revoke`, { confirm: '1' }],
++ [`/projects/${bobProject}/apps/${bobApp.id}/credentials/${annCred ? annCred.id : 'cred_x'}/revoke`, {}],
++ [`/releases/${annRelease}/publish`, {}], [`/releases/${annRelease}/deprecate`, { reason: 'x' }], [`/releases/${annRelease}/revoke`, { reason: 'x' }],
++ ]) {
++ refused(await t.get(p, { as: bob, form }), `POST ${p}`);
++ }
++ assert.strictEqual(await snapshot(), before, 'something of Ann\'s changed');
++ });
++
++ await check('controls: Ann reads her project and publishes her release', async () => {
++ const page = await t.get(`/projects/${annProject}/apps/${annApp.id}`, { as: ann });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Ann Hidden App'));
++ const r = await t.get(`/releases/${annRelease}/publish`, { as: ann, form: {} });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 200));
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
nnew file mode 100644
iindex 0000000..d215c1b
---- /dev/null
++++ b/test/security-private.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * A developer's projects, apps, credentials and draft releases reach nobody else, on any path
++ * (roadmap WS-R task 5, the private-object class). Ann has a project with an app (its client secret
++ * and credential), a member, a draft release with notes, and a playground page; Bob has his own
++ * project. Every route the booted app has (listed from Express's router stack, test/security-crawl.js)
++ * is requested with Ann's project, app, credential, release and subject ids in every parameter,
++ * as anonymous, Bob and a signed-in person with no project. Ann's project and app names, the
++ * secret and the draft's notes never appear (the public release list and pages carry
++ * published releases only). A control shows Ann sees them.
++ *
++ * node test/security-private.test.js
++ */
++const assert = require('assert');
++const manifests = require('../server/domain/manifests');
++const appModule = require('../server/app');
++let capturedApp = null;
++const realCreate = appModule.createApp;
++appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };
++const { boot, check, done } = require('./helpers/boot');
++const { getPaths, crawl } = require('./security-crawl');
++
++(async () => {
++ const t = await boot();
++ assert.ok(capturedApp, 'the Express app was captured');
++ const ann = t.network.addUser('ann');
++ const bob = t.network.addUser('bob');
++ const nobody = t.network.addUser('nobody');
++ const annProject = await t.project(ann, 'Ann Secret Project');
++ const annApp = await t.app(ann, annProject, { name: 'Ann Hidden App' });
++ await t.project(bob, 'Bob Project');
++ const cred = [...t.network.state.creds.values()].find((c) => c.app_id === annApp.id);
++ const manifest = JSON.stringify({ ...manifests.template('app', { appId: annApp.id, projectId: annProject, environment: 'sandbox', name: 'Ann Hidden App' }), version: '1.0.0' });
++ const made = await t.get(`/projects/${annProject}/apps/${annApp.id}/releases`, { as: ann, form: { kind: 'app', manifest, intent: 'create', notes: 'ann-draft-notes' } });
++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
++ const release = made.headers.get('location').split('/').pop();
++ // (Ids are not needles: a page may echo the address it was asked for.)
++ const SECRET = { project: 'Ann Secret Project', app: 'Ann Hidden App', notes: 'ann-draft-notes', ...(annApp.secret ? { secret: annApp.secret } : {}) };
++
++ await check('control: Ann sees her project, app and draft', async () => {
++ assert.ok((await t.get(`/projects/${annProject}`, { as: ann })).text.includes('Ann Secret Project'));
++ assert.ok((await t.get(`/releases/${release}`, { as: ann })).text.includes('ann-draft-notes'));
++ });
++
++ await check('every GET route: nothing of Ann\'s reaches anonymous, Bob or a person without a project', async () => {
++ const values = (name) => {
++ if (name === 'project') return [annProject];
++ if (name === 'app') return [annApp.id];
++ if (name === 'credential') return [cred ? cred.id : 'cred_x'];
++ if (name === 'subject') return [ann.subject];
++ if (name === 'id') return [release, annApp.id, annProject];
++ return [annProject, annApp.id, release];
++ };
++ const paths = getPaths(capturedApp, values, {
++ query: `project=${annProject}&app=${annApp.id}&release=${release}&q=Ann`,
++ extra: [`/api/v1/releases/${release}`, `/api/v1/apps/${annApp.id}/releases`, `/api/v1/apps/${annApp.id}/trust`, `/apps/${annApp.id}/releases`, `/releases/${release}`,
++ '/sitemap.xml', '/events', '/updates', '/staff', `/projects/${annProject}/export?format=json`],
++ });
++ const r = await crawl(t, paths, { anonymous: null, bob, nobody }, () => SECRET);
++ console.log(` (${paths.length} paths × 3 people; answers ${JSON.stringify(r.statuses)})`);
++ assert.ok(r.answered >= paths.length * 2);
++ assert.deepStrictEqual(r.found, []);
++ });
++
++ await check('the events outbox carries nothing of the draft (drafts emit nothing)', async () => {
++ const text = JSON.stringify(await t.ctx.store.db.prepare('SELECT envelope FROM services_events_outbox').all());
++ assert.ok(!text.includes('ann-draft-notes') && !text.includes(release));
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
nnew file mode 100644
iindex 0000000..ed55a0b
---- /dev/null
++++ b/test/security-secrets.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * Services' own secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the
++ * internal-secret class). secrets.test.js pins the secrets Network hands out (client secrets, typed
++ * tokens); this suite covers Services' environment: its Network OAuth client secret and its form secret
++ * are sentinels, and every route the booted app has (listed from Express's router stack) is
++ * requested as anonymous, a developer and staff with real and nonsense ids, plus the sign-in callback
++ * with a forged code (it posts the client secret to Network), unknown paths and every write route
++ * with a broken body. No body or header may carry either (a form token derived from the form secret
++ * is fine; the secret is not), nor may the outbox or the captured log lines.
++ *
++ * node test/security-secrets.test.js
++ */
++const assert = require('assert');
++const appModule = require('../server/app');
++let capturedApp = null;
++const realCreate = appModule.createApp;
++appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };
++const { boot, check, done } = require('./helpers/boot');
++const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');
++
++const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-services-oauth-client', SERVICES_FORM_SECRET: 'sentinel-not-a-secret-services-form' };
++
++(async () => {
++ const t = await boot({ env: SECRETS });
++ const dev = t.network.addUser('dev');
++ const staff = t.network.addUser('root', { role: 'admin' });
++ const project = await t.project(dev, 'Secret Test');
++ const app = await t.app(dev, project, { name: 'Secret App' });
++
++ await check('every GET route, as three people, with real and nonsense ids: no sentinel', async () => {
++ const nonsense = ['nope', "'\"<x>", 'x'.repeat(300)];
++ const values = (name) => (name === 'project' ? [project, ...nonsense] : name === 'app' ? [app.id, ...nonsense] : [app.id, project, ...nonsense]);
++ const paths = getPaths(capturedApp, values, {
++ query: 'q=x&code=forged&state=forged&next=%2F',
++ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%'],
++ });
++ const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);
++ assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);
++ assert.deepStrictEqual(r.found, []);
++ });
++
++ await check('every write route with a broken body, anonymous and as the developer: no sentinel', async () => {
++ const found = [];
++ const values = (name) => (name === 'project' ? [project] : name === 'app' ? [app.id] : ['x']);
++ for (const route of listRoutes(capturedApp)) {
++ for (const method of route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m))) {
++ for (const p of expand(route.path, values)) {
++ for (const as of [null, dev]) {
++ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: '{"broken": ', headers: { 'content-type': 'application/json', 'x-forwarded-for': nextAddress() } });
++ for (const l of leaks(r, SECRETS)) found.push(`${method} ${p} → ${r.status} carries ${l.label}`);
++ }
++ }
++ }
++ }
++ assert.deepStrictEqual(found, []);
++ });
++
++ await check('the outbox and the captured log lines carry no sentinel', async () => {
++ const text = await t.dbDump() + t.logs();
++ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
nnew file mode 100644
iindex 0000000..5cd5b84
---- /dev/null
++++ b/test/security-session.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * The services_at session cookie must hold a Network SESSION token (audience openvibe.network, a
++ * person), not just anything the Network signed. The Network signs FedCM assertions (audience =
++ * the relying party's origin, any https://*.openvibe.* origin), app/service tokens and internal
++ * tokens with the same key and issuer; none of them is a Services session.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { signJwt } = require('./helpers/mocks');
++
++(async () => {
++ const s = await boot();
++ const staff = s.network.addUser('stafferson');
++ s.ctx.config.staffSubjects.push(staff.subject); // as SERVICES_STAFF_SUBJECTS would
++ const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';
++
++ const now = () => Math.floor(Date.now() / 1000);
++ // What POST /fedcm/assertion hands to ANY page on an owned zone the person continues on.
++ const fedcm = () => signJwt({
++ iss: s.network.url, sub: staff.id, id: staff.id, subject_id: staff.subject, username: staff.username, display_name: staff.username,
++ nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now(), exp: now() + 300,
++ }, s.network.privatePem);
++
++ await check('a FedCM assertion is not a Services session (cannot act as staff)', async () => {
++ const cookie = `services_at=${fedcm()}`;
++ const page = await s.get('/staff', { cookie });
++ assert.notStrictEqual(page.status, 200, 'the staff console must not open for an assertion');
++ const body = new URLSearchParams({ app_id: APP, tier: 'first-party', note: 'pwned', csrf: s.csrf(staff) }).toString();
++ const r = await s.get('/staff/trust', { cookie, body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });
++ assert.notStrictEqual(r.status, 303, 'the trust tier must not be set');
++ assert.strictEqual((await s.ctx.trust.get(APP)).tier, 'unreviewed');
++ });
++
++ await check('an app token is not a Services session', async () => {
++ const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);
++ const me = await s.get('/auth/me', { cookie: `services_at=${token}` });
++ assert.strictEqual(me.status, 401);
++ });
++
++ await check('/auth/me: a guest is signed out (200 { user: null }); a session cookie that fails is 401', async () => {
++ const guest = await s.get('/auth/me');
++ assert.strictEqual(guest.status, 200, 'no session cookie at all: not an error');
++ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
++ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await s.get('/auth/me', { cookie: 'services_at=garbage' })).status, 401, 'a present but invalid access cookie');
++ assert.strictEqual((await s.get('/auth/me', { cookie: 'services_rt=stale' })).status, 401, 'a refresh cookie alone is a session that did not resolve');
++ assert.strictEqual((await s.get('/auth/me', { as: staff })).status, 200);
++ });
++
++ await check('a real Network session token still signs the staff member in', async () => {
++ const page = await s.get('/staff', { as: staff });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
++ const r = await s.get('/staff/trust', { as: staff, form: { app_id: APP, tier: 'reviewed', note: 'checked' } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual((await s.ctx.trust.get(APP)).tier, 'reviewed');
++ });
++
++ await check('another site cannot sign a person out; this site can', async () => {
++ const cross = await s.get('/auth/logout', { as: staff, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 200, 'a confirm page, not a sign-out');
++ assert.ok(!/services_at=;|services_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')), 'the session cookie is kept');
++ assert.match(cross.text, /method="post"/);
++ const crossPost = await s.get('/auth/logout', { as: staff, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(crossPost.status, 403);
++ const same = await s.get('/auth/logout?next=/projects', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(same.status, 303);
++ assert.strictEqual(same.headers.get('location'), '/projects');
++ assert.match(String(same.headers.get('set-cookie') || ''), /services_at=;/);
++ });
++
++ await s.close();
++ done();
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
nnew file mode 100644
iindex 0000000..2140754
---- /dev/null
++++ b/test/security-ssrf.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++/**
++ * Services fetches no URL a developer typed (roadmap WS-R task 5, the SSRF class). The URLs developers
++ * give Services (manifest homepage/icon/webhook fields, OAuth redirect URIs, the webhook tester's
++ * delivery target) are validated and stored, or used to build a sample, never requested: every
++ * outbound request of the process is recorded while manifests and forms full of internal addresses
++ * in every spelling go through validate, create and publish, the redirect form and the webhook
++ * tools, and none may go to any of them. And a ratchet: every file in server/ that makes an
++ * outbound request itself is on a reviewed list with where it goes.
++ *
++ * node test/security-ssrf.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const outbound = [];
++const realFetch = globalThis.fetch;
++globalThis.fetch = (url, opts) => { outbound.push(String((url && url.url) || url)); return realFetch(url, opts); };
++
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++const PROBE = '/ssrf-probe-path';
++const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`, `http://0x7f000001${PROBE}`, `http://[::1]${PROBE}`, `http://[::ffff:127.0.0.1]${PROBE}`,
++ `http://169.254.169.254/latest/meta-data${PROBE}`, `http://10.0.0.1${PROBE}`, `http://localhost:4001${PROBE}`, `file:///etc/passwd${PROBE}`, `gopher://127.0.0.1:6379/_x${PROBE}`];
++
++(async () => {
++ const t = await boot();
++ const dev = t.network.addUser('dev');
++ const project = await t.project(dev, 'Probe Project');
++ const app = await t.app(dev, project, { name: 'Probe App' });
++ const base = `/projects/${project}/apps/${app.id}`;
++
++ await check('manifests, redirect URIs and the webhook tools full of internal URLs are never fetched', async () => {
++ let v = 1;
++ for (const u of INTERNAL) {
++ const manifest = JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId: project, environment: 'sandbox', name: 'Probe App' }), version: `1.0.${v++}`,
++ homepage: u, icon: u, icon_url: u, webhook_url: u, webhooks: [{ url: u, events: ['x'] }], support_url: u, privacy_policy_url: u });
++ await t.get(`${base}/releases`, { as: dev, form: { kind: 'app', manifest, intent: 'validate' } });
++ const made = await t.get(`${base}/releases`, { as: dev, form: { kind: 'app', manifest, intent: 'create' } });
++ if (made.status === 303) await t.get(`/releases/${made.headers.get('location').split('/').pop()}/publish`, { as: dev, form: {} });
++ await t.get('/manifests/validate', { form: { kind: 'app', manifest } });
++ await t.get(`${base}/redirects`, { as: dev, form: { redirect_uris: u } });
++ await t.get('/tools/webhooks/sample', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', event_type: 'services.app.published' } });
++ await t.get('/tools/webhooks/verify', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', body: '{}', signature_v2: 't=1,v2=00' } });
++ await t.get(`/tools/webhooks?url=${encodeURIComponent(u)}`);
++ }
++ const hit = outbound.filter((u) => u.includes(PROBE));
++ assert.deepStrictEqual(hit, [], 'Services fetched a URL a developer typed');
++ });
++
++ await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
++ const REVIEWED = {
++ 'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
++ 'server/authorities/adapter.js': 'each authority\'s /api/v1/resources at the internalOrigin of its pinned contracts manifest (or a SERVICES_<ID>_URL override): never a caller-chosen URL',
++ 'server/network.js': 'Network\'s developer projects API (configured OV_NETWORK_URL), with the caller\'s own token',
++ 'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
++ 'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',
++ 'server/domain/project-archive.js': 'no request: a shell snippet in the archive\'s README text',
++ 'server/http/docs.js': 'Network changelog, Tools catalog and Billing policy (configured)',
++ };
++ const root = path.join(__dirname, '..');
++ const found = [];
++ const walk = (dir) => {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const f = path.join(dir, e.name);
++ if (e.isDirectory()) walk(f);
++ else if (e.name.endsWith('.js')) {
++ const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1');
++ if (/(^|[^.\w])(fetch|fetchImpl)\(|\bhttps?\.(get|request)\(|new WebSocket\(|require\(['"](axios|got|node-fetch|undici)['"]\)/m.test(src)) found.push(path.relative(root, f));
++ }
++ }
++ };
++ walk(path.join(root, 'server'));
++ assert.ok(found.length >= 4, `the scan finds the known sites (${found.join(', ')})`);
++ assert.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a developer-chosen URL goes through openvibe-shared/egress; then add the file here with where it goes');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..bbb77b0
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * openvibe-sdk/service (plan T16/J2): the entry point's graceful stop runs its close steps in order
++ * (outbox.stop, then store.close) and resolves exit 0; a second signal while stopping is a no-op.
++ */
++const assert = require('assert');
++const http = require('http');
++const { check, done } = require('./helpers/boot');
++const { createLifecycle } = require('../server/index');
++
++const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++
++(async () => {
++ await check('the entry point stop runs outbox.stop then store.close, then exits 0', async () => {
++ const steps = [];
++ const ctx = {
++ outbox: { stop: async () => { steps.push('outbox.stop'); } },
++ store: { close: async () => { steps.push('store.close'); } },
++ };
++ const server = http.createServer((req, res) => res.end('ok'));
++ await listen(server);
++
++ const exits = [];
++ const lifecycle = createLifecycle({ server, ctx, exit: (code) => exits.push(code), signals: false });
++ const code = await lifecycle.stop('SIGTERM');
++
++ assert.strictEqual(code, 0);
++ assert.deepStrictEqual(exits, [0]);
++ assert.deepStrictEqual(steps, ['outbox.stop', 'store.close']);
++ assert.strictEqual(server.listening, false);
++ });
++
++ await check('a second signal while stopping is a no-op', async () => {
++ let stops = 0; let closes = 0; let exits = 0;
++ const ctx = {
++ outbox: { stop: async () => { stops += 1; } },
++ store: { close: async () => { closes += 1; } },
++ };
++ const server = http.createServer((req, res) => res.end('ok'));
++ await listen(server);
++
++ const lifecycle = createLifecycle({ server, ctx, exit: () => { exits += 1; }, signals: false });
++ const first = lifecycle.stop('SIGTERM');
++ const second = lifecycle.stop('SIGINT');
++
++ assert.strictEqual(first, second);
++ assert.deepStrictEqual(await Promise.all([first, second]), [0, 0]);
++ assert.strictEqual(stops, 1);
++ assert.strictEqual(closes, 1);
++ assert.strictEqual(exits, 1);
++ });
++
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/staff-map.test.js b/test/staff-map.test.js
nnew file mode 100644
iindex 0000000..8753053
---- /dev/null
++++ b/test/staff-map.test.js
@@@ -0,0 +1,28 @@
++'use strict';
++// Staff powers come from the openvibe-contracts staff map (ADR-022): staff.site.configure makes a person Services staff (SERVICES_STAFF_SUBJECTS names more).
++// No server file compares a person's role by hand.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { staff } = require('openvibe-contracts');
++
++const CAPS = ["staff.site.configure"];
++for (const [claims, want] of [[{"role": "user"}, [false]], [{"role": "global_mod"}, [false]], [{"role": "admin"}, [true]], [{"role": "admin", "is_owner": true}, [true]]]) {
++ CAPS.forEach((cap, i) => assert.strictEqual(staff.can(claims, cap), want[i], `${JSON.stringify(claims)} ${cap}`));
++}
++
++// Not staff: reading the role claim for display, and a project's own roles (owner/admin/developer/viewer).
++const ALLOW = [['server/auth/sso.js', "typeof claims.role === 'string'"], ['server/http/portal.js', "const assignable = role === 'owner'"]];
++const offenders = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const f = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(f); continue; }
++ if (!/\.(js|ts)$/.test(e.name)) continue;
++ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
++ if (/STAFF_ROLES|claims\.role\s*[!=]==?|\.(has|includes)\(\s*(claims|u|user|p)\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) { const rel = path.relative(path.join(__dirname, '..'), f).split(path.sep).join('/'); if (!ALLOW.some(([file, snip]) => file === rel && line.includes(snip))) offenders.push(`${rel}:${i + 1}`); }
++ });
++ }
++})(path.join(__dirname, '..', "server"));
++assert.deepStrictEqual(offenders, [], 'raw role checks; ask staff.can(claims, \'staff.…\')');
++console.log('staff map: all checks passed');
ddiff --git a/test/webhook-browser.test.js b/test/webhook-browser.test.js
nnew file mode 100644
iindex 0000000..7f0f21c
---- /dev/null
++++ b/test/webhook-browser.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++/**
++ * public/js/webhook.js (the in-browser verifier) decides as the server and openvibe-sdk do: run in a
++ * vm with Node's Web Crypto and a minimal stand-in for the form, it accepts a fresh v2 and refuses a
++ * stale, wrong or missing v2. It checks v2 only: the retired v1 header (2026-09-28, shim C-61) has
++ * no field and is neither read nor computed.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { check, done } = require('./helpers/boot');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'webhook.js'), 'utf8');
++
++/** Runs webhook.js against a fake form; resolves with the text of the result box. */
++function runBrowser(fields, nowMs) {
++ return new Promise((resolve, reject) => {
++ const el = (tag) => ({ tag, className: '', children: [], textContent: '', setAttribute() {}, appendChild(c) { this.children.push(c); } });
++ const out = el('div');
++ let submitHandler = null;
++ const form = {
++ // A Proxy, so reading a field the page does not have (the retired v1 one) fails the test.
++ elements: new Proxy(Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { value: v }])), {
++ get(target, k) {
++ if (typeof k === 'string' && !(k in target)) throw new Error(`the script read form field ${k}`);
++ return target[k];
++ },
++ }),
++ addEventListener: (type, fn) => { if (type === 'submit') submitHandler = fn; },
++ submit: () => reject(new Error('fell back to the server')),
++ };
++ const text = (n) => (n.children.length ? n.children.map(text).join(' ') : n.textContent);
++ // The script clears the result box with textContent = ''.
++ Object.defineProperty(out, 'textContent', { set() { out.children = []; }, get() { return ''; } });
++ const document = {
++ getElementById: (id) => (id === 'verify-form' ? form : id === 'verify-result' ? out : null),
++ createElement: el,
++ };
++ const window = { crypto: globalThis.crypto, TextEncoder };
++ const DateStub = { now: () => nowMs };
++ vm.runInNewContext(SRC, { window, document, crypto: globalThis.crypto, TextEncoder, Promise, Date: DateStub });
++ assert.ok(submitHandler, 'the script binds the form');
++ submitHandler({ preventDefault() {} });
++ const wait = () => (out.children.length ? resolve({ ok: out.children[0].className.includes(' ok'), text: text(out.children[0]) }) : setTimeout(wait, 5));
++ wait();
++ });
++}
++
++(async () => {
++ const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
++ const body = JSON.stringify({ event: { event_type: 'media.object.ready' }, seq: 3 });
++ const nowMs = 1_790_000_000_000;
++ const nowSec = nowMs / 1000;
++ const v2 = (ts, sec = secret) => `t=${ts},v2=${crypto.createHmac('sha256', sec).update(`${ts}.${body}`).digest('hex')}`;
++ const fields = (o) => ({ body, signature_v2: '', timestamp: '', secret, ...o });
++
++ await check('a fresh v2 is accepted, computed in the browser', async () => {
++ const r = await runBrowser(fields({ signature_v2: v2(nowSec), timestamp: String(nowSec) }), nowMs);
++ assert.strictEqual(r.ok, true, r.text);
++ assert.match(r.text, /Accepted/);
++ assert.match(r.text, /inside the ±300 s window/);
++ assert.match(r.text, /nothing was sent/);
++ });
++
++ await check('a v2 301 s old matches but is refused as outside the window', async () => {
++ const r = await runBrowser(fields({ signature_v2: v2(nowSec - 301) }), nowMs);
++ assert.strictEqual(r.ok, false);
++ assert.match(r.text, /signature matches; t=\d+ is 301 s ago, OUTSIDE/);
++ });
++
++ await check('no v2 is refused; a wrong v2 is refused; a differing X-OpenVibe-Timestamp is refused', async () => {
++ const noV2 = await runBrowser(fields({}), nowMs);
++ assert.strictEqual(noV2.ok, false);
++ assert.match(noV2.text, /v2: not given/);
++ const wrong = await runBrowser(fields({ signature_v2: v2(nowSec, 'other') }), nowMs);
++ assert.strictEqual(wrong.ok, false);
++ assert.match(wrong.text, /does NOT match/);
++ const differs = await runBrowser(fields({ signature_v2: v2(nowSec), timestamp: String(nowSec - 1) }), nowMs);
++ assert.strictEqual(differs.ok, false);
++ assert.match(differs.text, /DIFFERS from t=/);
++ const malformed = await runBrowser(fields({ signature_v2: 'v2=abc' }), nowMs);
++ assert.match(malformed.text, /malformed/);
++ });
++
++ await check('v2 only: the result has no v1 line and the script reads no v1 field', async () => {
++ const r = await runBrowser(fields({ signature_v2: v2(nowSec) }), nowMs);
++ assert.strictEqual(r.ok, true, r.text);
++ assert.doesNotMatch(r.text, /v1|sha256=/);
++ assert.doesNotMatch(SRC, /elements\.signature\b(?!_v2)/, 'no v1 field is read');
++ assert.doesNotMatch(SRC, /sha256=/, 'no v1 value is computed');
++ });
++
++ done();
++})();
ddiff --git a/test/webhooks.test.js b/test/webhooks.test.js
nnew file mode 100644
iindex 0000000..c300c6d
---- /dev/null
++++ b/test/webhooks.test.js
@@@ -0,0 +1,217 @@
++'use strict';
++/**
++ * Webhook signature verification, v2 only (v1 was retired on 2026-09-28, shim C-61): correct against
++ * independent HMACs, rejects tampering, wrong secrets, lengths and malformed headers, applies the
++ * ±300 s window exactly as openvibe-sdk's parseDelivery(..., { requireV2: true }) does, refuses a
++ * delivery that carries only the retired v1 header, compares in constant time
++ * (crypto.timingSafeEqual), and sample deliveries carry v2 only, which the SDK receiver accepts.
++ * The no-JS path works through the server and offers no v1 field.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const { verifyDeliveryV2, parseDelivery } = require('openvibe-sdk/events');
++const webhooks = require('../server/domain/webhooks');
++const { boot, check, done } = require('./helpers/boot');
++
++/** A value in the retired v1 format (sha256=<HMAC of the body alone>): the tester must ignore it. */
++const hmacV1 = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
++
++(async () => {
++ const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
++ const body = JSON.stringify({ event: { event_id: contracts.ids.newId('event'), event_type: 'network.app.created', version: 1, source: 'network', actor: { type: 'system', id: 'network' }, timestamp: new Date().toISOString(), subject: { type: 'app', id: 'app_1' }, payload: {} }, seq: 7 });
++
++ // A clock 0.7 s into a second: ages are whole seconds, the window is checked on the exact time.
++ const nowSec = Math.floor(Date.now() / 1000);
++ const nowMs = nowSec * 1000 + 700;
++ const v2 = (b, sec, ts) => `t=${ts},v2=${crypto.createHmac('sha256', sec).update(`${ts}.${b}`).digest('hex')}`;
++
++ await check('v2: a fresh, correct signature is accepted (independent HMAC of "<t>.<raw body>")', async () => {
++ const v = webhooks.inspect({ rawBody: body, signatureV2: v2(body, secret, nowSec), timestamp: String(nowSec), secret, now: nowMs });
++ assert.strictEqual(v.accepted, true);
++ assert.strictEqual(v.v2.valid, true);
++ assert.strictEqual(v.v2.signatureValid, true);
++ assert.strictEqual(v.v2.withinWindow, true);
++ assert.strictEqual(v.v2.timestampsAgree, true);
++ assert.strictEqual(v.v2.timestamp, nowSec);
++ assert.strictEqual(v.v2.expected, v2(body, secret, nowSec));
++ assert.strictEqual(v.v2.windowSec, 300);
++ assert.strictEqual(v.parsed.event_type, 'network.app.created');
++ assert.strictEqual(v.parsed.seq, 7);
++ assert.strictEqual(v.bodyBytes, Buffer.byteLength(body));
++ });
++
++ await check('the result is v2 only: { accepted, v2, bodyBytes, parsed }, with no v1 anywhere', async () => {
++ const v = webhooks.inspect({ rawBody: body, signature: hmacV1(body, secret), signatureV2: v2(body, secret, nowSec), secret, now: nowMs });
++ assert.deepStrictEqual(Object.keys(v).sort(), ['accepted', 'bodyBytes', 'parsed', 'v2']);
++ assert.strictEqual(v.v1, undefined);
++ assert.ok(!JSON.stringify(v).includes('sha256='), 'no v1 value is computed or echoed');
++ assert.ok(!/v1/i.test(Object.keys(v.v2).join(',')), 'the v2 check has no v1 fields');
++ const unusable = webhooks.inspect({ rawBody: '', signatureV2: v2(body, secret, nowSec), secret });
++ assert.deepStrictEqual(Object.keys(unusable).sort(), ['accepted', 'reason']);
++ assert.strictEqual(unusable.accepted, false);
++ assert.deepStrictEqual(Object.keys(webhooks.inspect({ rawBody: body, secret: '' })).sort(), ['accepted', 'reason']);
++ });
++
++ await check('v2: the ±300 s window, both directions, with the signature told apart from the time', async () => {
++ const at = (ts) => webhooks.inspect({ rawBody: body, signatureV2: v2(body, secret, ts), secret, now: nowMs });
++ assert.strictEqual(at(nowSec - 299).accepted, true);
++ assert.strictEqual(at(nowSec + 299).accepted, true);
++ const stale = at(nowSec - 301);
++ assert.strictEqual(stale.accepted, false);
++ assert.strictEqual(stale.v2.signatureValid, true, 'the HMAC matches; only the time is wrong');
++ assert.strictEqual(stale.v2.withinWindow, false);
++ assert.strictEqual(stale.v2.ageSec, 301);
++ assert.match(stale.v2.reason, /301 s in the past, outside the ±300 s window/);
++ const future = at(nowSec + 400);
++ assert.strictEqual(future.accepted, false);
++ assert.strictEqual(future.v2.withinWindow, false);
++ assert.match(future.v2.reason, /in the future/);
++ // Agrees with the SDK a receiver uses.
++ for (const ts of [nowSec - 301, nowSec - 299, nowSec + 299, nowSec + 301]) {
++ const headers = { 'x-openvibe-signature-v2': v2(body, secret, ts) };
++ assert.strictEqual(at(ts).accepted, parseDelivery(body, headers, secret, { requireV2: true, now: nowMs }) !== null, `t offset ${ts - nowSec}`);
++ }
++ });
++
++ await check('v2: wrong secret, tampered body, a v2 signed for another t, malformed and mismatched headers: refused', async () => {
++ const good = v2(body, secret, nowSec);
++ const run = (o) => webhooks.inspect({ rawBody: body, signatureV2: good, secret, now: nowMs, ...o });
++ const wrongSecret = run({ secret: secret + 'x' });
++ assert.strictEqual(wrongSecret.accepted, false);
++ assert.strictEqual(wrongSecret.v2.signatureValid, false);
++ assert.match(wrongSecret.v2.reason, /does not match/);
++ assert.strictEqual(run({ rawBody: body.replace('"seq":7', '"seq":8') }).accepted, false);
++ // The HMAC for one time moved onto another: the time is covered by the signature.
++ const moved = run({ signatureV2: good.replace(`t=${nowSec}`, `t=${nowSec - 1}`) });
++ assert.strictEqual(moved.accepted, false);
++ assert.strictEqual(moved.v2.signatureValid, false);
++ for (const bad of ['v2=abc', `t=${nowSec}`, `t=x,v2=${'0'.repeat(64)}`, 'nonsense', `t=${nowSec},t=${nowSec},v2=${'0'.repeat(64)}`]) {
++ const r = run({ signatureV2: bad });
++ assert.strictEqual(r.accepted, false, bad);
++ assert.match(r.v2.reason, /t=<unix seconds>,v2=/, bad);
++ }
++ const short = run({ signatureV2: good.slice(0, -2) });
++ assert.match(short.v2.reason, /wrong length/);
++ const mismatch = run({ timestamp: String(nowSec - 5) });
++ assert.strictEqual(mismatch.accepted, false);
++ assert.strictEqual(mismatch.v2.signatureValid, true);
++ assert.strictEqual(mismatch.v2.timestampsAgree, false);
++ assert.match(mismatch.v2.reason, /X-OpenVibe-Timestamp \(\d+\) is not the t=/);
++ });
++
++ await check('a delivery with only the retired v1 header is refused as having no X-OpenVibe-Signature-V2', async () => {
++ const v1Only = webhooks.inspect({ rawBody: body, signature: hmacV1(body, secret), secret, now: nowMs });
++ assert.strictEqual(v1Only.accepted, false);
++ assert.strictEqual(v1Only.v2.present, false);
++ assert.match(v1Only.v2.reason, /no X-OpenVibe-Signature-V2/);
++ assert.strictEqual(v1Only.v1, undefined);
++ // The SDK receiver every consumer runs refuses it too.
++ assert.strictEqual(parseDelivery(body, { 'x-openvibe-signature': hmacV1(body, secret) }, secret, { requireV2: true, now: nowMs }), null);
++ // A stale v2 stays refused whatever else the delivery carries.
++ assert.strictEqual(webhooks.inspect({ rawBody: body, signature: hmacV1(body, secret), signatureV2: v2(body, secret, nowSec - 3600), secret, now: nowMs }).accepted, false);
++ });
++
++ await check('the comparisons are constant-time (crypto.timingSafeEqual on equal-length buffers)', async () => {
++ const orig = crypto.timingSafeEqual;
++ let calls = 0;
++ crypto.timingSafeEqual = (a, b) => { calls++; assert.strictEqual(a.length, b.length); return orig(a, b); };
++ try {
++ webhooks.inspect({ rawBody: body, signatureV2: v2(body, secret, nowSec), secret, now: nowMs });
++ assert.strictEqual(calls, 2, 'the signature alone and the receiver check both go through timingSafeEqual');
++ calls = 0;
++ webhooks.inspect({ rawBody: body, signatureV2: v2(body, 'other', nowSec), secret, now: nowMs });
++ assert.strictEqual(calls, 2, 'a wrong v2 is compared in constant time too');
++ } finally { crypto.timingSafeEqual = orig; }
++ });
++
++ await check('a generated sample carries X-OpenVibe-Timestamp and v2 only (no X-OpenVibe-Signature), and the SDK receiver requiring v2 accepts it', async () => {
++ const s = webhooks.sample({ eventType: 'media.object.ready', secret, producer: 'media', now: nowMs });
++ assert.strictEqual(s.envelopeValid, true, JSON.stringify(s.envelopeErrors));
++ const headers = Object.fromEntries(Object.entries(s.headers).map(([k, v]) => [k.toLowerCase(), v]));
++ assert.ok(!('x-openvibe-signature' in headers), 'no v1 header');
++ assert.deepStrictEqual(Object.keys(headers).filter((k) => /signature/.test(k)), ['x-openvibe-signature-v2']);
++ assert.ok(!Object.values(headers).some((v) => String(v).startsWith('sha256=')), 'no v1 value under any name');
++ assert.strictEqual(headers['x-openvibe-timestamp'], String(nowSec));
++ assert.strictEqual(headers['x-openvibe-signature-v2'], v2(s.body, secret, nowSec));
++ assert.strictEqual(verifyDeliveryV2(s.body, headers, secret, { now: nowMs }), true);
++ const parsed = parseDelivery(s.body, headers, secret, { requireV2: true, now: nowMs });
++ assert.strictEqual(parsed.event.event_type, 'media.object.ready');
++ assert.strictEqual(parsed.seq, 1);
++ assert.strictEqual(contracts.validate('events.event-envelope@1', parsed.event).valid, true);
++ assert.strictEqual(parseDelivery(s.body, headers, secret, { requireV2: true, now: nowMs + 301_000 }), null, 'the sample goes stale after 300 s');
++ assert.strictEqual(parseDelivery(s.body, headers, 'wrong', { requireV2: true, now: nowMs }), null);
++ assert.deepStrictEqual(JSON.parse(s.body).event.payload, {}, 'no invented payload');
++ // What the tester says about its own sample.
++ const v = webhooks.inspect({ rawBody: s.body, signatureV2: headers['x-openvibe-signature-v2'], timestamp: headers['x-openvibe-timestamp'], secret, now: nowMs });
++ assert.strictEqual(v.accepted, true);
++ });
++
++ const t = await boot();
++
++ await check('no-JS path: the server checks v2, shows the window, and says why a delivery is refused', async () => {
++ const ts = Math.floor(Date.now() / 1000);
++ const ok = await t.get('/tools/webhooks/verify', { form: { body, signature_v2: v2(body, secret, ts), timestamp: String(ts), secret } });
++ assert.strictEqual(ok.status, 200);
++ assert.match(ok.text, /Accepted: v2 verifies and is inside the window\./);
++ assert.match(ok.text, /badge ok">inside</);
++ assert.doesNotMatch(ok.text, /<h3>v1/);
++ const stale = await t.get('/tools/webhooks/verify', { form: { body, signature_v2: v2(body, secret, ts - 600), secret } });
++ assert.strictEqual(stale.status, 422);
++ assert.match(stale.text, /Refused: a receiver that requires v2 rejects this delivery\./);
++ assert.match(stale.text, /badge warn">stale</);
++ assert.match(stale.text, /badge bad">outside</);
++ // A request carrying only a v1 value (the old field) is refused for want of v2, and the v1 value is not echoed.
++ const v1Only = await t.get('/tools/webhooks/verify', { form: { body, signature: hmacV1(body, secret), secret } });
++ assert.strictEqual(v1Only.status, 422);
++ assert.match(v1Only.text, /no X-OpenVibe-Signature-V2 value given/);
++ assert.ok(!v1Only.text.includes(hmacV1(body, secret)));
++ assert.doesNotMatch(v1Only.text, /sha256=[0-9a-f]{64}/);
++ const bad = await t.get('/tools/webhooks/verify', { form: { body, signature_v2: v2(body, 'nope', ts), secret } });
++ assert.strictEqual(bad.status, 422);
++ assert.match(bad.text, /v2 \(X-OpenVibe-Signature-V2\) <span class="badge bad">NOT valid/);
++ for (const r of [ok, stale, v1Only, bad]) {
++ assert.ok(!r.text.includes(secret));
++ assert.match(r.text, /<input type="password" name="secret" value=""/);
++ }
++ assert.ok(ok.text.includes(`value="${v2(body, secret, ts)}"`), 'the v2 header comes back in the form');
++ });
++
++ await check('no-JS path: sample generation for an event type from the contracts catalog, v2 only', async () => {
++ const r = await t.get('/tools/webhooks/sample', { form: { event_type: 'network.credential.rotated', secret } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /X-OpenVibe-Signature-V2/);
++ assert.match(r.text, /X-OpenVibe-Timestamp/);
++ assert.match(r.text, /<td><code>X-OpenVibe-Signature-V2<\/code><\/td>/);
++ assert.doesNotMatch(r.text, /<td><code>X-OpenVibe-Signature<\/code><\/td>/, 'no v1 header row in the sample');
++ assert.match(r.text, /X-OpenVibe-Signature-V2: t=\d+,v2=[0-9a-f]{64}/, 'the curl replay carries v2');
++ assert.doesNotMatch(r.text, /X-OpenVibe-Signature: /, 'no v1 header in the curl replay');
++ assert.doesNotMatch(r.text, /sha256=[0-9a-f]{64}/);
++ assert.match(r.text, /The envelope validates/);
++ const unknown = await t.get('/tools/webhooks/sample', { form: { event_type: 'made.up.event', secret } });
++ assert.strictEqual(unknown.status, 422);
++ });
++
++ await check('the page has no v1 input and lists only the timestamp and v2 headers', async () => {
++ const r = await t.get('/tools/webhooks');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<input name="signature_v2"/);
++ assert.match(r.text, /<input name="timestamp"/);
++ assert.doesNotMatch(r.text, /name="signature"/, 'no v1 field');
++ assert.doesNotMatch(r.text, /placeholder="sha256=/);
++ const headerList = r.text.slice(r.text.indexOf('<ul>'), r.text.indexOf('</ul>'));
++ assert.deepStrictEqual([...headerList.matchAll(/<code>(X-OpenVibe-[A-Za-z0-9-]+):/g)].map((m) => m[1]), ['X-OpenVibe-Timestamp', 'X-OpenVibe-Signature-V2']);
++ assert.match(r.text, /retired on 2026-09-28/);
++ assert.match(r.text, /300 seconds/);
++ });
++
++ await check('the page offers only event types some service manifest produces', async () => {
++ const r = await t.get('/tools/webhooks');
++ const offered = [...r.text.matchAll(/<option(?: selected)?>([^<]+)<\/option>/g)].map((m) => m[1]).sort();
++ const expected = [...new Set(contracts.services.manifests.flatMap((m) => m.eventsProduced || []))].sort();
++ assert.deepStrictEqual(offered, expected);
++ });
++
++ await t.close();
++ done();
++})();