openvibers/openvibe.host Commits
Stage B launch readiness: threat review, launch release steps with verification and rollback, and the launch-rule evidence per item
Changes
@@@ -2,7 +2,7 @@> The network's deployment/control plane first; then isolated hosting for community sites, bots and mods.--**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It is installed on the production host (2026-09-23, `/usr/local/bin/ovhost`, inventory `/etc/openvibe/host.json`) and used read-only (`status`, `validate`); no service's deploy has moved to it yet. Stage B (tenant static hosting) is a service, written and tested, **not deployed**. Stage C (sandboxed user code) is **not started**.++**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It is installed on the production host (2026-09-23, `/usr/local/bin/ovhost`, inventory `/etc/openvibe/host.json`) and used read-only (`status`, `validate`); no service's deploy has moved to it yet. Stage B (tenant static hosting) is a service, written and tested; it runs on the host on loopback `:4910` (2026-09-23 audit) and is **not launched**: `openvibe.host` is still the Sites placeholder and `*.openvibe.host` answers 404. The launch is ready to run: [docs/launch.md](docs/launch.md) (steps, verification, rollback and the launch-rule evidence) and [docs/threat-review.md](docs/threat-review.md). Stage C (sandboxed user code) is **not started**.**Domain:** `openvibe.host` (dashboard and API) and `*.openvibe.host` (tenant sites). The domain keeps its placeholder page on [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until Stage B is deployed and launched (see [Launch rule](#launch-rule)).**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3 and §15.17; roadmap Wave 21.**License:** AGPL-3.0 (same as every OpenVibe service).@@@ -46,7 +46,7 @@ Stage A adds a few safety rules of its own:## Depends on--- OpenVibe.Contracts (`openvibe-contracts` v0.19.0): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.++- OpenVibe.Contracts (`openvibe-contracts` v0.24.0; `host.*` capabilities and the `host` manifest are released there and unchanged in v0.30.1): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.- OpenVibe.Network (Stage B): SSO for the dashboard, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay.- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.2.2 transactional outbox.- `openvibe-shared` v1.3.0 (Stage B): shared chrome, legal pages, `/release.json`, `/metrics`, `/api/ready`.@@@ -240,11 +240,12 @@ People present their Network user JWT as a Bearer token. Services and apps prese| `GET/POST /sites/:id/deploys` · `GET /deploys/:id` · `GET /deploys/:id/log` | `host.deploy.create` | deployer || `POST /deploys/:id/activate` · `POST /sites/:id/rollback` (`{ deploy_id?, expected_active? }`) | `host.deploy.create` | deployer || `DELETE /deploys/:id` (not the active one) | `host.site.manage` | maintainer |++| `POST/DELETE /projects/:id/takedown` · `POST/DELETE /sites/:id/takedown` | `host.site.manage` | Network staff only || `GET/POST /sites/:id/domains` · `POST /domains/:id/verify` · `DELETE /domains/:id` | `host.domain.manage` | maintainer (reads: member) |--Errors are RFC 9457 problems (`application/problem+json`, with the legacy `error` field). A refused upload answers 413/422 with `deploy_id` and the log lines. Network staff (`role: admin`) can read every project, delete sites, projects and domains, and set quotas; they cannot publish into a tenant's site.++Errors are RFC 9457 problems (`application/problem+json`, with the legacy `error` field). A refused upload answers 413/422 with `deploy_id` and the log lines; `503 upload.busy` (with `Retry-After`) when `HOST_MAX_CONCURRENT_UPLOADS` uploads are already being validated; `507 storage.host_full` while the disk has less than `HOST_MIN_FREE_BYTES` free. Network staff (`role: admin`) can read every project, site, deploy and log, delete sites, projects and domains, set quotas, and **take a site or project down** (451 on every host, content kept for review, members see the reason and cannot publish or delete around it); they cannot publish into a tenant's site.--The three capability ids are proposals in [`docs/capabilities-proposal/`](docs/capabilities-proposal/) together with an updated [`docs/service-manifest-proposal.json`](docs/service-manifest-proposal.json). Until the lead releases them in `openvibe-contracts`, Host decides grants for exactly these ids with the library's own matching rule (`server/auth/capabilities.js`), and the CI contract check is non-blocking.++The three capability ids and the service manifest are released in `openvibe-contracts` (since v0.24.0, identical in v0.30.1), and the CI contract check is blocking. [`docs/capabilities-proposal/`](docs/capabilities-proposal/) mirrors them; `host.site.manage` there also lists the four takedown routes, which the next Contracts release should add (see [docs/launch.md](docs/launch.md#the-window-in-this-order)).### Activation and rollback@@@ -276,7 +277,7 @@ Server-rendered pages with the shared chrome (`openvibe-shared` v1.3.0: navbar a- OpenVibe.Network OAuth client **`host`**, redirect `https://openvibe.host/auth/callback`, scope `profile theme`. The same client is the service principal `svc:host`.- Grant `[host, events.event.publish, openvibe.events]`.- Callers of Host get `[<client>, host.site.manage | host.deploy.create | host.domain.manage, openvibe.host]` as needed. None exist yet (Codes, the expected first caller, is not built).--- Release `docs/capabilities-proposal/*.json` and `docs/service-manifest-proposal.json` in the next `openvibe-contracts`, then make the CI contract check blocking again.++- Released in `openvibe-contracts` v0.24.0 (CI contract check blocking). Next release: add the takedown routes to `host.site.manage.implementedBy` (docs/launch.md).### Deploying Stage B (for the operator)@@@ -290,7 +291,7 @@ Nothing here has been done yet.6. **Unit.** `sudo cp deploy/systemd/openvibe-host.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-host`, then `curl -s http://127.0.0.1:4910/api/ready`.7. **nginx.** Add the `host` entry from [`host.example.json`](host.example.json) to `/etc/openvibe/host.json`, update `/usr/local/lib/openvibe-host`, then `sudo ovhost nginx tenants host` (review) and `sudo ovhost nginx tenants host --install` (writes `openvibe.host.conf` and `openvibe.host-custom-domains.conf`, `nginx -t`, reload; restored on failure). [`deploy/nginx/openvibe.host.conf`](deploy/nginx/openvibe.host.conf) is a reference copy.8. **Custom domains** (whenever tenants verify one): `sudo ovhost nginx tenants host` lists the verified domains still waiting for a certificate. For each, `sudo certbot certonly --webroot -w /var/www/certbot -d <hostname>` (the generated port-80 block already answers the ACME challenge), then `sudo ovhost nginx tenants host --install` again. Until then, the domain is verified but only answers over HTTP with the challenge. Certificates never pass through the Host API.--9. **Launch.** The Sites placeholder stays until the launch rule below holds and the lead switches it. Until then [`deploy/nginx/openvibe.host-tenants-pending.conf`](deploy/nginx/openvibe.host-tenants-pending.conf) answers every `*.openvibe.host` name with a 404 over the wildcard certificate (installed 2026-09-23; without it the wildcard fell through to nginx's default server). Remove it in the same change that installs the tenant vhost.++9. **Launch.** Follow [docs/launch.md](docs/launch.md). Until then [`deploy/nginx/openvibe.host-tenants-pending.conf`](deploy/nginx/openvibe.host-tenants-pending.conf) answers every `*.openvibe.host` name with a 404 over the wildcard certificate (installed 2026-09-23; without it the wildcard fell through to nginx's default server). `ovhost nginx tenants host --install` removes it in the same `nginx -t` and reload that installs the tenant vhost (`nginx.tenants.replaces`), and puts it back if the test fails.### Not done yet (Stage B)@@@ -298,9 +299,10 @@ Nothing here has been done yet.- Objects live on the service's local disk, not in OpenVibe.Media; there is no replication and no backup beyond `ovhost backup host` (the SQLite database only).- Certificates for custom domains are issued by hand (step 8); there is no automatic ACME flow.- Projects are created in Host. When Network has projects (ADR-014), Host should accept only Network project ids and read membership from Network.--- `openvibe.host` is not on the Public Suffix List. Listing it would make each tenant its own site in browsers, but it would also make `openvibe.host` itself a public suffix, which affects the dashboard's cookies. Evaluate before submitting. The protections above do not depend on it.++- `openvibe.host` is not on the Public Suffix List, so a tenant page can still set `Domain=openvibe.host` cookies (a cookie bomb breaks the dashboard and other tenant sites for that visitor). The protections above do not depend on it. [docs/threat-review.md §5](docs/threat-review.md#5-the-public-suffix-list-question) records the decision (launch without it) and the recommended follow-up (dashboard off the tenant zone, then list the zone).++- Tenant objects are not in `ovhost backup` (only `host.db`).- No per-site headers, redirects or SPA fallback configuration; no preview deploys; no deploy of a Git repository (uploads only).--- No sitemap/robots/feed behaviour for tenant sites beyond what tenants upload themselves.++- Tenant sites have no Host-provided sitemap/robots/feed: they publish their own (sandbox projects are `noindex`). The dashboard host serves `/robots.txt` and `/sitemap.xml` (front page and legal pages).- The Codes portal (Wave 20) does not exist, so there is no public developer onboarding for Host yet.### Acceptance (what `npm test` demonstrates for Stage B)@@@ -312,7 +314,9 @@ Nothing here has been done yet.- **Custom domains** (`test/host-domains.test.js`): not served until verified; wrong or missing TXT stays pending; first verified proof wins; OpenVibe domains refused; removal and site deletion stop serving; background verification, pending expiry and lapse.- **No secrets in responses** (`test/host-secrets-dashboard.test.js`): no API, dashboard, readiness or event body contains the OAuth client secret, the form secret, an uploaded `.env`'s values or any credential-like string; response objects are allowlisted; plus the dashboard (chrome, forms, CSRF against same-site tenant origins, planted cookies) and machine endpoints.- **Lifecycle, headers, events** (`test/host-lifecycle.test.js`, `test/host-contracts-events.test.js`): content types, ETag/304, ranges, immutable caching, CSP, nosniff, custom 404, 405; valid event envelopes delivered by the relay with a Network service token; the proposals match the guarded routes.--- **Tenant vhosts** (`test/nginx-tenants.test.js`): the wildcard vhost with the certificate path parameter, HTTPS only for verified domains that have a certificate, hostile database values refused before they reach nginx, transactional install, no key file read.++- **Tenant vhosts** (`test/nginx-tenants.test.js`): the wildcard vhost with the certificate path parameter, HTTPS only for verified domains that have a certificate, hostile database values refused before they reach nginx, transactional install that removes the pending vhost in the same change, `www.` redirect, client address headers from `$remote_addr` only, no key file read.++- **Abuse controls** (`test/host-abuse.test.js`): staff takedowns (451 with Clear-Site-Data on every host, content kept, members cannot publish or delete around them, lift), certificate-validation paths refused, uploads validated two at a time (503 + Retry-After), the disk floor (507), the per-person project cap, fingerprinted assets capped at an hour at a CDN.++- The isolation test also covers cross-origin reads from a tenant page (no CORS grant anywhere), ETag/Range existence oracles, delegated service tokens, app principals and the dashboard.Not demonstrated yet: any of this on the production host.@@@ -346,6 +350,11 @@ The launch release removes the domain from `OpenVibe.Sites/sites.json`, switchesregisters maturity in the ecosystem registry atomically. A placeholder is never counted as animplemented service.++**Stage B against this rule (2026-09-23):** every item is met in code and tests. The evidence per++item, the exact launch steps (tenant vhost with the pending vhost removed in the same change, Sites,++Network registry), verification and rollback are in [docs/launch.md](docs/launch.md); the threat++review is [docs/threat-review.md](docs/threat-review.md). The launch has not been run.++---Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).@@@ -9,7 +9,7 @@"domain": "openvibe.host","tenantDomain": "*.openvibe.host","packages": [-- "openvibe-contracts v0.19.0",++ "openvibe-contracts v0.24.0 (host.* capabilities and manifest released; unchanged in v0.30.1)","openvibe-sdk v0.2.2","openvibe-shared v1.3.0"],@@@ -18,8 +18,8 @@"host.deploy.create","host.domain.manage"],-- "capabilitiesRegistered": false,-- "serviceManifestRegistered": "stage A manifest only (v0.10.0); the Stage B proposal is in docs/service-manifest-proposal.json",++ "capabilitiesRegistered": true,++ "serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events","events": ["host.deploy.created","host.deploy.activated",@@@ -27,15 +27,17 @@"host.domain.verified"],"stages": {-- "A": "alpha: ovhost CLI installed on the production host 2026-09-23 (/usr/local/bin/ovhost, inventory /etc/openvibe/host.json); used read-only (status, validate) so far — no service deploy has moved to it; restore drills (`ovhost drill`, Wave 22) written and tested against the fake host, not yet run on the host",-- "B": "alpha: tenant static hosting service written and tested (tenant isolation, traversal/symlink refusal, quotas, atomic rollback, TXT-verified custom domains, no secrets in responses); not deployed",++ "A": "alpha: ovhost CLI installed on the production host 2026-09-23 (/usr/local/bin/ovhost, inventory /etc/openvibe/host.json); status/validate/backup/drill used there (19 restore drills passed 2026-09-23, docs/restore-drills.md); no service deploy has moved to it yet; the D41 deploy-and-rollback proof (scripts/d41-proof.sh) is written and tested, not run",++ "B": "alpha: running on the host on loopback :4910 (2026-09-23 audit), not launched; launch-rule items met in code and tests, launch steps in docs/launch.md, threat review in docs/threat-review.md","C": "not started (deliberately: blocked until isolation and metering are proven)"},"artifactStorage": "local disk, content-addressed per project (not OpenVibe.Media yet)","customDomainCertificates": "operator-issued (certbot), not automated","installedOnHost": true,-- "deployed": false,++ "deployed": "loopback only, not launched","placeholderInSites": true,"adoptedByServices": [],-- "updated": "2026-09-22"++ "updated": "2026-09-23",++ "capabilitiesNote": "released in openvibe-contracts v0.24.0 and identical in v0.30.1; the next release should add the four takedown routes to host.site.manage.implementedBy (docs/launch.md)",++ "launchReady": true}nnew file mode 100644@@@ -0,0 +1,262 @@++# Launching Stage B at openvibe.host++++This is the launch release the [Launch rule](../README.md#launch-rule) describes. One change window:++++- `*.openvibe.host` starts serving tenant sites;++- `openvibe.host` switches from the OpenVibe.Sites placeholder to the Host dashboard;++- the interim tenants-pending vhost goes;++- the domain leaves `OpenVibe.Sites/sites.json`;++- the Network registry says Host is live.++++**Nothing here has been done.** The launch needs the owner's go and the checks below.++++The readiness evidence is in [Launch readiness](#launch-readiness-2026-09-23) at the end of this++file, and the threat review is in [threat-review.md](threat-review.md).++++## Before the window++++State on 2026-09-23 (audit of 18:15 UTC, not re-checked here):++++- The Host API runs on the host on loopback `:4910` with `/api/ready` 200, and 0 projects.++- `openvibe.host` is served by the Sites placeholder vhost `/etc/nginx/sites-available/openvibe.host.conf`++ (root `/opt/openvibe.sites/dist/openvibe.host`).++- `/etc/nginx/sites-available/openvibe.host-tenants-pending.conf` answers every `*.openvibe.host`++ with a 404.++- The wildcard certificate `/etc/letsencrypt/live/openvibe.host/` expires 2026-12-17.++- `*.openvibe.host` is proxied by Cloudflare; `cname.openvibe.host` is DNS-only.++++Prepare these commits, pushed but **not deployed**:++++1. **OpenVibe.Host:** this branch (`1e48cb2` and later): takedowns, bounded uploads, the disk floor,++ realip headers, `--install` removing the pending vhost, robots/sitemap.++2. **OpenVibe.Sites:** remove `openvibe.host`, the way `d3b71af` removed `openvibe.codes`:++ - delete the `openvibe.host` entry from `sites.json`;++ - run `node build.js`;++ - commit the removal of `deploy/nginx/openvibe.host.conf` and `dist/openvibe.host/`, and the++ one-line cross-link change in every other `dist/*/index.html`;++ - adjust `test/build.test.js` if it counts sites.++++ `npm test` must pass. **Do not run Sites' `deploy.sh` until step 4.** Until this commit is pulled++ on the host, a Sites deploy would put its placeholder vhost back over Host's (same file name).++3. **OpenVibe.Network:** flip the exposure overlay.++ - `server/registry/exposure.js`: change `host: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER }`++ to `host: { state: 'live', public_site: 'service', note: 'dashboard and API at openvibe.host; tenant sites at <site>.openvibe.host' }`.++ - `test/registry-exposure.test.js`:++ - `PINNED.host = 'live'`;++ - remove `'host'` from `PLACEHOLDER_DOMAINS` and from the list of ids that must be `soon`;++ - add `'host'` to the expected `open` nav ids, in the order `server/chrome/sites.js` lists them.++++ `npm test` must pass.++++Run the pre-flight on the host. Everything here is read-only; stop if any line disagrees:++++```bash++sudo ovhost status host # openvibe-host active, ready++curl -s http://127.0.0.1:4910/api/ready | jq '{status, failed}'++sudo ovhost validate host # required env NAMES set/empty (never values), unit, port 4910, deps++sudo ovhost certs | grep -E 'openvibe\.host' # the wildcard, valid, covers openvibe.host + *.openvibe.host++dig +short openvibe.host; dig +short x.openvibe.host; dig +short cname.openvibe.host++sudo nginx -T 2>/dev/null | grep -E 'real_ip_header|set_real_ip_from' | head -3 # realip is configured++ls -l /etc/nginx/sites-enabled/ | grep openvibe.host # openvibe.host.conf (Sites) + openvibe.host-tenants-pending.conf++df -h /var/lib/openvibe-host-api # well above 5 GiB free (HOST_MIN_FREE_BYTES)++curl -s https://openvibe.network/.well-known/openvibe | jq '.services[] | select(.id=="host")'++```++++`/etc/openvibe/host.env` must have, by name (see `.env.example`):++++- `BASE_URL=https://openvibe.host`++- `HOST_SITES_DOMAIN=openvibe.host`++- `HOST_CNAME_TARGET=cname.openvibe.host` (the DNS-only name; custom domains cannot CNAME to a++ proxied name)++- `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID=host` and a non-empty++ `OV_OAUTH_CLIENT_SECRET`++- a non-empty `HOST_FORM_SECRET`++- `EVENTS_URL`++- `TRUST_PROXY=1` or no `TRUST_PROXY` at all. Earlier copies of `.env.example` said 2. With the++ realip-only headers either value is safe, and 1 is correct.++++Optionally set `HOST_MAX_PROJECTS_PER_OWNER` and lower `HOST_QUOTA_STORAGE_BYTES` for the first++weeks.++++Save what the launch replaces, so the rollback is a copy:++++```bash++B=/root/host-launch-$(date -u +%Y%m%d-%H%M%S); sudo mkdir -m 700 "$B"++sudo cp -a /etc/nginx/sites-available/openvibe.host.conf /etc/nginx/sites-available/openvibe.host-tenants-pending.conf "$B"/++sudo ovhost backup host++echo "$B"++```++++**Optional step 0 (host-wide, reviewed separately).** This addresses a threat-review finding:++unverified custom domains reach nginx's default server. If `sudo nginx -T | grep default_server`++shows no catch-all, add one before tenants can point domains at the host. Test it with++`curl -sk --resolve nobody.example:443:127.0.0.1 https://nobody.example/`, which should fail the++handshake.++++```nginx++server { listen 80 default_server; listen [::]:80 default_server; server_name _; return 444; }++server { listen 443 ssl default_server; listen [::]:443 ssl default_server; server_name _; ssl_reject_handshake on; }++```++++## The window (in this order)++++**1. Update the `ovhost` CLI, which carries `replaces` and the templates.**++++```bash++sudo git -C /usr/local/lib/openvibe-host -c safe.directory=/usr/local/lib/openvibe-host pull --ff-only++sudo ovhost show host # nginx.tenants in /etc/openvibe/host.json: keep "replaces": ["openvibe.host-tenants-pending.conf"] (also the default)++```++++**2. Deploy the Host API code.** Nobody uses it yet, and this is also the first real `ovhost deploy`:++++```bash++sudo ovhost plan host++sudo ovhost deploy host # restarts openvibe-host only, polls /api/ready, rolls back by itself if not ready++curl -s http://127.0.0.1:4910/api/ready | jq '.checks' # includes disk_headroom++curl -s -H 'Host: openvibe.host' http://127.0.0.1:4910/robots.txt # Allow: /$ … Sitemap: https://openvibe.host/sitemap.xml++```++++If the pre-flight changed `/etc/openvibe/host.env`, run `sudo systemctl restart openvibe-host` and++check readiness again.++++**3. Install the tenant vhost, and remove the pending vhost in the same change.**++++```bash++sudo ovhost nginx tenants host # review: openvibe.host.conf + openvibe.host-custom-domains.conf;++ # "--install also removes … openvibe.host-tenants-pending.conf"++sudo ovhost nginx tenants host --install # writes both, removes the pending vhost, ONE nginx -t, ONE reload;++ # a failed nginx -t restores all three files and reloads nothing++ls -l /etc/nginx/sites-enabled/ | grep openvibe.host # openvibe.host.conf, openvibe.host-custom-domains.conf; no pending++```++++`openvibe.host.conf` replaces the Sites placeholder vhost of the same name. The previous copy is in++`$B`.++++**4. Sites.** Pull the commit that removed `openvibe.host`, then deploy. `deploy.sh` installs only++the vhosts still in the repository, so it never touches `openvibe.host.conf` again:++++```bash++sudo -u ubuntu git -C /opt/openvibe.sites pull --ff-only++/opt/openvibe.sites/deploy/scripts/deploy.sh++```++++**5. The Network registry.** Deploy the exposure commit the way Network is normally deployed (pull,++restart `openvibe-network`, poll `/api/ready`).++++**6. Host's own records.** In a follow-up Host commit, set README **Status**, `STATUS.json`++(`"deployed"`, `"placeholderInSites": false`) and the Launch rule note to *launched on <date>*.++++**Contracts** (no runtime effect, whenever the next release is cut): in++`manifests/capabilities/host.site.manage.json`, add the takedown routes and sentence from this++repository's `docs/capabilities-proposal/host.site.manage.json`:++++- `POST /api/v1/projects/:id/takedown`++- `DELETE /api/v1/projects/:id/takedown`++- `POST /api/v1/sites/:id/takedown`++- `DELETE /api/v1/sites/:id/takedown`++++Optionally add payload schemas `contracts/events/payloads/host.deploy.created|activated|failed.v1.json`++and `host.domain.verified.v1.json`, from the payloads in README [Events](../README.md#events).++++## Verify++++```bash++# The dashboard, not the placeholder++curl -s https://openvibe.host/ | grep -c 'Sign in with OpenVibe' # 1++curl -s https://openvibe.host/ | grep -ci 'this page is a placeholder' # 0++curl -s https://openvibe.host/ | grep -o '<meta name="robots"[^>]*>' # index, follow++curl -s https://openvibe.host/robots.txt; curl -s https://openvibe.host/sitemap.xml++curl -s https://openvibe.host/api/health; curl -s https://openvibe.host/api/ready | jq .status++curl -s -o /dev/null -w '%{http_code}\n' https://openvibe.host/metrics # 404++curl -sI https://www.openvibe.host/some/path | grep -i '^location' # https://openvibe.host/some/path++# Tenant zone: Host answers, the pending vhost is gone++curl -s https://no-such-site-xyz.openvibe.host/ # "Unknown host: no site is served at this address."++curl -s -X POST https://no-such-site-xyz.openvibe.host/ -o /dev/null -w '%{http_code}\n' # 403 (nginx: GET/HEAD only)++# Everything else unchanged++for u in https://openvibe.live/api/ready https://openvibe.network/api/ready https://openvibe.codes/ https://openvibe.news/; do curl -s -o /dev/null -w "%{http_code} $u\n" "$u"; done++# Registry++curl -s https://openvibe.network/.well-known/openvibe | jq '.services[] | select(.id=="host") | {status, exposure}'++curl -s https://openvibe.network/api/v1/status | jq '.services[] | select(.id=="host")'++```++++Then run one real tenant deploy and an isolation check in production (audit item 9), in a browser++signed in as an ordinary account:++++1. At `https://openvibe.host/`, create a **sandbox** project `launch-smoke` and a site++ `launch-smoke-<yyyymmdd>`. Upload a folder with an `index.html` and a `secret.html`.++2. Check the site:++ - `curl -sI https://launch-smoke-<yyyymmdd>.openvibe.host/` returns 200, `x-robots-tag: noindex`,++ a CSP and no `set-cookie`;++ - `curl -s https://launch-smoke-<yyyymmdd>.openvibe.host/secret.html` returns the file.++3. With a second account, create a second sandbox site. From it:++ - `curl -s --path-as-is https://<second>.openvibe.host/../secret.html` returns 404;++ - `curl -s 'https://<second>.openvibe.host/%2e%2e/secret.html'` returns 404;++ - `curl -s --path-as-is https://<second>.openvibe.host/../../host.db` returns 404;++ - neither answer contains the first site's content.++4. As the second account, `GET https://openvibe.host/api/v1/sites/<first site id>` with its Bearer++ token returns 404.++5. As staff:++ - `POST /api/v1/sites/<first>/takedown` with `{"reason":"launch smoke"}`, then the site answers++ 451 with `clear-site-data`;++ - `DELETE …/takedown`, and it serves again;++ - delete both sandbox projects.++6. Record the result in a row in [restore-drills.md](restore-drills.md)'s style, or in the launch++ commit message.++++## Roll back++++The rollback restores the old state in this order. Each step is independent and takes seconds.++++**1. nginx.** Put the placeholder and the pending vhost back, and remove the tenant vhosts:++++```bash++B=/root/host-launch-<stamp>++sudo install -m 644 "$B/openvibe.host.conf" /etc/nginx/sites-available/openvibe.host.conf++sudo install -m 644 "$B/openvibe.host-tenants-pending.conf" /etc/nginx/sites-available/openvibe.host-tenants-pending.conf++sudo ln -sf /etc/nginx/sites-available/openvibe.host-tenants-pending.conf /etc/nginx/sites-enabled/++sudo rm -f /etc/nginx/sites-enabled/openvibe.host-custom-domains.conf /etc/nginx/sites-available/openvibe.host-custom-domains.conf++sudo nginx -t && sudo systemctl reload nginx++curl -s https://openvibe.host/ | grep -ci 'placeholder' # ≥ 1 once Sites' dist/openvibe.host is back (step 2)++```++++**2. Sites.** Revert the removal commit, then pull and run `deploy.sh`. This rebuilds++`dist/openvibe.host` and reinstalls `deploy/nginx/openvibe.host.conf`, the same file as `$B`'s.++++**3. Network.** Revert the exposure commit and deploy it.++++**4. Host API code, only if the code is the problem.** Run `sudo ovhost rollback host`. It goes to++the release before the launch deploy (release log) and restarts `openvibe-host` only.++++Tenant data needs no rollback. Projects, deploys and objects stay in `host.db` and the object store,++and they serve again at the next launch. If tenants existed during the window, tell them their sites++are paused.++++## Launch readiness (2026-09-23)++++Each launch-rule item, checked against the code at this commit:++++| # | Launch-rule item | Evidence | Verdict |++|---|---|---|---|++| 1 | Owning runtime with health/readiness and observability | `server/index.js` + `deploy/systemd/openvibe-host.service`. `GET /api/health`, `/api/ready` (`server/observability.js`: required `db`, `storage`; optional `disk_headroom`, `network_jwks`, `events_relay`, `domain_checks`), `/release.json`, `/metrics` (loopback only; HTTP golden signals, process, release, `host_sites`). Running on loopback `:4910` with ready 200 (audit, 2026-09-23). `host-secrets-dashboard` "machine endpoints". | **met** |++| 2 | Canonical identity (Network subjects, scoped principals) | Network SSO OAuth client `host` (Network `server/db/database.js:516`); users are `usr_` subjects from Network JWTs verified against the JWKS (`server/auth/viewer.js`); services present client-credentials tokens for audience `openvibe.host` with one capability per route; `X-OV-Subject` for first-party services only; app principals act as themselves; sandbox tokens refused on production. Principal `svc:host` has `events.event.publish` (Network `server/identity/principals.js:100`). `host-quota-auth` tests. Projects are Host's own until Network has projects (ADR-014, documented). | **met** |++| 3 | SSR/static public routes useful without JS | Every dashboard page is server-rendered; every action is a plain form, including folder upload. Shared chrome with `<noscript>` nav and SSR footer. Legal pages. Tenant sites are static by definition. `host-secrets-dashboard` "signed-out home page … useful without JavaScript". | **met** |++| 4 | Real persistence and end-to-end workflows | SQLite `host.db` (WAL) plus the content-addressed object store; upload → validate → store → activate → serve → rollback → delete, custom domains through verification and lapse, all over HTTP in the tests (`host-lifecycle`, `host-rollback`, `host-domains`). `host.db` is in `ovhost backup` and the restore drill passed on the host (2026-09-23). Objects are not backed up (accepted for alpha, threat review §6). | **met** (objects backup: follow-up) |++| 5 | Capability and event registration against Contracts | `host.site.manage`, `host.deploy.create` and `host.domain.manage` and the `host` service manifest (with all four `host.*` events in `eventsProduced`) are released in `openvibe-contracts` **v0.24.0**, which Host pins, and are identical in **v0.30.1** (checked against the tag: every field matches `docs/capabilities-proposal/` as of `8575c91`). `npx openvibe-contracts-check --service host` passes, and CI runs it as a blocking step. Missing from the release: the four takedown routes in `host.site.manage.implementedBy` (this change; the exact addition is under *Contracts* above). No `host.*` payload schemas: optional, most services have none. | **met** (Contracts follow-up for takedown routes) |++| 6a | Migration/seed strategy | Bootstrap, not migration: Host replaces no legacy data (roadmap §8 register). The schema is created idempotently at every boot (`server/db.js`, `CREATE … IF NOT EXISTS`); `host_takedowns` is additive and appears on the next restart. There is no seed: a new database is an empty, valid Host. The pointer and data survive a restart (`host-rollback`). A future non-additive change needs a migration ledger, which Host does not have yet. | **met** |++| 6b | Security/threat review | [threat-review.md](threat-review.md): tenant isolation, uploads, custom domains, cookies, the PSL decision, quotas, abuse and takedown, Host's own surface. Seven gaps were fixed in `1e48cb2` and `0ff29d5`: takedowns, the upload concurrency cap, the CA-validation path refusal, the CDN cache cap, the realip templates, the disk floor with the owner project cap, and Clear-Site-Data. | **met** |++| 6c | Sitemap/robots/feed behaviour | Dashboard host: `/robots.txt` allows `/` and the legal pages and names `/sitemap.xml`; the signed-out front page is `index, follow` with a canonical URL; everything behind sign-in is `noindex` and `private, no-store`. Tenant sites: their own uploaded `robots.txt`/`sitemap.xml`; sandbox projects get `X-Robots-Tag: noindex`. No feed: a hosting control plane publishes no stream of public items, and tenants publish their own feeds. | **met** |++| 7 | Acceptance tests | `npm test` (20 files). Stage B: `host-isolation`, `host-uploads`, `host-quota-auth`, `host-rollback`, `host-domains`, `host-secrets-dashboard`, `host-lifecycle`, `host-contracts-events`, `host-abuse`, `nginx-tenants`. The exit criterion "a hosted static project cannot read another tenant's objects" is `host-isolation` (path, Host header, CORS, oracles, delegation, dashboard) plus `host-abuse` (CA paths). | **met** in CI; the production isolation check is part of *Verify* |++++**Verdict: ready to launch** through the steps above, once the owner says go. Nothing in the code++blocks it. Before or at launch the owner must:++++- **confirm the abuse contact:** `https://openvibe.host/dmca` names `[email protected]`, which must++ be monitored for Host reports, with a named person to act on them;++- **accept the alpha limits**, or ask for the follow-ups first:++ - tenant objects are not backed up;++ - no Public Suffix List listing, so cookie bombs across tenants remain possible;++- optionally, **approve the host-wide catch-all `default_server`** (step 0). Without it, custom domains++ that are not yet verified reach whatever site nginx treats as default.++++The D41 production proof (`scripts/d41-proof.sh`, [d41-proof.md](d41-proof.md)) is a separate Wave 21++exit item and is not required by the launch rule.@@@ -111,7 +111,7 @@ list endpoints differ, and the drill reports that honestly. Take the backup righ### Services--Supported with declared overrides (not yet run on the host with `ovhost drill`):++Supported with declared overrides (the table above records which have passed on the host):- network- communitynnew file mode 100644@@@ -0,0 +1,287 @@++# Threat review: OpenVibe.Host Stage B (tenant static hosting)++++Launch-rule item 6 (README, [Launch rule](../README.md#launch-rule)) and roadmap Track S require a++threat review before a service leaves placeholder status. This review covers Stage B as of this++commit:++++- the Host API service (`server/`, port 4910);++- the tenant vhosts that `ovhost nginx tenants` renders (`lib/nginx.js`, `templates/nginx/`).++++Stage A (`ovhost`) is covered only where it touches Stage B: vhost installation and certificates.++Stage C (tenant code) does not exist, and nothing in Stage B executes tenant content on the server.++++Reviewed 2026-09-23. Fixes made during the review are in commits `1e48cb2` and `0ff29d5`. The++Status column says which risks are **fixed**, **mitigated**, **accepted** (with the reason) or++**open**.++++## What is protected, and from whom++++| Asset | Where it lives |++|---|---|++| Other tenants' files, deploy history, logs and domains | `host_*` tables in `/var/lib/openvibe-host-api/host.db`; objects in `<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>` |++| OpenVibe sessions (Network SSO tokens, other products' cookies) | browsers; Network |++| The dashboard session and form tokens | `__Host-ov_host_session` cookie; `HOST_FORM_SECRET` |++| Host's own credentials (`OV_OAUTH_CLIENT_SECRET`, `HOST_FORM_SECRET`) | `/etc/openvibe/host.env` (0600) |++| Certificate private keys, DNS provider credentials | `/etc/letsencrypt`, `/root/.secrets` (root only; never read by Host) |++| The host itself: disk, memory and CPU shared with Live, Media, Chat and every database | the one production machine |++| Visitors of tenant sites | their browsers |++++The adversaries are:++++- a tenant, who uploads arbitrary HTML/JS/SVG/PDF and chooses file names, site names and custom++ domains;++- a visitor of a tenant site;++- anyone on the internet, with arbitrary `Host` headers, request targets and upload bodies;++- someone who controls a domain another tenant used to own.++++## 1. Tenant isolation++++**Serving: one site, one deploy, one project's objects.** Status: fixed by design and tested.++++`server/http/tenant.js` resolves the site once from the `Host` header:++++- `normaliseHost` (`:42`) accepts a lowercase name with an optional port and a trailing dot, and++ nothing else;++- `resolve` (`:92`) matches exactly one label under the sites domain, or a *verified* custom domain;++ anything deeper or unknown gets `404 Unknown host`.++++It reads the site's active deploy pointer once, then only that deploy's immutable rows++(`host_deploy_files`, and the triggers in `server/db.js:172-183`). The request path is++percent-decoded segment by segment, and encoded separators (`%2f %5c %00`) are refused++(`candidates`, `:108`). The decoded path must pass the same `checkPath` as uploads++(`server/artifacts/paths.js:26`), and is then looked up in the manifest table, never on the++filesystem. The bytes are opened at `blobs.pathFor(row.project_id, row.sha256)` (`:154`,++`server/storage.js:35`): a validated `prj_` id plus a validated lowercase sha256. That path is never++built from the URL. Absolute-form request targets must name the same host as `Host` (`:186-196`).++++Identical bytes uploaded by two projects are stored twice. Deleting one project's copy can never++break another's, and no tenant can confirm that another tenant has some content by uploading it.++++`test/host-isolation.test.js` covers:++++- 25 path tricks and 14 `Host` header tricks;++- absolute-form targets;++- tenant hosts never reaching the API, auth, metrics or readiness;++- per-project storage;++- 18 API routes answering 404 to non-members;++- a tenant pointing its own site at another tenant's deploy.++++This review added:++++- no CORS grant on tenant responses and no preflight success, so a script on `alpha` cannot read++ `beta`'s files in a visitor's browser;++- the Host API granting CORS to no tenant origin;++- another tenant's sha256 in `If-None-Match` or `Range` still getting a 404 (no existence oracle);++- a first-party service delegating for a person with `X-OV-Subject`, and an app principal, staying++ inside their own project (an app cannot delegate at all: 400);++- the dashboard showing a non-member no names, file paths or hashes.++++**API and dashboard authorization.** Status: tested.++++Every project-scoped read and write goes through `access.authorize`++(`server/domain/access.js:61-80`). A caller who is not a member gets the same 404 as for an id that++does not exist. Staff (Network role `admin`) can read and delete, but cannot deploy or activate: the++`deploy` need is refused to non-members, staff included. Reads of deploy lists, deploys and logs now++need `read` (the lowest member role, as before), so staff can review a reported site. Service tokens++must carry the route's capability (`guard`, one per route; `test/host-contracts-events.test.js`++checks every route). A sandbox token is refused on production projects.++++**Same-site dashboard.** Status: mitigated, see §4 and §5.++++Tenant pages on `<site>.openvibe.host` are *same-site* with the dashboard on `openvibe.host`. So:++++- the API ignores cookies entirely (`server/http/api.js` routes use Bearer tokens only;++ `host-isolation` "the API ignores cookies");++- every dashboard POST needs the dashboard's own `Origin` (or `Referer`) **and** an HMAC form token++ (`server/auth/forms.js:21-35`);++- the dashboard trusts only `__Host-` cookies (`server/auth/sso.js:33`).++++**Platform cookies never reach tenants.** Status: fixed by design.++++Tenant sites are on `openvibe.host`, a registrable domain separate from every cookie-bearing++OpenVibe domain. `ovhost nginx tenants` refuses a sites domain under `openvibe.network|live|media|community|tools`++(`lib/nginx.js:181`). nginx:++++- strips `Cookie` and `Authorization` from tenant requests;++- hides `Set-Cookie` from tenant responses;++- allows only `GET`/`HEAD`, with a 1 KB body limit (`templates/nginx/tenant-location.tmpl:3-19`).++++Network treats `*.openvibe.host` as user content: it is never trusted for SSO framing, FedCM or++sign-in handoffs (OpenVibe.Network `server/auth/sso-owned.js:10-19`, `USER_CONTENT_ZONES`).++++**Headers on tenant responses.** Status: fixed.++++Every tenant response carries:++++- `X-Content-Type-Options: nosniff`;++- a CSP (`default-src 'self'; script-src 'self'; … object-src 'none'; frame-ancestors 'self'`,++ `server/http/tenant.js:24`);++- `Cross-Origin-Opener-Policy: same-origin`;++- a restrictive `Permissions-Policy`;++- `X-Robots-Tag: noindex` for sandbox projects.++++The CSP limits what a tenant page itself can do: no inline scripts, no third-party scripts. It is a++product limit as much as a protection, and README documents it.++++## 2. Uploads++++**Nothing is extracted to disk.** Status: fixed by design.++++`server/artifacts/archive.js` parses tar and tar.gz from a Buffer. Entries become `{ path, data }`,++the validator builds a manifest, and each file is stored by the sha256 of its bytes. No archive entry++name ever becomes a filesystem path, which is why path traversal, symlinks and hard links cannot++escape a directory: there is no directory.++++| Threat | Handling | Status |++|---|---|---|++| **Path traversal** (`..`, absolute, `\`, NUL/control, non-ASCII, empty segments, depth over 32; pax and GNU long-name overrides get the same checks) | `checkPath` (`paths.js:26-54`) on every name (`validate.js`), after `./` normalisation; the whole upload fails. `test/host-uploads.test.js`. | fixed |++| **Symlinks, hard links, devices, FIFOs, sparse and other special entries** | refused, never skipped (`archive.js:107-112`); a pax `linkpath` on a regular entry is refused too | fixed |++| **Gzip bomb** | `gunzipSync` with `maxOutputLength` = the smaller of the project's storage quota and `HOST_MAX_UNPACKED_BYTES` (256 MiB), plus header slack (`archive.js:67-75`, `upload.js:153`); a 40 MB bomb is in the tests | fixed |++| **Zip bomb** | `.zip` files are published as opaque bytes (`application/zip`) and never opened. A zip is not accepted as the deploy archive: it fails as "not a tar archive". | fixed |++| **Tar bomb** (millions of tiny entries, huge headers) | file count (`archive.js:115`), bytes per file and total bytes (`:116-118`) are checked as the archive is read; pax headers are bounded by the archive itself | fixed |++| **Oversized body** | `Content-Length` checked before reading and the stream capped while reading (`upload.js:36-50,122`); the connection is closed, not drained; nginx caps upload routes at 110m and tenant hosts at 1k | fixed |++| **Memory exhaustion by concurrent uploads** (each held in memory: up to 100 MiB raw plus 256 MiB unpacked) | **added:** at most `HOST_MAX_CONCURRENT_UPLOADS` (default 2) uploads are validated at once, service-wide (`upload.js:181`, `api.js:148`, dashboard); others get `503 upload.busy` + `Retry-After` before a byte is read, and are not recorded as the tenant's failure | fixed (`1e48cb2`) |++| **Event-loop stall** (`gunzipSync` runs on the main thread: decompressing 256 MiB blocks serving for roughly a second) | bounded by the concurrency cap, the per-project deploys-per-day quota (checked before the body is read, failed uploads count) and nginx `limit_req` on the upload route | accepted: move decompression to a worker thread if uploads become frequent |++| **Server-side code, credentials, databases** (`php`, `cgi`, `sh`, `jar`, `.env`, `pem`, `sqlite`, …) | refused with a reason (`paths.js:56-63`), so nobody deploys them thinking they run or stay private | fixed |++| **Content types** | allowlist by extension (`paths.js:65-84`); extension-less files are `text/plain`; everything is served with `nosniff` and the manifest's type, never a sniffed one | fixed |++| **Active content** (HTML, SVG, XML/XSLT, PDF, WebAssembly) | runs only on the tenant's own origin, under the tenant CSP; never on `openvibe.host` or a first-party origin. Same model as GitHub Pages. | accepted by design |++| **Hidden files** (`.git/`, `.env`, `.htaccess`) | refused; only `.well-known/` is published | fixed |++| **Certificate-validation files** (`.well-known/acme-challenge/`, `.well-known/pki-validation/`), which would let a tenant prove control of `<site>.openvibe.host` to a CA over HTTPS | **added:** refused in uploads and never served (`paths.js:49-53`); the operator issues every certificate | fixed (`1e48cb2`) |++| **Duplicate paths, file/directory conflicts, empty uploads** | refused (`validate.js:43,58`) | fixed |++| **A refused upload leaves partial state** | nothing is stored; the refusal is a `failed` deploy with its log and `host.deploy.failed`; the active deploy keeps serving | fixed |++| **Filling the shared disk** | **added:** deploys are refused with `507 storage.host_full` while the object store's filesystem has less than `HOST_MIN_FREE_BYTES` (default 5 GiB) free (`deploys.js:78-86`); `/api/ready` reports `disk_headroom`. Serving is unaffected. See also §6. | fixed (`0ff29d5`) |++++## 3. Custom domains++++| Threat | Handling | Status |++|---|---|---|++| **Claiming someone else's domain** | a custom domain is served only after `_openvibe-host.<hostname>` TXT = `openvibe-host-verification=<random token>`; the first verified proof wins (unique index, `db.js`); `test/host-domains.test.js` | fixed |++| **Dangling-CNAME takeover** (a former tenant's DNS still points at Host) | nobody can serve on that name without the TXT record, which only the domain owner can publish | fixed |++| **Claiming OpenVibe names** | refused: `openvibe.<any tld>` and subdomains, every domain in the released service manifests, the sites domain and the dashboard (`domains.js:38-47,87-96`) | fixed |++| **Keeping a domain after losing it** | verified domains are re-checked daily; a TXT record gone for `HOST_DOMAIN_LAPSE_DAYS` lapses the domain and it stops being served; pending domains fail after `HOST_DOMAIN_PENDING_DAYS` (`server/worker.js`, `domains.js:186-`) | fixed |++| **Hostile values reaching nginx** (a tenant-controlled hostname in a vhost) | `ovhost nginx tenants` re-validates every verified hostname from the database and drops anything that is not a plain host name or that falls under the sites domain (`lib/nginx.js:227`); `test/nginx-tenants.test.js` injects `evil.org; } server {…` | fixed |++| **TLS keys through the API** | never: certificates are issued by the operator with certbot (webroot for custom domains, DNS-01 for the wildcard); `ovhost certs` never reads a key file (`lib/certs.js:13`); the DNS provider credential is root-only and never passed to Host | fixed |++| **Unverified domains reaching another site** (a name CNAMEd to `cname.openvibe.host` before verification, or after lapsing, reaches nginx's *default server*, which served another site on 2026-09-22) | host-wide nginx, not Host: add a catch-all `default_server` that returns 444 on port 80 and rejects the TLS handshake on 443 (docs/launch.md, optional step 0) | **open** (host-wide; recommended at launch) |++| **Sandbox projects** | cannot add custom domains (`domains.js:115`) | fixed |++++## 4. Cookies and sessions++++| Threat | Handling | Status |++|---|---|---|++| A tenant page reads the dashboard session | the session is `__Host-ov_host_session`: HttpOnly, Secure, host-only; tenant origins are different hosts; nginx strips `Cookie` from tenant requests | fixed |++| **Cookie tossing** (a tenant sets `Domain=openvibe.host` cookies to fix a session or fake a sign-in) | the server trusts only `__Host-` cookies, which a subdomain cannot set (`sso.js:33`); a planted `ov_token` is ignored (`host-secrets-dashboard` "a planted ov_token cookie … is not a session") | fixed |++| **Login CSRF / forged dashboard actions from a same-site tenant page** | state cookie `__Host-` + OAuth `state`; every POST needs the dashboard `Origin` and the HMAC form token (`forms.js`); tested with same-site tenant origins | fixed |++| **Logout CSRF** (`GET /auth/logout` from a tenant page signs the visitor out of the dashboard) | nuisance only | accepted |++| **Cookie bomb** (a tenant sets many large `Domain=openvibe.host` cookies, so the visitor's browser sends oversized headers and nginx answers 400 for `openvibe.host` and every tenant site until the cookies expire or are cleared) | only the Public Suffix List stops a subdomain from setting parent-domain cookies (§5) | **accepted** for alpha; see §5 |++| `ov_token` (the Network access JWT) is JS-readable on `openvibe.host` for the shared navbar | host-only cookie: tenants cannot read it. A dashboard XSS could, and the dashboard CSP allows inline scripts (for the shared chrome). Tenant-controlled values (project names, file paths, log lines, host names) go through `esc()` in `server/render/pages.js`; ids are server-generated and site names match `[a-z0-9-]`. | mitigated; revisit when the shared chrome no longer needs inline script |++| Clickjacking the dashboard | `frame-ancestors 'none'`, `X-Frame-Options: DENY` (`server/app.js:47-60`) | fixed |++++## 5. The Public Suffix List question++++`openvibe.host` is not on the PSL, so browsers treat every `<site>.openvibe.host` as the same *site*++as `openvibe.host` and as each other. Listing it would:++++- **stop cookie tossing and cookie bombs across tenants:** a tenant could no longer set++ `Domain=openvibe.host` cookies;++- make each tenant its own *site* for `SameSite`, storage partitioning and site isolation;++- also make `openvibe.host` itself a public suffix. The dashboard lives on that name.++ - Host-only cookies without a `Domain` attribute, which is what `__Host-` requires and what Host++ uses, remain possible on a host that equals a public suffix (RFC 6265 §5.3 step 5). This must be++ confirmed in current Chrome, Firefox and Safari before submitting.++ - The shared navbar's `ov_token`/`ov_sso_hint` cookies are also host-only.++- take weeks to be accepted and months to reach browsers. It requires a `_psl` TXT record and a++ registration that stays paid for at least two more years, and removal is equally slow.++++None of Host's protections depend on the PSL (§1, §4), and tests show each one without it.++++**Decision for launch:** launch without the PSL, and accept the cookie-bomb nuisance. It affects only++visitors who open a malicious tenant page, and staff can take that site down at once (§7).++++**Recommended follow-up:** move the dashboard and API to a name outside the tenant zone (for example++`host.openvibe.network`, or keep `openvibe.host` for the dashboard and serve tenants from a second++registrable domain). Then submit the tenant zone alone to the PSL, the way GitHub separates++`github.com` from `github.io`. This needs an owner decision on the domain.++++## 6. Quotas and resource abuse++++| Limit | Default (production / sandbox) | Where |++|---|---|---|++| storage per project (deduplicated) | 1 GiB / 100 MiB | `server/config.js:91-92` (`HOST_QUOTA_*`, `HOST_SANDBOX_QUOTA_*`), staff override per project |++| deploys per project per rolling 24 h (failed ones count; checked before the body is read) | 50 / 20 | `deploys.js:88-90` |++| files per deploy; bytes per file | 10 000, 25 MiB / 2 000, 10 MiB | `archive.js:115-116`, `validate.js` |++| sites per project; custom domains per project | 10, 5 / 3, 0 | `sites.js:67`, `domains.js:117` |++| **projects per person** | **10** (was a fixed 50), `HOST_MAX_PROJECTS_PER_OWNER` | `projects.js:84-85` (`0ff29d5`) |++| request size; unpacked size; concurrent uploads | 100 MiB; 256 MiB; 2 | `config.js:72-83` |++| free disk below which deploys stop | 5 GiB, `HOST_MIN_FREE_BYTES` | `deploys.js:78-86` |++| rate limits | API 240/min, dashboard 300/min, `/auth` 60/15 min per IP (`app.js`); nginx `limit_req` per zone | `templates/nginx/tenants.conf.tmpl:10-12` |++++The worst case per person is now 10 GiB of storage, down from 50 GiB. Network accounts are free,++so many accounts could still add up. The disk floor keeps that from ever filling the disk the whole++platform shares: uploads stop, and serving and every other service go on.++++Serving costs no disk and little CPU. Tenant traffic is behind Cloudflare++(`*.openvibe.host` is proxied), and nginx rate-limits each client address, set by realip from++Cloudflare. **Added:** every template ovhost renders now sends `X-Forwarded-For` from++`$remote_addr` only (`1e48cb2`). The templates had still appended the client's own header, so a++request reaching the origin directly could choose the address that rate limits and logs use. The++installed vhosts were fixed by hand on 2026-09-23 (`30592c0`). `TRUST_PROXY` now defaults to 1.++++Accepted for alpha: objects live only on the local disk and are not in `ovhost backup` (only++`host.db` is). A lost disk loses tenant sites, and tenants keep their source. README "Not done yet"++says so.++++## 7. Abuse and takedown++++Before this review, staff could only **delete** a site or a project. Deletion stops serving but also++destroys the content, which a report may need reviewed or preserved. **Added** (`1e48cb2`,++`server/domain/takedowns.js`):++++- **Staff take down a site or a whole project:** `POST /api/v1/sites/:id/takedown` or++ `/projects/:id/takedown` with `{ "reason": "…" }` (staff only, capability `host.site.manage`).++ Serving stops at once on the default and every custom domain: `451`, no tenant bytes, `no-store`,++ and `Clear-Site-Data: "cache", "storage"`, so visitors' browsers drop what the site cached, stored or++ installed as a service worker (`0ff29d5`).++- **Evidence is kept:** deploys, file rows, logs and objects stay. Staff can read the deploys and++ their file lists and logs through the API. The bytes are at++ `<HOST_STORAGE_DIR>/projects/<prj>/<aa>/<sha256>`.++- **Members see why:** the reason appears on the site and project in the API and as a dashboard++ notice. They cannot upload, activate, roll back or delete anything the takedown covers.++- **Staff lift it** (`DELETE …/takedown` with an optional `note`) or delete the content. Every++ takedown and lift stays in `host_takedowns`.++- **Tests:** `test/host-abuse.test.js`.++++**Procedure** for a report sent to the address on `https://openvibe.host/dmca` (the shared legal++pages, profile `ugc`, which name `[email protected]`):++++1. Take the site down (a project takedown for a pattern across sites).++2. Purge Cloudflare's cache for the site's host name. Fingerprinted assets otherwise stay at the edge++ for up to an hour: Host now sends `CDN-Cache-Control: public, max-age=3600` on them (`1e48cb2`), while++ browsers keep them for a year as `immutable`. HTML and other files revalidate on every request.++3. Review and preserve what the law requires.++4. Lift or delete.++++**Needs an owner:** confirmation that `[email protected]` is monitored for Host reports too, and a named person who acts on them. The++tooling exists; the duty does not yet. The Events log (`host.deploy.*`) and the upload logs give++the history of any site.++++## 8. Host's own surface++++| Item | Handling | Status |++|---|---|---|++| `/metrics` | direct loopback callers only; nginx returns 404 (`tenants.conf.tmpl:72`) | fixed |++| `/api/ready`, `/api/health`, `/release.json` | no secrets, no tenant data (`host-secrets-dashboard`) | fixed |++| Secrets in responses | allowlisted serializers (`server/http/serialize.js`); tested for the client secret, form secret, uploaded `.env` values and credential-like strings | fixed |++| Tenant secrets | Stage B stores none: no environment, build secrets or deploy keys | fixed by design |++| Tenant code execution | none: no build step, no server-side code | fixed by design |++| The tenant vhost install | transactional (`nginx -t`, restore on failure); **added:** removes the interim pending vhost in the same change (`lib/nginx.js:100-`, `nginx.tenants.replaces`) | fixed (`1e48cb2`) |++++## Residual risks++++| Risk | Severity | Owner |++|---|---|---|++| Unverified or lapsed custom domains reach nginx's default server | medium (confusion, mis-served content on a stranger's domain) | operator: catch-all `default_server` (docs/launch.md step 0) |++| Cookie bomb across tenants and the dashboard (no PSL) | low (per-visitor nuisance; takedown exists) | owner decision (§5) |++| No abuse mailbox/owner | medium (process, not code) | owner |++| Objects not backed up | medium for tenants (alpha, documented) | follow-up: add the object store to the backup run |++| `gunzipSync` blocks the event loop per upload | low (bounded) | follow-up if uploads grow |++| Takedown routes not in released `openvibe-contracts` `host.site.manage.implementedBy` | none at runtime (the contract check passes) | Contracts: next release (docs/launch.md) |
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> The network's deployment/control plane first; then isolated hosting for community sites, bots and mods.
> The network's deployment/control plane first; then isolated hosting for community sites, bots and mods.
-**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It is installed on the production host (2026-09-23, `/usr/local/bin/ovhost`, inventory `/etc/openvibe/host.json`) and used read-only (`status`, `validate`); no service's deploy has moved to it yet. Stage B (tenant static hosting) is a service, written and tested, **not deployed**. Stage C (sandboxed user code) is **not started**.
+**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It is installed on the production host (2026-09-23, `/usr/local/bin/ovhost`, inventory `/etc/openvibe/host.json`) and used read-only (`status`, `validate`); no service's deploy has moved to it yet. Stage B (tenant static hosting) is a service, written and tested; it runs on the host on loopback `:4910` (2026-09-23 audit) and is **not launched**: `openvibe.host` is still the Sites placeholder and `*.openvibe.host` answers 404. The launch is ready to run: [docs/launch.md](docs/launch.md) (steps, verification, rollback and the launch-rule evidence) and [docs/threat-review.md](docs/threat-review.md). Stage C (sandboxed user code) is **not started**.
**Domain:** `openvibe.host` (dashboard and API) and `*.openvibe.host` (tenant sites). The domain keeps its placeholder page on [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until Stage B is deployed and launched (see [Launch rule](#launch-rule)).
**Domain:** `openvibe.host` (dashboard and API) and `*.openvibe.host` (tenant sites). The domain keeps its placeholder page on [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until Stage B is deployed and launched (see [Launch rule](#launch-rule)).
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3 and §15.17; roadmap Wave 21.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3 and §15.17; roadmap Wave 21.
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -46,7 +46,7 @@ Stage A adds a few safety rules of its own:
@@ -46,7 +46,7 @@ Stage A adds a few safety rules of its own:
## Depends on
## Depends on
-- OpenVibe.Contracts (`openvibe-contracts` v0.19.0): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.
+- OpenVibe.Contracts (`openvibe-contracts` v0.24.0; `host.*` capabilities and the `host` manifest are released there and unchanged in v0.30.1): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.
- OpenVibe.Network (Stage B): SSO for the dashboard, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay.
- OpenVibe.Network (Stage B): SSO for the dashboard, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay.
- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.2.2 transactional outbox.
- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.2.2 transactional outbox.
- `openvibe-shared` v1.3.0 (Stage B): shared chrome, legal pages, `/release.json`, `/metrics`, `/api/ready`.
- `openvibe-shared` v1.3.0 (Stage B): shared chrome, legal pages, `/release.json`, `/metrics`, `/api/ready`.
@@ -240,11 +240,12 @@ People present their Network user JWT as a Bearer token. Services and apps prese
@@ -240,11 +240,12 @@ People present their Network user JWT as a Bearer token. Services and apps prese
| `GET/POST /sites/:id/deploys` · `GET /deploys/:id` · `GET /deploys/:id/log` | `host.deploy.create` | deployer |
| `GET/POST /sites/:id/deploys` · `GET /deploys/:id` · `GET /deploys/:id/log` | `host.deploy.create` | deployer |
| `POST /deploys/:id/activate` · `POST /sites/:id/rollback` (`{ deploy_id?, expected_active? }`) | `host.deploy.create` | deployer |
| `POST /deploys/:id/activate` · `POST /sites/:id/rollback` (`{ deploy_id?, expected_active? }`) | `host.deploy.create` | deployer |
| `DELETE /deploys/:id` (not the active one) | `host.site.manage` | maintainer |
| `DELETE /deploys/:id` (not the active one) | `host.site.manage` | maintainer |
+| `POST/DELETE /projects/:id/takedown` · `POST/DELETE /sites/:id/takedown` | `host.site.manage` | Network staff only |
| `GET/POST /sites/:id/domains` · `POST /domains/:id/verify` · `DELETE /domains/:id` | `host.domain.manage` | maintainer (reads: member) |
| `GET/POST /sites/:id/domains` · `POST /domains/:id/verify` · `DELETE /domains/:id` | `host.domain.manage` | maintainer (reads: member) |
-Errors are RFC 9457 problems (`application/problem+json`, with the legacy `error` field). A refused upload answers 413/422 with `deploy_id` and the log lines. Network staff (`role: admin`) can read every project, delete sites, projects and domains, and set quotas; they cannot publish into a tenant's site.
+Errors are RFC 9457 problems (`application/problem+json`, with the legacy `error` field). A refused upload answers 413/422 with `deploy_id` and the log lines; `503 upload.busy` (with `Retry-After`) when `HOST_MAX_CONCURRENT_UPLOADS` uploads are already being validated; `507 storage.host_full` while the disk has less than `HOST_MIN_FREE_BYTES` free. Network staff (`role: admin`) can read every project, site, deploy and log, delete sites, projects and domains, set quotas, and **take a site or project down** (451 on every host, content kept for review, members see the reason and cannot publish or delete around it); they cannot publish into a tenant's site.
-The three capability ids are proposals in [`docs/capabilities-proposal/`](docs/capabilities-proposal/) together with an updated [`docs/service-manifest-proposal.json`](docs/service-manifest-proposal.json). Until the lead releases them in `openvibe-contracts`, Host decides grants for exactly these ids with the library's own matching rule (`server/auth/capabilities.js`), and the CI contract check is non-blocking.
+The three capability ids and the service manifest are released in `openvibe-contracts` (since v0.24.0, identical in v0.30.1), and the CI contract check is blocking. [`docs/capabilities-proposal/`](docs/capabilities-proposal/) mirrors them; `host.site.manage` there also lists the four takedown routes, which the next Contracts release should add (see [docs/launch.md](docs/launch.md#the-window-in-this-order)).
### Activation and rollback
### Activation and rollback
@@ -276,7 +277,7 @@ Server-rendered pages with the shared chrome (`openvibe-shared` v1.3.0: navbar a
@@ -276,7 +277,7 @@ Server-rendered pages with the shared chrome (`openvibe-shared` v1.3.0: navbar a
- OpenVibe.Network OAuth client **`host`**, redirect `https://openvibe.host/auth/callback`, scope `profile theme`. The same client is the service principal `svc:host`.
- OpenVibe.Network OAuth client **`host`**, redirect `https://openvibe.host/auth/callback`, scope `profile theme`. The same client is the service principal `svc:host`.
- Grant `[host, events.event.publish, openvibe.events]`.
- Grant `[host, events.event.publish, openvibe.events]`.
- Callers of Host get `[<client>, host.site.manage | host.deploy.create | host.domain.manage, openvibe.host]` as needed. None exist yet (Codes, the expected first caller, is not built).
- Callers of Host get `[<client>, host.site.manage | host.deploy.create | host.domain.manage, openvibe.host]` as needed. None exist yet (Codes, the expected first caller, is not built).
-- Release `docs/capabilities-proposal/*.json` and `docs/service-manifest-proposal.json` in the next `openvibe-contracts`, then make the CI contract check blocking again.
+- Released in `openvibe-contracts` v0.24.0 (CI contract check blocking). Next release: add the takedown routes to `host.site.manage.implementedBy` (docs/launch.md).
### Deploying Stage B (for the operator)
### Deploying Stage B (for the operator)
@@ -290,7 +291,7 @@ Nothing here has been done yet.
@@ -290,7 +291,7 @@ Nothing here has been done yet.
6. **Unit.** `sudo cp deploy/systemd/openvibe-host.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-host`, then `curl -s http://127.0.0.1:4910/api/ready`.
6. **Unit.** `sudo cp deploy/systemd/openvibe-host.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-host`, then `curl -s http://127.0.0.1:4910/api/ready`.
7. **nginx.** Add the `host` entry from [`host.example.json`](host.example.json) to `/etc/openvibe/host.json`, update `/usr/local/lib/openvibe-host`, then `sudo ovhost nginx tenants host` (review) and `sudo ovhost nginx tenants host --install` (writes `openvibe.host.conf` and `openvibe.host-custom-domains.conf`, `nginx -t`, reload; restored on failure). [`deploy/nginx/openvibe.host.conf`](deploy/nginx/openvibe.host.conf) is a reference copy.
7. **nginx.** Add the `host` entry from [`host.example.json`](host.example.json) to `/etc/openvibe/host.json`, update `/usr/local/lib/openvibe-host`, then `sudo ovhost nginx tenants host` (review) and `sudo ovhost nginx tenants host --install` (writes `openvibe.host.conf` and `openvibe.host-custom-domains.conf`, `nginx -t`, reload; restored on failure). [`deploy/nginx/openvibe.host.conf`](deploy/nginx/openvibe.host.conf) is a reference copy.
8. **Custom domains** (whenever tenants verify one): `sudo ovhost nginx tenants host` lists the verified domains still waiting for a certificate. For each, `sudo certbot certonly --webroot -w /var/www/certbot -d <hostname>` (the generated port-80 block already answers the ACME challenge), then `sudo ovhost nginx tenants host --install` again. Until then, the domain is verified but only answers over HTTP with the challenge. Certificates never pass through the Host API.
8. **Custom domains** (whenever tenants verify one): `sudo ovhost nginx tenants host` lists the verified domains still waiting for a certificate. For each, `sudo certbot certonly --webroot -w /var/www/certbot -d <hostname>` (the generated port-80 block already answers the ACME challenge), then `sudo ovhost nginx tenants host --install` again. Until then, the domain is verified but only answers over HTTP with the challenge. Certificates never pass through the Host API.
-9. **Launch.** The Sites placeholder stays until the launch rule below holds and the lead switches it. Until then [`deploy/nginx/openvibe.host-tenants-pending.conf`](deploy/nginx/openvibe.host-tenants-pending.conf) answers every `*.openvibe.host` name with a 404 over the wildcard certificate (installed 2026-09-23; without it the wildcard fell through to nginx's default server). Remove it in the same change that installs the tenant vhost.
+9. **Launch.** Follow [docs/launch.md](docs/launch.md). Until then [`deploy/nginx/openvibe.host-tenants-pending.conf`](deploy/nginx/openvibe.host-tenants-pending.conf) answers every `*.openvibe.host` name with a 404 over the wildcard certificate (installed 2026-09-23; without it the wildcard fell through to nginx's default server). `ovhost nginx tenants host --install` removes it in the same `nginx -t` and reload that installs the tenant vhost (`nginx.tenants.replaces`), and puts it back if the test fails.
### Not done yet (Stage B)
### Not done yet (Stage B)
@@ -298,9 +299,10 @@ Nothing here has been done yet.
@@ -298,9 +299,10 @@ Nothing here has been done yet.
- Objects live on the service's local disk, not in OpenVibe.Media; there is no replication and no backup beyond `ovhost backup host` (the SQLite database only).
- Objects live on the service's local disk, not in OpenVibe.Media; there is no replication and no backup beyond `ovhost backup host` (the SQLite database only).
- Certificates for custom domains are issued by hand (step 8); there is no automatic ACME flow.
- Certificates for custom domains are issued by hand (step 8); there is no automatic ACME flow.
- Projects are created in Host. When Network has projects (ADR-014), Host should accept only Network project ids and read membership from Network.
- Projects are created in Host. When Network has projects (ADR-014), Host should accept only Network project ids and read membership from Network.
-- `openvibe.host` is not on the Public Suffix List. Listing it would make each tenant its own site in browsers, but it would also make `openvibe.host` itself a public suffix, which affects the dashboard's cookies. Evaluate before submitting. The protections above do not depend on it.
+- `openvibe.host` is not on the Public Suffix List, so a tenant page can still set `Domain=openvibe.host` cookies (a cookie bomb breaks the dashboard and other tenant sites for that visitor). The protections above do not depend on it. [docs/threat-review.md §5](docs/threat-review.md#5-the-public-suffix-list-question) records the decision (launch without it) and the recommended follow-up (dashboard off the tenant zone, then list the zone).
+- Tenant objects are not in `ovhost backup` (only `host.db`).
- No per-site headers, redirects or SPA fallback configuration; no preview deploys; no deploy of a Git repository (uploads only).
- No per-site headers, redirects or SPA fallback configuration; no preview deploys; no deploy of a Git repository (uploads only).
-- No sitemap/robots/feed behaviour for tenant sites beyond what tenants upload themselves.
+- Tenant sites have no Host-provided sitemap/robots/feed: they publish their own (sandbox projects are `noindex`). The dashboard host serves `/robots.txt` and `/sitemap.xml` (front page and legal pages).
- The Codes portal (Wave 20) does not exist, so there is no public developer onboarding for Host yet.
- The Codes portal (Wave 20) does not exist, so there is no public developer onboarding for Host yet.
### Acceptance (what `npm test` demonstrates for Stage B)
### Acceptance (what `npm test` demonstrates for Stage B)
@@ -312,7 +314,9 @@ Nothing here has been done yet.
@@ -312,7 +314,9 @@ Nothing here has been done yet.
- **Custom domains** (`test/host-domains.test.js`): not served until verified; wrong or missing TXT stays pending; first verified proof wins; OpenVibe domains refused; removal and site deletion stop serving; background verification, pending expiry and lapse.
- **Custom domains** (`test/host-domains.test.js`): not served until verified; wrong or missing TXT stays pending; first verified proof wins; OpenVibe domains refused; removal and site deletion stop serving; background verification, pending expiry and lapse.
- **No secrets in responses** (`test/host-secrets-dashboard.test.js`): no API, dashboard, readiness or event body contains the OAuth client secret, the form secret, an uploaded `.env`'s values or any credential-like string; response objects are allowlisted; plus the dashboard (chrome, forms, CSRF against same-site tenant origins, planted cookies) and machine endpoints.
- **No secrets in responses** (`test/host-secrets-dashboard.test.js`): no API, dashboard, readiness or event body contains the OAuth client secret, the form secret, an uploaded `.env`'s values or any credential-like string; response objects are allowlisted; plus the dashboard (chrome, forms, CSRF against same-site tenant origins, planted cookies) and machine endpoints.
- **Lifecycle, headers, events** (`test/host-lifecycle.test.js`, `test/host-contracts-events.test.js`): content types, ETag/304, ranges, immutable caching, CSP, nosniff, custom 404, 405; valid event envelopes delivered by the relay with a Network service token; the proposals match the guarded routes.
- **Lifecycle, headers, events** (`test/host-lifecycle.test.js`, `test/host-contracts-events.test.js`): content types, ETag/304, ranges, immutable caching, CSP, nosniff, custom 404, 405; valid event envelopes delivered by the relay with a Network service token; the proposals match the guarded routes.
-- **Tenant vhosts** (`test/nginx-tenants.test.js`): the wildcard vhost with the certificate path parameter, HTTPS only for verified domains that have a certificate, hostile database values refused before they reach nginx, transactional install, no key file read.
+- **Tenant vhosts** (`test/nginx-tenants.test.js`): the wildcard vhost with the certificate path parameter, HTTPS only for verified domains that have a certificate, hostile database values refused before they reach nginx, transactional install that removes the pending vhost in the same change, `www.` redirect, client address headers from `$remote_addr` only, no key file read.
+- **Abuse controls** (`test/host-abuse.test.js`): staff takedowns (451 with Clear-Site-Data on every host, content kept, members cannot publish or delete around them, lift), certificate-validation paths refused, uploads validated two at a time (503 + Retry-After), the disk floor (507), the per-person project cap, fingerprinted assets capped at an hour at a CDN.
+- The isolation test also covers cross-origin reads from a tenant page (no CORS grant anywhere), ETag/Range existence oracles, delegated service tokens, app principals and the dashboard.
Not demonstrated yet: any of this on the production host.
Not demonstrated yet: any of this on the production host.
@@ -346,6 +350,11 @@ The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches
@@ -346,6 +350,11 @@ The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches
registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
implemented service.
implemented service.
+**Stage B against this rule (2026-09-23):** every item is met in code and tests. The evidence per
+item, the exact launch steps (tenant vhost with the pending vhost removed in the same change, Sites,
+Network registry), verification and rollback are in [docs/launch.md](docs/launch.md); the threat
+review is [docs/threat-review.md](docs/threat-review.md). The launch has not been run.
+
---
---
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"domain": "openvibe.host",
"domain": "openvibe.host",
"tenantDomain": "*.openvibe.host",
"tenantDomain": "*.openvibe.host",
"packages": [
"packages": [
- "openvibe-contracts v0.19.0",
+ "openvibe-contracts v0.24.0 (host.* capabilities and manifest released; unchanged in v0.30.1)",
"openvibe-sdk v0.2.2",
"openvibe-sdk v0.2.2",
"openvibe-shared v1.3.0"
"openvibe-shared v1.3.0"
],
],
@@ -18,8 +18,8 @@
@@ -18,8 +18,8 @@
"host.deploy.create",
"host.deploy.create",
"host.domain.manage"
"host.domain.manage"
],
],
- "capabilitiesRegistered": false,
- "serviceManifestRegistered": "stage A manifest only (v0.10.0); the Stage B proposal is in docs/service-manifest-proposal.json",
+ "capabilitiesRegistered": true,
+ "serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events",
"events": [
"events": [
"host.deploy.created",
"host.deploy.created",
"host.deploy.activated",
"host.deploy.activated",
@@ -27,15 +27,17 @@
@@ -27,15 +27,17 @@
"host.domain.verified"
"host.domain.verified"
],
],
"stages": {
"stages": {
- "A": "alpha: ovhost CLI installed on the production host 2026-09-23 (/usr/local/bin/ovhost, inventory /etc/openvibe/host.json); used read-only (status, validate) so far — no service deploy has moved to it; restore drills (`ovhost drill`, Wave 22) written and tested against the fake host, not yet run on the host",
- "B": "alpha: tenant static hosting service written and tested (tenant isolation, traversal/symlink refusal, quotas, atomic rollback, TXT-verified custom domains, no secrets in responses); not deployed",
+ "A": "alpha: ovhost CLI installed on the production host 2026-09-23 (/usr/local/bin/ovhost, inventory /etc/openvibe/host.json); status/validate/backup/drill used there (19 restore drills passed 2026-09-23, docs/restore-drills.md); no service deploy has moved to it yet; the D41 deploy-and-rollback proof (scripts/d41-proof.sh) is written and tested, not run",
+ "B": "alpha: running on the host on loopback :4910 (2026-09-23 audit), not launched; launch-rule items met in code and tests, launch steps in docs/launch.md, threat review in docs/threat-review.md",
"C": "not started (deliberately: blocked until isolation and metering are proven)"
"C": "not started (deliberately: blocked until isolation and metering are proven)"
},
},
"artifactStorage": "local disk, content-addressed per project (not OpenVibe.Media yet)",
"artifactStorage": "local disk, content-addressed per project (not OpenVibe.Media yet)",
"customDomainCertificates": "operator-issued (certbot), not automated",
"customDomainCertificates": "operator-issued (certbot), not automated",
"installedOnHost": true,
"installedOnHost": true,
- "deployed": false,
+ "deployed": "loopback only, not launched",
"placeholderInSites": true,
"placeholderInSites": true,
"adoptedByServices": [],
"adoptedByServices": [],
- "updated": "2026-09-22"
+ "updated": "2026-09-23",
+ "capabilitiesNote": "released in openvibe-contracts v0.24.0 and identical in v0.30.1; the next release should add the four takedown routes to host.site.manage.implementedBy (docs/launch.md)",
+ "launchReady": true
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,262 @@
@@ -0,0 +1,262 @@
+# Launching Stage B at openvibe.host
+
+This is the launch release the [Launch rule](../README.md#launch-rule) describes. One change window:
+
+- `*.openvibe.host` starts serving tenant sites;
+- `openvibe.host` switches from the OpenVibe.Sites placeholder to the Host dashboard;
+- the interim tenants-pending vhost goes;
+- the domain leaves `OpenVibe.Sites/sites.json`;
+- the Network registry says Host is live.
+
+**Nothing here has been done.** The launch needs the owner's go and the checks below.
+
+The readiness evidence is in [Launch readiness](#launch-readiness-2026-09-23) at the end of this
+file, and the threat review is in [threat-review.md](threat-review.md).
+
+## Before the window
+
+State on 2026-09-23 (audit of 18:15 UTC, not re-checked here):
+
+- The Host API runs on the host on loopback `:4910` with `/api/ready` 200, and 0 projects.
+- `openvibe.host` is served by the Sites placeholder vhost `/etc/nginx/sites-available/openvibe.host.conf`
+ (root `/opt/openvibe.sites/dist/openvibe.host`).
+- `/etc/nginx/sites-available/openvibe.host-tenants-pending.conf` answers every `*.openvibe.host`
+ with a 404.
+- The wildcard certificate `/etc/letsencrypt/live/openvibe.host/` expires 2026-12-17.
+- `*.openvibe.host` is proxied by Cloudflare; `cname.openvibe.host` is DNS-only.
+
+Prepare these commits, pushed but **not deployed**:
+
+1. **OpenVibe.Host:** this branch (`1e48cb2` and later): takedowns, bounded uploads, the disk floor,
+ realip headers, `--install` removing the pending vhost, robots/sitemap.
+2. **OpenVibe.Sites:** remove `openvibe.host`, the way `d3b71af` removed `openvibe.codes`:
+ - delete the `openvibe.host` entry from `sites.json`;
+ - run `node build.js`;
+ - commit the removal of `deploy/nginx/openvibe.host.conf` and `dist/openvibe.host/`, and the
+ one-line cross-link change in every other `dist/*/index.html`;
+ - adjust `test/build.test.js` if it counts sites.
+
+ `npm test` must pass. **Do not run Sites' `deploy.sh` until step 4.** Until this commit is pulled
+ on the host, a Sites deploy would put its placeholder vhost back over Host's (same file name).
+3. **OpenVibe.Network:** flip the exposure overlay.
+ - `server/registry/exposure.js`: change `host: { state: 'internal', public_site: 'placeholder', note: SITE_PLACEHOLDER }`
+ to `host: { state: 'live', public_site: 'service', note: 'dashboard and API at openvibe.host; tenant sites at <site>.openvibe.host' }`.
+ - `test/registry-exposure.test.js`:
+ - `PINNED.host = 'live'`;
+ - remove `'host'` from `PLACEHOLDER_DOMAINS` and from the list of ids that must be `soon`;
+ - add `'host'` to the expected `open` nav ids, in the order `server/chrome/sites.js` lists them.
+
+ `npm test` must pass.
+
+Run the pre-flight on the host. Everything here is read-only; stop if any line disagrees:
+
+```bash
+sudo ovhost status host # openvibe-host active, ready
+curl -s http://127.0.0.1:4910/api/ready | jq '{status, failed}'
+sudo ovhost validate host # required env NAMES set/empty (never values), unit, port 4910, deps
+sudo ovhost certs | grep -E 'openvibe\.host' # the wildcard, valid, covers openvibe.host + *.openvibe.host
+dig +short openvibe.host; dig +short x.openvibe.host; dig +short cname.openvibe.host
+sudo nginx -T 2>/dev/null | grep -E 'real_ip_header|set_real_ip_from' | head -3 # realip is configured
+ls -l /etc/nginx/sites-enabled/ | grep openvibe.host # openvibe.host.conf (Sites) + openvibe.host-tenants-pending.conf
+df -h /var/lib/openvibe-host-api # well above 5 GiB free (HOST_MIN_FREE_BYTES)
+curl -s https://openvibe.network/.well-known/openvibe | jq '.services[] | select(.id=="host")'
+```
+
+`/etc/openvibe/host.env` must have, by name (see `.env.example`):
+
+- `BASE_URL=https://openvibe.host`
+- `HOST_SITES_DOMAIN=openvibe.host`
+- `HOST_CNAME_TARGET=cname.openvibe.host` (the DNS-only name; custom domains cannot CNAME to a
+ proxied name)
+- `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID=host` and a non-empty
+ `OV_OAUTH_CLIENT_SECRET`
+- a non-empty `HOST_FORM_SECRET`
+- `EVENTS_URL`
+- `TRUST_PROXY=1` or no `TRUST_PROXY` at all. Earlier copies of `.env.example` said 2. With the
+ realip-only headers either value is safe, and 1 is correct.
+
+Optionally set `HOST_MAX_PROJECTS_PER_OWNER` and lower `HOST_QUOTA_STORAGE_BYTES` for the first
+weeks.
+
+Save what the launch replaces, so the rollback is a copy:
+
+```bash
+B=/root/host-launch-$(date -u +%Y%m%d-%H%M%S); sudo mkdir -m 700 "$B"
+sudo cp -a /etc/nginx/sites-available/openvibe.host.conf /etc/nginx/sites-available/openvibe.host-tenants-pending.conf "$B"/
+sudo ovhost backup host
+echo "$B"
+```
+
+**Optional step 0 (host-wide, reviewed separately).** This addresses a threat-review finding:
+unverified custom domains reach nginx's default server. If `sudo nginx -T | grep default_server`
+shows no catch-all, add one before tenants can point domains at the host. Test it with
+`curl -sk --resolve nobody.example:443:127.0.0.1 https://nobody.example/`, which should fail the
+handshake.
+
+```nginx
+server { listen 80 default_server; listen [::]:80 default_server; server_name _; return 444; }
+server { listen 443 ssl default_server; listen [::]:443 ssl default_server; server_name _; ssl_reject_handshake on; }
+```
+
+## The window (in this order)
+
+**1. Update the `ovhost` CLI, which carries `replaces` and the templates.**
+
+```bash
+sudo git -C /usr/local/lib/openvibe-host -c safe.directory=/usr/local/lib/openvibe-host pull --ff-only
+sudo ovhost show host # nginx.tenants in /etc/openvibe/host.json: keep "replaces": ["openvibe.host-tenants-pending.conf"] (also the default)
+```
+
+**2. Deploy the Host API code.** Nobody uses it yet, and this is also the first real `ovhost deploy`:
+
+```bash
+sudo ovhost plan host
+sudo ovhost deploy host # restarts openvibe-host only, polls /api/ready, rolls back by itself if not ready
+curl -s http://127.0.0.1:4910/api/ready | jq '.checks' # includes disk_headroom
+curl -s -H 'Host: openvibe.host' http://127.0.0.1:4910/robots.txt # Allow: /$ … Sitemap: https://openvibe.host/sitemap.xml
+```
+
+If the pre-flight changed `/etc/openvibe/host.env`, run `sudo systemctl restart openvibe-host` and
+check readiness again.
+
+**3. Install the tenant vhost, and remove the pending vhost in the same change.**
+
+```bash
+sudo ovhost nginx tenants host # review: openvibe.host.conf + openvibe.host-custom-domains.conf;
+ # "--install also removes … openvibe.host-tenants-pending.conf"
+sudo ovhost nginx tenants host --install # writes both, removes the pending vhost, ONE nginx -t, ONE reload;
+ # a failed nginx -t restores all three files and reloads nothing
+ls -l /etc/nginx/sites-enabled/ | grep openvibe.host # openvibe.host.conf, openvibe.host-custom-domains.conf; no pending
+```
+
+`openvibe.host.conf` replaces the Sites placeholder vhost of the same name. The previous copy is in
+`$B`.
+
+**4. Sites.** Pull the commit that removed `openvibe.host`, then deploy. `deploy.sh` installs only
+the vhosts still in the repository, so it never touches `openvibe.host.conf` again:
+
+```bash
+sudo -u ubuntu git -C /opt/openvibe.sites pull --ff-only
+/opt/openvibe.sites/deploy/scripts/deploy.sh
+```
+
+**5. The Network registry.** Deploy the exposure commit the way Network is normally deployed (pull,
+restart `openvibe-network`, poll `/api/ready`).
+
+**6. Host's own records.** In a follow-up Host commit, set README **Status**, `STATUS.json`
+(`"deployed"`, `"placeholderInSites": false`) and the Launch rule note to *launched on <date>*.
+
+**Contracts** (no runtime effect, whenever the next release is cut): in
+`manifests/capabilities/host.site.manage.json`, add the takedown routes and sentence from this
+repository's `docs/capabilities-proposal/host.site.manage.json`:
+
+- `POST /api/v1/projects/:id/takedown`
+- `DELETE /api/v1/projects/:id/takedown`
+- `POST /api/v1/sites/:id/takedown`
+- `DELETE /api/v1/sites/:id/takedown`
+
+Optionally add payload schemas `contracts/events/payloads/host.deploy.created|activated|failed.v1.json`
+and `host.domain.verified.v1.json`, from the payloads in README [Events](../README.md#events).
+
+## Verify
+
+```bash
+# The dashboard, not the placeholder
+curl -s https://openvibe.host/ | grep -c 'Sign in with OpenVibe' # 1
+curl -s https://openvibe.host/ | grep -ci 'this page is a placeholder' # 0
+curl -s https://openvibe.host/ | grep -o '<meta name="robots"[^>]*>' # index, follow
+curl -s https://openvibe.host/robots.txt; curl -s https://openvibe.host/sitemap.xml
+curl -s https://openvibe.host/api/health; curl -s https://openvibe.host/api/ready | jq .status
+curl -s -o /dev/null -w '%{http_code}\n' https://openvibe.host/metrics # 404
+curl -sI https://www.openvibe.host/some/path | grep -i '^location' # https://openvibe.host/some/path
+# Tenant zone: Host answers, the pending vhost is gone
+curl -s https://no-such-site-xyz.openvibe.host/ # "Unknown host: no site is served at this address."
+curl -s -X POST https://no-such-site-xyz.openvibe.host/ -o /dev/null -w '%{http_code}\n' # 403 (nginx: GET/HEAD only)
+# Everything else unchanged
+for u in https://openvibe.live/api/ready https://openvibe.network/api/ready https://openvibe.codes/ https://openvibe.news/; do curl -s -o /dev/null -w "%{http_code} $u\n" "$u"; done
+# Registry
+curl -s https://openvibe.network/.well-known/openvibe | jq '.services[] | select(.id=="host") | {status, exposure}'
+curl -s https://openvibe.network/api/v1/status | jq '.services[] | select(.id=="host")'
+```
+
+Then run one real tenant deploy and an isolation check in production (audit item 9), in a browser
+signed in as an ordinary account:
+
+1. At `https://openvibe.host/`, create a **sandbox** project `launch-smoke` and a site
+ `launch-smoke-<yyyymmdd>`. Upload a folder with an `index.html` and a `secret.html`.
+2. Check the site:
+ - `curl -sI https://launch-smoke-<yyyymmdd>.openvibe.host/` returns 200, `x-robots-tag: noindex`,
+ a CSP and no `set-cookie`;
+ - `curl -s https://launch-smoke-<yyyymmdd>.openvibe.host/secret.html` returns the file.
+3. With a second account, create a second sandbox site. From it:
+ - `curl -s --path-as-is https://<second>.openvibe.host/../secret.html` returns 404;
+ - `curl -s 'https://<second>.openvibe.host/%2e%2e/secret.html'` returns 404;
+ - `curl -s --path-as-is https://<second>.openvibe.host/../../host.db` returns 404;
+ - neither answer contains the first site's content.
+4. As the second account, `GET https://openvibe.host/api/v1/sites/<first site id>` with its Bearer
+ token returns 404.
+5. As staff:
+ - `POST /api/v1/sites/<first>/takedown` with `{"reason":"launch smoke"}`, then the site answers
+ 451 with `clear-site-data`;
+ - `DELETE …/takedown`, and it serves again;
+ - delete both sandbox projects.
+6. Record the result in a row in [restore-drills.md](restore-drills.md)'s style, or in the launch
+ commit message.
+
+## Roll back
+
+The rollback restores the old state in this order. Each step is independent and takes seconds.
+
+**1. nginx.** Put the placeholder and the pending vhost back, and remove the tenant vhosts:
+
+```bash
+B=/root/host-launch-<stamp>
+sudo install -m 644 "$B/openvibe.host.conf" /etc/nginx/sites-available/openvibe.host.conf
+sudo install -m 644 "$B/openvibe.host-tenants-pending.conf" /etc/nginx/sites-available/openvibe.host-tenants-pending.conf
+sudo ln -sf /etc/nginx/sites-available/openvibe.host-tenants-pending.conf /etc/nginx/sites-enabled/
+sudo rm -f /etc/nginx/sites-enabled/openvibe.host-custom-domains.conf /etc/nginx/sites-available/openvibe.host-custom-domains.conf
+sudo nginx -t && sudo systemctl reload nginx
+curl -s https://openvibe.host/ | grep -ci 'placeholder' # ≥ 1 once Sites' dist/openvibe.host is back (step 2)
+```
+
+**2. Sites.** Revert the removal commit, then pull and run `deploy.sh`. This rebuilds
+`dist/openvibe.host` and reinstalls `deploy/nginx/openvibe.host.conf`, the same file as `$B`'s.
+
+**3. Network.** Revert the exposure commit and deploy it.
+
+**4. Host API code, only if the code is the problem.** Run `sudo ovhost rollback host`. It goes to
+the release before the launch deploy (release log) and restarts `openvibe-host` only.
+
+Tenant data needs no rollback. Projects, deploys and objects stay in `host.db` and the object store,
+and they serve again at the next launch. If tenants existed during the window, tell them their sites
+are paused.
+
+## Launch readiness (2026-09-23)
+
+Each launch-rule item, checked against the code at this commit:
+
+| # | Launch-rule item | Evidence | Verdict |
+|---|---|---|---|
+| 1 | Owning runtime with health/readiness and observability | `server/index.js` + `deploy/systemd/openvibe-host.service`. `GET /api/health`, `/api/ready` (`server/observability.js`: required `db`, `storage`; optional `disk_headroom`, `network_jwks`, `events_relay`, `domain_checks`), `/release.json`, `/metrics` (loopback only; HTTP golden signals, process, release, `host_sites`). Running on loopback `:4910` with ready 200 (audit, 2026-09-23). `host-secrets-dashboard` "machine endpoints". | **met** |
+| 2 | Canonical identity (Network subjects, scoped principals) | Network SSO OAuth client `host` (Network `server/db/database.js:516`); users are `usr_` subjects from Network JWTs verified against the JWKS (`server/auth/viewer.js`); services present client-credentials tokens for audience `openvibe.host` with one capability per route; `X-OV-Subject` for first-party services only; app principals act as themselves; sandbox tokens refused on production. Principal `svc:host` has `events.event.publish` (Network `server/identity/principals.js:100`). `host-quota-auth` tests. Projects are Host's own until Network has projects (ADR-014, documented). | **met** |
+| 3 | SSR/static public routes useful without JS | Every dashboard page is server-rendered; every action is a plain form, including folder upload. Shared chrome with `<noscript>` nav and SSR footer. Legal pages. Tenant sites are static by definition. `host-secrets-dashboard` "signed-out home page … useful without JavaScript". | **met** |
+| 4 | Real persistence and end-to-end workflows | SQLite `host.db` (WAL) plus the content-addressed object store; upload → validate → store → activate → serve → rollback → delete, custom domains through verification and lapse, all over HTTP in the tests (`host-lifecycle`, `host-rollback`, `host-domains`). `host.db` is in `ovhost backup` and the restore drill passed on the host (2026-09-23). Objects are not backed up (accepted for alpha, threat review §6). | **met** (objects backup: follow-up) |
+| 5 | Capability and event registration against Contracts | `host.site.manage`, `host.deploy.create` and `host.domain.manage` and the `host` service manifest (with all four `host.*` events in `eventsProduced`) are released in `openvibe-contracts` **v0.24.0**, which Host pins, and are identical in **v0.30.1** (checked against the tag: every field matches `docs/capabilities-proposal/` as of `8575c91`). `npx openvibe-contracts-check --service host` passes, and CI runs it as a blocking step. Missing from the release: the four takedown routes in `host.site.manage.implementedBy` (this change; the exact addition is under *Contracts* above). No `host.*` payload schemas: optional, most services have none. | **met** (Contracts follow-up for takedown routes) |
+| 6a | Migration/seed strategy | Bootstrap, not migration: Host replaces no legacy data (roadmap §8 register). The schema is created idempotently at every boot (`server/db.js`, `CREATE … IF NOT EXISTS`); `host_takedowns` is additive and appears on the next restart. There is no seed: a new database is an empty, valid Host. The pointer and data survive a restart (`host-rollback`). A future non-additive change needs a migration ledger, which Host does not have yet. | **met** |
+| 6b | Security/threat review | [threat-review.md](threat-review.md): tenant isolation, uploads, custom domains, cookies, the PSL decision, quotas, abuse and takedown, Host's own surface. Seven gaps were fixed in `1e48cb2` and `0ff29d5`: takedowns, the upload concurrency cap, the CA-validation path refusal, the CDN cache cap, the realip templates, the disk floor with the owner project cap, and Clear-Site-Data. | **met** |
+| 6c | Sitemap/robots/feed behaviour | Dashboard host: `/robots.txt` allows `/` and the legal pages and names `/sitemap.xml`; the signed-out front page is `index, follow` with a canonical URL; everything behind sign-in is `noindex` and `private, no-store`. Tenant sites: their own uploaded `robots.txt`/`sitemap.xml`; sandbox projects get `X-Robots-Tag: noindex`. No feed: a hosting control plane publishes no stream of public items, and tenants publish their own feeds. | **met** |
+| 7 | Acceptance tests | `npm test` (20 files). Stage B: `host-isolation`, `host-uploads`, `host-quota-auth`, `host-rollback`, `host-domains`, `host-secrets-dashboard`, `host-lifecycle`, `host-contracts-events`, `host-abuse`, `nginx-tenants`. The exit criterion "a hosted static project cannot read another tenant's objects" is `host-isolation` (path, Host header, CORS, oracles, delegation, dashboard) plus `host-abuse` (CA paths). | **met** in CI; the production isolation check is part of *Verify* |
+
+**Verdict: ready to launch** through the steps above, once the owner says go. Nothing in the code
+blocks it. Before or at launch the owner must:
+
+- **confirm the abuse contact:** `https://openvibe.host/dmca` names `[email protected]`, which must
+ be monitored for Host reports, with a named person to act on them;
+- **accept the alpha limits**, or ask for the follow-ups first:
+ - tenant objects are not backed up;
+ - no Public Suffix List listing, so cookie bombs across tenants remain possible;
+- optionally, **approve the host-wide catch-all `default_server`** (step 0). Without it, custom domains
+ that are not yet verified reach whatever site nginx treats as default.
+
+The D41 production proof (`scripts/d41-proof.sh`, [d41-proof.md](d41-proof.md)) is a separate Wave 21
+exit item and is not required by the launch rule.
@@ -111,7 +111,7 @@ list endpoints differ, and the drill reports that honestly. Take the backup righ
@@ -111,7 +111,7 @@ list endpoints differ, and the drill reports that honestly. Take the backup righ
### Services
### Services
-Supported with declared overrides (not yet run on the host with `ovhost drill`):
+Supported with declared overrides (the table above records which have passed on the host):
- network
- network
- community
- community
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -0,0 +1,287 @@
+# Threat review: OpenVibe.Host Stage B (tenant static hosting)
+
+Launch-rule item 6 (README, [Launch rule](../README.md#launch-rule)) and roadmap Track S require a
+threat review before a service leaves placeholder status. This review covers Stage B as of this
+commit:
+
+- the Host API service (`server/`, port 4910);
+- the tenant vhosts that `ovhost nginx tenants` renders (`lib/nginx.js`, `templates/nginx/`).
+
+Stage A (`ovhost`) is covered only where it touches Stage B: vhost installation and certificates.
+Stage C (tenant code) does not exist, and nothing in Stage B executes tenant content on the server.
+
+Reviewed 2026-09-23. Fixes made during the review are in commits `1e48cb2` and `0ff29d5`. The
+Status column says which risks are **fixed**, **mitigated**, **accepted** (with the reason) or
+**open**.
+
+## What is protected, and from whom
+
+| Asset | Where it lives |
+|---|---|
+| Other tenants' files, deploy history, logs and domains | `host_*` tables in `/var/lib/openvibe-host-api/host.db`; objects in `<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>` |
+| OpenVibe sessions (Network SSO tokens, other products' cookies) | browsers; Network |
+| The dashboard session and form tokens | `__Host-ov_host_session` cookie; `HOST_FORM_SECRET` |
+| Host's own credentials (`OV_OAUTH_CLIENT_SECRET`, `HOST_FORM_SECRET`) | `/etc/openvibe/host.env` (0600) |
+| Certificate private keys, DNS provider credentials | `/etc/letsencrypt`, `/root/.secrets` (root only; never read by Host) |
+| The host itself: disk, memory and CPU shared with Live, Media, Chat and every database | the one production machine |
+| Visitors of tenant sites | their browsers |
+
+The adversaries are:
+
+- a tenant, who uploads arbitrary HTML/JS/SVG/PDF and chooses file names, site names and custom
+ domains;
+- a visitor of a tenant site;
+- anyone on the internet, with arbitrary `Host` headers, request targets and upload bodies;
+- someone who controls a domain another tenant used to own.
+
+## 1. Tenant isolation
+
+**Serving: one site, one deploy, one project's objects.** Status: fixed by design and tested.
+
+`server/http/tenant.js` resolves the site once from the `Host` header:
+
+- `normaliseHost` (`:42`) accepts a lowercase name with an optional port and a trailing dot, and
+ nothing else;
+- `resolve` (`:92`) matches exactly one label under the sites domain, or a *verified* custom domain;
+ anything deeper or unknown gets `404 Unknown host`.
+
+It reads the site's active deploy pointer once, then only that deploy's immutable rows
+(`host_deploy_files`, and the triggers in `server/db.js:172-183`). The request path is
+percent-decoded segment by segment, and encoded separators (`%2f %5c %00`) are refused
+(`candidates`, `:108`). The decoded path must pass the same `checkPath` as uploads
+(`server/artifacts/paths.js:26`), and is then looked up in the manifest table, never on the
+filesystem. The bytes are opened at `blobs.pathFor(row.project_id, row.sha256)` (`:154`,
+`server/storage.js:35`): a validated `prj_` id plus a validated lowercase sha256. That path is never
+built from the URL. Absolute-form request targets must name the same host as `Host` (`:186-196`).
+
+Identical bytes uploaded by two projects are stored twice. Deleting one project's copy can never
+break another's, and no tenant can confirm that another tenant has some content by uploading it.
+
+`test/host-isolation.test.js` covers:
+
+- 25 path tricks and 14 `Host` header tricks;
+- absolute-form targets;
+- tenant hosts never reaching the API, auth, metrics or readiness;
+- per-project storage;
+- 18 API routes answering 404 to non-members;
+- a tenant pointing its own site at another tenant's deploy.
+
+This review added:
+
+- no CORS grant on tenant responses and no preflight success, so a script on `alpha` cannot read
+ `beta`'s files in a visitor's browser;
+- the Host API granting CORS to no tenant origin;
+- another tenant's sha256 in `If-None-Match` or `Range` still getting a 404 (no existence oracle);
+- a first-party service delegating for a person with `X-OV-Subject`, and an app principal, staying
+ inside their own project (an app cannot delegate at all: 400);
+- the dashboard showing a non-member no names, file paths or hashes.
+
+**API and dashboard authorization.** Status: tested.
+
+Every project-scoped read and write goes through `access.authorize`
+(`server/domain/access.js:61-80`). A caller who is not a member gets the same 404 as for an id that
+does not exist. Staff (Network role `admin`) can read and delete, but cannot deploy or activate: the
+`deploy` need is refused to non-members, staff included. Reads of deploy lists, deploys and logs now
+need `read` (the lowest member role, as before), so staff can review a reported site. Service tokens
+must carry the route's capability (`guard`, one per route; `test/host-contracts-events.test.js`
+checks every route). A sandbox token is refused on production projects.
+
+**Same-site dashboard.** Status: mitigated, see §4 and §5.
+
+Tenant pages on `<site>.openvibe.host` are *same-site* with the dashboard on `openvibe.host`. So:
+
+- the API ignores cookies entirely (`server/http/api.js` routes use Bearer tokens only;
+ `host-isolation` "the API ignores cookies");
+- every dashboard POST needs the dashboard's own `Origin` (or `Referer`) **and** an HMAC form token
+ (`server/auth/forms.js:21-35`);
+- the dashboard trusts only `__Host-` cookies (`server/auth/sso.js:33`).
+
+**Platform cookies never reach tenants.** Status: fixed by design.
+
+Tenant sites are on `openvibe.host`, a registrable domain separate from every cookie-bearing
+OpenVibe domain. `ovhost nginx tenants` refuses a sites domain under `openvibe.network|live|media|community|tools`
+(`lib/nginx.js:181`). nginx:
+
+- strips `Cookie` and `Authorization` from tenant requests;
+- hides `Set-Cookie` from tenant responses;
+- allows only `GET`/`HEAD`, with a 1 KB body limit (`templates/nginx/tenant-location.tmpl:3-19`).
+
+Network treats `*.openvibe.host` as user content: it is never trusted for SSO framing, FedCM or
+sign-in handoffs (OpenVibe.Network `server/auth/sso-owned.js:10-19`, `USER_CONTENT_ZONES`).
+
+**Headers on tenant responses.** Status: fixed.
+
+Every tenant response carries:
+
+- `X-Content-Type-Options: nosniff`;
+- a CSP (`default-src 'self'; script-src 'self'; … object-src 'none'; frame-ancestors 'self'`,
+ `server/http/tenant.js:24`);
+- `Cross-Origin-Opener-Policy: same-origin`;
+- a restrictive `Permissions-Policy`;
+- `X-Robots-Tag: noindex` for sandbox projects.
+
+The CSP limits what a tenant page itself can do: no inline scripts, no third-party scripts. It is a
+product limit as much as a protection, and README documents it.
+
+## 2. Uploads
+
+**Nothing is extracted to disk.** Status: fixed by design.
+
+`server/artifacts/archive.js` parses tar and tar.gz from a Buffer. Entries become `{ path, data }`,
+the validator builds a manifest, and each file is stored by the sha256 of its bytes. No archive entry
+name ever becomes a filesystem path, which is why path traversal, symlinks and hard links cannot
+escape a directory: there is no directory.
+
+| Threat | Handling | Status |
+|---|---|---|
+| **Path traversal** (`..`, absolute, `\`, NUL/control, non-ASCII, empty segments, depth over 32; pax and GNU long-name overrides get the same checks) | `checkPath` (`paths.js:26-54`) on every name (`validate.js`), after `./` normalisation; the whole upload fails. `test/host-uploads.test.js`. | fixed |
+| **Symlinks, hard links, devices, FIFOs, sparse and other special entries** | refused, never skipped (`archive.js:107-112`); a pax `linkpath` on a regular entry is refused too | fixed |
+| **Gzip bomb** | `gunzipSync` with `maxOutputLength` = the smaller of the project's storage quota and `HOST_MAX_UNPACKED_BYTES` (256 MiB), plus header slack (`archive.js:67-75`, `upload.js:153`); a 40 MB bomb is in the tests | fixed |
+| **Zip bomb** | `.zip` files are published as opaque bytes (`application/zip`) and never opened. A zip is not accepted as the deploy archive: it fails as "not a tar archive". | fixed |
+| **Tar bomb** (millions of tiny entries, huge headers) | file count (`archive.js:115`), bytes per file and total bytes (`:116-118`) are checked as the archive is read; pax headers are bounded by the archive itself | fixed |
+| **Oversized body** | `Content-Length` checked before reading and the stream capped while reading (`upload.js:36-50,122`); the connection is closed, not drained; nginx caps upload routes at 110m and tenant hosts at 1k | fixed |
+| **Memory exhaustion by concurrent uploads** (each held in memory: up to 100 MiB raw plus 256 MiB unpacked) | **added:** at most `HOST_MAX_CONCURRENT_UPLOADS` (default 2) uploads are validated at once, service-wide (`upload.js:181`, `api.js:148`, dashboard); others get `503 upload.busy` + `Retry-After` before a byte is read, and are not recorded as the tenant's failure | fixed (`1e48cb2`) |
+| **Event-loop stall** (`gunzipSync` runs on the main thread: decompressing 256 MiB blocks serving for roughly a second) | bounded by the concurrency cap, the per-project deploys-per-day quota (checked before the body is read, failed uploads count) and nginx `limit_req` on the upload route | accepted: move decompression to a worker thread if uploads become frequent |
+| **Server-side code, credentials, databases** (`php`, `cgi`, `sh`, `jar`, `.env`, `pem`, `sqlite`, …) | refused with a reason (`paths.js:56-63`), so nobody deploys them thinking they run or stay private | fixed |
+| **Content types** | allowlist by extension (`paths.js:65-84`); extension-less files are `text/plain`; everything is served with `nosniff` and the manifest's type, never a sniffed one | fixed |
+| **Active content** (HTML, SVG, XML/XSLT, PDF, WebAssembly) | runs only on the tenant's own origin, under the tenant CSP; never on `openvibe.host` or a first-party origin. Same model as GitHub Pages. | accepted by design |
+| **Hidden files** (`.git/`, `.env`, `.htaccess`) | refused; only `.well-known/` is published | fixed |
+| **Certificate-validation files** (`.well-known/acme-challenge/`, `.well-known/pki-validation/`), which would let a tenant prove control of `<site>.openvibe.host` to a CA over HTTPS | **added:** refused in uploads and never served (`paths.js:49-53`); the operator issues every certificate | fixed (`1e48cb2`) |
+| **Duplicate paths, file/directory conflicts, empty uploads** | refused (`validate.js:43,58`) | fixed |
+| **A refused upload leaves partial state** | nothing is stored; the refusal is a `failed` deploy with its log and `host.deploy.failed`; the active deploy keeps serving | fixed |
+| **Filling the shared disk** | **added:** deploys are refused with `507 storage.host_full` while the object store's filesystem has less than `HOST_MIN_FREE_BYTES` (default 5 GiB) free (`deploys.js:78-86`); `/api/ready` reports `disk_headroom`. Serving is unaffected. See also §6. | fixed (`0ff29d5`) |
+
+## 3. Custom domains
+
+| Threat | Handling | Status |
+|---|---|---|
+| **Claiming someone else's domain** | a custom domain is served only after `_openvibe-host.<hostname>` TXT = `openvibe-host-verification=<random token>`; the first verified proof wins (unique index, `db.js`); `test/host-domains.test.js` | fixed |
+| **Dangling-CNAME takeover** (a former tenant's DNS still points at Host) | nobody can serve on that name without the TXT record, which only the domain owner can publish | fixed |
+| **Claiming OpenVibe names** | refused: `openvibe.<any tld>` and subdomains, every domain in the released service manifests, the sites domain and the dashboard (`domains.js:38-47,87-96`) | fixed |
+| **Keeping a domain after losing it** | verified domains are re-checked daily; a TXT record gone for `HOST_DOMAIN_LAPSE_DAYS` lapses the domain and it stops being served; pending domains fail after `HOST_DOMAIN_PENDING_DAYS` (`server/worker.js`, `domains.js:186-`) | fixed |
+| **Hostile values reaching nginx** (a tenant-controlled hostname in a vhost) | `ovhost nginx tenants` re-validates every verified hostname from the database and drops anything that is not a plain host name or that falls under the sites domain (`lib/nginx.js:227`); `test/nginx-tenants.test.js` injects `evil.org; } server {…` | fixed |
+| **TLS keys through the API** | never: certificates are issued by the operator with certbot (webroot for custom domains, DNS-01 for the wildcard); `ovhost certs` never reads a key file (`lib/certs.js:13`); the DNS provider credential is root-only and never passed to Host | fixed |
+| **Unverified domains reaching another site** (a name CNAMEd to `cname.openvibe.host` before verification, or after lapsing, reaches nginx's *default server*, which served another site on 2026-09-22) | host-wide nginx, not Host: add a catch-all `default_server` that returns 444 on port 80 and rejects the TLS handshake on 443 (docs/launch.md, optional step 0) | **open** (host-wide; recommended at launch) |
+| **Sandbox projects** | cannot add custom domains (`domains.js:115`) | fixed |
+
+## 4. Cookies and sessions
+
+| Threat | Handling | Status |
+|---|---|---|
+| A tenant page reads the dashboard session | the session is `__Host-ov_host_session`: HttpOnly, Secure, host-only; tenant origins are different hosts; nginx strips `Cookie` from tenant requests | fixed |
+| **Cookie tossing** (a tenant sets `Domain=openvibe.host` cookies to fix a session or fake a sign-in) | the server trusts only `__Host-` cookies, which a subdomain cannot set (`sso.js:33`); a planted `ov_token` is ignored (`host-secrets-dashboard` "a planted ov_token cookie … is not a session") | fixed |
+| **Login CSRF / forged dashboard actions from a same-site tenant page** | state cookie `__Host-` + OAuth `state`; every POST needs the dashboard `Origin` and the HMAC form token (`forms.js`); tested with same-site tenant origins | fixed |
+| **Logout CSRF** (`GET /auth/logout` from a tenant page signs the visitor out of the dashboard) | nuisance only | accepted |
+| **Cookie bomb** (a tenant sets many large `Domain=openvibe.host` cookies, so the visitor's browser sends oversized headers and nginx answers 400 for `openvibe.host` and every tenant site until the cookies expire or are cleared) | only the Public Suffix List stops a subdomain from setting parent-domain cookies (§5) | **accepted** for alpha; see §5 |
+| `ov_token` (the Network access JWT) is JS-readable on `openvibe.host` for the shared navbar | host-only cookie: tenants cannot read it. A dashboard XSS could, and the dashboard CSP allows inline scripts (for the shared chrome). Tenant-controlled values (project names, file paths, log lines, host names) go through `esc()` in `server/render/pages.js`; ids are server-generated and site names match `[a-z0-9-]`. | mitigated; revisit when the shared chrome no longer needs inline script |
+| Clickjacking the dashboard | `frame-ancestors 'none'`, `X-Frame-Options: DENY` (`server/app.js:47-60`) | fixed |
+
+## 5. The Public Suffix List question
+
+`openvibe.host` is not on the PSL, so browsers treat every `<site>.openvibe.host` as the same *site*
+as `openvibe.host` and as each other. Listing it would:
+
+- **stop cookie tossing and cookie bombs across tenants:** a tenant could no longer set
+ `Domain=openvibe.host` cookies;
+- make each tenant its own *site* for `SameSite`, storage partitioning and site isolation;
+- also make `openvibe.host` itself a public suffix. The dashboard lives on that name.
+ - Host-only cookies without a `Domain` attribute, which is what `__Host-` requires and what Host
+ uses, remain possible on a host that equals a public suffix (RFC 6265 §5.3 step 5). This must be
+ confirmed in current Chrome, Firefox and Safari before submitting.
+ - The shared navbar's `ov_token`/`ov_sso_hint` cookies are also host-only.
+- take weeks to be accepted and months to reach browsers. It requires a `_psl` TXT record and a
+ registration that stays paid for at least two more years, and removal is equally slow.
+
+None of Host's protections depend on the PSL (§1, §4), and tests show each one without it.
+
+**Decision for launch:** launch without the PSL, and accept the cookie-bomb nuisance. It affects only
+visitors who open a malicious tenant page, and staff can take that site down at once (§7).
+
+**Recommended follow-up:** move the dashboard and API to a name outside the tenant zone (for example
+`host.openvibe.network`, or keep `openvibe.host` for the dashboard and serve tenants from a second
+registrable domain). Then submit the tenant zone alone to the PSL, the way GitHub separates
+`github.com` from `github.io`. This needs an owner decision on the domain.
+
+## 6. Quotas and resource abuse
+
+| Limit | Default (production / sandbox) | Where |
+|---|---|---|
+| storage per project (deduplicated) | 1 GiB / 100 MiB | `server/config.js:91-92` (`HOST_QUOTA_*`, `HOST_SANDBOX_QUOTA_*`), staff override per project |
+| deploys per project per rolling 24 h (failed ones count; checked before the body is read) | 50 / 20 | `deploys.js:88-90` |
+| files per deploy; bytes per file | 10 000, 25 MiB / 2 000, 10 MiB | `archive.js:115-116`, `validate.js` |
+| sites per project; custom domains per project | 10, 5 / 3, 0 | `sites.js:67`, `domains.js:117` |
+| **projects per person** | **10** (was a fixed 50), `HOST_MAX_PROJECTS_PER_OWNER` | `projects.js:84-85` (`0ff29d5`) |
+| request size; unpacked size; concurrent uploads | 100 MiB; 256 MiB; 2 | `config.js:72-83` |
+| free disk below which deploys stop | 5 GiB, `HOST_MIN_FREE_BYTES` | `deploys.js:78-86` |
+| rate limits | API 240/min, dashboard 300/min, `/auth` 60/15 min per IP (`app.js`); nginx `limit_req` per zone | `templates/nginx/tenants.conf.tmpl:10-12` |
+
+The worst case per person is now 10 GiB of storage, down from 50 GiB. Network accounts are free,
+so many accounts could still add up. The disk floor keeps that from ever filling the disk the whole
+platform shares: uploads stop, and serving and every other service go on.
+
+Serving costs no disk and little CPU. Tenant traffic is behind Cloudflare
+(`*.openvibe.host` is proxied), and nginx rate-limits each client address, set by realip from
+Cloudflare. **Added:** every template ovhost renders now sends `X-Forwarded-For` from
+`$remote_addr` only (`1e48cb2`). The templates had still appended the client's own header, so a
+request reaching the origin directly could choose the address that rate limits and logs use. The
+installed vhosts were fixed by hand on 2026-09-23 (`30592c0`). `TRUST_PROXY` now defaults to 1.
+
+Accepted for alpha: objects live only on the local disk and are not in `ovhost backup` (only
+`host.db` is). A lost disk loses tenant sites, and tenants keep their source. README "Not done yet"
+says so.
+
+## 7. Abuse and takedown
+
+Before this review, staff could only **delete** a site or a project. Deletion stops serving but also
+destroys the content, which a report may need reviewed or preserved. **Added** (`1e48cb2`,
+`server/domain/takedowns.js`):
+
+- **Staff take down a site or a whole project:** `POST /api/v1/sites/:id/takedown` or
+ `/projects/:id/takedown` with `{ "reason": "…" }` (staff only, capability `host.site.manage`).
+ Serving stops at once on the default and every custom domain: `451`, no tenant bytes, `no-store`,
+ and `Clear-Site-Data: "cache", "storage"`, so visitors' browsers drop what the site cached, stored or
+ installed as a service worker (`0ff29d5`).
+- **Evidence is kept:** deploys, file rows, logs and objects stay. Staff can read the deploys and
+ their file lists and logs through the API. The bytes are at
+ `<HOST_STORAGE_DIR>/projects/<prj>/<aa>/<sha256>`.
+- **Members see why:** the reason appears on the site and project in the API and as a dashboard
+ notice. They cannot upload, activate, roll back or delete anything the takedown covers.
+- **Staff lift it** (`DELETE …/takedown` with an optional `note`) or delete the content. Every
+ takedown and lift stays in `host_takedowns`.
+- **Tests:** `test/host-abuse.test.js`.
+
+**Procedure** for a report sent to the address on `https://openvibe.host/dmca` (the shared legal
+pages, profile `ugc`, which name `[email protected]`):
+
+1. Take the site down (a project takedown for a pattern across sites).
+2. Purge Cloudflare's cache for the site's host name. Fingerprinted assets otherwise stay at the edge
+ for up to an hour: Host now sends `CDN-Cache-Control: public, max-age=3600` on them (`1e48cb2`), while
+ browsers keep them for a year as `immutable`. HTML and other files revalidate on every request.
+3. Review and preserve what the law requires.
+4. Lift or delete.
+
+**Needs an owner:** confirmation that `[email protected]` is monitored for Host reports too, and a named person who acts on them. The
+tooling exists; the duty does not yet. The Events log (`host.deploy.*`) and the upload logs give
+the history of any site.
+
+## 8. Host's own surface
+
+| Item | Handling | Status |
+|---|---|---|
+| `/metrics` | direct loopback callers only; nginx returns 404 (`tenants.conf.tmpl:72`) | fixed |
+| `/api/ready`, `/api/health`, `/release.json` | no secrets, no tenant data (`host-secrets-dashboard`) | fixed |
+| Secrets in responses | allowlisted serializers (`server/http/serialize.js`); tested for the client secret, form secret, uploaded `.env` values and credential-like strings | fixed |
+| Tenant secrets | Stage B stores none: no environment, build secrets or deploy keys | fixed by design |
+| Tenant code execution | none: no build step, no server-side code | fixed by design |
+| The tenant vhost install | transactional (`nginx -t`, restore on failure); **added:** removes the interim pending vhost in the same change (`lib/nginx.js:100-`, `nginx.tenants.replaces`) | fixed (`1e48cb2`) |
+
+## Residual risks
+
+| Risk | Severity | Owner |
+|---|---|---|
+| Unverified or lapsed custom domains reach nginx's default server | medium (confusion, mis-served content on a stranger's domain) | operator: catch-all `default_server` (docs/launch.md step 0) |
+| Cookie bomb across tenants and the dashboard (no PSL) | low (per-visitor nuisance; takedown exists) | owner decision (§5) |
+| No abuse mailbox/owner | medium (process, not code) | owner |
+| Objects not backed up | medium for tenants (alpha, documented) | follow-up: add the object store to the backup run |
+| `gunzipSync` blocks the event loop per upload | low (bounded) | follow-up if uploads grow |
+| Takedown routes not in released `openvibe-contracts` `host.site.manage.implementedBy` | none at runtime (the contract check passes) | Contracts: next release (docs/launch.md) |