openvibers/openvibe.host Commits

Host: certificate lifecycle, per-site config, tenant sitemaps and preview deploys (plan T12 J1, J3, J4, J6; behind the loopback service until the launch).

OP OpenVibers committed 29 Sept 2026 · 4cbdf05650 · 29 files, +1254 −35

J1: ovhost certs renew [--install] (lib/acme.js) runs certbot renew, issues a webroot certificate for every verified custom domain still without one (hostnames validated before certbot's argv, no shell), then re-renders the tenant vhosts; openvibe-certs.service/.timer ship in deploy/systemd (installed at the launch).
J3: per-site headers, local-only redirects and the SPA fallback in PostgreSQL (migration 0002, server/domain/site-config.js, GET/PUT/DELETE /api/v1/sites/:id/config, a dashboard form); the platform's security, routing, caching, scope and transport headers are reserved (refused on write and skipped on serve), X-Forwarded-* as a prefix; redirects must stay local paths.
J6: when a deploy ships neither, sitemap.xml (its HTML pages on the host asked, so a custom domain gets its own) and robots.txt come from openvibe-shared/seo; a sandbox site is Disallow: / and noindex.
J4: a deploy uploaded as a preview (migration 0003) becomes the site's one live preview for an hour, served only on the dashboard host to members of its project at /preview/<deploy-id>/, sandboxed into an opaque origin by CSP, noindex, no-store, never pinged or listed; activating, rolling back or deleting ends it. The dashboard's upload form chooses activate / keep ready / preview only, and the site page links the live preview with its expiry.

npm test and test:pg 52/52.

Changes

ddiff --git a/README.md b/README.md
iindex 68168ae..7bd7805 100644
---- a/README.md
++++ b/README.md
@@@ -352,19 +352,19 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
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`, `INDEXNOW_KEY` (optional).
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.
++8. **Certificates.** `sudo ovhost certs renew --install` renews the existing lineages with `certbot renew` (the wildcard) and issues `certbot certonly --webroot -w /var/www/certbot -d <hostname>` for every verified custom domain that still has no certificate (the generated port-80 block already answers the ACME challenge), then re-renders the tenant vhosts through the same transactional install. A failure leaves that domain HTTP-only and fails the command; the others still proceed. `ovhost certs renew` without `--install` only reviews. `deploy/systemd/openvibe-certs.timer` runs it twice a day (the [unit](deploy/systemd/openvibe-certs.service) runs as root; certificates never pass through the Host API). The manual equivalent is `sudo ovhost nginx tenants host` to list the domains still waiting, then `certbot certonly` and `ovhost nginx tenants host --install` for each.
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)
- Uploads are held in memory while they are validated (bounded by `HOST_MAX_UPLOAD_BYTES` and `HOST_MAX_UNPACKED_BYTES`), 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 database (`ov_host` on the host's data role, ADR-035; schema in [migrations/](migrations/)) is backed up with the others by pgBackRest.
--- Certificates for custom domains are issued by hand (step 8); there is no automatic ACME flow.
++- Certificates for custom domains are issued and installed by `ovhost certs renew --install` (step 8) on the `openvibe-certs.timer`; certbot stays outside the Host service and no key file is read.
- 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, 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).
--- 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).
++- No deploy of a Git repository (uploads only).
++- Tenant sites publish their own sitemap/robots/feed; when a deploy ships no `sitemap.xml` or `robots.txt`, Host generates one (the manifest's HTML pages on the host the request came in on, so a verified custom domain gets its own `sitemap.xml`; sandbox projects get `Disallow: /` and are `noindex`). A tenant's own file at either path always wins. 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)
@@@ -379,6 +379,8 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
- **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.
- **IndexNow** (`test/indexnow.test.js`): off without `INDEXNOW_KEY` (no key route, nothing sent); with a key the key file is served at `/<key>.txt` as `text/plain` on the dashboard and on tenant hosts; activating a deploy pings the site page and its sitemap, a rollback or a site deletion pings again, a ready-but-not-active deploy and a sandbox site never ping.
++- **Tenant sitemap/robots** (`test/host-sitemap.test.js`): a deploy that ships none gets a generated `sitemap.xml` (the manifest's HTML pages, `index.html` → the directory URL, the error page left out, on the host the request came in on — so a verified custom domain gets its own) and `robots.txt` (crawlers welcomed and the sitemap named; a sandbox site gets `Disallow: /` and stays `noindex`); a file the tenant uploaded at either path wins; correct content types and caching; HEAD works; no secret in the body.
++- **Preview deploys** (`test/host-preview.test.js`): `POST /sites/:id/deploys?preview=1` stores a deploy with `source=preview` and points the site at it without activating (the public site keeps serving its active deploy); `/preview/<deploy-id>/…` on the dashboard is served to a project member only — non-members and signed-out callers get a plain 404, a public tenant host never serves it, every response is `noindex, no-store`, and the response's CSP sandboxes the tenant content into an opaque origin (no `allow-same-origin`) so it can never act as `openvibe.host`; a preview is never pinged to IndexNow and never listed in the site's sitemap; it vanishes when it expires, when the site deploys or rolls back, or when it is deleted; and it serves only its own project's objects (a same-named file of another project is not reachable).
- **Home-page size budget** (`test/perf-budget.test.js`): the server on a fresh database, the home page measured with `openvibe-shared/perf-budget` — html 15.9 KB (4.3 br), js 4 files 212.2 KB (49.9 br), css 1 file 3.1 KB (0.9 br), 0 external — within the committed budgets.
- 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.
ddiff --git a/deploy/systemd/openvibe-certs.service b/deploy/systemd/openvibe-certs.service
nnew file mode 100644
iindex 0000000..4e85bdb
---- /dev/null
++++ b/deploy/systemd/openvibe-certs.service
@@@ -0,0 +1,19 @@
++[Unit]
++Description=OpenVibe certificate lifecycle: renew the wildcard lineage and issue certificates for verified custom domains, then re-render the tenant vhosts
++Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/launch.md
++After=network-online.target
++Wants=network-online.target
++
++[Service]
++Type=oneshot
++# Root: certbot writes /etc/letsencrypt and ovhost re-renders and reloads nginx; neither runs as the
++# Host service user. Host itself never reads a key file (only stat()s fullchain.pem).
++User=root
++ExecStart=/usr/local/bin/ovhost certs renew --install
++Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
++Environment=OVHOST_INVENTORY=/etc/openvibe/host.json
++UMask=0077
++# /usr and /boot read-only; /etc stays writable (certbot and the nginx install write there).
++ProtectSystem=full
++PrivateTmp=true
++NoNewPrivileges=true
ddiff --git a/deploy/systemd/openvibe-certs.timer b/deploy/systemd/openvibe-certs.timer
nnew file mode 100644
iindex 0000000..40e64e4
---- /dev/null
++++ b/deploy/systemd/openvibe-certs.timer
@@@ -0,0 +1,14 @@
++[Unit]
++Description=Twice-daily OpenVibe certificate renewal (04:30 and 16:30 UTC, up to 15 min later)
++Documentation=https://github.com/OpenVibers/OpenVibe.Host/blob/main/docs/launch.md
++
++[Timer]
++OnCalendar=*-*-* 04,16:30:00 UTC
++RandomizedDelaySec=15min
++AccuracySec=1min
++# A run missed while the host was down happens at the next boot.
++Persistent=true
++Unit=openvibe-certs.service
++
++[Install]
++WantedBy=timers.target
ddiff --git a/docs/capabilities-proposal/host.site.config.json b/docs/capabilities-proposal/host.site.config.json
nnew file mode 100644
iindex 0000000..52991b8
---- /dev/null
++++ b/docs/capabilities-proposal/host.site.config.json
@@@ -0,0 +1,21 @@
++{
++ "id": "host.site.config",
++ "version": "1.0",
++ "owner": "host",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Read, set and reset a Host site's serving configuration: response headers, local-only redirects and the SPA fallback (extensionless paths serve index.html). A capability holder must also hold the acting principal's project role (maintainer or owner to change, any role to read). Reserved headers (Content-Security-Policy, Strict-Transport-Security, Set-Cookie, X-Forwarded-*, caching and the platform's own) are refused, and a redirect target must be a local path, so a site can never become an open redirect.",
++ "permissions": [
++ "host:write"
++ ],
++ "resourceConstraints": [
++ "project"
++ ],
++ "quotaClass": "host-manage",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/sites/:id/config",
++ "PUT /api/v1/sites/:id/config",
++ "DELETE /api/v1/sites/:id/config"
++ ]
++}
ddiff --git a/docs/launch.md b/docs/launch.md
iindex caf3f16..7c1626c 100644
---- a/docs/launch.md
++++ b/docs/launch.md
@@@ -132,6 +132,18 @@ ls -l /etc/nginx/sites-enabled/ | grep openvibe.host # openvibe.host.conf, ope
`openvibe.host.conf` replaces the Sites placeholder vhost of the same name. The previous copy is in
`$B`.
++**3b. Certificate renewal.** Additive, no downtime: the timer renews the wildcard lineage with
++`certbot renew` and issues a certificate for every verified custom domain that has none, then
++re-renders the tenant vhosts (the [unit](../deploy/systemd/openvibe-certs.service) runs as root;
++certbot stays outside the Host API). Run it once now to pick up anything already due:
++
++```bash
++sudo install -m 644 deploy/systemd/openvibe-certs.service deploy/systemd/openvibe-certs.timer /etc/systemd/system/
++sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-certs.timer
++sudo systemctl start openvibe-certs.service
++sudo ovhost certs | grep -E 'openvibe\.host|fullchain' # the wildcard and any new per-domain certificate
++```
++
**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:
@@@ -243,7 +255,7 @@ Each launch-rule item, checked against the code at this commit:
| 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** |
++| 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: a deploy's own uploaded `robots.txt`/`sitemap.xml`, else a generated one (the manifest's HTML pages on the host the request came in on, so a verified custom domain gets its own; `host-sitemap`); sandbox projects get `Disallow: /` and `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
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex 8242648..8ec3e68 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -16,7 +16,8 @@
"capabilities": [
"host.site.manage",
"host.deploy.create",
-- "host.domain.manage"
++ "host.domain.manage",
++ "host.site.config"
],
"eventsProduced": [
"host.deploy.created",
ddiff --git a/lib/acme.js b/lib/acme.js
nnew file mode 100644
iindex 0000000..a941c09
---- /dev/null
++++ b/lib/acme.js
@@@ -0,0 +1,85 @@
++'use strict';
++/**
++ * Certificate lifecycle (operator plane, `ovhost certs renew`).
++ *
++ * certbot stays outside the Host service: this module shells out to it and never reads a key file.
++ * It renews what already exists (`certbot renew` covers the wildcard lineage and any per-domain
++ * certificates), issues a certificate for every VERIFIED custom domain that still has none
++ * (HTTP-01 webroot, the port-80 block already answers the challenge), and — with --install —
++ * re-renders the tenant vhosts through the same transactional nginx install.
++ *
++ * Domains come straight from the Host database, so the values are validated exactly where nginx
++ * renders them (nginx.renderTenants): a hostile hostname is refused and never reaches certbot's
++ * argv. A failed issuance leaves the domain HTTP-only and is reported, never fatal to the others.
++ */
++const fs = require('fs');
++const path = require('path');
++const nginx = require('./nginx');
++
++const ACME_WEBROOT = '/var/www/certbot';
++
++/** The single service whose inventory declares an nginx.tenants block. */
++function tenantService(inv) {
++ const ids = Object.values(inv.services).filter((s) => s.nginx && s.nginx.tenants).map((s) => s.id);
++ if (!ids.length) throw new Error('no service has an nginx.tenants block (there is nothing to renew for)');
++ if (ids.length > 1) throw new Error(`more than one tenant service (${ids.join(', ')}); name one: ovhost certs renew <service>`);
++ return inv.services[ids[0]];
++}
++
++function firstLine(text) {
++ return String(text || '').trim().split('\n')[0] || '';
++}
++
++/**
++ * -> { renewed, issued: [host], failed: [{ hostname, error }], refused: [value], installed }
++ * `installed` is null without --install, else the nginx install result ({ changed, removed, reloaded }).
++ */
++async function renew(exec, inv, svc, { wildcardCert, install = false, webroot = ACME_WEBROOT, log = () => {} } = {}) {
++ nginx.tenantsConfig(svc); // throws early if this service does not host tenants
++
++ // 1. Renew every existing certificate. The wildcard is a DNS-01 lineage the operator issued;
++ // its credentials stay operator-side, so certbot renew owns it here too.
++ const renewed = await exec.run('certbot', ['renew', '--non-interactive', '--quiet'], { privileged: true });
++ if (renewed.code !== 0) log(`certbot renew reported a failure (exit ${renewed.code}): ${firstLine(renewed.stderr || renewed.stdout)}`);
++
++ // 2. Issue for the verified custom domains that still have no certificate. renderTenants is the
++ // same validator the vhosts use, so `needCert` holds only plain host names.
++ const before = await nginx.tenantDomains(exec, svc);
++ const pre = nginx.renderTenants(inv, svc, { wildcardCert, domains: before });
++ for (const h of pre.refused) log(`refused a database value that is not a plain host name: ${JSON.stringify(h)}`);
++ const issued = [];
++ const failed = [];
++ for (const host of pre.needCert) {
++ const r = await exec.run('certbot', ['certonly', '--webroot', '-w', webroot, '-d', host, '--non-interactive'], { privileged: true });
++ if (r.code === 0) { issued.push(host); log(`issued a certificate for ${host}`); }
++ else {
++ const error = firstLine(r.stderr || r.stdout) || `certbot exited ${r.code}`;
++ failed.push({ hostname: host, error });
++ log(`could not issue a certificate for ${host}: ${error} — it stays HTTP-only`);
++ }
++ }
++
++ // 3. Install only when asked: re-read the domains (a freshly issued one now has a certificate)
++ // and go through the transactional nginx install.
++ let installed = null;
++ if (install) {
++ const after = await nginx.tenantDomains(exec, svc);
++ const r = nginx.renderTenants(inv, svc, { wildcardCert, domains: after });
++ const res = await nginx.install(exec, inv, r.files, { log, remove: r.replaces });
++ for (const h of r.needCert) log(`still needs a certificate: ${h}`);
++ installed = { changed: res.changed, removed: res.removed, reloaded: res.reloaded };
++ }
++
++ return { renewed: renewed.code === 0, issued, failed, refused: pre.refused, installed };
++}
++
++/** The renewal unit files as rendered (deploy/systemd/openvibe-certs.{service,timer}). */
++function unitTexts() {
++ const dir = path.join(__dirname, '..', 'deploy', 'systemd');
++ return {
++ service: fs.readFileSync(path.join(dir, 'openvibe-certs.service'), 'utf8'),
++ timer: fs.readFileSync(path.join(dir, 'openvibe-certs.timer'), 'utf8'),
++ };
++}
++
++module.exports = { renew, tenantService, unitTexts, ACME_WEBROOT };
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 116c155..4fe0ae3 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -51,6 +51,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
/etc/openvibe/host.env (docs/release-notifications.md)
releases <service> [--limit <n>] the release log
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
++ certs renew [<service>] [--wildcard-cert <name|dir>] [--install]
++ renew existing certificates with certbot and issue one for every
++ VERIFIED custom domain that has none; --install then re-renders
++ the tenant vhosts (transactional nginx -t + reload)
nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
print a vhost from the service manifest; --install writes it,
runs nginx -t and reloads only if clean
@@@ -349,6 +353,19 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
return 0;
}
case 'certs': {
++ if (pos[1] === 'renew') {
++ const svc = pos[2] ? lib.inventory.service(inv, pos[2]) : lib.acme.tenantService(inv);
++ const r = await lib.acme.renew(exec, inv, svc, { wildcardCert: opts['wildcard-cert'], install: !!opts.install, log });
++ if (json) emit(r);
++ else {
++ out(`certbot renew: ${r.renewed ? 'ok' : 'reported a failure (existing certificates are untouched)'}`);
++ for (const h of r.issued) out(`issued ${h}`);
++ for (const h of r.refused) out(`refused (not a plain host name): ${JSON.stringify(h)}`);
++ for (const f of r.failed) out(`failed ${f.hostname}: ${f.error} — it stays HTTP-only`);
++ if (r.installed) out(`nginx: ${[r.installed.changed.length && `installed ${r.installed.changed.join(', ')}`, r.installed.removed.length && `removed ${r.installed.removed.join(', ')}`].filter(Boolean).join('; ') || 'nothing changed'}${r.installed.reloaded ? '; reloaded' : ''}`);
++ }
++ return (r.renewed && !r.failed.length) ? 0 : 2;
++ }
const list = await lib.certs.inventory(exec, inv, { warnDays: Number(opts['warn-days'] || 21) });
if (json) emit(list);
else {
ddiff --git a/lib/index.js b/lib/index.js
iindex 9470685..80c7677 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -13,6 +13,7 @@ const { snapshot } = require('./commands/snapshot');
const { drill, DrillError } = require('./commands/drill');
const releases = require('./releases');
const certs = require('./certs');
++const acme = require('./acme');
const nginx = require('./nginx');
const announce = require('./announce');
const { createSystemExecutor } = require('./executor');
@@@ -45,6 +46,7 @@ module.exports = {
DrillError,
releases,
certs,
++ acme,
nginx,
announce: announce.announce,
announceSummary: announce.summary,
ddiff --git a/migrations/0002_site_config.sql b/migrations/0002_site_config.sql
nnew file mode 100644
iindex 0000000..22d56ad
---- /dev/null
++++ b/migrations/0002_site_config.sql
@@@ -0,0 +1,15 @@
++-- phase: expand
++-- Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and
++-- the SPA fallback of ONE site, kept in PostgreSQL and applied in server/http/tenant.js BEFORE the
++-- platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,
++-- Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it, and a
++-- redirect target must be a local path, so a site can never become an open redirect. A site with no
++-- row here serves exactly as before (no headers, no redirects, no SPA fallback).
++CREATE TABLE host_site_config (
++ site_id text COLLATE "C" PRIMARY KEY REFERENCES host_sites(id),
++ headers jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "Header-Name": "value" }, validated on write
++ redirects jsonb NOT NULL DEFAULT '[]'::jsonb, -- [ { from, to, status } ], local targets only
++ spa boolean NOT NULL DEFAULT false, -- serve index.html for extensionless paths
++ updated_by text COLLATE "C",
++ updated_at bigint NOT NULL
++);
ddiff --git a/migrations/0003_preview.sql b/migrations/0003_preview.sql
nnew file mode 100644
iindex 0000000..0a29251
---- /dev/null
++++ b/migrations/0003_preview.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- Preview deploys (plan T12 J4, decision D5): a preview is served by the dashboard only, at
++-- /preview/<deploy-id>/…, to a member of the deploy's project — never on a public host, never indexed.
++--
++-- source='preview' a deploy uploaded as a preview (its archive/files kind is in the upload log).
++-- preview pointer host_sites.preview_deploy_id names the site's ONE live preview; the tenant
++-- server reads it once, like active_deploy_id. The pointer is cleared when the
++-- preview expires, when the site deploys or rolls back (any pointer switch), when
++-- the preview is deleted, or when the site is deleted.
++-- preview_expires_at a preview stops being served at this instant (epoch ms); the row and its
++-- objects stay until the deploy is deleted.
++ALTER TABLE host_deploys DROP CONSTRAINT host_deploys_source_check;
++ALTER TABLE host_deploys ADD CONSTRAINT host_deploys_source_check CHECK (source IN ('archive', 'files', 'preview'));
++
++ALTER TABLE host_sites ADD COLUMN preview_deploy_id text COLLATE "C";
++ALTER TABLE host_sites ADD COLUMN preview_expires_at bigint;
ddiff --git a/public/css/host.css b/public/css/host.css
iindex a908525..381b793 100644
---- a/public/css/host.css
++++ b/public/css/host.css
@@@ -18,7 +18,10 @@ form.inline { display: inline-flex; margin: 0; }
fieldset { border: 1px solid var(--hs-line); border-radius: 8px; padding: .5rem .75rem; }
label { display: flex; flex-direction: column; gap: .25rem; font-size: 14px; }
label.confirm { flex-direction: row; align-items: center; }
--input, select { font: inherit; padding: .4rem .5rem; border-radius: 6px; border: 1px solid var(--hs-line); background: var(--hs-card); color: var(--hs-fg); }
++fieldset.choice { display: flex; flex-direction: column; gap: .35rem; }
++textarea { width: min(100%, 44rem); min-height: 6.5rem; font-family: ui-monospace, monospace; font-size: 13px; line-height: 1.5; }
++strong.preview { color: var(--hs-warn); }
++input, select, textarea { font: inherit; padding: .4rem .5rem; border-radius: 6px; border: 1px solid var(--hs-line); background: var(--hs-card); color: var(--hs-fg); }
button, .button { font: inherit; padding: .45rem .9rem; border-radius: 6px; border: 0; background: var(--hs-accent); color: #0a0f1c; cursor: pointer; text-decoration: none; display: inline-block; }
button.link { background: none; color: var(--hs-accent); padding: 0; text-decoration: underline; }
button.danger, .danger { background: var(--hs-bad); color: #0a0f1c; } button.link.danger { background: none; color: var(--hs-bad); }
ddiff --git a/server/app.js b/server/app.js
iindex 82915bf..1518b43 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -31,6 +31,7 @@ const { createProjects } = require('./domain/projects');
const { createSites } = require('./domain/sites');
const { createDeploys } = require('./domain/deploys');
const { createDomains } = require('./domain/domains');
++const { createSiteConfig } = require('./domain/site-config');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createTenantServer } = require('./http/tenant');
@@@ -108,6 +109,7 @@ async function createApp(opts = {}) {
const sites = createSites({ store, config, access, projects, takedowns, indexnow: announce });
const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log, indexnow: announce });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log, indexnow: announce });
++ const siteConfig = createSiteConfig({ store });
const auth = opts.auth || createSsoClient({
site: 'host',
baseUrl: config.baseUrl,
@@@ -123,11 +125,11 @@ async function createApp(opts = {}) {
...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}),
});
const viewers = createViewerResolver({ auth, config, log });
-- const tenant = createTenantServer({ store, config, blobs, takedowns, log });
++ const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
-- const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, auth, viewers, tenant, worker, uploadGate, indexnow, log };
++ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, auth, viewers, tenant, worker, uploadGate, indexnow, log };
const app = express();
app.disable('x-powered-by');
@@@ -197,7 +199,8 @@ async function createApp(opts = {}) {
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
-- // never reach this app's routes; their robots.txt and sitemap are whatever the tenant uploads.
++ // never reach this app's routes; http/tenant.js serves a deploy's own robots.txt/sitemap.xml, or a
++ // generated one when the deploy ships none.
const legalPaths = require('openvibe-shared/legal').PATHS;
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
.send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
@@@ -224,6 +227,15 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
++ // ── Preview deploys (plan T12 J4) ───────────────────────
++ // A member views a site's live preview deploy at /preview/<deploy-id>/… — dashboard host only (the
++ // dispatch above sends every tenant host to tenant.handle, so this never answers on a public host),
++ // session-authenticated, membership-checked in tenant.preview, noindex and never cached.
++ app.use('/preview', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), viewers.middleware('dashboard'), (req, res, next) => {
++ res.set('Cache-Control', 'private, no-store');
++ Promise.resolve(tenant.preview(req, res)).catch(next);
++ });
++
// ── Dashboard ───────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use((req, res) => {
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
iindex 4c7b8f6..77856a9 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -13,6 +13,7 @@
* host.site.manage projects, members, sites, quotas (read), deleting deploys
* host.deploy.create upload deploys, list them and their logs, activate, roll back
* host.domain.manage add, verify and remove custom domains
++ * host.site.config read, set and reset a site's response headers, redirects and SPA fallback
*
* A service token is judged by its capability AND by the project role of the principal it acts
* as (domain/access.js). Browsers are judged by their role alone.
@@@ -23,6 +24,7 @@ const CAPABILITIES = Object.freeze({
SITE_MANAGE: 'host.site.manage',
DEPLOY_CREATE: 'host.deploy.create',
DOMAIN_MANAGE: 'host.domain.manage',
++ SITE_CONFIG: 'host.site.config',
});
const PROPOSED = new Set(Object.values(CAPABILITIES));
ddiff --git a/server/domain/deploys.js b/server/domain/deploys.js
iindex 655e3f9..8824d06 100644
---- a/server/domain/deploys.js
++++ b/server/domain/deploys.js
@@@ -12,6 +12,11 @@
* rollback the same switch, to a named deploy or to the one that was active before.
* delete only a deploy that is not active; its objects are removed when no other deploy of
* the project uses them.
++ * preview a deploy uploaded as a preview (source 'preview'): it becomes the site's ONE live
++ * preview (host_sites.preview_deploy_id + preview_expires_at) instead of going active,
++ * and is served only by the dashboard, to a member, at /preview/<deploy-id>/… (see
++ * server/http/tenant.js). Any pointer switch clears it (so a deploy or rollback makes it
++ * vanish), it expires on its own, and it is never announced to search engines.
*
* There is no build: Stage B serves the uploaded files as they are, and nothing is ever executed.
*/
@@@ -21,6 +26,9 @@ const { buildManifest } = require('../artifacts/validate');
const { principalOf, actorRef } = require('./access');
const DAY = 24 * 3600 * 1000;
++// How long a preview stays served. Bounded so a draft cannot linger on the dashboard forever; the
++// uploader can always upload another one.
++const PREVIEW_TTL_MS = 60 * 60 * 1000;
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
function createDeploys({ store, config = null, access, projects, sites, blobs, outbox, takedowns, log = console, indexnow = null }) {
@@@ -45,6 +53,9 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
logFor: db.prepare('SELECT level, message, created_at FROM host_deploy_logs WHERE deploy_id = ? ORDER BY id'),
files: db.prepare('SELECT path, sha256, size, content_type FROM host_deploy_files WHERE deploy_id = ? ORDER BY path'),
setPointer: db.prepare('UPDATE host_sites SET active_deploy_id = ?, updated_at = ? WHERE id = ? AND status = \'active\' AND active_deploy_id IS NOT DISTINCT FROM ?'),
++ setPreview: db.prepare('UPDATE host_sites SET preview_deploy_id = ?, preview_expires_at = ? WHERE id = ?'),
++ clearPreview: db.prepare('UPDATE host_sites SET preview_deploy_id = NULL, preview_expires_at = NULL WHERE id = ?'),
++ clearPreviewIf: db.prepare('UPDATE host_sites SET preview_deploy_id = NULL, preview_expires_at = NULL WHERE id = ? AND preview_deploy_id = ?'),
activation: db.prepare('INSERT INTO host_activations (site_id, deploy_id, previous_deploy_id, kind, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)'),
activations: db.prepare('SELECT deploy_id, previous_deploy_id, kind, actor, created_at FROM host_activations WHERE site_id = ? ORDER BY id DESC LIMIT ?'),
markDeleted: db.prepare("UPDATE host_deploys SET state = 'deleted', deleted_at = ? WHERE id = ?"),
@@@ -128,10 +139,15 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
* entries: [{ path, data }] already normalised by the reader. -> { deploy, log, activated }
* Throws ApiError (with extra.deploy_id and extra.log) when the upload is refused.
*/
-- async function create(viewer, ctx, entries, { source, activate = false, notes = [], traceparent } = {}) {
++ async function create(viewer, ctx, entries, { source, activate = false, preview = false, notes = [], traceparent } = {}) {
const { site, project, limits } = ctx;
++ // A preview is never activated: it is the site's live preview (its pointer), not its active
++ // deploy, and it is stored with source 'preview' so the two can never be confused.
++ const isPreview = preview === true;
++ const storedSource = isPreview ? 'preview' : source;
++ if (isPreview) activate = false;
const fail = async (status, code, problems) => {
-- const r = await recordFailure(viewer, ctx, { source, code, problems, notes, traceparent });
++ const r = await recordFailure(viewer, ctx, { source: storedSource, code, problems, notes, traceparent });
throw new ApiError(status, code, problems[0] ? problems[0].message : code, { deploy_id: r.deploy.id, log: r.log.map((l) => `${l.level}: ${l.message}`) });
};
@@@ -162,16 +178,20 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
{ level: 'info', message: 'no build step: Stage B serves the uploaded files as they are; nothing was executed' },
];
let activated = null;
++ const previewExpiresAt = isPreview ? now + PREVIEW_TTL_MS : null;
await store.tx(async () => {
-- await q.insert.run({ id, project_id: project.id, site_id: site.id, state: 'ready', source, manifest: built.manifestJson, manifest_sha256: built.manifestSha256, file_count: built.manifest.file_count, total_bytes: built.manifest.total_bytes, new_bytes: newBytes, failure_code: null, created_by: who, created_at: now });
++ await q.insert.run({ id, project_id: project.id, site_id: site.id, state: 'ready', source: storedSource, manifest: built.manifestJson, manifest_sha256: built.manifestSha256, file_count: built.manifest.file_count, total_bytes: built.manifest.total_bytes, new_bytes: newBytes, failure_code: null, created_by: who, created_at: now });
for (const f of built.files) await q.insertFile.run(id, f.path, f.sha256, f.size, f.content_type);
for (const f of fresh.values()) await q.insertBlob.run(project.id, f.sha256, f.size, now);
await outbox.emit({
event_type: 'host.deploy.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'deploy', id },
-- payload: { project_id: project.id, site_id: site.id, site: site.name, source, file_count: built.manifest.file_count, total_bytes: built.manifest.total_bytes, manifest_sha256: built.manifestSha256 },
++ payload: { project_id: project.id, site_id: site.id, site: site.name, source: storedSource, file_count: built.manifest.file_count, total_bytes: built.manifest.total_bytes, manifest_sha256: built.manifestSha256 },
}, { traceparent });
-- if (activate) {
++ if (isPreview) {
++ await q.setPreview.run(id, previewExpiresAt, site.id);
++ lines.push({ level: 'info', message: `preview: served only at /preview/${id}/ to a project member until it expires; the public site still serves its active deploy` });
++ } else if (activate) {
activated = await switchPointer(viewer, site, await get(id), 'activate', { traceparent });
lines.push({ level: 'info', message: 'activated: the site now serves this deploy' });
} else {
@@@ -181,7 +201,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
});
outbox.kick();
if (activated && activated.changed) announceSite(site, project);
-- return { deploy: await get(id), log: await q.logFor.all(id), activated };
++ return { deploy: await get(id), log: await q.logFor.all(id), activated, preview: isPreview ? { deploy_id: id, expires_at: previewExpiresAt } : null };
}
/** Inside a transaction. Compare-and-set on the pointer read in the same transaction. */
@@@ -197,6 +217,8 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
const r = await q.setPointer.run(deploy.id, now, site.id, current.active_deploy_id);
if (r.changes !== 1) throw new ApiError(409, 'site.active_changed', 'the active deploy changed during the switch');
await q.activation.run(site.id, deploy.id, current.active_deploy_id, kind, principalOf(viewer) || 'host', now);
++ // A real deploy or rollback supersedes any pending preview: it stops being served at once.
++ await q.clearPreview.run(site.id);
await outbox.emit({
event_type: 'host.deploy.activated', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'deploy', id: deploy.id },
@@@ -264,6 +286,8 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
if (active === deploy.id) throw new ApiError(409, 'deploy.active', 'this deploy is being served: activate another one first');
await q.markDeleted.run(store.now(), deploy.id);
await q.dropFiles.run(deploy.id);
++ // A deleted preview must stop being served even if it was the site's live one.
++ await q.clearPreviewIf.run(site.id, deploy.id);
freed = await collectGarbage(project.id);
});
unlinkBlobs(project.id, freed);
@@@ -279,4 +303,4 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
};
}
--module.exports = { createDeploys };
++module.exports = { createDeploys, PREVIEW_TTL_MS };
ddiff --git a/server/domain/site-config.js b/server/domain/site-config.js
nnew file mode 100644
iindex 0000000..310b752
---- /dev/null
++++ b/server/domain/site-config.js
@@@ -0,0 +1,182 @@
++'use strict';
++/**
++ * Per-site serving configuration (plan T12 J3, decision D4): the response headers, the redirects and
++ * the SPA fallback of ONE site, kept in PostgreSQL and applied by server/http/tenant.js BEFORE the
++ * platform's own headers. The platform's security, routing and caching headers (CSP, HSTS,
++ * Set-Cookie, X-Forwarded-*, Cache-Control, …) are reserved and can never be set through it; a
++ * redirect target must be a local path, so a site can never become an open redirect. A site with no
++ * row serves exactly as before.
++ */
++const { ApiError } = require('../http/errors');
++const { principalOf } = require('./access');
++
++const MAX_HEADERS = 50;
++const MAX_HEADER_VALUE = 1024;
++const MAX_REDIRECTS = 200;
++const MAX_LOCATION = 1024;
++const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/;
++const REDIRECT_STATUS = new Set([301, 302, 307, 308]);
++
++// Headers the platform owns: a site's config can never set one. `X-Forwarded-*` is refused as a
++// prefix, so a site can never lie about the client to an upstream reading those headers.
++const RESERVED_HEADERS = new Set([
++ 'content-security-policy', 'content-security-policy-report-only', 'strict-transport-security',
++ 'set-cookie', 'set-cookie2', 'clear-site-data',
++ 'content-length', 'transfer-encoding', 'connection', 'keep-alive', 'trailer', 'upgrade', 'te', 'host', 'location',
++ 'content-type', 'content-disposition', 'content-encoding', 'content-range', 'accept-ranges',
++ 'cache-control', 'cdn-cache-control', 'etag', 'expires', 'last-modified', 'vary', 'age',
++ 'x-content-type-options', 'x-served-by', 'x-robots-tag', 'referrer-policy',
++ 'cross-origin-opener-policy', 'cross-origin-embedder-policy', 'cross-origin-resource-policy', 'permissions-policy',
++ // Scope and transport are the platform's too: a wider service-worker scope, or another host:port for this origin.
++ 'service-worker-allowed', 'alt-svc',
++]);
++const RESERVED_PREFIXES = ['x-forwarded-'];
++
++/** Why `name` cannot be configured, or null when it can. */
++function reservedReason(name) {
++ const lower = String(name).toLowerCase();
++ if (RESERVED_HEADERS.has(lower)) return `"${name}" is set by the platform and cannot be configured`;
++ if (RESERVED_PREFIXES.some((p) => lower.startsWith(p))) return `"${name}" is reserved (X-Forwarded-*) and cannot be configured`;
++ return null;
++}
++function isReservedHeader(name) { return reservedReason(name) !== null; }
++
++const fail = (code, message) => { throw new ApiError(422, code, message); };
++
++function cleanHeaderValue(name, value) {
++ const v = value == null ? '' : String(value);
++ if (v.length > MAX_HEADER_VALUE) fail('site_config.header_value', `the value of "${name}" is longer than ${MAX_HEADER_VALUE} characters`);
++ // eslint-disable-next-line no-control-regex
++ if (/[\u0000-\u001f\u007f]/.test(v)) fail('site_config.header_value', `the value of "${name}" contains a control character: a header value must be one line`);
++ return v;
++}
++
++/** name → value object of configurable headers, or throws ApiError(422). */
++function cleanHeaders(input) {
++ if (input == null) return {};
++ if (typeof input !== 'object' || Array.isArray(input)) fail('site_config.headers', 'headers must be an object of name → value');
++ const names = Object.keys(input);
++ if (names.length > MAX_HEADERS) fail('site_config.too_many', `a site may set at most ${MAX_HEADERS} headers`);
++ const out = {};
++ for (const name of names) {
++ if (!HEADER_NAME_RE.test(name)) fail('site_config.header_name', `"${name}" is not a valid header name`);
++ const reason = reservedReason(name);
++ if (reason) fail('site_config.reserved_header', reason);
++ out[name] = cleanHeaderValue(name, input[name]);
++ }
++ return out;
++}
++
++/** A redirect source or target must be a local path: no scheme, no host, no backslash (which browsers read as "/"). */
++function cleanLocalPath(value, field) {
++ const s = value == null ? '' : String(value).trim();
++ if (!s || s.length > MAX_LOCATION) fail('site_config.redirect', `${field} must be a path of 1–${MAX_LOCATION} characters`);
++ // eslint-disable-next-line no-control-regex
++ if (/[\u0000-\u001f\u007f]/.test(s)) fail('site_config.redirect', `${field} contains a control character`);
++ if (s.includes('\\')) fail('site_config.redirect', `${field} contains a backslash`);
++ if (s[0] !== '/') fail('site_config.redirect', `${field} must be a local path starting with "/"`);
++ if (s.startsWith('//')) fail('site_config.redirect', `${field} must be a local path, not a protocol-relative URL`);
++ let decoded;
++ try { decoded = decodeURIComponent(s); } catch { fail('site_config.redirect', `${field} is not valid percent-encoding`); }
++ // eslint-disable-next-line no-control-regex
++ if (decoded.includes('\\') || /[\u0000-\u001f\u007f]/.test(decoded) || /(^|\/)\/\//.test(decoded)) fail('site_config.redirect', `${field} must be a local path (it decoded to a scheme, a host or a control character)`);
++ return s;
++}
++
++/** [{ from, to, status }] of local-only redirects, or throws ApiError(422). */
++function cleanRedirects(input) {
++ if (input == null) return [];
++ if (!Array.isArray(input)) fail('site_config.redirects', 'redirects must be an array');
++ if (input.length > MAX_REDIRECTS) fail('site_config.too_many', `a site may have at most ${MAX_REDIRECTS} redirects`);
++ const seen = new Set();
++ const out = [];
++ for (const [i, r] of input.entries()) {
++ if (!r || typeof r !== 'object' || Array.isArray(r)) fail('site_config.redirect', `redirect ${i + 1} must be an object with "from" and "to"`);
++ const from = cleanLocalPath(r.from, `redirect ${i + 1} "from"`);
++ const to = cleanLocalPath(r.to, `redirect ${i + 1} "to"`);
++ if (seen.has(from)) fail('site_config.redirect', `two redirects have the same "from" path (${from})`);
++ seen.add(from);
++ const status = r.status == null ? 301 : Number(r.status);
++ if (!REDIRECT_STATUS.has(status)) fail('site_config.redirect', `redirect ${i + 1} status must be one of 301, 302, 307, 308`);
++ out.push({ from, to, status });
++ }
++ return out;
++}
++
++const cleanSpa = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v == null ? '' : v).toLowerCase());
++
++/** The whole config, cleaned. */
++function normalize(input = {}) {
++ return { headers: cleanHeaders(input.headers), redirects: cleanRedirects(input.redirects), spa: cleanSpa(input.spa) };
++}
++
++function defaults() { return { headers: {}, redirects: [], spa: false }; }
++
++// ── Dashboard text form (one "Name: value" per line; one "from -> to [status]" per line) ──
++function headersToText(headers) {
++ return Object.entries(headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
++}
++function parseHeadersText(text) {
++ const obj = {};
++ for (const raw of String(text || '').split('\n')) {
++ const line = raw.trim();
++ if (!line) continue;
++ const i = line.indexOf(':');
++ if (i <= 0) fail('site_config.header_syntax', `each header line must be "Name: value" (got "${line.slice(0, 60)}")`);
++ const name = line.slice(0, i).trim();
++ if (name in obj) fail('site_config.header_syntax', `"${name}" is listed twice`);
++ obj[name] = line.slice(i + 1).trim();
++ }
++ return cleanHeaders(obj);
++}
++function redirectsToText(redirects) {
++ return (redirects || []).map((r) => `${r.from} -> ${r.to}${r.status && r.status !== 301 ? ` ${r.status}` : ''}`).join('\n');
++}
++function parseRedirectsText(text) {
++ const list = [];
++ for (const raw of String(text || '').split('\n')) {
++ const line = raw.trim();
++ if (!line) continue;
++ const m = /^(\S+)\s*->\s*(\S+)(?:\s+(\d{3}))?$/.exec(line);
++ if (!m) fail('site_config.redirect_syntax', `each redirect line must be "from -> to [status]" (got "${line.slice(0, 60)}")`);
++ list.push({ from: m[1], to: m[2], status: m[3] ? Number(m[3]) : 301 });
++ }
++ return cleanRedirects(list);
++}
++
++function createSiteConfig({ store }) {
++ const { db } = store;
++ const q = {
++ bySite: db.prepare('SELECT * FROM host_site_config WHERE site_id = ?'),
++ upsert: db.prepare(`INSERT INTO host_site_config (site_id, headers, redirects, spa, updated_by, updated_at)
++ VALUES (?, ?::jsonb, ?::jsonb, ?, ?, ?)
++ ON CONFLICT (site_id) DO UPDATE SET headers = EXCLUDED.headers, redirects = EXCLUDED.redirects,
++ spa = EXCLUDED.spa, updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at`),
++ remove: db.prepare('DELETE FROM host_site_config WHERE site_id = ?'),
++ };
++
++ const rowToConfig = (row) => (row
++ ? { headers: row.headers && typeof row.headers === 'object' && !Array.isArray(row.headers) ? row.headers : {}, redirects: Array.isArray(row.redirects) ? row.redirects : [], spa: Boolean(row.spa) }
++ : defaults());
++
++ async function getForSite(siteId) { return rowToConfig(await q.bySite.get(siteId)); }
++
++ async function set(viewer, site, input) {
++ const cfg = normalize(input);
++ await q.upsert.run(site.id, JSON.stringify(cfg.headers), JSON.stringify(cfg.redirects), cfg.spa, principalOf(viewer), store.now());
++ return await getForSite(site.id);
++ }
++
++ async function remove(site) { await q.remove.run(site.id); return defaults(); }
++
++ return {
++ getForSite, set, remove, defaults, normalize, cleanHeaders, cleanRedirects,
++ headersToText, parseHeadersText, redirectsToText, parseRedirectsText,
++ };
++}
++
++module.exports = {
++ createSiteConfig, normalize, defaults, isReservedHeader, cleanHeaders, cleanRedirects,
++ parseHeadersText, parseRedirectsText, headersToText, redirectsToText,
++ RESERVED_HEADERS, RESERVED_PREFIXES, REDIRECT_STATUS, MAX_HEADERS, MAX_REDIRECTS,
++};
ddiff --git a/server/domain/sites.js b/server/domain/sites.js
iindex a3245e6..6b5f953 100644
---- a/server/domain/sites.js
++++ b/server/domain/sites.js
@@@ -33,7 +33,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nu
VALUES (?, ?, ?, NULL, 'active', ?, ?, ?)`),
insertDefaultDomain: db.prepare(`INSERT INTO host_domains (id, project_id, site_id, hostname, kind, status, token, created_by, created_at, verified_at)
VALUES (?, ?, ?, ?, 'default', 'verified', NULL, ?, ?, ?)`),
-- markDeleted: db.prepare("UPDATE host_sites SET status = 'deleted', active_deploy_id = NULL, deleted_at = ?, updated_at = ? WHERE id = ?"),
++ markDeleted: db.prepare("UPDATE host_sites SET status = 'deleted', active_deploy_id = NULL, preview_deploy_id = NULL, preview_expires_at = NULL, deleted_at = ?, updated_at = ? WHERE id = ?"),
};
const defaultHostname = (name) => `${name}.${config.sitesDomain}`;
@@@ -98,6 +98,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nu
await db.prepare("UPDATE host_deploys SET state = 'deleted', deleted_at = ? WHERE site_id = ? AND state <> 'deleted'").run(now, site.id);
await db.prepare('DELETE FROM host_deploy_files WHERE deploy_id IN (SELECT id FROM host_deploys WHERE site_id = ?)').run(site.id);
await db.prepare('DELETE FROM host_domains WHERE site_id = ?').run(site.id);
++ await db.prepare('DELETE FROM host_site_config WHERE site_id = ?').run(site.id);
}
async function remove(viewer, id, { deploys }) {
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 171e8bf..6a1f134 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -8,6 +8,7 @@
* projects GET/POST /projects · GET/DELETE /projects/:id · GET/PUT /projects/:id/quota
* PUT/DELETE /projects/:id/members/:principal host.site.manage
* sites GET/POST /projects/:id/sites · GET/DELETE /sites/:id host.site.manage
++ * GET/PUT/DELETE /sites/:id/config (headers, redirects, SPA fallback) host.site.config
* deploys GET/POST /sites/:id/deploys · GET /deploys/:id · GET /deploys/:id/log
* POST /deploys/:id/activate · POST /sites/:id/rollback host.deploy.create
* DELETE /deploys/:id host.site.manage
@@@ -25,7 +26,7 @@ const out = require('./serialize');
const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());
function createApi(ctx) {
-- const { config, projects, sites, deploys, domains, viewers, takedowns } = ctx;
++ const { config, projects, sites, deploys, domains, siteConfig, viewers, takedowns } = ctx;
const router = express.Router();
// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
@@@ -158,10 +159,12 @@ function createApi(ctx) {
const r = await deploys.recordFailure(req.viewer, pre, { source: err.source || (multipart ? 'files' : 'archive'), code: err.code, problems: [{ code: err.code, message: err.message }], traceparent: tp(req) });
return contracts.http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov, extra: { deploy_id: r.deploy.id, log: r.log.map((l) => `${l.level}: ${l.message}`) } });
}
-- const activate = truthy(up.fields.activate != null ? up.fields.activate : req.query.activate);
-- const r = await deploys.create(req.viewer, pre, up.entries, { source: up.source, activate, notes: up.notes, traceparent: tp(req) });
++ const preview = truthy(up.fields.preview != null ? up.fields.preview : req.query.preview);
++ // A preview is never the site's active deploy; it is served only at /preview/<id>/ later.
++ const activate = !preview && truthy(up.fields.activate != null ? up.fields.activate : req.query.activate);
++ const r = await deploys.create(req.viewer, pre, up.entries, { source: up.source, activate, preview, notes: up.notes, traceparent: tp(req) });
const site = await sites.get(pre.site.id);
-- res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: Boolean(r.activated && r.activated.changed), url: siteUrl(sites.defaultHostname(site.name)) });
++ res.status(201).json({ deploy: out.deploy(r.deploy, { active: site.active_deploy_id === r.deploy.id, log: r.log }), activated: Boolean(r.activated && r.activated.changed), preview: r.preview, url: siteUrl(sites.defaultHostname(site.name)) });
} catch (err) {
// Refused before the body was read (404, 429, 503): close instead of draining a large upload.
if (!req.complete) res.set('Connection', 'close');
@@@ -207,6 +210,21 @@ function createApi(ctx) {
}));
router.delete('/domains/:id', guard('host.domain.manage'), run(async (req) => await domains.remove(req.viewer, req.params.id)));
++ // ── Per-site configuration: headers, redirects, SPA fallback (host.site.config) ──
++ // Applied by http/tenant.js before the platform's headers; reserved headers are refused on write.
++ router.get('/sites/:id/config', guard('host.site.config'), run(async (req) => {
++ const { site } = await sites.load(req.viewer, req.params.id, 'read');
++ return { config: await siteConfig.getForSite(site.id) };
++ }));
++ router.put('/sites/:id/config', guard('host.site.config'), jsonBody, run(async (req) => {
++ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');
++ return { config: await siteConfig.set(req.viewer, site, req.body || {}) };
++ }));
++ router.delete('/sites/:id/config', guard('host.site.config'), run(async (req) => {
++ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');
++ return { config: await siteConfig.remove(site) };
++ }));
++
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: `no route ${req.method} ${req.baseUrl}${req.path}`, ctx: req.ov }));
return router;
}
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex fbc2a08..096e2e8 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -15,7 +15,7 @@ const { readUpload, UploadError } = require('./upload');
const { ApiError, privateNoStore } = require('./errors');
function createDashboard(ctx) {
-- const { config, projects, sites, deploys, domains, viewers, access, log = console } = ctx;
++ const { config, projects, sites, deploys, domains, siteConfig, viewers, access, log = console } = ctx;
const router = express.Router();
const urlencoded = express.urlencoded({ extended: false, limit: '16kb' });
@@@ -86,10 +86,12 @@ function createDashboard(ctx) {
router.get('/sites/:id', view(async (req, res) => {
if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
const { site, project, role } = await sites.load(req.viewer, req.params.id, 'read');
++ const cfg = await siteConfig.getForSite(site.id);
page(req, res, 200, site.name, pages.site({
-- site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer),
++ site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer), now: ctx.store.now(),
deploys: await deploys.list(site.id, 50), activations: await deploys.activationsOf(site.id, 10),
domains: (await domains.listForSite(site.id)).map((d) => ({ domain: d, instructions: domains.instructions(d, site) })),
++ siteConfig: { ...cfg, headersText: siteConfig.headersToText(cfg.headers), redirectsText: siteConfig.redirectsToText(cfg.redirects) },
}), takedownNotice(await ctx.takedowns.ofSite(site)) || notice(req));
}));
@@@ -131,6 +133,13 @@ function createDashboard(ctx) {
await projects.remove(req.viewer, project, { sites });
return back('/', 'Project deleted.');
}));
++ router.post('/sites/:id/config', action(async (req) => {
++ const { site } = await sites.load(req.viewer, req.params.id, 'maintain');
++ const headers = siteConfig.parseHeadersText(req.body.headers);
++ const redirects = siteConfig.parseRedirectsText(req.body.redirects);
++ await siteConfig.set(req.viewer, site, { headers, redirects, spa: req.body.spa === '1' });
++ return back(`/sites/${site.id}`, 'Site configuration saved.');
++ }));
router.post('/sites/:id/domains', action(async (req) => {
const { domain } = await domains.add(req.viewer, req.params.id, { hostname: req.body.hostname });
return back(`/sites/${domain.site_id}`, `Added ${domain.hostname}: publish the DNS records below, then check.`);
@@@ -186,8 +195,12 @@ function createDashboard(ctx) {
}
if (!checkCsrf(config, req.viewer, up.fields.csrf)) throw new ApiError(403, 'form.invalid', 'this form expired; reload the page and try again');
try {
-- const r = await deploys.create(req.viewer, pre, up.entries, { source: up.source, activate: up.fields.activate === '1', notes: up.notes, traceparent: req.ov && req.ov.traceparent });
-- return res.redirect(303, back(`/deploys/${r.deploy.id}`, r.activated && r.activated.changed ? 'Deployed and activated.' : 'Deployed. Activate it from the site page.'));
++ // The form's choice: activate it, keep it ready, or make it the site's private preview.
++ const mode = ['activate', 'ready', 'preview'].includes(up.fields.mode) ? up.fields.mode : (up.fields.activate === '1' ? 'activate' : 'ready');
++ const r = await deploys.create(req.viewer, pre, up.entries, { source: up.source, activate: mode === 'activate', preview: mode === 'preview', notes: up.notes, traceparent: req.ov && req.ov.traceparent });
++ const done = r.preview ? 'Deployed as a preview: open it from the site page. Only members of this project can see it.'
++ : r.activated && r.activated.changed ? 'Deployed and activated.' : 'Deployed. Activate it from the site page.';
++ return res.redirect(303, back(`/deploys/${r.deploy.id}`, done));
} catch (err) {
if (err instanceof ApiError && err.extra && err.extra.deploy_id) return res.redirect(303, back(`/deploys/${err.extra.deploy_id}`, `Upload refused: ${err.message}`, 'error'));
throw err;
ddiff --git a/server/http/tenant.js b/server/http/tenant.js
iindex 3161ee6..0d06a74 100644
---- a/server/http/tenant.js
++++ b/server/http/tenant.js
@@@ -16,10 +16,25 @@
* If-None-Match; single byte ranges; `immutable` caching for fingerprinted assets and revalidation
* for everything else (so a rollback shows at once); nosniff; a strict CSP; never a cookie.
* A site that staff took down (domain/takedowns.js) answers 451 with none of its content.
++ * A deploy that ships no sitemap.xml or robots.txt gets a generated one (the manifest's HTML pages on
++ * the host the request came in on, so a verified custom domain gets its own; a sandbox site gets
++ * `Disallow: /`). A file the tenant uploaded at either path is served instead.
++ *
++ * A site's own configuration (domain/site-config.js) adds its response headers, its local-only
++ * redirects and an optional SPA fallback (extensionless paths serve index.html). The headers are
++ * applied BEFORE the platform's own, and a reserved header (CSP, HSTS, Set-Cookie, X-Forwarded-*, …)
++ * is never applied even if a row holds one, so a tenant can never weaken or spoof them.
++ *
++ * preview() serves a site's ONE live preview deploy (host_sites.preview_deploy_id) on the dashboard
++ * host only, to a member of its project, at /preview/<deploy-id>/… — always noindex, never cached by
++ * a shared cache, and never announced to a search engine (plan T12 J4, decision D5).
*/
const fs = require('fs');
++const { sitemapXml, robotsTxt } = require('openvibe-shared/seo');
const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
const { isHostname } = require('../domain/domains');
++const { isReservedHeader } = require('../domain/site-config');
++const { isId } = require('../ids');
const TENANT_CSP = [
"default-src 'self'",
@@@ -38,6 +53,30 @@ const TENANT_CSP = [
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
++/**
++ * A preview is tenant content served from the dashboard's OWN origin, so — unlike tenant sites, which
++ * live on a separate registrable domain — it must be stopped from acting as openvibe.host. Its CSP
++ * sandboxes the document into an opaque origin (there is deliberately no `allow-same-origin`), so
++ * every tenant script, style and asset it loads runs without access to the dashboard's cookies,
++ * storage or DOM (the "active content runs only on the tenant's own origin" control, docs/threat-review.md §5).
++ */
++function previewCsp(origin) {
++ return [
++ "default-src 'none'",
++ `script-src ${origin}`,
++ `style-src ${origin} 'unsafe-inline'`,
++ `img-src ${origin} data: https:`,
++ `font-src ${origin} data:`,
++ `media-src ${origin} https:`,
++ `connect-src ${origin}`,
++ "object-src 'none'",
++ "base-uri 'none'",
++ "form-action 'none'",
++ "frame-ancestors 'none'",
++ 'sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
++ ].join('; ');
++}
++
/** Host header → lowercase host name without port or trailing dot; null when it is not one. */
function normaliseHost(raw) {
if (typeof raw !== 'string' || !raw || raw.length > 260) return null;
@@@ -75,7 +114,15 @@ function plain(res, status, text, extra = {}) {
res.end(text);
}
--function createTenantServer({ store, config, blobs, takedowns = null, log = console }) {
++/** Apply a site's configured headers, never a reserved one (defense in depth: they are refused on write too). */
++function applyConfiguredHeaders(res, headers) {
++ for (const [name, value] of Object.entries(headers || {})) {
++ if (isReservedHeader(name)) continue;
++ try { res.setHeader(name, value); } catch { /* a name or value Node refuses is simply not set */ }
++ }
++}
++
++function createTenantServer({ store, config, blobs, takedowns = null, siteConfig = null, access = null, log = console }) {
const { db } = store;
const q = {
siteByName: db.prepare("SELECT s.*, p.environment FROM host_sites s JOIN host_projects p ON p.id = s.project_id WHERE s.name = ? AND s.status = 'active' AND p.status = 'active'"),
@@@ -83,8 +130,15 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
WHERE d.hostname = ? AND d.kind = 'custom' AND d.status = 'verified' AND s.status = 'active' AND p.status = 'active'`),
file: db.prepare(`SELECT f.sha256, f.size, f.content_type, d.project_id FROM host_deploy_files f JOIN host_deploys d ON d.id = f.deploy_id
WHERE f.deploy_id = ? AND f.path = ? AND d.state = 'ready'`),
++ htmlFiles: db.prepare("SELECT path FROM host_deploy_files WHERE deploy_id = ? AND content_type LIKE 'text/html%' ORDER BY path"),
++ deployById: db.prepare('SELECT id, site_id, project_id, state FROM host_deploys WHERE id = ?'),
++ siteById: db.prepare('SELECT * FROM host_sites WHERE id = ?'),
++ projectById: db.prepare('SELECT id, status, environment FROM host_projects WHERE id = ?'),
};
const suffix = `.${config.sitesDomain}`;
++ let previewOrigin = "'self'";
++ try { previewOrigin = new URL(config.baseUrl).origin; } catch { /* keep 'self' */ }
++ const PREVIEW_CSP = previewCsp(previewOrigin);
/**
* -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }
@@@ -124,13 +178,13 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
return { list: [p, `${p}.html`], dirIndex: `${p}/index.html`, dir: false, path: p };
}
-- function send(req, res, site, row, { status = 200, cache }) {
++ function send(req, res, site, row, { status = 200, cache, deployId = site.active_deploy_id }) {
const etag = `"${row.sha256}"`;
res.setHeader('ETag', etag);
res.setHeader('Content-Type', row.content_type);
res.setHeader('Cache-Control', cache);
res.setHeader('Accept-Ranges', 'bytes');
-- res.setHeader('X-OpenVibe-Deploy', site.active_deploy_id);
++ res.setHeader('X-OpenVibe-Deploy', deployId);
const inm = req.headers['if-none-match'];
if (status === 200 && inm && inm.split(',').map((s) => s.trim().replace(/^W\//, '')).includes(etag)) {
res.statusCode = 304;
@@@ -160,6 +214,68 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
stream.pipe(res);
}
++ /** The absolute origin for the host this request arrived on (tenant hosts are https in production). */
++ function originOf(req) {
++ const host = normaliseHost(req.headers.host);
++ if (!host) return null;
++ let proto = 'https';
++ try { proto = new URL(config.baseUrl).protocol.replace(':', '') || 'https'; } catch { /* default */ }
++ return `${proto}://${host}`;
++ }
++
++ /**
++ * A manifest HTML path as the absolute-path URL a crawler should use: the error page is left out,
++ * `index.html` becomes the directory URL, and everything else keeps its manifest path.
++ */
++ function pagePath(p) {
++ if (!/\.html?$/i.test(p)) return null;
++ if (/(^|\/)404\.html?$/i.test(p)) return null;
++ const stem = p.replace(/\.html?$/i, '');
++ if (stem === 'index') return '/';
++ if (stem.endsWith('/index')) return `/${stem.slice(0, -'index'.length)}`;
++ return `/${p}`;
++ }
++
++ /**
++ * A sitemap the deploy did not ship: the manifest's HTML pages on the host this request came in on.
++ * Served per host (a sitemap may only list URLs of one host), so a verified custom domain gets its
++ * own copy under its own name.
++ */
++ async function generatedSitemap(req, res, site) {
++ const origin = originOf(req);
++ if (!origin) return await notFound(req, res, site, 'invalid');
++ const locs = [];
++ const seen = new Set();
++ for (const row of await q.htmlFiles.all(site.active_deploy_id)) {
++ const p = pagePath(row.path);
++ if (!p) continue;
++ const loc = `${origin}${p}`;
++ if (seen.has(loc)) continue;
++ seen.add(loc);
++ locs.push({ loc });
++ }
++ const body = sitemapXml(locs);
++ res.statusCode = 200;
++ res.setHeader('Content-Type', 'application/xml; charset=utf-8');
++ res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
++ res.setHeader('Content-Length', Buffer.byteLength(body));
++ return req.method === 'HEAD' ? res.end() : res.end(body);
++ }
++
++ /** A robots.txt the deploy did not ship: welcome crawlers to a production site, keep a sandbox out. */
++ async function generatedRobots(req, res, site) {
++ const origin = originOf(req);
++ if (!origin) return await notFound(req, res, site, 'invalid');
++ const body = site.environment === 'sandbox'
++ ? robotsTxt({ disallow: ['/'], allow: [], allowAI: false })
++ : robotsTxt({ sitemaps: [`${origin}/sitemap.xml`] });
++ res.statusCode = 200;
++ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
++ res.setHeader('Cache-Control', 'public, max-age=3600');
++ res.setHeader('Content-Length', Buffer.byteLength(body));
++ return req.method === 'HEAD' ? res.end() : res.end(body);
++ }
++
async function notFound(req, res, site, reason) {
const page = site.active_deploy_id ? await q.file.get(site.active_deploy_id, '404.html') : null;
if (page && page.project_id === site.project_id) return send(req, res, site, page, { status: 404, cache: 'no-cache' });
@@@ -173,8 +289,20 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
return res.end(body);
}
++ /** An extensionless path (`/app/route`, `/a-b-c`, `/`) — the only kind an SPA fallback rewrites. */
++ function isExtensionlessPath(rawPath) {
++ if (rawPath === '/' || rawPath === '') return true;
++ const p = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;
++ const last = p.slice(p.lastIndexOf('/') + 1);
++ return !last.includes('.');
++ }
++
/** Serves one request for a resolved site. */
async function handle(req, res, site) {
++ const cfg = siteConfig ? await siteConfig.getForSite(site.id) : null;
++ // The site's own headers go on first; baseHeaders then overrides every reserved one, so a
++ // tenant can never weaken CSP/HSTS/nosniff or set a cookie.
++ if (cfg) applyConfiguredHeaders(res, cfg.headers);
baseHeaders(res, { sandbox: site.environment === 'sandbox' });
if (req.method !== 'GET' && req.method !== 'HEAD') {
return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
@@@ -196,6 +324,17 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
const q0 = target.indexOf('?');
const rawPath = q0 >= 0 ? target.slice(0, q0) : target;
const search = q0 >= 0 ? target.slice(q0) : '';
++ // A configured redirect: an exact path to a local path (validated on write, never an open redirect).
++ // It does not need an active deploy, and it is checked before anything is read from one.
++ if (cfg && cfg.redirects.length) {
++ const r = cfg.redirects.find((x) => x.from === rawPath);
++ if (r) {
++ res.statusCode = r.status;
++ res.setHeader('Location', r.to);
++ res.setHeader('Cache-Control', 'no-cache');
++ return res.end();
++ }
++ }
if (!site.active_deploy_id) return await notFound(req, res, site, 'no-deploy');
const c = candidates(rawPath);
if (!c) return await notFound(req, res, site, 'invalid');
@@@ -218,6 +357,16 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
res.setHeader('Cache-Control', 'no-cache');
return res.end();
}
++ // The deploy ships no sitemap.xml / robots.txt of its own: generate them (the tenant's own file,
++ // looked up above, always wins). IndexNow's ping of /sitemap.xml now has a real target.
++ if (rawPath === '/sitemap.xml') return await generatedSitemap(req, res, site);
++ if (rawPath === '/robots.txt') return await generatedRobots(req, res, site);
++ // SPA fallback: an extensionless path with no file of its own serves this site's index.html
++ // (a client-side route). A missing file with an extension is still a 404.
++ if (cfg && cfg.spa && isExtensionlessPath(rawPath)) {
++ const row = await q.file.get(deployId, 'index.html');
++ if (row && row.project_id === site.project_id) return send(req, res, site, row, { cache: 'public, max-age=0, must-revalidate' });
++ }
return await notFound(req, res, site, 'missing');
}
@@@ -225,7 +374,52 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
return plain(res, 404, 'Unknown host: no site is served at this address.');
}
-- return { resolve, handle, unknownHost, normaliseHost, candidates, TENANT_CSP };
++ /**
++ * GET/HEAD /preview/<deploy-id>/<path> — one deploy of a site, served ONLY to a member of its
++ * project and ONLY on the dashboard host (the dispatch in server/app.js never sends a tenant host
++ * here, so a preview can never appear at <site>.<sitesDomain>). It serves exactly the deploy the
++ * site currently points at as its live preview (host_sites.preview_deploy_id) while that preview
++ * has not expired; once it expires, the site deploys or rolls back, or the preview is deleted, the
++ * address is a plain 404. Every response is noindex, no-store and never announced to a search
++ * engine, and the bytes come from the deploy's own project and manifest — never from the URL —
++ * exactly as in handle().
++ */
++ async function preview(req, res) {
++ // Never the dashboard's own CSP: the preview is sandboxed into an opaque origin (see previewCsp).
++ res.setHeader('Content-Security-Policy', PREVIEW_CSP);
++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
++ res.setHeader('Cache-Control', 'private, no-store');
++ const miss = () => plain(res, 404, 'No preview is served at this address.');
++ const viewer = req.viewer;
++ if (!access || !viewer || viewer.kind === 'anonymous') return miss();
++ if (req.method !== 'GET' && req.method !== 'HEAD') return plain(res, 405, 'Static preview: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
++ const raw = req.originalUrl || req.url || '';
++ const cut = raw.indexOf('?');
++ const m = /^\/preview\/([^/?#]+)(\/[^?#]*)?$/.exec(cut >= 0 ? raw.slice(0, cut) : raw);
++ if (!m || !isId('deploy', m[1])) return miss();
++ const deploy = await q.deployById.get(m[1]);
++ if (!deploy || deploy.state !== 'ready') return miss();
++ const site = await q.siteById.get(deploy.site_id);
++ if (!site || site.status !== 'active' || site.project_id !== deploy.project_id) return miss();
++ // Only the site's ONE live preview is served, and only until it expires.
++ if (site.preview_deploy_id !== deploy.id || !site.preview_expires_at || site.preview_expires_at <= store.now()) return miss();
++ const project = await q.projectById.get(deploy.project_id);
++ try { await access.authorize(project, viewer, 'read'); } catch { return miss(); }
++ if (takedowns && await takedowns.ofSite(site)) {
++ return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.', { 'Clear-Site-Data': '"cache", "storage"' });
++ }
++ const c = candidates(m[2] || '/');
++ if (!c) return miss();
++ for (const p of c.list) {
++ const row = await q.file.get(deploy.id, p);
++ if (row && row.project_id === deploy.project_id) {
++ return send(req, res, site, row, { cache: 'private, no-store, max-age=0', deployId: deploy.id });
++ }
++ }
++ return miss();
++ }
++
++ return { resolve, handle, preview, unknownHost, normaliseHost, candidates, TENANT_CSP };
}
module.exports = { createTenantServer, normaliseHost, TENANT_CSP };
ddiff --git a/server/http/upload.js b/server/http/upload.js
iindex 290b331..5606659 100644
---- a/server/http/upload.js
++++ b/server/http/upload.js
@@@ -19,7 +19,7 @@ const { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError } =
const { checkPath, PathError } = require('../artifacts/paths');
const ARCHIVE_TYPES = new Set(['application/gzip', 'application/x-gzip', 'application/x-tar', 'application/octet-stream', 'application/x-compressed-tar', 'application/tar', 'application/tar+gzip']);
--const FIELD_NAMES = new Set(['activate', 'root', 'strip', 'csrf', 'expected_active']);
++const FIELD_NAMES = new Set(['activate', 'preview', 'mode', 'root', 'strip', 'csrf', 'expected_active']);
const fmt = (n) => `${(n / 1048576).toFixed(1)} MiB`;
class UploadError extends Error {
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex dc44241..73aa2e9 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -83,15 +83,20 @@ function domainBlock(d, instructions, csrf) {
return `${body}</li>`;
}
--function site({ site: s, project: p, role, deploys, activations, domains, csrf, url }) {
++function site({ site: s, project: p, role, deploys, activations, domains, siteConfig, csrf, url, now = Date.now() }) {
const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
const canDeploy = role !== 'staff';
++ // The site's one live preview (plan T12 J4): served to project members on this dashboard until it expires.
++ const previewId = s.preview_deploy_id && Number(s.preview_expires_at) > now ? s.preview_deploy_id : null;
++ const previewHref = (id) => `/preview/${encodeURIComponent(id)}/`;
const rows = deploys.map((d) => {
const active = d.id === s.active_deploy_id;
++ const previewing = d.id === previewId;
const actions = [];
++ if (previewing && d.state === 'ready') actions.push(`<a href="${esc(previewHref(d.id))}" target="_blank" rel="noopener">Open preview</a>`);
if (canDeploy && d.state === 'ready' && !active) actions.push(form(`/deploys/${d.id}/activate`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id || '')}"><button type="submit">Activate</button>`, { cls: 'inline' }));
if (canMaintain && !active) actions.push(form(`/deploys/${d.id}/delete`, csrf, '<button type="submit" class="link danger">Delete</button>', { cls: 'inline' }));
-- return `<tr${active ? ' class="active"' : ''}><td><a href="/deploys/${esc(d.id)}"><code>${short(d.id)}</code></a>${active ? ' <strong>active</strong>' : ''}</td><td>${esc(d.state)}${d.failure_code ? ` (<code>${esc(d.failure_code)}</code>)` : ''}</td><td>${esc(d.file_count)}</td><td>${bytes(d.total_bytes)}</td><td>${when(d.created_at)}</td><td>${actions.join(' ')}</td></tr>`;
++ return `<tr${active ? ' class="active"' : ''}><td><a href="/deploys/${esc(d.id)}"><code>${short(d.id)}</code></a>${active ? ' <strong>active</strong>' : ''}${previewing ? ' <strong class="preview">preview</strong>' : ''}</td><td>${esc(d.state)}${d.failure_code ? ` (<code>${esc(d.failure_code)}</code>)` : ''}</td><td>${esc(d.file_count)}</td><td>${bytes(d.total_bytes)}</td><td>${when(d.created_at)}</td><td>${actions.join(' ')}</td></tr>`;
}).join('');
const history = activations.slice(0, 10).map((a) => `<li>${when(a.created_at)} ${esc(a.kind)} → <code>${short(a.deploy_id)}</code>${a.previous_deploy_id ? ` (was <code>${short(a.previous_deploy_id)}</code>)` : ''} by <code>${esc(a.actor)}</code></li>`).join('');
return `<p class="crumbs"><a href="/">Projects</a> › <a href="/projects/${esc(p.id)}">${esc(p.name)}</a> ›</p>
@@@ -102,15 +107,26 @@ ${canDeploy ? `<h2>Upload a deploy</h2>
${form(`/sites/${s.id}/deploys`, csrf, `<fieldset><legend>A folder</legend><input type="file" name="files" multiple webkitdirectory><input type="hidden" name="strip" value="folder"></fieldset>
<fieldset><legend>…or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar"></fieldset>
<label>Deploy from subfolder (optional) <input name="root" placeholder="dist"></label>
--<label><input type="checkbox" name="activate" value="1" checked> Activate when it is ready</label>
++<fieldset class="choice"><legend>When it is ready</legend>
++<label class="confirm"><input type="radio" name="mode" value="activate" checked> Activate it</label>
++<label class="confirm"><input type="radio" name="mode" value="ready"> Keep it ready (activate later)</label>
++<label class="confirm"><input type="radio" name="mode" value="preview"> Preview only: a private address for members of this project, for one hour</label>
++</fieldset>
<button type="submit">Upload</button>`, { enctype: 'multipart/form-data' })}` : ''}
<h2>Deploys</h2>
++${previewId ? `<p class="notice">A preview of <code>${short(previewId)}</code> is open to members of this project at <a href="${esc(previewHref(previewId))}" target="_blank" rel="noopener">${esc(previewHref(previewId))}</a> until ${when(Number(s.preview_expires_at))}. It is never indexed, and activating or rolling back ends it.</p>` : ''}
${deploys.length ? `<table><thead><tr><th>Deploy</th><th>State</th><th>Files</th><th>Size</th><th>Uploaded</th><th></th></tr></thead><tbody>${rows}</tbody></table>` : '<p>No deploys yet.</p>'}
${canDeploy && s.active_deploy_id ? form(`/sites/${s.id}/rollback`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id)}"><button type="submit">Roll back to the previous deploy</button>`) : ''}
${history ? `<h3>Activation history</h3><ul class="history">${history}</ul>` : ''}
<h2>Domains</h2>
<ul class="domains">${domains.map((d) => domainBlock(d.domain, d.instructions, csrf)).join('')}</ul>
${canMaintain && p.environment !== 'sandbox' ? form(`/sites/${s.id}/domains`, csrf, '<label>Custom domain <input name="hostname" required placeholder="www.example.org"></label><button type="submit">Add domain</button>') : ''}
++${canMaintain ? `<h2>Per-site configuration</h2>
++<p class="muted">Headers, redirects and an SPA fallback, applied to every response of this site. Platform headers (Content-Security-Policy, Strict-Transport-Security, Set-Cookie, X-Forwarded-*, caching) cannot be set here, and a redirect target must be a local path.</p>
++${form(`/sites/${s.id}/config`, csrf, `<label>Headers (one per line: <code>Name: value</code>)<textarea name="headers" rows="4" spellcheck="false" placeholder="X-Frame-Options: SAMEORIGIN">${esc(siteConfig.headersText)}</textarea></label>
++<label>Redirects (one per line: <code>from -&gt; to [status]</code>)<textarea name="redirects" rows="4" spellcheck="false" placeholder="/old -&gt; /new 301">${esc(siteConfig.redirectsText)}</textarea></label>
++<label class="confirm"><input type="checkbox" name="spa" value="1"${siteConfig.spa ? ' checked' : ''}> Serve index.html for extensionless paths (single-page app)</label>
++<button type="submit">Save configuration</button>`)}` : ''}
${canMaintain ? `<h2>Delete site</h2>${form(`/sites/${s.id}/delete`, csrf, '<button type="submit" class="danger">Delete this site</button>', { confirm: `${s.name} stops being served at once` })}` : ''}`;
}
ddiff --git a/test/certs-acme.test.js b/test/certs-acme.test.js
nnew file mode 100644
iindex 0000000..ae3e0cd
---- /dev/null
++++ b/test/certs-acme.test.js
@@@ -0,0 +1,118 @@
++'use strict';
++/**
++ * `ovhost certs renew [--install]`: renew the existing certificates with certbot, issue one for every
++ * VERIFIED custom domain that has none, then (with --install) re-render the tenant vhosts through the
++ * transactional nginx install. A hostile database value never reaches certbot's argv, a failed
++ * issuance stays HTTP-only, and no key file is ever read — nothing real is run (test/fake-host.js).
++ */
++const assert = require('assert');
++const { scenario, test, runTests } = require('./helpers');
++const { LONG } = require('./fixtures/certs');
++const acme = require('../lib/acme');
++
++function withHost(host, rows = []) {
++ const doc = JSON.parse(host.read('/etc/openvibe/host.json'));
++ doc.services.host = {
++ repo: '/opt/openvibe.host',
++ units: ['openvibe-host.service'],
++ port: 4910,
++ ready: { url: 'http://127.0.0.1:4910/api/ready', timeoutSeconds: 30 },
++ databases: [{ name: 'host', path: '/var/lib/openvibe-host-api/host.db' }],
++ nginx: { tenants: { sitesDomain: 'openvibe.host', wildcardCert: 'openvibe.host', database: '/var/lib/openvibe-host-api/host.db', maxUpload: '110m' } },
++ };
++ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
++ host.sqliteHandler = (db, sql) => {
++ assert.match(sql, /^SELECT hostname FROM host_domains WHERE kind = 'custom' AND status = 'verified'/);
++ return rows;
++ };
++ return host;
++}
++
++/** certbot that issues for real (writes the certificate) but runs nothing. */
++function issuer(host) {
++ host.certbot = (args) => {
++ if (args[0] === 'renew') return { code: 0, stdout: 'Certificate not yet due for renewal.' };
++ if (args[0] === 'certonly') {
++ const h = args[args.indexOf('-d') + 1];
++ host.put(`/etc/letsencrypt/live/${h}/fullchain.pem`, LONG, { mode: 0o644 });
++ return { code: 0, stdout: `Successfully received certificate for ${h}.` };
++ }
++ return { code: 1, stderr: `unknown certbot subcommand ${args[0]}` };
++ };
++ return host;
++}
++
++runTests([
++ test('a verified domain with no certificate is issued and then installed over HTTPS', async () => {
++ const host = issuer(withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]));
++ assert.strictEqual(host.read('/etc/letsencrypt/live/docs.tenant-b.net/fullchain.pem'), null, 'no certificate yet');
++ const r = await host.cli('certs', 'renew', 'host', '--install');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.ok(host.certbotCalls.some((c) => c.args[0] === 'renew'), 'the wildcard lineage is renewed first');
++ const call = host.certbotCalls.find((c) => c.args[0] === 'certonly');
++ assert.ok(call, 'certbot certonly ran');
++ assert.deepStrictEqual(call.args.slice(0, 4), ['certonly', '--webroot', '-w', '/var/www/certbot']);
++ assert.strictEqual(call.args[call.args.indexOf('-d') + 1], 'docs.tenant-b.net');
++ assert.ok(call.privileged, 'certbot runs as root');
++ const custom = host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf');
++ assert.match(custom, /server_name docs\.tenant-b\.net;[\s\S]*?ssl_certificate +\/etc\/letsencrypt\/live\/docs\.tenant-b\.net\/fullchain\.pem;/);
++ assert.ok(host.files.get('/etc/nginx/sites-enabled/openvibe.host-custom-domains.conf'), 'installed and enabled');
++ assert.ok(host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'nginx reloaded');
++ assert.ok(!host.reads.some((f) => /privkey|\.key$/.test(f)), 'no key file was read');
++ assert.ok(!host.certbotCalls.some((c) => c.args.some((a) => /privkey|\.key$/.test(String(a)))), 'no command touched a key path');
++ }),
++
++ test('issuance failure keeps the domain HTTP-only and fails the command', async () => {
++ const host = withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]);
++ host.certbot = (args) => (args[0] === 'certonly' ? { code: 1, stderr: 'Certbot failed to authenticate some domains: Challenge failed' } : { code: 0, stdout: '' });
++ const r = await host.cli('certs', 'renew', 'host', '--install');
++ assert.strictEqual(r.code, 2, r.out);
++ assert.match(r.out, /failed docs\.tenant-b\.net: .*Challenge failed .* it stays HTTP-only/);
++ const custom = host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf');
++ assert.ok(!/ssl_certificate +\/etc\/letsencrypt\/live\/docs\.tenant-b\.net\/fullchain\.pem;/.test(custom), 'no HTTPS block without a certificate');
++ assert.match(custom, /server_name docs\.tenant-b\.net;[\s\S]*?location \/ \{ return 404; \}/, 'ACME only until it succeeds');
++ assert.ok(host.files.get('/etc/nginx/sites-enabled/openvibe.host-custom-domains.conf'), 'the vhosts were still installed');
++ }),
++
++ test('hostile database values never reach certbot argv', async () => {
++ const host = issuer(withHost(scenario(), [
++ { hostname: 'ok.example.org' },
++ { hostname: 'evil.org; } server { listen 80; root /; }' },
++ { hostname: 'x.openvibe.host' },
++ { hostname: '../../etc/letsencrypt' },
++ { hostname: 'with space.org' },
++ ]));
++ const r = await host.cli('certs', 'renew', 'host');
++ assert.strictEqual(r.code, 0, r.out);
++ const certonly = host.certbotCalls.filter((c) => c.args[0] === 'certonly');
++ assert.deepStrictEqual(certonly.map((c) => c.args[c.args.indexOf('-d') + 1]), ['ok.example.org'], 'only the plain host name was issued');
++ assert.match(r.out, /refused \(not a plain host name\)/);
++ assert.ok(certonly.every((c) => c.args.every((a) => !/evil|x\.openvibe\.host|\.\.\/|with space/.test(a))));
++ }),
++
++ test('without --install it writes no vhost and reports the plan; --json is the same shape', async () => {
++ const host = issuer(withHost(scenario(), [{ hostname: 'docs.tenant-b.net' }]));
++ const r = await host.cli('certs', 'renew', '--json', 'host');
++ assert.strictEqual(r.code, 0, r.out);
++ const o = JSON.parse(r.out);
++ assert.strictEqual(o.renewed, true);
++ assert.deepStrictEqual(o.issued, ['docs.tenant-b.net']);
++ assert.deepStrictEqual(o.failed, []);
++ assert.strictEqual(o.installed, null);
++ assert.strictEqual(host.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), null, 'render alone writes nothing');
++ assert.ok(!host.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'));
++ }),
++
++ test('the renewal unit is rendered: a root oneshot that renews and installs, on a persistent timer', async () => {
++ const { service, timer } = acme.unitTexts();
++ assert.match(service, /^Type=oneshot$/m);
++ assert.match(service, /^User=root$/m);
++ assert.match(service, /^ExecStart=\/usr\/local\/bin\/ovhost certs renew --install$/m);
++ assert.match(service, /^Environment=OVHOST_INVENTORY=\/etc\/openvibe\/host\.json$/m);
++ assert.match(timer, /^OnCalendar=.*UTC$/m);
++ assert.match(timer, /^Persistent=true$/m);
++ assert.match(timer, /^Unit=openvibe-certs\.service$/m);
++ assert.match(timer, /^WantedBy=timers\.target$/m);
++ assert.ok(!service.includes('{{') && !timer.includes('{{'));
++ }),
++]);
ddiff --git a/test/cli-inventory.test.js b/test/cli-inventory.test.js
iindex 257b1df..e87508e 100644
---- a/test/cli-inventory.test.js
++++ b/test/cli-inventory.test.js
@@@ -43,7 +43,7 @@ runTests([
const m = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));
contracts.assertValid('registry.service-manifest', m);
assert.strictEqual(m.id, 'host');
-- assert.deepStrictEqual(m.capabilities, ['host.site.manage', 'host.deploy.create', 'host.domain.manage'], 'Stage A has no API; the Stage B service enforces these three');
++ assert.deepStrictEqual(m.capabilities, ['host.site.manage', 'host.deploy.create', 'host.domain.manage', 'host.site.config'], 'Stage A has no API; the Stage B service enforces these four');
}),
test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 88ef99b..aff44fe 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -52,6 +52,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
statfsFree: 100 * 1024 ** 3,
statfsSize: 500 * 1024 ** 3,
reads: [],
++ certbot: null, // (args, opts) -> { code, stdout, stderr } | Error ; default: nothing to do
++ certbotCalls: [], // { args, as, privileged }
onRestart: null, // (unit) -> void
onSystemdRun: null, // (spec) -> undefined | { code, stderr } ; spec = { unit, uid, cwd, props, argv, pid }
onKill: null, // (pid, signal) -> false to ignore the signal
@@@ -430,6 +432,12 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const r = host.onNode ? host.onNode(args, opts) : undefined;
return r ? { stdout: '', stderr: '', ...r } : { code: 0, stdout: '', stderr: '' };
}
++ case 'certbot': {
++ host.certbotCalls.push({ args, as: opts.as || null, privileged: !!opts.privileged });
++ const r = host.certbot ? host.certbot(args, opts) : undefined;
++ if (r instanceof Error) return { code: 1, stdout: '', stderr: r.message };
++ return { code: 0, stdout: 'Certificate not yet due for renewal; no action taken.', stderr: '', ...(r || {}) };
++ }
case 'fuser': return { code: (host.openFiles || new Set()).has(path.resolve(args[args.length - 1])) ? 0 : 1, stdout: '', stderr: '' };
// ── PostgreSQL tools (lib/dbengine.js runs each of these as the postgres OS user) ──
case 'psql': {
ddiff --git a/test/host-preview.test.js b/test/host-preview.test.js
nnew file mode 100644
iindex 0000000..ba6de5d
---- /dev/null
++++ b/test/host-preview.test.js
@@@ -0,0 +1,153 @@
++'use strict';
++/**
++ * Preview deploys (plan T12 J4, decision D5). Uploading a deploy with ?preview=1 stores it with
++ * source 'preview' and makes it the site's live preview (host_sites.preview_deploy_id) WITHOUT
++ * activating it: the public site keeps serving its active deploy. A preview is served only on the
++ * dashboard host, at /preview/<deploy-id>/…, only to a member of the deploy's project, always
++ * noindex and never cached by a shared cache; it is never pinged to IndexNow and never appears in
++ * the site's sitemap. It stops being served when it expires, when the site deploys or rolls back,
++ * or when the preview is deleted, and it can only reach its own project's objects.
++ */
++const assert = require('assert');
++const { boot, check, done, DASHBOARD } = require('./stageb/boot');
++const { PREVIEW_TTL_MS } = require('../server/domain/deploys');
++
++/** A spy in place of IndexNow's HTTP send: records every pingSoon batch (the repo's test pattern). */
++function makeSpy() {
++ const pings = [];
++ return {
++ pings,
++ module: {
++ enabled: true,
++ keyFile: (_req, _res, next) => next(),
++ pingSoon: (urls) => { const a = Array.isArray(urls) ? urls : [urls]; pings.push(...a); return a.length; },
++ ping: async () => ({ sent: 0, status: 0 }),
++ flush: async () => ({ sent: 0, status: 0 }),
++ },
++ };
++}
++
++(async () => {
++ const s = makeSpy();
++ const t = await boot({ indexnow: s.module });
++ const alice = t.user('alice');
++ const bob = t.user('bob');
++ const pa = await t.project(alice, 'A');
++ const pb = await t.project(bob, 'B');
++ const alpha = await t.site(alice, pa.id, 'alpha');
++ const beta = await t.site(bob, pb.id, 'beta');
++ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA LIVE', 'about.html': 'ALPHA ABOUT' });
++ await t.deploy(bob, beta.id, { 'index.html': 'BETA LIVE', 'secret.txt': 'BETA SECRET' });
++ s.pings.length = 0;
++
++ const preview = (id, p = '/') => `/preview/${id}${p}`;
++ const asMember = (id, p, user) => t.get(DASHBOARD, preview(id, p), { session: user });
++ let pv = null;
++
++ await check('a preview is stored as source=preview, points the site at it, and never goes active', async () => {
++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW', 'about.html': 'PREVIEW ABOUT' }, { activate: false, query: 'preview=1' });
++ assert.strictEqual(r.status, 201, r.text);
++ const body = r.json();
++ pv = body.deploy;
++ assert.strictEqual(pv.source, 'preview');
++ assert.strictEqual(body.activated, false, 'a preview is never activated');
++ assert.ok(body.preview && body.preview.deploy_id === pv.id, r.text);
++ assert.ok(body.preview.expires_at > t.clock.now());
++ const pub = await t.get('alpha.openvibe.host', '/');
++ assert.strictEqual(pub.status, 200);
++ assert.strictEqual(pub.text, 'ALPHA LIVE', 'the public site keeps serving its active deploy');
++ assert.ok(!s.pings.some((u) => u.includes('/preview/')), 'a preview is never pinged');
++ });
++
++ await check('a member views the preview on the dashboard: 200, noindex, no-store, no cookie', async () => {
++ const r = await asMember(pv.id, '/', alice);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.text, 'ALPHA PREVIEW');
++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');
++ assert.match(r.headers['cache-control'], /no-store/);
++ assert.strictEqual(r.headers['set-cookie'], undefined);
++ assert.strictEqual(r.headers['x-openvibe-deploy'], pv.id);
++ // Tenant content on the dashboard's own origin is sandboxed into an opaque origin: it can
++ // never act as openvibe.host (no allow-same-origin, never the dashboard's own CSP).
++ assert.match(r.headers['content-security-policy'], /sandbox /);
++ assert.ok(!r.headers['content-security-policy'].includes('allow-same-origin'), 'the preview must not keep the dashboard origin');
++ assert.ok(r.headers['content-security-policy'].startsWith("default-src 'none'"), 'not the dashboard CSP');
++ const file = await asMember(pv.id, '/about.html', alice);
++ assert.strictEqual(file.status, 200);
++ assert.strictEqual(file.text, 'PREVIEW ABOUT');
++ const head = await t.get(DASHBOARD, preview(pv.id), { method: 'HEAD', session: alice });
++ assert.strictEqual(head.status, 200);
++ assert.strictEqual(head.text, '');
++ });
++
++ await check('non-members and signed-out callers cannot view a preview', async () => {
++ assert.strictEqual((await asMember(pv.id, '/', bob)).status, 404, 'another project member must not see it');
++ assert.strictEqual((await t.get(DASHBOARD, preview(pv.id))).status, 404, 'signed out must not see it');
++ assert.strictEqual((await t.get(DASHBOARD, preview(pv.id), { as: bob })).status, 404, 'a bearer token is not a dashboard session');
++ assert.strictEqual((await asMember('dpl_00000000000000000000000000', '/', alice)).status, 404);
++ assert.strictEqual((await asMember('not-an-id', '/', alice)).status, 404);
++ });
++
++ await check('a preview is never served on a public tenant host', async () => {
++ for (const host of ['alpha.openvibe.host', 'beta.openvibe.host']) {
++ const r = await t.get(host, preview(pv.id));
++ assert.strictEqual(r.status, 404, `${host} → ${r.status}`);
++ assert.ok(!r.text.includes('ALPHA PREVIEW'), host);
++ }
++ });
++
++ await check('isolation: another project\'s preview and another project\'s files are unreachable', async () => {
++ const b = await t.upload(bob, beta.id, { 'index.html': 'BETA PREVIEW' }, { activate: false, query: 'preview=1' });
++ assert.strictEqual(b.status, 201, b.text);
++ const bpv = b.json().deploy;
++ assert.strictEqual((await asMember(bpv.id, '/', alice)).status, 404, 'alice is not on beta');
++ assert.strictEqual((await asMember(bpv.id, '/', bob)).status, 200);
++ assert.strictEqual((await asMember(pv.id, '/secret.txt', alice)).status, 404, 'alpha cannot read beta\'s same-named file');
++ for (const p of ['/../secret.txt', '/%2e%2e/secret.txt', '/a%2fb', '/%00']) {
++ assert.strictEqual((await asMember(pv.id, p, alice)).status, 404, p);
++ }
++ });
++
++ await check('a rollback makes the live preview vanish', async () => {
++ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA LIVE 2' });
++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 2' }, { activate: false, query: 'preview=1' });
++ const p2 = r.json().deploy;
++ assert.strictEqual((await asMember(p2.id, '/', alice)).status, 200);
++ const rb = await t.api('POST', `/api/v1/sites/${alpha.id}/rollback`, { as: alice, json: {} });
++ assert.strictEqual(rb.status, 200, rb.text);
++ assert.strictEqual((await asMember(p2.id, '/', alice)).status, 404, 'a rollback supersedes the preview');
++ });
++
++ await check('an expired preview vanishes', async () => {
++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 3' }, { activate: false, query: 'preview=1' });
++ const p3 = r.json().deploy;
++ assert.strictEqual((await asMember(p3.id, '/', alice)).status, 200);
++ t.clock.advance(PREVIEW_TTL_MS + 1000);
++ assert.strictEqual((await asMember(p3.id, '/', alice)).status, 404, 'an expired preview is not served');
++ });
++
++ await check('deleting the preview deploy makes it vanish', async () => {
++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 4' }, { activate: false, query: 'preview=1' });
++ const p4 = r.json().deploy;
++ assert.strictEqual((await asMember(p4.id, '/', alice)).status, 200);
++ const del = await t.api('DELETE', `/api/v1/deploys/${p4.id}`, { as: alice, json: {} });
++ assert.strictEqual(del.status, 200, del.text);
++ assert.strictEqual((await asMember(p4.id, '/', alice)).status, 404, 'a deleted preview is not served');
++ });
++
++ await check('a preview is never listed in the sitemap and never pinged', async () => {
++ const r = await t.upload(alice, alpha.id, { 'index.html': 'ALPHA PREVIEW 5', 'page.html': 'PAGE' }, { activate: false, query: 'preview=1' });
++ const p5 = r.json().deploy;
++ assert.strictEqual((await asMember(p5.id, '/page.html', alice)).status, 200);
++ s.pings.length = 0;
++ const sm = await t.get('alpha.openvibe.host', '/sitemap.xml');
++ assert.strictEqual(sm.status, 200, sm.text);
++ assert.ok(!sm.text.includes('/preview/'), 'the sitemap never lists a preview URL');
++ assert.ok(!sm.text.includes(p5.id), 'the sitemap never names a preview deploy');
++ assert.ok(!sm.text.includes('ALPHA PREVIEW'), 'the sitemap cannot leak draft content');
++ assert.deepStrictEqual(s.pings, [], 'viewing a preview pings nothing');
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/host-secrets-dashboard.test.js b/test/host-secrets-dashboard.test.js
iindex e041980..073dcd6 100644
---- a/test/host-secrets-dashboard.test.js
++++ b/test/host-secrets-dashboard.test.js
@@@ -121,6 +121,19 @@ const ORIGIN = 'https://openvibe.host';
const bad = await t.api('POST', `/sites/${siteId}/deploys`, { session: alice, body: part('csrf', null, csrf) + part('files', 'x.php', '<?php') + `--${boundary}--\r\n`, headers: { origin: ORIGIN, 'content-type': `multipart/form-data; boundary=${boundary}` } });
assert.strictEqual(bad.status, 303);
assert.match(decodeURIComponent(bad.headers.location), /Upload refused/);
++ // "Preview only": the site keeps its active deploy; the site page links the member to the private preview.
++ assert.match(sitePage.text, /name="mode" value="preview"/);
++ const pv = await t.api('POST', `/sites/${siteId}/deploys`, { session: alice, body: part('csrf', null, csrf) + part('strip', null, 'folder') + part('mode', null, 'preview') + part('files', 'site/index.html', 'DASH PREVIEW') + `--${boundary}--\r\n`, headers: { origin: ORIGIN, 'content-type': `multipart/form-data; boundary=${boundary}` } });
++ assert.strictEqual(pv.status, 303, pv.text);
++ assert.match(decodeURIComponent(pv.headers.location), /Deployed as a preview/);
++ const pvId = pv.headers.location.match(/\/deploys\/(dpl_[0-9A-Z]+)/)[1];
++ assert.strictEqual((await t.get('dash-site.openvibe.host', '/')).text, 'DASH OK', 'a preview never goes live');
++ const withPreview = await t.api('GET', `/sites/${siteId}`, { session: alice });
++ assert.ok(withPreview.text.includes(`href="/preview/${pvId}/"`), 'the site page links the live preview');
++ assert.match(withPreview.text, /<strong class="preview">preview<\/strong>/);
++ const seen = await t.api('GET', `/preview/${pvId}/`, { session: alice });
++ assert.strictEqual(seen.status, 200, seen.text);
++ assert.strictEqual(seen.text, 'DASH PREVIEW');
});
await check('forms: domain instructions, rollback, and refusal of cross-origin posts even with a valid token', async () => {
ddiff --git a/test/host-site-config.test.js b/test/host-site-config.test.js
nnew file mode 100644
iindex 0000000..f6c61f7
---- /dev/null
++++ b/test/host-site-config.test.js
@@@ -0,0 +1,149 @@
++'use strict';
++/**
++ * Per-site configuration (plan T12 J3, decision D4): response headers, local-only redirects and the
++ * SPA fallback, stored per site (host_site_config) and applied by http/tenant.js BEFORE the
++ * platform's headers. The platform's security, routing and caching headers can never be set through
++ * it (on write or from a smuggled row); a redirect target is always a local path; and a config is
++ * private to its site and project.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./stageb/boot');
++
++const EMPTY = { headers: {}, redirects: [], spa: false };
++
++(async () => {
++ const t = await boot();
++ const alice = t.user('alice');
++ const bob = t.user('bob');
++ const staff = t.user('root', { role: 'admin' });
++ const pa = await t.project(alice, 'A');
++ const pb = await t.project(bob, 'B');
++ const alpha = await t.site(alice, pa.id, 'alpha');
++ const beta = await t.site(bob, pb.id, 'beta');
++ await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA HOME', 'about.html': 'ALPHA ABOUT', 'app.3f2a9c1b.js': 'js();', '404.html': 'ALPHA NOT FOUND' });
++ await t.deploy(bob, beta.id, { 'index.html': 'BETA HOME' });
++
++ const put = (as, id, json) => t.api('PUT', `/api/v1/sites/${id}/config`, { as, json });
++
++ await check('defaults: no row means no headers, no redirects, no SPA', async () => {
++ const r = await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: alice });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json().config, EMPTY);
++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM host_site_config').get()).n, 0);
++ });
++
++ await check('the API sets, reads and resets a config', async () => {
++ const set = await put(alice, alpha.id, {
++ headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' },
++ redirects: [{ from: '/old', to: '/new', status: 308 }, { from: '/moved', to: '/about.html' }],
++ spa: true,
++ });
++ assert.strictEqual(set.status, 200, set.text);
++ assert.deepStrictEqual(set.json().config.headers, { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' });
++ assert.deepStrictEqual(set.json().config.redirects, [{ from: '/old', to: '/new', status: 308 }, { from: '/moved', to: '/about.html', status: 301 }]);
++ assert.strictEqual(set.json().config.spa, true);
++ assert.deepStrictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: alice })).json().config, set.json().config);
++ const del = await t.api('DELETE', `/api/v1/sites/${alpha.id}/config`, { as: alice });
++ assert.strictEqual(del.status, 200, del.text);
++ assert.deepStrictEqual(del.json().config, EMPTY);
++ assert.strictEqual((await t.get('alpha.openvibe.host', '/old')).status, 404, 'reset drops the redirect');
++ });
++
++ await check('configured headers ride on 200, the 404 and the 451 after a takedown', async () => {
++ assert.strictEqual((await put(alice, alpha.id, { headers: { 'X-Frame-Options': 'SAMEORIGIN', 'X-Content-Language': 'en' } })).status, 200);
++ const ok = await t.get('alpha.openvibe.host', '/');
++ assert.strictEqual(ok.status, 200);
++ assert.strictEqual(ok.headers['x-frame-options'], 'SAMEORIGIN');
++ assert.strictEqual(ok.headers['x-content-language'], 'en');
++ assert.strictEqual(ok.headers['x-served-by'], 'OpenVibe.Host');
++ const nf = await t.get('alpha.openvibe.host', '/nope.html');
++ assert.strictEqual(nf.status, 404);
++ assert.strictEqual(nf.text, 'ALPHA NOT FOUND');
++ assert.strictEqual(nf.headers['x-frame-options'], 'SAMEORIGIN');
++ const tk = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'config test' } });
++ assert.strictEqual(tk.status, 201, tk.text);
++ const down = await t.get('alpha.openvibe.host', '/');
++ assert.strictEqual(down.status, 451);
++ assert.strictEqual(down.headers['x-frame-options'], 'SAMEORIGIN');
++ assert.strictEqual(down.headers['clear-site-data'], '"cache", "storage"');
++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 200);
++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).status, 200);
++ });
++
++ await check('reserved headers and header injection are refused on write', async () => {
++ for (const name of ['Content-Security-Policy', 'Strict-Transport-Security', 'Set-Cookie', 'X-Forwarded-For', 'X-Forwarded-Proto', 'Cache-Control', 'Content-Type', 'X-Content-Type-Options', 'Location', 'Service-Worker-Allowed', 'Alt-Svc']) {
++ const r = await put(alice, alpha.id, { headers: { [name]: 'x' } });
++ assert.strictEqual(r.status, 422, `${name} → ${r.status} ${r.text}`);
++ assert.strictEqual(r.json().code, 'site_config.reserved_header', name);
++ }
++ const inj = await put(alice, alpha.id, { headers: { 'X-Ok': 'a\r\nSet-Cookie: sid=1' } });
++ assert.strictEqual(inj.status, 422);
++ assert.strictEqual(inj.json().code, 'site_config.header_value');
++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).headers['set-cookie'], undefined);
++ });
++
++ await check('a reserved header smuggled into the row is never served (platform headers win)', async () => {
++ await t.ctx.store.db.prepare(`INSERT INTO host_site_config (site_id, headers, redirects, spa, updated_by, updated_at)
++ VALUES (?, ?::jsonb, ?::jsonb, false, ?, ?)
++ ON CONFLICT (site_id) DO UPDATE SET headers = EXCLUDED.headers`)
++ .run(alpha.id, JSON.stringify({
++ 'Content-Security-Policy': 'default-src *', 'Strict-Transport-Security': 'max-age=0',
++ 'Set-Cookie': 'sid=1', 'X-Forwarded-For': '1.2.3.4', 'X-Custom': 'kept',
++ }), JSON.stringify([]), 'test', t.clock.now());
++ const r = await t.get('alpha.openvibe.host', '/');
++ assert.match(r.headers['content-security-policy'], /default-src 'self'/);
++ assert.ok(!/default-src \*/.test(r.headers['content-security-policy']), 'the tenant CSP cannot be replaced');
++ assert.strictEqual(r.headers['strict-transport-security'], undefined);
++ assert.strictEqual(r.headers['set-cookie'], undefined);
++ assert.strictEqual(r.headers['x-forwarded-for'], undefined);
++ assert.strictEqual(r.headers['x-custom'], 'kept', 'a non-reserved header in the same row still applies');
++ });
++
++ await check('no open redirect: a foreign target is refused on write; a local one is kept', async () => {
++ for (const to of ['//evil.com', '/\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/%09/evil.com', '///evil.com', '/%2f%2fevil.com', '/\n/evil.com', 'javascript:alert(1)', ' //evil.com', '\\evil.com']) {
++ const r = await put(alice, alpha.id, { redirects: [{ from: '/go', to }] });
++ assert.strictEqual(r.status, 422, `${JSON.stringify(to)} → ${r.status} ${r.text}`);
++ assert.strictEqual(r.json().code, 'site_config.redirect');
++ }
++ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: 'https://evil.com/x', to: '/x' }] })).status, 422, 'a foreign "from" is refused too');
++ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: '/go', to: '/new', status: 200 }] })).status, 422, 'only 301/302/307/308');
++ assert.strictEqual((await put(alice, alpha.id, { redirects: [{ from: '/old', to: '/about.html', status: 308 }] })).status, 200);
++ const r = await t.get('alpha.openvibe.host', '/old');
++ assert.strictEqual(r.status, 308);
++ assert.strictEqual(r.headers.location, '/about.html');
++ });
++
++ await check('SPA fallback: extensionless paths serve index.html; an extension is still a 404', async () => {
++ assert.strictEqual((await put(alice, alpha.id, { spa: true })).status, 200, 'spa only');
++ for (const p of ['/app', '/app/dashboard', '/settings/']) {
++ const r = await t.get('alpha.openvibe.host', p);
++ assert.strictEqual(r.status, 200, `${p} → ${r.status}`);
++ assert.strictEqual(r.text, 'ALPHA HOME');
++ assert.strictEqual(r.headers['content-type'], 'text/html; charset=utf-8');
++ }
++ assert.strictEqual((await t.get('alpha.openvibe.host', '/about.html')).text, 'ALPHA ABOUT', 'a real file wins');
++ for (const p of ['/missing.js', '/app/missing.css', '/nope.json']) assert.strictEqual((await t.get('alpha.openvibe.host', p)).status, 404, p);
++ assert.strictEqual((await put(alice, alpha.id, { spa: false })).status, 200);
++ assert.strictEqual((await t.get('alpha.openvibe.host', '/app/dashboard')).status, 404, 'SPA off restores the 404');
++ });
++
++ await check('a config is private to its site and project (404 to non-members; no leak to another site)', async () => {
++ assert.strictEqual((await put(alice, alpha.id, { headers: { 'X-Alpha-Secret': 'alpha-only' } })).status, 200);
++ for (const [method, json] of [['GET', undefined], ['PUT', { headers: { 'X-Evil': '1' } }], ['DELETE', undefined]]) {
++ const r = await t.api(method, `/api/v1/sites/${alpha.id}/config`, { as: bob, json });
++ assert.strictEqual(r.status, 404, `${method} → ${r.status} ${r.text}`);
++ }
++ const b = await t.get('beta.openvibe.host', '/');
++ assert.strictEqual(b.text, 'BETA HOME');
++ assert.strictEqual(b.headers['x-alpha-secret'], undefined);
++ // A service token needs host.site.config AND the acting principal's project role.
++ const noCap = t.network.serviceToken('codes', ['host.site.manage']);
++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: noCap, headers: { 'x-ov-subject': alice.subject } })).status, 403);
++ const stranger = t.network.serviceToken('codes', ['host.site.config']);
++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: stranger, headers: { 'x-ov-subject': bob.subject } })).status, 404, 'a non-member service learns nothing');
++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/config`, { as: stranger, headers: { 'x-ov-subject': alice.subject } })).status, 200);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/host-sitemap.test.js b/test/host-sitemap.test.js
nnew file mode 100644
iindex 0000000..c8824dd
---- /dev/null
++++ b/test/host-sitemap.test.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * Tenant sitemap.xml and robots.txt when the deploy ships none: the generated sitemap lists the
++ * manifest's HTML pages (index → the directory URL, the error page left out) on the host the request
++ * came in on, so a verified custom domain gets its own copy under its own name; robots welcomes
++ * crawlers to a production site and keeps a sandbox out (Disallow: /). A file the tenant uploaded at
++ * either path always wins. Neither generated body carries a secret, and both say how to cache them.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./stageb/boot');
++
++const HOST = 'widgets.openvibe.host';
++const CUSTOM = 'www.widgets-example.org';
++
++(async () => {
++ const t = await boot();
++ const alice = t.user('alice', { display_name: 'Alice' });
++ const project = await t.project(alice, 'Widgets');
++ const site = await t.site(alice, project.id, 'widgets');
++ const origin = `https://${HOST}`;
++
++ await t.deploy(alice, site.id, {
++ 'index.html': '<h1>Home</h1>',
++ 'about.html': '<h1>About</h1>',
++ 'docs/index.html': '<h1>Docs</h1>',
++ '404.html': '<h1>Gone</h1>',
++ 'style.css': 'body{}',
++ 'notes.txt': 'hi',
++ }, { activate: true });
++
++ await check('a generated sitemap lists the manifest HTML paths on this host (index → /, 404 left out)', async () => {
++ const r = await t.get(HOST, '/sitemap.xml');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.headers['content-type'], /application\/xml/);
++ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
++ assert.deepStrictEqual([...new Set(locs)].sort(), [`${origin}/`, `${origin}/about.html`, `${origin}/docs/`].sort());
++ });
++
++ await check('a generated robots.txt welcomes crawlers and names the sitemap', async () => {
++ const r = await t.get(HOST, '/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers['content-type'], /text\/plain/);
++ assert.match(r.text, /^User-agent: \*\nAllow: \/\n/);
++ assert.ok(r.text.includes(`Sitemap: ${origin}/sitemap.xml`), r.text);
++ });
++
++ await check('a verified custom domain serves its own generated sitemap under its own name', async () => {
++ const add = await t.api('POST', `/api/v1/sites/${site.id}/domains`, { as: alice, json: { hostname: CUSTOM } });
++ assert.strictEqual(add.status, 201, add.text);
++ const dom = add.json().domain;
++ t.dns.set(`_openvibe-host.${CUSTOM}`, [dom.instructions.verification.value]);
++ const v = await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice });
++ assert.strictEqual(v.json().domain.status, 'verified', v.text);
++ const r = await t.get(CUSTOM, '/sitemap.xml');
++ assert.strictEqual(r.status, 200, r.text);
++ const locs = [...r.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
++ assert.deepStrictEqual([...new Set(locs)].sort(), [`https://${CUSTOM}/`, `https://${CUSTOM}/about.html`, `https://${CUSTOM}/docs/`].sort());
++ const rb = await t.get(CUSTOM, '/robots.txt');
++ assert.ok(rb.text.includes(`Sitemap: https://${CUSTOM}/sitemap.xml`), rb.text);
++ });
++
++ await check("the tenant's own sitemap.xml and robots.txt win", async () => {
++ await t.deploy(alice, site.id, {
++ 'index.html': '<h1>Home</h1>',
++ 'sitemap.xml': '<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>https://example.com/own</loc></url></urlset>',
++ 'robots.txt': 'User-agent: *\nDisallow: /private\n',
++ }, { activate: true });
++ const sm = await t.get(HOST, '/sitemap.xml');
++ assert.strictEqual(sm.status, 200);
++ assert.match(sm.headers['content-type'], /application\/xml/);
++ assert.ok(sm.text.includes('https://example.com/own'), sm.text);
++ assert.ok(!sm.text.includes('/about.html'), sm.text);
++ const rb = await t.get(HOST, '/robots.txt');
++ assert.strictEqual(rb.text, 'User-agent: *\nDisallow: /private\n');
++ assert.match(rb.headers['content-type'], /text\/plain/);
++ });
++
++ await check('a sandbox site generates Disallow: / (and stays noindex)', async () => {
++ const sandbox = await t.project(alice, 'Sandbox', { environment: 'sandbox' });
++ const sb = await t.site(alice, sandbox.id, 'playground');
++ await t.deploy(alice, sb.id, { 'index.html': '<h1>Play</h1>' }, { activate: true });
++ const r = await t.get(`${sb.name}.openvibe.host`, '/robots.txt');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.text, /^User-agent: \*\nDisallow: \/\n*$/);
++ assert.ok(!r.text.includes('Sitemap'), r.text);
++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');
++ });
++
++ await check('generated files are cacheable, answer HEAD, and never carry a secret', async () => {
++ await t.deploy(alice, site.id, { 'index.html': '<h1>Home</h1>', 'about.html': '<h1>About</h1>' }, { activate: true });
++ const sm = await t.get(HOST, '/sitemap.xml');
++ const rb = await t.get(HOST, '/robots.txt');
++ assert.match(sm.headers['cache-control'], /max-age=/);
++ assert.match(rb.headers['cache-control'], /max-age=/);
++ const head = await t.get(HOST, '/sitemap.xml', { method: 'HEAD' });
++ assert.strictEqual(head.status, 200);
++ assert.strictEqual(head.text, '');
++ for (const r of [sm, rb]) {
++ for (const secretish of [t.SECRET, '_openvibe-host', 'openvibe-host-verification', 'prj_', 'site_', 'dpl_', 'dom_']) {
++ assert.ok(!r.text.includes(secretish), `${secretish} in ${r.text}`);
++ }
++ assert.strictEqual(r.headers['set-cookie'], undefined);
++ assert.match(r.headers['content-security-policy'], /default-src 'self'/);
++ }
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });