openvibers/openvibe.host Commits

Stage B launch readiness: threat review, launch release steps with verification and rollback, and the launch-rule evidence per item

OP OpenVibers committed 23 Sept 2026 · 2bfc62d91b · 5 files, +577 −17

- docs/threat-review.md: tenant isolation (serving, storage, API, same-site dashboard, platform cookies, response headers), uploads (traversal, links and special entries, gzip/zip/tar bombs, memory, content types, active content, CA-validation files, the shared disk), custom domains (TXT proof, dangling CNAMEs, first-party names, lapse, hostile values, TLS, the default-server finding), cookies and sessions (tossing, login/logout CSRF, cookie bombs, ov_token), the Public Suffix List decision (launch without it; move the dashboard off the tenant zone, then list the zone), quotas and resource abuse, abuse and takedown procedure, Host's own surface, residual risks with owners. Fixes made during the review: 1e48cb2, 0ff29d5.
- docs/launch.md: prepared commits (Sites removal as d3b71af did for codes; Network exposure.js + registry-exposure test pins), read-only pre-flight (status, ready, validate, certs, DNS, realip, vhosts, disk, env names incl. HOST_CNAME_TARGET and TRUST_PROXY), saving what is replaced, an optional host-wide catch-all default_server; the window in order (ovhost CLI, ovhost deploy host, ovhost nginx tenants host --install which removes openvibe.host-tenants-pending.conf in the same nginx -t/reload, Sites pull + deploy, Network registry, Host records, the Contracts addition); verification curls plus a production tenant deploy and isolation check; rollback per layer; the launch-rule table with evidence (all seven items met in code and tests) and the verdict: ready once the owner confirms the abuse contact and accepts the alpha limits.
- README/STATUS.json: Stage B runs on loopback and is not launched; the host.* capabilities and manifest are released in openvibe-contracts v0.24.0 (unchanged in v0.30.1) and the contract check is blocking; takedowns, busy/full upload answers, the PSL decision and the unbacked-up objects documented; new acceptance tests listed.

Changes

ddiff --git a/README.md b/README.md
iindex 593c3b2..759ca98 100644
---- a/README.md
++++ b/README.md
@@@ -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`, switches
registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
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).
ddiff --git a/STATUS.json b/STATUS.json
iindex b002828..b2952a8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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
}
ddiff --git a/docs/launch.md b/docs/launch.md
nnew file mode 100644
iindex 0000000..c504aa4
---- /dev/null
++++ b/docs/launch.md
@@@ -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.
ddiff --git a/docs/restore-drills.md b/docs/restore-drills.md
iindex 931e5c0..f6248f6 100644
---- a/docs/restore-drills.md
++++ b/docs/restore-drills.md
@@@ -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
- community
ddiff --git a/docs/threat-review.md b/docs/threat-review.md
nnew file mode 100644
iindex 0000000..b45efa9
---- /dev/null
++++ b/docs/threat-review.md
@@@ -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) |