openvibers/openvibe.codes Commits

Codes is the open coding-agent harness; the developer console moved to OpenVibe.Services (#25)

OP OpenVibers committed 8 Oct 2026 · c50db79fb0 · 81 files, +650 −7901

Owner decision 2026-10-08. The home page is the harness: the agent catalog, how a task finds its agent (capability first, then cost, through openvibe-sdk/placement), Improve OpenVibe and an honest table of what works today and what comes next. /harnesses is the catalog page (from /docs/harnesses), /improve lists every repository from the pinned contracts and the path to a reviewed pull request, and the community documents stay here. Every former console address (/projects, /docs, /oauth, /tools, /manifests, /releases, /apps, /staff, the platform policy pages and the rest of /api/v1) answers 301 (reads) or 308 (writes) to the same path on openvibe.services. The console's code, tests and tables go (migration 0002: their rows were copied into ov_services first); no capability or event is left (openvibe-contracts v0.113.0). openvibe-sdk v0.35.2, openvibe-shared v2.13.3.


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 2a73169..2d586b4 100644
---- a/.env.example
++++ b/.env.example
@@@ -12,13 +12,13 @@ DATABASE_URL=
DATABASE_DIRECT_URL=
VALKEY_URL=
VALKEY_PREFIX=
--# Per-actor limits (server/http/actor-limits.js): reads one caller (an app, a person, else an address)
--# may make to the API and the portal per minute and per hour; past them 429 rate_limited with
--# Retry-After. Writes have their own numbers (README). The per-address limits stay.
++# Per-actor limits (server/http/actor-limits.js): reads one caller (a person, else an address) may make to the
++# API per minute and per hour; past them 429 rate_limited with Retry-After. The router has its own number
++# (README). The per-address limits stay.
#CODES_LIMITS_MINUTE=120
#CODES_LIMITS_HOUR=3000
--# OpenVibe.Network: SSO (OAuth client `codes`, PKCE S256), JWKS, the /api/v1/projects API, the registry.
++# OpenVibe.Network: SSO (OAuth client `codes`, PKCE S256) and its JWKS.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# OV_NETWORK_ISSUER=https://openvibe.network # defaults to OV_NETWORK_URL
@@@ -27,34 +27,5 @@ OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.codes/auth/callback
# OV_SESSION_AUDIENCE=openvibe.network # audience a Network session token must carry to be a Codes session
--# Signs the per-person form token (CSRF). Unset: a random per-process key (forms expire on restart).
--CODES_FORM_SECRET=
# optional: defaults to true in production
# COOKIE_SECURE=true
--
--# Codes staff (trust tiers, metadata only): staff with staff.site.configure (Network admins), plus these
--# usr_ subjects (comma list; the break-glass way in).
--CODES_STAFF_SUBJECTS=
--
--# Playgrounds call these with the APP's own token (defaults: the public origins in openvibe-contracts).
--# CODES_PLAYGROUND_EVENTS_URL=https://openvibe.events
--# CODES_PLAYGROUND_MEDIA_URL=https://openvibe.media
--# CODES_PLAYGROUND_MAX_UPLOAD_BYTES=1048576
--# CODES_PLAYGROUND_RUNS_PER_HOUR=60
--
--# The full project archive (owner/admin, /projects/:project/export/archive) reads Media and Events
--# server-side with Network's read-only export tokens (defaults: OV_MEDIA_INTERNAL_URL /
--# OV_EVENTS_INTERNAL_URL, else loopback). Limits per environment; signed URL lifetime (60-3600 s).
--# CODES_EXPORT_MEDIA_URL=http://127.0.0.1:4100
--# CODES_EXPORT_EVENTS_URL=http://127.0.0.1:4300
--# CODES_EXPORT_MAX_OBJECTS=5000
--# CODES_EXPORT_MAX_EVENTS=10000
--# CODES_EXPORT_URL_TTL_S=3600
--
--# Registry answers are cached this long (ms).
--# CODES_REGISTRY_TTL_MS=30000
--
--# OpenVibe.Events: the outbox relay (codes.app.published|deprecated|revoked) runs when both
--# EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise events wait in event_outbox.
--EVENTS_URL=http://127.0.0.1:4300
--# EVENTS_RELAY_INTERVAL_MS=2000
ddiff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
iindex fa123cb..6dc1a74 100644
---- a/CODE_OF_CONDUCT.md
++++ b/CODE_OF_CONDUCT.md
@@@ -2,7 +2,7 @@
**Draft — pending owner review.** This text is a proposal. It takes effect only when the owner of the OpenVibers organization approves it and removes this line.
--This code applies to everyone who takes part in OpenVibe's developer spaces: the OpenVibers repositories on GitHub (issues, pull requests, discussions, reviews), OpenVibe.Codes, and any chat or call about building on OpenVibe. The products themselves (Live, Chat, Community and the rest) have their own rules for viewers and streamers; this code is about how we work together on the platform and the apps built on it.
++This code applies to everyone who takes part in OpenVibe's developer spaces: the OpenVibers repositories on GitHub (issues, pull requests, discussions, reviews), OpenVibe.Codes, OpenVibe.Services, and any chat or call about building on OpenVibe. The products themselves (Live, Chat, Community and the rest) have their own rules for viewers and streamers; this code is about how we work together on the platform and the apps built on it.
## What we expect
ddiff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
iindex 2ffb723..26ba927 100644
---- a/CONTRIBUTING.md
++++ b/CONTRIBUTING.md
@@@ -6,7 +6,7 @@ Thank you for helping. This guide covers every OpenVibers repository; a reposito
## Where things live
--- Each service has its own repository under [github.com/OpenVibers](https://github.com/OpenVibers): OpenVibe.Network (identity, projects, apps, grants), OpenVibe.Live, OpenVibe.Media, OpenVibe.Events, OpenVibe.Chat, OpenVibe.Codes (this portal), and others.
++- Each service has its own repository under [github.com/OpenVibers](https://github.com/OpenVibers): OpenVibe.Network (identity, projects, apps, grants), OpenVibe.Live, OpenVibe.Media, OpenVibe.Events, OpenVibe.Chat, OpenVibe.Services (the developer platform), OpenVibe.Codes (the coding-agent harness, where this guide lives), and others.
- Interfaces between services live in OpenVibe.Contracts: schemas, capabilities, event types, service manifests and the decision record (ADRs).
- The client library for apps is OpenVibe.SDK (`openvibe-sdk`). Working examples are in OpenVibe.Examples.
@@@ -14,7 +14,7 @@ Thank you for helping. This guide covers every OpenVibers repository; a reposito
- For a bug, open an issue in the repository that has it: what you did, what you expected, what happened, and the version (the service's `/release.json`, or the package version).
- For a small fix (a typo, a clear bug with an obvious fix), you can open a pull request straight away.
--- For anything that changes a public interface — a contract, a capability, an event type, an API route others call — start with a proposal, as described in [proposals and decisions](https://openvibe.codes/policy/rfc). A pull request that changes an interface without an accepted proposal will be asked to open one first.
++- For anything that changes a public interface — a contract, a capability, an event type, an API route others call — start with a proposal, as described in [proposals and decisions](https://openvibe.services/policy/rfc). A pull request that changes an interface without an accepted proposal will be asked to open one first.
- Security problems: do not open a public issue. Write to [email protected] with the details and give us time to fix it before you publish anything.
## Making a change
@@@ -38,7 +38,7 @@ A maintainer may ask for changes. When the pull request is approved and CI passe
## Licensing
--Each repository's LICENSE file applies to contributions to it. The services are licensed under the AGPL-3.0; the SDK and contracts packages have their own licenses, shown on the [licensing page](https://openvibe.codes/policy/licensing). By opening a pull request you confirm that you have the right to contribute the code under that repository's license. Do not add code you copied from somewhere whose license does not allow it.
++Each repository's LICENSE file applies to contributions to it. The services are licensed under the AGPL-3.0; the SDK and contracts packages have their own licenses, shown on the [licensing page](https://openvibe.services/policy/licensing). By opening a pull request you confirm that you have the right to contribute the code under that repository's license. Do not add code you copied from somewhere whose license does not allow it.
## Growing as a contributor
ddiff --git a/README.md b/README.md
iindex 8548c6f..a51cb4e 100644
---- a/README.md
++++ b/README.md
@@@ -1,188 +1,97 @@
# OpenVibe.Codes
--> The developer portal: projects and apps over OpenVibe.Network, scoped credentials, generated contract and SDK docs, OAuth and webhook tools, grant-respecting playgrounds, manifest validation and release metadata.
++> Code with any agent: the open coding-agent harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself, and the way to improve OpenVibe itself.
--**Status:** alpha (roadmap Wave 20). **Deployed and public since 2026-09-23:** `https://openvibe.codes` is served by this portal (openvibe-ovh, unit `openvibe-codes` on 127.0.0.1:4900 behind nginx), and the domain left [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites). The Network OAuth client `codes` is registered. Production use so far is nil (0 manifests, 0 releases, 0 playground runs). 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.
++**Status:** alpha. Public at `https://openvibe.codes` (openvibe-ovh, unit `openvibe-codes` on 127.0.0.1:4900 behind nginx). On 2026-10-08 the owner made Codes the open, modular coding-agent harness; the developer console that used to live here (projects, apps, keys, grants, the API reference, OAuth and webhook tools, releases) moved to [OpenVibe.Services](https://github.com/OpenVibers/OpenVibe.Services) at `https://openvibe.services`, and every one of its addresses here answers a permanent redirect there. See [STATUS.json](STATUS.json) for exactly what works and what does not.
**Domain:** `openvibe.codes` · **Port:** 4900 · **Service id:** `codes`
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.17, §24.2 (proof flow 6), §30; binding decision ADR-014 in OpenVibe.Contracts.
++**Plan:** plan T16 (the coding-harness fabric); the harness boundaries are [ADR-050](docs/adr/050-codes-harness-fabric.md) (proposed).
**License:** AGPL-3.0 (same as every OpenVibe service).
## Purpose
--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. Codes is a **portal**: the identity authority (OpenVibe.Network) owns projects, apps, credentials, grants and quotas (ADR-014), and Codes shows and forwards what the signed-in person asks for, with that person's own Network token.
--
--## Owns
--
--- **Release metadata** keyed to Network app ids: validated app and mod manifests, releases (`draft → published → deprecated → revoked`), an append-only release log.
--- **The authority resource index** (ADR-048, `codes.resource.read`): those rows as `common.resource-summary@1` — `codes.manifest` (`mfs_`) and `codes.release` (`rel_`) — for OpenVibe.Services to fan out over and merge. Both kinds are nameable, so every summary carries an OVRN; 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 `codes.app.published`, `codes.app.deprecated`, `codes.app.revoked`, and `codes.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 portal's pages.
--
--## Does not own
--
--- Projects, members, apps, credentials, grants, allowances, quotas, audit: **OpenVibe.Network** (`/api/v1/projects`). Codes never copies them.
--- Secrets: Network returns a client secret once; Codes 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. Codes labels quotas "recorded limit — enforced by `<service>`".
--- The registry (Network) and the contracts (OpenVibe.Contracts).
++One place to send a coding task to the agent that fits it — by what the task needs first, then by cost — hand it over when one agent gets stuck, and keep the run in one place; and the front door for anyone who wants to improve OpenVibe itself. The agents run on people's own machines or OpenVibe's; Codes routes, records and hands off, and never holds a person's provider keys in the clear.
## 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 Codes' 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.codes/docs/export)) and **delete** a project (owner, confirmed by name: Network archives it — it offers archiving, not erasure — then Codes 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. |
--| Coding-harness registry | `/docs/harnesses`, `/api/v1/harnesses` | Lists the coding harnesses Codes can route to with their agents (`GET /api/v1/harnesses`) and picks an offer for a task (`POST /api/v1/harnesses/route`). |
--| 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 `codes.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` | The authority resource index (ADR-048, `codes.resource.read`, first-party): a page of `common.resource-summary@1` for the manifests (`codes.manifest`, `mfs_`) and releases (`codes.release`, `rel_`) Codes owns, one by its OVRN. `?project=prj_…` is the tenancy boundary (another project's rows never answer), `?kind=` picks one kind, and `?cursor=`/`?limit=` page (opaque cursor, default 100, max 1000). Guarded with openvibe-contracts `requireCapability`; RFC 9457 problems (`resources.bad_query`, `resources.unknown_resource`). |
--| 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 Codes stores and does not); governance — code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), [contributor ladder](docs/governance/contributor-ladder.md) and [moderation policy](docs/governance/moderation.md) — rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. |
--| API | `/api/v1/*` | Public release reads (`codes.release.read`: no token needed; a presented token must hold it), manifest validation, docs versions; release management by an app with its own token (`codes.release.manage`); Codes' own resource index (`codes.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 Codes' own are optional: in-browser webhook verification and a copy button.
++| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
++| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
++| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
++| Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. |
++| Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |
++| Moved | `/projects`, `/docs`, `/oauth`, `/tools`, `/manifests`, `/releases`, `/apps`, `/staff`, `/policy/{rfc,compatibility,licensing,transparency}`, `/api/v1/*` but the agent API | Permanent redirects to the same path and query on `https://openvibe.services`: 301 for GET and HEAD, 308 for anything else (the method and body are kept). `/docs/harnesses` goes to `/harnesses` here. [`server/http/moved.js`](server/http/moved.js). |
++| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (the database and the catalog required; JWKS, the OAuth client and Valkey 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. robots.txt disallows sign-in and the API and leaves the moved addresses crawlable, so engines follow their 301s to openvibe.services; the sitemap lists the public pages with STATUS.json's content date (never "today"). |
--## Depends on
++Everything is server-rendered and usable without JavaScript.
--- **OpenVibe.Network** — SSO (OAuth client `codes`, 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 (Codes' own for the events relay; the app's own in playgrounds).
--- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' 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.107.0, **openvibe-sdk v0.35.0**, **openvibe-shared v2.13.2** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
++## What comes next
--No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
++The harness grows out of the agent pool that already builds OpenVibe (`~/openvibe/agents` on the owner's machines: Claude Code, Codex, OpenCode Go, Command Code and the DeepSeek API, each with launch, streaming, usage, resume and pooling, plus routing and hand-offs). The order, adapter interface first:
--## Grants and registration the lead must add
++1. **Adapters** — one interface per harness (launch, stream, usage, resume, pool, capabilities), the personal paths and credentials replaced by tenant configuration and keys kept in OpenVibe.AI.
++2. **Runs** — `POST /api/v1/jobs`, a stream, cancel; budgets per person and per project; the run log in Codes' own database.
++3. **Hand-offs and sessions** — a stalled run moves to the next candidate with its context.
++4. **A runner on your own machine** — your CLIs and your keys, through an OpenVibe Node.
++5. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.
--Implemented here (the service manifest's `capabilities`): `codes.release.read` (public release reads;
--no token needed, a presented token must hold it) and `codes.release.manage` (release management by an
--app with its own token), both guarded with openvibe-contracts `requireCapability`; and
--`codes.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 `codes`, `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`.
++## Owns
--In Network (`server/identity/principals.js` / `server/db/database.js`, as for coupons and host):
++- The harness catalog and the routing policy over it (placement itself is `openvibe-sdk/placement`, the one implementation every service uses).
++- Its pages and the community documents in this repository.
++- Nothing in its database yet: migration 0002 dropped the console's tables when the console moved; the harness's runs will live here.
--- OAuth client `codes`, name `OpenVibe.Codes`, redirect URI `https://openvibe.codes/auth/callback`.
--- Service grant `['codes', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
++## Does not own
--The Codes service manifest, `codes.release.manage|read`, `codes.resource.read` (planned until the contracts follow-up flips it after this ships) and `codes.app-manifest@1` are released in openvibe-contracts (this repository pins v0.107.0); the CI contracts check is blocking.
++- The developer console, releases, manifests, trust tiers, playgrounds and the generated reference: **OpenVibe.Services**.
++- Accounts, projects, apps, credentials and grants: **OpenVibe.Network**.
++- Provider keys: **OpenVibe.AI** (bring-your-own keys are stored and used there).
--For the playgrounds to succeed end to end (not Codes' 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 Codes playgrounds, and it is not yet a committed, repeatable check.
++## Depends on
--## Configuration
++- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
++- **openvibe-contracts v0.113.0**, **openvibe-sdk v0.35.2**, **openvibe-shared v2.13.3** (pinned tag tarballs).
--See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET`, `CODES_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), `CODES_STAFF_SUBJECTS`, `CODES_PLAYGROUND_*`, `CODES_REGISTRY_TTL_MS`, `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR`.
++No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
--### IndexNow (openvibe-shared/indexnow)
++## Configuration
--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`.
++See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET`, `BASE_URL`, `DATABASE_URL` (and `DATABASE_DIRECT_URL` for the boot migrations, run as the owner). Optional: `VALKEY_URL`, `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR`.
### Per-actor limits
--`/api/v1`, the portal (`/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
--`codes_rate_limited_total{limit,window}`. The per-address limits (pages 300 a minute, portal writes 60,
--tools 30, API writes 60, sign-in) and the playground's runs an hour stay. Codes hosts no git remotes, so
--there are no clone or push routes to leave out.
--
--| Routes | Per caller, a minute / an hour |
--|---|---|
--| API reads; portal pages (each asks Network with the person's token) | `CODES_LIMITS_MINUTE` / `CODES_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" (portal and API) | 20 / 200 |
--| Release publish, deprecate, revoke (portal 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.
++`/api/v1` limits who calls it, once the caller is known and before any work: `server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. A signed-in person counts as `user:usr_…`, anyone else by address. Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and `codes_rate_limited_total{limit,window}`. Reads: `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR` (120 / 3000); the router: 30 a minute, 600 an hour. Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in and the public pages (the per-address limit bounds them).
## Deploy (for the lead)
--Production deploys with `sudo ovhost deploy codes` on the host (strategy `git-checkout`: fetch,
--fast-forward `/opt/openvibe.codes`, install on a lockfile change, restart, wait for `/api/ready`).
--The unit is `openvibe-codes.service` on `127.0.0.1:4900`, the env file `/etc/openvibe/codes.env`. The database is
--`ov_codes` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh codes` writes its settings); the
--release migrates it at boot. nginx serves `openvibe.codes` from
--[deploy/nginx/openvibe.codes.conf](deploy/nginx/openvibe.codes.conf).
--Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
--restart; afterwards `sudo ovhost rollback codes --to <sha>`. One blocker: the trust table was rebuilt
--once to the ADR-013 tier names, so a release from before that change expects the old names.
--
--First install (done once; kept for a rebuild):
--
--1. `git clone` to `/opt/openvibe.codes`; `npm ci --omit=dev` with Node 22.
--2. `/etc/openvibe/codes.env` from `.env.example` (secrets from the Network client registration).
--3. `deploy/systemd/openvibe-codes.service` → `/etc/systemd/system/`; `systemctl enable --now openvibe-codes`.
--4. `deploy/nginx/openvibe.codes.conf` → `/etc/nginx/sites-available/`, certificate for `openvibe.codes` + `www`, reload nginx.
--5. Check `curl -s http://127.0.0.1:4900/api/ready`.
--6. Only when the launch rule below holds: remove `openvibe.codes` from `OpenVibe.Sites/sites.json` and switch routing (done on 2026-09-23).
++Production deploys with `sudo ovhost deploy codes` on the host (strategy `git-checkout`: fetch, fast-forward `/opt/openvibe.codes`, install on a lockfile change, restart, wait for `/api/ready`). The unit is `openvibe-codes.service` on `127.0.0.1:4900`, the env file `/etc/openvibe/codes.env`, the database `ov_codes` on the host's data role. nginx serves `openvibe.codes` from [deploy/nginx/openvibe.codes.conf](deploy/nginx/openvibe.codes.conf). Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the restart; afterwards `sudo ovhost rollback codes --to <sha>`. Migration 0002 drops the console's tables (their rows were copied into `ov_services` first), so a rollback to a release before it finds no console tables: roll Codes forward, or restore `ov_codes` from the backup taken before the deploy.
## Development
```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 test # every test/*.test.js on temp PGlite databases with a mock Network
fnm exec --using=22.22.1 npm run dev # http://localhost:4900
--npx openvibe-contracts-check --service codes --src server
```
--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 `codes.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 codes 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).
--
--## Acceptance (must be true before "done")
--
--- 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).**
--
--## Launch rule
--
--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). **Codes left Sites on 2026-09-23 and serves `openvibe.codes`.** The items: an owning runtime with health/readiness and observability (**done**); canonical identity/auth integration (**done; the OAuth client `codes` is registered**); 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 portal**); 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 launch Codes as a developer portal while these paths are mocked.
++Tests: the catalog (validation at boot, the API and the router: capability first, the reasons, refusals as problems); the redirects (every console address 301 or 308 to the same path on openvibe.services, what stays here answering 200); sign-in (PKCE, state, next=, an assertion or app token is not a session, cross-site sign-out refused); the community documents (drafts marked, not indexed, every link resolving); the crawl artifacts; per-actor limits; no internal key anywhere; no secret in any response, the database or the logs; no URL a caller typed ever fetched; readiness, release.json and loopback metrics; the released codes manifest matching the code; and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
## Security (threat notes)
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
--- 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.
--- Codes never fetches a URL a developer types (the webhook tester produces a `curl` for their own endpoint instead).
--- Grant checks happen twice: Codes 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.
++- Session tokens are httpOnly cookies; a FedCM assertion, an app token or a service token is never a session.
++- Codes never fetches a URL anyone typed: the router reads requirements, and the moved addresses are redirects built from the path.
++- Secrets never reach logs: errors are logged without request bodies.
---
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.2
++- openvibe-shared: v2.13.3
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 7dc0ce5..e26f37b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,66 +3,44 @@
"stage": "alpha",
"code": true,
"wave": "W20",
-- "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
++ "plan": "OpenVibe Updated Plan Based On Codebase (T16, owner decision 2026-10-08: Codes is the open coding-agent harness; the developer console moved to OpenVibe.Services)",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4900,
"domain": "openvibe.codes",
-- "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.2",
++ "shared": "openvibe-shared v2.13.3",
"packages": [
-- "openvibe-contracts v0.107.0",
-- "openvibe-sdk v0.35.0",
-- "openvibe-shared v2.13.0"
++ "openvibe-contracts v0.113.0",
++ "openvibe-sdk v0.35.2",
++ "openvibe-shared v2.13.3"
],
-- "summary": "Deployed and public since 2026-09-23: https://openvibe.codes is served by this portal (deployed with `sudo ovhost deploy codes`, unit openvibe-codes on 127.0.0.1:4900 behind nginx), and the domain left OpenVibe.Sites. Sign-in uses the registered Network OAuth client `codes` (PKCE S256); the principal `codes` holds events.event.publish and the Events relay is configured. On 2026-09-23 production held 0 manifests, 0 releases and 0 playground runs. The external-developer path (account, project, sandbox app, grants, Media upload, app events) was run once on production on 2026-09-23 with public endpoints and curl, not through these playgrounds; that check is not committed to a repository or CI.",
++ "summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: the agent catalog with a public routing API, Improve OpenVibe (every repository and the path to a reviewed pull request) and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"works": [
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
-- "Projects, members, apps, credentials (rotate, revoke), grants (request, approve, deny, revoke), quotas and audit over Network's /api/v1/projects API with the person's own token; Network's errors shown as Network answered them",
-- "Per-project usage page (owner, admins, staff): Network's GET /api/v1/projects/:project/usage rendered server-side, with quota headroom, published service limits and sampled failures",
-- "Project export (one JSON document: Network's project, members, apps, credential ids and hints, grants, quotas, audit for admin+, plus Codes' releases, manifests, trust and playground runs) and delete (owner: archive in Network first, then drafts and playground runs deleted, public releases revoked). Network has no project erasure endpoint, only archive",
-- "Full project archive for the owner and admins (roadmap WS-N task 9): one zip with the metadata document (audit included), the release manifests (the project's modules), Media objects (listed with public or 1-hour signed download URLs) and namespaces, and the app events Events still keeps, for production and sandbox; read with Network's read-only export tokens (POST /api/v1/projects/:project/export-tokens, 5 minutes, audited). All or nothing when a service fails; at most CODES_EXPORT_MAX_OBJECTS / CODES_EXPORT_MAX_EVENTS per environment, stated in manifest.json when reached",
-- "Client secrets shown once, in the create/rotate response only, with no-store; never stored or logged (tested)",
-- "Scope editor offering only capabilities apps can be granted (public, or partner when in the allowance)",
-- "Docs generated at boot from the pinned openvibe-contracts and openvibe-sdk: contracts with field tables and fixtures, capability catalog, event types, SDK reference; service registry read live from Network with its health",
-- "OAuth callback helper and a test callback that never exchanges the code",
-- "Webhook tester for signature v2 (server or in-browser): decides as a receiver requiring v2 does and shows the timestamp's age and the ±300 s window; v1 was retired on 2026-09-28 and has no field; signed sample deliveries carry X-OpenVibe-Timestamp and v2 only",
-- "Playgrounds (Media upload with media.object.upload, Events publish with events.app.publish) that run with the app's own token and refuse without the grant",
-- "App and mod manifest validation; release metadata (draft, publish, deprecate, revoke) with codes.app.* events through the SDK outbox; trust tiers per ADR-013 (unreviewed, reviewed, first-party) as staff-set metadata, older names migrated at boot",
-- "Policy pages rendered from the published ADRs; licensing read from package metadata; governance pages (code of conduct, contributing, contributor ladder, moderation policy) rendered from the repository's Markdown",
-- "Release API guarded by openvibe-contracts requireCapability (codes.release.manage, codes.release.read)",
-- "Authority resource index (ADR-048, codes.resource.read, first-party): GET /api/v1/resources lists the validated manifests (codes.manifest, mfs_) and releases (codes.release, rel_) Codes owns as pages of common.resource-summary@1, GET /api/v1/resources/:ovrn reads one by its computed OVRN; ?project=prj_… is the tenancy boundary (another project's rows never answer), ?kind= narrows, keyset cursor paging (default limit 100, max 1000). Guarded with openvibe-contracts requireCapability; every page and summary is validated against the released common.resource-list-result@1 / common.resource-summary@1 in test/resource-index.test.js. The capability is released 'planned' — the contracts follow-up flips it once this is deployed",
-- "Readiness (/api/ready), /release.json and loopback-only /metrics",
-- "Coding-harness registry: a validated catalog of harnesses and their agents (capabilities, address, price and limits), a task placement adapter over openvibe-sdk/placement, and a public read API and read-only docs page (GET /api/v1/harnesses, POST /api/v1/harnesses/route, /docs/harnesses); each harness × agent pair is routed as a platform.resource-offer@1 of kind harness with task:<kind> and harness:<flag> capabilities and a platform.harness-offer@1 detail",
-- "Crawl and machine-readable artifacts built with openvibe-shared/seo: robots.txt (every previous Disallow kept, search and AI crawlers named), sitemap.xml (the fixed public pages with STATUS.json's content date, each public app and published release with its real publish time — never \"today\"), llms.txt and llms-full.txt (every fixed public page with a one-line description), and the home page's WebSite/WebApplication/WebPage JSON-LD; the portal, staff console, sign-in and API are absent from all of them and Disallowed in robots.txt"
++ "The agent catalog (server/data/harness-offers.json, validated at boot as platform.harness-offer@1 / platform.agent-offer@1): /harnesses and GET /api/v1/harnesses",
++ "Routing a task over the catalog with openvibe-sdk/placement: POST /api/v1/harnesses/route { task, requirements? } → { selected, reasons, candidates }",
++ "Improve OpenVibe (/improve): every repository from the pinned contracts' service manifests, and the contributor path",
++ "The community documents (code of conduct, contributing, contributor ladder, moderation) rendered from this repository",
++ "Permanent redirects for every former console address (301 for reads, 308 for writes) to openvibe.services",
++ "robots.txt, sitemap.xml, llms.txt and llms-full.txt; per-actor and per-address limits; truthful readiness"
],
"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"
++ "Running a task on an agent, streaming its output, budgets (the agents run today on OpenVibe's own machines through ~/openvibe/agents)",
++ "Hand-offs between agents and resumable sessions",
++ "A runner on a person's own machine (their CLIs, their keys)",
++ "Improve OpenVibe as a flow on this site (repository → change → tests → review → pull request); today it is the contributor path on GitHub",
++ "The community documents are drafts pending the owner's review"
],
"capabilitiesRegistered": true,
"serviceManifestRegistered": true,
"deployed": true,
"placeholderInSites": false,
"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 codes.app.* events",
-- "ADR-013 trust tiers",
-- "policy and governance pages (governance drafts pending owner review)",
-- "per-actor limits (openvibe-sdk/limits)",
-- "coding harness catalog, task routing and a read-only docs page",
-- "authority resource index (codes.resource.read) over Codes' manifests and releases, with OVRN reads",
-- "crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt) and the home page's JSON-LD"
++ "coding-agent catalog and router",
++ "Improve OpenVibe",
++ "community documents",
++ "redirects for the moved developer console"
],
"updated": "2026-10-08"
}
ddiff --git a/deploy/nginx/openvibe.codes.conf b/deploy/nginx/openvibe.codes.conf
iindex b91fe2b..051fac5 100644
---- a/deploy/nginx/openvibe.codes.conf
++++ b/deploy/nginx/openvibe.codes.conf
@@@ -2,12 +2,12 @@
## openvibe.codes — Nginx configuration (REFERENCE)
##
## Deployed to /etc/nginx/sites-available/openvibe.codes.conf — keep them in sync and reload nginx
--## after changing it. The Node app (port 4900, systemd unit openvibe-codes) renders every page;
--## nginx terminates TLS, redirects www → apex, rate-limits sign-in, forms and API, and compresses text.
++## after changing it. The Node app (port 4900, systemd unit openvibe-codes) renders every page and answers
++## the developer console's former addresses with permanent redirects to openvibe.services; nginx terminates
++## TLS, redirects www → apex, rate-limits sign-in, the old form addresses and the API, and compresses text.
##
--## 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). Never log request bodies here: forms carry secrets.
++## No proxy_cache: the app decides cacheability per response (public for the pages rendered for anonymous
++## visitors; private, no-store for everything personal). Never log request bodies here.
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=ovcodes_auth:10m rate=10r/m;
@@@ -91,7 +91,7 @@ server {
proxy_pass http://127.0.0.1:4900;
}
-- # ── State-changing forms (projects, releases, tools, staff) ──
++ # ── The developer console's former form addresses (now 308s to openvibe.services) ──
location ~ ^/(projects|releases|tools|manifests|staff) {
limit_req zone=ovcodes_forms burst=20 nodelay;
proxy_pass http://127.0.0.1:4900;
ddiff --git a/deploy/systemd/openvibe-codes.service b/deploy/systemd/openvibe-codes.service
iindex 71afb77..b08d64f 100644
---- a/deploy/systemd/openvibe-codes.service
++++ b/deploy/systemd/openvibe-codes.service
@@@ -1,5 +1,5 @@
[Unit]
--Description=OpenVibe.Codes — the developer portal (projects over Network's API, generated docs, tools, release metadata)
++Description=OpenVibe.Codes — the open coding-agent harness (the agent catalog and router, Improve OpenVibe; the developer console moved to OpenVibe.Services)
# Network (sign-in, JWKS, the projects API, registry, tokens) and Events (the outbox relay) should be
# up first, but Codes degrades honestly without them: docs, tools and policy pages serve; project
# pages show Network's failure; events wait in the outbox (see /api/ready).
ddiff --git a/docs/governance/contributor-ladder.md b/docs/governance/contributor-ladder.md
iindex ea7d0cb..28cb18a 100644
---- a/docs/governance/contributor-ladder.md
++++ b/docs/governance/contributor-ladder.md
@@@ -34,7 +34,7 @@ A contributor trusted to review other people's changes in a repository.
Someone responsible for a repository: its direction, its releases and its quality.
- **Can:** merge pull requests; cut releases and tags; accept or decline proposals that affect only their repository; take part in moderation decisions.
--- **Expected to:** keep `main` releasable and tests passing; keep the README and status notes honest; follow the [compatibility policy](https://openvibe.codes/policy/compatibility) (deprecate before removing, announce breaking changes); take part in proposals that touch their repository's interfaces.
++- **Expected to:** keep `main` releasable and tests passing; keep the README and status notes honest; follow the [compatibility policy](https://openvibe.services/policy/compatibility) (deprecate before removing, announce breaking changes); take part in proposals that touch their repository's interfaces.
- **How to become one:** sustained work as a reviewer, with good judgement about compatibility and security. Existing maintainers propose it; the owner approves.
- **Deploy access** (production servers, secrets) is separate from maintainer rights and is given only by the owner, to named people, for named services.
ddiff --git a/docs/governance/moderation.md b/docs/governance/moderation.md
iindex 67e890c..026a4e9 100644
---- a/docs/governance/moderation.md
++++ b/docs/governance/moderation.md
@@@ -2,12 +2,12 @@
**Draft — pending owner review.** This text is a proposal. It takes effect only when the owner of the OpenVibers organization approves it and removes this line.
--This policy explains how OpenVibe moderates its developer spaces: the OpenVibers repositories, OpenVibe.Codes, and the apps and releases published through it. Viewer- and streamer-facing products (Live, Chat, Community) keep their own moderation rules; this policy does not change them.
++This policy explains how OpenVibe moderates its developer spaces: the OpenVibers repositories, OpenVibe.Codes, and the apps and releases published through OpenVibe.Services. Viewer- and streamer-facing products (Live, Chat, Community) keep their own moderation rules; this policy does not change them.
## What can be moderated
- **Conduct** in issues, pull requests, discussions and reviews, under the [code of conduct](https://openvibe.codes/policy/code-of-conduct).
--- **Releases** published in Codes: a release that is malicious, misleading about what it does, infringes someone's rights, or breaks the platform's rules.
++- **Releases** published on OpenVibe.Services: a release that is malicious, misleading about what it does, infringes someone's rights, or breaks the platform's rules.
- **Apps and projects**: an app that abuses its grants, tries to get around them, or harms the people who use it.
- **Trust tiers** (unreviewed, reviewed, first-party): these are information only. A tier never grants or blocks anything; changing one is a note to users, not a sanction.
@@@ -35,7 +35,7 @@ From mildest to most serious. Moderators pick the mildest step that fixes the pr
- Revocation and deprecation reasons for releases are public on the release page, and changes to trust tiers are listed in the app's public trust history.
- Network keeps an append-only audit log of changes to projects, apps, credentials and grants; project admins can read it for their own project, and can download it with the project export.
--- A release revoked by Codes staff who could not manage it as project members, and every trust tier change, are also reported as `codes.moderation.action` to the network's moderation audit log, which OpenVibe staff can read.
++- A release revoked by Services staff who could not manage it as project members, and every trust tier change, are also reported as `services.moderation.action` to the network's moderation audit log, which OpenVibe staff can read.
- Reports and the identity of the people who report stay confidential.
## Appeals
ddiff --git a/migrations/0002_console_moved.sql b/migrations/0002_console_moved.sql
nnew file mode 100644
iindex 0000000..15e8ae5
---- /dev/null
++++ b/migrations/0002_console_moved.sql
@@@ -0,0 +1,14 @@
++-- phase: contract
++-- after: 0001
++-- The developer console moved to OpenVibe.Services on 2026-10-08 (openvibe-contracts 0.113.0). Its rows — 16 manifests,
++-- 16 releases and 64 release-log entries, all from the daily developer-path probe, every release revoked — were copied
++-- into ov_services column for column before this release, and the sent outbox rows (48, codes.app.* events Events
++-- already has) are history Events keeps. Codes reads none of these tables any more, so they go; the harness's own tables
++-- arrive with its runs.
++DROP TABLE IF EXISTS release_log;
++DROP TABLE IF EXISTS releases;
++DROP TABLE IF EXISTS manifests;
++DROP TABLE IF EXISTS trust_history;
++DROP TABLE IF EXISTS trust;
++DROP TABLE IF EXISTS playground_runs;
++DROP TABLE IF EXISTS event_outbox;
ddiff --git a/package-lock.json b/package-lock.json
iindex 5fcf811..69ff957 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -9,16 +9,15 @@
"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",
"iovalkey": "^0.4.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",
++ "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.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.3",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -123,17 +122,6 @@
"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",
@@@ -883,9 +871,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",
@@@ -899,9 +887,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.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
++ "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",
"license": "MIT",
"engines": {
"node": ">=20"
@@@ -928,9 +916,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"
@@@ -1354,14 +1342,6 @@
"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 179e942..9511560 100644
---- a/package.json
++++ b/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-codes",
"version": "0.1.0",
"private": true,
-- "description": "OpenVibe.Codes: the developer portal. Projects, apps, credentials and grants over OpenVibe.Network's API (Network owns them), docs generated from the pinned contracts and SDK, OAuth callback helper, webhook tester, grant-respecting playgrounds, manifest validation and release metadata. Server-rendered and useful without JavaScript.",
++ "description": "OpenVibe.Codes: the open coding-agent harness. The agent catalog (Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API) with a public routing API, and the way to improve OpenVibe itself. Server-rendered and useful without JavaScript.",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
@@@ -21,16 +21,15 @@
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Codes",
"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",
"iovalkey": "^0.4.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",
++ "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.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.3",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/js/webhook.js b/public/js/webhook.js
ddeleted file mode 100644
iindex 8e9fa09..0000000
---- a/public/js/webhook.js
++++ /dev/null
@@@ -1,92 +0,0 @@
--// OpenVibe.Codes — 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 Codes 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 b583481..84ec8ab 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -1,18 +1,17 @@
'use strict';
/**
-- * OpenVibe.Codes — Express app factory. server/index.js listens and starts the outbox relay; tests
-- * build their own instance with a temp database, an injectable clock and mock neighbours.
++ * OpenVibe.Codes — the open coding-agent harness. Express app factory; server/index.js listens, tests build their own
++ * instance with a temp database and mock neighbours.
*
-- * / 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 portal over Network's projects API (http/portal.js)
-- * /releases/:id/* release actions (http/portal.js)
-- * /api/v1 JSON API (http/api.js)
-- * /api/v1/resources the authority resource index, codes.resource.read (registry/resource-index.js)
++ * /, /harnesses, /improve, /policy/*, /updates the public pages (http/pages.js)
++ * /api/v1/harnesses, /api/v1/harnesses/route the agent catalog and its router (http/api.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
++ *
++ * The developer console moved to OpenVibe.Services on 2026-10-08: http/moved.js answers its addresses (/projects,
++ * /docs, /oauth, /tools, /manifests, /releases, /apps, /staff, the platform policy pages and the rest of /api/v1)
++ * with permanent redirects there, before anything else runs.
*/
const path = require('path');
const express = require('express');
@@@ -26,24 +25,12 @@ const configLib = require('./config');
const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
--const { createNetworkClient } = require('./clients/network');
--const { createCodesOutbox } = require('./events/outbox');
--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 { createHarnesses } = require('./domain/harnesses');
--const { createDocsRoutes } = require('./http/docs');
--const { createToolRoutes } = require('./http/tools');
const { createPageRoutes } = require('./http/pages');
--const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
--const { createApi, createCapabilityAccess } = require('./http/api');
--const resourceIndex = require('./registry/resource-index');
++const { createApi } = require('./http/api');
++const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = 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');
@@@ -52,7 +39,7 @@ const VERSION = require('../package.json').version;
/**
* opts: config, store, now (clock), fetchImpl, log, limitsNow (the per-actor limiter's clock,
-- * tests), actorLimits (false: count nobody, tests only)
++ * tests), actorLimits (false: count nobody, tests only), valkey
*/
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
@@@ -61,26 +48,11 @@ async function createApp(opts = {}) {
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
const store = opts.store || await openStore(config, { now: opts.now, log });
-- const docs = generate({ now: store.now });
const 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 = createCodesOutbox({ 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 harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.
const harnesses = createHarnesses();
-- const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, harnesses, indexnow, log };
++ const ctx = { config, store, keys, sso, harnesses, log };
const app = express();
app.disable('x-powered-by');
@@@ -90,8 +62,8 @@ async function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'codes', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.ctx = ctx;
-- // Per-actor limits (http/actor-limits.js) at /api/v1, the portal, release actions and the tools'
-- // forms, counted once the caller is known; the per-address limits below stay.
++ // Per-actor limits (http/actor-limits.js) at /api/v1, 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;
@@@ -129,9 +101,12 @@ async function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
-- const readiness = createCodesReadiness({ store, network, outbox, docs, config, release: release.release, valkey: ctx.valkey });
++ const readiness = createCodesReadiness({ store, harnesses, config, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
++ // ── The developer console's addresses: permanent redirects to OpenVibe.Services ──
++ app.use(createMovedRoutes());
++
// ── Who is asking (verified offline; refreshed when expired) ──
app.use(sso.middleware());
@@@ -140,9 +115,6 @@ async function createApp(opts = {}) {
app.use('/auth', sso.routes());
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'codes', service: 'codes', host: 'openvibe.codes', name: 'OpenVibe.Codes', 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());
@@@ -156,20 +128,13 @@ async function createApp(opts = {}) {
}));
// ── API ─────────────────────────────────────────────────
-- // The authority resource index (ADR-048, codes.resource.read): mounted before /api/v1 so the path is
-- // the index's own, guarded with the same service-token check the JSON API's first-party routes use.
-- app.use('/api/v1/resources', resourceIndex.router({ guard: createCapabilityAccess({ config, keys }).checked(resourceIndex.RESOURCE_READ) }));
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.CODES_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>.</p>` }));
++ 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 <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
ddiff --git a/server/auth/forms.js b/server/auth/forms.js
ddeleted file mode 100644
iindex 31d220e..0000000
---- a/server/auth/forms.js
++++ /dev/null
@@@ -1,31 +0,0 @@
--'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 CODES_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(`codes-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
iindex 10ea764..b0b3852 100644
---- a/server/auth/keys.js
++++ b/server/auth/keys.js
@@@ -3,19 +3,18 @@
/**
* 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.
++ * against it: the person's session token (sign-in).
*
* 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 Codes (the contracts-based requireCapability guard, the readiness
-- * check, the SSO middleware) can keep talking to `keys.get()` / `keys.ensure()` / `keys.loaded()`,
++ * is a thin shim so the rest of Codes (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.
++ * signature) are kept. Every rejection is logged.
*/
const sdk = require('openvibe-sdk/auth');
@@@ -40,9 +39,9 @@ function jwksUrl(config) {
}
/**
-- * `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()).
++ * `keys` is what app.js hands the rest of the service: a facade over the SDK's jwksClient. A synchronous caller
++ * wants a 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);
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 67d3a70..d59023d 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -1,5 +1,4 @@
'use strict';
--const { staff: staffMap } = require('openvibe-contracts');
/**
* Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client
@@@ -110,8 +109,6 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
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 CODES_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,
};
ddiff --git a/server/clients/network.js b/server/clients/network.js
ddeleted file mode 100644
iindex 3ef37e5..0000000
---- a/server/clients/network.js
++++ /dev/null
@@@ -1,126 +0,0 @@
--'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); Codes
-- * 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: 'codes.internal', detail: 'unexpected error', requestId: null, unreachable: false };
--}
--
--function createNetworkClient({ config, fetchImpl, log = console }) {
-- const base = config.networkInternalUrl;
-- const client = createClient({
-- network: base, autoDiscover: false, baseUrls: { network: base }, retries: 1, timeoutMs: 8000, deadlineMs: 15000,
-- ...(fetchImpl ? { fetch: fetchImpl } : {}),
-- onWarning: (m) => log.warn('[Codes] 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 096d08f..f40f1db 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -8,15 +8,8 @@
* load(env) is pure so tests can build a config without touching process.env.
*/
require('dotenv').config();
--const contracts = require('openvibe-contracts');
--
const trim = (s) => String(s || '').replace(/\/+$/, '');
const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
--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;
--};
function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
@@@ -46,9 +39,7 @@ function load(env = process.env) {
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.CODES_PGLITE_DIR || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:codes:' },
-- // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS, the developer
-- // projects API (/api/v1/projects, called server-side with the signed-in person's token),
-- // the registry, and client-credentials tokens (Codes' own and, in playgrounds, the app's).
++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
@@@ -62,46 +53,7 @@ function load(env = process.env) {
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.CODES_FORM_SECRET || '',
--
-- // Staff (trust tiers): staff.site.configure (Network admins), plus these subjects (usr_…).
-- staffSubjects: list(env.CODES_STAFF_SUBJECTS),
--
-- // Where playgrounds call, with the APP's own token (never Codes' credentials). Defaults are
-- // the public origins published in openvibe-contracts' service manifests.
-- playground: {
-- eventsUrl: trim(env.CODES_PLAYGROUND_EVENTS_URL || originOf('events', 'https://openvibe.events')),
-- mediaUrl: trim(env.CODES_PLAYGROUND_MEDIA_URL || originOf('media', 'https://openvibe.media')),
-- maxUploadBytes: int(env.CODES_PLAYGROUND_MAX_UPLOAD_BYTES, 1024 * 1024),
-- runsPerHour: int(env.CODES_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.CODES_EXPORT_MEDIA_URL || env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
-- eventsUrl: trim(env.CODES_EXPORT_EVENTS_URL || env.OV_EVENTS_INTERNAL_URL || 'http://127.0.0.1:4300'),
-- maxObjects: Math.max(1, int(env.CODES_EXPORT_MAX_OBJECTS, 5000)),
-- maxEvents: Math.max(1, int(env.CODES_EXPORT_MAX_EVENTS, 10000)),
-- urlTtlS: Math.min(3600, Math.max(60, int(env.CODES_EXPORT_URL_TTL_S, 3600))),
-- },
--
-- // Registry answers are cached this long (Network's own health poll runs every 60 s).
-- registryTtlMs: int(env.CODES_REGISTRY_TTL_MS, 30 * 1000),
--
-- // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.
-- events: {
-- url: trim(env.EVENTS_URL || ''),
-- intervalMs: 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 0c2ad06..dfee53e 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -3,19 +3,16 @@
/**
* OpenVibe.Codes' own PostgreSQL database (ADR-035, roadmap WS-X2): the schema is migrations/NNNN_*.sql, applied at boot.
*
-- * Codes owns ONLY what is Codes' by ADR-014: 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 table).
++ * Codes holds no table today: the developer console's records (manifests, releases, trust tiers, playground logs)
++ * moved to OpenVibe.Services on 2026-10-08 and migration 0002 dropped them here. The harness's runs, sessions and
++ * hand-offs will live here when they arrive; nothing written here will ever contain a secret, token or credential.
*/
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 TABLES = [];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
ddiff --git a/server/docs/dts.js b/server/docs/dts.js
ddeleted file mode 100644
iindex 06f0efd..0000000
---- a/server/docs/dts.js
++++ /dev/null
@@@ -1,83 +0,0 @@
--'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
ddeleted file mode 100644
iindex 44c00b7..0000000
---- a/server/docs/generate.js
++++ /dev/null
@@@ -1,189 +0,0 @@
--'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
ddeleted file mode 100644
iindex 543a6de..0000000
---- a/server/domain/limits.js
++++ /dev/null
@@@ -1,57 +0,0 @@
--'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 Codes 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: 'codes.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
ddeleted file mode 100644
iindex 61735f8..0000000
---- a/server/domain/manifests.js
++++ /dev/null
@@@ -1,134 +0,0 @@
--'use strict';
--
--/**
-- * Manifest validation with openvibe-contracts.
-- *
-- * mod mods.mod-manifest@1
-- * app codes.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: 'codes.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
ddeleted file mode 100644
iindex 9b1e1d6..0000000
---- a/server/domain/playground.js
++++ /dev/null
@@@ -1,217 +0,0 @@
--'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
-- *
-- * Codes' 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; Codes 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.networkIssuer, 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('[Codes] 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
ddeleted file mode 100644
iindex 8ebae5d..0000000
---- a/server/domain/project-archive.js
++++ /dev/null
@@@ -1,289 +0,0 @@
--'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; Codes'
-- * releases, trust tiers and playground runs. This is the configuration.
-- * modules/<release_id>.json each release's manifest (codes.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 Codes 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.codes.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: 'codes.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.networkInternalUrl, autoDiscover: false, retries: 1, timeoutMs: 15000, deadlineMs: 30000,
-- ...(fetchImpl ? { fetch: fetchImpl } : {}),
-- onWarning: (m) => log.warn('[Codes] 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 (CODES_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 (CODES_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.codes && metadata.codes.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' : 'codes.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.Codes' 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
ddeleted file mode 100644
iindex a6d1238..0000000
---- a/server/domain/project-export.js
++++ /dev/null
@@@ -1,67 +0,0 @@
--'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: Codes adds nothing and copies nothing into its own database. Codes adds only what is
-- * Codes': 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
-- * Codes 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.codes.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'] },
-- codes: { 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' },
-- codes: {
-- 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
ddeleted file mode 100644
iindex 4c43ac2..0000000
---- a/server/domain/releases.js
++++ /dev/null
@@@ -1,250 +0,0 @@
--'use strict';
--
--/**
-- * Release metadata — Codes-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; Codes staff may revoke any release. An app acting with its own token (API,
-- * codes.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('codes.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('codes.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('codes.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) }))));
--
-- /**
-- * Codes' 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 codes.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
ddeleted file mode 100644
iindex 5e8433f..0000000
---- a/server/domain/trust.js
++++ /dev/null
@@@ -1,54 +0,0 @@
--'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
-- * Codes (or anywhere) reads a tier to allow an action; the tier is shown next to releases and
-- * carried in codes.app.* event payloads. Every app starts unreviewed; Codes staff change it, with
-- * a note, and every change is kept and reported to Network's moderation audit log
-- * (codes.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 Codes 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
ddeleted file mode 100644
iindex 42f53f3..0000000
---- a/server/domain/webhooks.js
++++ /dev/null
@@@ -1,151 +0,0 @@
--'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 (Codes 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
ddeleted file mode 100644
iindex 96cd8c8..0000000
---- a/server/domain/zip.js
++++ /dev/null
@@@ -1,95 +0,0 @@
--'use strict';
--
--/**
-- * A minimal zip writer (PKZIP 2.0: deflate, UTF-8 names, no zip64) for the project archive. Every
-- * archive Codes 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
ddeleted file mode 100644
iindex 2087829..0000000
---- a/server/events/outbox.js
++++ /dev/null
@@@ -1,40 +0,0 @@
--'use strict';
--
--/**
-- * Codes → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
-- *
-- * codes.app.published a release became public subject { type: 'app', id: app_… }
-- * codes.app.deprecated a public release was deprecated (payload: release id, version, kind,
-- * codes.app.revoked a public release was revoked environment, trust tier, reason…)
-- * codes.moderation.action Codes 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 Codes' OWN service token (events.event.publish,
-- * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise rows
-- * wait in event_outbox and /api/ready reports the relay as off. Payloads never carry a secret.
-- */
--const { createServiceOutbox } = require('openvibe-sdk/events');
--
--const EVENT_TYPES = ['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked', 'codes.moderation.action'];
--
--/** Codes' wiring of the SDK's shared outbox (plan T1). */
--function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
-- return createServiceOutbox({
-- db,
-- source: 'codes',
-- eventsUrl: config.events.url,
-- networkInternalUrl: config.networkInternalUrl,
-- clientId: config.oauth.clientId,
-- clientSecret: config.oauth.clientSecret,
-- intervalMs: config.events.intervalMs,
-- log,
-- eventTypes: EVENT_TYPES,
-- autoDiscover: false,
-- ...(fetchImpl ? { fetch: fetchImpl } : {}),
-- ...(now ? { now } : {}),
-- });
--}
--
--module.exports = { createCodesOutbox, EVENT_TYPES };
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex b3f576c..2cd3c46 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -1,26 +1,23 @@
'use strict';
/**
-- * Per-actor rate limits at Codes' 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.
++ * Per-actor rate limits at Codes' boundaries (roadmap WS-R task 4; openvibe-sdk/limits): /api/v1 (the coding-agent
++ * catalog and its router).
*
-- * 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
++ * The per-address limits in app.js and the routers (pages 300 a minute, the router 60, sign-in) 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>
*
-- * Codes 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
-- * codes_rate_limited_total{limit,window}. Reads take CODES_LIMITS_MINUTE / CODES_LIMITS_HOUR (120 and
-- * 3000); every write has its own number below. Counters live in this process: a restart forgets them.
++ * Codes 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 body is read); the refusal is
++ * logged once and counted in codes_rate_limited_total{limit,window}. Reads take CODES_LIMITS_MINUTE /
++ * CODES_LIMITS_HOUR (120 and 3000); the router has its own number below. Counters live in this process unless
++ * VALKEY_URL is set.
*
-- * Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the public pages and
-- * docs (the per-address limit bounds them).
++ * Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the public pages (the per-address
++ * limit bounds them).
*/
const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');
@@@ -34,33 +31,8 @@ function actor(req) {
/** 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.
-- 'codes.project.create': { minute: 5, hour: 30 },
-- // Members, roles, archive and delete: an owner changes a few at a time.
-- 'codes.project.manage': { minute: 20, hour: 200 },
-- // The full archive reads every object and event a project holds (one at a time per project).
-- 'codes.project.export': { minute: 3, hour: 20 },
-- // An app is a Network client with credentials: a developer registers a few.
-- 'codes.app.create': { minute: 10, hour: 60 },
-- // Redirect URIs, grants and revoking an app: a developer saves a form now and then.
-- 'codes.app.manage': { minute: 30, hour: 300 },
-- // Rotating or revoking a credential mints or kills a secret: a few at a time.
-- 'codes.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 (CODES_PLAYGROUND_RUNS_PER_HOUR, 60), which keeps deciding; refusals count.
-- 'codes.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.
-- 'codes.release.create': { minute: 20, hour: 200 },
-- // Publishing, deprecating and revoking a release announce it to the network.
-- 'codes.release.manage': { minute: 20, hour: 200 },
-- // Validating a manifest or checking a webhook signature: a developer's pace, not a crawler's.
-- 'codes.manifest.validate': { minute: 30, hour: 600 },
-- 'codes.webhook.tool': { minute: 30, hour: 600 },
// Routing a coding task over the harness catalog (no I/O, but a POST: the reads' defaults skip it).
'codes.harness.route': { minute: 30, hour: 600 },
-- // Staff setting an app's trust tier.
-- 'codes.trust.set': { minute: 30, hour: 300 },
};
/**
ddiff --git a/server/http/api.js b/server/http/api.js
iindex cf07151..b4ccd0e 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -1,99 +1,37 @@
'use strict';
/**
-- * JSON API (/api/v1). Errors are RFC 9457 problems (openvibe-contracts http.sendProblem).
++ * JSON API (/api/v1): the coding-agent catalog and its router. Errors are RFC 9457 problems (openvibe-contracts
++ * http.sendProblem). Both routes are public: the catalog is published data and routing runs no I/O.
*
-- * GET /docs/versions the pinned versions the docs were generated from
-- * POST /manifests/validate { kind: app|mod, manifest } → { valid, errors, warnings }
-- * GET /harnesses the harness catalog, each harness with its agents
-- * POST /harnesses/route { task, requirements? } → { selected, reasons, candidates }
++ * GET /harnesses the harness catalog, each harness with its agents (platform.harness-offer@1 rows)
++ * POST /harnesses/route { task, requirements? } → { selected, reasons, candidates } (openvibe-sdk/placement)
*
-- * codes.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
-- *
-- * codes.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.codes, 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.
++ * The developer platform's API (releases, manifest validation, the docs' versions, the resource index) moved to
++ * OpenVibe.Services on 2026-10-08; server/http/moved.js answers those paths with permanent redirects there.
*/
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 { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
const placement = require('../domain/harness-placement');
--const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
--const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
--const MANAGE = 'codes.release.manage';
--const READ = 'codes.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 codes.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('codes.resource.read') }));
-- */
--function createCapabilityAccess({ config, keys }) {
-- const guardOpts = { issuer: config.networkIssuer, audience: 'openvibe.codes', 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.codes>') => {
-- 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, harnesses } = ctx;
++ const { actorLimits, harnesses } = 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.
++ // Per-actor limits (http/actor-limits.js): reads take the defaults; the route names its budget before the body is read.
const reads = actorLimits.reads('codes.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 json = express.json({ limit: '32kb' });
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('codes.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 });
-- });
++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
r.get('/harnesses', reads, (req, res) => {
res.json({ harnesses: harnesses.list().map((h) => ({ ...h, agents: harnesses.agents(h.id) })) });
});
// Every harness × agent offer with the defaults (health up, no latency); placement runs no I/O.
-- r.post('/harnesses/route', limiter, B('codes.harness.route'), json, (req, res) => {
++ r.post('/harnesses/route', limiter, actorLimits.budget('codes.harness.route'), json, (req, res) => {
const b = req.body || {};
const plain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
if (typeof b.task !== 'string' || !b.task) return problem(req, res, 422, 'harness.invalid', 'task is a non-empty string');
@@@ -110,96 +48,7 @@ function createApi(ctx) {
}
});
-- // 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('codes.release.read');
-- // Public reads: anonymous callers pass; a presented token must be valid and hold codes.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 codes.release.manage.
-- const manageAccess = checked('codes.release.manage', 'send Authorization: Bearer <app token for audience openvibe.codes>');
--
-- 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('[Codes] 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('codes.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('codes.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('[Codes] 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 };
++module.exports = { createApi };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 1ca4f2f..a2a7d57 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -6,51 +6,31 @@
* 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.
++ * Public pages only, and never the viewer: sign-in and the API are absent from all of them and Disallowed in
++ * robots.txt. lastmod is the committed content-revision date in STATUS.json, never "today" from the clock, which
++ * would tell crawlers the whole site changed on every fetch. The developer console's pages moved to
++ * openvibe.services (http/moved.js answers them with 301s), so none of them is listed here.
*/
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');
const SITE_NAME = 'OpenVibe.Codes';
--const DESCRIPTION = 'The OpenVibe developer portal: create projects and apps in OpenVibe.Network, get scoped credentials and capability grants, and read reference documentation generated from the exact contracts and SDK versions the platform 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;
++const SERVICES_ORIGIN = 'https://openvibe.services';
++const DESCRIPTION = 'The open coding-agent harness: send a coding task to Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';
++// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.
++const DISALLOW = ['/auth/', '/api/'];
// 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.Codes home', 'The developer portal: create a project, get scoped credentials and request capability grants over OpenVibe.Network.'],
++ '/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent catalog, how a task is routed, what works today, and how to improve OpenVibe.'],
++ '/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek) with its models, capabilities, prices and limits.'],
++ '/improve': ['Improve OpenVibe', 'How OpenVibe is built in the open: every repository, and the path from a change to a reviewed pull request.'],
'/updates': ['What shipped on OpenVibe.Codes', '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/harnesses': ['Coding harnesses', 'The harnesses and agents a coding task can be routed to, with their prices and limits.'],
-- '/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 Codes runs is licensed under.'],
-- '/policy/transparency': ['Transparency', 'What Codes stores, what it does not, and what works today.'],
++ '/policy': ['Community', 'The code of conduct, the contributing guide, the contributor ladder and the moderation policy.'],
};
/** "2026-09-28" or "2026-09-28T12:00:00Z" as YYYY-MM-DD; null when the value is unusable. */
@@@ -65,212 +45,105 @@ function siteUpdated() {
}
/**
-- * The home page's JSON-LD: the site as a WebSite, the developer portal 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.
++ * The home page's JSON-LD: the site as a WebSite, the harness 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: 'developer portal, api, oauth, webhooks, sdk, openvibe' }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent router, claude code, codex, opencode, deepseek, aider, open source coding harness, contribute to open source' }),
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 and from the repository's Markdown, so their lastmod is the site's
-- * content-revision date; governance drafts stay out (they are noindex and not published).
-- */
--function publicPages({ docs, governance = [] }) {
++/** The site's public pages, with their crawl hints; a community document is listed once it is published. */
++function publicPages({ governance = [] }) {
const pages = [
-- { path: '/', changefreq: 'daily', priority: 1.0 },
++ { path: '/', changefreq: 'weekly', priority: 1.0 },
++ { path: '/harnesses', changefreq: 'weekly', priority: 0.9 },
++ { path: '/improve', changefreq: 'weekly', priority: 0.8 },
{ 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/harnesses', changefreq: 'weekly', priority: 0.6 },
-- { 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 },
++ { path: '/policy', changefreq: 'monthly', priority: 0.4 },
];
for (const g of governance) if (!g.draft) pages.push({ path: `/policy/${g.slug}`, changefreq: 'monthly', priority: 0.3 });
-- 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,
-- * governance pages from their own blurb, 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, governance = [], apiIndex = [] }) {
++/** The title and one-line text /llms-full.txt gives a public page. */
++function describePage(pathname, { governance = [] }) {
if (PAGE_TEXT[pathname]) return { title: PAGE_TEXT[pathname][0], text: PAGE_TEXT[pathname][1] };
-- let m;
-- if ((m = /^\/policy\/([^/]+)$/.exec(pathname))) {
-- const g = governance.find((x) => x.slug === m[1]);
-- if (g) return { title: g.title, text: g.blurb || '' };
-- }
-- 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.` };
-- }
++ const m = /^\/policy\/([^/]+)$/.exec(pathname);
++ const g = m && governance.find((x) => x.slug === m[1]);
++ if (g) return { title: g.title, text: g.blurb || '' };
return { title: pathname, text: '' };
}
function createDiscoveryRoutes(ctx) {
-- const { config, docs, releases, governance = [] } = ctx;
++ const { config, governance = [] } = 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, governance }).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;
-- }
++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
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.codes: the public pages are for search and AI crawlers; the portal, sign-in, staff and the API are not.\n'
++ res.type('text/plain').set('Cache-Control', TEXT).send(
++ '# openvibe.codes: the public pages are for search and AI crawlers; sign-in 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({
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
-- summary: 'OpenVibe.Codes: the OpenVibe developer portal over OpenVibe.Network\'s API — projects, apps, scoped credentials and capability grants, 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.',
++ summary: 'OpenVibe.Codes: the open coding-agent harness. A catalog of coding agents (Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API) with a public routing API, and the way to improve OpenVibe itself.',
++ details: `Every page is server-rendered and readable without JavaScript. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. Running tasks, hand-offs and a self-hosted runner come next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,
sections: [
{ title: 'Start here', links: [
-- { title: 'The developer portal', url: abs('/'), note: 'what the portal is for and how an account becomes an integration' },
-- { title: 'Reference documentation', url: abs('/docs'), note: 'generated from the pinned contracts and SDK' },
++ { title: 'Code with any agent', url: abs('/'), note: 'what the harness is, how a task finds its agent, what works today' },
++ { title: 'The coding agents', url: abs('/harnesses'), note: 'every harness with its models, capabilities, prices and limits' },
++ { title: 'Improve OpenVibe', url: abs('/improve'), note: 'every repository and the path to a reviewed pull request' },
{ title: 'What shipped on OpenVibe.Codes', 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: abs('/docs/harnesses'), note: 'the harness catalog and how tasks are routed' },
-- { title: 'SDK', url: abs('/docs/sdk') },
++ { title: 'Community', links: [
++ { title: 'Community home', url: abs('/policy') },
++ ...governance.filter((g) => !g.draft).map((g) => ({ title: g.title, url: abs(`/policy/${g.slug}`) })),
] },
-- { 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: 'Machine-readable', links: [
++ { title: 'The agent catalog (JSON)', url: abs('/api/v1/harnesses'), note: 'every harness with its agents (platform.harness-offer@1 rows)' },
++ { title: 'Route a task', url: abs('/api/v1/harnesses/route'), note: 'POST { task, requirements? } → { selected, reasons, candidates }' },
++ { title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages, each with a real lastmod' },
++ { title: 'Full text for language models', url: abs('/llms-full.txt'), note: 'every public page, one line each' },
++ { title: 'Release metadata (JSON)', url: abs('/release.json'), note: 'this service\'s current release, per ADR-016' },
] },
-- { 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') },
-- ...governance.filter((g) => !g.draft).map((g) => ({ title: g.title, url: abs(`/policy/${g.slug}`) })),
++ { title: 'Elsewhere', links: [
++ { title: 'OpenVibe.Services', url: SERVICES_ORIGIN, note: 'the developer platform: projects, keys, grants and the API docs of every service' },
] },
-- { 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.
++ // /llms-full.txt: the same public pages the sitemap lists, one title and one line of text each.
r.get('/llms-full.txt', (_req, res) => {
-- const pages = publicPages({ docs, governance });
-- 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, governance, apiIndex }) }));
-- res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsFull({
++ const pages = publicPages({ governance }).map((p) => ({ url: p.path, ...describePage(p.path, { governance }) }));
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
-- summary: 'Every fixed public page of OpenVibe.Codes, one line each: the home and update log, the reference generated from the pinned contracts and SDK, the developer tools, and the policy pages.',
++ summary: 'Every public page of OpenVibe.Codes, one line each: the harness, the agent catalog, Improve OpenVibe, the update log and the community documents.',
base: site,
-- maxBytes: 512 * 1024,
++ maxBytes: 128 * 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)) },
++ { title: 'Harness', pages: pages.filter((p) => ['/', '/harnesses', '/improve'].includes(p.url)) },
++ { title: 'Site', pages: pages.filter((p) => !['/', '/harnesses', '/improve'].includes(p.url)) },
],
}));
});
-- 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));
++ r.get('/sitemap.xml', (_req, res) => {
++ const lastmod = siteUpdated();
++ const urls = publicPages({ governance }).map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
return r;
}
--module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, dayOf };
++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, dayOf, SERVICES_ORIGIN, DESCRIPTION };
ddiff --git a/server/http/docs.js b/server/http/docs.js
ddeleted file mode 100644
iindex 2104ebe..0000000
---- a/server/http/docs.js
++++ /dev/null
@@@ -1,571 +0,0 @@
--'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/harnesses the coding harnesses and agents a task can be routed to, from the catalog
-- * /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 Codes 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="/docs/harnesses"><strong>Harnesses</strong></a><span>the coding harnesses and agents a task can be routed 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.codes/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.networkUrl}/api/v1/changelog?service=codes&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: 'codes.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',
-- ]))}`)}`,
-- });
-- });
--
-- // ── Coding harnesses (the catalog Codes routes coding tasks over, T16) ─────────
-- // The catalog is ctx.harnesses (the committed seed, validated at boot); nothing is fetched and
-- // no address target that is a local path is ever shown — only the address kind.
-- const isLocalPath = (t) => /^(?:[A-Za-z]:[\\/]|[~/]|\.\.?[\\/])/.test(String(t || ''));
-- const addressOf = (o) => {
-- const kind = (o.address && o.address.kind) || '—';
-- const target = o.address && o.address.target;
-- return target && !isLocalPath(target) ? html`${kind} <code>${target}</code>` : kind;
-- };
-- const usd = (n) => `$${Number(n || 0).toFixed(2)}`;
-- const duration = (n) => (n >= 3600 && n % 3600 === 0 ? `${n / 3600} h` : `${n} s`);
-- const flags = (c) => ['host_access', 'mcp', 'long_autonomy', 'resume'].filter((k) => c && c[k]).map((k) => k.replace(/_/g, ' ')).join(', ') || '—';
-- const count = (n) => Number(n).toLocaleString('en-US');
-- r.get('/harnesses', (req, res) => {
-- const offers = ctx.harnesses.list();
-- page(req, res, {
-- title: 'Coding harnesses',
-- description: 'The coding harnesses and agents Codes can route a task to, with their capabilities, prices and limits.',
-- crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Harnesses' }],
-- body: html`<h1>Coding harnesses</h1>
--<p>A task is routed with <code>POST /api/v1/harnesses/route</code> over <code>openvibe-sdk/placement</code>, which picks an offer from this catalog; see the <a href="/docs/api">API explorer</a>. The catalog below is read and validated when Codes starts, and every price and limit is as the offer states it.</p>
--${table(['Harness', 'Provider', 'Address', 'Capabilities', 'Price', 'Limits'], offers.map((o) => [
-- html`<strong>${o.name}</strong><br><code>${o.id}</code>`,
-- o.provider,
-- addressOf(o),
-- flags(o.capabilities),
-- `${usd(o.price.amount_usd)} / ${o.price.unit}`,
-- `${duration(o.limits.max_duration_seconds)}, ${count(o.limits.max_concurrent_runs)} concurrent, ${count(o.limits.max_context_tokens)} context tokens`,
-- ]))}
--<h2>Agents</h2>
--${offers.map((o) => html`<h3>${o.name} <code>${o.id}</code></h3>${o.agents.length
-- ? table(['Provider', 'Model', 'Context (in / out)', 'Price per 1k tokens'], o.agents.map((a) => [
-- a.provider,
-- a.model,
-- `${count(a.context_limits.input_tokens)} / ${count(a.context_limits.output_tokens)}`,
-- `${usd(a.price_per_1k_tokens.fresh_usd)} fresh, ${usd(a.price_per_1k_tokens.cached_usd)} cached, ${usd(a.price_per_1k_tokens.output_usd)} output`,
-- ]))
-- : html`<p class="muted">no agents yet</p>`}`)}`,
-- });
-- });
--
-- // ── 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: 'codes.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. Codes 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.codes.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 Codes 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. Codes 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. Codes then reads the project's data with it. Codes keeps nothing of the archive.</p>
--${table(['File', 'What it holds'], [
-- [code('manifest.json'), 'format (openvibe.codes.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 (codes.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/moved.js b/server/http/moved.js
nnew file mode 100644
iindex 0000000..1ea4218
---- /dev/null
++++ b/server/http/moved.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++/**
++ * The developer console moved to OpenVibe.Services on 2026-10-08 (owner decision; openvibe-contracts 0.113.0). Every
++ * address it had here answers with a permanent redirect to the same path and query there, so bookmarks, links,
++ * search results and API clients keep working:
++ *
++ * GET and HEAD 301 Moved Permanently (search engines move the page's ranking with it)
++ * any other method 308 Permanent Redirect (the method and body are kept: a POST stays a POST)
++ *
++ * What stays here: the home page, the agents (/harnesses, /api/v1/harnesses), Improve OpenVibe, the community
++ * documents (/policy and /policy/<document>), the update log, sign-in and the legal pages. /docs/harnesses, the
++ * catalog's old address, now lives at /harnesses on this site.
++ */
++const SERVICES_ORIGIN = 'https://openvibe.services';
++
++// The console's paths (and everything under them), and the platform policy pages that moved with it.
++const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
++const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
++// The console's API: everything under /api/v1 but the agent catalog.
++const API_STAYS = /^\/api\/v1\/harnesses(\/|$)/;
++
++const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
++
++/** Where a request moved to, or null when this site still answers it. */
++function movedTo(req) {
++ const p = req.path;
++ if (p === '/docs/harnesses' || p === '/docs/harnesses/') return '/harnesses';
++ const q = req.originalUrl.slice(req.path.length); // the query string (and nothing else) as it was sent
++ if (MOVED_EXACT.includes(p) || MOVED_PREFIXES.some((x) => under(p, x))) return `${SERVICES_ORIGIN}${p}${q}`;
++ if (under(p, '/api/v1') && !API_STAYS.test(p)) return `${SERVICES_ORIGIN}${p}${q}`;
++ return null;
++}
++
++function createMovedRoutes() {
++ return (req, res, next) => {
++ const to = movedTo(req);
++ if (!to) return next();
++ res.set('Cache-Control', 'public, max-age=86400');
++ return res.redirect(req.method === 'GET' || req.method === 'HEAD' ? 301 : 308, to);
++ };
++}
++
++module.exports = { createMovedRoutes, movedTo, SERVICES_ORIGIN, MOVED_PREFIXES, MOVED_EXACT };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f74416a..a6e6030 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -1,39 +1,36 @@
'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.
++ * Public pages: the home page (the open coding-agent harness), the agent catalog, Improve OpenVibe (how OpenVibe
++ * itself is built and how to help), the community documents, and the update log. 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 Codes runs; the governance pages
-- * (code of conduct, contributing, contributor ladder, moderation) render this repository's
-- * Markdown files as they are.
++ * What is shown is read, never restated: the catalog is server/data/harness-offers.json as validated at boot, the
++ * repositories are the pinned openvibe-contracts service manifests, and the community documents render this
++ * repository's Markdown files as they are. The developer console that used to live here (projects, apps, docs,
++ * tools, releases) is OpenVibe.Services' since 2026-10-08; http/moved.js redirects its addresses there.
*/
const fs = require('fs');
++const path = require('path');
++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 } = require('./discovery');
--const { html, raw, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
++const { createDiscoveryRoutes, homeJsonLd, SERVICES_ORIGIN } = require('./discovery');
++const { html, raw, table, notice } = 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 placement = require('../domain/harness-placement');
const ROOT = path.join(__dirname, '..', '..');
--const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
++const GITHUB = 'https://github.com/OpenVibers';
/**
-- * Governance documents: Markdown in this repository (the same files GitHub shows), rendered as
-- * they are. A document whose text starts with the draft line is shown with a draft notice, kept out
-- * of search engines and the sitemap; removing that line after the owner's review publishes it.
++ * Community documents: Markdown in this repository (the same files GitHub shows), rendered as they are. A document
++ * whose text starts with the draft line is shown with a draft notice, kept out of search engines and the sitemap;
++ * removing that line after the owner's review publishes it.
*/
const GOVERNANCE = [
{ slug: 'code-of-conduct', file: 'CODE_OF_CONDUCT.md', blurb: 'how we treat each other, and how to report a problem' },
@@@ -52,276 +49,174 @@ function loadGovernance() {
});
}
--/** 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');
++/**
++ * The repositories OpenVibe is built from, as the pinned contracts' service manifests name them: the services that
++ * run (by their site position), then the libraries every service shares. A manifest without a repository is skipped.
++ */
++function repositories() {
++ const rows = contracts.services.manifests
++ .filter((m) => m && typeof m.repository === 'string' && /^OpenVibers\/[A-Za-z0-9._-]+$/.test(m.repository))
++ .filter((m) => m.exposure && ['live', 'library'].includes(m.exposure.state));
++ const order = (m) => (m.site && m.site.position != null ? m.site.position : 50);
++ const running = rows.filter((m) => m.exposure.state === 'live').sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name));
++ const libraries = rows.filter((m) => m.exposure.state === 'library').sort((a, b) => a.name.localeCompare(b.name));
++ return { running, libraries };
}
++const FLAG_LABELS = { host_access: 'runs on your machine', mcp: 'MCP tools', long_autonomy: 'long autonomous runs', resume: 'resumes a session', review: 'reviews', vision: 'reads images' };
++const flagsOf = (c) => Object.keys(FLAG_LABELS).filter((k) => c && c[k]).map((k) => FLAG_LABELS[k]);
++
function createPageRoutes(ctx) {
-- const { config, docs, releases, trust, network, sso } = ctx;
++ const { config, harnesses } = ctx;
const r = asyncRouter();
-- const form = express.urlencoded({ extended: false, limit: '32kb' });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const governance = loadGovernance();
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();`;
++ // ── Home: code with any agent ───────────────────────────
++ r.get('/', (req, res) => {
++ const offers = harnesses.list();
++ const tasks = Object.keys(placement.TASKS);
++ const snippet = `# Which agent should take this task?
++curl -s ${config.baseUrl}/api/v1/harnesses/route \\
++ -H 'content-type: application/json' \\
++ -d '{"task":"edit"}'
++
++# → { "selected": "claude-code:…",
++# "reasons": […], "candidates": […] }`;
page(req, res, {
index: true, cache: PUBLIC_CACHE,
jsonLd: homeJsonLd(config),
styles: [showcase.STYLESHEET],
body: html`${raw(showcase.hero({
-- eyebrow: 'OpenVibe.Codes · the developer portal · alpha',
-- title: 'Build on', accent: 'OpenVibe',
-- lede: 'Create a project, get scoped credentials, request capability grants, and read reference docs generated from the exact contract and SDK versions the platform runs.',
-- actions: [{ label: 'Create a project', href: '/projects', primary: true }, { label: 'Read the docs', href: '/docs' }],
-- note: 'Network owns projects, apps, credentials, grants and quotas; Codes is a portal over its API and never stores a secret.',
++ eyebrow: 'OpenVibe.Codes · the open coding-agent harness · alpha',
++ title: 'Code with', accent: 'any agent',
++ lede: 'One open harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself: send a coding task to the agent that fits it, hand it over when one gets stuck, and keep the whole run in one place. Then point it at OpenVibe itself.',
++ actions: [{ label: 'Meet the agents', href: '/harnesses', primary: true }, { label: 'Improve OpenVibe', href: '/improve' }],
++ note: 'Open source (AGPL-3.0). Today: the agent catalog and a routing API anyone can call. Running jobs, hand-offs and a self-hosted runner come next; the list below says exactly what works.',
aside: { html: `<pre class="codes-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; rotate or revoke it any time.', 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: 'Generated reference', text: `Contracts, capabilities, events and APIs from openvibe-contracts v${docs.contractsVersion}.`, href: '/docs' },
-- { icon: 'ov:code', title: 'Playgrounds', text: 'Try calls with your app\'s own credentials: it cannot do anything the app is not granted.', href: '/oauth' },
-- { icon: 'ov:json', title: 'The SDK', text: `openvibe-sdk v${docs.sdkVersion}: one package for browser and server, typed.`, href: '/docs/sdk' },
-- ],
++ id: 'agents', title: 'The agents', lede: `${offers.length} harnesses in the catalog, each with the models it runs. Routing picks one by what the task needs first, then by cost.`,
++ items: offers.map((o) => ({ title: o.name, text: [o.provider, ...flagsOf(o.capabilities).slice(0, 3)].join(' · '), href: `/harnesses#${o.id}` })),
}))}
${raw(showcase.steps({
-- title: 'From account to integration',
++ title: 'How a task finds its agent',
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' },
++ { title: 'Describe the task', text: `One of ${tasks.join(', ')}, plus anything it must have: a capability, a runtime, a ceiling on cost.` },
++ { title: 'Hard limits first', text: 'An agent that lacks a required capability is out, whatever it costs. Each refusal says why.' },
++ { title: 'Then the cheapest fit', text: 'Already-paid capacity and free allowances first, then price per token, through the same placement engine every OpenVibe service uses.' },
++ { title: 'Hand-offs', text: 'When the first agent stalls, the run moves to the next candidate with its context (next: the runner).' },
],
}))}
--${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.', actions: [{ label: 'Create a project', href: '/projects', primary: true }] }))}`,
++<section class="sc-sec" aria-labelledby="h-improve"><h2 id="h-improve">Improve OpenVibe</h2>
++<p class="sc-lede">OpenVibe is built in the open, ${repositories().running.length} services and their shared libraries, every one on GitHub. Pick a repository, describe the change, and send a pull request; the harness's job is to make that one step.</p>
++<p><a class="sc-btn sc-primary" href="/improve">Start contributing</a> <a class="sc-btn" href="/policy/contributing">Read the guide</a></p></section>
++<section class="sc-sec" aria-labelledby="h-status"><h2 id="h-status">What works today</h2>
++${table(['Piece', 'State'], [
++ [html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
++ [html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
++ ['Running a task on an agent, streaming its output, budgets', 'Next. The agents run today on OpenVibe\'s own machines; opening that to everyone is the next step.'],
++ ['Hand-offs between agents, resumable sessions', 'Next, with the runner.'],
++ ['A runner on your own machine (your CLIs, your keys)', 'Planned.'],
++ [html`Improve OpenVibe from here: repository → change → tests → review → pull request`, 'Planned. Today: the contributor path on GitHub, below.'],
++ ])}</section>
++${raw(showcase.cta({ title: 'Building an app instead?', text: 'Projects, keys, grants and the API docs of every OpenVibe service live on OpenVibe.Services.', actions: [{ label: 'Open the developer platform', href: SERVICES_ORIGIN, primary: true }, { label: 'Meet the agents', href: '/harnesses' }] }))}`,
++ });
++ });
++
++ // ── The agent catalog (server/data/harness-offers.json, validated at boot) ──
++ // No address target that is a local path is ever shown — only the address kind.
++ const isLocalPath = (t) => /^(?:[A-Za-z]:[\\/]|[~/]|\.\.?[\\/])/.test(String(t || ''));
++ const addressOf = (o) => {
++ const kind = (o.address && o.address.kind) || '—';
++ const target = o.address && o.address.target;
++ return target && !isLocalPath(target) ? html`${kind} <code>${target}</code>` : kind;
++ };
++ const usd = (n) => `$${Number(n || 0).toFixed(2)}`;
++ const duration = (n) => (n >= 3600 && n % 3600 === 0 ? `${n / 3600} h` : `${n} s`);
++ const count = (n) => Number(n).toLocaleString('en-US');
++ r.get('/harnesses', (req, res) => {
++ const offers = harnesses.list();
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'The coding agents',
++ description: 'Claude Code, Codex, OpenCode, Command Code, Aider and the DeepSeek API: every coding harness OpenVibe.Codes routes a task to, with its models, capabilities, prices and limits.',
++ crumbs: [{ label: 'Agents' }],
++ body: html`<h1>The coding agents</h1>
++<p>A task is routed with <code>POST /api/v1/harnesses/route</code> over <code>openvibe-sdk/placement</code>, which picks an offer from this catalog: hard requirements first (a capability the task needs), then cost. The catalog is read and validated when Codes starts; every price and limit is as the offer states it. Machine-readable: <a href="/api/v1/harnesses"><code>GET /api/v1/harnesses</code></a>.</p>
++${table(['Harness', 'Provider', 'Address', 'Can', 'Price', 'Limits'], offers.map((o) => [
++ html`<strong id="${o.id}">${o.name}</strong><br><code>${o.id}</code>`,
++ o.provider,
++ addressOf(o),
++ flagsOf(o.capabilities).join(', ') || '—',
++ `${usd(o.price.amount_usd)} / ${o.price.unit}`,
++ `${duration(o.limits.max_duration_seconds)}, ${count(o.limits.max_concurrent_runs)} concurrent, ${count(o.limits.max_context_tokens)} context tokens`,
++ ]))}
++<h2>Models</h2>
++${offers.map((o) => html`<h3>${o.name} <code>${o.id}</code></h3>${o.agents.length
++ ? table(['Provider', 'Model', 'Context (in / out)', 'Price per 1k tokens'], o.agents.map((a) => [
++ a.provider,
++ a.model,
++ `${count(a.context_limits.input_tokens)} / ${count(a.context_limits.output_tokens)}`,
++ `${usd(a.price_per_1k_tokens.fresh_usd)} fresh, ${usd(a.price_per_1k_tokens.cached_usd)} cached, ${usd(a.price_per_1k_tokens.output_usd)} output`,
++ ]))
++ : html`<p class="muted">no models yet</p>`}`)}`,
});
});
-- // ── Policy ──────────────────────────────────────────────
-- const adr = (id) => docs.adrs.find((a) => a.id === id);
++ // ── Improve OpenVibe ─────────────────────────────────────
++ r.get('/improve', (req, res) => {
++ const { running, libraries } = repositories();
++ const repoCards = (list) => html`<ul class="cards">${list.map((m) => html`<li><a href="${GITHUB}/${m.repository.split('/')[1]}"><strong>${m.repository.split('/')[1]}</strong></a><span>${(m.site && (m.site.tagline || m.site.what)) || m.name}</span></li>`)}</ul>`;
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'Improve OpenVibe',
++ description: 'OpenVibe is open source: every service and library is on GitHub. How to pick a repository, make a change, run its tests and open a pull request, and the community rules that apply.',
++ crumbs: [{ label: 'Improve OpenVibe' }],
++ body: html`<h1>Improve OpenVibe</h1>
++<p>Every OpenVibe service and library is open source under <a href="${GITHUB}">OpenVibers</a> on GitHub, and changes arrive as pull requests that the owner approves. You can do it by hand today; the harness will take the same path for you: repository → change → tests → independent review → pull request, never straight to <code>main</code>.</p>
++<ol class="steps">
++<li><strong>Pick a repository</strong> from the lists below, or find the page you want to change and follow its "source" link.</li>
++<li><strong>Read its README</strong>: what it owns, how to run it, and its tests (<code>npm test</code> runs them on temporary databases; nothing needs a live service).</li>
++<li><strong>Make the change</strong> on a branch, with a test that would have failed before it.</li>
++<li><strong>Open a pull request</strong> that says what changes for people and why. CI runs the tests, an audit and a secret scan.</li>
++<li><strong>Review</strong>: a maintainer reviews it; the owner approves what ships. See the <a href="/policy/contributor-ladder">contributor ladder</a> for how reviewers and maintainers are chosen.</li>
++</ol>
++<p>Before you start: the <a href="/policy/contributing">contributing guide</a> and the <a href="/policy/code-of-conduct">code of conduct</a>. Building your own app on OpenVibe instead? That is <a href="${SERVICES_ORIGIN}">OpenVibe.Services</a>.</p>
++<h2>Services that run</h2>
++${repoCards(running)}
++<h2>Shared libraries</h2>
++${repoCards(libraries)}`,
++ });
++ });
++
++ // ── Community documents and the update log ───────────────
// What shipped on OpenVibe.Codes: 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.Codes', body: raw(frame.updatesBody({ service: 'codes', siteName: 'OpenVibe.Codes' }) + `<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 Codes stores, what it does not, and what works today</span></li></ul>
--<h2>Community</h2><ul class="cards">
--${governance.map((g) => html`<li><a href="/policy/${g.slug}"><strong>${g.title}</strong></a><span>${g.blurb}${g.draft ? ' (draft, pending owner review)' : ''}</span></li>`)}</ul>`,
++ index: true, cache: PUBLIC_CACHE, title: 'Community', crumbs: [{ label: 'Community' }],
++ body: html`<h1>Community</h1>
++<p>The rules for everyone who builds OpenVibe and everything published on it: the repositories, and the apps and releases on OpenVibe.Services.</p>
++<ul class="cards">
++${governance.map((g) => html`<li><a href="/policy/${g.slug}"><strong>${g.title}</strong></a><span>${g.blurb}${g.draft ? ' (draft, pending owner review)' : ''}</span></li>`)}</ul>
++<p class="muted">The platform's own policy (how a contract or capability changes, compatibility and deprecation, licensing, transparency) is on <a href="${SERVICES_ORIGIN}/policy">OpenVibe.Services</a>.</p>`,
}));
for (const g of governance) {
r.get(`/policy/${g.slug}`, (req, res) => page(req, res, {
-- index: !g.draft, cache: PUBLIC_CACHE, title: g.title, crumbs: [{ label: 'Policy', href: '/policy' }, { label: g.title }],
++ index: !g.draft, cache: PUBLIC_CACHE, title: g.title, crumbs: [{ label: 'Community', href: '/policy' }, { label: g.title }],
body: html`<h1>${g.title}</h1>
${g.draft ? notice(html`<strong>Draft — pending owner review.</strong> This is a proposal, not yet in effect. It takes effect when the owner of the OpenVibers organization approves it.`, 'warn') : ''}
<article class="prose">${raw(markdown(g.body, { headingOffset: 0 }))}</article>
--<p class="muted small">Source: <a href="https://github.com/OpenVibers/OpenVibe.Codes/blob/main/${g.file}"><code>${g.file}</code></a>. Changes go through a pull request that the owner approves.</p>`,
++<p class="muted small">Source: <a href="${GITHUB}/OpenVibe.Codes/blob/main/${g.file}"><code>${g.file}</code></a>. Changes go through a pull request that the owner approves.</p>`,
}));
}
-- 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.Codes/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-codes'), 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 Codes 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 Codes does not store</h2>
--<ul><li>Projects, members, apps, credentials, grants and quotas: OpenVibe.Network owns them; Codes shows what Network answers for the signed-in person.</li>
--<li>Client secrets: Network returns a new secret once, Codes 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 Codes 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 Codes 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('codes.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) ─────────────────────
++ // ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt (http/discovery.js, openvibe-shared/seo) ──
r.use(createDiscoveryRoutes({ ...ctx, governance }));
return r;
}
--const statusBadge = (s) => badge(s, s === 'published' ? 'ok' : (s === 'revoked' ? 'bad' : (s === 'deprecated' ? 'warn' : '')));
--
--module.exports = { createPageRoutes, statusBadge, GOVERNANCE };
++module.exports = { createPageRoutes, GOVERNANCE, repositories };
ddiff --git a/server/http/portal.js b/server/http/portal.js
ddeleted file mode 100644
iindex ed04e54..0000000
---- a/server/http/portal.js
++++ /dev/null
@@@ -1,767 +0,0 @@
--'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; Codes stores none of them). Plus the Codes-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; Codes 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(`[Codes] 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('codes.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('codes.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 || ''} Codes 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 Codes' 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 Codes' 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('codes.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('codes.project.manage'), form, guard, async (req, res) => {
-- if (!USR_RE.test(req.params.subject)) return problemPage(req, res, { status: 422, code: 'codes.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('codes.project.manage'), form, guard, async (req, res) => {
-- if (!USR_RE.test(req.params.subject)) return problemPage(req, res, { status: 422, code: 'codes.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('codes.project.manage'), form, guard, async (req, res) => {
-- if (req.body.confirm !== '1') return problemPage(req, res, { status: 422, code: 'codes.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('codes.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.networkUrl, 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('codes.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: 'codes.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.networkUrl, 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(`[Codes] 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(`[Codes] 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('codes.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: 'codes.confirm', detail: 'type the project name exactly to confirm' }, { back: back(req) });
-- // Network first: if it refuses, nothing in Codes 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(`[Codes] 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. Codes 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('codes.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('codes.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('codes.app.manage'), form, guard, async (req, res) => {
-- if (req.body.confirm !== '1') return problemPage(req, res, { status: 422, code: 'codes.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('codes.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('codes.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('codes.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: 'codes.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('codes.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 — Codes 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. Codes' 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('codes.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('codes.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 (Codes-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 Codes' 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>codes.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('codes.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('codes.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);
-- // Codes staff may revoke a release of a project they cannot see in Network.
-- if (!(action === 'revoke' && req.viewer.staff && p.status === 404)) {
-- log.warn(`[Codes] 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, 'codes.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('[Codes]', 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/tools.js b/server/http/tools.js
ddeleted file mode 100644
iindex fffb722..0000000
---- a/server/http/tools.js
++++ /dev/null
@@@ -1,189 +0,0 @@
--'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('codes.webhook.tool');
-- const validateLimit = actorLimits.budget('codes.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.networkUrl}/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.networkUrl}/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. Codes 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.networkUrl}/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; Codes 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>Codes 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.networkUrl}/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 Codes, 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 Codes 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 (Codes 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/codes.app-manifest"><code>codes.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 8a764ee..1ea863f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -2,21 +2,20 @@
/**
* OpenVibe.Codes — process entry. `node server/index.js`
-- * Listens on PORT (4900) behind nginx (deploy/). Starts the outbox relay when EVENTS_URL and the
-- * client secret are set.
++ * Listens on PORT (4900) behind nginx (deploy/).
*/
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
/**
* The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the HTTP drain runs
-- * (defaults 4000/5000), then the outbox settles and the store closes; past the deadline the process
++ * (defaults 4000/5000), then the JWKS refresher stops and the store closes; past the deadline the process
* exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject `exit` and `signals: false`.
*/
function createLifecycle({ server, ctx, exit, signals }) {
return gracefulStop({
name: 'Codes', server, deadlineExitCode: 0, exit, signals,
-- close: [() => ctx.outbox.stop(), () => ctx.store.close()],
++ close: [() => ctx.keys.client.stop(), () => ctx.store.close()],
});
}
@@@ -26,10 +25,9 @@ async function start() {
const server = app.listen(config.port, config.host, () => {
console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
-- console.log(`[Codes] docs from openvibe-contracts v${ctx.docs.contractsVersion} and openvibe-sdk v${ctx.docs.sdkVersion}; events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}`);
++ console.log(`[Codes] ${ctx.harnesses.list().length} harnesses in the catalog; the developer console's addresses redirect to openvibe.services`);
});
server.keepAliveTimeout = 65_000;
-- ctx.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.
ctx.keys.client.start();
ddiff --git a/server/observability.js b/server/observability.js
iindex 22f02a3..7c08cbd 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -2,22 +2,19 @@
/**
* Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
*
-- * db required a real query on Codes' PostgreSQL store: every Codes 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
++ * db required a real round trip to Codes' PostgreSQL store (it holds no table of its own since the
++ * console moved to OpenVibe.Services; the harness's runs will)
++ * catalog required the harness catalog loaded and validated at boot
++ * jwks optional openvibe-sdk/auth's JWKS cache has the Network signing key; without it nobody can sign
++ * in (every public page and the router still serve). The SDK keeps one client per URL and
++ * reports readiness, staleness, failures and the next try for us.
++ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in needs it)
++ * valkey optional shared per-actor limit counters
*/
const { jwksStatus } = require('openvibe-sdk/auth');
const { createReadiness } = require('openvibe-shared/ready');
--const { TABLES } = require('./db');
--function createCodesReadiness({ store, network, outbox, docs, config, valkey = null, release = null }) {
++function createCodesReadiness({ store, harnesses, config, valkey = null, release = null }) {
const { db } = store;
return createReadiness({
service: 'codes',
@@@ -25,20 +22,15 @@ function createCodesReadiness({ store, network, outbox, docs, config, valkey = n
checks: [
{
name: 'db', required: true,
-- // A real round trip that names the store (postgresql / pglite), and the tables present.
++ // A real round trip that names the store (postgresql / pglite).
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 };
++ return r.ok ? { ok: true, detail: r.detail } : r.error;
},
},
{
-- 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: 'catalog', required: true,
++ check: () => (harnesses && harnesses.list().length ? { ok: true, detail: { harnesses: harnesses.list().length } } : 'the harness catalog is empty'),
},
{ name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
@@@ -49,29 +41,14 @@ function createCodesReadiness({ store, network, outbox, docs, config, valkey = n
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';
++ if (!first.ready) return 'Network signing key not loaded yet: sign-in is 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 } };
-- },
-- },
],
});
}
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
ddeleted file mode 100644
iindex 0e4114a..0000000
---- a/server/registry/resource-index.js
++++ /dev/null
@@@ -1,193 +0,0 @@
--'use strict';
--/**
-- * Codes' authority resource index (ADR-048 section 3; capability codes.resource.read):
-- *
-- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1
-- * GET /api/v1/resources/:ovrn → common.resource-summary@1
-- *
-- * It pages the resources OpenVibe.Codes owns — its validated app and mod manifests (codes.manifest, mfs_)
-- * and its releases (codes.release, rel_) — as common.resource-summary@1, the shape OpenVibe.Services fans
-- * out over and merges (openvibe-sdk/resources' createResourceIndex). Trust tiers are per-app metadata and
-- * playground runs are audit records, neither a resource; Codes hosts no repositories, so no codes.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 Codes-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: codes.manifest is mfs_, codes.release is rel_): a manifest is named
-- * ovrn:codes:<prj_…>:manifest/mfs_…, a release ovrn:codes:<prj_…>:release/rel_…. That is also what
-- * GET /api/v1/resources/:ovrn 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):
-- * codes.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.
-- * codes.release — name: releases.name, state: releases.status (draft|published|deprecated|revoked).
-- * owner: created_by likewise.
-- */
--const contracts = require('openvibe-contracts');
--const { asyncRouter } = require('../http/router');
--
--const SERVICE = 'codes';
--const RESOURCE_READ = 'codes.resource.read';
--const MANIFEST_KIND = 'codes.manifest';
--const RELEASE_KIND = 'codes.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: codes.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 };
--}
--
--/** The Codes store (the app's own database), as server/app.js keeps it. */
--const dbOf = (req) => req.app.locals.ctx.store.db;
--
--/**
-- * router({ guard }) — guard is the middleware chain that enforces codes.resource.read
-- * (server/http/api.js's createCapabilityAccess(...).checked('codes.resource.read')), mounted at
-- * /api/v1/resources by server/app.js.
-- */
--function router({ guard }) {
-- const open = (res) => res.set('Cache-Control', 'private, max-age=60');
-- const bad = (res, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail });
-- const unknown = (res, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}` });
--
-- /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */
-- async function page(req, res) {
-- const f = filtersOf(req.query);
-- if (f.error) return bad(res, f.error);
-- const all = await collect(dbOf(req), 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;
-- open(res).json({ resources, next_cursor });
-- }
--
-- /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */
-- async function one(req, res) {
-- const name = String(req.params.ovrn);
-- const parsed = contracts.resources.parse(name);
-- const db = dbOf(req);
-- 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 unknown(res, name);
-- open(res).json(summary);
-- }
--
-- const r = asyncRouter();
-- r.get('/', ...guard, page);
-- r.get('/:ovrn', ...guard, one);
-- return r;
--}
--
--module.exports = {
-- router, SERVICE, RESOURCE_READ, KINDS, MANIFEST_KIND, RELEASE_KIND, DEFAULT_LIMIT, MAX_LIMIT,
-- manifestSummary, releaseSummary, ovrnOf, collect, encodeCursor,
--};
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 4418941..cfa2aef 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -2,12 +2,11 @@
/**
* 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,
++ * - <head>: title, description, canonical, robots (pages are noindex unless they say otherwise), Open Graph/Twitter,
* JSON-LD (openvibe-shared/seo), plus Codes' 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
-- * - Codes' own script only where a page offers an optional in-browser convenience (webhook
-- * signature computed locally, copy buttons); every form works without it
++ * - Codes' own script only where a page offers an optional in-browser convenience (copy buttons)
*/
const crypto = require('crypto');
const ovServe = require('openvibe-shared/serve');
@@@ -23,12 +22,9 @@ const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Codes';
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' },
++ { label: 'Agents', href: '/harnesses' },
++ { label: 'Improve OpenVibe', href: '/improve' },
++ { label: 'Community', href: '/policy' },
];
const hashes = new Map();
@@@ -69,11 +65,12 @@ function renderPage(o) {
};
// 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' }] };
++ // Your projects and apps live on the developer platform (OpenVibe.Services).
++ if (signedIn) nav.menu = { before: [{ label: 'Your projects', href: 'https://openvibe.services/projects', icon: 'fa-folder' }] };
const footer = { service: 'codes', 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`;
++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
++ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
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>`
: '';
@@@ -81,10 +78,10 @@ function renderPage(o) {
return shell.page({
name: SITE_NAME,
lang: 'en',
-- title: o.title || `${SITE_NAME}: the OpenVibe developer portal`,
++ title: o.title || `${SITE_NAME}: code with any agent`,
titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
siteName: SITE_NAME,
-- description: o.description || 'Build on OpenVibe: projects, apps and scoped credentials, generated contract and SDK docs, webhook and OAuth tools, and manifest validation.',
++ description: o.description || 'The open coding-agent harness: Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and your own models behind one router, and the way to improve OpenVibe itself.',
canonical,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
@@@ -118,7 +115,7 @@ document.addEventListener('DOMContentLoaded', function () {
function render(v) { return require('./html').render(v); }
--/** Send a page; portal pages are private and never cached. */
++/** Send a page; a page rendered for a signed-in person is 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';
ddiff --git a/server/render/usage.js b/server/render/usage.js
ddeleted file mode 100644
iindex b2c91e7..0000000
---- a/server/render/usage.js
++++ /dev/null
@@@ -1,109 +0,0 @@
--'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/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 130fff6..7e7f282 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -2,10 +2,9 @@
/**
* 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.
++ * another caller still passes; the window reopens on the clock. A person counts as themselves, anyone
++ * else by address. The router has its own number. 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');
@@@ -17,54 +16,36 @@ const { actor } = require('../server/http/actor-limits');
const t = await boot({ actorLimits: true, limitsNow: () => clock, env: { CODES_LIMITS_MINUTE: '3', CODES_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'));
++ await check('a catalog 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('/api/v1/harnesses', { as: rosa })).status, 200);
++ const r = await t.get('/api/v1/harnesses', { as: rosa });
++ assert.strictEqual(r.status, 429);
++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
++ assert.ok(Number(r.headers.get('retry-after')) > 0 && Number(r.headers.get('retry-after')) <= 45, r.headers.get('retry-after'));
const body = r.json();
-- assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);
-- assert.ok(body.detail.includes('codes.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');
++ assert.strictEqual(body.code, 'rate_limited');
++ assert.ok(body.detail.includes('codes.read'), body.detail);
++ assert.strictEqual((await t.get('/api/v1/harnesses', { 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.codes', ['codes.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.codes', ['codes.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('signed-out callers count by address', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/harnesses', { headers: { 'x-forwarded-for': '203.0.113.7' } })).status, 200);
++ assert.strictEqual((await t.get('/api/v1/harnesses', { headers: { 'x-forwarded-for': '203.0.113.7' } })).status, 429);
++ assert.strictEqual((await t.get('/api/v1/harnesses', { headers: { 'x-forwarded-for': '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);
++ clock += 60_000;
++ assert.strictEqual((await t.get('/api/v1/harnesses', { 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('codes.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('the router has its own number: 30 a minute, the 31st refused before the body is read', async () => {
++ for (let i = 0; i < 30; i++) assert.strictEqual((await t.get('/api/v1/harnesses/route', { as: sam, json: { task: 'edit' } })).status, 200);
++ const r = await t.get('/api/v1/harnesses/route', { as: sam, json: { task: 'edit' } });
++ assert.strictEqual(r.status, 429);
++ assert.ok(r.json().detail.includes('codes.harness.route'), r.json().detail);
++ assert.strictEqual((await t.get('/api/v1/harnesses/route', { as: rosa, json: { task: 'edit' } })).status, 200, 'another person still routes');
});
await check('health, ready, release.json and metrics are never limited', async () => {
@@@ -79,19 +60,15 @@ const { actor } = require('../server/http/actor-limits');
await check('refusals are counted in codes_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('codes_rate_limited_total')).join('\n');
-- assert.ok(/codes_rate_limited_total\{limit="codes.portal.read",window="minute"\} 1/.test(m), counted);
assert.ok(/codes_rate_limited_total\{limit="codes.read",window="minute"\} 2/.test(m), counted);
-- assert.ok(/codes_rate_limited_total\{limit="codes.project.create",window="minute"\} 1/.test(m), counted);
++ assert.ok(/codes_rate_limited_total\{limit="codes.harness.route",window="minute"\} 1/.test(m), counted);
const logs = t.logs();
-- assert.ok(logs.includes(`[Limits] codes.portal.read: user:${rosa.subject} refused`), 'one log line per refusal');
-- assert.ok(logs.includes(`[Limits] codes.read: app:${app.id} refused`));
++ assert.ok(logs.includes(`[Limits] codes.read: user:${rosa.subject} refused`), 'one log line per refusal');
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');
});
ddiff --git a/test/auth-jwks.test.js b/test/auth-jwks.test.js
iindex ba42ee2..e0db7e7 100644
---- a/test/auth-jwks.test.js
++++ b/test/auth-jwks.test.js
@@@ -59,7 +59,7 @@ function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg:
await jwks.close();
});
-- // A rotation publishes two keys: the /api/v1 guard must verify against the one the token names, not the first.
++ // A rotation publishes two keys: verification must use 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 });
@@@ -118,16 +118,16 @@ function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg:
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 Codes: neither /api/v1 nor /api/ready names it.
++ // The same unreachable JWKS behind a booted Codes: neither sign-in 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)}`);
++ const me = await t.get('/auth/me', { cookie: `codes_at=${token}` });
++ assert.strictEqual(me.status, 401, `an unverifiable session token is 401: ${me.status} ${me.text.slice(0, 200)}`);
++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(me.text),
++ `the sign-in answer leaks the JWKS URL or the fetch error: ${me.text.slice(0, 300)}`);
await t.close();
});
ddiff --git a/test/auth-ops.test.js b/test/auth-ops.test.js
iindex 8cf4de1..363226d 100644
---- a/test/auth-ops.test.js
++++ b/test/auth-ops.test.js
@@@ -60,25 +60,17 @@ const { boot, check, done } = require('./helpers/boot');
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.codes' } });
-- assert.strictEqual(ok.status, 303);
-- });
--
-- await check('readiness is truthful: required db + docs; Network, relay and client are optional', async () => {
++ await check('readiness is truthful: required db + the catalog; JWKS, the OAuth client and Valkey 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.db.required, true);
++ assert.strictEqual(b.checks.catalog.status, 'ok');
++ assert.strictEqual(b.checks.catalog.required, true);
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'));
++ assert.strictEqual(b.checks.oauth_client.status, 'ok');
++ assert.ok(!('docs' in b.checks) && !('events_relay' in b.checks), 'the console\'s checks moved with it');
});
await check('/release.json and /metrics (loopback only)', async () => {
@@@ -94,65 +86,16 @@ const { boot, check, done } = require('./helpers/boot');
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']) {
++ await check('public pages are complete without JavaScript (noscript nav, SSR footer)', async () => {
++ for (const p of ['/', '/harnesses', '/improve', '/policy', '/policy/contributing']) {
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.codes/oauth/test-callback');
});
await t.close();
--
-- // ── Outbox relay: Codes' own token, events.event.publish, to Events ──
-- const t2 = await boot({ relay: true });
-- await check('with EVENTS_URL set, the relay publishes codes.app.* with Codes\' 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:codes');
-- assert.strictEqual(t2.events.published[0].event.event_type, 'codes.app.published');
-- const tok = t2.network.tokenRequests.find((x) => x.client_id === 'codes' && 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/contracts.test.js b/test/contracts.test.js
iindex 3d69653..d54cc78 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -1,53 +1,48 @@
'use strict';
/**
-- * The released contracts (the pinned openvibe-contracts tag) describe what the code does: the codes
-- * 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.
++ * The released contracts (the pinned openvibe-contracts tag) describe what the code does. Since 0.113.0 the codes
++ * service manifest declares no capability and no event: the developer console moved to OpenVibe.Services, which owns
++ * services.release.* and services.app.*, and the codes.* forms are retired. The harness catalog's rows are the
++ * contracts' platform.harness-offer@1 and platform.agent-offer@1 (checked at boot by server/domain/harnesses.js).
*/
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('codes');
-- await check('the codes service manifest is released as alpha, with this code\'s events', async () => {
++ await check('the codes service manifest is released as alpha, live, with no capability and no event', async () => {
assert.ok(manifest);
assert.strictEqual(manifest.status, 'alpha');
assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);
-- assert.deepStrictEqual([...manifest.eventsProduced].sort(), [...EVENT_TYPES].sort());
-- for (const e of manifest.eventsProduced) assert.strictEqual(e.split('.').length, 3, `${e} has three segments`);
++ assert.strictEqual(manifest.exposure.state, 'live');
++ assert.deepStrictEqual(manifest.capabilities, []);
++ assert.deepStrictEqual(manifest.eventsProduced, []);
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 codes, and are the ones the API guards', async () => {
-- // codes.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]) {
++ await check('the console\'s codes.* names are retired, each with its services.* replacement', async () => {
++ for (const id of ['codes.release.read', 'codes.release.manage', 'codes.resource.read']) {
++ assert.strictEqual(contracts.capabilities.get(id).status, 'retired', id);
++ }
++ for (const id of ['services.release.read', 'services.release.manage']) {
const c = contracts.capabilities.get(id);
-- assert.ok(c, id);
-- assert.strictEqual(c.owner, 'codes');
-- assert.strictEqual(c.status, 'active');
++ assert.ok(c && c.status === 'active' && c.owner === 'services', id);
++ }
++ });
++
++ await check('nothing here guards or emits a console name any more', async () => {
++ const files = [];
++ 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 (f.endsWith('.js')) files.push(f); } };
++ walk(path.join(__dirname, '..', 'server'));
++ for (const f of files) {
++ const text = fs.readFileSync(f, 'utf8');
++ assert.ok(!/codes\.(release|resource|app)\.|codes\.moderation\.action|requireCapability/.test(text), `${path.relative(path.join(__dirname, '..'), f)} still names the console`);
}
-- const resource = contracts.capabilities.get(RESOURCE_READ);
-- assert.ok(resource, RESOURCE_READ);
-- assert.strictEqual(resource.owner, 'codes');
-- 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\('codes\.release\.manage'/);
-- assert.match(api, /guardFor\('codes\.release\.read'\)/);
-- const index = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
-- assert.match(index, /RESOURCE_READ = 'codes\.resource\.read'/, 'the resource index guards codes.resource.read');
});
await check('no proposal left behind: everything proposed is released', async () => {
@@@ -55,7 +50,6 @@ const { check, done } = require('./helpers/boot');
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('codes.app-manifest@1'));
});
done();
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex df21b90..b2f1049 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -16,12 +16,13 @@ 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 () => {
++ await check('robots.txt: 200 text/plain, sign-in and the API disallowed, the moved addresses crawlable (their 301s carry the ranking to openvibe.services), 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}`);
++ for (const d of ['/auth/', '/api/']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);
++ for (const d of ['/projects', '/staff', '/docs']) assert.ok(!r.text.includes(`Disallow: ${d}`), `${d} must stay crawlable so engines follow its 301`);
assert.ok(r.text.includes('Sitemap: https://openvibe.codes/sitemap.xml'), 'sitemap not named');
assert.ok(/^User-agent: \*$/m.test(r.text), 'no User-agent: * group');
});
@@@ -31,9 +32,8 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
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.codes/docs</loc>'), 'no /docs entry');
-- assert.ok(r.text.includes('<loc>https://openvibe.codes/docs/harnesses</loc>'), 'no /docs/harnesses entry');
-- assert.ok(r.text.includes('<loc>https://openvibe.codes/policy/transparency</loc>'), 'no policy entry');
++ for (const p of ['/', '/harnesses', '/improve', '/policy']) assert.ok(r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `no ${p} entry`);
++ for (const p of ['/docs', '/projects', '/oauth', '/tools/webhooks', '/policy/transparency']) assert.ok(!r.text.includes(`<loc>https://openvibe.codes${p}</loc>`), `${p} moved to openvibe.services`);
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)}`);
@@@ -47,12 +47,12 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
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.Codes'), 'no title');
-- assert.ok(r.text.includes('(https://openvibe.codes/docs)'), 'no reference link');
-- assert.ok(r.text.includes('(https://openvibe.codes/docs/harnesses)'), 'no harnesses reference link');
++ assert.ok(r.text.includes('(https://openvibe.codes/harnesses)'), 'no agents link');
++ assert.ok(r.text.includes('(https://openvibe.codes/improve)'), 'no Improve OpenVibe link');
++ assert.ok(r.text.includes('(https://openvibe.services)'), 'no pointer to the developer platform');
assert.ok(r.text.includes('https://openvibe.codes/sitemap.xml'), 'sitemap not listed');
assert.ok(r.text.includes('(https://openvibe.codes/llms-full.txt)'), 'llms-full.txt not listed');
for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.codes${p}`), `private path in llms.txt: ${p}`);
-- assert.ok(!r.text.includes('https://openvibe.codes/api/'), 'api path in llms.txt');
});
await check('llms-full.txt: 200 text/plain, the site title, and every page the sitemap lists', async () => {
@@@ -63,10 +63,9 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
assert.ok(r.text.startsWith('# OpenVibe.Codes'), '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}`);
++ assert.ok(locs.length >= 5, `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.codes/docs\n'), 'no /docs entry');
-- assert.ok(r.text.includes('URL: https://openvibe.codes/tools/webhooks\n'), 'no playground entry');
++ assert.ok(r.text.includes('URL: https://openvibe.codes/harnesses\n'), 'no /harnesses entry');
for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.codes${p}`), `private path in llms-full.txt: ${p}`);
assert.ok(!r.text.includes('https://openvibe.codes/api/'), 'api path in llms-full.txt');
});
ddiff --git a/test/docs-billing.test.js b/test/docs-billing.test.js
ddeleted file mode 100644
iindex 9211d91..0000000
---- a/test/docs-billing.test.js
++++ /dev/null
@@@ -1,56 +0,0 @@
--'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
ddeleted file mode 100644
iindex c7d5932..0000000
---- a/test/docs-limits.test.js
++++ /dev/null
@@@ -1,90 +0,0 @@
--'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
ddeleted file mode 100644
iindex 7f7641a..0000000
---- a/test/docs.test.js
++++ /dev/null
@@@ -1,156 +0,0 @@
--'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'), 'v0.112.0');
-- assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
-- assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
-- assert.strictEqual(sdkPkg.version, '0.35.0');
-- // The contracts tag v0.112.0 carries the released codes manifest, codes.app-manifest@1 and codes.resource.read.
-- assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
-- assert.ok(contracts.resolve('codes.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/v0.112.0'), `${p} links the pinned tag`);
-- if (contractsVersion !== '0.112.0') assert.ok(r.text.includes('(tag v0.112.0)'), `${p} says the tag differs from the package version`);
-- 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, 'v0.112.0');
-- 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/governance.test.js b/test/governance.test.js
iindex 2fc2e8d..6f3cc40 100644
---- a/test/governance.test.js
++++ b/test/governance.test.js
@@@ -44,7 +44,7 @@ const ROOT = path.join(__dirname, '..');
assert.match(idx.text, /\(draft, pending owner review\)/);
const map = await t.get('/sitemap.xml');
for (const g of GOVERNANCE) assert.ok(!map.text.includes(`/policy/${g.slug}<`), `${g.slug} not in the sitemap while a draft`);
-- assert.ok(map.text.includes('/policy/compatibility<'));
++ assert.ok(map.text.includes('/improve<'));
});
await check('every openvibe.codes link in the documents resolves', async () => {
@@@ -53,7 +53,7 @@ const ROOT = path.join(__dirname, '..');
const text = fs.readFileSync(path.join(ROOT, g.file), 'utf8');
for (const m of text.matchAll(/\]\(https:\/\/openvibe\.codes(\/[^)\s#]*)\)/g)) links.add(m[1]);
}
-- assert.ok(links.size >= 5);
++ assert.ok(links.size >= 4);
for (const l of links) assert.strictEqual((await t.get(l)).status, 200, l);
});
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 221488b..21b9dcd 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -8,12 +8,12 @@ process.env.CODES_RATE_LIMIT_PER_MIN = process.env.CODES_RATE_LIMIT_PER_MIN || '
* (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.get(path, { as: user, form, json, headers, method })
* t.signIn(user) → cookie header value (codes_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)
++ * t.logs(), t.dbDump()
*/
const http = require('http');
--const { startNetwork, startEvents, startMedia } = require('./mocks');
++const { startNetwork } = require('./mocks');
const captured = [];
for (const m of ['log', 'info', 'warn', 'error']) {
@@@ -23,17 +23,10 @@ for (const m of ['log', 'info', 'warn', 'error']) {
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.codes', TRUST_PROXY: '1',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',
-- CODES_FORM_SECRET: 'test-form-secret',
-- CODES_PLAYGROUND_EVENTS_URL: events.url, CODES_PLAYGROUND_MEDIA_URL: media.url,
-- CODES_EXPORT_EVENTS_URL: events.url, CODES_EXPORT_MEDIA_URL: media.url,
-- CODES_REGISTRY_TTL_MS: '0',
-- ...(opts.relay ? { EVENTS_URL: events.url, EVENTS_RELAY_INTERVAL_MS: '50' } : {}),
...(opts.env || {}),
};
const configLib = require('../../server/config');
@@@ -44,14 +37,12 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_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 });
++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true });
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) => `codes_at=${network.userToken(user)}`;
-- const csrf = (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.CODES_FORM_SECRET }, user);
async function get(p, o = {}) {
const headers = { ...(o.headers || {}) };
@@@ -60,19 +51,9 @@ async function boot(opts = {}) {
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();
++ body = new URLSearchParams({ ...o.form }).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');
@@@ -86,35 +67,14 @@ async function boot(opts = {}) {
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,
++ base, network, config, ctx: built.ctx, get, signIn, dbDump,
logs: () => captured.join('\n'),
async close() {
await new Promise((r) => server.close(r));
-- await built.ctx.outbox.stop();
++ built.ctx.keys.client.stop();
await testdb.close();
-- await network.close(); await events.close(); await media.close();
++ await network.close();
},
};
return t;
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex c137142..289fe7b 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -1,6 +1,7 @@
'use strict';
/**
-- * In-process stand-ins for Codes' neighbours, with a real RS256 key pair.
++ * An in-process stand-in for OpenVibe.Network (sign-in, JWKS, tokens), with a real RS256 key pair. Much of it served
++ * the developer console that moved to OpenVibe.Services; what Codes' tests use is sign-in and the JWKS.
*
* Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `codes`, refresh_token,
* client_credentials for `codes` and for developer apps with Network's app rules:
@@@ -13,18 +14,10 @@
* 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) {
@@@ -333,165 +326,4 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
};
}
--/** 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 };
++module.exports = { startNetwork, signJwt };
ddiff --git a/test/helpers/unzip.js b/test/helpers/unzip.js
ddeleted file mode 100644
iindex 57a1e2d..0000000
---- a/test/helpers/unzip.js
++++ /dev/null
@@@ -1,48 +0,0 @@
--'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
ddeleted file mode 100644
iindex a3bf937..0000000
---- a/test/indexnow.test.js
++++ /dev/null
@@@ -1,77 +0,0 @@
--'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.codes/sitemap.xml';
--const UPDATES = 'https://openvibe.codes/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.codes/releases/${release}`), JSON.stringify(pings));
-- assert.ok(pings.includes(`https://openvibe.codes/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.codes/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
iindex f7362b3..9df17ec 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -20,7 +20,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
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', '/docs/harnesses']) {
++ for (const p of ['/', '/harnesses', '/improve', '/policy']) {
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
const m = r.text.match(MARKER);
@@@ -29,10 +29,10 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
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 });
++ const signedIn = await t.get('/improve', { 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');
++ assert.match(signedIn.text, MARKER, '/improve signed in: no ov-boost marker');
++ assert.match(signedIn.text, BOOST, '/improve signed in: no boost script');
});
await check('the home page head comes from openvibe-shared/shell with the same title, canonical, robots and JSON-LD', async () => {
@@@ -40,7 +40,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
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.Codes: the OpenVibe developer portal</title>'), 'home title');
++ assert.ok(head.includes('<title>OpenVibe.Codes: code with any agent</title>'), 'home title');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.codes/">'), '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']);
@@@ -53,13 +53,13 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
});
await check('the navbar config uses the {path} login template, not the path baked in', async () => {
-- const r = await t.get('/docs');
++ const r = await t.get('/harnesses');
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 check('the harness catalog page lists every seed harness server-side, with no local path', async () => {
-- const r = await t.get('/docs/harnesses');
++ const r = await t.get('/harnesses');
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.match(r.headers.get('content-type'), /^text\/html/);
const rows = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'server', 'data', 'harness-offers.json'), 'utf8'));
ddiff --git a/test/manifests.test.js b/test/manifests.test.js
ddeleted file mode 100644
iindex 4065d5f..0000000
---- a/test/manifests.test.js
++++ /dev/null
@@@ -1,101 +0,0 @@
--'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 codes.app-manifest@1, fixtures included', async () => {
-- assert.strictEqual(manifests.SCHEMAS.app, 'codes.app-manifest@1');
-- const dir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', 'codes.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, 'codes.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
ddeleted file mode 100644
iindex 4254add..0000000
---- a/test/moderation-events.test.js
++++ /dev/null
@@@ -1,78 +0,0 @@
--'use strict';
--/**
-- * codes.moderation.action (ADR-022): Codes 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 event_outbox ORDER BY id').all())
-- .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => e.event_type === 'codes.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('codes.moderation.action@1', e.payload);
-- assert.strictEqual(r.valid, true, JSON.stringify(r.errors));
-- assert.strictEqual(e.source, 'codes');
-- 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 codes.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/moved.test.js b/test/moved.test.js
nnew file mode 100644
iindex 0000000..5a95772
---- /dev/null
++++ b/test/moved.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * The developer console moved to OpenVibe.Services on 2026-10-08 (server/http/moved.js): every address it had here
++ * answers with a permanent redirect to the same path and query there — 301 for GET and HEAD, 308 for anything else
++ * (the method and body are kept) — before sign-in or any route runs. What Codes still serves is untouched, and
++ * /docs/harnesses, the agent catalog's old address, goes to /harnesses on this site.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { movedTo } = require('../server/http/moved');
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('mover');
++
++ await check('every console page answers 301 to the same path and query on openvibe.services', async () => {
++ for (const p of ['/projects', '/projects/prj_01JABCDEFGHJKMNPQRSTVWXYZ0/apps?x=1', '/docs', '/docs/api/media', '/docs/contracts/common.problem.json',
++ '/oauth', '/oauth/test-callback?code=c&state=s', '/tools/webhooks', '/manifests/validate', '/releases/rel_x', '/apps/app_x', '/staff',
++ '/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 301, `${p}: ${r.status}`);
++ assert.strictEqual(r.headers.get('location'), `https://openvibe.services${p}`, p);
++ }
++ });
++
++ await check('a signed-in person is redirected too (nothing of the console runs here any more)', async () => {
++ const r = await t.get('/projects', { as: user });
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), 'https://openvibe.services/projects');
++ });
++
++ await check('the console API: 301 for reads, 308 for writes (the method and body survive)', async () => {
++ const read = await t.get('/api/v1/apps/app_x/releases');
++ assert.strictEqual(read.status, 301);
++ assert.strictEqual(read.headers.get('location'), 'https://openvibe.services/api/v1/apps/app_x/releases');
++ const write = await t.get('/api/v1/apps/app_x/releases', { method: 'POST', json: { kind: 'app' } });
++ assert.strictEqual(write.status, 308);
++ assert.strictEqual(write.headers.get('location'), 'https://openvibe.services/api/v1/apps/app_x/releases');
++ assert.strictEqual((await t.get('/api/v1/resources?project=prj_x')).headers.get('location'), 'https://openvibe.services/api/v1/resources?project=prj_x');
++ });
++
++ await check('what Codes serves stays here', async () => {
++ for (const p of ['/', '/harnesses', '/improve', '/policy', '/policy/contributing', '/updates', '/api/v1/harnesses', '/robots.txt']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, `${p}: ${r.status}`);
++ }
++ assert.strictEqual((await t.get('/api/v1/harnesses/route', { json: { task: 'edit' } })).status, 200);
++ assert.strictEqual(movedTo({ path: '/auth/login', originalUrl: '/auth/login' }), null, 'sign-in stays');
++ });
++
++ await check('/docs/harnesses is the catalog\'s old address: 301 to /harnesses here', async () => {
++ const r = await t.get('/docs/harnesses');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), '/harnesses');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/network-errors.test.js b/test/network-errors.test.js
ddeleted file mode 100644
iindex cfe14aa..0000000
---- a/test/network-errors.test.js
++++ /dev/null
@@@ -1,111 +0,0 @@
--'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: `codes_at=${expired}; codes_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, /codes_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: `codes_at=${expired}` });
-- assert.strictEqual(r.status, 401);
-- assert.match(r.text, /Sign in/);
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/no-internal-key.test.js b/test/no-internal-key.test.js
iindex 3c071c0..18e0a76 100644
---- a/test/no-internal-key.test.js
++++ b/test/no-internal-key.test.js
@@@ -36,24 +36,19 @@ function files(dir) {
});
const t = await boot();
-- await check('a whole portal session never sends an internal key to Network', async () => {
++ await check('a whole session (sign-in, every page, the router) 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' } } });
++ for (const p of ['/', '/harnesses', '/improve', '/policy', '/auth/me']) await t.get(p, { as: u });
++ await t.get('/api/v1/harnesses/route', { as: u, json: { task: 'edit' } });
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 check('presenting X-Internal-Key changes nothing: the public routes answer as for anyone', async () => {
++ const r = await t.get('/api/v1/harnesses', { headers: { 'x-internal-key': 'anything' } });
++ assert.strictEqual(r.status, 200);
++ const me = await t.get('/auth/me', { headers: { 'x-internal-key': 'anything' } });
++ assert.deepStrictEqual(JSON.parse(me.text), { user: null }, 'the key signs nobody in');
});
await t.close();
ddiff --git a/test/playground.test.js b/test/playground.test.js
ddeleted file mode 100644
iindex c0a8d2d..0000000
---- a/test/playground.test.js
++++ /dev/null
@@@ -1,176 +0,0 @@
--'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 codes, 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
ddeleted file mode 100644
iindex dafb116..0000000
---- a/test/project-archive.test.js
++++ /dev/null
@@@ -1,280 +0,0 @@
--'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, Codes' 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', 'codes.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.codes.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.codes.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 Codes\' 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 Codes\' 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
ddeleted file mode 100644
iindex 19e2fc6..0000000
---- a/test/project-export-delete.test.js
++++ /dev/null
@@@ -1,207 +0,0 @@
--'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 Codes'
-- * 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 Codes when Network refuses; then deletes drafts (and
-- * their manifests) and playground runs, and revokes public releases with codes.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 Codes 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.codes.releases.map((rel) => [rel.id, rel.status]), [[published, 'published'], [drafted, 'draft']]);
-- assert.strictEqual(x.codes.releases[0].manifest.body.version, '1.0.0');
-- assert.deepStrictEqual(x.codes.releases[0].log.map((l) => l.action), ['created', 'published']);
-- assert.strictEqual(x.codes.trust[0].app_id, app.id);
-- assert.strictEqual(x.codes.trust[0].tier, 'unreviewed');
-- assert.strictEqual(x.codes.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, Codes 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 event_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 === 'codes.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.codes.releases.map((rel) => [rel.id, rel.status]), [[published, 'revoked']]);
-- assert.deepStrictEqual(x.codes.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
ddeleted file mode 100644
iindex c01d726..0000000
---- a/test/project-usage.test.js
++++ /dev/null
@@@ -1,172 +0,0 @@
--'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 Codes'.
-- assert.match(body, new RegExp(`<form method="get" action="/projects/${projectId}/usage"`));
-- assert.ok(!/src="\/js\//.test(body), 'no Codes 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
ddeleted file mode 100644
iindex 95cdcf3..0000000
---- a/test/releases.test.js
++++ /dev/null
@@@ -1,198 +0,0 @@
--'use strict';
--/**
-- * Release metadata (Codes-owned, keyed to Network app ids) and its events.
-- * - draft → published → deprecated → revoked, with Network's role rules (developer+ sandbox)
-- * - each public transition writes codes.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 codes.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 event_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; Codes 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 codes.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, 'codes.app.published');
-- assert.strictEqual(e.event_type.split('.').length, 3);
-- assert.strictEqual(e.source, 'codes');
-- 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, 'codes.app.deprecated');
-- assert.strictEqual(e.payload.replacement, rel2);
-- });
--
-- await check('revoking needs confirmation and a reason; emits codes.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, 'codes.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 codes.app.* types are ever produced', async () => {
-- const types = new Set((await outbox()).map((e) => e.event_type));
-- for (const x of types) assert.ok(['codes.app.published', 'codes.app.deprecated', 'codes.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 codes.release.manage creates and publishes its own release', async () => {
-- const token = t.network.mintApp(app.id, 'openvibe.codes', ['codes.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', ['codes.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.codes', ['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.codes', ['codes.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 codes.release.read', async () => {
-- assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`)).status, 200);
-- const withRead = t.network.mintApp(app.id, 'openvibe.codes', ['codes.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.codes', ['codes.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
ddeleted file mode 100644
iindex 59237f1..0000000
---- a/test/resource-index.test.js
++++ /dev/null
@@@ -1,175 +0,0 @@
--'use strict';
--// Codes' authority resource index (ADR-048 section 3; capability codes.resource.read):
--// GET /api/v1/resources pages common.resource-summary@1 for the resources Codes owns — its validated
--// manifests (codes.manifest, mfs_) and its releases (codes.release, rel_) — and GET /api/v1/resources/:ovrn
--// 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; every summary carries the project it was created in. The guard is the service-token
--// capability check (no token 401, a token without the capability 403, with it 200), and every page and
--// every 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:codes:${project}:manifest/${id}`;
-- const ovrnOfRelease = (project, id) => `ovrn:codes:${project}:release/${id}`;
--
-- // A first-party service token signed by the Network mock, holding the capabilities asked for.
-- const token = (cap, { aud = 'openvibe.codes', 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])}` };
--
-- await check('no token 401, a token without codes.resource.read 403, wrong audience 401, with it 200', async () => {
-- const none = await get('/api/v1/resources');
-- assert.deepStrictEqual([none.status, none.json().code], [401, 'auth.required']);
-- assert.ok(/^application\/problem\+json/.test(none.headers.get('content-type')));
-- const without = await get('/api/v1/resources', { authorization: `Bearer ${token(['codes.release.read'])}` });
-- assert.deepStrictEqual([without.status, without.json().code], [403, 'capability.denied']);
-- const wrongAud = await get('/api/v1/resources', { authorization: `Bearer ${token([resourceIndex.RESOURCE_READ], { aud: 'openvibe.media' })}` });
-- assert.deepStrictEqual([wrongAud.status, wrongAud.json().code], [401, 'token.wrong_audience']);
-- const forged = await get('/api/v1/resources', { authorization: 'Bearer not.a.jwt' });
-- assert.strictEqual(forged.status, 401);
-- assert.strictEqual((await get('/api/v1/resources', auth)).status, 200);
-- // The guard is on the single read too.
-- assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOfRelease(prjA, relA1))}`)).status, 401);
-- });
--
-- const aPage = async (q = '', headers = auth) => {
-- const y = await get(`/api/v1/resources${q}`, headers);
-- assert.strictEqual(y.status, 200, `${q}: ${y.text.slice(0, 300)}`);
-- assert.ok(validate('common.resource-list-result@1', y.json()).valid, JSON.stringify(validate('common.resource-list-result@1', y.json()).errors));
-- return y;
-- };
-- const idsOf = async (q, headers = auth) => (await aPage(q, headers)).json().resources.map((r) => r.id);
--
-- await check('the page is common.resource-list-result@1: only its fields, every summary valid and sorted', async () => {
-- const y = await aPage();
-- assert.strictEqual(y.headers.get('cache-control'), 'private, max-age=60');
-- assert.deepStrictEqual(Object.keys(y.json()).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');
-- y.json().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.json().next_cursor, null, 'one page holds the whole index');
-- assert.deepStrictEqual(y.json().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.json().resources.map((r) => r.service))], ['codes']);
-- assert.deepStrictEqual([...new Set(y.json().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 aPage()).json().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');
-- for (const s of (await aPage(`?project=${prjA}`)).json().resources) assert.strictEqual(s.project_id, prjA);
-- });
--
-- await check('?kind= narrows to one kind; an unknown kind is an empty page, not an error', async () => {
-- assert.deepStrictEqual((await idsOf('?kind=codes.manifest')).sort(), [mfsA1, mfsA2, mfsB].sort());
-- assert.deepStrictEqual((await idsOf('?kind=codes.release')).sort(), [relA1, relA2, relB].sort());
-- assert.deepStrictEqual((await idsOf(`?project=${prjA}&kind=codes.release`)).sort(), [relA1, relA2].sort(), 'kind narrows within the project');
-- assert.deepStrictEqual(await idsOf('?kind=codes.unknown'), []);
-- });
--
-- await check('an opaque cursor pages the whole index: no duplicates, none skipped, order preserved', async () => {
-- const seen = [];
-- let cursor = null;
-- let pages = 0;
-- for (;;) {
-- const y = await aPage(`?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
-- seen.push(...y.json().resources.map((r) => r.id));
-- if (y.json().next_cursor === null) { assert.strictEqual(seen.length, expected.length, 'the cursor chain ends at the end'); break; }
-- assert.ok(typeof y.json().next_cursor === 'string' && y.json().next_cursor !== '');
-- cursor = y.json().next_cursor;
-- if (++pages > 50) assert.fail('the cursor chain never ended');
-- }
-- assert.deepStrictEqual(seen, expected.map((r) => r.id));
-- assert.deepStrictEqual(await idsOf('?limit=1000'), expected.map((r) => r.id), 'the maximum limit still pages one result');
-- });
--
-- await check('GET /:ovrn reads one resource by its computed name', async () => {
-- const listed = new Map((await aPage()).json().resources.map((r) => [r.id, r]));
-- for (const [why, name, id] of [['a manifest', ovrnOfManifest(prjA, mfsA1), mfsA1], ['a release', ovrnOfRelease(prjA, relA1), relA1]]) {
-- const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);
-- assert.strictEqual(y.status, 200, `${why}: ${y.text.slice(0, 300)}`);
-- assert.strictEqual(y.headers.get('cache-control'), 'private, max-age=60');
-- assert.ok(validate('common.resource-summary@1', y.json()).valid);
-- assert.deepStrictEqual(y.json(), listed.get(id), `${why}: the same summary the list answers`);
-- }
-- });
--
-- await check('an unknown or non-matching OVRN is 404 resources.unknown_resource', async () => {
-- const missing = [
-- ['an unknown manifest', ovrnOfManifest(prjA, ids.newId('manifest'))],
-- ['an unknown release', ovrnOfRelease(prjA, ids.newId('release'))],
-- ["another project's resource", ovrnOfRelease(prjA, relB)],
-- ['a type this service does not hold', `ovrn:codes:${prjA}:object/${mfsA1}`],
-- ['an id of the other kind', ovrnOfRelease(prjA, mfsA1)],
-- ['a non-OVRN', 'nope'],
-- ];
-- for (const [why, name] of missing) {
-- const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);
-- assert.strictEqual(y.status, 404, why);
-- assert.strictEqual(y.headers.get('content-type'), 'application/problem+json', why);
-- assert.strictEqual(y.json().code, 'resources.unknown_resource', why);
-- }
-- });
--
-- await check('a query that cannot be honoured is 400 resources.bad_query', async () => {
-- const bad = [['?project=nope', 'project not a prj_ id'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
-- for (const [q, why] of bad) {
-- const y = await get(`/api/v1/resources${q}`, auth);
-- assert.strictEqual(y.status, 400, why);
-- assert.strictEqual(y.headers.get('content-type'), 'application/problem+json', why);
-- assert.strictEqual(y.json().code, 'resources.bad_query', why);
-- }
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/scope-editor.test.js b/test/scope-editor.test.js
ddeleted file mode 100644
iindex 76a35c1..0000000
---- a/test/scope-editor.test.js
++++ /dev/null
@@@ -1,96 +0,0 @@
--'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, Codes 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 Codes 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, /codes\.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 Codes');
-- });
--
-- 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
ddeleted file mode 100644
iindex a16165c..0000000
---- a/test/secrets.test.js
++++ /dev/null
@@@ -1,127 +0,0 @@
--'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
-- * Codes' 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, /Codes 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, /codes_oauth=.*HttpOnly/i);
-- });
--
-- await t.close();
-- done();
--})();
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
ddeleted file mode 100644
iindex fc28781..0000000
---- a/test/security-idor.test.js
++++ /dev/null
@@@ -1,87 +0,0 @@
--'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 (Codes asks with the
-- * person's own token, so Network's rules apply); releases, trust tiers and playground runs are
-- * Codes' 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
-- * Codes 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
ddeleted file mode 100644
iindex d2c959a..0000000
---- a/test/security-private.test.js
++++ /dev/null
@@@ -1,72 +0,0 @@
--'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 event_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
iindex a106b21..8afb30a 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -1,13 +1,11 @@
'use strict';
/**
* Codes' 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 Codes' 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.
++ * internal-secret class). Codes' environment holds one: its Network OAuth client secret, a sentinel here. Every route
++ * the booted app has (listed from Express's router stack) is requested as anonymous, a signed-in person and a Network
++ * admin with real and nonsense ids, plus the sign-in callback with a forged code (it posts the client secret to
++ * Network), the developer console's old addresses (now redirects), unknown paths and every write route with a broken
++ * body. No body or header may carry it, nor may the database or the captured log lines.
*
* node test/security-secrets.test.js
*/
@@@ -19,21 +17,20 @@ appModule.createApp = async (...a) => { const built = await realCreate(...a); ca
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-codes-oauth-client', CODES_FORM_SECRET: 'sentinel-not-a-secret-codes-form' };
++const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-client' };
(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 values = () => ['claude-code', ...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/%'],
++ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%',
++ '/projects', '/docs/api', '/oauth/test-callback?code=x', '/api/v1/releases/rel_x', '/docs/harnesses'],
});
const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);
assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);
@@@ -42,7 +39,7 @@ const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-cli
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']);
++ const values = () => ['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)) {
@@@ -56,7 +53,7 @@ const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-cli
assert.deepStrictEqual(found, []);
});
-- await check('the outbox and the captured log lines carry no sentinel', async () => {
++ await check('the database 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`);
});
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex af2ea34..e7b435c 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -12,7 +12,6 @@ const { signJwt } = require('./helpers/mocks');
(async () => {
const s = await boot();
const staff = s.network.addUser('stafferson');
-- s.ctx.config.staffSubjects.push(staff.subject); // as CODES_STAFF_SUBJECTS would
const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';
const now = () => Math.floor(Date.now() / 1000);
@@@ -22,14 +21,12 @@ const { signJwt } = require('./helpers/mocks');
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 Codes session (cannot act as staff)', async () => {
++ await check('a FedCM assertion is not a Codes session (it signs nobody in)', async () => {
const cookie = `codes_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');
++ const me = await s.get('/auth/me', { cookie });
++ assert.strictEqual(me.status, 401, 'an assertion for another site is not a session here');
++ const page = await s.get('/', { cookie });
++ assert.ok(!page.text.includes(`Signed in as <strong>${staff.username}`), 'the page shows nobody signed in');
});
await check('an app token is not a Codes session', async () => {
@@@ -48,12 +45,10 @@ const { signJwt } = require('./helpers/mocks');
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('a real Network session token signs the person in', async () => {
++ const me = await s.get('/auth/me', { as: staff });
++ assert.strictEqual(me.status, 200, me.text.slice(0, 200));
++ assert.strictEqual(JSON.parse(me.text).user.username, staff.username);
});
await check('another site cannot sign a person out; this site can', async () => {
@@@ -63,9 +58,9 @@ const { signJwt } = require('./helpers/mocks');
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' } });
++ const same = await s.get('/auth/logout?next=/improve', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });
assert.strictEqual(same.status, 303);
-- assert.strictEqual(same.headers.get('location'), '/projects');
++ assert.strictEqual(same.headers.get('location'), '/improve');
assert.match(String(same.headers.get('set-cookie') || ''), /codes_at=;/);
});
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 82f0569..bbbbef0 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -1,12 +1,10 @@
'use strict';
/**
-- * Codes fetches no URL a developer typed (roadmap WS-R task 5, the SSRF class). The URLs developers
-- * give Codes (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.
++ * Codes fetches no URL anyone typed (roadmap WS-R task 5, the SSRF class). The values a caller can hand Codes (a
++ * routing request's requirements, sign-in's next=, the developer console's old addresses that now redirect) are read
++ * or echoed into a redirect, never requested: every outbound request of the process is recorded while requests full
++ * of internal addresses in every spelling go through them, 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
*/
@@@ -18,7 +16,6 @@ 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';
@@@ -28,35 +25,22 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${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;
++ await check('routing requests, sign-in and the moved addresses full of internal URLs are never fetched', async () => {
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: 'codes.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('/api/v1/harnesses/route', { as: dev, json: { task: 'edit', requirements: { capabilities: [u], url: u, endpoint: u } } });
++ await t.get(`/auth/login?next=${encodeURIComponent(u)}`);
++ await t.get(`/projects?url=${encodeURIComponent(u)}`);
await t.get(`/tools/webhooks?url=${encodeURIComponent(u)}`);
++ await t.get('/api/v1/releases', { method: 'POST', json: { url: u } });
}
const hit = outbound.filter((u) => u.includes(PROBE));
-- assert.deepStrictEqual(hit, [], 'Codes fetched a URL a developer typed');
++ assert.deepStrictEqual(hit, [], 'Codes fetched a URL a caller 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/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 = [];
@@@ -71,7 +55,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
}
};
walk(path.join(root, 'server'));
-- assert.ok(found.length >= 4, `the scan finds the known sites (${found.join(', ')})`);
++ assert.ok(found.includes('server/auth/sso.js'), `the scan finds the known site (${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');
});
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
iindex bbb77b0..5cf1080 100644
---- a/test/service-kit.test.js
++++ b/test/service-kit.test.js
@@@ -1,7 +1,7 @@
'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.
++ * (the JWKS refresher stops, then store.close) and resolves exit 0; a second signal while stopping is a no-op.
*/
const assert = require('assert');
const http = require('http');
@@@ -11,10 +11,10 @@ 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 () => {
++ await check('the entry point stop stops the JWKS refresher then closes the store, then exits 0', async () => {
const steps = [];
const ctx = {
-- outbox: { stop: async () => { steps.push('outbox.stop'); } },
++ keys: { client: { stop: () => { steps.push('keys.stop'); } } },
store: { close: async () => { steps.push('store.close'); } },
};
const server = http.createServer((req, res) => res.end('ok'));
@@@ -26,14 +26,14 @@ const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1'
assert.strictEqual(code, 0);
assert.deepStrictEqual(exits, [0]);
-- assert.deepStrictEqual(steps, ['outbox.stop', 'store.close']);
++ assert.deepStrictEqual(steps, ['keys.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; } },
++ keys: { client: { stop: () => { stops += 1; } } },
store: { close: async () => { closes += 1; } },
};
const server = http.createServer((req, res) => res.end('ok'));
ddiff --git a/test/staff-map.test.js b/test/staff-map.test.js
ddeleted file mode 100644
iindex 95f96c3..0000000
---- a/test/staff-map.test.js
++++ /dev/null
@@@ -1,28 +0,0 @@
--'use strict';
--// Staff powers come from the openvibe-contracts staff map (ADR-022): staff.site.configure makes a person Codes staff (CODES_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
ddeleted file mode 100644
iindex 7f0f21c..0000000
---- a/test/webhook-browser.test.js
++++ /dev/null
@@@ -1,96 +0,0 @@
--'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
ddeleted file mode 100644
iindex c300c6d..0000000
---- a/test/webhooks.test.js
++++ /dev/null
@@@ -1,217 +0,0 @@
--'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();
--})();