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).
Changes
@@@ -352,19 +352,19 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant5. **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.nnew file mode 100644@@@ -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=truennew file mode 100644@@@ -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.targetnnew file mode 100644@@@ -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"++ ]++}@@@ -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 onlythe 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@@@ -16,7 +16,8 @@"capabilities": ["host.site.manage","host.deploy.create",-- "host.domain.manage"++ "host.domain.manage",++ "host.site.config"],"eventsProduced": ["host.deploy.created",nnew file mode 100644@@@ -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 };@@@ -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 logcerts [--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) => procreturn 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 {@@@ -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,nnew file mode 100644@@@ -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++);nnew file mode 100644@@@ -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;@@@ -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); }@@@ -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) => {@@@ -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));@@@ -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, ologFor: 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, oconst 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, oif (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 };nnew file mode 100644@@@ -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,++};@@@ -33,7 +33,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nuVALUES (?, ?, ?, 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 = nuawait 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 }) {@@@ -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;}@@@ -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;@@@ -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 = consWHERE 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_idWHERE 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 = consreturn { 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 = consstream.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 = consreturn 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 = consconst 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 = consres.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 = consreturn 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 };@@@ -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 {@@@ -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 -> to [status]</code>)<textarea name="redirects" rows="4" spellcheck="false" placeholder="/old -> /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` })}` : ''}`;}nnew file mode 100644@@@ -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('{{'));++ }),++]);@@@ -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 () => {@@@ -52,6 +52,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', ststatfsFree: 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) -> voidonSystemdRun: 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', stconst 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': {nnew file mode 100644@@@ -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); });@@@ -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 () => {nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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); });
@@ -352,19 +352,19 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
@@ -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).
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`.
6. **Unit.** `sudo cp deploy/systemd/openvibe-host.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now openvibe-host`, then `curl -s http://127.0.0.1:4910/api/ready`.
7. **nginx.** Add the `host` entry from [`host.example.json`](host.example.json) to `/etc/openvibe/host.json`, update `/usr/local/lib/openvibe-host`, then `sudo ovhost nginx tenants host` (review) and `sudo ovhost nginx tenants host --install` (writes `openvibe.host.conf` and `openvibe.host-custom-domains.conf`, `nginx -t`, reload; restored on failure). [`deploy/nginx/openvibe.host.conf`](deploy/nginx/openvibe.host.conf) is a reference copy.
7. **nginx.** Add the `host` entry from [`host.example.json`](host.example.json) to `/etc/openvibe/host.json`, update `/usr/local/lib/openvibe-host`, then `sudo ovhost nginx tenants host` (review) and `sudo ovhost nginx tenants host --install` (writes `openvibe.host.conf` and `openvibe.host-custom-domains.conf`, `nginx -t`, reload; restored on failure). [`deploy/nginx/openvibe.host.conf`](deploy/nginx/openvibe.host.conf) is a reference copy.
-8. **Custom domains** (whenever tenants verify one): `sudo ovhost nginx tenants host` lists the verified domains still waiting for a certificate. For each, `sudo certbot certonly --webroot -w /var/www/certbot -d <hostname>` (the generated port-80 block already answers the ACME challenge), then `sudo ovhost nginx tenants host --install` again. Until then, the domain is verified but only answers over HTTP with the challenge. Certificates never pass through the Host API.
+8. **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.
9. **Launch.** Follow [docs/launch.md](docs/launch.md). Until then [`deploy/nginx/openvibe.host-tenants-pending.conf`](deploy/nginx/openvibe.host-tenants-pending.conf) answers every `*.openvibe.host` name with a 404 over the wildcard certificate (installed 2026-09-23; without it the wildcard fell through to nginx's default server). `ovhost nginx tenants host --install` removes it in the same `nginx -t` and reload that installs the tenant vhost (`nginx.tenants.replaces`), and puts it back if the test fails.
### Not done yet (Stage B)
### Not done yet (Stage B)
- 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.
- 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.
- 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.
- 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).
- `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`).
- 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.
- The Codes portal (Wave 20) does not exist, so there is no public developer onboarding for Host yet.
### Acceptance (what `npm test` demonstrates for Stage B)
### Acceptance (what `npm test` demonstrates for Stage B)
@@ -379,6 +379,8 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
@@ -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.
- **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.
- **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.
- **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.
- **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.
- 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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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"
+ ]
+}
@@ -132,6 +132,18 @@ ls -l /etc/nginx/sites-enabled/ | grep openvibe.host # openvibe.host.conf, ope
@@ -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
`openvibe.host.conf` replaces the Sites placeholder vhost of the same name. The previous copy is in
`$B`.
`$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
**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:
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:
@@ -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) |
| 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** |
| 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** |
| 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* |
| 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
**Verdict: ready to launch** through the steps above, once the owner says go. Nothing in the code
@@ -16,7 +16,8 @@
@@ -16,7 +16,8 @@
"capabilities": [
"capabilities": [
"host.site.manage",
"host.site.manage",
"host.deploy.create",
"host.deploy.create",
- "host.domain.manage"
+ "host.domain.manage",
+ "host.site.config"
],
],
"eventsProduced": [
"eventsProduced": [
"host.deploy.created",
"host.deploy.created",
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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 };
@@ -51,6 +51,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -51,6 +51,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
/etc/openvibe/host.env (docs/release-notifications.md)
/etc/openvibe/host.env (docs/release-notifications.md)
releases <service> [--limit <n>] the release log
releases <service> [--limit <n>] the release log
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
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]
nginx render <service> [--variant http|sse|websocket] [--manifest <file>] [--install]
print a vhost from the service manifest; --install writes it,
print a vhost from the service manifest; --install writes it,
runs nginx -t and reloads only if clean
runs nginx -t and reloads only if clean
@@ -349,6 +353,19 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -349,6 +353,19 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
return 0;
return 0;
}
}
case 'certs': {
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) });
const list = await lib.certs.inventory(exec, inv, { warnDays: Number(opts['warn-days'] || 21) });
if (json) emit(list);
if (json) emit(list);
else {
else {
@@ -13,6 +13,7 @@ const { snapshot } = require('./commands/snapshot');
@@ -13,6 +13,7 @@ const { snapshot } = require('./commands/snapshot');
const { drill, DrillError } = require('./commands/drill');
const { drill, DrillError } = require('./commands/drill');
const releases = require('./releases');
const releases = require('./releases');
const certs = require('./certs');
const certs = require('./certs');
+const acme = require('./acme');
const nginx = require('./nginx');
const nginx = require('./nginx');
const announce = require('./announce');
const announce = require('./announce');
const { createSystemExecutor } = require('./executor');
const { createSystemExecutor } = require('./executor');
@@ -45,6 +46,7 @@ module.exports = {
@@ -45,6 +46,7 @@ module.exports = {
DrillError,
DrillError,
releases,
releases,
certs,
certs,
+ acme,
nginx,
nginx,
announce: announce.announce,
announce: announce.announce,
announceSummary: announce.summary,
announceSummary: announce.summary,
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -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
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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;
@@ -18,7 +18,10 @@ form.inline { display: inline-flex; margin: 0; }
@@ -18,7 +18,10 @@ form.inline { display: inline-flex; margin: 0; }
fieldset { border: 1px solid var(--hs-line); border-radius: 8px; padding: .5rem .75rem; }
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 { display: flex; flex-direction: column; gap: .25rem; font-size: 14px; }
label.confirm { flex-direction: row; align-items: center; }
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, .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.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); }
button.danger, .danger { background: var(--hs-bad); color: #0a0f1c; } button.link.danger { background: none; color: var(--hs-bad); }
@@ -31,6 +31,7 @@ const { createProjects } = require('./domain/projects');
@@ -31,6 +31,7 @@ const { createProjects } = require('./domain/projects');
const { createSites } = require('./domain/sites');
const { createSites } = require('./domain/sites');
const { createDeploys } = require('./domain/deploys');
const { createDeploys } = require('./domain/deploys');
const { createDomains } = require('./domain/domains');
const { createDomains } = require('./domain/domains');
+const { createSiteConfig } = require('./domain/site-config');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createTenantServer } = require('./http/tenant');
const { createTenantServer } = require('./http/tenant');
@@ -108,6 +109,7 @@ async function createApp(opts = {}) {
@@ -108,6 +109,7 @@ async function createApp(opts = {}) {
const sites = createSites({ store, config, access, projects, takedowns, indexnow: announce });
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 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 domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log, indexnow: announce });
+ const siteConfig = createSiteConfig({ store });
const auth = opts.auth || createSsoClient({
const auth = opts.auth || createSsoClient({
site: 'host',
site: 'host',
baseUrl: config.baseUrl,
baseUrl: config.baseUrl,
@@ -123,11 +125,11 @@ async function createApp(opts = {}) {
@@ -123,11 +125,11 @@ async function createApp(opts = {}) {
...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}),
...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}),
});
});
const viewers = createViewerResolver({ auth, config, log });
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 uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -197,7 +199,8 @@ async function createApp(opts = {}) {
@@ -197,7 +199,8 @@ async function createApp(opts = {}) {
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
app.get('/metrics', sharedMetrics.metricsHandler(registry));
// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
// 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;
const legalPaths = require('openvibe-shared/legal').PATHS;
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
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')));
.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 = {}) {
@@ -224,6 +227,15 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
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 ───────────────────────────────────────────
// ── Dashboard ───────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use((req, res) => {
app.use((req, res) => {
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
* host.site.manage projects, members, sites, quotas (read), deleting deploys
* host.site.manage projects, members, sites, quotas (read), deleting deploys
* host.deploy.create upload deploys, list them and their logs, activate, roll back
* host.deploy.create upload deploys, list them and their logs, activate, roll back
* host.domain.manage add, verify and remove custom domains
* 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
* 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.
* as (domain/access.js). Browsers are judged by their role alone.
@@ -23,6 +24,7 @@ const CAPABILITIES = Object.freeze({
@@ -23,6 +24,7 @@ const CAPABILITIES = Object.freeze({
SITE_MANAGE: 'host.site.manage',
SITE_MANAGE: 'host.site.manage',
DEPLOY_CREATE: 'host.deploy.create',
DEPLOY_CREATE: 'host.deploy.create',
DOMAIN_MANAGE: 'host.domain.manage',
DOMAIN_MANAGE: 'host.domain.manage',
+ SITE_CONFIG: 'host.site.config',
});
});
const PROPOSED = new Set(Object.values(CAPABILITIES));
const PROPOSED = new Set(Object.values(CAPABILITIES));
@@ -12,6 +12,11 @@
@@ -12,6 +12,11 @@
* rollback the same switch, to a named deploy or to the one that was active before.
* 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
* delete only a deploy that is not active; its objects are removed when no other deploy of
* the project uses them.
* 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.
* 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');
@@ -21,6 +26,9 @@ const { buildManifest } = require('../artifacts/validate');
const { principalOf, actorRef } = require('./access');
const { principalOf, actorRef } = require('./access');
const DAY = 24 * 3600 * 1000;
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`);
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 }) {
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
@@ -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'),
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'),
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 ?'),
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 (?, ?, ?, ?, ?, ?)'),
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 ?'),
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 = ?"),
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
@@ -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 }
* 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.
* 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;
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 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}`) });
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
@@ -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' },
{ level: 'info', message: 'no build step: Stage B serves the uploaded files as they are; nothing was executed' },
];
];
let activated = null;
let activated = null;
+ const previewExpiresAt = isPreview ? now + PREVIEW_TTL_MS : null;
await store.tx(async () => {
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 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);
for (const f of fresh.values()) await q.insertBlob.run(project.id, f.sha256, f.size, now);
await outbox.emit({
await outbox.emit({
event_type: 'host.deploy.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
event_type: 'host.deploy.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'deploy', id },
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 });
}, { 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 });
activated = await switchPointer(viewer, site, await get(id), 'activate', { traceparent });
lines.push({ level: 'info', message: 'activated: the site now serves this deploy' });
lines.push({ level: 'info', message: 'activated: the site now serves this deploy' });
} else {
} else {
@@ -181,7 +201,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -181,7 +201,7 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
});
});
outbox.kick();
outbox.kick();
if (activated && activated.changed) announceSite(site, project);
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. */
/** 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
@@ -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);
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');
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);
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({
await outbox.emit({
event_type: 'host.deploy.activated', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
event_type: 'host.deploy.activated', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'deploy', id: deploy.id },
subject: { type: 'deploy', id: deploy.id },
@@ -264,6 +286,8 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -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');
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.markDeleted.run(store.now(), deploy.id);
await q.dropFiles.run(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);
freed = await collectGarbage(project.id);
});
});
unlinkBlobs(project.id, freed);
unlinkBlobs(project.id, freed);
@@ -279,4 +303,4 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
@@ -279,4 +303,4 @@ function createDeploys({ store, config = null, access, projects, sites, blobs, o
};
};
}
}
-module.exports = { createDeploys };
+module.exports = { createDeploys, PREVIEW_TTL_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -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,
+};
@@ -33,7 +33,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nu
@@ -33,7 +33,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nu
VALUES (?, ?, ?, NULL, 'active', ?, ?, ?)`),
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)
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, ?, ?, ?)`),
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}`;
const defaultHostname = (name) => `${name}.${config.sitesDomain}`;
@@ -98,6 +98,7 @@ function createSites({ store, config, access, projects, takedowns, indexnow = nu
@@ -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("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_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_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 }) {
async function remove(viewer, id, { deploys }) {
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* projects GET/POST /projects · GET/DELETE /projects/:id · GET/PUT /projects/:id/quota
* projects GET/POST /projects · GET/DELETE /projects/:id · GET/PUT /projects/:id/quota
* PUT/DELETE /projects/:id/members/:principal host.site.manage
* PUT/DELETE /projects/:id/members/:principal host.site.manage
* sites GET/POST /projects/:id/sites · GET/DELETE /sites/:id 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
* deploys GET/POST /sites/:id/deploys · GET /deploys/:id · GET /deploys/:id/log
* POST /deploys/:id/activate · POST /sites/:id/rollback host.deploy.create
* POST /deploys/:id/activate · POST /sites/:id/rollback host.deploy.create
* DELETE /deploys/:id host.site.manage
* DELETE /deploys/:id host.site.manage
@@ -25,7 +26,7 @@ const out = require('./serialize');
@@ -25,7 +26,7 @@ const out = require('./serialize');
const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());
const truthy = (v) => v === true || v === 1 || ['1', 'true', 'yes', 'on'].includes(String(v || '').toLowerCase());
function createApi(ctx) {
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();
const router = express.Router();
// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
@@ -158,10 +159,12 @@ function createApi(ctx) {
@@ -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) });
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}`) } });
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);
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) {
} catch (err) {
// Refused before the body was read (404, 429, 503): close instead of draining a large upload.
// Refused before the body was read (404, 429, 503): close instead of draining a large upload.
if (!req.complete) res.set('Connection', 'close');
if (!req.complete) res.set('Connection', 'close');
@@ -207,6 +210,21 @@ function createApi(ctx) {
@@ -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)));
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 }));
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;
return router;
}
}
@@ -15,7 +15,7 @@ const { readUpload, UploadError } = require('./upload');
@@ -15,7 +15,7 @@ const { readUpload, UploadError } = require('./upload');
const { ApiError, privateNoStore } = require('./errors');
const { ApiError, privateNoStore } = require('./errors');
function createDashboard(ctx) {
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 router = express.Router();
const urlencoded = express.urlencoded({ extended: false, limit: '16kb' });
const urlencoded = express.urlencoded({ extended: false, limit: '16kb' });
@@ -86,10 +86,12 @@ function createDashboard(ctx) {
@@ -86,10 +86,12 @@ function createDashboard(ctx) {
router.get('/sites/:id', view(async (req, res) => {
router.get('/sites/:id', view(async (req, res) => {
if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
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 { 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({
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),
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) })),
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));
}), takedownNotice(await ctx.takedowns.ofSite(site)) || notice(req));
}));
}));
@@ -131,6 +133,13 @@ function createDashboard(ctx) {
@@ -131,6 +133,13 @@ function createDashboard(ctx) {
await projects.remove(req.viewer, project, { sites });
await projects.remove(req.viewer, project, { sites });
return back('/', 'Project deleted.');
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) => {
router.post('/sites/:id/domains', action(async (req) => {
const { domain } = await domains.add(req.viewer, req.params.id, { hostname: req.body.hostname });
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.`);
return back(`/sites/${domain.site_id}`, `Added ${domain.hostname}: publish the DNS records below, then check.`);
@@ -186,8 +195,12 @@ function createDashboard(ctx) {
@@ -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');
if (!checkCsrf(config, req.viewer, up.fields.csrf)) throw new ApiError(403, 'form.invalid', 'this form expired; reload the page and try again');
try {
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) {
} 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'));
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;
throw err;
@@ -16,10 +16,25 @@
@@ -16,10 +16,25 @@
* If-None-Match; single byte ranges; `immutable` caching for fingerprinted assets and revalidation
* 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.
* 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 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 fs = require('fs');
+const { sitemapXml, robotsTxt } = require('openvibe-shared/seo');
const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
const { isHostname } = require('../domain/domains');
const { isHostname } = require('../domain/domains');
+const { isReservedHeader } = require('../domain/site-config');
+const { isId } = require('../ids');
const TENANT_CSP = [
const TENANT_CSP = [
"default-src 'self'",
"default-src 'self'",
@@ -38,6 +53,30 @@ const TENANT_CSP = [
@@ -38,6 +53,30 @@ const TENANT_CSP = [
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
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. */
/** Host header → lowercase host name without port or trailing dot; null when it is not one. */
function normaliseHost(raw) {
function normaliseHost(raw) {
if (typeof raw !== 'string' || !raw || raw.length > 260) return null;
if (typeof raw !== 'string' || !raw || raw.length > 260) return null;
@@ -75,7 +114,15 @@ function plain(res, status, text, extra = {}) {
@@ -75,7 +114,15 @@ function plain(res, status, text, extra = {}) {
res.end(text);
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 { db } = store;
const q = {
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'"),
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
@@ -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'`),
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
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'`),
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}`;
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 }
* -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }
@@ -124,13 +178,13 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
@@ -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 };
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}"`;
const etag = `"${row.sha256}"`;
res.setHeader('ETag', etag);
res.setHeader('ETag', etag);
res.setHeader('Content-Type', row.content_type);
res.setHeader('Content-Type', row.content_type);
res.setHeader('Cache-Control', cache);
res.setHeader('Cache-Control', cache);
res.setHeader('Accept-Ranges', 'bytes');
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'];
const inm = req.headers['if-none-match'];
if (status === 200 && inm && inm.split(',').map((s) => s.trim().replace(/^W\//, '')).includes(etag)) {
if (status === 200 && inm && inm.split(',').map((s) => s.trim().replace(/^W\//, '')).includes(etag)) {
res.statusCode = 304;
res.statusCode = 304;
@@ -160,6 +214,68 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
@@ -160,6 +214,68 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
stream.pipe(res);
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) {
async function notFound(req, res, site, reason) {
const page = site.active_deploy_id ? await q.file.get(site.active_deploy_id, '404.html') : null;
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' });
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
@@ -173,8 +289,20 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
return res.end(body);
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. */
/** Serves one request for a resolved site. */
async function handle(req, res, 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' });
baseHeaders(res, { sandbox: site.environment === 'sandbox' });
if (req.method !== 'GET' && req.method !== 'HEAD') {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, 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
@@ -196,6 +324,17 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
const q0 = target.indexOf('?');
const q0 = target.indexOf('?');
const rawPath = q0 >= 0 ? target.slice(0, q0) : target;
const rawPath = q0 >= 0 ? target.slice(0, q0) : target;
const search = q0 >= 0 ? target.slice(q0) : '';
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');
if (!site.active_deploy_id) return await notFound(req, res, site, 'no-deploy');
const c = candidates(rawPath);
const c = candidates(rawPath);
if (!c) return await notFound(req, res, site, 'invalid');
if (!c) return await notFound(req, res, site, 'invalid');
@@ -218,6 +357,16 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
@@ -218,6 +357,16 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
return res.end();
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');
return await notFound(req, res, site, 'missing');
}
}
@@ -225,7 +374,52 @@ function createTenantServer({ store, config, blobs, takedowns = null, log = cons
@@ -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 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 };
module.exports = { createTenantServer, normaliseHost, TENANT_CSP };
@@ -19,7 +19,7 @@ const { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError } =
@@ -19,7 +19,7 @@ const { readArchive, normaliseName, selectRoot, stripSharedTop, ArchiveError } =
const { checkPath, PathError } = require('../artifacts/paths');
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 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`;
const fmt = (n) => `${(n / 1048576).toFixed(1)} MiB`;
class UploadError extends Error {
class UploadError extends Error {
@@ -83,15 +83,20 @@ function domainBlock(d, instructions, csrf) {
@@ -83,15 +83,20 @@ function domainBlock(d, instructions, csrf) {
return `${body}</li>`;
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 canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
const canDeploy = 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 rows = deploys.map((d) => {
const active = d.id === s.active_deploy_id;
const active = d.id === s.active_deploy_id;
+ const previewing = d.id === previewId;
const actions = [];
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 (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' }));
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('');
}).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('');
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>
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>
@@ -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>
${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>
<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>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' })}` : ''}
<button type="submit">Upload</button>`, { enctype: 'multipart/form-data' })}` : ''}
<h2>Deploys</h2>
<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>'}
${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>`) : ''}
${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>` : ''}
${history ? `<h3>Activation history</h3><ul class="history">${history}</ul>` : ''}
<h2>Domains</h2>
<h2>Domains</h2>
<ul class="domains">${domains.map((d) => domainBlock(d.domain, d.instructions, csrf)).join('')}</ul>
<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 && 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 -> to [status]</code>)<textarea name="redirects" rows="4" spellcheck="false" placeholder="/old -> /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` })}` : ''}`;
${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` })}` : ''}`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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('{{'));
+ }),
+]);
@@ -43,7 +43,7 @@ runTests([
@@ -43,7 +43,7 @@ runTests([
const m = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));
const m = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'service-manifest-proposal.json'), 'utf8'));
contracts.assertValid('registry.service-manifest', m);
contracts.assertValid('registry.service-manifest', m);
assert.strictEqual(m.id, 'host');
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 () => {
test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {
@@ -52,6 +52,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -52,6 +52,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
statfsFree: 100 * 1024 ** 3,
statfsFree: 100 * 1024 ** 3,
statfsSize: 500 * 1024 ** 3,
statfsSize: 500 * 1024 ** 3,
reads: [],
reads: [],
+ certbot: null, // (args, opts) -> { code, stdout, stderr } | Error ; default: nothing to do
+ certbotCalls: [], // { args, as, privileged }
onRestart: null, // (unit) -> void
onRestart: null, // (unit) -> void
onSystemdRun: null, // (spec) -> undefined | { code, stderr } ; spec = { unit, uid, cwd, props, argv, pid }
onSystemdRun: null, // (spec) -> undefined | { code, stderr } ; spec = { unit, uid, cwd, props, argv, pid }
onKill: null, // (pid, signal) -> false to ignore the signal
onKill: null, // (pid, signal) -> false to ignore the signal
@@ -430,6 +432,12 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
@@ -430,6 +432,12 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const r = host.onNode ? host.onNode(args, opts) : undefined;
const r = host.onNode ? host.onNode(args, opts) : undefined;
return r ? { stdout: '', stderr: '', ...r } : { code: 0, stdout: '', stderr: '' };
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: '' };
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) ──
// ── PostgreSQL tools (lib/dbengine.js runs each of these as the postgres OS user) ──
case 'psql': {
case 'psql': {
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -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); });
@@ -121,6 +121,19 @@ const ORIGIN = 'https://openvibe.host';
@@ -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}` } });
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.strictEqual(bad.status, 303);
assert.match(decodeURIComponent(bad.headers.location), /Upload refused/);
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 () => {
await check('forms: domain instructions, rollback, and refusal of cross-origin posts even with a valid token', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -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); });