openvibers/openvibe.host Commits

Stage B docs, deploy and CI: README Stage B section (model, uploads, serving, API, events, grants, operator steps incl. DNS and the wildcard certificate; Stage C not started), STATUS, systemd unit, CI syntax check of server/ and a non-blocking contract check until the host.* capabilities are released

OP OpenVibers committed 23 Sept 2026 · a157b8f912 · 4 files, +234 −22


Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex c632d07..c408f50 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -15,6 +15,10 @@ jobs:
- run: npm ci --no-audit --no-fund
- run: npm test
- name: syntax check
-- run: for f in bin/ovhost lib/*.js lib/commands/*.js; do node --check "$f"; done
++ run: for f in bin/ovhost lib/*.js lib/commands/*.js server/*.js server/*/*.js; do node --check "$f"; done
++ # Stage B's capabilities (host.site.manage, host.deploy.create, host.domain.manage) are
++ # proposals in docs/capabilities-proposal/ until the lead releases them in openvibe-contracts;
++ # until then the check reports them as undefined. Make this step blocking again with that release.
- name: contract check (openvibe-contracts)
-- run: npx openvibe-contracts-check --service host --src lib
++ continue-on-error: true
++ run: npx openvibe-contracts-check --service host --src lib --src server
ddiff --git a/README.md b/README.md
iindex b9c92e9..f306111 100644
---- a/README.md
++++ b/README.md
@@@ -2,8 +2,8 @@
> 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 has **not** been installed on the production host yet, and no service's deploy has moved to it. Stages B and C are **not started**.
--**Domain:** `openvibe.host`. It keeps its placeholder page on [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites): a CLI for operators is not a public product.
++**Status:** alpha. Stage A (operator plane) is a CLI, `ovhost`, tested against a fake host. It has **not** been installed on the production host yet, and no service's deploy has moved to it. Stage B (tenant static hosting) is a service, written and tested, **not deployed**. 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).
@@@ -32,31 +32,37 @@ Stage A adds a few safety rules of its own:
## Owns
- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, SQLite backup hooks.
--- Later: tenant projects, immutable deploy artifacts, domains/TLS, build logs, quotas (Stage B); sandbox profiles, budgets, secret references, outbound policy (Stage C).
++- Stage B: tenant projects (keyed by project id), static sites, immutable content-addressed deploy artifacts, activation and rollback, default and custom domains (DNS TXT verification), per-project quotas, upload/validation logs, the tenant vhosts (`ovhost nginx tenants`).
++- Later (Stage C): sandbox profiles, budgets, secret references, outbound policy.
## Does not own
- Product business logic. Host calls product hooks (`build`) and reads product endpoints (`/api/ready`, `/api/streams`). It never embeds product rules.
- Secrets. `ovhost` reads env files only to learn variable **names** and whether each one is empty. It never stores, prints or snapshots a value.
- Platform-wide credentials for hosted code (never).
--- Certificate issuance/renewal (certbot does this today). Stage A only takes inventory.
++- Certificate issuance/renewal (certbot does this today). Stage A only takes inventory; Stage B never handles a certificate or key through its API.
++- Projects as an identity concept: ADR-014 puts projects in OpenVibe.Network. Until Network has them, Host creates a `prj_` project for its owner and records `network_project_id` when one is given.
++- Tenant secrets. Stage B stores none (no environment variables, no build secrets, no deploy keys).
## Depends on
--- OpenVibe.Contracts (`openvibe-contracts` v0.7.0): service manifests, for vhost rendering and snapshots.
--- OpenVibe.Network, OpenVibe.Media, OpenVibe.Events: Stage B/C only.
++- 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.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`.
++- OpenVibe.Media: not yet. The roadmap stores artifacts "through Media where practical"; Stage B keeps them on local disk for now (see [Not done yet](#not-done-yet-stage-b)).
## Stages
| Stage | Scope | State |
|---|---|---|
| **A: operator plane** | `ovhost` CLI and library: inventory, validate, plan, deploy (`--wait-idle`/`--force`), automatic rollback, rollback, status, releases, certs, nginx render/install, snapshot, backup. No daemon and no server. Port **4910** is reserved for a later operator API. | **alpha**: written and tested (fake host, plus the real executor against temp SQLite/HTTP/git). Not installed on the host. |
--| **B: tenant static hosting** | Immutable deploy artifacts stored through Media, domains and TLS, quotas, build logs, rollback. | **not started** |
--| **C: sandboxed user code** | Isolation profiles, CPU/memory/time/network/storage budgets, secret references, outbound policy, metering, kill/revoke without touching platform services. | **not started**. Blocked until isolation and metering are proven. |
++| **B: tenant static hosting** | The Host API service (port 4910): projects, sites, immutable content-addressed deploys, activation and rollback, `<site>.openvibe.host` and TXT-verified custom domains, quotas, upload logs, events, a server-rendered dashboard; tenant vhosts via `ovhost nginx tenants`. | **alpha**: written and tested (`npm test`, against a temp database, a mock Network and a DNS table). Not deployed. |
++| **C: sandboxed user code** | Isolation profiles, CPU/memory/time/network/storage budgets, secret references, outbound policy, metering, kill/revoke without touching platform services. | **not started**, deliberately. Blocked until isolation and metering are proven. Nothing in Stage B runs tenant code. |
Not in Stage A yet (from the charter/roadmap list): container adapters, DNS adapters, certificate **renewal**, logs/metrics links, incident/maintenance controls, the release-manifest/active-client work in §15.18, `host.release.deployed|rolled_back` events (they need an Events outbox and a service principal), and the restore drills and cutover runbook that Wave 22 will add.
--## Acceptance
++## Acceptance (Stage A)
What the tests demonstrate (`npm test`, every system call made against `test/fake-host.js`):
@@@ -83,6 +89,7 @@ ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]
ovhost releases <service> [--limit <n>]
ovhost certs [--warn-days <n>]
ovhost nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
++ovhost nginx tenants <service> [--wildcard-cert <name|dir>] [--install] Stage B tenant vhosts
ovhost snapshot <service> [--out <file>]
ovhost backup <service>
```
@@@ -124,14 +131,155 @@ Nothing here has been done yet. These are the steps:
4. **Run as root:** `sudo ovhost …`. `ovhost` drops to `ubuntu` for git/npm/SQLite (`runuser`) and needs root for `systemctl`, `nginx -t`, reading `/etc/openvibe/*.env` (names only) and writing vhosts. For least privilege, give operator accounts a sudoers rule for `/usr/local/bin/ovhost` alone instead of a general `ALL`. Do **not** grant it to the `ubuntu` service user if that account has no sudo today.
5. **First run:** `sudo ovhost status`, then `sudo ovhost validate <each service>`, then `sudo ovhost plan live`, all read-only apart from `git fetch`. Adopt `deploy` one service at a time, starting with one that has no protected sessions.
++## Stage B: tenant static hosting
++
++A Node service (`server/`, Express 4, better-sqlite3, port **4910**, service id `host`) that hosts static sites for OpenVibe projects. Every request is dispatched on its `Host` header first:
++
++| Host | What answers |
++|---|---|
++| `openvibe.host` (the `BASE_URL` host) and loopback | the dashboard, `/api/v1`, `/auth/*`, `/api/ready`, `/api/health`, `/release.json`, `/metrics` (direct loopback only), legal pages |
++| `<site>.openvibe.host` (one label) | that site's active deploy, and nothing else: no API, no sign-in, no cookies |
++| a custom domain with `status = verified` | the site it was verified for |
++| anything else (including pending, failed or lapsed custom domains, and `a.b.openvibe.host`) | `404 Unknown host`, with no tenant content |
++
++### Model
++
++- **Projects** (`prj_<ULID>`) have an owner (a `usr_` subject), an environment (`production` or `sandbox`), members with a role (`owner` > `maintainer` > `deployer`), and optionally the `network_project_id` of the OpenVibe.Network project (ADR-014: Network owns projects; until it has them, Host creates one for its owner). Every Host row and every stored object is keyed by the project id.
++- **Sites** (`site_<ULID>`) have a name (one DNS label, 3–40 characters, reserved names refused) that is their default host, `<name>.openvibe.host`, and a pointer to the active deploy. A deleted site's name is held for 30 days so nobody else can serve content on links that still point there.
++- **Deploys** (`dpl_<ULID>`) are immutable artifacts: a manifest (`host.deploy-manifest@1`: path, sha256, size and content type of every file, file count, total bytes) with its own sha256, file rows, and the upload/validation log. Database triggers refuse any update of a deploy's artifact columns or file rows. The bytes are content-addressed per project: `<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>`.
++- **Domains**: every site has its default domain. A custom domain is added as `pending` and becomes `verified` (and served) once `_openvibe-host.<hostname>` has the TXT record `openvibe-host-verification=<token>`. A verified name belongs to one site. OpenVibe domains (every domain in the released service manifests, `openvibe.<tld>`, the sites domain itself) can never be claimed. The worker re-checks pending domains (they fail after `HOST_DOMAIN_PENDING_DAYS`) and re-checks verified ones daily: a TXT record gone for `HOST_DOMAIN_LAPSE_DAYS` lapses the domain and it stops being served.
++- **Quotas** per project, enforced by Host: storage bytes (objects stored once per project, so re-uploading unchanged files costs nothing), deploys per rolling 24 hours (checked before the body is read; failed uploads count), files per deploy, bytes per file, sites, custom domains. A deploy's own size before deduplication may not exceed the storage quota. Defaults come from `HOST_QUOTA_*` / `HOST_SANDBOX_QUOTA_*`; staff override them per project. Sandbox projects get smaller quotas, no custom domains, and `X-Robots-Tag: noindex`.
++- **Build logs**: Stage B has no build step. Each deploy's log records what was received, every validation problem, what was stored, the manifest digest and the activation, and says that nothing was executed. Refused uploads are recorded as `failed` deploys, so their logs are visible too.
++
++### Uploads
++
++`POST /api/v1/sites/:id/deploys[?activate=1][&root=dist]` accepts a tar or tar.gz archive (`Content-Type: application/gzip`, `application/x-tar`, `application/octet-stream`) or `multipart/form-data` (`archive=<one archive>` or `files=<file>…`, each part's filename being the path; `strip=folder` removes a browser folder upload's shared top folder). The archive is parsed in memory and never extracted. The whole upload is refused, and recorded as a failed deploy, when any of these is true:
++
++- a path is absolute, contains `..`, `.`, an empty segment, a backslash, a control or non-ASCII character, or goes more than 32 levels deep; a pax or GNU long-name header that rewrites a name gets the same checks;
++- an entry is a symbolic link, a hard link, a device, a FIFO or any other non-regular entry (never followed and never skipped);
++- a hidden file or directory other than `.well-known/` is present (`.env`, `.git/`, `.htaccess`, …);
++- a file is server-side code or an executable (`php`, `cgi`, `pl`, `py`, `sh`, `asp(x)`, `jsp`, `shtml`, `exe`, `jar`, …) or looks like a credential or database (`pem`, `key`, `p12`, `env`, `sqlite`, …);
++- a file type is not on the allowlist (HTML, CSS, JavaScript, JSON, source maps, web manifests, text, Markdown, CSV, XML/RSS/Atom, WebVTT, SVG, PNG, JPEG, GIF, WebP, AVIF, ICO, BMP, fonts, MP4/WebM/Ogg video, MP3/Ogg/Opus/WAV/M4A/FLAC audio, PDF, WebAssembly, glTF, zip; extension-less files are plain text);
++- two entries have the same path, a path is both a file and a directory, or there are no files;
++- a limit is exceeded: request size (`HOST_MAX_UPLOAD_BYTES`, the connection is closed rather than drained), files, bytes per file, total bytes (decompression stops at the limit, so a gzip bomb cannot fill memory), or storage.
++
++### Serving
++
++Requests resolve their site once from the `Host` header, read the site's active deploy pointer once, and then read only that deploy's immutable rows. The file's bytes are opened at a path built from the site's project id and the manifest's sha256, never from the URL, so no path, encoding or `Host` trick can reach another project's objects. Absolute-form request targets must name the same host as the `Host` header.
++
++- `GET`/`HEAD` only (405 otherwise). `/` and `/dir/` serve `index.html`; `/page` also tries `page.html`; `/dir` redirects to `/dir/` when `dir/index.html` exists.
++- `Content-Type` from the manifest; strong `ETag` (the sha256) with `304` on `If-None-Match`; single byte ranges (`206`/`416`).
++- `Cache-Control: public, max-age=31536000, immutable` for fingerprinted asset names (`app.3f2a9c1b.js`, `index-BdK3x9aQ.js`), `public, max-age=0, must-revalidate` for everything else, so an activation or rollback shows at once.
++- `X-Content-Type-Options: nosniff`, a strict CSP (`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; … object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'`), `Referrer-Policy`, `Cross-Origin-Opener-Policy: same-origin`, a restrictive `Permissions-Policy`. Never a `Set-Cookie`.
++- A missing file serves the deploy's own `404.html` with status 404, or a plain Host 404 page.
++
++**Why a separate registrable domain.** Tenant pages are arbitrary HTML and JavaScript. They live under `openvibe.host`, never under `openvibe.network` or `openvibe.live`, so no OpenVibe session cookie can ever reach them (`ovhost nginx tenants` refuses a sites domain under a first-party domain). The dashboard shares `openvibe.host` with the tenants, which makes tenant pages *same-site* with it. So:
++
++- the dashboard trusts only `__Host-` prefixed cookies (a subdomain cannot set them); the plain `ov_token` cookie the shared navbar reads is display-only and is never trusted;
++- every dashboard form needs the dashboard's own `Origin` **and** a form token (an HMAC of the signed-in subject); `SameSite` alone would not stop a tenant page;
++- `/api/v1` ignores cookies entirely: `Authorization: Bearer` only;
++- nginx strips `Cookie` and `Authorization` from tenant requests, hides `Set-Cookie` from tenant responses and allows only `GET`/`HEAD` with a 1 KB body limit.
++
++### API (`/api/v1`)
++
++People present their Network user JWT as a Bearer token. Services and apps present a Network client-credentials token for audience `openvibe.host`. Each route checks exactly one capability for service tokens, and every caller is also judged by its project role. A first-party service (`svc:…`) may act for a person with `X-OV-Subject: usr_…`; an app (`app:app_…`) is a principal and always acts as itself, so it must be added to a project as a member (for example as `deployer`, for CI). A token with the claim `env: "sandbox"` is refused on production projects (ADR-014). Callers who are not members get 404 for everything in a project.
++
++| Route | Capability | Least role |
++|---|---|---|
++| `GET/POST /projects` · `GET/DELETE /projects/:id` | `host.site.manage` | member / owner (delete) |
++| `GET /projects/:id/quota` · `PUT /projects/:id/quota` | `host.site.manage` | member / Network staff (PUT) |
++| `PUT/DELETE /projects/:id/members/:principal` | `host.site.manage` | owner |
++| `GET/POST /projects/:id/sites` · `GET/DELETE /sites/:id` | `host.site.manage` | member / maintainer |
++| `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 |
++| `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.
++
++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.
++
++### Activation and rollback
++
++Activation is one SQLite transaction: a compare-and-set on the site's `active_deploy_id`, an activation record and the `host.deploy.activated` event. If any part fails, none of it happens. `expected_active` turns a racing operator's switch into a 409. Rollback goes to a named ready deploy of the same site, or to the most recent previously active one that still exists. The active deploy cannot be deleted. Deleting another deploy removes its objects when no other deploy of the project uses them.
++
++### Events
++
++Through the `openvibe-sdk` v0.2.2 transactional outbox (`event_outbox`), inside the transaction that makes the change:
++
++| Event | When | Payload |
++|---|---|---|
++| `host.deploy.created` | an upload became a ready deploy | `project_id, site_id, site, source, file_count, total_bytes, manifest_sha256` |
++| `host.deploy.activated` | a site's active deploy changed | `project_id, site_id, site, deploy_id, previous_deploy_id, rollback` |
++| `host.deploy.failed` | an upload was refused | `project_id, site_id, site, code, problems` (codes only, never file contents) |
++| `host.domain.verified` | a custom domain's TXT record was found | `project_id, site_id, site, hostname` |
++
++The relay publishes with Host's service token (`events.event.publish`, audience `openvibe.events`) only when `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set. Otherwise rows wait and `/api/ready` says the relay is off.
++
++### Dashboard
++
++Server-rendered pages with the shared chrome (`openvibe-shared` v1.3.0: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.
++
++### Observability
++
++`GET /api/ready` (openvibe-shared/ready): `db` and `storage` are required; `network_jwks`, `events_relay` and `domain_checks` are optional and report degradation. `GET /release.json`. `GET /metrics` for direct loopback callers only (nginx also returns 404): HTTP golden signals by route template (tenant requests are one `tenant_site` series), process metrics, release info and `host_sites`.
++
++### Grants and registration (for the lead)
++
++- 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.
++
++### Deploying Stage B (for the operator)
++
++Nothing here has been done yet.
++
++1. **DNS.** `openvibe.host` and `*.openvibe.host`: `A`/`AAAA` records to the host. If the zone is on Cloudflare, keep `*.openvibe.host` **DNS-only** (grey cloud), or set `HOST_CNAME_TARGET` to a DNS-only name: custom domains in other accounts cannot CNAME to a proxied hostname (Cloudflare error 1014). Set `HOST_ORIGIN_IPV4`/`HOST_ORIGIN_IPV6` if tenants should be told the addresses for apex domains.
++2. **Wildcard certificate.** A DNS-01 challenge is required for `*.openvibe.host`, for example `certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.secrets/certbot-cloudflare.ini -d openvibe.host -d '*.openvibe.host'`. The credentials file is root-only (0600), outside every repository, and never passed to Host. The certificate lands in `/etc/letsencrypt/live/openvibe.host/`; another location is passed with `--wildcard-cert <name|dir>`.
++3. **Network.** Register the OAuth client `host` and the grant above.
++4. **Code and state.** `sudo -u ubuntu git clone https://github.com/OpenVibers/OpenVibe.Host /opt/openvibe.host && cd /opt/openvibe.host && sudo -u ubuntu npm ci --omit=dev --no-audit --no-fund`. This checkout is the service; the root-owned `/usr/local/lib/openvibe-host` stays the `ovhost` CLI. The unit's state directory is `/var/lib/openvibe-host-api` (database and objects), deliberately **not** `/var/lib/openvibe-host`, which is `ovhost`'s own root-only state.
++5. **Environment.** `/etc/openvibe/host.env` (0600), from [`.env.example`](.env.example): at least `BASE_URL=https://openvibe.host`, `HOST_SITES_DOMAIN=openvibe.host`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID=host`, `OV_OAUTH_CLIENT_SECRET`, `HOST_FORM_SECRET`, `EVENTS_URL`.
++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.
++
++### Not done yet (Stage B)
++
++- Uploads are held in memory while they are validated (bounded by `HOST_MAX_UPLOAD_BYTES` and the decompression cap), so the service needs that much headroom per concurrent upload.
++- 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.
++- 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.
++- 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)
++
++- **Tenant isolation** (`test/host-isolation.test.js`): 25 path tricks (`..`, `%2e%2e`, `%2f`, `%5c`, `%00`, double encoding, absolute object-store paths, sha256 paths, `/host.db`) and 14 `Host` header tricks never return another tenant's bytes; absolute-form targets that disagree with `Host` are refused; tenant hosts never reach the API, auth, metrics or readiness; identical bytes are stored per project; every API route answers 404 to non-members; the API ignores cookies.
++- **Traversal and links refused** (`test/host-uploads.test.js`): `..`, absolute, backslash, non-ASCII, pax and GNU long-name overrides, symlinks, hard links, devices, FIFOs, hidden files, server-side code, credentials, unknown types, duplicates, conflicts, corrupt archives and a 40 MB gzip bomb. Each becomes a failed deploy with a log and `host.deploy.failed`, nothing is stored, and the active deploy keeps serving.
++- **Quotas** (`test/host-quota-auth.test.js`): deploys per day (rolling), deduplicated storage, files, bytes per file, request size, sites, custom domains, sandbox limits; service-token audience/capability, `X-OV-Subject` delegation, app principals, sandbox tokens, mods, staff.
++- **Atomic rollback** (`test/host-rollback.test.js`): 320 requests while the pointer flips 40 times all return one whole deploy; a failure inside the switch changes nothing; `expected_active` conflicts; database-level immutability; the pointer survives a restart.
++- **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.
++
++Not demonstrated yet: any of this on the production host.
++
## Development
```
--npm test # every test, each file in its own process
++npm test # every test, each file in its own process (Node 22)
node test/deploy.test.js # one file
++npm run dev # Stage B service on http://localhost:4910 (HOST_SITES_DOMAIN=localhost serves sites at http://<site>.localhost:4910)
```
--Everything that touches the system goes through the executor (`lib/executor.js`). The tests replace it with `test/fake-host.js`, an in-memory host with git repos, systemd units, npm, nginx, ss, HTTP endpoints and SQLite. No test runs systemctl, git, npm, nginx or curl against the real machine. `test/executor.test.js` covers the real executor only where that is safe: SQLite on a temp database, HTTP to a local server, and temp files.
++Stage B tests (`test/host-*.test.js`) boot the service on a temp database and object store with a controllable clock, a mock Network (real RS256 keys) and a DNS table, and talk to it over raw HTTP so they can send any `Host` header and request target.
++
++Stage A: everything that touches the system goes through the executor (`lib/executor.js`). The tests replace it with `test/fake-host.js`, an in-memory host with git repos, systemd units, npm, nginx, ss, HTTP endpoints and SQLite. No test runs systemctl, git, npm, nginx or curl against the real machine. `test/executor.test.js` covers the real executor only where that is safe: SQLite on a temp database, HTTP to a local server, and temp files.
## Launch rule
ddiff --git a/STATUS.json b/STATUS.json
iindex 81eb996..6e3ae02 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -2,21 +2,40 @@
"repository": "OpenVibers/OpenVibe.Host",
"stage": "alpha",
"code": true,
-- "wave": "W21 Stage A",
++ "wave": "W21 Stage A + Stage B",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "runtime": "node 22, CLI only (no server)",
-- "port": null,
-- "portReserved": 4910,
++ "runtime": "node 22; Stage A: CLI (ovhost); Stage B: express 4 + better-sqlite3 11 service",
++ "port": 4910,
"domain": "openvibe.host",
-- "contracts": "openvibe-contracts v0.7.0",
-- "capabilities": [],
++ "tenantDomain": "*.openvibe.host",
++ "packages": [
++ "openvibe-contracts v0.19.0",
++ "openvibe-sdk v0.2.2",
++ "openvibe-shared v1.3.0"
++ ],
++ "capabilities": [
++ "host.site.manage",
++ "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",
++ "events": [
++ "host.deploy.created",
++ "host.deploy.activated",
++ "host.deploy.failed",
++ "host.domain.verified"
++ ],
"stages": {
-- "A": "alpha: ovhost CLI written and tested against a fake host; not installed on the production host",
-- "B": "not started",
-- "C": "not started"
++ "A": "alpha: ovhost CLI written and tested against a fake host; installedOnHost is left as the Stage A commit recorded it (the lead updates it)",
++ "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",
++ "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": false,
++ "deployed": false,
++ "placeholderInSites": true,
"adoptedByServices": [],
"updated": "2026-09-22"
}
ddiff --git a/deploy/systemd/openvibe-host.service b/deploy/systemd/openvibe-host.service
nnew file mode 100644
iindex 0000000..6951cc4
---- /dev/null
++++ b/deploy/systemd/openvibe-host.service
@@@ -0,0 +1,41 @@
++[Unit]
++Description=OpenVibe.Host Stage B: tenant static hosting (dashboard, API and *.openvibe.host sites)
++# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Host
++# degrades gracefully without them: tenant sites keep serving; sign-in, API calls and events wait
++# (see /api/ready).
++After=network.target
++Wants=openvibe-network.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.host
++EnvironmentFile=/etc/openvibe/host.env
++Environment=NODE_ENV=production
++Environment=PORT=4910
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.host/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++TimeoutStopSec=10
++NoNewPrivileges=true
++PrivateTmp=true
++PrivateDevices=true
++ProtectSystem=strict
++ProtectHome=true
++ProtectKernelTunables=true
++ProtectKernelModules=true
++ProtectControlGroups=true
++RestrictSUIDSGID=true
++LockPersonality=true
++# Host's database and content-addressed objects. NOT /var/lib/openvibe-host: that is ovhost's own
++# root-owned state directory (release logs, locks, snapshots).
++StateDirectory=openvibe-host-api
++StateDirectoryMode=0750
++Environment=HOST_DB_PATH=/var/lib/openvibe-host-api/host.db
++Environment=HOST_STORAGE_DIR=/var/lib/openvibe-host-api/objects
++
++[Install]
++WantedBy=multi-user.target