openvibers/openvibe.host Commits

Stage B launch readiness: staff takedowns, bounded uploads, no CA validation files, realip-only nginx headers, a launch that removes the pending vhost in the same change

OP OpenVibers committed 23 Sept 2026 · 1e48cb2c81 · 31 files, +640 −57

- Takedowns (server/domain/takedowns.js, host_takedowns): staff stop a site or a whole project at once (451 on default and custom domains, no tenant bytes, no-store) and the deploys, files, logs and objects are KEPT for review; members see the reason on the site/project and in the dashboard, and cannot upload, activate, roll back or delete around it; staff lift it (history kept) or delete. API: POST/DELETE /api/v1/{projects,sites}/:id/takedown (host.site.manage + staff). Staff may now read deploy lists, deploys and logs (they could read projects and sites already); they still cannot publish.
- Uploads: at most HOST_MAX_CONCURRENT_UPLOADS (default 2) are read and validated at once, service-wide; others get 503 upload.busy + Retry-After before a byte is read, and are not recorded as the tenant's failed deploy.
- .well-known/acme-challenge/ and .well-known/pki-validation/ are refused in uploads and never served: no tenant can prove control of <site>.openvibe.host to a certificate authority.
- Fingerprinted tenant assets get CDN-Cache-Control: public, max-age=3600, so a takedown, deletion or rollback leaves Cloudflare's edge within the hour even unpurged (browsers keep immutable).
- The signed-out front page is indexable with a canonical URL; /robots.txt allows it and the legal pages and names /sitemap.xml (front page + legal pages). Everything behind sign-in stays noindex.
- nginx: every template ovhost renders (tenant vhosts and nginx render) now sends X-Forwarded-For and CF-Connecting-IP from $remote_addr only, like the installed vhosts since 30592c0; the templates still appended the client's X-Forwarded-For. TRUST_PROXY defaults to 1 (nginx resolves Cloudflare with realip).
- ovhost nginx tenants --install removes nginx.tenants.replaces (default <sitesDomain>-tenants-pending.conf) in the same nginx -t and reload, and brings it back if the test fails: the pending vhost sorts first and would keep winning *.openvibe.host. www.openvibe.host redirects to the dashboard over the wildcard certificate (it was the Sites placeholder's redirect).
- Tests: test/host-abuse.test.js (takedowns, bounded uploads, CA paths, CDN cache); host-isolation adds cross-origin reads from a tenant page (no CORS grant on tenant responses or to tenant origins on the API), ETag/Range existence oracles, delegated service tokens, app principals and the dashboard; nginx-tenants adds the launch removal, the www redirect and a realip check over every template; host-secrets-dashboard adds robots/sitemap/front-page indexing.
- docs/capabilities-proposal/host.site.manage.json lists the takedown routes (released openvibe-contracts v0.30.1 does not have them yet).

Changes

ddiff --git a/.env.example b/.env.example
iindex 18e3ef8..3cf9646 100644
---- a/.env.example
++++ b/.env.example
@@@ -10,8 +10,9 @@ HOST=127.0.0.1
NODE_ENV=development
# The dashboard + API origin (production: https://openvibe.host)
BASE_URL=http://localhost:4910
--# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
--TRUST_PROXY=2
++# Proxy hops in front of Node that set X-Forwarded-For. nginx resolves the Cloudflare hop itself
++# (realip) and sends only $remote_addr, so there is one: nginx → Node = 1.
++TRUST_PROXY=1
# Tenant sites are served at <site>.<HOST_SITES_DOMAIN>. It must be its own registrable domain
# (never under openvibe.network / openvibe.live). Development: localhost (http://<site>.localhost:4910)
@@@ -43,6 +44,9 @@ HOST_FORM_SECRET=
#HOST_MAX_UPLOAD_BYTES=104857600
# Largest size an uploaded archive may unpack to (default 256 MiB; the storage quota also applies).
#HOST_MAX_UNPACKED_BYTES=268435456
++# Uploads read and validated at the same time, service-wide; each is held in memory (up to the two
++# limits above), so this bounds what concurrent uploads can cost. Others get 503 + Retry-After.
++#HOST_MAX_CONCURRENT_UPLOADS=2
# Per-project quota defaults (a staff override per project wins). Production projects:
#HOST_QUOTA_STORAGE_BYTES=1073741824
ddiff --git a/deploy/nginx/openvibe.host-tenants-pending.conf b/deploy/nginx/openvibe.host-tenants-pending.conf
iindex 1428330..b0f7d0e 100644
---- a/deploy/nginx/openvibe.host-tenants-pending.conf
++++ b/deploy/nginx/openvibe.host-tenants-pending.conf
@@@ -1,6 +1,7 @@
# *.openvibe.host before Host Stage B tenant hosting launches: a clear 404 over the wildcard
# certificate instead of falling through to the default server (which served another site).
--# Remove when `ovhost nginx tenants host --install` writes the real tenant vhost.
++# `ovhost nginx tenants host --install` removes it in the same nginx -t and reload that installs the
++# real tenant vhost (nginx.tenants.replaces); see docs/launch.md.
server {
listen 443 ssl;
listen [::]:443 ssl;
ddiff --git a/deploy/nginx/openvibe.host.conf b/deploy/nginx/openvibe.host.conf
iindex 260b456..3940cb1 100644
---- a/deploy/nginx/openvibe.host.conf
++++ b/deploy/nginx/openvibe.host.conf
@@@ -1,5 +1,6 @@
## REFERENCE copy of `ovhost nginx tenants host` output with no custom domains (regenerate it with
--## that command; install with --install, which also writes the custom-domain vhost).
++## that command; install with --install, which also writes the custom-domain vhost and removes
++## openvibe.host-tenants-pending.conf in the same nginx -t and reload).
## ═══════════════════════════════════════════════════════════════
## openvibe.host and *.openvibe.host — generated by `ovhost nginx tenants host`
##
@@@ -79,6 +80,21 @@ server {
}
}
++# ── www.openvibe.host → openvibe.host ─────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.host;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.host/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.host/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ add_header Strict-Transport-Security "max-age=31536000" always;
++ return 301 https://openvibe.host$request_uri;
++}
++
# ── Tenant sites ───────────────────────────────────────────
server {
listen 443 ssl;
ddiff --git a/docs/capabilities-proposal/host.site.manage.json b/docs/capabilities-proposal/host.site.manage.json
iindex 1cbb094..c0a69f1 100644
---- a/docs/capabilities-proposal/host.site.manage.json
++++ b/docs/capabilities-proposal/host.site.manage.json
@@@ -4,7 +4,7 @@
"owner": "host",
"status": "active",
"visibility": "first-party",
-- "description": "Manage OpenVibe.Host tenant projects and static sites: create a project (owned by the person named with X-OV-Subject; apps cannot own projects), read projects, quotas and usage, manage members (owner role), create and delete sites, delete a deploy that is not active. Also judged by the acting principal's project role; a sandbox token (env=sandbox) is refused on production projects. Quota changes additionally need a Network staff user.",
++ "description": "Manage OpenVibe.Host tenant projects and static sites: create a project (owned by the person named with X-OV-Subject; apps cannot own projects), read projects, quotas and usage, manage members (owner role), create and delete sites, delete a deploy that is not active. Also judged by the acting principal's project role; a sandbox token (env=sandbox) is refused on production projects. Quota changes and takedowns (stop serving a site or a whole project while keeping its content for review; lift) additionally need a Network staff user.",
"permissions": [
"host:write"
],
@@@ -26,6 +26,10 @@
"POST /api/v1/projects/:id/sites",
"GET /api/v1/sites/:id",
"DELETE /api/v1/sites/:id",
-- "DELETE /api/v1/deploys/:id"
++ "DELETE /api/v1/deploys/:id",
++ "POST /api/v1/projects/:id/takedown",
++ "DELETE /api/v1/projects/:id/takedown",
++ "POST /api/v1/sites/:id/takedown",
++ "DELETE /api/v1/sites/:id/takedown"
]
}
ddiff --git a/host.example.json b/host.example.json
iindex d0f6c71..b0421aa 100644
---- a/host.example.json
++++ b/host.example.json
@@@ -226,7 +226,7 @@
"ready": { "url": "http://127.0.0.1:4910/api/ready", "timeoutSeconds": 30 },
"noRestartPaths": ["docs/", "test/", "**/*.md"],
"databases": [{ "name": "host", "path": "/var/lib/openvibe-host-api/host.db" }],
-- "nginx": { "tenants": { "sitesDomain": "openvibe.host", "dashboardDomain": "openvibe.host", "wildcardCert": "openvibe.host", "database": "/var/lib/openvibe-host-api/host.db", "maxUpload": "110m" } },
++ "nginx": { "tenants": { "sitesDomain": "openvibe.host", "dashboardDomain": "openvibe.host", "wildcardCert": "openvibe.host", "database": "/var/lib/openvibe-host-api/host.db", "maxUpload": "110m", "replaces": ["openvibe.host-tenants-pending.conf"] } },
"drill": {
"port": 14910,
"databases": { "host": "HOST_DB_PATH" },
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 621e7da..d278aac 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -206,12 +206,13 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
const domains = await lib.nginx.tenantDomains(exec, svc);
const r = lib.nginx.renderTenants(inv, svc, { wildcardCert: opts['wildcard-cert'], domains });
for (const h of r.refused) log(`refused a custom domain value that is not a plain host name: ${JSON.stringify(h)}`);
-- if (json && !opts.install) { emit({ files: r.files.map((f) => f.name), certDir: r.certDir, needCert: r.needCert, refused: r.refused }); return 0; }
++ if (json && !opts.install) { emit({ files: r.files.map((f) => f.name), replaces: r.replaces, certDir: r.certDir, needCert: r.needCert, refused: r.refused }); return 0; }
if (!opts.install) {
for (const f of r.files) out(`# ---- ${f.name} ----\n${f.text}`);
++ if (r.replaces.length) out(`# --install also removes, in the same nginx -t and reload: ${r.replaces.join(', ')} (if present)`);
} else {
-- const res = await lib.nginx.install(exec, inv, r.files, { log });
-- if (!res.changed.length) log('tenant vhosts already installed; nothing changed');
++ const res = await lib.nginx.install(exec, inv, r.files, { log, remove: r.replaces });
++ if (!res.changed.length && !res.removed.length) log('tenant vhosts already installed; nothing changed');
}
for (const h of r.needCert) log(`needs a certificate: certbot certonly --webroot -w /var/www/certbot -d ${h} (then run this again)`);
return 0;
ddiff --git a/lib/nginx.js b/lib/nginx.js
iindex ab7d687..3cd15d4 100644
---- a/lib/nginx.js
++++ b/lib/nginx.js
@@@ -93,11 +93,33 @@ async function test(exec, inv) {
/**
* files: [{ name, text }]. Writes changed files to sites-available, links them into sites-enabled,
* runs nginx -t; restores the previous state on failure; reloads nginx on success.
-- * -> { changed: [names], reloaded }
++ * remove: vhost file names to take out of sites-enabled and sites-available IN THE SAME CHANGE (an
++ * interim vhost the new one replaces); they come back too if nginx -t fails.
++ * -> { changed: [names], removed: [names], reloaded }
*/
--async function install(exec, inv, files, { log = () => {} } = {}) {
++async function install(exec, inv, files, { log = () => {}, remove = [] } = {}) {
const undo = [];
const changed = [];
++ const removed = [];
++ for (const name of remove) {
++ if (!/^[A-Za-z0-9._-]+$/.test(name) || files.some((f) => f.name === name)) throw new Error(`cannot remove vhost "${name}"`);
++ const available = path.join(inv.nginx.sitesAvailable, name);
++ const enabled = path.join(inv.nginx.sitesEnabled, name);
++ const prevText = await exec.readFile(available, { privileged: true });
++ // Usually a link to sites-available (possibly dangling); a plain copy is put back as a copy.
++ const target = await exec.readlink(enabled);
++ const link = target || await exec.stat(enabled);
++ if (link) {
++ const copy = target ? null : await exec.readFile(enabled, { privileged: true });
++ await exec.removeFile(enabled, { privileged: true });
++ undo.push(target ? () => exec.symlink(target, enabled, { privileged: true }) : () => exec.writeFile(enabled, copy == null ? '' : copy, { privileged: true, mode: 0o644 }));
++ }
++ if (prevText != null) {
++ await exec.removeFile(available, { privileged: true });
++ undo.push(() => exec.writeFile(available, prevText, { privileged: true, mode: 0o644 }));
++ }
++ if (link || prevText != null) removed.push(name);
++ }
for (const f of files) {
if (!/^[A-Za-z0-9._-]+$/.test(f.name)) throw new Error(`vhost name "${f.name}" is not a file name`);
const available = path.join(inv.nginx.sitesAvailable, f.name);
@@@ -114,7 +136,7 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
if (!changed.includes(f.name)) changed.push(f.name);
}
}
-- if (!changed.length) return { changed, reloaded: false };
++ if (!changed.length && !removed.length) return { changed, removed, reloaded: false };
const t = await test(exec, inv);
if (!t.ok) {
for (const u of undo.reverse()) await u();
@@@ -123,8 +145,8 @@ async function install(exec, inv, files, { log = () => {} } = {}) {
throw err;
}
await systemd.reloadNginx(exec);
-- log(`nginx: installed ${changed.join(', ')}; nginx -t clean; reloaded`);
-- return { changed, reloaded: true };
++ log(`nginx: ${[changed.length && `installed ${changed.join(', ')}`, removed.length && `removed ${removed.join(', ')}`].filter(Boolean).join('; ')}; nginx -t clean; reloaded`);
++ return { changed, removed, reloaded: true };
}
// ── Stage B: tenant static hosting (OpenVibe.Host's own service) ─────────────
@@@ -159,6 +181,10 @@ function tenantsConfig(svc) {
if (/(^|\.)openvibe\.(network|live|media|community|tools)$/.test(sitesDomain)) throw new Error(`tenant sites must not live under ${sitesDomain}: its cookies would reach tenant pages`);
if (!svc.port) throw new Error(`${svc.id} has no port in the inventory`);
if (t.maxUpload && !/^\d+[km]?$/.test(String(t.maxUpload))) throw new Error('nginx.tenants.maxUpload must look like 110m');
++ // The interim vhost that answered *.<sitesDomain> with a 404 before launch (deploy/nginx in this
++ // repo). It sorts before <sitesDomain>.conf, so left in place it would keep winning the wildcard.
++ const replaces = t.replaces || [`${sitesDomain}-tenants-pending.conf`];
++ if (!Array.isArray(replaces) || replaces.some((n) => typeof n !== 'string' || !/^[A-Za-z0-9._-]+\.conf$/.test(n))) throw new Error('nginx.tenants.replaces must list vhost file names');
return {
sitesDomain,
dashboard,
@@@ -168,6 +194,7 @@ function tenantsConfig(svc) {
vhost: t.vhost || `${sitesDomain}.conf`,
customVhost: t.customVhost || `${sitesDomain}-custom-domains.conf`,
maxUpload: t.maxUpload || '110m',
++ replaces,
};
}
@@@ -181,8 +208,13 @@ function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {
const zone = `ov${svc.id.replace(/[^a-z0-9]/g, '')}`;
const base = { port: svc.port, zone, sitesDomain: t.sitesDomain, service: svc.id };
const tenantLocation = fill(readTemplate('tenant-location.tmpl'), base);
++ // www.<dashboard> would otherwise be a tenant name (reserved, so "unknown host"): redirect it,
++ // as the placeholder did. Only when the wildcard certificate covers it.
++ const www = `www.${t.dashboard}`;
++ const wwwRedirect = www.endsWith(`.${t.sitesDomain}`) && www.split('.').length === t.sitesDomain.split('.').length + 1
++ ? fill(readTemplate('www-redirect.tmpl'), { www, dashboard: t.dashboard, certDir }) : '';
const main = fill(readTemplate('tenants.conf.tmpl'), {
-- ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation,
++ ...base, dashboard: t.dashboard, certDir, maxUpload: t.maxUpload, sitesAvailable: inv.nginx.sitesAvailable, vhost: t.vhost, tenantLocation, wwwRedirect,
});
const refused = [];
@@@ -211,6 +243,7 @@ function renderTenants(inv, svc, { wildcardCert, domains = [] } = {}) {
needCert: withoutCert.map((d) => d.hostname),
refused,
certDir,
++ replaces: t.replaces,
};
}
ddiff --git a/server/app.js b/server/app.js
iindex 1cace92..ace9fd3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -25,6 +25,7 @@ const { openStore } = require('./db');
const { createBlobStore } = require('./storage');
const { createHostOutbox } = require('./events/outbox');
const { createAccess } = require('./domain/access');
++const { createTakedowns } = require('./domain/takedowns');
const { createProjects } = require('./domain/projects');
const { createSites } = require('./domain/sites');
const { createDeploys } = require('./domain/deploys');
@@@ -32,6 +33,7 @@ const { createDomains } = require('./domain/domains');
const { createAuthClient, createAuthRoutes } = require('./auth/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createTenantServer } = require('./http/tenant');
++const { createUploadGate } = require('./http/upload');
const { createApi } = require('./http/api');
const { createDashboard } = require('./http/dashboard');
const { createHostReadiness } = require('./observability');
@@@ -71,16 +73,18 @@ function createApp(opts = {}) {
const outbox = createHostOutbox({ db: store.db, config, fetchImpl: opts.fetchImpl, now: store.now, log });
const access = createAccess({ store });
-- const projects = createProjects({ store, config, access, blobs, log });
-- const sites = createSites({ store, config, access, projects });
-- const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, log });
++ const takedowns = createTakedowns({ store });
++ const projects = createProjects({ store, config, access, blobs, takedowns, log });
++ const sites = createSites({ store, config, access, projects, takedowns });
++ const deploys = createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config });
-- const tenant = createTenantServer({ store, config, blobs, log });
++ const tenant = createTenantServer({ store, config, blobs, takedowns, log });
++ const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
-- const ctx = { config, store, blobs, outbox, access, projects, sites, deploys, domains, auth, viewers, tenant, worker, log };
++ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, auth, viewers, tenant, worker, uploadGate, log };
const app = express();
app.disable('x-powered-by');
@@@ -128,7 +132,13 @@ function createApp(opts = {}) {
const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release });
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
-- app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /$\nDisallow: /\n'));
++ // 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.
++ 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')));
++ app.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')
++ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${['/', ...legalPaths].map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`));
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
ddiff --git a/server/artifacts/paths.js b/server/artifacts/paths.js
iindex 14cd57b..4383772 100644
---- a/server/artifacts/paths.js
++++ b/server/artifacts/paths.js
@@@ -16,6 +16,7 @@ const MAX_PATH = 1024;
const MAX_SEGMENT = 255;
const MAX_DEPTH = 32;
const SEGMENT_RE = /^[A-Za-z0-9._@+~()!,= -]+$/;
++const CA_VALIDATION = new Set(['acme-challenge', 'pki-validation']);
class PathError extends Error {
constructor(code, message) { super(message); this.code = code; }
@@@ -42,6 +43,12 @@ function checkPath(input) {
}
if (s.endsWith(' ') || s.startsWith(' ')) throw new PathError('path.invalid_character', `segment "${s}" starts or ends with a space`);
});
++ // Certificate authorities prove control of a host name with files under these paths (ACME
++ // HTTP-01, and the file-based validation other CAs accept over HTTPS). A tenant must never be
++ // able to obtain a certificate for <site>.openvibe.host: the operator issues every certificate.
++ if (segments[0] === '.well-known' && CA_VALIDATION.has(String(segments[1]).toLowerCase())) {
++ throw new PathError('path.reserved', `"${segments.slice(0, 2).join('/')}/" is reserved: certificate validation files are never published from a site`);
++ }
return input;
}
ddiff --git a/server/config.js b/server/config.js
iindex ca07231..4f6bd02 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -42,7 +42,7 @@ function load(env = process.env) {
baseUrl,
dashboardHost,
sitesDomain,
-- trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1, // nginx (realip) → Node
dbPath: env.HOST_DB_PATH || './data/host.db',
// Content-addressed deploy objects, one directory per project (tenancy keyed by project id).
@@@ -74,6 +74,8 @@ function load(env = process.env) {
// unpacked files are held in memory while they are checked, so this bounds what a small
// gzip bomb can cost, whatever quota staff grant a project.
maxUnpackedBytes: int(env.HOST_MAX_UNPACKED_BYTES, 256 * MiB),
++ // Uploads read and validated at the same time, service-wide (each is held in memory).
++ maxConcurrent: int(env.HOST_MAX_CONCURRENT_UPLOADS, 2),
},
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
ddiff --git a/server/db.js b/server/db.js
iindex 87c5a07..a7ba035 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -15,6 +15,7 @@
* host_activations every pointer switch (activate / rollback), for audit and rollback targets
* host_deploy_logs the upload/validation log of each deploy (there is no build in Stage B)
* host_domains default <site>.openvibe.host and custom domains (TXT-verified)
++ * host_takedowns staff takedowns of a site or a project (serving stops; content is kept)
* event_outbox openvibe-sdk transactional outbox
*
* Immutability is enforced by the database itself: triggers refuse any UPDATE of a deploy's
@@@ -152,6 +153,21 @@ CREATE UNIQUE INDEX IF NOT EXISTS host_domains_verified ON host_domains (hostnam
CREATE UNIQUE INDEX IF NOT EXISTS host_domains_site_host ON host_domains (site_id, hostname);
CREATE INDEX IF NOT EXISTS host_domains_status ON host_domains (kind, status, last_checked_at);
++-- Abuse takedowns (staff only): a site, or every site of a project, stops being served (451) while
++-- its deploys, files and objects are KEPT for review. Rows are never deleted: a lift is recorded.
++CREATE TABLE IF NOT EXISTS host_takedowns (
++ id INTEGER PRIMARY KEY,
++ target_kind TEXT NOT NULL CHECK (target_kind IN ('project','site')),
++ target_id TEXT NOT NULL, -- prj_… or site_…
++ reason TEXT NOT NULL,
++ created_by TEXT NOT NULL,
++ created_at INTEGER NOT NULL,
++ lifted_by TEXT,
++ lifted_at INTEGER,
++ lift_note TEXT
++);
++CREATE UNIQUE INDEX IF NOT EXISTS host_takedowns_active ON host_takedowns (target_kind, target_id) WHERE lifted_at IS NULL;
++
-- Deploy artifacts are immutable: only state / deleted_at may change, and never back from 'deleted'.
CREATE TRIGGER IF NOT EXISTS host_deploys_immutable
BEFORE UPDATE OF id, project_id, site_id, source, manifest, manifest_sha256, file_count, total_bytes, new_bytes, created_by, created_at ON host_deploys
@@@ -167,7 +183,7 @@ BEFORE DELETE ON host_deploy_files WHEN (SELECT state FROM host_deploys WHERE id
BEGIN SELECT RAISE(ABORT, 'host: files of a live deploy cannot be removed'); END;
`;
--const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains'];
++const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns'];
function openStore(dbPath, { now = () => Date.now() } = {}) {
if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
ddiff --git a/server/domain/deploys.js b/server/domain/deploys.js
iindex 5022dbd..fa39508 100644
---- a/server/domain/deploys.js
++++ b/server/domain/deploys.js
@@@ -23,7 +23,7 @@ const { principalOf, actorRef } = require('./access');
const DAY = 24 * 3600 * 1000;
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
--function createDeploys({ store, access, projects, sites, blobs, outbox, log = console }) {
++function createDeploys({ store, access, projects, sites, blobs, outbox, takedowns, log = console }) {
const { db } = store;
const q = {
byId: db.prepare('SELECT * FROM host_deploys WHERE id = ?'),
@@@ -74,6 +74,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
/** Before any byte is read: may this caller deploy to this site right now? */
function precheck(viewer, siteId) {
const { site, project } = sites.load(viewer, siteId, 'deploy');
++ takedowns.assertOpen(site);
const limits = limitsFor(project);
const recent = q.deploysSince.get(project.id, store.now() - DAY).n;
if (recent >= limits.quota.deploysPerDay) {
@@@ -190,6 +191,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
function activate(viewer, id, { expectedActive, traceparent } = {}) {
const { deploy, site } = load(viewer, id, 'deploy');
++ takedowns.assertOpen(site);
const out = store.tx(() => switchPointer(viewer, site, deploy, 'activate', { expectedActive, traceparent }));
outbox.kick();
return out;
@@@ -198,6 +200,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
/** To `deployId`, or else to the most recent previously active deploy that still exists. */
function rollback(viewer, siteId, { deployId, expectedActive, traceparent } = {}) {
const { site } = sites.load(viewer, siteId, 'deploy');
++ takedowns.assertOpen(site);
const out = store.tx(() => {
const current = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;
let target = null;
@@@ -236,6 +239,7 @@ function createDeploys({ store, access, projects, sites, blobs, outbox, log = co
function remove(viewer, id) {
const { deploy, site, project } = load(viewer, id, 'maintain');
++ takedowns.assertDeletable(viewer, { site });
let freed = [];
store.tx(() => {
const active = db.prepare('SELECT active_deploy_id FROM host_sites WHERE id = ?').get(site.id).active_deploy_id;
ddiff --git a/server/domain/projects.js b/server/domain/projects.js
iindex 05c2800..18c91b6 100644
---- a/server/domain/projects.js
++++ b/server/domain/projects.js
@@@ -18,7 +18,7 @@ const PRINCIPAL_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|app:app_[0-9A-HJKMNP-TV-Z]{26
const QUOTA_FIELDS = { storage_bytes: 'storageBytes', deploys_per_day: 'deploysPerDay', max_files: 'maxFiles', max_file_bytes: 'maxFileBytes', sites: 'sites', custom_domains: 'customDomains' };
const DAY = 24 * 3600 * 1000;
--function createProjects({ store, config, access, blobs, log = console }) {
++function createProjects({ store, config, access, blobs, takedowns, log = console }) {
const { db } = store;
const q = {
byId: db.prepare('SELECT * FROM host_projects WHERE id = ?'),
@@@ -146,6 +146,7 @@ function createProjects({ store, config, access, blobs, log = console }) {
/** Owner (or staff) deletes the project: every site stops serving at once; objects are removed. */
function remove(viewer, project, { sites }) {
access.authorize(project, viewer, 'own');
++ takedowns.assertDeletable(viewer, { projectId: project.id });
const now = store.now();
store.tx(() => {
for (const s of sites.listForProject(project.id)) sites.markDeleted(s, now);
ddiff --git a/server/domain/sites.js b/server/domain/sites.js
iindex 3005910..c940f5b 100644
---- a/server/domain/sites.js
++++ b/server/domain/sites.js
@@@ -21,7 +21,7 @@ const RESERVED = new Set([
]);
const HOLD_MS = 30 * 24 * 3600 * 1000;
--function createSites({ store, config, access, projects }) {
++function createSites({ store, config, access, projects, takedowns }) {
const { db } = store;
const reserved = new Set([...RESERVED, ...config.sites.extraReservedNames]);
const q = {
@@@ -97,6 +97,7 @@ function createSites({ store, config, access, projects }) {
function remove(viewer, id, { deploys }) {
const { site, project } = load(viewer, id, 'maintain');
++ takedowns.assertDeletable(viewer, { site });
const now = store.now();
let freed = [];
store.tx(() => {
ddiff --git a/server/domain/takedowns.js b/server/domain/takedowns.js
nnew file mode 100644
iindex 0000000..413d3ac
---- /dev/null
++++ b/server/domain/takedowns.js
@@@ -0,0 +1,82 @@
++'use strict';
++/**
++ * Abuse takedowns, by OpenVibe staff only (Network role admin).
++ *
++ * A takedown stops a site, or every site of a project, from being served at once: its default and
++ * custom domains answer 451 with no tenant content. Unlike a delete, it KEEPS the deploys, their
++ * file rows, logs and objects, so a report can be reviewed and, where the law requires it, the
++ * material preserved and handed over. While a takedown is active the project's members cannot
++ * upload, activate, roll back or delete anything it covers; staff can still delete. Every takedown
++ * and every lift stays in host_takedowns.
++ *
++ * Members see that their site is taken down, and why (site/project `takedown` in the API).
++ */
++const { ApiError } = require('../http/errors');
++const { principalOf } = require('./access');
++
++const REASON_MAX = 500;
++
++function createTakedowns({ store }) {
++ const { db } = store;
++ const q = {
++ forSite: db.prepare(`SELECT target_kind, reason, created_at FROM host_takedowns
++ WHERE lifted_at IS NULL AND ((target_kind = 'site' AND target_id = ?) OR (target_kind = 'project' AND target_id = ?))
++ ORDER BY target_kind = 'project' DESC, created_at LIMIT 1`),
++ forProject: db.prepare("SELECT target_kind, reason, created_at FROM host_takedowns WHERE lifted_at IS NULL AND target_kind = 'project' AND target_id = ?"),
++ anyInProject: db.prepare(`SELECT target_kind, reason, created_at FROM host_takedowns t
++ WHERE lifted_at IS NULL AND ((target_kind = 'project' AND target_id = @p)
++ OR (target_kind = 'site' AND target_id IN (SELECT id FROM host_sites WHERE project_id = @p AND status = 'active')))
++ LIMIT 1`),
++ insert: db.prepare('INSERT INTO host_takedowns (target_kind, target_id, reason, created_by, created_at) VALUES (?, ?, ?, ?, ?)'),
++ lift: db.prepare('UPDATE host_takedowns SET lifted_by = ?, lifted_at = ?, lift_note = ? WHERE target_kind = ? AND target_id = ? AND lifted_at IS NULL'),
++ };
++
++ const shape = (row) => (row ? { scope: row.target_kind, reason: row.reason, since: row.created_at } : null);
++
++ /** The takedown that covers a site (its own, or its project's), or null. */
++ const ofSite = (site) => shape(q.forSite.get(site.id, site.project_id));
++ /** The project-wide takedown, or null. */
++ const ofProject = (projectId) => shape(q.forProject.get(projectId));
++
++ /** Members may not publish into, or change, content that is taken down. */
++ function assertOpen(site) {
++ const t = ofSite(site);
++ if (t) throw new ApiError(403, 'site.taken_down', `this ${t.scope} was taken down by OpenVibe staff (${t.reason}); nothing can be published or changed until staff lift it`);
++ }
++
++ /** Deleting taken-down content would destroy what staff are reviewing: staff only. */
++ function assertDeletable(viewer, { site = null, projectId = null }) {
++ if (viewer && viewer.staff) return;
++ const row = site ? q.forSite.get(site.id, site.project_id) : q.anyInProject.get({ p: projectId });
++ if (row) throw new ApiError(409, 'site.taken_down', `this ${row.target_kind} was taken down by OpenVibe staff and is kept for review; it cannot be deleted until staff lift the takedown`);
++ }
++
++ function requireStaff(viewer) {
++ if (!viewer || !viewer.staff) throw new ApiError(403, 'auth.staff_only', 'only OpenVibe staff can take content down or lift a takedown');
++ }
++
++ /** kind: 'site' | 'project'. -> the takedown */
++ function takeDown(viewer, kind, targetId, reasonRaw) {
++ requireStaff(viewer);
++ const reason = String(reasonRaw || '').trim();
++ if (!reason || reason.length > REASON_MAX) throw new ApiError(422, 'takedown.reason_required', `a takedown needs a reason of 1–${REASON_MAX} characters (members see it)`);
++ try {
++ q.insert.run(kind, targetId, reason, principalOf(viewer), store.now());
++ } catch (err) {
++ if (/UNIQUE/.test(err.message)) throw new ApiError(409, 'takedown.exists', `this ${kind} is already taken down`);
++ throw err;
++ }
++ return { scope: kind, reason, since: store.now() };
++ }
++
++ function lift(viewer, kind, targetId, note) {
++ requireStaff(viewer);
++ const r = q.lift.run(principalOf(viewer), store.now(), note == null ? null : String(note).slice(0, REASON_MAX), kind, targetId);
++ if (r.changes !== 1) throw new ApiError(404, 'takedown.not_found', `this ${kind} is not taken down`);
++ return { lifted: true };
++ }
++
++ return { ofSite, ofProject, assertOpen, assertDeletable, takeDown, lift };
++}
++
++module.exports = { createTakedowns };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 908a608..b275479 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -13,6 +13,7 @@
* DELETE /deploys/:id host.site.manage
* domains GET/POST /sites/:id/domains · POST /domains/:id/verify
* DELETE /domains/:id host.domain.manage
++ * takedowns POST/DELETE /projects/:id/takedown · POST/DELETE /sites/:id/takedown host.site.manage (staff)
*/
const express = require('express');
const contracts = require('openvibe-contracts');
@@@ -24,7 +25,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 } = ctx;
++ const { config, projects, sites, deploys, domains, viewers, takedowns } = ctx;
const router = express.Router();
// CORS for first-party browser origins presenting a Bearer token (never credentials/cookies).
@@@ -47,8 +48,8 @@ function createApi(ctx) {
const u = new URL(config.baseUrl);
return `${u.protocol}//${hostname}${u.port ? `:${u.port}` : ''}`;
};
-- const siteOut = (s) => { const hostname = sites.defaultHostname(s.name); return out.site(s, { hostname, url: siteUrl(hostname) }); };
-- const projectOut = (p, role) => out.project(p, { role, quota: projects.quotaOf(p), usage: projects.usageOf(p) });
++ const siteOut = (s) => { const hostname = sites.defaultHostname(s.name); return out.site(s, { hostname, url: siteUrl(hostname), takedown: takedowns.ofSite(s) }); };
++ const projectOut = (p, role) => out.project(p, { role, quota: projects.quotaOf(p), usage: projects.usageOf(p), takedown: takedowns.ofProject(p.id) });
const tp = (req) => (req.ov ? req.ov.traceparent : undefined);
const loadProject = (req, need) => {
const project = projects.get(req.params.id);
@@@ -59,7 +60,7 @@ function createApi(ctx) {
// ── Projects ────────────────────────────────────────────
router.get('/projects', guard('host.site.manage'), run((req) => {
if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
-- return { projects: projects.listFor(req.viewer).map((p) => out.project(p)) };
++ return { projects: projects.listFor(req.viewer).map((p) => out.project(p, { takedown: takedowns.ofProject(p.id) })) };
}));
router.post('/projects', guard('host.site.manage'), jsonBody, run((req) => {
if (req.viewer.kind === 'anonymous') throw new ApiError(401, 'auth.required', 'sign in with OpenVibe, or present a service token');
@@@ -94,6 +95,25 @@ function createApi(ctx) {
return { members: list.map((m) => ({ principal: m.principal, role: m.role, added_at: out.iso(m.created_at) })) };
}));
++ // ── Takedowns (staff only; members see them on the project and site) ──
++ // Serving stops at once and the content is kept for review (domain/takedowns.js).
++ router.post('/projects/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {
++ const { project } = loadProject(req, 'read');
++ return { takedown: out.takedownOut(takedowns.takeDown(req.viewer, 'project', project.id, (req.body || {}).reason)) };
++ }, 201));
++ router.delete('/projects/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {
++ const { project } = loadProject(req, 'read');
++ return takedowns.lift(req.viewer, 'project', project.id, (req.body || {}).note);
++ }));
++ router.post('/sites/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {
++ const { site } = sites.load(req.viewer, req.params.id, 'read');
++ return { takedown: out.takedownOut(takedowns.takeDown(req.viewer, 'site', site.id, (req.body || {}).reason)) };
++ }, 201));
++ router.delete('/sites/:id/takedown', guard('host.site.manage'), jsonBody, run((req) => {
++ const { site } = sites.load(req.viewer, req.params.id, 'read');
++ return takedowns.lift(req.viewer, 'site', site.id, (req.body || {}).note);
++ }));
++
// ── Sites ───────────────────────────────────────────────
router.get('/projects/:id/sites', guard('host.site.manage'), run((req) => {
const { project } = loadProject(req, 'read');
@@@ -112,7 +132,8 @@ function createApi(ctx) {
// ── Deploys ─────────────────────────────────────────────
router.get('/sites/:id/deploys', guard('host.deploy.create'), run((req) => {
-- const { site } = sites.load(req.viewer, req.params.id, 'deploy');
++ // Reads need the least member role (deployer); staff may read them too, e.g. to review a takedown.
++ const { site } = sites.load(req.viewer, req.params.id, 'read');
return {
active_deploy_id: site.active_deploy_id || null,
deploys: deploys.list(site.id, Number(req.query.limit) || 50).map((d) => out.deploy(d, { active: d.id === site.active_deploy_id })),
@@@ -121,8 +142,11 @@ function createApi(ctx) {
}));
router.post('/sites/:id/deploys', guard('host.deploy.create'), async (req, res) => {
++ let release = null;
try {
const pre = deploys.precheck(req.viewer, req.params.id);
++ release = ctx.uploadGate.enter();
++ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Host is validating other uploads right now; try again in 30 seconds'); }
let up;
try {
up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
@@@ -138,18 +162,20 @@ function createApi(ctx) {
const site = 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)) });
} catch (err) {
-- // Refused before the body was read (404, 429): 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 (!res.headersSent) sendError(res, req, err);
++ } finally {
++ if (release) release();
}
});
router.get('/deploys/:id', guard('host.deploy.create'), run((req) => {
-- const { deploy, site } = deploys.load(req.viewer, req.params.id, 'deploy');
++ const { deploy, site } = deploys.load(req.viewer, req.params.id, 'read');
return { deploy: out.deploy(deploy, { active: site.active_deploy_id === deploy.id, files: deploys.filesOf(deploy.id) }) };
}));
router.get('/deploys/:id/log', guard('host.deploy.create'), run((req) => {
-- const { deploy } = deploys.load(req.viewer, req.params.id, 'deploy');
++ const { deploy } = deploys.load(req.viewer, req.params.id, 'read');
return { deploy_id: deploy.id, state: deploy.state, log: deploys.logOf(deploy.id).map(out.logLine) };
}));
router.post('/deploys/:id/activate', guard('host.deploy.create'), jsonBody, run((req) => {
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex c88fbf3..d5e25c5 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -25,8 +25,8 @@ function createDashboard(ctx) {
router.use(viewers.middleware('dashboard'));
router.use((req, res, next) => { privateNoStore(res); next(); });
-- function page(req, res, status, title, body, notice) {
-- res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice }));
++ function page(req, res, status, title, body, notice, { indexable = false } = {}) {
++ res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable }));
}
function fail(req, res, err) {
@@@ -56,11 +56,15 @@ function createDashboard(ctx) {
const n = req.query.notice;
return typeof n === 'string' && n.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: n } : null;
};
++ /** A staff takedown outranks any other notice: members must see why nothing is served. */
++ const takedownNotice = (t) => (t ? { kind: 'error', text: `This ${t.scope} was taken down by OpenVibe staff: ${t.reason}. Nothing of it is served, and nothing can be published or deleted, until staff lift the takedown.` } : null);
const back = (path, text, kind) => `${path}?notice=${encodeURIComponent(text)}${kind ? `&kind=${kind}` : ''}`;
// ── Pages ───────────────────────────────────────────────
router.get('/', view((req, res) => {
-- if (req.viewer.kind !== 'user') return page(req, res, 200, null, pages.signedOut());
++ // The signed-out front page is the public page of openvibe.host (sitemap.xml): indexable.
++ // Everything behind sign-in stays noindex and private.
++ if (req.viewer.kind !== 'user') return page(req, res, 200, null, pages.signedOut(), null, { indexable: !req.query || !Object.keys(req.query).length });
page(req, res, 200, 'Projects', pages.home({ projects: projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer) }), notice(req));
}));
@@@ -71,7 +75,7 @@ function createDashboard(ctx) {
page(req, res, 200, project.name, pages.project({
project, role, sites: sites.listForProject(project.id), quota: projects.quotaOf(project), usage: projects.usageOf(project),
members: projects.members(project), csrf: csrfToken(config, req.viewer), siteUrl,
-- }), notice(req));
++ }), takedownNotice(ctx.takedowns.ofProject(project.id)) || notice(req));
}));
router.get('/sites/:id', view((req, res) => {
@@@ -81,12 +85,12 @@ function createDashboard(ctx) {
site, project, role, url: siteUrl(site), csrf: csrfToken(config, req.viewer),
deploys: deploys.list(site.id, 50), activations: deploys.activationsOf(site.id, 10),
domains: domains.listForSite(site.id).map((d) => ({ domain: d, instructions: domains.instructions(d, site) })),
-- }), notice(req));
++ }), takedownNotice(ctx.takedowns.ofSite(site)) || notice(req));
}));
router.get('/deploys/:id', view((req, res) => {
if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
-- const { deploy, site, project } = deploys.load(req.viewer, req.params.id, 'deploy');
++ const { deploy, site, project } = deploys.load(req.viewer, req.params.id, 'read');
page(req, res, 200, `Deploy ${deploy.id.slice(0, 12)}`, pages.deploy({
deploy, site, project, active: site.active_deploy_id === deploy.id, files: deploys.filesOf(deploy.id), log: deploys.logOf(deploy.id),
}), notice(req));
@@@ -158,10 +162,13 @@ function createDashboard(ctx) {
// Upload: multipart; the form token is checked after the body is parsed (Origin before).
router.post('/sites/:id/deploys', async (req, res) => {
++ let release = null;
try {
if (req.viewer.kind !== 'user') throw new ApiError(401, 'auth.required', 'sign in first');
if (!sameOrigin(config, req)) throw new ApiError(403, 'form.invalid', 'this form did not come from this dashboard');
const pre = deploys.precheck(req.viewer, req.params.id);
++ release = ctx.uploadGate.enter();
++ if (!release) { res.set('Retry-After', '30'); throw new ApiError(503, 'upload.busy', 'Host is validating other uploads right now; try again in 30 seconds'); }
let up;
try {
up = await readUpload(req, { maxUploadBytes: config.uploads.maxUploadBytes, maxUnpackedBytes: config.uploads.maxUnpackedBytes, limits: pre.limits });
@@@ -183,6 +190,8 @@ function createDashboard(ctx) {
} catch (err) {
if (!req.complete) res.set('Connection', 'close');
fail(req, res, err);
++ } finally {
++ if (release) release();
}
});
ddiff --git a/server/http/serialize.js b/server/http/serialize.js
iindex 0d5ee8a..73d5cf9 100644
---- a/server/http/serialize.js
++++ b/server/http/serialize.js
@@@ -8,7 +8,12 @@
*/
const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
--function project(p, { role = null, quota = null, usage = null } = {}) {
++/** A staff takedown as members see it (domain/takedowns.js), or null. */
++function takedownOut(t) {
++ return t ? { scope: t.scope, reason: t.reason, since: iso(t.since) } : null;
++}
++
++function project(p, { role = null, quota = null, usage = null, takedown = null } = {}) {
return {
id: p.id,
name: p.name,
@@@ -17,6 +22,7 @@ function project(p, { role = null, quota = null, usage = null } = {}) {
owner: p.owner_subject,
role: role || p.role || null,
created_at: iso(p.created_at),
++ takedown: takedownOut(takedown),
...(quota ? { quota: quotaOut(quota) } : {}),
...(usage ? { usage: usageOut(usage) } : {}),
};
@@@ -33,7 +39,7 @@ function usageOut(u) {
return { storage_bytes: u.storageBytes, objects: u.objects, deploys_last_24h: u.deploysLast24h, sites: u.sites, custom_domains: u.customDomains };
}
--function site(s, { hostname, url }) {
++function site(s, { hostname, url, takedown = null }) {
return {
id: s.id,
project_id: s.project_id,
@@@ -41,6 +47,7 @@ function site(s, { hostname, url }) {
hostname,
url,
active_deploy_id: s.active_deploy_id || null,
++ takedown: takedownOut(takedown),
created_at: iso(s.created_at),
updated_at: iso(s.updated_at),
};
@@@ -89,4 +96,4 @@ function activation(a) {
return { deploy_id: a.deploy_id, previous_deploy_id: a.previous_deploy_id || null, kind: a.kind, actor: a.actor, at: iso(a.created_at) };
}
--module.exports = { project, quotaOut, usageOut, site, deploy, logLine, domain, activation, iso };
++module.exports = { project, takedownOut, quotaOut, usageOut, site, deploy, logLine, domain, activation, iso };
ddiff --git a/server/http/tenant.js b/server/http/tenant.js
iindex 469dc98..e5542d5 100644
---- a/server/http/tenant.js
++++ b/server/http/tenant.js
@@@ -15,6 +15,7 @@
* Responses: GET/HEAD only; Content-Type from the manifest; strong ETag (the sha256); 304 on
* 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.
*/
const fs = require('fs');
const { checkPath, PathError, isHashedAsset } = require('../artifacts/paths');
@@@ -74,7 +75,7 @@ function plain(res, status, text, extra = {}) {
res.end(text);
}
--function createTenantServer({ store, config, blobs, log = console }) {
++function createTenantServer({ store, config, blobs, takedowns = 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'"),
@@@ -178,6 +179,10 @@ function createTenantServer({ store, config, blobs, log = console }) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return plain(res, 405, 'Static site: only GET and HEAD are supported.', { Allow: 'GET, HEAD' });
}
++ // Taken down by staff: nothing of the tenant's is served, on any of its hosts, until lifted.
++ if (takedowns && takedowns.ofSite(site)) {
++ return plain(res, 451, 'This site is unavailable: OpenVibe.Host staff took it down after a report.');
++ }
// Absolute-form request targets must agree with the Host header (or they would pick the site).
let target = req.url || '/';
if (!target.startsWith('/')) {
@@@ -196,8 +201,12 @@ function createTenantServer({ store, config, blobs, log = console }) {
for (const p of c.list) {
const row = q.file.get(deployId, p);
if (row && row.project_id === site.project_id) {
-- const cache = isHashedAsset(p) ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate';
-- return send(req, res, site, row, { cache });
++ const hashed = isHashedAsset(p);
++ // Browsers keep fingerprinted assets for a year; a shared CDN in front of the tenant
++ // vhost (Cloudflare) keeps them for an hour at most, so a takedown, deletion or
++ // rollback leaves the edge within the hour even if nobody purges it.
++ if (hashed) res.setHeader('CDN-Cache-Control', 'public, max-age=3600');
++ return send(req, res, site, row, { cache: hashed ? 'public, max-age=31536000, immutable' : 'public, max-age=0, must-revalidate' });
}
}
if (!c.dir && c.dirIndex && q.file.get(deployId, c.dirIndex)) {
ddiff --git a/server/http/upload.js b/server/http/upload.js
iindex 75fb55d..290b331 100644
---- a/server/http/upload.js
++++ b/server/http/upload.js
@@@ -172,4 +172,25 @@ async function readUpload(req, { maxUploadBytes, maxUnpackedBytes = Infinity, li
return { source, entries, fields, notes };
}
--module.exports = { readUpload, UploadError };
++/**
++ * At most `max` uploads are read and validated at once, service-wide. An upload is held in memory
++ * (up to HOST_MAX_UPLOAD_BYTES, plus up to HOST_MAX_UNPACKED_BYTES once unpacked), so without a cap
++ * a handful of accounts uploading together could exhaust the host's memory. enter() -> release()
++ * or null when every slot is taken (the caller answers 503 with Retry-After before reading a byte).
++ */
++function createUploadGate(max) {
++ const limit = Math.max(1, Number(max) || 1);
++ let inFlight = 0;
++ return {
++ enter() {
++ if (inFlight >= limit) return null;
++ inFlight++;
++ let released = false;
++ return () => { if (!released) { released = true; inFlight--; } };
++ },
++ get inFlight() { return inFlight; },
++ limit,
++ };
++}
++
++module.exports = { readUpload, UploadError, createUploadGate };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c188a01..20cacf5 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -53,6 +53,7 @@ function renderPage(o) {
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME)}</title>
<meta name="robots" content="${o.indexable ? 'index, follow' : 'noindex, nofollow'}">
++${o.indexable ? `<link rel="canonical" href="${esc(`${o.config.baseUrl}${o.path && o.path.split('?')[0] !== '/' ? o.path.split('?')[0] : '/'}`)}">` : ''}
<meta name="description" content="OpenVibe.Host: static site hosting for OpenVibe projects (alpha).">
${appIcon.headTags({ site: 'host' })}
<link rel="stylesheet" href="${asset('css/host.css')}">
ddiff --git a/templates/nginx/location-sse.tmpl b/templates/nginx/location-sse.tmpl
iindex 51e26e6..87cbeb5 100644
---- a/templates/nginx/location-sse.tmpl
++++ b/templates/nginx/location-sse.tmpl
@@@ -7,7 +7,8 @@
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Last-Event-ID $http_last_event_id;
proxy_buffering off;
ddiff --git a/templates/nginx/location-websocket.tmpl b/templates/nginx/location-websocket.tmpl
iindex 77c7bc6..aa7bc97 100644
---- a/templates/nginx/location-websocket.tmpl
++++ b/templates/nginx/location-websocket.tmpl
@@@ -7,7 +7,8 @@
proxy_set_header Connection ${{zone}}_connection;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 1h;
ddiff --git a/templates/nginx/tenant-location.tmpl b/templates/nginx/tenant-location.tmpl
iindex 2925676..c2ee5e2 100644
---- a/templates/nginx/tenant-location.tmpl
++++ b/templates/nginx/tenant-location.tmpl
@@@ -13,7 +13,8 @@
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header Set-Cookie;
proxy_read_timeout 60s;
ddiff --git a/templates/nginx/tenants.conf.tmpl b/templates/nginx/tenants.conf.tmpl
iindex 1747e03..fe3aec9 100644
---- a/templates/nginx/tenants.conf.tmpl
++++ b/templates/nginx/tenants.conf.tmpl
@@@ -44,7 +44,8 @@ server {
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
location /auth/ {
@@@ -76,7 +77,7 @@ server {
}
}
--# ── Tenant sites ───────────────────────────────────────────
++{{wwwRedirect}}# ── Tenant sites ───────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
ddiff --git a/templates/nginx/vhost.conf.tmpl b/templates/nginx/vhost.conf.tmpl
iindex 10ea477..ac27483 100644
---- a/templates/nginx/vhost.conf.tmpl
++++ b/templates/nginx/vhost.conf.tmpl
@@@ -46,7 +46,8 @@ server {
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
ddiff --git a/templates/nginx/www-redirect.tmpl b/templates/nginx/www-redirect.tmpl
nnew file mode 100644
iindex 0000000..f7ba6d3
---- /dev/null
++++ b/templates/nginx/www-redirect.tmpl
@@@ -0,0 +1,15 @@
++# ── {{www}} → {{dashboard}} ─────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name {{www}};
++
++ ssl_certificate {{certDir}}/fullchain.pem;
++ ssl_certificate_key {{certDir}}/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ add_header Strict-Transport-Security "max-age=31536000" always;
++ return 301 https://{{dashboard}}$request_uri;
++}
++
ddiff --git a/test/host-abuse.test.js b/test/host-abuse.test.js
nnew file mode 100644
iindex 0000000..63fab1d
---- /dev/null
++++ b/test/host-abuse.test.js
@@@ -0,0 +1,170 @@
++'use strict';
++/**
++ * Abuse controls (docs/threat-review.md): staff takedowns stop serving at once and keep the content
++ * for review; members cannot publish, switch or delete around a takedown; certificate-validation
++ * paths cannot be published; uploads are validated a few at a time, so concurrent uploads cannot
++ * exhaust memory; fingerprinted assets leave a shared CDN within the hour.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const { boot, check, done } = require('./stageb/boot');
++const { site: siteTar } = require('./stageb/tar');
++
++(async () => {
++ const t = await boot({ env: { HOST_MAX_CONCURRENT_UPLOADS: '1' } });
++ const alice = t.user('alice');
++ const staff = t.user('root', { role: 'admin' });
++ const pa = await t.project(alice, 'A');
++ const alpha = await t.site(alice, pa.id, 'alpha');
++ const other = await t.site(alice, pa.id, 'other');
++ const d1 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA ONE', 'app.3f2a9c1b.js': 'js();' });
++ const d2 = await t.deploy(alice, alpha.id, { 'index.html': 'ALPHA TWO' });
++ await t.deploy(alice, other.id, { 'index.html': 'OTHER' });
++ const dom = await t.api('POST', `/api/v1/sites/${alpha.id}/domains`, { as: alice, json: { hostname: 'www.alpha-example.org' } });
++ const domain = dom.json().domain;
++ t.dns.set("_openvibe-host.www.alpha-example.org", [domain.instructions.verification.value]);
++ assert.strictEqual((await t.api('POST', `/api/v1/domains/${domain.id}/verify`, { as: alice })).json().domain.status, 'verified');
++ const objectsOf = (p) => { const out = []; const walk = (d) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(path.join(d, e.name)); else out.push(e.name); } }; walk(path.join(t.dir, 'objects', 'projects', p)); return out.sort(); };
++
++ await check('fingerprinted assets: immutable for browsers, at most an hour at a shared CDN', async () => {
++ const r = await t.get('alpha.openvibe.host', '/app.3f2a9c1b.js');
++ await t.api('POST', `/api/v1/deploys/${d1.id}/activate`, { as: alice });
++ const r1 = await t.get('alpha.openvibe.host', '/app.3f2a9c1b.js');
++ assert.strictEqual(r.status, 404, 'not in the active deploy yet');
++ assert.strictEqual(r1.headers['cache-control'], 'public, max-age=31536000, immutable');
++ assert.strictEqual(r1.headers['cdn-cache-control'], 'public, max-age=3600');
++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).headers['cdn-cache-control'], undefined);
++ await t.api('POST', `/api/v1/deploys/${d2.id}/activate`, { as: alice });
++ });
++
++ await check('only staff take a site down; members and strangers cannot', async () => {
++ const mine = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: alice, json: { reason: 'self' } });
++ assert.strictEqual(mine.status, 403);
++ assert.strictEqual(mine.json().code, 'auth.staff_only');
++ const stranger = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: t.user('mallory'), json: { reason: 'x' } });
++ assert.strictEqual(stranger.status, 404, 'a stranger learns nothing');
++ const noReason = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: {} });
++ assert.strictEqual(noReason.status, 422);
++ });
++
++ const objectsBefore = objectsOf(pa.id);
++ await check('a takedown stops the default and custom domains at once (451, no tenant bytes) and keeps everything', async () => {
++ const r = await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'phishing report #42' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().takedown.reason, 'phishing report #42');
++ for (const host of ['alpha.openvibe.host', 'www.alpha-example.org']) {
++ for (const p of ['/', '/index.html', '/app.3f2a9c1b.js', '/404.html', '/missing']) {
++ const g = await t.get(host, p);
++ assert.strictEqual(g.status, 451, `${host}${p}`);
++ assert.ok(!/ALPHA|js\(\)/.test(g.text), 'no tenant content');
++ assert.strictEqual(g.headers['cache-control'], 'no-store');
++ assert.strictEqual(g.headers.etag, undefined);
++ }
++ }
++ assert.strictEqual((await t.get('other.openvibe.host', '/')).text, 'OTHER', 'a site-level takedown leaves the project\'s other sites up');
++ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore, 'every object kept');
++ const files = await t.api('GET', `/api/v1/deploys/${d1.id}`, { as: staff });
++ assert.strictEqual(files.json().deploy.files.length, 2, 'file rows kept for review');
++ assert.strictEqual((await t.api('POST', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { reason: 'again' } })).status, 409);
++ });
++
++ await check('members see the takedown and why, and cannot publish, switch or delete around it', async () => {
++ const s = await t.api('GET', `/api/v1/sites/${alpha.id}`, { as: alice });
++ assert.deepStrictEqual([s.json().site.takedown.scope, s.json().site.takedown.reason], ['site', 'phishing report #42']);
++ const up = await t.upload(alice, alpha.id, { 'index.html': 'EVADE' });
++ assert.strictEqual(up.status, 403);
++ assert.strictEqual(up.json().code, 'site.taken_down');
++ for (const [method, p, json] of [
++ ['POST', `/api/v1/deploys/${d1.id}/activate`, {}], ['POST', `/api/v1/sites/${alpha.id}/rollback`, {}],
++ ['DELETE', `/api/v1/deploys/${d1.id}`], ['DELETE', `/api/v1/sites/${alpha.id}`], ['DELETE', `/api/v1/projects/${pa.id}`],
++ ]) {
++ const r = await t.api(method, p, { as: alice, json });
++ assert.ok([403, 409].includes(r.status), `${method} ${p} → ${r.status} ${r.text}`);
++ assert.strictEqual(r.json().code, 'site.taken_down');
++ }
++ const page = await t.api('GET', `/sites/${alpha.id}`, { session: alice });
++ assert.match(page.text, /taken down by OpenVibe staff: phishing report #42/);
++ assert.deepStrictEqual(objectsOf(pa.id), objectsBefore);
++ });
++
++ await check('staff lift a takedown: the same content serves again; members only lift nothing', async () => {
++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: alice })).status, 403);
++ const r = await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff, json: { note: 'reviewed: false positive' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).text, 'ALPHA TWO');
++ assert.strictEqual((await t.get('www.alpha-example.org', '/')).text, 'ALPHA TWO');
++ assert.strictEqual((await t.api('DELETE', `/api/v1/sites/${alpha.id}/takedown`, { as: staff })).status, 404);
++ const rows = t.ctx.store.db.prepare('SELECT target_kind, reason, lifted_at IS NOT NULL AS lifted, lift_note FROM host_takedowns').all();
++ assert.deepStrictEqual(rows, [{ target_kind: 'site', reason: 'phishing report #42', lifted: 1, lift_note: 'reviewed: false positive' }], 'the history is kept');
++ });
++
++ await check('a project takedown covers every site of the project; staff can still delete', async () => {
++ const r = await t.api('POST', `/api/v1/projects/${pa.id}/takedown`, { as: staff, json: { reason: 'spam network' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual((await t.get('alpha.openvibe.host', '/')).status, 451);
++ assert.strictEqual((await t.get('other.openvibe.host', '/')).status, 451);
++ const p = await t.api('GET', `/api/v1/projects/${pa.id}`, { as: alice });
++ assert.strictEqual(p.json().project.takedown.scope, 'project');
++ assert.strictEqual(p.json().sites[0].takedown.scope, 'project');
++ const list = await t.api('GET', '/api/v1/projects', { as: alice });
++ assert.strictEqual(list.json().projects[0].takedown.reason, 'spam network');
++ assert.strictEqual((await t.upload(alice, other.id, { 'index.html': 'x' })).status, 403);
++ const del = await t.api('DELETE', `/api/v1/sites/${other.id}`, { as: staff });
++ assert.strictEqual(del.status, 200, del.text);
++ assert.strictEqual((await t.get('other.openvibe.host', '/')).status, 404);
++ });
++
++ await check('certificate-validation paths are never published (no tenant can get a certificate for its host name)', async () => {
++ const pb = await t.project(alice, 'B');
++ const beta = await t.site(alice, pb.id, 'beta');
++ for (const p of ['.well-known/acme-challenge/token123', '.well-known/pki-validation/fileauth.txt', '.well-known/ACME-CHALLENGE/x']) {
++ const r = await t.upload(alice, beta.id, { 'index.html': 'B', [p]: 'proof' });
++ assert.strictEqual(r.status, 422, `${p} → ${r.status}`);
++ assert.ok(r.json().log.some((l) => /reserved/.test(l)), r.text);
++ }
++ await t.deploy(alice, beta.id, { 'index.html': 'B', '.well-known/security.txt': 'Contact: mailto:[email protected]' });
++ assert.strictEqual((await t.get('beta.openvibe.host', '/.well-known/security.txt')).status, 200, 'other .well-known files still publish');
++ assert.strictEqual((await t.get('beta.openvibe.host', '/.well-known/acme-challenge/token123')).status, 404);
++ });
++
++ await check('uploads are validated a few at a time: a second concurrent upload gets 503 + Retry-After and is not a failed deploy', async () => {
++ const pc = await t.project(alice, 'C');
++ const gamma = await t.site(alice, pc.id, 'gamma');
++ const body = siteTar({ 'index.html': 'GAMMA' });
++ const failedBefore = t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM host_deploys WHERE state = 'failed'").get().n;
++ // The first upload sends its headers and half its body, then waits.
++ let firstResponse;
++ let finish;
++ const first = new Promise((resolve) => {
++ const req = http.request({ host: '127.0.0.1', port: t.port, method: 'POST', path: `/api/v1/sites/${gamma.id}/deploys?activate=1`, headers: { host: 'openvibe.host', authorization: `Bearer ${t.network.userToken(alice)}`, 'content-type': 'application/gzip', 'content-length': body.length } }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => { firstResponse = { status: res.statusCode, text: Buffer.concat(chunks).toString() }; resolve(); });
++ });
++ req.write(body.subarray(0, Math.floor(body.length / 2)));
++ finish = () => req.end(body.subarray(Math.floor(body.length / 2)));
++ });
++ await new Promise((r) => setTimeout(r, 150));
++ assert.strictEqual(t.ctx.uploadGate.inFlight, 1);
++ const second = await t.upload(alice, gamma.id, { 'index.html': 'SECOND' });
++ assert.strictEqual(second.status, 503, second.text);
++ assert.strictEqual(second.json().code, 'upload.busy');
++ assert.strictEqual(second.headers['retry-after'], '30');
++ finish();
++ await first;
++ assert.strictEqual(firstResponse.status, 201, firstResponse.text);
++ assert.strictEqual(t.ctx.uploadGate.inFlight, 0, 'the slot is released');
++ assert.strictEqual(t.ctx.store.db.prepare("SELECT COUNT(*) AS n FROM host_deploys WHERE state = 'failed'").get().n, failedBefore, 'a busy refusal is not the tenant\'s failed deploy');
++ const third = await t.upload(alice, gamma.id, { 'index.html': 'THIRD' });
++ assert.strictEqual(third.status, 201, third.text);
++ // A refused upload releases its slot too.
++ const bad = await t.upload(alice, gamma.id, { '../x.html': 'no' });
++ assert.strictEqual(bad.status, 422);
++ assert.strictEqual(t.ctx.uploadGate.inFlight, 0);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/host-isolation.test.js b/test/host-isolation.test.js
iindex 4303e91..30fd818 100644
---- a/test/host-isolation.test.js
++++ b/test/host-isolation.test.js
@@@ -1,8 +1,10 @@
'use strict';
/**
* Wave 21 exit criterion: "a hosted static project cannot read another tenant's objects".
-- * Path tricks, encoded traversal, Host header tricks, absolute-form targets, shared content, and
-- * the API: a tenant only ever reaches its own site, deploys and domains.
++ * Path tricks, encoded traversal, Host header tricks, absolute-form targets, shared content, the API,
++ * cross-origin reads from a tenant page (no CORS grant anywhere), existence oracles (ETag, Range),
++ * delegated service tokens, app principals and the dashboard: a tenant only ever reaches its own
++ * site, deploys and domains.
*/
const assert = require('assert');
const fs = require('fs');
@@@ -135,6 +137,65 @@ const { boot, check, done } = require('./stageb/boot');
assert.strictEqual(r.headers['content-type'], 'application/problem+json');
});
++ await check('a tenant page\'s scripts cannot read another tenant\'s files: no CORS grant on tenant responses, preflights refused', async () => {
++ for (const origin of ['https://alpha.openvibe.host', 'null', 'https://evil.example']) {
++ const r = await t.get('beta.openvibe.host', '/secret.html', { headers: { origin } });
++ assert.strictEqual(r.status, 200);
++ for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith('access-control-'), `beta answered ${origin} with ${h}`);
++ const pre = await t.request({ method: 'OPTIONS', host: 'beta.openvibe.host', path: '/secret.html', headers: { origin, 'access-control-request-method': 'GET' } });
++ assert.strictEqual(pre.status, 405);
++ assert.ok(!Object.keys(pre.headers).some((h) => h.startsWith('access-control-')));
++ }
++ assert.strictEqual((await t.get('beta.openvibe.host', '/secret.html')).headers['cross-origin-opener-policy'], 'same-origin');
++ });
++
++ await check('the Host API grants CORS to no tenant origin (a tenant page cannot call it from a visitor\'s browser)', async () => {
++ for (const origin of ['https://alpha.openvibe.host', 'https://beta.openvibe.host', 'https://openvibe.host.evil.example']) {
++ const r = await t.api('GET', '/api/v1/projects', { as: bob, headers: { origin } });
++ assert.strictEqual(r.headers['access-control-allow-origin'], undefined, origin);
++ const pre = await t.request({ method: 'OPTIONS', host: 'openvibe.host', path: '/api/v1/projects', headers: { origin, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
++ assert.strictEqual(pre.headers['access-control-allow-origin'], undefined, `preflight from ${origin}`);
++ }
++ });
++
++ await check('no existence oracle: another tenant\'s sha256 as If-None-Match or a Range on alpha is still a 404', async () => {
++ const r = await t.get('alpha.openvibe.host', '/secret.html', { headers: { 'if-none-match': `"${secretSha}"` } });
++ assert.strictEqual(r.status, 404);
++ const r2 = await t.get('alpha.openvibe.host', `/${secretSha}`, { headers: { 'if-none-match': `"${secretSha}"`, range: 'bytes=0-3' } });
++ assert.strictEqual(r2.status, 404);
++ assert.ok(!leaks(r) && !leaks(r2));
++ });
++
++ await check('delegation and app principals stay inside their own project', async () => {
++ const codes = t.network.serviceToken('codes', ['host.site.manage', 'host.deploy.create', 'host.domain.manage']);
++ for (const p of [`/api/v1/projects/${pb.id}`, `/api/v1/deploys/${db.id}`, `/api/v1/deploys/${db.id}/log`, `/api/v1/sites/${beta.id}/deploys`]) {
++ const r = await t.api('GET', p, { as: codes, headers: { 'x-ov-subject': alice.subject } });
++ assert.strictEqual(r.status, 404, `svc:codes for alice → ${p} ${r.status}`);
++ assert.ok(!leaks(r) && !r.text.includes(secretSha));
++ }
++ const ci = t.network.appToken('app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', ['host.deploy.create', 'host.site.manage']);
++ const add = await t.api('PUT', `/api/v1/projects/${pa.id}/members/app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3`, { as: alice, json: { role: 'deployer' } });
++ assert.strictEqual(add.status, 200, add.text);
++ assert.strictEqual((await t.api('GET', `/api/v1/sites/${alpha.id}/deploys`, { as: ci })).status, 200, 'the app reads its own project');
++ for (const p of [`/api/v1/deploys/${db.id}`, `/api/v1/sites/${beta.id}/deploys`]) assert.strictEqual((await t.api('GET', p, { as: ci })).status, 404, p);
++ const up = await t.api('POST', `/api/v1/sites/${beta.id}/deploys?activate=1`, { as: ci, body: require('./stageb/tar').site({ 'index.html': 'PWNED' }), headers: { 'content-type': 'application/gzip' } });
++ assert.strictEqual(up.status, 404);
++ // X-OV-Subject is honoured for first-party services only: an app cannot borrow bob's identity.
++ const borrow = await t.api('GET', `/api/v1/deploys/${db.id}`, { as: ci, headers: { 'x-ov-subject': bob.subject } });
++ assert.strictEqual(borrow.status, 400);
++ assert.strictEqual(borrow.json().code, 'subject.not_delegable');
++ assert.ok(!leaks(borrow));
++ assert.strictEqual((await t.get('beta.openvibe.host', '/')).text, 'BETA HOME');
++ });
++
++ await check('the dashboard (cookie session) shows a non-member nothing of another tenant: no names, files or hashes', async () => {
++ for (const p of [`/projects/${pb.id}`, `/sites/${beta.id}`, `/deploys/${db.id}`]) {
++ const r = await t.api('GET', p, { session: alice });
++ assert.strictEqual(r.status, 404, `${p} → ${r.status}`);
++ assert.ok(!/secret\.html|BETA|beta-example/.test(r.text) && !r.text.includes(secretSha), `${p} leaked`);
++ }
++ });
++
await t.close();
done();
})();
ddiff --git a/test/host-secrets-dashboard.test.js b/test/host-secrets-dashboard.test.js
iindex c328d4b..7084816 100644
---- a/test/host-secrets-dashboard.test.js
++++ b/test/host-secrets-dashboard.test.js
@@@ -57,7 +57,26 @@ const ORIGIN = 'https://openvibe.host';
assert.match(r.headers['content-security-policy'], /frame-ancestors 'none'/);
assert.strictEqual(r.headers['x-frame-options'], 'DENY');
assert.strictEqual(r.headers['cache-control'], 'private, no-store');
-- assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);
++ // The public front page is indexable, with a canonical URL; nothing behind sign-in is.
++ assert.match(r.text, /<meta name="robots" content="index, follow">/);
++ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.host\/">/);
++ const signedIn = await t.api('GET', '/', { session: alice });
++ assert.match(signedIn.text, /<meta name="robots" content="noindex, nofollow">/);
++ assert.ok(!/rel="canonical"/.test(signedIn.text));
++ const withQuery = await t.api('GET', '/?notice=hi');
++ assert.match(withQuery.text, /<meta name="robots" content="noindex, nofollow">/);
++ });
++
++ await check('robots.txt and sitemap.xml: the front page and the legal pages only; tenant sites keep their own', async () => {
++ const robots = await t.api('GET', '/robots.txt');
++ assert.strictEqual(robots.status, 200);
++ assert.strictEqual(robots.text, 'User-agent: *\nAllow: /$\nAllow: /terms$\nAllow: /privacy$\nAllow: /dmca$\nDisallow: /\n\nSitemap: https://openvibe.host/sitemap.xml\n');
++ const map = await t.api('GET', '/sitemap.xml');
++ assert.strictEqual(map.status, 200);
++ assert.match(map.headers['content-type'], /application\/xml/);
++ const locs = [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
++ assert.deepStrictEqual(locs, ['https://openvibe.host/', 'https://openvibe.host/terms', 'https://openvibe.host/privacy', 'https://openvibe.host/dmca']);
++ for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
});
await check('a planted ov_token cookie (e.g. set by a tenant subdomain) is not a session', async () => {
ddiff --git a/test/nginx-tenants.test.js b/test/nginx-tenants.test.js
iindex 768c84f..96341f9 100644
---- a/test/nginx-tenants.test.js
++++ b/test/nginx-tenants.test.js
@@@ -84,6 +84,64 @@ runTests([
assert.strictEqual(fresh.read('/etc/nginx/sites-available/openvibe.host-custom-domains.conf'), null);
}),
++ test('launch: --install removes the interim tenants-pending vhost in the same nginx -t and reload; a failed test brings it back', async () => {
++ const PENDING = '# *.openvibe.host before Host Stage B tenant hosting launches\nserver { server_name *.openvibe.host; return 404; }\n';
++ const SITES = '# openvibe.host — static front page (OpenVibe.Sites)\nserver { server_name openvibe.host; root /opt/openvibe.sites/dist/openvibe.host; }\n';
++ const prepare = (host) => {
++ host.put('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', PENDING);
++ host.files.set('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf', owner: 'root', mode: 0o777 });
++ host.put('/etc/nginx/sites-available/openvibe.host.conf', SITES);
++ host.files.set('/etc/nginx/sites-enabled/openvibe.host.conf', { type: 'symlink', target: '/etc/nginx/sites-available/openvibe.host.conf', owner: 'root', mode: 0o777 });
++ return host;
++ };
++ const review = await prepare(withHost(scenario(), [])).cli('nginx', 'tenants', 'host');
++ assert.match(review.out, /--install also removes, in the same nginx -t and reload: openvibe\.host-tenants-pending\.conf/);
++
++ const host = prepare(withHost(scenario(), []));
++ let seenAtTest = null;
++ host.nginxTest = () => {
++ seenAtTest = { pending: host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'), main: host.read('/etc/nginx/sites-available/openvibe.host.conf') };
++ return { code: 0, stderr: 'ok' };
++ };
++ const r = await host.cli('nginx', 'tenants', 'host', '--install');
++ assert.strictEqual(r.code, 0, r.out);
++ assert.deepStrictEqual([seenAtTest.pending, /server_name \*\.openvibe\.host;/.test(seenAtTest.main)], [false, true], 'one nginx -t sees the tenant vhost and no pending vhost');
++ assert.ok(!host.files.has('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf'));
++ assert.ok(!host.files.has('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'));
++ assert.match(r.out, /removed openvibe\.host-tenants-pending\.conf/);
++ assert.strictEqual(host.calls.filter((c) => c.cmd === 'systemctl' && c.args[0] === 'reload').length, 1, 'one reload');
++ const again = await host.cli('nginx', 'tenants', 'host', '--install');
++ assert.match(again.out, /already installed; nothing changed/);
++
++ const bad = prepare(withHost(scenario(), []));
++ bad.nginxTest = () => ({ code: 1, stderr: 'nginx: [emerg] cannot load certificate' });
++ const f = await bad.cli('nginx', 'tenants', 'host', '--install');
++ assert.strictEqual(f.code, 2);
++ assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host-tenants-pending.conf'), PENDING, 'pending vhost restored');
++ assert.strictEqual(bad.files.get('/etc/nginx/sites-enabled/openvibe.host-tenants-pending.conf').target, '/etc/nginx/sites-available/openvibe.host-tenants-pending.conf');
++ assert.strictEqual(bad.read('/etc/nginx/sites-available/openvibe.host.conf'), SITES, 'the Sites placeholder vhost restored');
++ assert.ok(!bad.calls.some((c) => c.cmd === 'systemctl' && c.args[0] === 'reload'), 'not reloaded');
++ }),
++
++ test('client address headers come from $remote_addr only (realip), in every template ovhost renders', async () => {
++ const fs = require('fs');
++ const path = require('path');
++ const dir = path.join(__dirname, '..', 'templates', 'nginx');
++ for (const f of fs.readdirSync(dir)) {
++ const t = fs.readFileSync(path.join(dir, f), 'utf8');
++ assert.ok(!/\$proxy_add_x_forwarded_for|\$http_x_forwarded_for|\$http_cf_connecting_ip/.test(t), `${f} passes a client-supplied address on`);
++ if (/X-Forwarded-For/.test(t)) assert.match(t, /X-Forwarded-For \$remote_addr;/, f);
++ }
++ const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');
++ assert.ok(!/proxy_add_x_forwarded_for/.test(r.out));
++ assert.match(r.out, /proxy_set_header CF-Connecting-IP \$remote_addr;/);
++ }),
++
++ test('www.openvibe.host redirects to the dashboard over the wildcard certificate', async () => {
++ const r = await withHost(scenario(), []).cli('nginx', 'tenants', 'host');
++ assert.match(r.out, /server_name www\.openvibe\.host;\n\n ssl_certificate +\/etc\/letsencrypt\/live\/openvibe\.host\/fullchain\.pem;[\s\S]*?return 301 https:\/\/openvibe\.host\$request_uri;/);
++ }),
++
test('refuses a service without a tenants block, a bad certificate path, and sites under a first-party domain', async () => {
const host = withHost(scenario(), []);
assert.notStrictEqual((await host.cli('nginx', 'tenants', 'media')).code, 0);